diff --git a/examples/test-ui/.devcontainer/Dockerfile b/examples/test-ui/.devcontainer/Dockerfile new file mode 100644 index 0000000..e28bfd6 --- /dev/null +++ b/examples/test-ui/.devcontainer/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11 + +# Add non-root user for security +ARG USERNAME=nonroot +RUN groupadd --gid 1000 $USERNAME && useradd --uid 1000 --gid 1000 -m $USERNAME +## Make sure to reflect new user in PATH +ENV PATH="/home/${USERNAME}/.local/bin:${PATH}" +USER $USERNAME + +# Install pre-requirements +RUN pip install pip~=23.3.2 setuptools~=69.0.3 + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY dev_requirements.txt ./ +RUN pip install -r dev_requirements.txt \ No newline at end of file diff --git a/examples/test-ui/.devcontainer/devcontainer.json b/examples/test-ui/.devcontainer/devcontainer.json new file mode 100644 index 0000000..5c3e7da --- /dev/null +++ b/examples/test-ui/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "runArgs": [ + "--network=host" + ], + "remoteUser": "nonroot", + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.mypy-type-checker", + "charliermarsh.ruff" + ], + "settings": { + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.editor.defaultFormatter": "charliermarsh.ruff", + "python.linting.mypy": true, + "python.linting.enabled": true, + "explorer.excludeGitIgnore": true, + "python.analysis.packageIndexDepths": [ + {"name": "benchling_sdk", "depth": 8, "includeAllSymbols": true} + ] + } + } + } +} \ No newline at end of file diff --git a/examples/test-ui/.gitignore b/examples/test-ui/.gitignore new file mode 100644 index 0000000..51f73ee --- /dev/null +++ b/examples/test-ui/.gitignore @@ -0,0 +1,11 @@ +.idea +.vscode +**/.venv/ +**/*.egg-info +**/__pycache__ +**/.env* +.DS_Store +**/.client_secret* +**/.pytest_cache +.mypy_cache +.ruff_cache diff --git a/examples/test-ui/Dockerfile b/examples/test-ui/Dockerfile new file mode 100644 index 0000000..4d288b8 --- /dev/null +++ b/examples/test-ui/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11 + +# Install pre-requirements +RUN pip install pip~=23.3.2 setuptools~=69.0.3 + +COPY requirements.txt ./ +RUN pip install -r requirements.txt +COPY ./local_app /src/local_app +WORKDIR /src/local_app +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/examples/test-ui/README.md b/examples/test-ui/README.md new file mode 100644 index 0000000..d8e2e0c --- /dev/null +++ b/examples/test-ui/README.md @@ -0,0 +1,221 @@ +# Benchling App Example: Minimal UI Testing + +An example Benchling App written in Python to test canvas session and user interactions. + +![image info](./docs/demo-full.gif) +_The App features branching flows and will also validate user inputs._ + +**Appendices**: +* [Architecture Diagram](#architecture-diagram) + +## Technical Prerequisites + +This app is optimized as a minimal local development experience using [Docker](https://www.docker.com/) for reproducibility. + +> ⚠️ **Development Only**: This example is not meant to be copied into production as-is. There are additional deployment, scale, and security concerns that should be addressed before deploying an app based on this example to production. + +It relies on a few other tools that will be installed for you within Docker containers: +* [Cloudflare-tunnel](https://www.cloudflare.com/products/tunnel/) - expose a public webhook URL and forward the results locally. ⚠️ *Not for production or real data!* +* [Flask](https://flask.palletsprojects.com/) - A simple Python web application framework + +## Getting Started + +Create an empty placeholder file for Docker secrets. *nix example: + +```bash +touch .client_secret +``` + +Windows example: + +```cmd +echo.> .client_secret +``` + +Start Docker: + +```bash +docker compose up --build -d +``` + +Tip: You can omit the `-d` option if you want to run in the foreground. Otherwise, use `docker compose logs -f` to tail logs. + +> ℹ️ **Windows Note 1:** "Use ContainerD for pulling and storing images" may need to be enabled in `Docker > Settings > Features in development > Beta Features` + +> ℹ️ **Windows Note 2**: If running into an error like "ERROR: request returned Bad Gateway for API route and version", [this solution](https://github.com/docker/for-mac/issues/6956#issuecomment-1876444658) may fix the problem. + + +You can verify that Flask is up and running: + +```bash +curl localhost:8000/health +``` + +If Flask is running, you should see `OK` printed. + +Be sure to note the URL created for you by `cloudflare-tunnel`. The log line should look something like this: + +``` +cloudflare-tunnel-1 | 2025-03-11T18:45:24Z INF +--------------------------------------------------------------------------------------------+ +cloudflare-tunnel-1 | 2025-03-11T18:45:24Z INF | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): | +cloudflare-tunnel-1 | 2025-03-11T18:45:24Z INF | https://pioneer-rights-hardly-disorder.trycloudflare.com | +cloudflare-tunnel-1 | 2025-03-11T18:45:24Z INF +--------------------------------------------------------------------------------------------+ +``` + +On *nix systems, you can easily obtain _just_ the URL via: + +``` +docker compose logs cloudflare-tunnel | grep -o 'https://[^ ]*trycloudflare.com[^ ]*' | tail -n 1 +``` + +Example Output: + +``` +https://pioneer-rights-hardly-disorder.trycloudflare.com +``` + +> 💡 Don't forget to append `/1/webhooks`, making the full URL given to Benchling `https://pioneer-rights-hardly-disorder.trycloudflare.com/1/webhooks` + +## Setting Up Your App in Benchling + +### Benchling Prerequisites +1. Access to a Benchling tenant, like `https://my-tenant.benchling.com` +2. Ensure you've been granted access to the [Benchling Developer Platform Capability](https://help.benchling.com/hc/en-us/articles/9714802977805-Access-the-Benchling-Developer-Platform). +3. [Optional] If you'd like to render the App's UI in a Run, you'll need a [Benchling Connect](https://www.benchling.com/connect) license. +5. [Global Apps](https://docs.benchling.com/docs/global-apps-faq) will need to be enabled on your tenant. + +### Upload the App Manifest + +Click the user icon in the bottom left corner to bring up the main menu. Select "Feature Settings" > "Developer Console" + +Next, click the "Create app" button and choose "From manifest." + +When prompted to upload a file, select `manifest.yaml` and click "Create." + +![image info](./docs/create-app.gif) + +### Update the Webhook URL + +Every time we restart the `cloudflare-tunnel` Docker container, it will provision +a new public webhook URL. + +Update the Benchling App's Webhook URL in the UI with the new server and +append the path our Flask route expects (see `local_app/app.py`). + +For example, if our `cloudflare-tunnel` generated URL is `https://pioneer-rights-hardly-disorder.trycloudflare.com`, +the webhook URL in Benchling should be: + +``` +https://https://pioneer-rights-hardly-disorder.trycloudflare.com/1/webhooks +``` + +![image info](./docs/update-webhook-url.gif) + +### Generating a Client Secret + +Generate a client secret in Benchling and be sure to copy the secret. + +![image info](./docs/generate-secret.gif) + +Since the client secret is sensitive, it's handled a bit differently. It's +registered as a `secret` in our `docker-compose.yaml` file, which will be looking +for a file `./client_secret`. + +We can create this file and paste in the secret plaintext value if we have the secret in our clipboard. +On *nix: + +```bash +touch .client_secret +pbpaste > .client_secret +``` + +> ⚠️ **Security Note:** Be sure to avoid committing `.client_secret` to a source code repository. + +You'll then need to restart _just_ the `benchling-app` Docker service to pick up the changes: + +```bash +docker-compose up -d +``` + +If you restart both containers, be sure to update your App in Benchling with the new webhook URL from cloudflare-tunnel. + +### Setting Client ID + +Our App needs a Client ID to pair with the Client Secret for authentication to Benchling. In this case, we've created our +App to accept `CLIENT_ID` as an environment variable. + +One easy way to set an environment variables for Docker is to add a `.env` file. + +```bash +touch .env +``` + +Windows example: + +```cmd +echo.> .env +``` + +Open it in an editor of your choice and set the values with the plaintext client ID +for your App. For example: + +``` +CLIENT_ID=Ts7jtwPohM +``` + +### Setting App Definition ID + +The App definition ID is available from the Developer Console by selecting the App to view. + +![image info](./docs/global-app-definition-id.png) + +> ℹ️ **Note:** If you do NOT see this ID, please ensure [Global Apps](https://docs.benchling.com/docs/global-apps-faq) are enabled for your tenant. + +Add it to your `.env` file with a variable name `APP_DEFINITION_ID`. The contents of your `.env` file should now look something like: + +``` +CLIENT_ID=Ts7jtwPohM +APP_DEFINITION_ID=appdef_Trow4zbR3o +``` + +### Restarting the Container to Reflect Environment Changes + +Restart the `benchling-app` Docker container to pick up the environment changes. + +```bash +docker-compose up -d +``` + +### Security Note: Storing App Secrets in Production + +> ⚠️ **Security Note:** In production, store the secret with a secure solution such as a secrets store (AWS Secrets Manager, as an example) or, if storing programmatically, encrypted using app-layer encryption. Avoid placing it in plaintext anywhere in code or configuration. + +We declare two `features` in the `manifest.yaml` so that our App can render +its UI as a `CANVAS` (e.g. within the Notebook) or on an `ASSAY_RUN`. If you'd like to use a Run, we'll also need: +1. An Lab Automation run schema + +### Updating the App's Configuration + +App Configuration gives us a stable code contract for referencing data mapped in a Benchling tenant. +The values of the data in Benchling can then be changed without updating App code. + +### Permission the App + +By default, Benchling Apps do not have permission to any data in Benchling. +Let's grant some access by adding the Benchling App to an organization. + +![image info](./docs/permission-app.gif) + +## [Optional] Running the App - via a Run + +1. Insert a Run of the schema linked in App Config +2. Create the Run +3. Continue with steps 3-6 above + +![image info](./docs/demo.gif) + +## Appendices + +### Architecture Diagram + +![image info](./docs/architecture-diagram.png) diff --git a/examples/test-ui/create_session.sh b/examples/test-ui/create_session.sh new file mode 100644 index 0000000..6aa636e --- /dev/null +++ b/examples/test-ui/create_session.sh @@ -0,0 +1,25 @@ +curl -X POST \ + 'https://celleste-biotest.benchling.com/oauth/token' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=client_credentials' \ + -d 'client_id=YOUR_CLIENT_ID' \ + -d 'client_secret=YOUR_CLIENT_SECRET' + +curl -X POST \ + 'https://celleste-biotest.benchling.com/api/v2/token' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=client_credentials' \ + -d 'client_id=YOUR_CLIENT_ID' \ + -d 'client_secret=YOUR_CLIENT_SECRET' + +curl -X 'POST' \ + 'https://celleste-biotest.benchling.com/api/v2/app-sessions' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer API-KEY' \ + -d '{ + "appId": "app_oJ71aUtNif8fQpYe", + "messages": [], + "name": "test_session", + "timeoutSeconds": 2592000 + }' diff --git a/examples/test-ui/dev_requirements.txt b/examples/test-ui/dev_requirements.txt new file mode 100644 index 0000000..99559b0 --- /dev/null +++ b/examples/test-ui/dev_requirements.txt @@ -0,0 +1,4 @@ +# Only for maintainers +ruff~=0.1.11 +pytest~=7.4.4 +mypy~=1.8.0 \ No newline at end of file diff --git a/examples/test-ui/docker-compose.yaml b/examples/test-ui/docker-compose.yaml new file mode 100644 index 0000000..660e518 --- /dev/null +++ b/examples/test-ui/docker-compose.yaml @@ -0,0 +1,37 @@ +services: + benchling-app: + build: . + ports: + - "8000:5000" + volumes: + - type: bind + source: . + target: /src + environment: + # Don't set debug in production! + - FLASK_DEBUG=1 + # Enable debug logging for Python Benchling App. This is not native, specific to this example implementation + - BENCHLING_APP_LOG_LEVEL=DEBUG + # Client ID is not sensitive and is the same across all tenants, so could be hard-coded + # You might choose to have two different Apps for dev vs prod, which would have different client IDs + - CLIENT_ID + - APP_DEFINITION_ID + # Client secret for the Benchling App, stored somewhere securely in production. + # Injected here for convenience. Each Client ID will have its own Client secret + - CLIENT_SECRET_FILE=/run/secrets/app_client_secret + secrets: + - app_client_secret + + # FOR LOCAL DEVELOPMENT ONLY! + # Free tool for providing a public URL to forward webhooks to our Benchling App running locally + # Do not do this in production or use with any sensitive data. + # Benchling has not vetted this tool for use in production or in sensitive systems. + # Conduct your own due diligence before choosing a tool for production use. + cloudflare-tunnel: + image: cloudflare/cloudflared + restart: unless-stopped + command: tunnel --url http://benchling-app:5000 + +secrets: + app_client_secret: + file: .client_secret diff --git a/examples/test-ui/docs/architecture-diagram.png b/examples/test-ui/docs/architecture-diagram.png new file mode 100644 index 0000000..f0c42b9 Binary files /dev/null and b/examples/test-ui/docs/architecture-diagram.png differ diff --git a/examples/test-ui/docs/create-app.gif b/examples/test-ui/docs/create-app.gif new file mode 100644 index 0000000..7b74245 Binary files /dev/null and b/examples/test-ui/docs/create-app.gif differ diff --git a/examples/test-ui/docs/create-folder.gif b/examples/test-ui/docs/create-folder.gif new file mode 100644 index 0000000..d705bd6 Binary files /dev/null and b/examples/test-ui/docs/create-folder.gif differ diff --git a/examples/test-ui/docs/create-molecule-schema.gif b/examples/test-ui/docs/create-molecule-schema.gif new file mode 100644 index 0000000..9d4d9d4 Binary files /dev/null and b/examples/test-ui/docs/create-molecule-schema.gif differ diff --git a/examples/test-ui/docs/create-run-schema.gif b/examples/test-ui/docs/create-run-schema.gif new file mode 100644 index 0000000..8c96f6a Binary files /dev/null and b/examples/test-ui/docs/create-run-schema.gif differ diff --git a/examples/test-ui/docs/demo-full.gif b/examples/test-ui/docs/demo-full.gif new file mode 100644 index 0000000..9cb74e8 Binary files /dev/null and b/examples/test-ui/docs/demo-full.gif differ diff --git a/examples/test-ui/docs/demo-notebook.gif b/examples/test-ui/docs/demo-notebook.gif new file mode 100644 index 0000000..f0deb39 Binary files /dev/null and b/examples/test-ui/docs/demo-notebook.gif differ diff --git a/examples/test-ui/docs/demo-short.gif b/examples/test-ui/docs/demo-short.gif new file mode 100644 index 0000000..a4dae27 Binary files /dev/null and b/examples/test-ui/docs/demo-short.gif differ diff --git a/examples/test-ui/docs/demo.gif b/examples/test-ui/docs/demo.gif new file mode 100644 index 0000000..61a3f54 Binary files /dev/null and b/examples/test-ui/docs/demo.gif differ diff --git a/examples/test-ui/docs/generate-secret.gif b/examples/test-ui/docs/generate-secret.gif new file mode 100644 index 0000000..bd505ad Binary files /dev/null and b/examples/test-ui/docs/generate-secret.gif differ diff --git a/examples/test-ui/docs/global-app-definition-id.png b/examples/test-ui/docs/global-app-definition-id.png new file mode 100644 index 0000000..e19439a Binary files /dev/null and b/examples/test-ui/docs/global-app-definition-id.png differ diff --git a/examples/test-ui/docs/permission-app.gif b/examples/test-ui/docs/permission-app.gif new file mode 100644 index 0000000..1a98c13 Binary files /dev/null and b/examples/test-ui/docs/permission-app.gif differ diff --git a/examples/test-ui/docs/schema-example.png b/examples/test-ui/docs/schema-example.png new file mode 100644 index 0000000..c29d6bf Binary files /dev/null and b/examples/test-ui/docs/schema-example.png differ diff --git a/examples/test-ui/docs/update-app-config.gif b/examples/test-ui/docs/update-app-config.gif new file mode 100644 index 0000000..6ad3033 Binary files /dev/null and b/examples/test-ui/docs/update-app-config.gif differ diff --git a/examples/test-ui/docs/update-webhook-url.gif b/examples/test-ui/docs/update-webhook-url.gif new file mode 100644 index 0000000..51c43ac Binary files /dev/null and b/examples/test-ui/docs/update-webhook-url.gif differ diff --git a/examples/test-ui/get_cloudflair_url.sh b/examples/test-ui/get_cloudflair_url.sh new file mode 100644 index 0000000..42fcd5e --- /dev/null +++ b/examples/test-ui/get_cloudflair_url.sh @@ -0,0 +1 @@ +docker compose logs cloudflare-tunnel | grep -o 'https://[^ ]*trycloudflare.com[^ ]*' | tail -n 1 diff --git a/examples/test-ui/local_app/__init__.py b/examples/test-ui/local_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/test-ui/local_app/app.py b/examples/test-ui/local_app/app.py new file mode 100644 index 0000000..767decc --- /dev/null +++ b/examples/test-ui/local_app/app.py @@ -0,0 +1,46 @@ +from threading import Thread + +from benchling_sdk.apps.helpers.webhook_helpers import verify +from flask import Flask, request + +from local_app.benchling_app.handler import handle_webhook +from local_app.benchling_app.setup import app_definition_id +from local_app.lib.logger import get_logger + +logger = get_logger() + + +def create_app() -> Flask: + app = Flask("benchling-app") + + @app.route("/health") + def health_check() -> tuple[str, int]: + # Just a route allowing us to check that Flask itself is up and running + return "OK", 200 + + @app.route("/1/webhooks/", methods=["POST"]) + def receive_webhooks(target: str) -> tuple[str, int]: # noqa: ARG001 + # For security, don't do anything else without first verifying the webhook + app_def_id = app_definition_id() + + # Important! To verify webhooks, we need to pass the body as an unmodified string + # Flask's request.data is bytes, so decode to string. Passing bytes or JSON won't work + verify(app_def_id, request.data.decode("utf-8"), request.headers) + + logger.debug("Received webhook message: %s", request.json) + # Dispatch work and ACK webhook as quickly as possible + _enqueue_work() + # ACK webhook by returning 2xx status code so Benchling knows the app received the signal + return "OK", 200 + + return app + + +def _enqueue_work() -> None: + # PRODUCTION NOTE: A high volume of webhooks may spawn too many threads and lead to processing failures + # In production, we recommend a more robust queueing system for scale + thread = Thread( + target=handle_webhook, + args=(request.json,), + ) + thread.start() diff --git a/examples/test-ui/local_app/benchling_app/__init__.py b/examples/test-ui/local_app/benchling_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/test-ui/local_app/benchling_app/canvas_interaction.py b/examples/test-ui/local_app/benchling_app/canvas_interaction.py new file mode 100644 index 0000000..f292311 --- /dev/null +++ b/examples/test-ui/local_app/benchling_app/canvas_interaction.py @@ -0,0 +1,67 @@ +import re +from typing import cast +from urllib.parse import quote + +from benchling_sdk.apps.canvas.framework import CanvasBuilder +from benchling_sdk.apps.framework import App +from benchling_sdk.apps.status.errors import AppUserFacingError +from benchling_sdk.models import AppCanvasUpdate +from benchling_sdk.models.webhooks.v0 import CanvasInteractionWebhookV2 +from local_app.benchling_app.views.constants import ( + CANCEL_BUTTON_ID, + CREATE_BUTTON_ID, + SEARCH_BUTTON_ID, + SEARCH_TEXT_ID, +) + +from local_app.benchling_app.views.canvas_initialize import input_blocks +from local_app.benchling_app.views.completed import render_completed_canvas +from local_app.lib.logger import get_logger + +logger = get_logger() + +class UnsupportedButtonError(Exception): + pass + + +def route_interaction_webhook(app: App, canvas_interaction: CanvasInteractionWebhookV2) -> None: + canvas_id = canvas_interaction.canvas_id + if canvas_interaction.button_id == SEARCH_BUTTON_ID: + with app.create_session_context("Submit", timeout_seconds=20) as session: + session.attach_canvas(canvas_id) + canvas_builder = _canvas_builder_from_canvas_id(app, canvas_id) + canvas_inputs = canvas_builder.inputs_to_dict_single_value() + sanitized_inputs = _validate_and_sanitize_inputs(canvas_inputs) + results = {} + render_completed_canvas(results, canvas_id, canvas_builder, session) + elif canvas_interaction.button_id == CANCEL_BUTTON_ID: + # Set session_id = None to detach and prior state or messages (essentially, reset) + canvas_builder = _canvas_builder_from_canvas_id(app, canvas_id) + canvas_update = canvas_builder.with_enabled()\ + .with_session_id(None)\ + .with_blocks(input_blocks())\ + .to_update() + app.benchling.apps.update_canvas(canvas_id, canvas_update) + else: + # Re-enable the Canvas, or it will stay disabled and the user will be stuck + app.benchling.apps.update_canvas(canvas_id, AppCanvasUpdate(enabled=True)) + # Not shown to user by default, for our own logs cause we forgot to handle some button + # This is developer error + raise UnsupportedButtonError( + f"Whoops, the developer forgot to handle the button {canvas_interaction.button_id}", + ) + +def _canvas_builder_from_canvas_id(app: App, canvas_id: str) -> CanvasBuilder: + current_canvas = app.benchling.apps.get_canvas_by_id(canvas_id) + return CanvasBuilder.from_canvas(current_canvas) + +def _validate_and_sanitize_inputs(inputs: dict[str, str]) -> dict[str, str]: + sanitized_inputs = {} + if not inputs[SEARCH_TEXT_ID]: + # AppFacingUserError is a special error that will propagate the error message as-is back to the user + # via the App's session and end control flow + raise AppUserFacingError("Please enter something") + if not re.match("^[a-zA-Z\\d\\s\\-]+$", inputs[SEARCH_TEXT_ID]): + raise AppUserFacingError("The input can only contain letters, numbers, spaces, and hyphens") + sanitized_inputs[SEARCH_TEXT_ID] = quote(inputs[SEARCH_TEXT_ID]) + return sanitized_inputs diff --git a/examples/test-ui/local_app/benchling_app/handler.py b/examples/test-ui/local_app/benchling_app/handler.py new file mode 100644 index 0000000..a75e3c1 --- /dev/null +++ b/examples/test-ui/local_app/benchling_app/handler.py @@ -0,0 +1,48 @@ +from typing import Any + +from benchling_sdk.apps.status.errors import AppUserFacingError +from benchling_sdk.models.webhooks.v0 import ( + CanvasCreatedWebhookV2Beta, + CanvasInitializeWebhookV2, + CanvasInteractionWebhookV2, + WebhookEnvelopeV0, +) + +from local_app.benchling_app.canvas_interaction import route_interaction_webhook +from local_app.benchling_app.setup import init_app_from_webhook +from local_app.benchling_app.views.canvas_initialize import ( + render_search_canvas, + render_search_canvas_for_created_canvas, +) +from local_app.lib.logger import get_logger + +logger = get_logger() + + +class UnsupportedWebhookError(Exception): + pass + + +def handle_webhook(webhook_dict: dict[str, Any]) -> None: + logger.debug("Handling webhook with payload: %s", webhook_dict) + webhook = WebhookEnvelopeV0.from_dict(webhook_dict) + app = init_app_from_webhook(webhook) + # Could also choose to route on webhook.message.type + # Note: if your manifest specifies more than one item in `features`, + # then `webhook.message.feature_id` may also need to be part of your routing logic + try: + if isinstance(webhook.message, CanvasInitializeWebhookV2): + render_search_canvas(app, webhook.message) + elif isinstance(webhook.message, CanvasInteractionWebhookV2): + route_interaction_webhook(app, webhook.message) + elif isinstance(webhook.message, CanvasCreatedWebhookV2Beta): + render_search_canvas_for_created_canvas(app, webhook.message) + else: + # Should only happen if the app's manifest requests webhooks that aren't handled in its code paths + raise UnsupportedWebhookError(f"Received an unsupported webhook type: {webhook}") + logger.debug("Successfully completed request for webhook: %s", webhook_dict) + # We want errors shown to the user to end control flow, but we don't want them to propagate + # and show as errors in our logs. + # For this example, Flask error handler won't intercept this since we're within a thread + except AppUserFacingError as e: + logger.debug("Exiting with client error: %s", e) diff --git a/examples/test-ui/local_app/benchling_app/setup.py b/examples/test-ui/local_app/benchling_app/setup.py new file mode 100644 index 0000000..9bcf448 --- /dev/null +++ b/examples/test-ui/local_app/benchling_app/setup.py @@ -0,0 +1,45 @@ +import os +from functools import cache +from pathlib import Path + +from benchling_sdk.apps.framework import App +from benchling_sdk.auth.client_credentials_oauth2 import ClientCredentialsOAuth2 +from benchling_sdk.benchling import Benchling +from benchling_sdk.models.webhooks.v0 import WebhookEnvelopeV0 + + +def init_app_from_webhook(webhook: WebhookEnvelopeV0) -> App: + return App(webhook.app.id, _benchling_from_webhook(webhook)) + + +@cache +def app_definition_id() -> str: + # App definition ID is available to "global" apps. It uniquely identifies the Benchling App + # above the tenant context. + # + # Although it is available via the webhook, for security purposes we choose to supply it with + # the App's code to avoid reusing elements of the webhook's data payload as part of its verification. + # For ease of setup, we retrieve it from an environment variable. + # You could choose to simply leave it in code like `return "appdef_SpzX0d5oDA"`. + app_def_id = os.environ.get("APP_DEFINITION_ID") + assert app_def_id is not None, "Missing APP_DEFINITION_ID from environment" + return app_def_id + + +def _benchling_from_webhook(webhook: WebhookEnvelopeV0) -> Benchling: + return Benchling(webhook.base_url, _auth_method()) + + +@cache +def _auth_method() -> ClientCredentialsOAuth2: + client_id = os.environ.get("CLIENT_ID") + assert client_id is not None, "Missing CLIENT_ID from environment" + client_secret = _client_secret_from_file() + return ClientCredentialsOAuth2(client_id, client_secret) + + +def _client_secret_from_file() -> str: + file_path = os.environ.get("CLIENT_SECRET_FILE") + assert file_path is not None, "Missing CLIENT_SECRET_FILE from environment" + with Path(file_path).open() as f: + return f.read() diff --git a/examples/test-ui/local_app/benchling_app/views/__init__.py b/examples/test-ui/local_app/benchling_app/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/test-ui/local_app/benchling_app/views/canvas_initialize.py b/examples/test-ui/local_app/benchling_app/views/canvas_initialize.py new file mode 100644 index 0000000..3c06490 --- /dev/null +++ b/examples/test-ui/local_app/benchling_app/views/canvas_initialize.py @@ -0,0 +1,46 @@ +from benchling_sdk.apps.canvas.framework import CanvasBuilder +from benchling_sdk.apps.canvas.types import UiBlock +from benchling_sdk.apps.framework import App +from benchling_sdk.models import ( + ButtonUiBlock, + ButtonUiBlockType, + TextInputUiBlock, + TextInputUiBlockType, +) +from benchling_sdk.models.webhooks.v0 import ( + CanvasCreatedWebhookV2Beta, + CanvasInitializeWebhookV2, +) + +from local_app.benchling_app.views.constants import SEARCH_BUTTON_ID, SEARCH_TEXT_ID + +def render_search_canvas(app: App, canvas_initialized: CanvasInitializeWebhookV2) -> None: + with app.create_session_context("Start Session", timeout_seconds=20): + canvas_builder = CanvasBuilder( + app_id=app.id, + feature_id=canvas_initialized.feature_id, + resource_id=canvas_initialized.resource_id, + ) + canvas_builder.blocks.append(input_blocks()) + app.benchling.apps.create_canvas(canvas_builder.to_create()) + +def render_search_canvas_for_created_canvas(app: App, canvas_created: CanvasCreatedWebhookV2Beta) -> None: + with app.create_session_context("Start Session", timeout_seconds=20): + canvas_builder = CanvasBuilder(app_id=app.id, feature_id=canvas_created.feature_id) + canvas_builder.blocks.append(input_blocks()) + app.benchling.apps.update_canvas(canvas_created.canvas_id, canvas_builder.to_update()) + +def input_blocks() -> list[UiBlock]: + return [ + TextInputUiBlock( + id=SEARCH_TEXT_ID, + type=TextInputUiBlockType.TEXT_INPUT, + placeholder="Type something...", + value="", + ), + ButtonUiBlock( + id=SEARCH_BUTTON_ID, + text="Submit", + type=ButtonUiBlockType.BUTTON, + ), + ] diff --git a/examples/test-ui/local_app/benchling_app/views/completed.py b/examples/test-ui/local_app/benchling_app/views/completed.py new file mode 100644 index 0000000..6e0ff80 --- /dev/null +++ b/examples/test-ui/local_app/benchling_app/views/completed.py @@ -0,0 +1,44 @@ +from benchling_sdk.apps.canvas.framework import CanvasBuilder +from benchling_sdk.apps.canvas.types import UiBlock +from benchling_sdk.apps.status.framework import SessionContextManager +from benchling_sdk.apps.status.helpers import ref +from benchling_sdk.models import ( + AppSessionMessageCreate, + AppSessionMessageStyle, + AppSessionUpdateStatus, + MarkdownUiBlock, + MarkdownUiBlockType +) + + +def render_completed_canvas( + result: dict, + canvas_id: str, + canvas_builder: CanvasBuilder, + session: SessionContextManager, +) -> None: + canvas_builder = canvas_builder.with_blocks(_completed_blocks()) + session.app.benchling.apps.update_canvas( + canvas_id, + canvas_builder.with_enabled().to_update(), + ) + session.close_session( + AppSessionUpdateStatus.SUCCEEDED, + messages=[ + AppSessionMessageCreate( + # ref() will turn supported objects into clickable "chips" in the Benchling UI + f"Success!", + style=AppSessionMessageStyle.SUCCESS, + ), + ], + ) + + +def _completed_blocks() -> list[UiBlock]: + return [ + MarkdownUiBlock( + id="completed", + type=MarkdownUiBlockType.MARKDOWN, + value="Success!", + ), + ] diff --git a/examples/test-ui/local_app/benchling_app/views/constants.py b/examples/test-ui/local_app/benchling_app/views/constants.py new file mode 100644 index 0000000..90d5b1b --- /dev/null +++ b/examples/test-ui/local_app/benchling_app/views/constants.py @@ -0,0 +1,7 @@ +# Button IDs for routing actions +SEARCH_BUTTON_ID = "search_button" +CREATE_BUTTON_ID = "create_button" +CANCEL_BUTTON_ID = "cancel_button" + +# Inputs we want to gather data from, submitted by the user +SEARCH_TEXT_ID = "search_text" diff --git a/examples/test-ui/local_app/lib/__init__.py b/examples/test-ui/local_app/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/test-ui/local_app/lib/logger.py b/examples/test-ui/local_app/lib/logger.py new file mode 100644 index 0000000..fed2224 --- /dev/null +++ b/examples/test-ui/local_app/lib/logger.py @@ -0,0 +1,14 @@ +import logging +import os + +# Enable basic logging for development debugging +logging.basicConfig(format="%(name)s - %(asctime)s - {%(pathname)s:%(lineno)d} - %(message)s", + level=logging.WARNING) + + +# Get a logger for our App, with a level enabled specifically for our logging but not other libraries +def get_logger(name: str = "benchling-app") -> logging.Logger: + level = os.environ.get("BENCHLING_APP_LOG_LEVEL", "INFO").upper() + logger = logging.getLogger(name) + logger.setLevel(level) + return logger diff --git a/examples/test-ui/manifest.yaml b/examples/test-ui/manifest.yaml new file mode 100644 index 0000000..a8f669e --- /dev/null +++ b/examples/test-ui/manifest.yaml @@ -0,0 +1,26 @@ +manifestVersion: 1 + +info: + name: Test UI + version: 0.1.6 +features: + - name: App Homepage canvas + id: test_app_homepage_feature + type: APP_HOMEPAGE + - name: Submit Step + id: submit_step + type: ASSAY_RUN + - name: Submit Step + id: canvas_submit_step + type: CANVAS + locations: + - ENTRY + - ENTRY_TEMPLATE +subscriptions: + deliveryMethod: WEBHOOK + endpoint: /1/webhooks/canvas + messages: + - type: v2.canvas.initialized + - type: v2.canvas.userInteracted + - type: v2-beta.canvas.created +# configuration: diff --git a/examples/test-ui/requirements.txt b/examples/test-ui/requirements.txt new file mode 100644 index 0000000..f3a9270 --- /dev/null +++ b/examples/test-ui/requirements.txt @@ -0,0 +1,3 @@ +flask~=3.0.2 +# Cryptography extra needed for webhook verification +benchling-sdk[cryptography]==1.21.1 \ No newline at end of file diff --git a/examples/test-ui/ruff.toml b/examples/test-ui/ruff.toml new file mode 100644 index 0000000..d6bc18c --- /dev/null +++ b/examples/test-ui/ruff.toml @@ -0,0 +1,10 @@ +# Run with: ruff check . --fix + +target-version = "py311" +line-length = 110 + +select = ["ALL"] +ignore = ["D100", "D101", "D103", "D104", "EM101", "EM102", "S101", "TRY003", "TRY301"] + +[per-file-ignores] +"**/tests/*" = ["ANN001", "ANN101", "D102", "PLR2004"] \ No newline at end of file diff --git a/examples/test-ui/tests/__init__.py b/examples/test-ui/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/test-ui/tests/files/pubchem/compound.json b/examples/test-ui/tests/files/pubchem/compound.json new file mode 100644 index 0000000..694bc13 --- /dev/null +++ b/examples/test-ui/tests/files/pubchem/compound.json @@ -0,0 +1,561 @@ +{ + "PC_Compounds": [ + { + "id": { + "id": { + "cid": 2244 + } + }, + "atoms": { + "aid": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "element": [ + 8, + 8, + 8, + 8, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "bonds": { + "aid1": [ + 1, + 1, + 2, + 2, + 3, + 4, + 5, + 5, + 6, + 6, + 7, + 7, + 8, + 8, + 9, + 9, + 10, + 12, + 13, + 13, + 13 + ], + "aid2": [ + 5, + 12, + 11, + 21, + 11, + 12, + 6, + 7, + 8, + 11, + 9, + 14, + 10, + 15, + 10, + 16, + 17, + 13, + 18, + 19, + 20 + ], + "order": [ + 1, + 1, + 1, + 1, + 2, + 2, + 1, + 2, + 2, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "coords": [ + { + "type": [ + 1, + 5, + 255 + ], + "aid": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "conformers": [ + { + "x": [ + 3.732, + 6.3301, + 4.5981, + 2.866, + 4.5981, + 5.4641, + 4.5981, + 6.3301, + 5.4641, + 6.3301, + 5.4641, + 2.866, + 2, + 4.0611, + 6.8671, + 5.4641, + 6.8671, + 2.31, + 1.4631, + 1.69, + 6.3301 + ], + "y": [ + -0.06, + 1.44, + 1.44, + -1.56, + -0.56, + -0.06, + -1.56, + -0.56, + -2.06, + -1.56, + 0.94, + -0.56, + -0.06, + -1.87, + -0.25, + -2.68, + -1.87, + 0.4769, + 0.25, + -0.5969, + 2.06 + ], + "style": { + "annotation": [ + 8, + 8, + 8, + 8, + 8, + 8 + ], + "aid1": [ + 5, + 5, + 6, + 7, + 8, + 9 + ], + "aid2": [ + 6, + 7, + 8, + 9, + 10, + 10 + ] + } + } + ] + } + ], + "charge": 0, + "props": [ + { + "urn": { + "label": "Compound", + "name": "Canonicalized", + "datatype": 5, + "release": "2021.10.14" + }, + "value": { + "ival": 1 + } + }, + { + "urn": { + "label": "Compound Complexity", + "datatype": 7, + "implementation": "E_COMPLEXITY", + "version": "3.4.8.18", + "software": "Cactvs", + "source": "Xemistry GmbH", + "release": "2021.10.14" + }, + "value": { + "fval": 212 + } + }, + { + "urn": { + "label": "Count", + "name": "Hydrogen Bond Acceptor", + "datatype": 5, + "implementation": "E_NHACCEPTORS", + "version": "3.4.8.18", + "software": "Cactvs", + "source": "Xemistry GmbH", + "release": "2021.10.14" + }, + "value": { + "ival": 4 + } + }, + { + "urn": { + "label": "Count", + "name": "Hydrogen Bond Donor", + "datatype": 5, + "implementation": "E_NHDONORS", + "version": "3.4.8.18", + "software": "Cactvs", + "source": "Xemistry GmbH", + "release": "2021.10.14" + }, + "value": { + "ival": 1 + } + }, + { + "urn": { + "label": "Count", + "name": "Rotatable Bond", + "datatype": 5, + "implementation": "E_NROTBONDS", + "version": "3.4.8.18", + "software": "Cactvs", + "source": "Xemistry GmbH", + "release": "2021.10.14" + }, + "value": { + "ival": 3 + } + }, + { + "urn": { + "label": "Fingerprint", + "name": "SubStructure Keys", + "datatype": 16, + "parameters": "extended 2", + "implementation": "E_SCREEN", + "version": "3.4.8.18", + "software": "Cactvs", + "source": "Xemistry GmbH", + "release": "2021.10.14" + }, + "value": { + "binary": "00000371C0703800000000000000000000000000000000000000300000000000000000010000001A00000800000C04809800320E80000600880220D208000208002420000888010608C80C273684351A827B60A5E01108B98788C8208E00000000000800000000000000100000000000000000" + } + }, + { + "urn": { + "label": "IUPAC Name", + "name": "Allowed", + "datatype": 1, + "version": "2.7.0", + "software": "Lexichem TK", + "source": "OpenEye Scientific Software", + "release": "2021.10.14" + }, + "value": { + "sval": "2-acetoxybenzoic acid" + } + }, + { + "urn": { + "label": "IUPAC Name", + "name": "CAS-like Style", + "datatype": 1, + "version": "2.7.0", + "software": "Lexichem TK", + "source": "OpenEye Scientific Software", + "release": "2021.10.14" + }, + "value": { + "sval": "2-acetyloxybenzoic acid" + } + }, + { + "urn": { + "label": "IUPAC Name", + "name": "Markup", + "datatype": 1, + "version": "2.7.0", + "software": "Lexichem TK", + "source": "OpenEye Scientific Software", + "release": "2021.10.14" + }, + "value": { + "sval": "2-acetyloxybenzoic acid" + } + }, + { + "urn": { + "label": "IUPAC Name", + "name": "Preferred", + "datatype": 1, + "version": "2.7.0", + "software": "Lexichem TK", + "source": "OpenEye Scientific Software", + "release": "2021.10.14" + }, + "value": { + "sval": "2-acetyloxybenzoic acid" + } + }, + { + "urn": { + "label": "IUPAC Name", + "name": "Systematic", + "datatype": 1, + "version": "2.7.0", + "software": "Lexichem TK", + "source": "OpenEye Scientific Software", + "release": "2021.10.14" + }, + "value": { + "sval": "2-acetyloxybenzoic acid" + } + }, + { + "urn": { + "label": "IUPAC Name", + "name": "Traditional", + "datatype": 1, + "version": "2.7.0", + "software": "Lexichem TK", + "source": "OpenEye Scientific Software", + "release": "2021.10.14" + }, + "value": { + "sval": "2-acetoxybenzoic acid" + } + }, + { + "urn": { + "label": "InChI", + "name": "Standard", + "datatype": 1, + "version": "1.0.6", + "software": "InChI", + "source": "iupac.org", + "release": "2021.10.14" + }, + "value": { + "sval": "InChI=1S/C9H8O4/c1-6(10)13-8-5-3-2-4-7(8)9(11)12/h2-5H,1H3,(H,11,12)" + } + }, + { + "urn": { + "label": "InChIKey", + "name": "Standard", + "datatype": 1, + "version": "1.0.6", + "software": "InChI", + "source": "iupac.org", + "release": "2021.10.14" + }, + "value": { + "sval": "BSYNRYMUTXBXSQ-UHFFFAOYSA-N" + } + }, + { + "urn": { + "label": "Log P", + "name": "XLogP3", + "datatype": 7, + "version": "3.0", + "source": "sioc-ccbg.ac.cn", + "release": "2021.10.14" + }, + "value": { + "fval": 1.2 + } + }, + { + "urn": { + "label": "Mass", + "name": "Exact", + "datatype": 1, + "version": "2.2", + "software": "PubChem", + "source": "ncbi.nlm.nih.gov", + "release": "2021.10.14" + }, + "value": { + "sval": "180.04225873" + } + }, + { + "urn": { + "label": "Molecular Formula", + "datatype": 1, + "version": "2.2", + "software": "PubChem", + "source": "ncbi.nlm.nih.gov", + "release": "2021.10.14" + }, + "value": { + "sval": "C9H8O4" + } + }, + { + "urn": { + "label": "Molecular Weight", + "datatype": 1, + "version": "2.2", + "software": "PubChem", + "source": "ncbi.nlm.nih.gov", + "release": "2021.10.14" + }, + "value": { + "sval": "180.16" + } + }, + { + "urn": { + "label": "SMILES", + "name": "Canonical", + "datatype": 1, + "version": "2.3.0", + "software": "OEChem", + "source": "OpenEye Scientific Software", + "release": "2021.10.14" + }, + "value": { + "sval": "CC(=O)OC1=CC=CC=C1C(=O)O" + } + }, + { + "urn": { + "label": "SMILES", + "name": "Isomeric", + "datatype": 1, + "version": "2.3.0", + "software": "OEChem", + "source": "OpenEye Scientific Software", + "release": "2021.10.14" + }, + "value": { + "sval": "CC(=O)OC1=CC=CC=C1C(=O)O" + } + }, + { + "urn": { + "label": "Topological", + "name": "Polar Surface Area", + "datatype": 7, + "implementation": "E_TPSA", + "version": "3.4.8.18", + "software": "Cactvs", + "source": "Xemistry GmbH", + "release": "2021.10.14" + }, + "value": { + "fval": 63.6 + } + }, + { + "urn": { + "label": "Weight", + "name": "MonoIsotopic", + "datatype": 1, + "version": "2.2", + "software": "PubChem", + "source": "ncbi.nlm.nih.gov", + "release": "2021.10.14" + }, + "value": { + "sval": "180.04225873" + } + } + ], + "count": { + "heavy_atom": 13, + "atom_chiral": 0, + "atom_chiral_def": 0, + "atom_chiral_undef": 0, + "bond_chiral": 0, + "bond_chiral_def": 0, + "bond_chiral_undef": 0, + "isotope_atom": 0, + "covalent_unit": 1, + "tautomers": -1 + } + } + ] +} \ No newline at end of file diff --git a/examples/test-ui/tests/files/pubchem/search.json b/examples/test-ui/tests/files/pubchem/search.json new file mode 100644 index 0000000..1ad180d --- /dev/null +++ b/examples/test-ui/tests/files/pubchem/search.json @@ -0,0 +1,7 @@ +{ + "IdentifierList": { + "CID": [ + 2244 + ] + } +} \ No newline at end of file diff --git a/examples/test-ui/tests/files/pubchem/synonyms.json b/examples/test-ui/tests/files/pubchem/synonyms.json new file mode 100644 index 0000000..5ed497d --- /dev/null +++ b/examples/test-ui/tests/files/pubchem/synonyms.json @@ -0,0 +1,633 @@ +{ + "InformationList": { + "Information": [ + { + "CID": 2244, + "Synonym": [ + "aspirin", + "ACETYLSALICYLIC ACID", + "50-78-2", + "2-Acetoxybenzoic acid", + "2-(Acetyloxy)benzoic acid", + "Acetylsalicylate", + "O-Acetylsalicylic acid", + "o-Acetoxybenzoic acid", + "Acenterine", + "Acetophen", + "Acetosal", + "Acylpyrin", + "Easprin", + "Ecotrin", + "Salicylic acid acetate", + "Acetosalin", + "Aspirdrops", + "Polopiryna", + "Salcetogen", + "Aceticyl", + "Acetonyl", + "Acetylin", + "Acidum acetylsalicylicum", + "Benaspir", + "Colfarit", + "Empirin", + "Endydol", + "Measurin", + "Rhodine", + "Saletin", + "o-Carboxyphenyl acetate", + "Enterosarein", + "Enterosarine", + "Acetisal", + "Acetylsal", + "Aspirine", + "Bialpirinia", + "Entericin", + "Enterophen", + "Micristin", + "Pharmacin", + "Premaspin", + "Salacetin", + "Solpyron", + "Temperal", + "Acesal", + "Acisal", + "Asagran", + "Asteric", + "Duramax", + "Ecolen", + "Extren", + "Globoid", + "Helicon", + "Idragin", + "Rhonal", + "Aspro", + "Novid", + "Rheumintabletten", + "Yasta", + "Solprin acid", + "Benzoic acid, 2-(acetyloxy)-", + "Acimetten", + "Bialpirina", + "Claradin", + "Clariprin", + "Delgesic", + "Entrophen", + "Globentyl", + "Neuronika", + "Acetilum acidulatum", + "Cemirit", + "Decaten", + "Levius", + "Pirseal", + "Solfrin", + "Acetilsalicilico", + "Adiro", + "Aspec", + "Acetosalic acid", + "2-acetyloxybenzoic acid", + "Dolean pH 8", + "Triple-sal", + "Spira-Dine", + "ZORprin", + "Contrheuma retard", + "Bi-prin", + "XAXA", + "Acido acetilsalicilico", + "Acide acetylsalicylique", + "Persistin", + "Bayer", + "2-Carboxyphenyl acetate", + "A.S.A. empirin", + "ASA", + "8-hour Bayer", + "Endosprin", + "Kapsazal", + "Asatard", + "Durlaza", + "Bayer Plus", + "Acetylsalicylsaure", + "Rheumin tabletten", + "Solprin", + "Tasprin", + "Nu-seals aspirin", + "Salicylic acid, acetate", + "Acido O-acetil-benzoico", + "Kyselina acetylsalicylova", + "2-Acetoxybenzenecarboxylic acid", + "Acetylsalicylsaeure", + "Azetylsalizylsaeure", + "SP 189", + "St. Joseph Aspirin for Adults", + "A.S.A.", + "St. Joseph", + "Kyselina 2-acetoxybenzoova", + "AC 5230", + "Acetylsalicyclic acid", + "Acetylsalicylicum acidum", + "Aspropharm", + "Cardioaspirin", + "Acetard", + "CHEBI:15365", + "acetyl salicylate", + "S-211", + "ECM", + "NSC-27223", + "acide 2-(acetyloxy)benzoique", + "Bayer Extra Strength Aspirin for Migraine Pain", + "Aspirin (Standard)", + "NSC-406186", + "R16CO5Y76E", + "DTXSID5020108", + "2-(acetyloxy)benzoate", + "o-(Acetyloxy)benzoic acid", + "benzoic acid, 2-acetoxy-", + "BAY1019036", + "DTXCID50108", + "Acetylsalicylic acid (who-ip)", + "AXOTAL COMPONENT ASPIRIN", + "AZDONE COMPONENT ASPIRIN", + "CODOXY COMPONENT ASPIRIN", + "AGGRENOX COMPONENT ASPIRIN", + "Aspirin form II", + "DUOCOVER COMPONENT ASPIRIN", + "EXCEDRIN COMPONENT ASPIRIN", + "FIORINAL COMPONENT ASPIRIN", + "NORGESIC COMPONENT ASPIRIN", + "PERCODAN COMPONENT ASPIRIN", + "Q-GESIC COMPONENT ASPIRIN", + "ROXIPRIN COMPONENT ASPIRIN", + "VICOPRIN COMPONENT ASPIRIN", + "YOSPRALA COMPONENT ASPIRIN", + "DUOPLAVIN COMPONENT ASPIRIN", + "EQUAGESIC COMPONENT ASPIRIN", + "INVAGESIC COMPONENT ASPIRIN", + "LANORINAL COMPONENT ASPIRIN", + "MICRAININ COMPONENT ASPIRIN", + "ROBAXISAL COMPONENT ASPIRIN", + "component of Midol", + "ASPIRIN COMPONENT OF AXOTAL", + "ASPIRIN COMPONENT OF AZDONE", + "ASPIRIN COMPONENT OF CODOXY", + "NSC27223", + "ORPHENGESIC COMPONENT ASPIRIN", + "ASPIRIN COMPONENT OF AGGRENOX", + "ASPIRIN COMPONENT OF DUOCOVER", + "ASPIRIN COMPONENT OF EXCEDRIN", + "ASPIRIN COMPONENT OF FIORINAL", + "ASPIRIN COMPONENT OF NORGESIC", + "ASPIRIN COMPONENT OF PERCODAN", + "ASPIRIN COMPONENT OF Q-GESIC", + "ASPIRIN COMPONENT OF ROXIPRIN", + "ASPIRIN COMPONENT OF VICOPRIN", + "ASPIRIN COMPONENT OF YOSPRALA", + "SYNALGOS-DC COMPONENT ASPIRIN", + "component of Synirin", + "ASPIRIN COMPONENT OF DUOPLAVIN", + "ASPIRIN COMPONENT OF EQUAGESIC", + "ASPIRIN COMPONENT OF INVAGESIC", + "ASPIRIN COMPONENT OF LANORINAL", + "ASPIRIN COMPONENT OF MICRAININ", + "ASPIRIN COMPONENT OF ROBAXISAL", + "MFCD00002430", + "NSC406186", + "component of Zactirin", + "MEPRO-ASPIRIN COMPONENT ASPIRIN", + "PERCODAN-DEMI COMPONENT ASPIRIN", + "PRAVIGARD PAC COMPONENT ASPIRIN", + "SOMA COMPOUND COMPONENT ASPIRIN", + "ASPIRIN COMPONENT OF ORPHENGESIC", + "component of Coricidin", + "component of Persistin", + "component of Robaxisal", + "o-Acetoxybenzoate", + "ASPIRIN COMPONENT OF SYNALGOS-DC", + "DARVON COMPOUND COMPONENT ASPIRIN", + "INVAGESIC FORTE COMPONENT ASPIRIN", + "TALWIN COMPOUND COMPONENT ASPIRIN", + "NCGC00015067-04", + "Acetysal", + "ACIDUM ACETYLSALICYLICUM (WHO-IP)", + "ASPIRIN COMPONENT OF MEPRO-ASPIRIN", + "ASPIRIN COMPONENT OF PERCODAN-DEMI", + "ASPIRIN COMPONENT OF PRAVIGARD PAC", + "ASPIRIN COMPONENT OF SOMA COMPOUND", + "Istopirin", + "Magnecyl", + "Medisyl", + "Polopirin", + "ORPHENGESIC FORTE COMPONENT ASPIRIN", + "Ronal", + "ASPIRIN COMPONENT OF DARVON COMPOUND", + "ASPIRIN COMPONENT OF INVAGESIC FORTE", + "ASPIRIN COMPONENT OF TALWIN COMPOUND", + "ASPIRIN (MART.)", + "ASPIRIN [MART.]", + "Bayer Buffered", + "ASPIRIN COMPONENT OF ORPHENGESIC FORTE", + "Aspro Clear", + "component of Ascodeen-30", + "ASPIRIN (USP-RS)", + "ASPIRIN [USP-RS]", + "WLN: QVR BOV1", + "aspirin (acetylsalicylic acid)", + "AcetylsalicylicAcid", + "Aspirina 03", + "Acetylsalycilic acid", + "acetyl salicylic acid", + "ASPIRIN (USP MONOGRAPH)", + "ASPIRIN [USP MONOGRAPH]", + "component of Darvon with A.S.A", + "Bayer Aspirin 8 Hour", + "Asaphen", + "Aspalon", + "Asprin", + "Bayer Children's Aspirin", + "Nu-seals", + "component of St. Joseph Cold Tablets", + "Aspir-Mox", + "Durlaza ER", + "Acetylsalicylsaure [German]", + "CAS-50-78-2", + "Acetoxybenzoic acid", + "Acetysalicylic acid", + "AIN", + "SMR000059138", + "Ascoden-30", + "CCRIS 3243", + "HSDB 652", + "Acide acetylsalicylique [French]", + "Acido acetilsalicilico [Italian]", + "Kyselina acetylsalicylova [Czech]", + "Acido O-acetil-benzoico [Italian]", + "SR-01000075668", + "Kyselina 2-acetoxybenzoova [Czech]", + "EINECS 200-064-1", + "NSC 27223", + "Aspirin [USP:BAN:JAN]", + "Bayer Enteric 325 mg Regular Strength", + "BRN 0779271", + "vetality", + "Bay E4465", + "UNII-R16CO5Y76E", + "Bayer Enteric 81 mg Adult Low Strength", + "Cardioaspirina", + "Acetyonyl", + "Angettes", + "Asacard", + "Ascolong", + "Aspirina", + "AspirinChewable", + "Bayer Enteric 500 mg Arthritis Strength", + "Cardiprin", + "Claragine", + "Colsprin", + "Encaprin", + "Fasprin", + "Medpurine", + "Miniasal", + "Salospir", + "Acesan", + "careone aspirin", + "Toldex", + "Aramark Aspirin", + "Aspirin Powder", + "Aspirin Regimen", + "AspirinEC", + "Canine Aspirin", + "Coated Aspirin", + "Enteric Aspirin", + "Equate Aspirin", + "Leader Aspirin", + "Rapidol Aspirin", + "Rexall Aspirin", + "Sunmark Aspirin", + "Topcare Aspirin", + "AI3-02956", + "Aspirin Bolus", + "Aspirin Nsaid", + "Bayer Aspirin", + "Rugby Aspirin", + "Aspi-cor", + "Buffered Aspirin", + "Chewable Aspirin", + "Equaline Aspirin", + "Geritrex Aspirin", + "McKesson Aspirin", + "AspirinNSAID", + "Azetylsalizylsaure", + "Aspirin EC", + "aspirinpain relief", + "Childrens Aspirin", + "Unishield Aspirin", + "ASA Empirin", + "1oxr", + "2-Acetoxybenzoate", + "Aspirin 5 Grain", + "Care One Aspirin", + "Bufferin Arthritis", + "ULINE Aspirin", + "CAREALL Aspirin", + "MooreBrand Aspirin", + "Aspica (Aspirin)", + "Aspirin 81mg", + "Dg Health Aspirin", + "Medi-first Aspirin", + "Basic Care Aspirin", + "caring mill aspirin", + "Dr Pausins Aspirin", + "Good Sense Aspirin", + "rugby adult aspirin", + "Aspirin 325mg", + "Aspirin 81", + "Aspirin 81 mg", + "Aspirin,(S)", + "Up and Up Aspirin", + "Aspalon (JAN)", + "Durlaza (TN)", + "Easprin (TN)", + "Health Mart Aspirin", + "MBR Aspirin Powder", + "Pain Relief Aspirin", + "Solves-aspirinCherry", + "Aspirin 325 mg", + "Aspirin Pain reliver", + "Tri-buffered Aspirin", + "acetyl-salicylic acid", + "Aspirin 325", + "Aspirin 50 CT", + "AspirinEnteric Coated", + "ASPRISOL", + "Crane Safety Aspirin", + "equate aspirinchewable", + "Health Sense Ecpirin", + "Henry Schein Aspirin", + "sunmark adult aspirin", + "Travel Savvy Aspirin", + "VAZALORE", + "ASPIRINLow Strength", + "acetyl salicyclic acid", + "Adult Aspirin Regimen", + "Aspirin Bolus-240", + "AspirinDelayed Release", + "Bayer Aspirin Regimen", + "Bayer Genuine Aspirin", + "Direct Safety Aspirin", + "Moore Medical Aspirin", + "o-(Acetyloxy)benzoate", + "Rapid Comfort Aspirin", + "Safety Coated Aspirin", + "signature care aspirin", + "Aspirinregular strength", + "ASSURED ASPIRIN", + "Percodan (Salt/Mix)", + "Adult Chewable Aspirin", + "Biovanta Double Action", + "Enteric Coated Aspirin", + "Extra Strength Aspirin", + "ADVANCED ASPIRIN", + "Ascriptin (Salt/Mix)", + "Micrainin (Salt/Mix)", + "VALUMEDS ASPIRIN", + "2-acetoxy benzoic acid", + "Aspirin tablet 325mg", + "Chewable Aspirin 81mg", + "Dye-Free Aspirin 81", + "Family Wellness Aspirin", + "Physicians Care Aspirin", + "RHODINE NC RP", + "Signature Care Aspririn", + "Spectrum_001245", + "2-Acetylsalicyclic acid", + "First Aid Only Aspirin", + "Acide acetyl salicylique", + "ASPIRIN [VANDF]", + "Medique at Home Aspirin", + "ASPIRIN [HSDB]", + "Aspirin Regular Strength", + "Ecotrin Regular Strength", + "Medi-First Plus Aspirin", + "Medique Products Aspirin", + "Regular Strength Aspirin", + "Salicylic acid, acetyl-", + "ASPIRIN [JAN]", + "Bayer PlusExtra Strength", + "Circle K Aspirin 325", + "Pharbest Aspirin 325mg", + "ASPIRIN [MI]", + "CHEMBL25", + "health mart adult aspirin", + "Spectrum2_001899", + "Spectrum3_001295", + "Spectrum4_000099", + "Spectrum5_000740", + "Aspirin (JP17/USP)", + "Lopac-A-5376", + "Salycylacetylsalicylic acid", + "ASPIRIN480", + "BAYER 500 mg", + "Chronic Pain/Fever Relief", + "CARDIASPIRIN PROTECT", + "NobleAid PAIN RELIEVER", + "Berkley and Jensen Aspirin", + "Epitope ID:114151", + "Percodan Demi (Salt/Mix)", + "Soma Compound (Salt/Mix)", + "EC 200-064-1", + "Acetylsalicylic acid, 99%", + "ASPIRINEXTRA STRENGTH", + "cid_2244", + "Pravigard PAC (Salt/Mix)", + "SCHEMBL1353", + "Up and Up Chewable Aspirin", + "ASPIRIN BOLUS-480", + "AspirinEnteric Safety Coated", + "AspirinEnteric Safety-Coated", + "Regular Strength Aspirin EC", + "2-(Acetyloxy)-benzoic acid", + "Bay-e-4465", + "Plus PharmaNSAID 325 mg", + "365 Everyday Value Aspirin", + "Alka-Seltzer Original Flavor", + "Aspirin 81mg Enteric coated", + "AspirinLow Strength, Enteric", + "Critical Care Aspirin To Go", + "Lopac0_000038", + "ASPIRIN 325 MG EC", + "KBioGR_000398", + "KBioGR_002271", + "KBioSS_001725", + "KBioSS_002272", + "4-10-00-00138 (Beilstein Handbook Reference)", + "MLS001055329", + "MLS001066332", + "MLS001336045", + "MLS001336046", + "ASPIRIN [ORANGE BOOK]", + "BAYER Aspirin Extra Strength", + "BIDD:GT0118", + "DivK1c_000555", + "Lil Drug Store Aspirin 325", + "SPECTRUM1500130", + "Aspirin 81 mg Enteric Coated", + "Bayer Aspirin Regimen Chewable", + "Bayer Chewable-Aspirin Regimen", + "Good Neighbor Pharmacy Aspirin", + "Rapidol Aspirin Display 2x25", + "SPBio_001838", + "Acetylsalicylic acid, >=99%", + "Buffered AspirinFor Small Dogs", + "GTPL4139", + "O-Acetylsalicylic acid; Aspirin", + "MBR Aspirin Bolus 240 Grains", + "BDBM22360", + "HMS501L17", + "KBio1_000555", + "KBio2_001725", + "KBio2_002271", + "KBio2_004293", + "KBio2_004839", + "KBio2_006861", + "KBio2_007407", + "KBio3_002149", + "KBio3_002751", + "Empirin with Codeine (Salt/Mix)", + "Pharbest Regular Strength Aspirin", + "Value PharmaAspirin Pain Reliever", + "Acetylsalicylic acid, >=99.0%", + "cMAP_000006", + "component of Zactirin (Salt/Mix)", + "First Aid Direct Chewable Aspirin", + "NINDS_000555", + "ASPIRIN Analgesic and Antipyretic", + "HMS1920E13", + "HMS2090G03", + "HMS2091K13", + "HMS2233L18", + "HMS3260G17", + "HMS3372N15", + "HMS3656N14", + "HMS3715P19", + "HMS3866L03", + "HMS3885G03", + "Pharmakon1600-01500130", + "ACETYLSALICYLIC ACID [INCI]", + "Bayer Aspirin Regimenenteric coated", + "BCP21790", + "STR01551", + "ACETYLSALICYLIC ACID; ASPIRIN", + "BAYER AspirinExtra Strength Caplets", + "Tox21_110076", + "Tox21_202117", + "Tox21_300146", + "Tox21_500038", + "Buffered aspirin, effervescent tablet", + "CCG-39490", + "HY-14654R", + "NSC755899", + "s3017", + "ACETYLSALICYLIC ACID [WHO-DD]", + "Bufferin Regular Strength Pain Relief", + "enteric coated aspirinRegular Strength", + "AKOS000118884", + "Aspirin Enteric Coated Tablets 81 mg", + "component of Ascodeen-30 (Salt/Mix)", + "Tox21_110076_1", + "ACETYLSALICYLIC ACID [EMA EPAR]", + "ACETYLSALICYLICUM ACIDUM [HPUS]", + "Aspirin 81 mg Delayed Release Tablets", + "CS-2001", + "DB00945", + "LP00038", + "NSC-755899", + "PL-2200", + "SDCCGSBI-0050027.P005", + "Value PharmaPain RelieverExtra Strength", + "AspirinEnteric Coated, Regular Strength", + "BAY-1019036", + "IDI1_000555", + "Regular Strength Enteric Coated Aspirin", + "ACETYLSALICYLIC ACID [GREEN BOOK]", + "Acetylsalicylic acid, analytical standard", + "Aspirin Delayed Release Tablets, 81 mg", + "Buffered AspirinFor Medium to Large Dogs", + "Coraspirin 81 mg Enteric Coasted Tablet", + "NCGC00015067-01", + "NCGC00015067-02", + "NCGC00015067-03", + "NCGC00015067-05", + "NCGC00015067-06", + "NCGC00015067-07", + "NCGC00015067-08", + "NCGC00015067-09", + "NCGC00015067-10", + "NCGC00015067-11", + "NCGC00015067-12", + "NCGC00015067-13", + "NCGC00015067-14", + "NCGC00015067-24", + "NCGC00015067-26", + "NCGC00090977-01", + "NCGC00090977-02", + "NCGC00090977-03", + "NCGC00090977-04", + "NCGC00090977-05", + "NCGC00090977-06", + "NCGC00090977-07", + "NCGC00254034-01", + "NCGC00259666-01", + "NCGC00260723-01", + "Aspirin, meets USP testing specifications", + "HY-14654", + "NCI60_002222", + "ACETYLSALICYLIC ACID [EP MONOGRAPH]", + "Aspirin 81mg Enteric coatedDelayed Release", + "Cardioaspirin 81 mg Enteric Coated Tablet", + "SBI-0050027.P004", + "UNM-0000306102", + "component of Darvon with A.S.A (Salt/Mix)", + "CS-0694916", + "EU-0100038", + "FT-0655181", + "FT-0661360", + "SW199665-2", + "CARISOPRODOL COMPOUND COMPONENT ASPIRIN", + "EN300-19606", + "A 5376", + "Acetylsalicylic Acid 1.0 mg/ml in Acetonitrile", + "C01405", + "D00109", + "E80792", + "Q18216", + "Safety Coated Aspirin 325 mg Regular Strength", + "AB00051918-08", + "AB00051918_09", + "AB00051918_10", + "ASPIRIN COMPONENT OF CARISOPRODOL COMPOUND", + "Arthritis Pain Formula Maximum Strength (Salt/Mix)", + "Health Mart Regular Strength Enteric Coated Aspirin", + "Natural Aspirin plus Tart Cherry Dietary Supplement", + "SR-01000075668-1", + "SR-01000075668-4", + "SR-01000075668-6", + "Acetylsalicylic acid, Vetec(TM) reagent grade, >=99%", + "Aspirin 81mg Enteric coatedLow Strength Aspirin Regimen", + "Aspirin, British Pharmacopoeia (BP) Reference Standard", + "CLOPIDOGREL/ACETYLSALICYLIC ACID COMPONENT ASPIRIN", + "F2191-0068", + "Natural Aspirin plus Immune Supporting Dietary Supplement", + "Natural Aspirin plus Lemon and Honey Dietary Supplement", + "Z104474430", + "ASPIRIN COMPONENT OF CLOPIDOGREL/ACETYLSALICYLIC ACID", + "Aspirin, United States Pharmacopeia (USP) Reference Standard", + "D41527A7-A9EB-472D-A7FC-312821130549", + "Acetylsalicylic acid, European Pharmacopoeia (EP) Reference Standard", + "Acetylsalicylic acid, BioReagent, plant cell culture tested, >=99.0%", + "Acetylsalicylic acid for peak identification, European Pharmacopoeia (EP) Reference Standard", + "InChI=1/C9H8O4/c1-6(10)13-8-5-3-2-4-7(8)9(11)12/h2-5H,1H3,(H,11,12", + "11126-35-5", + "Aspirin (Acetyl Salicylic Acid), Pharmaceutical Secondary Standard; Certified Reference Material" + ] + } + ] + } +} \ No newline at end of file diff --git a/examples/test-ui/tests/files/test_client_secret b/examples/test-ui/tests/files/test_client_secret new file mode 100644 index 0000000..7fdf977 --- /dev/null +++ b/examples/test-ui/tests/files/test_client_secret @@ -0,0 +1 @@ +fake_secret \ No newline at end of file diff --git a/examples/test-ui/tests/files/webhooks/app_activation_webhook.json b/examples/test-ui/tests/files/webhooks/app_activation_webhook.json new file mode 100644 index 0000000..ce99869 --- /dev/null +++ b/examples/test-ui/tests/files/webhooks/app_activation_webhook.json @@ -0,0 +1,18 @@ +{ + "version": "0", + "baseURL": "https://non-existent.benchling.com", + "tenantId": "ten_bcvxj2yf7q", + "app": { + "id": "app_DRBigxGEyr2BzW4T" + }, + "appDefinition": { + "id": "appdef_PRhebXCvtw", + "versionNumber": "0.0.1" + }, + "channel": "app_signals", + "message": { + "type": "v0.app.activateRequested", + "deprecated": false, + "excludedProperties": [] + } +} \ No newline at end of file diff --git a/examples/test-ui/tests/files/webhooks/canvas_initialize_webhook.json b/examples/test-ui/tests/files/webhooks/canvas_initialize_webhook.json new file mode 100644 index 0000000..5635efc --- /dev/null +++ b/examples/test-ui/tests/files/webhooks/canvas_initialize_webhook.json @@ -0,0 +1,20 @@ +{ + "version": "0", + "baseURL": "https://non-existent.benchling.com", + "tenantId": "ten_bcvxj2yf7q", + "app": { + "id": "app_DRBigxGEyr2BzW4T" + }, + "appDefinition": { + "id": "appdef_PRhebXCvtw", + "versionNumber": "0.0.1" + }, + "channel": "app_signals", + "message": { + "featureId": "feat_1234", + "resourceId": "assayrun_1234", + "type": "v2.canvas.initialized", + "deprecated": false, + "excludedProperties": [] + } +} \ No newline at end of file diff --git a/examples/test-ui/tests/files/webhooks/canvas_interaction_webhook.json b/examples/test-ui/tests/files/webhooks/canvas_interaction_webhook.json new file mode 100644 index 0000000..648fc56 --- /dev/null +++ b/examples/test-ui/tests/files/webhooks/canvas_interaction_webhook.json @@ -0,0 +1,20 @@ +{ + "version": "0", + "baseURL": "https://non-existent.benchling.com", + "tenantId": "ten_bcvxj2yf7q", + "app": { + "id": "app_DRBigxGEyr2BzW4T" + }, + "appDefinition": { + "id": "appdef_PRhebXCvtw", + "versionNumber": "0.0.1" + }, + "channel": "app_signals", + "message": { + "type": "v2.canvas.userInteracted", + "buttonId": "button_1", + "canvasId": "cnvs_1234", + "deprecated": false, + "excludedProperties": [] + } +} \ No newline at end of file diff --git a/examples/test-ui/tests/helpers.py b/examples/test-ui/tests/helpers.py new file mode 100644 index 0000000..43f0a7f --- /dev/null +++ b/examples/test-ui/tests/helpers.py @@ -0,0 +1,11 @@ +import json +from pathlib import Path + +from benchling_sdk.models.webhooks.v0 import WebhookEnvelopeV0 + + +def load_webhook_json(file_path: Path) -> WebhookEnvelopeV0: + assert file_path.is_file(), f"Missing webhook JSON file at {file_path}" + with file_path.open() as f: + webhook_dict = json.loads(f.read()) + return WebhookEnvelopeV0.from_dict(webhook_dict) diff --git a/examples/test-ui/tests/unit/__init__.py b/examples/test-ui/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/test-ui/tests/unit/local_app/__init__.py b/examples/test-ui/tests/unit/local_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/test-ui/tests/unit/local_app/benchling_app/__init__.py b/examples/test-ui/tests/unit/local_app/benchling_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/test-ui/tests/unit/local_app/benchling_app/test_canvas_interaction.py b/examples/test-ui/tests/unit/local_app/benchling_app/test_canvas_interaction.py new file mode 100644 index 0000000..e174f81 --- /dev/null +++ b/examples/test-ui/tests/unit/local_app/benchling_app/test_canvas_interaction.py @@ -0,0 +1,198 @@ + + +import json +from unittest.mock import MagicMock, patch + +import pytest +from benchling_sdk.apps.canvas.framework import CanvasBuilder +from benchling_sdk.apps.canvas.types import UiBlock +from benchling_sdk.apps.framework import App +from benchling_sdk.apps.status.errors import AppUserFacingError +from benchling_sdk.apps.status.framework import SessionContextManager +from benchling_sdk.apps.types import JsonType +from benchling_sdk.models import AppCanvas, AppCanvasUpdate, Molecule, TextInputUiBlock +from benchling_sdk.models.webhooks.v0 import CanvasInteractionWebhookV2 + +from local_app.benchling_app.canvas_interaction import UnsupportedButtonError, route_interaction_webhook +from local_app.benchling_app.views.canvas_initialize import input_blocks +from local_app.benchling_app.views.constants import ( + CANCEL_BUTTON_ID, + CID_KEY, + CREATE_BUTTON_ID, + SEARCH_BUTTON_ID, + SEARCH_TEXT_ID, +) + + +class TestCanvasInteraction: + + @patch("local_app.benchling_app.canvas_interaction.render_preview_canvas") + @patch("local_app.benchling_app.canvas_interaction.search") + def test_route_interaction_webhook_search(self, mock_search, mock_render_preview_canvas) -> None: + app = MagicMock(App) + interaction_webhook = _mock_interaction_webhook("canvas_id", SEARCH_BUTTON_ID) + mock_search_input = MagicMock(TextInputUiBlock) + mock_search_input.id = SEARCH_TEXT_ID + mock_search_input.value = "User Input" + mock_canvas = _mock_canvas([mock_search_input]) + mock_session_context_manager = MagicMock() + mock_session_context = MagicMock(SessionContextManager) + mock_session_context_manager.__enter__.return_value = mock_session_context + app.create_session_context.return_value = mock_session_context_manager + app.benchling.apps.get_canvas_by_id.return_value = mock_canvas + mock_search.return_value = {"cid": "example"} + expected_canvas_builder = CanvasBuilder.from_canvas(mock_canvas) + + # Test + route_interaction_webhook(app, interaction_webhook) + + # Verify + app.benchling.apps.get_canvas_by_id.assert_called_once_with("canvas_id") + mock_render_preview_canvas.assert_called_once_with( + {"cid": "example"}, + "canvas_id", + expected_canvas_builder, + mock_session_context, + ) + # Our sanitization will HTML encode the space + mock_search.assert_called_once_with("User%20Input") + mock_session_context.attach_canvas.assert_called_once_with("canvas_id") + + @patch("local_app.benchling_app.canvas_interaction.render_preview_canvas") + @patch("local_app.benchling_app.canvas_interaction.search") + def test_route_interaction_webhook_search_no_value(self, mock_search, mock_render_preview_canvas) -> None: + app = MagicMock(App) + interaction_webhook = _mock_interaction_webhook("canvas_id", SEARCH_BUTTON_ID) + mock_search_input = MagicMock(TextInputUiBlock) + mock_search_input.id = SEARCH_TEXT_ID + mock_search_input.value = "" + mock_canvas = _mock_canvas([mock_search_input]) + mock_session_context_manager = MagicMock() + mock_session_context = MagicMock(SessionContextManager) + mock_session_context_manager.__enter__.return_value = mock_session_context + app.create_session_context.return_value = mock_session_context_manager + app.benchling.apps.get_canvas_by_id.return_value = mock_canvas + + # Test + with pytest.raises(AppUserFacingError, match="Please enter a chemical name to search for"): + route_interaction_webhook(app, interaction_webhook) + + # Verify + app.benchling.apps.get_canvas_by_id.assert_called_once_with("canvas_id") + mock_render_preview_canvas.assert_not_called() + mock_search.assert_not_called() + mock_session_context.attach_canvas.assert_called_once_with("canvas_id") + + @patch("local_app.benchling_app.canvas_interaction.render_preview_canvas") + @patch("local_app.benchling_app.canvas_interaction.search") + def test_route_interaction_webhook_search_invalid_characters( + self, + mock_search, + mock_render_preview_canvas, + ) -> None: + app = MagicMock(App) + interaction_webhook = _mock_interaction_webhook("canvas_id", SEARCH_BUTTON_ID) + mock_search_input = MagicMock(TextInputUiBlock) + mock_search_input.id = SEARCH_TEXT_ID + mock_search_input.value = "<(Invalid #!)>" + mock_canvas = _mock_canvas([mock_search_input]) + mock_session_context_manager = MagicMock() + mock_session_context = MagicMock(SessionContextManager) + mock_session_context_manager.__enter__.return_value = mock_session_context + app.create_session_context.return_value = mock_session_context_manager + app.benchling.apps.get_canvas_by_id.return_value = mock_canvas + + # Test + with pytest.raises(AppUserFacingError, + match="The chemical name can only contain letters, numbers, spaces, and hyphens"): + route_interaction_webhook(app, interaction_webhook) + + # Verify + app.benchling.apps.get_canvas_by_id.assert_called_once_with("canvas_id") + mock_render_preview_canvas.assert_not_called() + mock_search.assert_not_called() + mock_session_context.attach_canvas.assert_called_once_with("canvas_id") + + def test_route_interaction_webhook_cancel(self) -> None: + app = MagicMock(App) + interaction_webhook = _mock_interaction_webhook("canvas_id", CANCEL_BUTTON_ID) + mock_canvas = _mock_canvas() + app.benchling.apps.get_canvas_by_id.return_value = mock_canvas + expected_canvas_builder = CanvasBuilder.from_canvas(mock_canvas)\ + .with_enabled()\ + .with_session_id(None)\ + .with_blocks(input_blocks()) + expected_update = expected_canvas_builder.to_update() + + # Test + route_interaction_webhook(app, interaction_webhook) + + # Verify + app.benchling.apps.get_canvas_by_id.assert_called_once_with("canvas_id") + app.benchling.apps.update_canvas.assert_called_once_with("canvas_id", expected_update) + + @patch("local_app.benchling_app.canvas_interaction.render_completed_canvas") + @patch("local_app.benchling_app.canvas_interaction.create_molecule") + @patch("local_app.benchling_app.canvas_interaction.get_by_cid") + def test_route_interaction_webhook_create_molecule( + self, + mock_get_by_cid, + mock_create_molecule, + mock_render_completed_canvas, + ) -> None: + app = MagicMock(App) + interaction_webhook = _mock_interaction_webhook("canvas_id", CREATE_BUTTON_ID) + mock_canvas = _mock_canvas(data={CID_KEY: "Test-CID"}) + mock_session_context_manager = MagicMock() + mock_session_context = MagicMock(SessionContextManager) + mock_session_context_manager.__enter__.return_value = mock_session_context + app.create_session_context.return_value = mock_session_context_manager + app.benchling.apps.get_canvas_by_id.return_value = mock_canvas + molecule_cid_data = {"CID": "Sample"} + mock_get_by_cid.return_value = molecule_cid_data + mock_molecule = MagicMock(Molecule) + mock_create_molecule.return_value = mock_molecule + expected_canvas_builder = CanvasBuilder.from_canvas(mock_canvas) + + # Test + route_interaction_webhook(app, interaction_webhook) + + # Verify + app.benchling.apps.get_canvas_by_id.assert_called_once_with("canvas_id") + mock_render_completed_canvas.assert_called_once_with( + mock_molecule, + "canvas_id", + expected_canvas_builder, + mock_session_context, + ) + mock_get_by_cid.assert_called_once_with("Test-CID") + mock_create_molecule.assert_called_once_with(app, molecule_cid_data) + mock_session_context.attach_canvas.assert_called_once_with("canvas_id") + + + def test_route_interaction_webhook_unspported_button(self) -> None: + app = MagicMock(App) + interaction_webhook = _mock_interaction_webhook("canvas_id", "not-a-button-in-our-canvas") + app.benchling.apps.get_canvas_by_id.return_value = MagicMock(AppCanvas) + expected_update = AppCanvasUpdate(enabled=True) + + # Test + with pytest.raises(UnsupportedButtonError): + route_interaction_webhook(app, interaction_webhook) + + # Verify + app.benchling.apps.update_canvas.assert_called_once_with("canvas_id", expected_update) + + +def _mock_canvas(blocks: list[UiBlock] | None = None, data: JsonType | None = None) -> AppCanvas: + mock_canvas = MagicMock(AppCanvas) + mock_canvas.blocks = blocks if blocks else [] + mock_canvas.data = json.dumps(data) if data is not None else None + return mock_canvas + + +def _mock_interaction_webhook(canvas_id: str, button_id: str) -> CanvasInteractionWebhookV2: + interaction_webhook = MagicMock(CanvasInteractionWebhookV2) + interaction_webhook.button_id = button_id + interaction_webhook.canvas_id = canvas_id + return interaction_webhook diff --git a/examples/test-ui/tests/unit/local_app/benchling_app/test_handler.py b/examples/test-ui/tests/unit/local_app/benchling_app/test_handler.py new file mode 100644 index 0000000..edeb65c --- /dev/null +++ b/examples/test-ui/tests/unit/local_app/benchling_app/test_handler.py @@ -0,0 +1,61 @@ +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest +from benchling_sdk.apps.framework import App +from benchling_sdk.apps.status.errors import AppUserFacingError + +from local_app.benchling_app.handler import UnsupportedWebhookError, handle_webhook +from tests.helpers import load_webhook_json + +_TEST_FILES_PATH = Path(__file__).parent.parent.parent.parent / "files/webhooks" + + +class TestWebhookHandler: + + @patch("local_app.benchling_app.handler.render_search_canvas") + @patch("local_app.benchling_app.handler.init_app_from_webhook") + def test_handle_webhook_canvas_initialize(self, mock_init_app_from_webhook, + mock_render_search_canvas) -> None: + webhook = load_webhook_json(_TEST_FILES_PATH / "canvas_initialize_webhook.json") + mock_app = MagicMock(App) + mock_init_app_from_webhook.return_value = mock_app + handle_webhook(webhook.to_dict()) + mock_render_search_canvas.assert_called_once_with(mock_app, webhook.message) + + @patch("local_app.benchling_app.handler.route_interaction_webhook") + @patch("local_app.benchling_app.handler.init_app_from_webhook") + def test_handle_webhook_canvas_interaction(self, + mock_init_app_from_webhook, + mock_route_interaction_webhook) -> None: + webhook = load_webhook_json(_TEST_FILES_PATH / "canvas_interaction_webhook.json") + mock_app = MagicMock(App) + mock_init_app_from_webhook.return_value = mock_app + handle_webhook(webhook.to_dict()) + mock_route_interaction_webhook.assert_called_once_with(mock_app, webhook.message) + + @patch("local_app.benchling_app.handler.init_app_from_webhook") + def test_handle_webhook_unsupported(self, mock_init_app_from_webhook) -> None: + webhook = load_webhook_json(_TEST_FILES_PATH / "app_activation_webhook.json") + mock_app = MagicMock(App) + mock_init_app_from_webhook.return_value = mock_app + with pytest.raises(UnsupportedWebhookError): + handle_webhook(webhook.to_dict()) + + @patch("local_app.benchling_app.handler.logger") + @patch("local_app.benchling_app.handler.render_search_canvas") + @patch("local_app.benchling_app.handler.init_app_from_webhook") + def test_handle_webhook_user_error(self, + mock_init_app_from_webhook, + mock_render_search_canvas, + mock_logger) -> None: + webhook = load_webhook_json(_TEST_FILES_PATH / "canvas_initialize_webhook.json") + mock_app = MagicMock(App) + mock_init_app_from_webhook.return_value = mock_app + mock_error = AppUserFacingError("Client error") + mock_render_search_canvas.side_effect = [mock_error] + handle_webhook(webhook.to_dict()) + mock_logger.debug.assert_has_calls( + [call("Exiting with client error: %s", mock_error)], + any_order=True, + ) diff --git a/examples/test-ui/tests/unit/local_app/benchling_app/test_molecules.py b/examples/test-ui/tests/unit/local_app/benchling_app/test_molecules.py new file mode 100644 index 0000000..3123186 --- /dev/null +++ b/examples/test-ui/tests/unit/local_app/benchling_app/test_molecules.py @@ -0,0 +1,77 @@ +from pathlib import Path +from unittest.mock import MagicMock + +from benchling_sdk.apps.config.mock_config import MockConfigItemStore +from benchling_sdk.apps.framework import App +from benchling_sdk.apps.helpers.manifest_helpers import manifest_from_file +from benchling_sdk.helpers.serialization_helpers import fields +from benchling_sdk.models import ( + GenericApiIdentifiedAppConfigItem, + GenericApiIdentifiedAppConfigItemType, + Molecule, + MoleculeCreate, + MoleculeStructure, + MoleculeStructureStructureFormat, +) + +from local_app.benchling_app.molecules import create_molecule + + +class TestMolecules: + def test_create_molecule(self) -> None: + # Setup mocks + app = MagicMock(App) + manifest = manifest_from_file(Path(__file__).parent.parent.parent.parent.parent / "manifest.yaml") + # This will mock all config items with random valid values + # We can override particular configs if desired. This shows an example of overriding a folder config + mock_config_store = MockConfigItemStore.from_manifest(manifest).with_replacement( + GenericApiIdentifiedAppConfigItem( + path=["Sync Folder"], + value="set_folder_id", + type=GenericApiIdentifiedAppConfigItemType.FOLDER), + ) + app.config_store = mock_config_store + chemical_result = { + "cid": "cid_value", + "smiles": "smiles_value", + "name": "chemical_name", + "molecularWeight": 0.123, + "monoisotopic": 1.456, + } + mock_molecule = MagicMock(Molecule) + app.benchling.molecules.create.return_value = mock_molecule + + # Expected values + molecule_structure = MoleculeStructure( + structure_format=MoleculeStructureStructureFormat.SMILES, + value="smiles_value", + ) + # These are generated as random valid values unless manually overridden + # Since we didn't override them but we're verifying the entire MoleculeCreate stub for correctness, + # pull the mocked values from the mock_config_store + weight_field_config_value = mock_config_store\ + .config_by_path(["Molecule Schema", "Molecular Weight"])\ + .required().value_str() + mono_isotopic_field_config_value = mock_config_store\ + .config_by_path(["Molecule Schema", "MonoIsotopic"])\ + .required().value_str() + schema_id = mock_config_store.config_by_path(["Molecule Schema"]).required().value_str() + expected_argument = MoleculeCreate( + chemical_structure=molecule_structure, + name="chemical_name", + aliases=["cid:cid_value"], + # We manually set this one instead of a random mocked value, as an example + folder_id="set_folder_id", + schema_id=schema_id, + fields=fields( + { + weight_field_config_value: {"value": 0.123}, + mono_isotopic_field_config_value: {"value": 1.456}, + }, + ), + ) + + # Verify + result = create_molecule(app, chemical_result) + assert mock_molecule == result + app.benchling.molecules.create.assert_called_once_with(expected_argument) diff --git a/examples/test-ui/tests/unit/local_app/benchling_app/test_setup.py b/examples/test-ui/tests/unit/local_app/benchling_app/test_setup.py new file mode 100644 index 0000000..c739b2a --- /dev/null +++ b/examples/test-ui/tests/unit/local_app/benchling_app/test_setup.py @@ -0,0 +1,49 @@ +from pathlib import Path + +import pytest +from benchling_sdk.apps.framework import App + +from local_app.benchling_app.setup import _auth_method, app_definition_id, init_app_from_webhook +from tests.helpers import load_webhook_json + +_TEST_FILES_PATH = Path(__file__).parent.parent.parent.parent / "files/webhooks" + + +class TestBenchlingAppSetup: + + def setup_method(self) -> None: + _auth_method.cache_clear() + app_definition_id.cache_clear() + + def test_init_app_from_webhook(self, monkeypatch) -> None: + webhook = load_webhook_json(_TEST_FILES_PATH / "canvas_initialize_webhook.json") + with monkeypatch.context() as context: + context.setenv("CLIENT_ID", "clientId") + context.setenv("CLIENT_SECRET_FILE", str(_TEST_FILES_PATH.parent / "test_client_secret")) + result = init_app_from_webhook(webhook) + assert isinstance(result, App) + + def test_init_app_from_webhook_missing_client_id(self, monkeypatch) -> None: + webhook = load_webhook_json(_TEST_FILES_PATH / "canvas_initialize_webhook.json") + with monkeypatch.context() as context: + context.setenv("CLIENT_SECRET_FILE", str(_TEST_FILES_PATH.parent / "test_client_secret")) + with pytest.raises(AssertionError, match="Missing CLIENT_ID from environment"): + init_app_from_webhook(webhook) + + def test_init_app_from_webhook_missing_client_secret_file(self, monkeypatch) -> None: + webhook = load_webhook_json(_TEST_FILES_PATH / "canvas_initialize_webhook.json") + with monkeypatch.context() as context: + context.setenv("CLIENT_ID", "clientId") + with pytest.raises(AssertionError, match="Missing CLIENT_SECRET_FILE from environment"): + init_app_from_webhook(webhook) + + def test_app_definition_id(self, monkeypatch) -> None: + with monkeypatch.context() as context: + context.setenv("APP_DEFINITION_ID", "app_def1234") + result = app_definition_id() + assert result == "app_def1234" + + def test_app_definition_id_missing_app_definition_id(self, monkeypatch) -> None: + with (monkeypatch.context(), + pytest.raises(AssertionError, match="Missing APP_DEFINITION_ID from environment")): + app_definition_id() diff --git a/examples/test-ui/tests/unit/local_app/benchling_app/views/__init__.py b/examples/test-ui/tests/unit/local_app/benchling_app/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/test-ui/tests/unit/local_app/benchling_app/views/test_canvas_initialize.py b/examples/test-ui/tests/unit/local_app/benchling_app/views/test_canvas_initialize.py new file mode 100644 index 0000000..e58b201 --- /dev/null +++ b/examples/test-ui/tests/unit/local_app/benchling_app/views/test_canvas_initialize.py @@ -0,0 +1,29 @@ +from unittest.mock import MagicMock + +from benchling_sdk.apps.canvas.framework import CanvasBuilder +from benchling_sdk.apps.framework import App +from benchling_sdk.models.webhooks.v0 import CanvasInitializeWebhookV2 + +from local_app.benchling_app.views.canvas_initialize import input_blocks, render_search_canvas + + +class TestCanvasInitialize: + + def test_render_search_canvas(self) -> None: + initialize_webhook = MagicMock(CanvasInitializeWebhookV2) + initialize_webhook.feature_id = "feature_id" + initialize_webhook.resource_id = "resource_id" + app = MagicMock(App) + app.id = "app_id" + expected_canvas_builder = CanvasBuilder( + app_id="app_id", + feature_id="feature_id", + resource_id="resource_id", + blocks=input_blocks(), + ) + + # Test + render_search_canvas(app, initialize_webhook) + + # Verify + app.benchling.apps.create_canvas.assert_called_once_with(expected_canvas_builder.to_create()) diff --git a/examples/test-ui/tests/unit/local_app/benchling_app/views/test_chemical_preview.py b/examples/test-ui/tests/unit/local_app/benchling_app/views/test_chemical_preview.py new file mode 100644 index 0000000..cb24dcd --- /dev/null +++ b/examples/test-ui/tests/unit/local_app/benchling_app/views/test_chemical_preview.py @@ -0,0 +1,127 @@ +from unittest.mock import MagicMock, patch + +from benchling_sdk.apps.canvas.framework import CanvasBuilder +from benchling_sdk.apps.canvas.types import UiBlock +from benchling_sdk.apps.status.framework import SessionContextManager +from benchling_sdk.models import ( + AppCanvas, + AppSessionMessageCreate, + AppSessionMessageStyle, + AppSessionUpdateStatus, + ButtonUiBlock, + ButtonUiBlockType, + MarkdownUiBlock, + MarkdownUiBlockType, + SectionUiBlock, + SectionUiBlockType, +) + +from local_app.benchling_app.views.canvas_initialize import input_blocks +from local_app.benchling_app.views.chemical_preview import render_preview_canvas +from local_app.benchling_app.views.constants import ( + CANCEL_BUTTON_ID, + CID_KEY, + CREATE_BUTTON_ID, + SEARCH_TEXT_ID, +) + + +class TestChemicalPreview: + + @patch("local_app.benchling_app.views.chemical_preview.image_url") + def test_render_preview_canvas_with_results(self, mock_image_url) -> None: + mock_session = MagicMock(SessionContextManager) + results = [ + { + "name": "Test Chemical", + "smiles": "010101", + "cid": "test_cid", + }, + ] + mock_image_url.return_value = "https://images.benchling.com" + mock_expected_canvas = MagicMock(AppCanvas) + expected_canvas_builder = CanvasBuilder.from_canvas(mock_expected_canvas)\ + .with_enabled()\ + .with_data({CID_KEY: "test_cid"})\ + .with_blocks(_expected_preview_blocks()) + + # Test + render_preview_canvas(results, "canvas_id", expected_canvas_builder, mock_session) + + # Verify + mock_session.app.benchling.apps.update_canvas.assert_called_once_with( + "canvas_id", + expected_canvas_builder.to_update(), + ) + + def test_render_preview_canvas_no_results(self) -> None: + mock_session = MagicMock(SessionContextManager) + original_canvas = MagicMock(AppCanvas) + original_canvas_builder = CanvasBuilder.from_canvas(original_canvas).with_blocks(input_blocks()) + # Set an input value + original_canvas_builder.blocks.get_by_id(SEARCH_TEXT_ID).to_api_model().value = "User Input" + expected_canvas_builder = CanvasBuilder.from_canvas(original_canvas)\ + .with_enabled()\ + .with_blocks(input_blocks()) + + + # Test + render_preview_canvas(None, "canvas_id", original_canvas_builder, mock_session) + + # Verify + mock_session.app.benchling.apps.update_canvas.assert_called_once_with( + "canvas_id", + expected_canvas_builder.to_update(), + ) + mock_session.close_session.assert_called_once_with( + AppSessionUpdateStatus.SUCCEEDED, + messages=[ + AppSessionMessageCreate( + "Couldn't find any chemicals for 'User Input'", + style=AppSessionMessageStyle.INFO, + ), + ], + ) + + +def _expected_preview_blocks() -> list[UiBlock]: + return [ + MarkdownUiBlock( + id="results", + type=MarkdownUiBlockType.MARKDOWN, + value="We found the following chemical based on your search:", + ), + MarkdownUiBlock( + id="chemical_preview", + type=MarkdownUiBlockType.MARKDOWN, + value=( + "**Name**: Test Chemical\n\n**Structure**: 010101" + ), + ), + MarkdownUiBlock( + id="chemical_image", + type=MarkdownUiBlockType.MARKDOWN, + value="![Test Chemical](https://images.benchling.com)", + ), + MarkdownUiBlock( + id="user_prompt", + type=MarkdownUiBlockType.MARKDOWN, + value="Would you like to create it in Benchling?", + ), + SectionUiBlock( + id="preview_buttons", + type=SectionUiBlockType.SECTION, + children=[ + ButtonUiBlock( + id=CREATE_BUTTON_ID, + text="Create Molecule", + type=ButtonUiBlockType.BUTTON, + ), + ButtonUiBlock( + id=CANCEL_BUTTON_ID, + text="Cancel", + type=ButtonUiBlockType.BUTTON, + ), + ], + ), + ] diff --git a/examples/test-ui/tests/unit/local_app/benchling_app/views/test_completed.py b/examples/test-ui/tests/unit/local_app/benchling_app/views/test_completed.py new file mode 100644 index 0000000..a204c62 --- /dev/null +++ b/examples/test-ui/tests/unit/local_app/benchling_app/views/test_completed.py @@ -0,0 +1,59 @@ +from unittest.mock import MagicMock, patch + +from benchling_sdk.apps.canvas.framework import CanvasBuilder +from benchling_sdk.apps.canvas.types import UiBlock +from benchling_sdk.apps.status.framework import SessionContextManager +from benchling_sdk.models import ( + AppCanvas, + AppSessionMessageCreate, + AppSessionMessageStyle, + AppSessionUpdateStatus, + MarkdownUiBlock, + MarkdownUiBlockType, + Molecule, +) + +from local_app.benchling_app.views.completed import render_completed_canvas + + +class TestCompleted: + + @patch("local_app.benchling_app.views.completed.ref") + def test_render_completed_canvas(self, mock_ref) -> None: + mock_session = MagicMock(SessionContextManager) + molecule = MagicMock(Molecule) + mock_ref.return_value = "(reference)" + mock_canvas = MagicMock(AppCanvas) + canvas_builder = CanvasBuilder.from_canvas(mock_canvas) + expected_canvas_builder = CanvasBuilder.from_canvas(mock_canvas)\ + .with_blocks(_expected_completed_blocks())\ + .with_enabled() + + # Test + render_completed_canvas(molecule, "canvas_id", canvas_builder, mock_session) + + # Verify + mock_session.app.benchling.apps.update_canvas.assert_called_once_with( + "canvas_id", + expected_canvas_builder.to_update(), + ) + mock_session.close_session.assert_called_once_with( + AppSessionUpdateStatus.SUCCEEDED, + messages=[ + AppSessionMessageCreate( + "Created the molecule (reference) in Benchling!", + style=AppSessionMessageStyle.SUCCESS, + ), + ], + ) + mock_ref.assert_called_once_with(molecule) + + +def _expected_completed_blocks() -> list[UiBlock]: + return [ + MarkdownUiBlock( + id="completed", + type=MarkdownUiBlockType.MARKDOWN, + value="The chemical has been synced into Benchling! Please follow procedures for next steps.", + ), + ] diff --git a/examples/test-ui/tests/unit/local_app/lib/__init__.py b/examples/test-ui/tests/unit/local_app/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/test-ui/tests/unit/local_app/lib/test_logger.py b/examples/test-ui/tests/unit/local_app/lib/test_logger.py new file mode 100644 index 0000000..6a0c1f0 --- /dev/null +++ b/examples/test-ui/tests/unit/local_app/lib/test_logger.py @@ -0,0 +1,17 @@ +import logging + +from local_app.lib.logger import get_logger + + +class TestLogger: + def test_logger_with_name(self) -> None: + logger = get_logger("test-logger") + assert logger.name == "test-logger" + assert logger.level == logging.INFO + + def test_logger_with_level(self, monkeypatch) -> None: + with monkeypatch.context() as context: + context.setenv("BENCHLING_APP_LOG_LEVEL", "DEBUG") + logger = get_logger() + assert logger.name == "benchling-app" + assert logger.level == logging.DEBUG diff --git a/examples/test-ui/tests/unit/local_app/lib/test_pub_chem.py b/examples/test-ui/tests/unit/local_app/lib/test_pub_chem.py new file mode 100644 index 0000000..fd423d2 --- /dev/null +++ b/examples/test-ui/tests/unit/local_app/lib/test_pub_chem.py @@ -0,0 +1,66 @@ +import json +from pathlib import Path +from unittest.mock import call, patch + +from httpx import Response + +from local_app.lib.pub_chem import _pubchem_get, get_by_cid, image_url, search + +_TEST_FILES_PATH = Path(__file__).parent.parent.parent.parent / "files/pubchem" + + +class TestPubChem: + + def setup_method(self) -> None: + _pubchem_get.cache_clear() + + @patch("local_app.lib.pub_chem.get_by_cid") + @patch("local_app.lib.pub_chem.httpx") + def test_search(self, mock_httpx, mock_get_by_cid) -> None: + response_json = _load_pubchem_json(_TEST_FILES_PATH / "search.json") + mock_httpx.get.return_value = _mock_httpx_json_response(response_json) + mock_get_by_cid.return_value = { + "cid": 2244, + } + result = search("search_cid") + assert [{"cid": 2244}] == result + mock_httpx.get.assert_called_once_with("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/search_cid/cids/JSON?MaxRecords=1") + mock_get_by_cid.assert_called_once_with(2244) + + @patch("local_app.lib.pub_chem.httpx") + def test_search_no_results(self, mock_httpx) -> None: + mock_httpx.get.return_value = _mock_httpx_json_response({}) + result = search("search_cid") + assert [] == result + mock_httpx.get.assert_called_once_with("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/search_cid/cids/JSON?MaxRecords=1") + + @patch("local_app.lib.pub_chem.httpx") + def test_get_by_cid(self, mock_httpx) -> None: + mock_httpx.get.side_effect = [ + _mock_httpx_json_response(_load_pubchem_json(_TEST_FILES_PATH / "compound.json")), + _mock_httpx_json_response(_load_pubchem_json(_TEST_FILES_PATH / "synonyms.json")), + ] + result = get_by_cid("test_cid") + assert {"cid": "test_cid", + "molecularWeight": "180.16", + "monoisotopic": "180.04225873", + "name": "aspirin", + "smiles": "CC(=O)OC1=CC=CC=C1C(=O)O"} == result + mock_httpx.get.assert_has_calls([ + call("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/test_cid/JSON"), + call("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/test_cid/synonyms/JSON"), + ]) + + def test_image_url(self) -> None: + result = image_url("CID_1234") + assert result == "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/CID_1234/PNG" + + +def _load_pubchem_json(file_path: Path) -> dict: + assert file_path.is_file(), f"Missing pubchem JSON file at {file_path}" + with file_path.open() as f: + return json.loads(f.read()) + + +def _mock_httpx_json_response(response_json: dict) -> Response: + return Response(200, json=response_json) diff --git a/examples/test-ui/tests/unit/local_app/test_app.py b/examples/test-ui/tests/unit/local_app/test_app.py new file mode 100644 index 0000000..a027d0c --- /dev/null +++ b/examples/test-ui/tests/unit/local_app/test_app.py @@ -0,0 +1,41 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest +from flask import Flask +from flask.testing import FlaskClient + +from local_app.app import create_app +from tests.helpers import load_webhook_json + +_TEST_FILES_PATH = Path(__file__).parent.parent.parent / "files/webhooks" + + +@pytest.fixture() +def app() -> Flask: + app = create_app() + app.config.update({ + "TESTING": True, + }) + return app + + +@pytest.fixture() +def client(app: Flask) -> FlaskClient: + return app.test_client() + + +class TestApp: + + @patch("local_app.app._enqueue_work") + @patch("local_app.app.app_definition_id") + @patch("local_app.app.verify") + def test_app_receive_webhook( + self, mock_verify, mock_app_definition_id, mock_enqueue_work, client, + ) -> None: + webhook = load_webhook_json(_TEST_FILES_PATH / "canvas_initialize_webhook.json") + response = client.post("1/webhooks/canvas", json=webhook.to_dict()) + assert response.status_code == 200 + mock_verify.assert_called_once() + mock_app_definition_id.assert_called_once() + mock_enqueue_work.assert_called_once()