From 76e59c489704e93978aa0da979b5be22c2df431c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 30 Sep 2026 17:05:53 +0200 Subject: [PATCH] feat(plugins): add the surfsense-plugins command for plugin authors --- .../workflows/plugins-pull-request-checks.yml | 44 +++ .vscode/settings.json | 3 +- AGENTS.md | 4 +- SurfSense.code-workspace | 12 + .../plugins/cli/01-author-commands.md | 5 +- plugins/.gitignore | 4 +- plugins/README.md | 110 +++++++ plugins/core/build-targets.json | 8 + plugins/core/cli/pyproject.toml | 66 ++++ .../core/cli/surfsense_plugin_cli/__init__.py | 1 + .../core/cli/surfsense_plugin_cli/__main__.py | 5 + .../cli/surfsense_plugin_cli/build_targets.py | 41 +++ .../dependencies/__init__.py | 1 + .../surfsense_plugin_cli/dependencies/add.py | 32 ++ .../dependencies/listed.py | 16 + .../dependencies/pin_dependencies.py | 30 ++ .../dependencies/remove.py | 31 ++ .../dependencies/rewrite_and_pin.py | 84 +++++ .../surfsense_plugin_cli/invoke/__init__.py | 1 + .../invoke/app/__init__.py | 1 + .../invoke/app/app_api.py | 23 ++ .../invoke/app/choose_workspace.py | 43 +++ .../invoke/app/find_app.py | 33 ++ .../surfsense_plugin_cli/invoke/command.py | 55 ++++ .../invoke/plugin/__init__.py | 1 + .../invoke/plugin/dependencies.py | 43 +++ .../invoke/plugin/plugin_environment.py | 49 +++ .../invoke/plugin/run_plugin.py | 39 +++ .../invoke/plugin/secrets.py | 28 ++ .../invoke/request/__init__.py | 1 + .../invoke/request/declared_action.py | 19 ++ .../invoke/request/inputs.py | 57 ++++ plugins/core/cli/surfsense_plugin_cli/main.py | 23 ++ .../cli/surfsense_plugin_cli/new/__init__.py | 1 + .../cli/surfsense_plugin_cli/new/command.py | 63 ++++ .../cli/surfsense_plugin_cli/new/template.py | 51 +++ .../packaging/__init__.py | 1 + .../packaging/install_requirements.py | 53 +++ .../cli/surfsense_plugin_cli/plugin_folder.py | 32 ++ .../cli/surfsense_plugin_cli/repository.py | 7 + .../cli/surfsense_plugin_cli/reserved_ids.py | 11 + plugins/core/cli/tests/conftest.py | 271 ++++++++++++++++ .../invoke/test_choosing_the_workspace.py | 48 +++ .../core/cli/tests/invoke/test_data_folder.py | 38 +++ .../cli/tests/invoke/test_dependencies.py | 111 +++++++ .../core/cli/tests/invoke/test_feedback.py | 65 ++++ .../cli/tests/invoke/test_finding_the_app.py | 82 +++++ plugins/core/cli/tests/invoke/test_inputs.py | 87 +++++ plugins/core/cli/tests/invoke/test_running.py | 58 ++++ plugins/core/cli/tests/invoke/test_secrets.py | 62 ++++ plugins/core/cli/tests/test_build_targets.py | 27 ++ plugins/core/cli/tests/test_dependencies.py | 159 +++++++++ plugins/core/cli/tests/test_example.py | 51 +++ plugins/core/cli/tests/test_new.py | 108 +++++++ plugins/core/cli/uv.lock | 303 ++++++++++++++++++ plugins/core/policy/reserved-plugin-ids.txt | 1 + plugins/example/example/__init__.py | 2 + plugins/example/example/count_words.py | 26 ++ plugins/example/example/notes_added.py | 11 + plugins/example/main.py | 6 + plugins/example/manifest.json | 18 ++ plugins/example/requirements.in | 1 + plugins/example/requirements.txt | 6 + .../src/main/api-url/announce-api-url.test.ts | 30 ++ .../src/main/api-url/announce-api-url.ts | 17 + surfsense_local/electron/src/main/index.ts | 3 + 66 files changed, 2718 insertions(+), 5 deletions(-) create mode 100644 plugins/README.md create mode 100644 plugins/core/build-targets.json create mode 100644 plugins/core/cli/pyproject.toml create mode 100644 plugins/core/cli/surfsense_plugin_cli/__init__.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/__main__.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/build_targets.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/dependencies/__init__.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/dependencies/add.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/dependencies/listed.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/dependencies/pin_dependencies.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/dependencies/remove.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/dependencies/rewrite_and_pin.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/__init__.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/app/__init__.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/app/app_api.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/app/choose_workspace.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/app/find_app.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/command.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/plugin/__init__.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/plugin/dependencies.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/plugin/plugin_environment.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/plugin/run_plugin.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/plugin/secrets.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/request/__init__.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/request/declared_action.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/invoke/request/inputs.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/main.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/new/__init__.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/new/command.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/new/template.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/packaging/__init__.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/packaging/install_requirements.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/plugin_folder.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/repository.py create mode 100644 plugins/core/cli/surfsense_plugin_cli/reserved_ids.py create mode 100644 plugins/core/cli/tests/conftest.py create mode 100644 plugins/core/cli/tests/invoke/test_choosing_the_workspace.py create mode 100644 plugins/core/cli/tests/invoke/test_data_folder.py create mode 100644 plugins/core/cli/tests/invoke/test_dependencies.py create mode 100644 plugins/core/cli/tests/invoke/test_feedback.py create mode 100644 plugins/core/cli/tests/invoke/test_finding_the_app.py create mode 100644 plugins/core/cli/tests/invoke/test_inputs.py create mode 100644 plugins/core/cli/tests/invoke/test_running.py create mode 100644 plugins/core/cli/tests/invoke/test_secrets.py create mode 100644 plugins/core/cli/tests/test_build_targets.py create mode 100644 plugins/core/cli/tests/test_dependencies.py create mode 100644 plugins/core/cli/tests/test_example.py create mode 100644 plugins/core/cli/tests/test_new.py create mode 100644 plugins/core/cli/uv.lock create mode 100644 plugins/core/policy/reserved-plugin-ids.txt create mode 100644 plugins/example/example/__init__.py create mode 100644 plugins/example/example/count_words.py create mode 100644 plugins/example/example/notes_added.py create mode 100644 plugins/example/main.py create mode 100644 plugins/example/manifest.json create mode 100644 plugins/example/requirements.in create mode 100644 plugins/example/requirements.txt create mode 100644 surfsense_local/electron/src/main/api-url/announce-api-url.test.ts create mode 100644 surfsense_local/electron/src/main/api-url/announce-api-url.ts diff --git a/.github/workflows/plugins-pull-request-checks.yml b/.github/workflows/plugins-pull-request-checks.yml index 09b555f94..45529a65c 100644 --- a/.github/workflows/plugins-pull-request-checks.yml +++ b/.github/workflows/plugins-pull-request-checks.yml @@ -25,6 +25,7 @@ jobs: manifest_rules_changed: ${{ steps.changed_paths.outputs.manifest_rules_changed }} sdk_changed: ${{ steps.changed_paths.outputs.sdk_changed }} sdk_or_app_changed: ${{ steps.changed_paths.outputs.sdk_or_app_changed }} + cli_changed: ${{ steps.changed_paths.outputs.cli_changed }} steps: - name: Checkout code @@ -48,6 +49,12 @@ jobs: - 'plugins/core/sdk/**' - 'surfsense_local/backend/**' - '.github/workflows/plugins-pull-request-checks.yml' + # invoke runs the SDK and the example against the real app. + cli_changed: + - 'plugins/core/**' + - 'plugins/example/**' + - 'surfsense_local/backend/**' + - '.github/workflows/plugins-pull-request-checks.yml' check-manifest-rules: name: Check manifest rules @@ -148,3 +155,40 @@ jobs: - name: Run contract tests working-directory: plugins/core/sdk run: uv run pytest -m contract + + check-cli: + name: Check CLI + runs-on: ubuntu-latest + needs: detect-changes + if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.cli_changed == 'true' + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup uv + uses: astral-sh/setup-uv@v10.2.0 + with: + enable-cache: true + python-version: '3.12' + + # invoke's tests run plugins against the real backend. + - name: Install the app's dependencies + working-directory: surfsense_local/backend + run: uv sync + + - name: Install dependencies + working-directory: plugins/core/cli + run: uv sync + + - name: Lint + working-directory: plugins/core/cli + run: uv run ruff check . + + - name: Check formatting + working-directory: plugins/core/cli + run: uv run ruff format --check . + + - name: Run tests + working-directory: plugins/core/cli + run: uv run pytest diff --git a/.vscode/settings.json b/.vscode/settings.json index 1a40d8e6c..8c41e3a40 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,8 @@ "deepscan.ignoreConfirmWarning": true, "python.defaultInterpreterPath": "${workspaceFolder}/surfsense_local/backend/.venv/bin/python", "basedpyright.analysis.extraPaths": [ - "${workspaceFolder}/surfsense_local/backend" + "${workspaceFolder}/surfsense_local/backend", + "${workspaceFolder}/plugins/core/sdk" ], "python-envs.pythonProjects": [] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index b40f2050c..c3d7fa13f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,10 +66,11 @@ cd surfsense_local/electron && pnpm dev # MCP cd surfsense_mcp && uv sync -# plugins: the manifest rules, the SDK +# plugins: the manifest rules, the SDK, the surfsense-plugins command cd plugins/core/manifest && uv sync cd plugins/core/sdk && uv sync cd plugins/core/sdk && uv run pyright +cd plugins/core/cli && uv sync # compose (dev and self-host, not production) docker compose -f docker/docker-compose.yml @@ -90,6 +91,7 @@ pre-commit run --all-files | Plugins: manifest rules | `cd plugins/core/manifest && uv run pytest -m unit` | | Plugins: SDK | `cd plugins/core/sdk && uv run pytest -m unit` | | Plugins: SDK against the app | `cd plugins/core/sdk && uv run pytest -m contract` (starts the backend; `uv sync` it first) | +| Plugins: CLI | `cd plugins/core/cli && uv run pytest` (starts the backend; `uv sync` it first) | | Desktop | `cd surfsense_local/electron && pnpm test` | CI: `.github/workflows/`. New behavior: one failing test, then the minimum code to pass it. Use the `tdd` skill. Tests hit public seams, not internals. diff --git a/SurfSense.code-workspace b/SurfSense.code-workspace index 443cfdfd0..89b69cec1 100644 --- a/SurfSense.code-workspace +++ b/SurfSense.code-workspace @@ -7,6 +7,18 @@ { "name": "local backend", "path": "surfsense_local/backend" + }, + { + "name": "plugin CLI", + "path": "plugins/core/cli" + }, + { + "name": "plugin SDK", + "path": "plugins/core/sdk" + }, + { + "name": "plugin manifest rules", + "path": "plugins/core/manifest" } ], "settings": {} diff --git a/docs/proposals/plugins/cli/01-author-commands.md b/docs/proposals/plugins/cli/01-author-commands.md index 3e3cb7c73..8978f3d84 100644 --- a/docs/proposals/plugins/cli/01-author-commands.md +++ b/docs/proposals/plugins/cli/01-author-commands.md @@ -22,17 +22,18 @@ An author goes from nothing to a plugin running in their own SurfSense in a few - `plugins/core/cli/pyproject.toml`: the package `surfsense_plugin_cli`, and the command `surfsense-plugins` through `[project.scripts]`, built with Typer. Its Python is pinned to the version in `plugins/core/build-targets.json`, because `invoke` runs plugins on it. It depends on the manifest rules by path, and on the `uv` package, so every machine pins and installs with the same `uv`. - One folder per job in the package: `new/`, `dependencies/` for `add`, `remove` and `pin-dependencies`, `invoke/`, and later `packaging/`, `check/`, `audit/`, `release/` and `directory_site/`. What several jobs share sits beside them: `plugin_folder.py` finds a plugin's folder and loads its manifest through the rules, and `build_targets.py` reads `build-targets.json`. -- Output a person can act on. An error says what to do next and never shows a traceback. Colours when a person reads the terminal, plain text when a machine does. Every command's `--help` shows an example. +- Output a person can act on. Every command says what it did, so a success is never silent. An error says what to do next and never shows a traceback. Colours when a person reads the terminal, plain text when a machine does. Every command's `--help` shows an example. - `new ` refuses an id that breaks the id rule, is taken, or is reserved, and says which. Otherwise it lays down `plugins//`: a `manifest.json` with the id, a name and description to edit, `author` from `git config user.name`, `access: free`, `hosts: []` and one action with one text input; a `main.py` that imports the action from a package named after the plugin, such as `hn_search/`. The result runs with `invoke` unchanged. - `add …` and `remove …` edit `requirements.in`, which stays the short list of what the plugin asked for and reads well in review. All three dependency commands then pin through one function, running the protocol's `uv pip compile` command, and keep the versions already pinned, so adding one library upgrades nothing else. - `invoke [--input name=value]… [--workspace id] [--api-url url]`: - Loads the manifest through the rules, and refuses an action it does not declare. - Converts each `--input` to its declared kind: a `number` to `int` or `float`, a `boolean` from `true` or `false`. It refuses an unknown name, a value of the wrong kind, and a missing required input, as the app's run route does. - Finds the app at `--api-url`, else `SURFSENSE_PLUGIN_API_URL`, else `~/.surfsense-dev/api-url`, which a development app writes, else `~/.surfsense/api-url`, which a packaged app writes. When none answers: "SurfSense is not running: start it, or pass --api-url". - - Writes to `--workspace`. Without it, to the app's only workspace; with several, it lists their ids and names and runs nothing. + - Writes to `--workspace`. Without it, to the app's only workspace; with several, or with an id the app does not have, it lists their ids and names and runs nothing. - Takes each declared secret from the author's own `SURFSENSE_PLUGIN_SECRET_`, else from a hidden prompt, never from a flag, so secrets stay out of shell history. - Installs `requirements.txt` into `plugins//site-packages/` for this machine's platform through packaging's install step, `install_requirements(plugin, platform, into)`, so an author's machine installs what a release installs. It skips the install when `requirements.txt` has not changed since the last one. A dependency with no prebuilt wheel for this platform fails, naming the package and the platform. - Gives the plugin `plugins//dev-data/` as its data folder: git-ignored, kept between invokes, and never mixed with a copy installed in the app. + - Says on stderr which action runs, in which workspace and at which address, then `Done.` or `Failed: exit .`, so the plugin's own output stays the command's only output. - Runs `python -m surfsense_plugin_sdk.run` on the CLI's own Python, with the environment the app's runner builds, streams the plugin's log to the terminal, and exits with its code. It sets no run id, so a note's provenance names no run. It does not intercept HTTP and does not stub the app. - Runs outside the app, so the app's egress consent does not apply. The SDK's `http` still refuses a host the manifest does not declare. - `plugins/example/`, a word counter. `access: free`, `hosts: []`, no secrets. One action, `count-words`, with a required string input `text` and an optional number input `top`, 10 when empty. It depends on `tabulate`, which is pure Python, so it packages into one `any` file. It adds a note titled "Word count #N" holding the text and a table of its `top` most frequent words, and counts N in `data()`. Its `main.py` imports only the SDK and its own package. The other streams' tests run it, and the guide points at it rather than pasting a second copy. It is not a product plugin. diff --git a/plugins/.gitignore b/plugins/.gitignore index 641f11cf2..d4f918b81 100644 --- a/plugins/.gitignore +++ b/plugins/.gitignore @@ -1,5 +1,7 @@ __pycache__/ .pytest_cache/ .ruff_cache/ -# The harness installs a plugin's dependencies here; packaging builds its own. +# surfsense-plugins invoke installs a plugin's dependencies here, and gives it +# dev-data as its data folder; packaging builds its own. /*/site-packages/ +/*/dev-data/ diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 000000000..ee3dfe0a3 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,110 @@ +# Plugins + +A plugin adds something to SurfSense: a sidebar action that fetches, converts or writes documents into the user's workspace. Each plugin is a folder here, `plugins//`, and you add one with a pull request. It ships with the next SurfSense release, and users install it from the app. + +Thank you for writing one. This guide takes you from nothing to a plugin running in your own SurfSense, then covers what to know before opening the pull request. [`example/`](example/) is a complete plugin to read alongside it: it counts the words in a text, uses a library, and remembers how many notes it has added. + +## Your first plugin + +Once, from the repository root, install the command every step uses: + +``` +uv tool install --editable plugins/core/cli +``` + +If your shell cannot find `surfsense-plugins` afterwards, uv printed the folder it installed into: add that folder to your `PATH`. This happens in the terminal of an editor installed as a snap. You can also skip the install and put `uv run --project plugins/core/cli` in front of every command. + +Then: + +| Step | Command | +|---|---| +| Start a plugin | `surfsense-plugins new hn-search` | +| Add a library it needs | `surfsense-plugins add hn-search requests` | +| Drop one it no longer needs | `surfsense-plugins remove hn-search requests` | +| Try it in your SurfSense | `surfsense-plugins invoke hn-search add-note --input text="Hello"` | + +`new` lays out a plugin that runs as it is, so you can `invoke` it before changing a line. `invoke` needs SurfSense running: start it with `pnpm dev` in `surfsense_local/electron`, and `invoke` finds it on its own. The note it adds appears in your Sources. + +Every command explains itself with `--help`. + +## What a plugin folder holds + +``` +plugins/hn-search/ + manifest.json what the app must know before it runs the plugin + main.py where SurfSense starts it; it imports your actions + hn_search/ your code, in a package named after the plugin + requirements.in the libraries you asked for, written by add and remove + requirements.txt every version and file hash, never edited by hand +``` + +Put your code in a package named after the plugin, as `new` does. A module of your own named like a standard-library module or a library, such as `json.py`, would clash with it. + +`manifest.json`: + +| Field | What it is | +|---|---| +| `id` | The folder's name: lowercase letters, digits and `-` | +| `name`, `description` | What the app and the plugin directory show: up to 80 and 200 characters | +| `author` | Your name, as you want it shown | +| `access` | `free` | +| `hosts` | Every host the plugin reaches, such as `hn.algolia.com`. The user allows them before its first run. | +| `secrets` | What the user enters once in Settings, such as an API token: `name`, `title`, and an optional `description` saying where to get it | +| `actions` | What the user can run: `name`, `title`, `inputs`, and an optional `timeout_seconds`, 1800 when left out | +| `platforms` | Optional: only when the plugin cannot run on every system SurfSense supports | + +An input is `{ "name", "title", "kind", "required" }`, with `kind` one of `string`, `number` or `boolean`. You never write a version: the release sets it. + +## Writing the code + +```python +from surfsense_plugin_sdk import action, data, document, http, secret + + +@action("search") +def search(query: str) -> None: + response = http.get( + "https://hn.algolia.com/api/v1/search", + params={"query": query}, + headers={"Authorization": f"Bearer {secret('token')}"}, + ) + for hit in response.json()["hits"]: + document.add(title=hit["title"], content=hit.get("story_text") or hit["title"]) +``` + +- `@action("search")` marks the function the app runs for the action `manifest.json` names `search`. +- Inputs arrive as keyword arguments named after the declared inputs. A `string` arrives as `str`, a `number` as `int` or `float`, so annotate it `float`, and a `boolean` as `bool`. An optional input the user left empty arrives as `None`, so annotate it `str | None` or the like. +- `secret("token")` returns a secret the manifest declares, as the user entered it. +- `data()` is your plugin's own folder (see below). +- `document.add`, `list` and `update` work on the workspace the user ran the plugin in. +- When something goes wrong, say why on stderr, or with `sys.exit("Hacker News refused your token. Update it in Settings.")`. The user reads it in the run's log. + +## Asking the user for things + +Ask with an input for anything that changes from one run to the next, such as a search term. Ask with a secret for anything that must not be shown again, such as a token or a password. Never ask for a secret as an input: inputs are kept on the run's record. + +## Network + +Send every request through `http`, and list every host it reaches in `hosts`, redirects included. `http` refuses a host you did not list, and the user sees each host before the plugin's first run. A library that makes its own requests, such as a site's API client, is not checked: list every host it reaches, and say so in your pull request. + +## Dependencies + +Add a library with `surfsense-plugins add`, and never edit `requirements.txt` by hand: it pins every version and file hash for every system SurfSense runs on. Prefer pure-Python libraries, which ship as one small download for every system. A library with compiled code is fine if it publishes wheels for every platform in [`core/build-targets.json`](core/build-targets.json). + +`invoke` installs your dependencies for your own system only, so a library missing a wheel for another system shows up in the pull request's checks, not in `invoke`. + +## Your data folder + +`data()` is a folder only your plugin writes to. It survives updates and goes when the plugin is uninstalled, so it is the place for what your plugin remembers between runs, such as a cache or the time it last fetched. `invoke` gives your plugin `dev-data/` beside it, which git ignores. + +It is shared by every workspace. To know what is already in the workspace the user ran the plugin in, call `document.list()`. + +An app can move your plugin back to an older version when a newer one is blocked, so read data a newer version wrote with care, or start over. + +## Releasing + +You never write a version. Once your pull request is merged, the plugin ships with the next SurfSense release, stamped with its version, and appears in the app's plugin list. + +## When the SDK is missing something + +You are welcome to propose it. Open the pull request beside the plugin that needs it, or an issue first; a maintainer reviews every change to the SDK, since every plugin relies on it. diff --git a/plugins/core/build-targets.json b/plugins/core/build-targets.json new file mode 100644 index 000000000..579eda932 --- /dev/null +++ b/plugins/core/build-targets.json @@ -0,0 +1,8 @@ +{ + "python": "3.12", + "platforms": { + "windows-x64": "x86_64-pc-windows-msvc", + "macos-arm64": "aarch64-apple-darwin", + "linux-x64": "x86_64-manylinux_2_34" + } +} diff --git a/plugins/core/cli/pyproject.toml b/plugins/core/cli/pyproject.toml new file mode 100644 index 000000000..a05e50b89 --- /dev/null +++ b/plugins/core/cli/pyproject.toml @@ -0,0 +1,66 @@ +[project] +name = "surfsense-plugin-cli" +# Never published: authors, CI and maintainers run it from this repository. +version = "0.0.0" +description = "The surfsense-plugins command: what authors, CI and maintainers run on a plugin outside the app." +# The Python plugins run on, as plugins/core/build-targets.json names it: +# invoke runs a plugin on this interpreter. +requires-python = "==3.12.*" +license = { text = "Apache-2.0" } +# uv as a package, so every machine pins and installs with the same uv. +dependencies = ["typer>=0.15", "uv>=0.8", "surfsense-plugin-manifest"] + +[project.scripts] +surfsense-plugins = "surfsense_plugin_cli.main:app" + +[tool.uv.sources] +surfsense-plugin-manifest = { path = "../manifest", editable = true } + +[dependency-groups] +dev = ["pytest>=8.0", "ruff>=0.8"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["surfsense_plugin_cli"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --tb=short -ra --strict-markers --import-mode=importlib" +markers = [ + "unit: needs no app", + "app: runs against the real app, started for the test", +] + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E4", + "E7", + "E9", + "F", # Pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "T20", # flake8-print + "SIM", # flake8-simplify + "RUF", + # Every package, public class, method and function says what it is for. + # Private helpers are held to the same in review: pydocstyle skips them. + "D101", + "D102", + "D103", + "D104", +] +# Exceptions are named for what happened, as the SDK's HostNotDeclared is. +ignore = ["N818"] + +[tool.ruff.format] +quote-style = "double" diff --git a/plugins/core/cli/surfsense_plugin_cli/__init__.py b/plugins/core/cli/surfsense_plugin_cli/__init__.py new file mode 100644 index 000000000..19e6b9bf7 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/__init__.py @@ -0,0 +1 @@ +"""The surfsense-plugins command: what authors, CI and maintainers run outside the app.""" diff --git a/plugins/core/cli/surfsense_plugin_cli/__main__.py b/plugins/core/cli/surfsense_plugin_cli/__main__.py new file mode 100644 index 000000000..a24616541 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/__main__.py @@ -0,0 +1,5 @@ +"""python -m surfsense_plugin_cli, the same command as surfsense-plugins.""" + +from surfsense_plugin_cli.main import app + +app() diff --git a/plugins/core/cli/surfsense_plugin_cli/build_targets.py b/plugins/core/cli/surfsense_plugin_cli/build_targets.py new file mode 100644 index 000000000..0e1bfb957 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/build_targets.py @@ -0,0 +1,41 @@ +"""What plugins are built for: the app's Python and its platforms, from build-targets.json.""" + +import json +import platform +import sys +from dataclasses import dataclass + +import typer + +from surfsense_plugin_cli.repository import CORE + + +@dataclass(frozen=True) +class BuildTargets: + """The Python version, and each platform's key with the uv target it installs for.""" + + python: str + platforms: dict[str, str] + + +def build_targets() -> BuildTargets: + """Read fresh each time: the file is the one list packaging, checks and the app share.""" + declared = json.loads((CORE / "build-targets.json").read_text(encoding="utf-8")) + return BuildTargets(declared["python"], declared["platforms"]) + + +def this_platform() -> str: + """This machine's key, such as linux-x64, if plugins are built for it.""" + arm = platform.machine().lower() in ("arm64", "aarch64") + system = {"linux": "linux", "darwin": "macos", "win32": "windows"}.get( + sys.platform, sys.platform + ) + key = f"{system}-{'arm64' if arm else 'x64'}" + supported = build_targets().platforms + if key not in supported: + typer.echo( + f"plugins are built for {', '.join(supported)}; this machine is {key}", + err=True, + ) + raise typer.Exit(1) + return key diff --git a/plugins/core/cli/surfsense_plugin_cli/dependencies/__init__.py b/plugins/core/cli/surfsense_plugin_cli/dependencies/__init__.py new file mode 100644 index 000000000..566563e34 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/dependencies/__init__.py @@ -0,0 +1 @@ +"""A plugin's dependencies: the short list it asks for, and the pins every platform installs.""" diff --git a/plugins/core/cli/surfsense_plugin_cli/dependencies/add.py b/plugins/core/cli/surfsense_plugin_cli/dependencies/add.py new file mode 100644 index 000000000..33782e835 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/dependencies/add.py @@ -0,0 +1,32 @@ +"""surfsense-plugins add: a library the plugin needs, listed and pinned.""" + +from typing import Annotated + +import typer + +from surfsense_plugin_cli.dependencies.listed import library_name, listed +from surfsense_plugin_cli.dependencies.rewrite_and_pin import rewrite_and_pin +from surfsense_plugin_cli.plugin_folder import plugin_folder + + +def add( + plugin: Annotated[ + str, typer.Argument(help="The plugin's id, such as hn-search, or its folder.") + ], + requirements: Annotated[ + list[str], typer.Argument(help="A library, such as lxml or 'requests>=2'.") + ], +) -> None: + """Add libraries to a plugin, pinned for every platform it runs on. + + Example: surfsense-plugins add hn-search lxml + """ + folder = plugin_folder(plugin) + lines = listed(folder.path) + for requirement in requirements: + name = library_name(requirement) + # A library already listed takes the new version range in its place. + lines = [line for line in lines if library_name(line) != name] + lines.append(requirement) + pinned = rewrite_and_pin(folder, lines) + typer.echo(f"Added {', '.join(requirements)} to {folder.manifest.id}: {pinned}.") diff --git a/plugins/core/cli/surfsense_plugin_cli/dependencies/listed.py b/plugins/core/cli/surfsense_plugin_cli/dependencies/listed.py new file mode 100644 index 000000000..bec94064b --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/dependencies/listed.py @@ -0,0 +1,16 @@ +"""requirements.in: the libraries a plugin asks for, as the author wrote them.""" + +import re +from pathlib import Path + + +def listed(plugin: Path) -> list[str]: + """Its lines, or none when the plugin has no dependencies.""" + path = plugin / "requirements.in" + return path.read_text(encoding="utf-8").splitlines() if path.is_file() else [] + + +def library_name(requirement: str) -> str: + """The name pip compares: tiny_lib, Tiny.Lib and tiny-lib are one library.""" + match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", requirement) + return re.sub(r"[-_.]+", "-", match.group(1)).lower() if match else "" diff --git a/plugins/core/cli/surfsense_plugin_cli/dependencies/pin_dependencies.py b/plugins/core/cli/surfsense_plugin_cli/dependencies/pin_dependencies.py new file mode 100644 index 000000000..7189fc565 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/dependencies/pin_dependencies.py @@ -0,0 +1,30 @@ +"""surfsense-plugins pin-dependencies: pins again what requirements.in lists.""" + +from typing import Annotated + +import typer + +from surfsense_plugin_cli.dependencies.listed import listed +from surfsense_plugin_cli.dependencies.rewrite_and_pin import rewrite_and_pin +from surfsense_plugin_cli.plugin_folder import plugin_folder + + +def pin_dependencies( + plugin: Annotated[ + str, typer.Argument(help="The plugin's id, such as hn-search, or its folder.") + ], +) -> None: + """Pin a plugin's dependencies again, after editing requirements.in by hand. + + Example: surfsense-plugins pin-dependencies hn-search + """ + folder = plugin_folder(plugin) + lines = listed(folder.path) + if not lines: + typer.echo( + f"{folder.manifest.id} lists no dependencies: add one with" + f" surfsense-plugins add {folder.manifest.id} ", + err=True, + ) + raise typer.Exit(1) + typer.echo(f"{rewrite_and_pin(folder, lines)}.") diff --git a/plugins/core/cli/surfsense_plugin_cli/dependencies/remove.py b/plugins/core/cli/surfsense_plugin_cli/dependencies/remove.py new file mode 100644 index 000000000..2aecb1cad --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/dependencies/remove.py @@ -0,0 +1,31 @@ +"""surfsense-plugins remove: a library the plugin no longer needs.""" + +from typing import Annotated + +import typer + +from surfsense_plugin_cli.dependencies.listed import library_name, listed +from surfsense_plugin_cli.dependencies.rewrite_and_pin import rewrite_and_pin +from surfsense_plugin_cli.plugin_folder import plugin_folder + + +def remove( + plugin: Annotated[ + str, typer.Argument(help="The plugin's id, such as hn-search, or its folder.") + ], + names: Annotated[list[str], typer.Argument(help="A library the plugin lists.")], +) -> None: + """Remove libraries from a plugin, and pin what remains. + + Example: surfsense-plugins remove hn-search lxml + """ + folder = plugin_folder(plugin) + lines = listed(folder.path) + for name in names: + kept = [line for line in lines if library_name(line) != library_name(name)] + if kept == lines: + typer.echo(f"{folder.manifest.id} does not list {name}", err=True) + raise typer.Exit(1) + lines = kept + pinned = rewrite_and_pin(folder, lines) + typer.echo(f"Removed {', '.join(names)}: {pinned}.") diff --git a/plugins/core/cli/surfsense_plugin_cli/dependencies/rewrite_and_pin.py b/plugins/core/cli/surfsense_plugin_cli/dependencies/rewrite_and_pin.py new file mode 100644 index 000000000..e97b20986 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/dependencies/rewrite_and_pin.py @@ -0,0 +1,84 @@ +"""Writes requirements.in and pins requirements.txt from it, the one way all three commands do.""" + +import re +import subprocess + +import typer +from uv import find_uv_bin + +from surfsense_plugin_cli.build_targets import build_targets +from surfsense_plugin_cli.plugin_folder import PluginFolder + +FILES = ("requirements.in", "requirements.txt") + + +def rewrite_and_pin(plugin: PluginFolder, lines: list[str]) -> str: + """Both files updated together, or both put back as they were. + + Returns what is pinned now, in words, for the command to tell the author. + """ + before = {name: _read(plugin, name) for name in FILES} + if not lines: + for name in FILES: + (plugin.path / name).unlink(missing_ok=True) + return f"{plugin.manifest.id} has no dependencies left" + (plugin.path / "requirements.in").write_text( + "\n".join(lines) + "\n", encoding="utf-8" + ) + refused = _pin(plugin) + if refused: + for name, text in before.items(): + _restore(plugin, name, text) + typer.echo(f"the dependencies cannot be pinned:\n{refused}", err=True) + raise typer.Exit(1) + pinned = _pinned_packages(plugin) + return f"requirements.txt pins {pinned} package{'' if pinned == 1 else 's'}" + + +def _pin(plugin: PluginFolder) -> str | None: + """Every version and file hash, for every platform. uv's reason when it cannot. + + uv keeps what requirements.txt already pins, so one change upgrades nothing else. + """ + finished = subprocess.run( + [ + find_uv_bin(), + "pip", + "compile", + "--quiet", + "--universal", + "--generate-hashes", + "--python-version", + build_targets().python, + "--custom-compile-command", + f"surfsense-plugins pin-dependencies {plugin.manifest.id}", + "requirements.in", + "--output-file", + "requirements.txt", + ], + cwd=plugin.path, + capture_output=True, + text=True, + ) + return finished.stderr.strip() if finished.returncode != 0 else None + + +def _pinned_packages(plugin: PluginFolder) -> int: + """How many packages requirements.txt pins, the libraries' own dependencies included.""" + pinned = _read(plugin, "requirements.txt") or "" + return len(re.findall(r"^[A-Za-z0-9][^=\s]*==", pinned, re.MULTILINE)) + + +def _read(plugin: PluginFolder, name: str) -> str | None: + """A file's text, or None when the plugin has no such file.""" + path = plugin.path / name + return path.read_text(encoding="utf-8") if path.is_file() else None + + +def _restore(plugin: PluginFolder, name: str, text: str | None) -> None: + """A file as it was, including not being there at all.""" + path = plugin.path / name + if text is None: + path.unlink(missing_ok=True) + else: + path.write_text(text, encoding="utf-8") diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/__init__.py b/plugins/core/cli/surfsense_plugin_cli/invoke/__init__.py new file mode 100644 index 000000000..5a29e7913 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/__init__.py @@ -0,0 +1 @@ +"""Runs one action of a plugin once, against the author's running SurfSense.""" diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/app/__init__.py b/plugins/core/cli/surfsense_plugin_cli/invoke/app/__init__.py new file mode 100644 index 000000000..b5a9d27d5 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/app/__init__.py @@ -0,0 +1 @@ +"""The author's running SurfSense: finding it, and choosing the workspace to write to.""" diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/app/app_api.py b/plugins/core/cli/surfsense_plugin_cli/invoke/app/app_api.py new file mode 100644 index 000000000..384f9d264 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/app/app_api.py @@ -0,0 +1,23 @@ +"""What invoke reads from the running app before the plugin starts.""" + +import json +import urllib.error +import urllib.request + +# Loopback: a proxy would only get in the way. +_opener_without_proxy = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def answers(url: str) -> bool: + """Whether the app is up: its /health answers once it can take requests.""" + try: + with _opener_without_proxy.open(f"{url}/health", timeout=3) as reply: + return reply.status == 200 + except (urllib.error.URLError, OSError, ValueError): + return False + + +def read_from_app(url: str, path: str) -> object: + """One of the app's routes, as JSON.""" + with _opener_without_proxy.open(f"{url}{path}", timeout=30) as reply: + return json.loads(reply.read()) diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/app/choose_workspace.py b/plugins/core/cli/surfsense_plugin_cli/invoke/app/choose_workspace.py new file mode 100644 index 000000000..f5e0cc604 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/app/choose_workspace.py @@ -0,0 +1,43 @@ +"""Picks the workspace a run writes to, and never guesses between several.""" + +from dataclasses import dataclass +from typing import TypedDict, cast + +import typer + +from surfsense_plugin_cli.invoke.app.app_api import read_from_app + + +@dataclass(frozen=True) +class Workspace: + """A workspace of the running app, named so the author sees where a run writes.""" + + id: int + name: str + + +class _Listed(TypedDict): + id: int + name: str + + +def choose_workspace(api_url: str, workspace: int | None) -> Workspace: + """The one the author named, else the app's only one; otherwise it asks.""" + listed = cast(list[_Listed], read_from_app(api_url, "/workspaces")) + workspaces = [Workspace(each["id"], each["name"]) for each in listed] + if workspace is None and len(workspaces) == 1: + return workspaces[0] + for each in workspaces: + if each.id == workspace: + return each + + typer.echo( + "SurfSense has several workspaces: pass --workspace with one of these ids" + if workspace is None + else f"SurfSense has no workspace {workspace}: pass --workspace with one of" + " these ids", + err=True, + ) + for each in workspaces: + typer.echo(f" {each.id} {each.name}", err=True) + raise typer.Exit(1) diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/app/find_app.py b/plugins/core/cli/surfsense_plugin_cli/invoke/app/find_app.py new file mode 100644 index 000000000..fe61aea91 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/app/find_app.py @@ -0,0 +1,33 @@ +"""Finds the author's running SurfSense, so they never look up its port themselves.""" + +import os +from pathlib import Path + +import typer + +from surfsense_plugin_cli.invoke.app.app_api import answers + +# Where a development app, then a packaged one, writes the address it answers at. +ANNOUNCED_IN = (".surfsense-dev", ".surfsense") + + +def find_app(api_url: str | None) -> str: + """An address the author named must answer; otherwise the first app announced.""" + named = api_url or os.environ.get("SURFSENSE_PLUGIN_API_URL") + candidates = [named] if named else [_announced(folder) for folder in ANNOUNCED_IN] + for url in candidates: + if url and answers(url.rstrip("/")): + return url.rstrip("/") + where = f" at {named}" if named else "" + typer.echo( + f"SurfSense is not running{where}: start it, or pass --api-url with the" + " address it answers at", + err=True, + ) + raise typer.Exit(1) + + +def _announced(folder: str) -> str | None: + """The address an app wrote when it started, if it is running now.""" + file = Path.home() / folder / "api-url" + return file.read_text(encoding="utf-8").strip() if file.is_file() else None diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/command.py b/plugins/core/cli/surfsense_plugin_cli/invoke/command.py new file mode 100644 index 000000000..b2ef2e88f --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/command.py @@ -0,0 +1,55 @@ +"""surfsense-plugins invoke: what the author types, turned into one run.""" + +from typing import Annotated + +import typer + +from surfsense_plugin_cli.invoke.app.choose_workspace import choose_workspace +from surfsense_plugin_cli.invoke.app.find_app import find_app +from surfsense_plugin_cli.invoke.plugin.dependencies import install_dependencies +from surfsense_plugin_cli.invoke.plugin.run_plugin import run_plugin +from surfsense_plugin_cli.invoke.plugin.secrets import secrets_for +from surfsense_plugin_cli.invoke.request.declared_action import declared_action +from surfsense_plugin_cli.invoke.request.inputs import inputs_for +from surfsense_plugin_cli.plugin_folder import plugin_folder + + +def invoke( + plugin: Annotated[ + str, typer.Argument(help="The plugin's id, such as hn-search, or its folder.") + ], + action: Annotated[str, typer.Argument(help="The action to run, as named.")], + inputs: Annotated[ + list[str] | None, + typer.Option("--input", help="name=value, once per input."), + ] = None, + workspace: Annotated[ + int | None, typer.Option(help="The workspace the plugin writes to.") + ] = None, + api_url: Annotated[ + str | None, typer.Option(help="Where your SurfSense answers.") + ] = None, +) -> None: + """Run one action of a plugin once, against your running SurfSense. + + Example: surfsense-plugins invoke example count-words --input text="a b a" + """ + folder = plugin_folder(plugin) + given = inputs_for(declared_action(folder, action), inputs or []) + install_dependencies(folder.path) + app = find_app(api_url) + chosen = choose_workspace(app, workspace) + context = { + "SURFSENSE_PLUGIN_API_URL": app, + "SURFSENSE_PLUGIN_WORKSPACE_ID": str(chosen.id), + "SURFSENSE_PLUGIN_ID": folder.manifest.id, + } | secrets_for(folder.manifest) + + # On stderr, so what the plugin prints stays the command's only output. + typer.echo( + f'Running {action} of {folder.manifest.id} in "{chosen.name}" at {app}', + err=True, + ) + code = run_plugin(folder.path, action, given, context) + typer.echo("Done." if code == 0 else f"Failed: exit {code}.", err=True) + raise typer.Exit(code) diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/__init__.py b/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/__init__.py new file mode 100644 index 000000000..30424c77b --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/__init__.py @@ -0,0 +1 @@ +"""Getting the plugin ready, then running it the way the app does.""" diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/dependencies.py b/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/dependencies.py new file mode 100644 index 000000000..1ce2e489b --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/dependencies.py @@ -0,0 +1,43 @@ +"""Installs the plugin's dependencies beside it before a run, only when they changed.""" + +import hashlib +import shutil +import tempfile +from pathlib import Path + +import typer + +from surfsense_plugin_cli.build_targets import this_platform +from surfsense_plugin_cli.packaging.install_requirements import ( + DependenciesNotInstalled, + install_requirements, +) + +# In site-packages: the sha256 of the requirements.txt it was installed from. +INSTALLED_FROM = ".installed-from" + + +def install_dependencies(plugin: Path) -> None: + """Into plugins//site-packages, where the SDK looks, for this machine.""" + requirements = plugin / "requirements.txt" + if not requirements.is_file(): + return + site_packages = plugin / "site-packages" + digest = hashlib.sha256(requirements.read_bytes()).hexdigest() + stamp = site_packages / INSTALLED_FROM + if stamp.is_file() and stamp.read_text() == digest: + return + + # Built aside and swapped in whole, so a failed install leaves the last good one. + staging = Path(tempfile.mkdtemp(prefix="surfsense-site-packages-")) + platform = this_platform() + typer.echo(f"Installing dependencies for {platform}…", err=True) + try: + install_requirements(requirements, platform, staging) + except DependenciesNotInstalled as refused: + shutil.rmtree(staging, ignore_errors=True) + typer.echo(str(refused), err=True) + raise typer.Exit(1) from None + (staging / INSTALLED_FROM).write_text(digest) + shutil.rmtree(site_packages, ignore_errors=True) + shutil.move(staging, site_packages) diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/plugin_environment.py b/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/plugin_environment.py new file mode 100644 index 000000000..ce7da65a2 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/plugin_environment.py @@ -0,0 +1,49 @@ +"""The environment a plugin runs in, built from scratch as the app's runner builds it. + +Nothing else of the author's environment reaches the plugin, so a plugin that +works here does not lean on a variable the app never sets. +""" + +import os + +from surfsense_plugin_cli.repository import SDK + +# What the protocol lets through from the operating system. +FROM_THE_SYSTEM = ( + "PATH", + "HOME", + "USERPROFILE", + "TMPDIR", + "TEMP", + "TMP", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TZ", + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "APPDATA", + "LOCALAPPDATA", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", +) + +FOR_PYTHON = { + "PYTHONPATH": str(SDK), + "PYTHONSAFEPATH": "1", + "PYTHONNOUSERSITE": "1", + "PYTHONUTF8": "1", + "PYTHONUNBUFFERED": "1", +} + + +def plugin_environment(context: dict[str, str]) -> dict[str, str]: + """The system's allowed variables, Python's, and the run's own context.""" + allowed = {name: os.environ[name] for name in FROM_THE_SYSTEM if name in os.environ} + return allowed | FOR_PYTHON | context diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/run_plugin.py b/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/run_plugin.py new file mode 100644 index 000000000..c2a69ffed --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/run_plugin.py @@ -0,0 +1,39 @@ +"""Starts the plugin with the command the app uses, and waits for how it ends.""" + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +from surfsense_plugin_cli.invoke.plugin.plugin_environment import plugin_environment + + +def run_plugin( + folder: Path, + action: str, + inputs: dict[str, object], + context: dict[str, str], +) -> int: + """The plugin's exit code. Its log goes straight to the author's terminal.""" + data = folder / "dev-data" + data.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix="surfsense-invoke-") as run_folder: + inputs_file = Path(run_folder) / "inputs.json" + inputs_file.write_text(json.dumps(inputs), encoding="utf-8") + finished = subprocess.run( + [ + sys.executable, + "-m", + "surfsense_plugin_sdk.run", + str(folder), + action, + "--inputs", + str(inputs_file), + "--data", + str(data), + ], + cwd=folder, + env=plugin_environment(context), + ) + return finished.returncode diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/secrets.py b/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/secrets.py new file mode 100644 index 000000000..49ef2863b --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/plugin/secrets.py @@ -0,0 +1,28 @@ +"""The plugin's declared secrets, handed over only in the run's environment. + +Never a flag, so a secret stays out of shell history, and never a file. +""" + +import getpass +import os +import sys + +from surfsense_plugin_manifest import Manifest + + +def secrets_for(manifest: Manifest) -> dict[str, str]: + """One variable per declared secret: the author's own, else asked for.""" + found: dict[str, str] = {} + for declared in manifest.secrets: + variable = f"SURFSENSE_PLUGIN_SECRET_{declared.name.upper()}" + found[variable] = os.environ.get(variable) or _asked_for(declared.title) + return found + + +def _asked_for(title: str) -> str: + """Typed without echo at a terminal; read as a line when piped, for scripts.""" + if sys.stdin.isatty(): + return getpass.getpass(f"{title}: ") + sys.stderr.write(f"{title}: ") + sys.stderr.flush() + return sys.stdin.readline().rstrip("\n") diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/request/__init__.py b/plugins/core/cli/surfsense_plugin_cli/invoke/request/__init__.py new file mode 100644 index 000000000..b7777324f --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/request/__init__.py @@ -0,0 +1 @@ +"""What the author asked for: the action, and the inputs to run it with.""" diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/request/declared_action.py b/plugins/core/cli/surfsense_plugin_cli/invoke/request/declared_action.py new file mode 100644 index 000000000..3ab256a6a --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/request/declared_action.py @@ -0,0 +1,19 @@ +"""The action the author named, as the plugin's manifest declares it.""" + +import typer +from surfsense_plugin_manifest import Action + +from surfsense_plugin_cli.plugin_folder import PluginFolder + + +def declared_action(folder: PluginFolder, name: str) -> Action: + """Refused by name, with the actions the plugin does have.""" + for action in folder.manifest.actions: + if action.name == name: + return action + offered = ", ".join(action.name for action in folder.manifest.actions) + typer.echo( + f'{folder.manifest.id} has no action named "{name}": its actions are {offered}', + err=True, + ) + raise typer.Exit(1) diff --git a/plugins/core/cli/surfsense_plugin_cli/invoke/request/inputs.py b/plugins/core/cli/surfsense_plugin_cli/invoke/request/inputs.py new file mode 100644 index 000000000..26341d0ca --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/invoke/request/inputs.py @@ -0,0 +1,57 @@ +"""The author's --input values, turned into the kinds the action declares. + +Refused the way the app's run route refuses them, so a run that works here +starts in the app too. +""" + +import typer +from surfsense_plugin_manifest import Action + + +def inputs_for(action: Action, typed: list[str]) -> dict[str, object]: + """Every value converted, or every problem named at once.""" + declared = {each.name: each for each in action.inputs} + values: dict[str, object] = {} + errors: list[str] = [] + for entry in typed: + name, separator, text = entry.partition("=") + if not separator: + errors.append(f"{entry}: an input is name=value") + elif name not in declared: + errors.append(f'{action.name} has no input named "{name}"') + else: + try: + values[name] = _as_kind(declared[name].kind, text) + except ValueError as wrong: + errors.append(f"{name} must be {wrong}, not {text}") + errors += [ + f"{each.name} is required: pass --input {each.name}=…" + for each in action.inputs + if each.required and each.name not in values and each.name not in _typed(typed) + ] + if errors: + for error in errors: + typer.echo(error, err=True) + raise typer.Exit(1) + return values + + +def _as_kind(kind: str, text: str) -> object: + """The value a declared kind means; a ValueError says what it had to be.""" + if kind == "number": + for number in (int, float): + try: + return number(text) + except ValueError: + pass + raise ValueError("a number") + if kind == "boolean": + if text.lower() not in ("true", "false"): + raise ValueError("true or false") + return text.lower() == "true" + return text + + +def _typed(typed: list[str]) -> set[str]: + """The names the author gave, even ones refused for their value.""" + return {entry.partition("=")[0] for entry in typed if "=" in entry} diff --git a/plugins/core/cli/surfsense_plugin_cli/main.py b/plugins/core/cli/surfsense_plugin_cli/main.py new file mode 100644 index 000000000..a8095601d --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/main.py @@ -0,0 +1,23 @@ +"""The surfsense-plugins command, one subcommand per job.""" + +import typer + +from surfsense_plugin_cli.dependencies.add import add +from surfsense_plugin_cli.dependencies.pin_dependencies import pin_dependencies +from surfsense_plugin_cli.dependencies.remove import remove +from surfsense_plugin_cli.invoke.command import invoke +from surfsense_plugin_cli.new.command import new + +app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False) + + +@app.callback() +def surfsense_plugins() -> None: + """Write, try and check SurfSense plugins.""" + + +app.command()(new) +app.command()(add) +app.command()(remove) +app.command(name="pin-dependencies")(pin_dependencies) +app.command()(invoke) diff --git a/plugins/core/cli/surfsense_plugin_cli/new/__init__.py b/plugins/core/cli/surfsense_plugin_cli/new/__init__.py new file mode 100644 index 000000000..575b08671 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/new/__init__.py @@ -0,0 +1 @@ +"""surfsense-plugins new: a plugin folder that passes the rules and runs as it is.""" diff --git a/plugins/core/cli/surfsense_plugin_cli/new/command.py b/plugins/core/cli/surfsense_plugin_cli/new/command.py new file mode 100644 index 000000000..e549b09f6 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/new/command.py @@ -0,0 +1,63 @@ +"""surfsense-plugins new: what the author types, turned into a plugin folder.""" + +import json +import subprocess +from typing import Annotated + +import typer +from pydantic import ValidationError +from surfsense_plugin_manifest import Manifest +from surfsense_plugin_manifest.errors.from_validation_errors import errors_from + +from surfsense_plugin_cli.new.template import template +from surfsense_plugin_cli.repository import PLUGINS +from surfsense_plugin_cli.reserved_ids import is_reserved + + +def new( + plugin: Annotated[ + str, typer.Argument(help="The new plugin's id, such as hn-search.") + ], +) -> None: + """Start a plugin in plugins/, with one action that runs as it is. + + Example: surfsense-plugins new hn-search + """ + folder = PLUGINS / plugin + plugin_id = folder.name + files = template(plugin_id, _authors_git_name()) + _refuse_what_it_cannot_take(plugin_id, json.loads(files["manifest.json"])) + if folder.exists(): + typer.echo(f"{plugin_id} already exists at {folder}", err=True) + raise typer.Exit(1) + + for relative, text in files.items(): + path = folder / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + typer.echo(f"Created {folder}\nTry it:") + typer.echo(f' surfsense-plugins invoke {plugin_id} add-note --input text="Hello"') + + +def _refuse_what_it_cannot_take(plugin_id: str, manifest: dict[str, object]) -> None: + """The same rules a pull request's checks apply, before anything is written.""" + if is_reserved(plugin_id): + typer.echo(f"{plugin_id} is reserved for SurfSense's own plugins", err=True) + raise typer.Exit(1) + try: + Manifest.model_validate(manifest) + except ValidationError as broken: + for error in errors_from(broken): + typer.echo(f"{plugin_id}: {error}", err=True) + raise typer.Exit(1) from None + + +def _authors_git_name() -> str: + """Who the author already is to git, or nothing to fill in later.""" + try: + found = subprocess.run( + ["git", "config", "user.name"], capture_output=True, text=True + ) + except FileNotFoundError: + return "" + return found.stdout.strip() diff --git a/plugins/core/cli/surfsense_plugin_cli/new/template.py b/plugins/core/cli/surfsense_plugin_cli/new/template.py new file mode 100644 index 000000000..225045627 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/new/template.py @@ -0,0 +1,51 @@ +"""The files a new plugin starts with: one action that adds a note, ready to run.""" + +import json + + +def template(plugin_id: str, author: str) -> dict[str, str]: + """Each file's path in the plugin's folder, and its text.""" + package = plugin_id.replace("-", "_") + name = plugin_id.replace("-", " ").capitalize() + manifest = { + "id": plugin_id, + "name": name, + "description": f"What {name} adds to SurfSense, in one line.", + "author": author, + "access": "free", + "hosts": [], + "actions": [ + { + "name": "add-note", + "title": "Add a note", + "inputs": [ + { + "name": "text", + "title": "Text", + "kind": "string", + "required": True, + } + ], + } + ], + } + return { + "manifest.json": json.dumps(manifest, indent=2) + "\n", + "main.py": ( + f'"""Where SurfSense starts {name}.\n\n' + 'Importing a module registers the actions it marks with @action.\n"""\n\n' + f"import {package}.add_note # noqa: F401\n" + ), + f"{package}/__init__.py": ( + f'"""{name}\'s own code, in a package named after it, so no module of' + ' yours can clash with a library."""\n' + ), + f"{package}/add_note.py": ( + '"""The add-note action: what the user typed, kept as a note."""\n\n' + "from surfsense_plugin_sdk import action, document\n\n\n" + '@action("add-note")\n' + "def add_note(text: str) -> None:\n" + ' """Adds the text as a note in the workspace the user ran this in."""\n' + ' document.add(title="Note", content=text)\n' + ), + } diff --git a/plugins/core/cli/surfsense_plugin_cli/packaging/__init__.py b/plugins/core/cli/surfsense_plugin_cli/packaging/__init__.py new file mode 100644 index 000000000..3e4ab42e6 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/packaging/__init__.py @@ -0,0 +1 @@ +"""Turns a plugin folder into what a release installs, starting with its dependencies.""" diff --git a/plugins/core/cli/surfsense_plugin_cli/packaging/install_requirements.py b/plugins/core/cli/surfsense_plugin_cli/packaging/install_requirements.py new file mode 100644 index 000000000..72a077da6 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/packaging/install_requirements.py @@ -0,0 +1,53 @@ +"""Installs a plugin's pinned dependencies for one platform. + +The one way both packaging and invoke install them, so what works on an +author's machine is installed the same way by a release. +""" + +import subprocess +import sys +from pathlib import Path + +from uv import find_uv_bin + +from surfsense_plugin_cli.build_targets import build_targets + + +class DependenciesNotInstalled(Exception): + """uv refused: its own words name the package, and this says for which system.""" + + def __init__(self, platform: str, reason: str) -> None: + super().__init__( + f"the dependencies cannot be installed for {platform}, from prebuilt" + f" wheels only:\n{reason}" + ) + + +def install_requirements(requirements: Path, platform: str, into: Path) -> None: + """Prebuilt wheels only, every file's hash checked, for the app's Python.""" + targets = build_targets() + finished = subprocess.run( + [ + find_uv_bin(), + "pip", + "install", + "--quiet", + "--python", + sys.executable, + "--python-version", + targets.python, + "--python-platform", + targets.platforms[platform], + "--only-binary", + ":all:", + "--require-hashes", + "--requirement", + str(requirements), + "--target", + str(into), + ], + capture_output=True, + text=True, + ) + if finished.returncode != 0: + raise DependenciesNotInstalled(platform, finished.stderr.strip()) diff --git a/plugins/core/cli/surfsense_plugin_cli/plugin_folder.py b/plugins/core/cli/surfsense_plugin_cli/plugin_folder.py new file mode 100644 index 000000000..9dafab731 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/plugin_folder.py @@ -0,0 +1,32 @@ +"""Finds a plugin's folder from what the author typed, and loads its manifest.""" + +from dataclasses import dataclass +from pathlib import Path + +import typer +from surfsense_plugin_manifest import Manifest, ManifestError, load_manifest + +from surfsense_plugin_cli.repository import PLUGINS + + +@dataclass(frozen=True) +class PluginFolder: + """A plugin's folder, with a manifest that follows every rule.""" + + path: Path + manifest: Manifest + + +def plugin_folder(plugin: str) -> PluginFolder: + """An id such as hn-search names a folder in plugins/; a path names itself.""" + path = PLUGINS / plugin + if not (path / "manifest.json").is_file(): + typer.echo(f"no plugin at {path}: it needs a manifest.json", err=True) + raise typer.Exit(1) + try: + manifest = load_manifest(path / "manifest.json") + except ManifestError as refused: + for error in refused.errors: + typer.echo(f"manifest.json: {error}", err=True) + raise typer.Exit(1) from None + return PluginFolder(path, manifest) diff --git a/plugins/core/cli/surfsense_plugin_cli/repository.py b/plugins/core/cli/surfsense_plugin_cli/repository.py new file mode 100644 index 000000000..c1d0763f9 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/repository.py @@ -0,0 +1,7 @@ +"""Where this repository keeps the plugins and the SDK they run on.""" + +from pathlib import Path + +CORE = Path(__file__).resolve().parents[2] +PLUGINS = CORE.parent +SDK = CORE / "sdk" diff --git a/plugins/core/cli/surfsense_plugin_cli/reserved_ids.py b/plugins/core/cli/surfsense_plugin_cli/reserved_ids.py new file mode 100644 index 000000000..5355a2165 --- /dev/null +++ b/plugins/core/cli/surfsense_plugin_cli/reserved_ids.py @@ -0,0 +1,11 @@ +"""Ids SurfSense keeps for its own plugins, which a contributed plugin cannot take.""" + +from surfsense_plugin_cli.repository import CORE + +RESERVED_PREFIX = "surfsense-" + + +def is_reserved(plugin_id: str) -> bool: + """Listed in plugins/core/policy/reserved-plugin-ids.txt, or named surfsense-*.""" + listed = (CORE / "policy" / "reserved-plugin-ids.txt").read_text(encoding="utf-8") + return plugin_id in listed.split() or plugin_id.startswith(RESERVED_PREFIX) diff --git a/plugins/core/cli/tests/conftest.py b/plugins/core/cli/tests/conftest.py new file mode 100644 index 000000000..33b9c452c --- /dev/null +++ b/plugins/core/cli/tests/conftest.py @@ -0,0 +1,271 @@ +import base64 +import contextlib +import hashlib +import json +import os +import shutil +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +import zipfile +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + +BACKEND_DIR = Path(__file__).resolve().parents[4] / "surfsense_local" / "backend" + +# A first `uv run` may still be installing the backend's own packages. +STARTUP_SECONDS = 180 + + +def example_manifest() -> dict: + """A small plugin with one action, which each test changes what it needs in.""" + return { + "id": "example", + "name": "Example", + "description": "Echoes its input.", + "author": "SurfSense", + "access": "free", + "hosts": [], + "actions": [ + { + "name": "echo", + "title": "Echo", + "inputs": [ + {"name": "text", "title": "Text", "kind": "string"}, + ], + } + ], + } + + +@dataclass +class Plugin: + """A plugin folder an author is working on, outside the repository's plugins/.""" + + folder: Path + manifest: dict = field(default_factory=example_manifest) + + def write(self, main: str, files: dict[str, str] | None = None) -> None: + """Lays down the plugin's files, its manifest.json included.""" + self.folder.mkdir(parents=True, exist_ok=True) + (self.folder / "manifest.json").write_text(json.dumps(self.manifest)) + (self.folder / "main.py").write_text(main) + for relative, text in (files or {}).items(): + path = self.folder / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +@pytest.fixture +def plugin(tmp_path: Path) -> Plugin: + """An example plugin folder for a test to write and invoke.""" + return Plugin(folder=tmp_path / "plugins" / "example") + + +@pytest.fixture +def home(tmp_path: Path) -> Path: + """A home folder of the test's own, so no test reads the author's ~/.surfsense.""" + folder = tmp_path / "home" + folder.mkdir() + return folder + + +@pytest.fixture +def cli(home: Path) -> Callable[..., subprocess.CompletedProcess[str]]: + """Runs surfsense-plugins as an author would, in a home of its own.""" + + def run( + *arguments: str, + environment: dict[str, str] | None = None, + typed: str | None = None, + ) -> subprocess.CompletedProcess[str]: + """One call of the command, with what the author typed at any prompt.""" + return subprocess.run( + [sys.executable, "-m", "surfsense_plugin_cli", *arguments], + env={k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} + | {"HOME": str(home), "USERPROFILE": str(home)} + | (environment or {}), + input=typed, + capture_output=True, + text=True, + timeout=300, + ) + + return run + + +@dataclass +class RealApp: + """The app's real API, which invoke runs plugins against.""" + + url: str + + def call(self, method: str, path: str, body: object = None) -> object: + """Calls the app the way any client does, to set up or check a test.""" + request = urllib.request.Request( + self.url + path, + method=method, + data=None if body is None else json.dumps(body).encode(), + headers={} if body is None else {"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read() or b"null") + + def new_workspace(self) -> int: + """A workspace of the test's own, so no test sees another's documents.""" + created = self.call("POST", "/workspaces", {"name": "CLI test"}) + return int(created["id"]) # type: ignore[index] + + +def _free_port() -> int: + """A port nothing listens on, for this session's app.""" + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +def _answers(url: str) -> bool: + """Whether the app is up: /health answers once migrations have run.""" + try: + with urllib.request.urlopen(f"{url}/health", timeout=2) as response: + return response.status == 200 + except (urllib.error.URLError, OSError): + return False + + +@contextlib.contextmanager +def _started_app(folder: Path) -> Iterator[RealApp]: + """The backend on a data folder of its own, as Electron starts it.""" + uv = shutil.which("uv") + if uv is None: + pytest.fail("these tests start the backend with uv, which is not on PATH") + + port = _free_port() + url = f"http://127.0.0.1:{port}" + log = folder / "app.log" + environment = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} | { + "SURFSENSE_LOCAL_DATA_DIR": str(folder / "data"), + "SURFSENSE_LOCAL_SECRET": "cli-tests", + "SURFSENSE_LOCAL_HOST": "127.0.0.1", + "SURFSENSE_LOCAL_PORT": str(port), + } + with log.open("w") as output: + app = subprocess.Popen( + [ + uv, + "run", + "--directory", + str(BACKEND_DIR), + "python", + "-c", + "from api.server import serve; serve()", + ], + env=environment, + stdout=output, + stderr=subprocess.STDOUT, + ) + try: + deadline = time.monotonic() + STARTUP_SECONDS + while not _answers(url): + if app.poll() is not None or time.monotonic() > deadline: + pytest.fail(f"the app did not start:\n{log.read_text()[-3000:]}") + time.sleep(0.5) + yield RealApp(url) + finally: + app.terminate() + try: + app.wait(timeout=15) + except subprocess.TimeoutExpired: + app.kill() + + +@pytest.fixture(scope="session") +def real_app(tmp_path_factory: pytest.TempPathFactory) -> Iterator[RealApp]: + """One app for the session: each test makes the workspaces it needs.""" + with _started_app(tmp_path_factory.mktemp("app")) as app: + yield app + + +@pytest.fixture +def fresh_app(tmp_path: Path) -> Iterator[RealApp]: + """An app as first installed: its one default workspace and nothing else.""" + with _started_app(tmp_path) as app: + yield app + + +ADDS_A_NOTE = """ +from surfsense_plugin_sdk import action, document + + +@action("echo") +def echo(text: str) -> None: + print(document.add(title="Echo", content=text).id) +""" + + +@pytest.fixture +def plugin_that_adds_a_note(plugin: Plugin) -> Plugin: + """The example plugin, printing the id of the note it adds.""" + plugin.write(ADDS_A_NOTE) + return plugin + + +@dataclass +class Wheels: + """A folder of wheels the test builds, standing in for PyPI.""" + + folder: Path + + def make( + self, name: str, version: str, files: dict[str, str], tag: str = "py3-none-any" + ) -> Path: + """A wheel holding these files, for the platforms its tag names.""" + dist = f"{name.replace('-', '_')}-{version}" + wheel = self.folder / f"{dist}-{tag}.whl" + entries = dict(files) | { + f"{dist}.dist-info/METADATA": ( + f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n" + ), + f"{dist}.dist-info/WHEEL": ( + "Wheel-Version: 1.0\nGenerator: tests\n" + f"Root-Is-Purelib: {'true' if tag.endswith('any') else 'false'}\n" + f"Tag: {tag}\n" + ), + } + record = [ + f"{path},sha256={_digest(text.encode())},{len(text.encode())}" + for path, text in entries.items() + ] + entries[f"{dist}.dist-info/RECORD"] = "\n".join( + [*record, f"{dist}.dist-info/RECORD,,"] + ) + with zipfile.ZipFile(wheel, "w") as archive: + for path, text in entries.items(): + archive.writestr(path, text) + return wheel + + @property + def environment(self) -> dict[str, str]: + """What points uv at this folder and nowhere else.""" + return {"UV_OFFLINE": "1", "UV_FIND_LINKS": str(self.folder)} + + +def _digest(content: bytes) -> str: + """A RECORD line's hash: urlsafe base64 of the sha256, without padding.""" + return ( + base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode() + ) + + +@pytest.fixture +def wheels(tmp_path: Path) -> Wheels: + """An empty folder of wheels for the test to fill.""" + folder = tmp_path / "wheels" + folder.mkdir() + return Wheels(folder) diff --git a/plugins/core/cli/tests/invoke/test_choosing_the_workspace.py b/plugins/core/cli/tests/invoke/test_choosing_the_workspace.py new file mode 100644 index 000000000..50d33f657 --- /dev/null +++ b/plugins/core/cli/tests/invoke/test_choosing_the_workspace.py @@ -0,0 +1,48 @@ +import pytest + +pytestmark = pytest.mark.app + + +def test_with_one_workspace_invoke_writes_to_it( + cli, plugin_that_adds_a_note, fresh_app +) -> None: + """A first install has one workspace, and nothing to choose between.""" + [only] = fresh_app.call("GET", "/workspaces") + + finished = cli( + "invoke", + str(plugin_that_adds_a_note.folder), + "echo", + "--input", + "text=hi", + "--api-url", + fresh_app.url, + ) + + assert finished.returncode == 0, finished.stderr + [note] = fresh_app.call("GET", f"/workspaces/{only['id']}/documents") + assert str(note["id"]) == finished.stdout.strip() + + +def test_with_several_workspaces_invoke_lists_them_and_runs_nothing( + cli, plugin_that_adds_a_note, real_app +) -> None: + """Writing into a workspace the author did not pick would surprise them.""" + real_app.new_workspace() + workspaces = real_app.call("GET", "/workspaces") + + finished = cli( + "invoke", + str(plugin_that_adds_a_note.folder), + "echo", + "--input", + "text=hi", + "--api-url", + real_app.url, + ) + + assert finished.returncode == 1 + assert "--workspace" in finished.stderr + for workspace in workspaces: + assert f"{workspace['id']} {workspace['name']}" in finished.stderr + assert finished.stdout == "" diff --git a/plugins/core/cli/tests/invoke/test_data_folder.py b/plugins/core/cli/tests/invoke/test_data_folder.py new file mode 100644 index 000000000..dc6f02c82 --- /dev/null +++ b/plugins/core/cli/tests/invoke/test_data_folder.py @@ -0,0 +1,38 @@ +import pytest + +pytestmark = pytest.mark.app + +COUNTS_ITS_RUNS = """ +from surfsense_plugin_sdk import action, data + + +@action("echo") +def echo(text: str) -> None: + count = data() / "count.txt" + runs = int(count.read_text()) + 1 if count.exists() else 1 + count.write_text(str(runs)) + print(runs) +""" + + +def test_the_plugins_data_is_kept_between_invokes_in_dev_data( + cli, plugin, real_app +) -> None: + """The plugin's memory between runs, beside it and never in the app's copy.""" + plugin.write(COUNTS_ITS_RUNS) + arguments = ( + "invoke", + str(plugin.folder), + "echo", + "--input", + "text=hi", + "--api-url", + real_app.url, + "--workspace", + str(real_app.new_workspace()), + ) + + first, second = cli(*arguments), cli(*arguments) + + assert (first.stdout, second.stdout) == ("1\n", "2\n"), second.stderr + assert (plugin.folder / "dev-data" / "count.txt").read_text() == "2" diff --git a/plugins/core/cli/tests/invoke/test_dependencies.py b/plugins/core/cli/tests/invoke/test_dependencies.py new file mode 100644 index 000000000..1c1933a9d --- /dev/null +++ b/plugins/core/cli/tests/invoke/test_dependencies.py @@ -0,0 +1,111 @@ +import hashlib +import platform +import sys + +import pytest + +IMPORTS_TINYLIB = """ +import tinylib +from surfsense_plugin_sdk import action + + +@action("echo") +def echo(text: str) -> None: + print(tinylib.NAME) +""" + + +def _pin(plugin, wheel, name: str, version: str) -> None: + """requirements.txt as pin-dependencies writes it: exact, with the file's hash.""" + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + (plugin.folder / "requirements.txt").write_text( + f"{name}=={version} \\\n --hash=sha256:{digest}\n" + ) + + +def _tinylib(plugin, wheels, tag: str = "py3-none-any") -> None: + """The plugin, depending on a one-module library built for these platforms.""" + wheel = wheels.make( + "surfsense-test-tinylib", "1.0", {"tinylib.py": 'NAME = "tinylib"\n'}, tag=tag + ) + plugin.write(IMPORTS_TINYLIB) + _pin(plugin, wheel, "surfsense-test-tinylib", "1.0") + + +def _invoke(cli, plugin, wheels, real_app=None): + """Invoke the plugin with uv pointed at the test's wheels only.""" + app = ( + ["--api-url", real_app.url, "--workspace", str(real_app.new_workspace())] + if real_app + else [] + ) + return cli( + "invoke", + str(plugin.folder), + "echo", + "--input", + "text=hi", + *app, + environment=wheels.environment, + ) + + +@pytest.mark.app +def test_a_pinned_dependency_is_installed_for_this_machine_and_imported( + cli, plugin, wheels, real_app +) -> None: + """Installed beside the plugin, the way a release installs it for each system.""" + _tinylib(plugin, wheels) + + finished = _invoke(cli, plugin, wheels, real_app) + + assert finished.returncode == 0, finished.stderr + assert finished.stdout == "tinylib\n" + assert (plugin.folder / "site-packages" / "tinylib.py").is_file() + assert f"Installing dependencies for {_this_platform()}" in finished.stderr + + +@pytest.mark.app +def test_an_unchanged_list_of_dependencies_is_not_installed_again( + cli, plugin, wheels, real_app +) -> None: + """Trying a change should take the time of the run, not of an install.""" + _tinylib(plugin, wheels) + _invoke(cli, plugin, wheels, real_app) + left_by_the_author = plugin.folder / "site-packages" / "left-alone.txt" + left_by_the_author.write_text("still here") + + finished = _invoke(cli, plugin, wheels, real_app) + + assert finished.returncode == 0, finished.stderr + assert left_by_the_author.is_file() + assert "Installing dependencies" not in finished.stderr + + +def _another_platform() -> str: + """A wheel tag for a system this test is not running on.""" + if sys.platform == "win32": + return "cp312-cp312-manylinux_2_17_x86_64" + return "cp312-cp312-win_amd64" + + +def _this_platform() -> str: + """This machine's key, as plugins/core/build-targets.json names it.""" + arm = platform.machine().lower() in ("arm64", "aarch64") + system = {"linux": "linux", "darwin": "macos", "win32": "windows"}[sys.platform] + return f"{system}-{'arm64' if arm else 'x64'}" + + +@pytest.mark.unit +def test_a_dependency_with_no_wheel_for_this_machine_is_refused( + cli, plugin, wheels +) -> None: + """Named with the platform, since invoke only ever installs for this one.""" + _tinylib(plugin, wheels, tag=_another_platform()) + + finished = _invoke(cli, plugin, wheels) + + assert finished.returncode == 1 + assert "surfsense-test-tinylib" in finished.stderr + assert f"for {_this_platform()}" in finished.stderr + assert finished.stdout == "" diff --git a/plugins/core/cli/tests/invoke/test_feedback.py b/plugins/core/cli/tests/invoke/test_feedback.py new file mode 100644 index 000000000..70811b1b0 --- /dev/null +++ b/plugins/core/cli/tests/invoke/test_feedback.py @@ -0,0 +1,65 @@ +import pytest + +pytestmark = pytest.mark.app + + +def _invoke(cli, plugin, real_app, workspace: int): + """Invoke the plugin's echo action in one workspace of the app.""" + return cli( + "invoke", + str(plugin.folder), + "echo", + "--input", + "text=hi", + "--api-url", + real_app.url, + "--workspace", + str(workspace), + ) + + +def test_invoke_says_where_it_runs_and_that_it_finished( + cli, plugin_that_adds_a_note, real_app +) -> None: + """A run that worked is never silent: the author knows where to look.""" + workspace = real_app.new_workspace() + + finished = _invoke(cli, plugin_that_adds_a_note, real_app, workspace) + + assert finished.returncode == 0, finished.stderr + assert f'Running echo of example in "CLI test" at {real_app.url}' in finished.stderr + assert finished.stderr.rstrip().endswith("Done.") + + +def test_invoke_says_how_a_failed_run_ended(cli, plugin, real_app) -> None: + """The exit code the app would record, after the plugin's own reason.""" + plugin.write( + """ +import sys + +from surfsense_plugin_sdk import action + + +@action("echo") +def echo(text: str) -> None: + sys.exit("the source is down") +""" + ) + + finished = _invoke(cli, plugin, real_app, real_app.new_workspace()) + + assert finished.returncode == 1 + assert "the source is down" in finished.stderr + assert finished.stderr.rstrip().endswith("Failed: exit 1.") + + +def test_a_workspace_the_app_does_not_have_is_refused_before_running( + cli, plugin_that_adds_a_note, real_app +) -> None: + """Named, with the workspaces there are, rather than failing inside the plugin.""" + finished = _invoke(cli, plugin_that_adds_a_note, real_app, 999999) + + assert finished.returncode == 1 + assert "SurfSense has no workspace 999999" in finished.stderr + assert "CLI test" in finished.stderr + assert finished.stdout == "" diff --git a/plugins/core/cli/tests/invoke/test_finding_the_app.py b/plugins/core/cli/tests/invoke/test_finding_the_app.py new file mode 100644 index 000000000..7be254513 --- /dev/null +++ b/plugins/core/cli/tests/invoke/test_finding_the_app.py @@ -0,0 +1,82 @@ +import socket + +import pytest + + +def _invoke(cli, plugin, *extra: str, environment=None): + """Invoke the note plugin's action, adding whatever the test is about.""" + return cli( + "invoke", + str(plugin.folder), + "echo", + "--input", + "text=hi", + *extra, + environment=environment, + ) + + +@pytest.mark.app +@pytest.mark.parametrize("folder", [".surfsense-dev", ".surfsense"]) +def test_invoke_finds_the_app_by_the_file_it_writes( + cli, plugin_that_adds_a_note, real_app, home, folder: str +) -> None: + """A development app and a packaged one each announce where they answer.""" + (home / folder).mkdir() + (home / folder / "api-url").write_text(real_app.url) + workspace = real_app.new_workspace() + + finished = _invoke(cli, plugin_that_adds_a_note, "--workspace", str(workspace)) + + assert finished.returncode == 0, finished.stderr + assert real_app.call("GET", f"/workspaces/{workspace}/documents") + + +@pytest.mark.app +def test_invoke_finds_the_app_named_in_the_environment( + cli, plugin_that_adds_a_note, real_app +) -> None: + """The same variable the app's runner sets, so a script can point at an app.""" + workspace = real_app.new_workspace() + + finished = _invoke( + cli, + plugin_that_adds_a_note, + "--workspace", + str(workspace), + environment={"SURFSENSE_PLUGIN_API_URL": real_app.url}, + ) + + assert finished.returncode == 0, finished.stderr + assert real_app.call("GET", f"/workspaces/{workspace}/documents") + + +@pytest.mark.unit +def test_with_no_app_running_invoke_says_how_to_reach_one( + cli, plugin_that_adds_a_note +) -> None: + """The fix is to start SurfSense or name it, not to read a connection error.""" + finished = _invoke(cli, plugin_that_adds_a_note) + + assert finished.returncode == 1 + assert "SurfSense is not running" in finished.stderr + assert "--api-url" in finished.stderr + assert finished.stdout == "" + + +@pytest.mark.unit +def test_an_app_that_no_longer_answers_is_not_running( + cli, plugin_that_adds_a_note, home +) -> None: + """A file left behind by an app that crashed points nowhere.""" + with socket.socket() as unused: + unused.bind(("127.0.0.1", 0)) + closed_port = unused.getsockname()[1] + (home / ".surfsense-dev").mkdir() + (home / ".surfsense-dev" / "api-url").write_text(f"http://127.0.0.1:{closed_port}") + + finished = _invoke(cli, plugin_that_adds_a_note) + + assert finished.returncode == 1 + assert "SurfSense is not running" in finished.stderr + assert finished.stdout == "" diff --git a/plugins/core/cli/tests/invoke/test_inputs.py b/plugins/core/cli/tests/invoke/test_inputs.py new file mode 100644 index 000000000..0478a86c2 --- /dev/null +++ b/plugins/core/cli/tests/invoke/test_inputs.py @@ -0,0 +1,87 @@ +import pytest + +PRINTS_ITS_INPUTS = """ +from surfsense_plugin_sdk import action + + +@action("echo") +def echo(text: str, top: float | None, loud: bool | None, ratio: float | None) -> None: + print(type(text).__name__, repr(top), repr(loud), repr(ratio)) +""" + + +def _declaring_every_kind(plugin) -> None: + """One required text input, and an optional one of every other kind.""" + plugin.manifest["actions"][0]["inputs"] = [ + {"name": "text", "title": "Text", "kind": "string", "required": True}, + {"name": "top", "title": "Top", "kind": "number"}, + {"name": "loud", "title": "Loud", "kind": "boolean"}, + {"name": "ratio", "title": "Ratio", "kind": "number"}, + ] + + +@pytest.mark.app +def test_inputs_arrive_as_their_declared_kinds(cli, plugin, real_app) -> None: + """What the author types is text; the plugin gets what its manifest declares.""" + _declaring_every_kind(plugin) + plugin.write(PRINTS_ITS_INPUTS) + + finished = cli( + "invoke", + str(plugin.folder), + "echo", + "--input", + "text=hi", + "--input", + "top=5", + "--input", + "loud=true", + "--input", + "ratio=0.5", + "--api-url", + real_app.url, + "--workspace", + str(real_app.new_workspace()), + ) + + assert finished.returncode == 0, finished.stderr + assert finished.stdout == "str 5 True 0.5\n" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("typed", "reason"), + [ + (["text=hi", "colour=red"], 'echo has no input named "colour"'), + (["text=hi", "top=many"], "top must be a number, not many"), + (["text=hi", "loud=yes"], "loud must be true or false, not yes"), + (["top=5"], "text is required: pass --input text=…"), + (["text"], "text: an input is name=value"), + ], +) +def test_an_input_the_action_cannot_take_is_refused_before_running( + cli, plugin, typed: list[str], reason: str +) -> None: + """Refused as the app's run route refuses it, before any app is involved.""" + _declaring_every_kind(plugin) + plugin.write(PRINTS_ITS_INPUTS) + arguments = [part for entry in typed for part in ("--input", entry)] + + finished = cli("invoke", str(plugin.folder), "echo", *arguments) + + assert finished.returncode == 1 + assert reason in finished.stderr + assert finished.stdout == "" + + +@pytest.mark.unit +def test_an_action_the_plugin_does_not_declare_is_refused(cli, plugin) -> None: + """Named plainly, with the actions the plugin does have.""" + plugin.write(PRINTS_ITS_INPUTS) + + finished = cli("invoke", str(plugin.folder), "search", "--input", "text=hi") + + assert finished.returncode == 1 + assert 'example has no action named "search": its actions are echo' in ( + finished.stderr + ) diff --git a/plugins/core/cli/tests/invoke/test_running.py b/plugins/core/cli/tests/invoke/test_running.py new file mode 100644 index 000000000..61566fb3e --- /dev/null +++ b/plugins/core/cli/tests/invoke/test_running.py @@ -0,0 +1,58 @@ +import pytest + +pytestmark = pytest.mark.app + + +def test_invoke_runs_the_action_once_against_the_app( + cli, plugin_that_adds_a_note, real_app +) -> None: + """The author's plugin writes into their SurfSense, as it will once installed.""" + workspace = real_app.new_workspace() + + finished = cli( + "invoke", + str(plugin_that_adds_a_note.folder), + "echo", + "--input", + "text=hi", + "--api-url", + real_app.url, + "--workspace", + str(workspace), + ) + + assert finished.returncode == 0, finished.stderr + note = real_app.call( + "GET", f"/workspaces/{workspace}/documents/{finished.stdout.strip()}" + ) + assert note["content"] == "hi" + + +def test_invoke_exits_with_the_plugins_own_code(cli, plugin, real_app) -> None: + """A script or CI step can tell a failed run from a good one.""" + plugin.write( + """ +import sys + +from surfsense_plugin_sdk import action + + +@action("echo") +def echo(text: str) -> None: + sys.exit(3) +""" + ) + + finished = cli( + "invoke", + str(plugin.folder), + "echo", + "--input", + "text=hi", + "--api-url", + real_app.url, + "--workspace", + str(real_app.new_workspace()), + ) + + assert finished.returncode == 3 diff --git a/plugins/core/cli/tests/invoke/test_secrets.py b/plugins/core/cli/tests/invoke/test_secrets.py new file mode 100644 index 000000000..b3573debd --- /dev/null +++ b/plugins/core/cli/tests/invoke/test_secrets.py @@ -0,0 +1,62 @@ +import pytest + +pytestmark = pytest.mark.app + +PRINTS_ITS_TOKEN = """ +from surfsense_plugin_sdk import action, secret + + +@action("echo") +def echo(text: str) -> None: + print(secret("token")) +""" + + +def _declaring_a_token(plugin) -> None: + """The secret the plugin reads, as its manifest declares it.""" + plugin.manifest["secrets"] = [{"name": "token", "title": "API token"}] + plugin.write(PRINTS_ITS_TOKEN) + + +def _invoke(cli, plugin, real_app, environment=None, typed=None): + """Invoke the token plugin, giving the secret whichever way the test says.""" + return cli( + "invoke", + str(plugin.folder), + "echo", + "--input", + "text=hi", + "--api-url", + real_app.url, + "--workspace", + str(real_app.new_workspace()), + environment=environment, + typed=typed, + ) + + +def test_a_secret_comes_from_the_authors_own_environment(cli, plugin, real_app) -> None: + """The same variable the app hands the plugin, set in the author's shell.""" + _declaring_a_token(plugin) + + finished = _invoke( + cli, plugin, real_app, environment={"SURFSENSE_PLUGIN_SECRET_TOKEN": "s3cret"} + ) + + assert finished.returncode == 0, finished.stderr + assert finished.stdout == "s3cret\n" + + +def test_a_secret_not_in_the_environment_is_asked_for_and_kept_nowhere( + cli, plugin, real_app +) -> None: + """Asked for by its title, never a flag, so it stays out of shell history.""" + _declaring_a_token(plugin) + + finished = _invoke(cli, plugin, real_app, typed="s3cret\n") + + assert finished.returncode == 0, finished.stderr + assert finished.stdout == "s3cret\n" + assert "API token" in finished.stderr + written = [path for path in plugin.folder.rglob("*") if path.is_file()] + assert not any(b"s3cret" in path.read_bytes() for path in written) diff --git a/plugins/core/cli/tests/test_build_targets.py b/plugins/core/cli/tests/test_build_targets.py new file mode 100644 index 000000000..da9b98681 --- /dev/null +++ b/plugins/core/cli/tests/test_build_targets.py @@ -0,0 +1,27 @@ +import platform +import sys + +import pytest +import typer + +from surfsense_plugin_cli.build_targets import this_platform + +pytestmark = pytest.mark.unit + + +def test_a_machine_plugins_are_not_built_for_is_told_which_are( + monkeypatch, capsys +) -> None: + """Named with the platforms that are supported, not failing later inside uv. + + Called directly: the command cannot be started on a system it does not run on. + """ + monkeypatch.setattr(sys, "platform", "freebsd14") + monkeypatch.setattr(platform, "machine", lambda: "riscv64") + + with pytest.raises(typer.Exit): + this_platform() + + message = capsys.readouterr().err + assert "this machine is freebsd14-x64" in message + assert "linux-x64" in message diff --git a/plugins/core/cli/tests/test_dependencies.py b/plugins/core/cli/tests/test_dependencies.py new file mode 100644 index 000000000..77e29994d --- /dev/null +++ b/plugins/core/cli/tests/test_dependencies.py @@ -0,0 +1,159 @@ +import pytest + +pytestmark = pytest.mark.unit + +TINYLIB = "surfsense-test-tinylib" +OTHERLIB = "surfsense-test-otherlib" + + +def _library(wheels, name: str, version: str = "1.0") -> None: + """A one-module library the test's wheel folder offers.""" + module = name.rsplit("-", 1)[1] + wheels.make(name, version, {f"{module}.py": f'NAME = "{module}"\n'}) + + +def _files(plugin) -> tuple[str, str]: + """requirements.in and requirements.txt, empty when absent.""" + read = [plugin.folder / "requirements.in", plugin.folder / "requirements.txt"] + return tuple(path.read_text() if path.exists() else "" for path in read) + + +@pytest.fixture +def written(plugin): + """The example plugin, with no dependencies yet.""" + plugin.write("from surfsense_plugin_sdk import action\n") + return plugin + + +def test_add_lists_the_library_and_pins_it_with_hashes(cli, written, wheels) -> None: + """The short list for review, and the exact files for every platform.""" + _library(wheels, TINYLIB) + + finished = cli("add", str(written.folder), TINYLIB, environment=wheels.environment) + + assert finished.returncode == 0, finished.stderr + listed, pinned = _files(written) + assert listed == f"{TINYLIB}\n" + assert f"{TINYLIB}==1.0" in pinned + assert "--hash=sha256:" in pinned + assert f"Added {TINYLIB}" in finished.stdout + assert "requirements.txt pins 1 package" in finished.stdout + + +def test_adding_a_library_keeps_the_versions_already_pinned( + cli, written, wheels +) -> None: + """Adding one library upgrades nothing else.""" + _library(wheels, TINYLIB, "1.0") + cli("add", str(written.folder), TINYLIB, environment=wheels.environment) + _library(wheels, TINYLIB, "2.0") + _library(wheels, OTHERLIB) + + finished = cli("add", str(written.folder), OTHERLIB, environment=wheels.environment) + + assert finished.returncode == 0, finished.stderr + listed, pinned = _files(written) + assert listed == f"{TINYLIB}\n{OTHERLIB}\n" + assert f"{TINYLIB}==1.0" in pinned + assert f"{OTHERLIB}==1.0" in pinned + + +def test_remove_drops_a_library_from_both_files(cli, written, wheels) -> None: + """What the plugin no longer imports is no longer installed.""" + _library(wheels, TINYLIB) + _library(wheels, OTHERLIB) + cli("add", str(written.folder), TINYLIB, OTHERLIB, environment=wheels.environment) + + finished = cli( + "remove", str(written.folder), TINYLIB, environment=wheels.environment + ) + + assert finished.returncode == 0, finished.stderr + listed, pinned = _files(written) + assert TINYLIB not in listed + pinned + assert f"{OTHERLIB}==1.0" in pinned + assert f"Removed {TINYLIB}" in finished.stdout + assert "requirements.txt pins 1 package" in finished.stdout + + +def test_removing_the_last_library_removes_both_files(cli, written, wheels) -> None: + """A plugin with no dependencies has neither file.""" + _library(wheels, TINYLIB) + cli("add", str(written.folder), TINYLIB, environment=wheels.environment) + + finished = cli( + "remove", str(written.folder), TINYLIB, environment=wheels.environment + ) + + assert finished.returncode == 0, finished.stderr + assert not (written.folder / "requirements.in").exists() + assert not (written.folder / "requirements.txt").exists() + assert "example has no dependencies left" in finished.stdout + + +def test_removing_a_library_the_plugin_does_not_list_is_refused(cli, written) -> None: + """Named, rather than silently doing nothing.""" + finished = cli("remove", str(written.folder), TINYLIB) + + assert finished.returncode == 1 + assert f"example does not list {TINYLIB}" in finished.stderr + + +def test_adding_a_library_that_cannot_be_found_changes_nothing( + cli, written, wheels +) -> None: + """The list stays as it was, so the plugin is never left half pinned.""" + finished = cli( + "add", + str(written.folder), + "surfsense-test-missing", + environment=wheels.environment, + ) + + assert finished.returncode == 1 + assert "surfsense-test-missing" in finished.stderr + assert _files(written) == ("", "") + + +def test_pin_dependencies_pins_again_after_a_hand_edit(cli, written, wheels) -> None: + """For a version range written by hand, or a platform build-targets.json gains.""" + _library(wheels, TINYLIB) + (written.folder / "requirements.in").write_text(f"{TINYLIB}>=1\n") + + finished = cli( + "pin-dependencies", str(written.folder), environment=wheels.environment + ) + + assert finished.returncode == 0, finished.stderr + listed, pinned = _files(written) + assert listed == f"{TINYLIB}>=1\n" + assert f"{TINYLIB}==1.0" in pinned + assert "requirements.txt pins 1 package" in finished.stdout + + +def test_the_pinned_file_says_which_command_made_it(cli, written, wheels) -> None: + """A reader who opens requirements.txt learns how to change it, not to edit it.""" + _library(wheels, TINYLIB) + + cli("add", str(written.folder), TINYLIB, environment=wheels.environment) + + _, pinned = _files(written) + assert "surfsense-plugins pin-dependencies example" in pinned + + +def test_a_removal_that_cannot_be_pinned_changes_nothing(cli, written, wheels) -> None: + """Both files stay as they were, so the plugin is never left half pinned.""" + _library(wheels, TINYLIB) + _library(wheels, OTHERLIB) + cli("add", str(written.folder), TINYLIB, OTHERLIB, environment=wheels.environment) + before = _files(written) + for wheel in wheels.folder.glob("surfsense_test_otherlib-*.whl"): + wheel.unlink() + + finished = cli( + "remove", str(written.folder), TINYLIB, environment=wheels.environment + ) + + assert finished.returncode == 1 + assert "the dependencies cannot be pinned" in finished.stderr + assert _files(written) == before diff --git a/plugins/core/cli/tests/test_example.py b/plugins/core/cli/tests/test_example.py new file mode 100644 index 000000000..54021ef41 --- /dev/null +++ b/plugins/core/cli/tests/test_example.py @@ -0,0 +1,51 @@ +import re +import shutil + +import pytest + +from surfsense_plugin_cli.repository import PLUGINS + +pytestmark = pytest.mark.app + + +@pytest.fixture +def example(tmp_path): + """A copy of plugins/example as git holds it, without what an invoke left there.""" + copy = tmp_path / "plugins" / "example" + left_by_invoke = shutil.ignore_patterns("dev-data", "site-packages", "__pycache__") + shutil.copytree(PLUGINS / "example", copy, ignore=left_by_invoke) + return copy + + +def test_the_example_counts_words_into_numbered_notes(cli, example, real_app) -> None: + """The plugin the guide points at, dependency and all, as an author first runs it. + + Installs tabulate from PyPI: the one test that needs the network. + """ + workspace = real_app.new_workspace() + arguments = ( + "invoke", + str(example), + "count-words", + "--input", + "text=a b a", + "--input", + "top=2", + "--api-url", + real_app.url, + "--workspace", + str(workspace), + ) + + first, second = cli(*arguments), cli(*arguments) + + assert first.returncode == 0, first.stderr + assert second.returncode == 0, second.stderr + notes = real_app.call("GET", f"/workspaces/{workspace}/documents") + assert sorted(note["title"] for note in notes) == ["Word count #1", "Word count #2"] + [latest] = [note for note in notes if note["title"] == "Word count #2"] + read = real_app.call("GET", f"/workspaces/{workspace}/documents/{latest['id']}") + content = read["content"] + assert content.startswith("a b a\n") + assert re.search(r"^\| a\s+\|\s+2 \|$", content, re.MULTILINE) + assert re.search(r"^\| b\s+\|\s+1 \|$", content, re.MULTILINE) diff --git a/plugins/core/cli/tests/test_new.py b/plugins/core/cli/tests/test_new.py new file mode 100644 index 000000000..68d3ed148 --- /dev/null +++ b/plugins/core/cli/tests/test_new.py @@ -0,0 +1,108 @@ +import pytest +from surfsense_plugin_manifest import load_manifest + + +@pytest.mark.unit +def test_new_lays_out_a_plugin_that_passes_the_manifest_rules(cli, tmp_path) -> None: + """A valid start, with its code in a package named after the plugin.""" + folder = tmp_path / "plugins" / "hn-search" + + finished = cli("new", str(folder)) + + assert finished.returncode == 0, finished.stderr + manifest = load_manifest(folder / "manifest.json") + assert (manifest.id, manifest.access, manifest.hosts) == ("hn-search", "free", []) + assert (folder / "main.py").is_file() + assert (folder / "hn_search" / "__init__.py").is_file() + + +@pytest.mark.unit +def test_new_says_how_to_try_the_plugin(cli, tmp_path) -> None: + """The next step, ready to copy.""" + finished = cli("new", str(tmp_path / "plugins" / "hn-search")) + + assert "surfsense-plugins invoke hn-search add-note --input text=" in ( + finished.stdout + ) + + +@pytest.mark.unit +def test_new_signs_the_plugin_with_the_authors_git_name(cli, tmp_path, home) -> None: + """The author field, filled from who the author already is.""" + (home / ".gitconfig").write_text("[user]\n\tname = Ada Lovelace\n") + folder = tmp_path / "plugins" / "hn-search" + + cli("new", str(folder)) + + assert load_manifest(folder / "manifest.json").author == "Ada Lovelace" + + +@pytest.mark.app +def test_a_new_plugin_runs_with_invoke_unchanged(cli, tmp_path, real_app) -> None: + """Nothing to fix before the first run.""" + folder = tmp_path / "plugins" / "hn-search" + cli("new", str(folder)) + workspace = real_app.new_workspace() + + finished = cli( + "invoke", + str(folder), + "add-note", + "--input", + "text=hi", + "--api-url", + real_app.url, + "--workspace", + str(workspace), + ) + + assert finished.returncode == 0, finished.stderr + [note] = real_app.call("GET", f"/workspaces/{workspace}/documents") + assert note["title"] == "Note" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("name", "reason"), + [ + ("HN Search", 'must be lowercase letters, digits and "-"'), + ("core", "core is reserved for SurfSense's own plugins"), + ("surfsense-scraper", "surfsense-scraper is reserved for SurfSense's own"), + ], +) +def test_new_refuses_an_id_it_cannot_take( + cli, tmp_path, name: str, reason: str +) -> None: + """Refused before anything is written, with the rule it breaks.""" + folder = tmp_path / "plugins" / name + + finished = cli("new", str(folder)) + + assert finished.returncode == 1 + assert reason in finished.stderr + assert not folder.exists() + + +@pytest.mark.unit +def test_new_never_writes_over_a_plugin_that_exists(cli, tmp_path) -> None: + """An author's work is never replaced by a template.""" + folder = tmp_path / "plugins" / "hn-search" + folder.mkdir(parents=True) + (folder / "main.py").write_text("# mine\n") + + finished = cli("new", str(folder)) + + assert finished.returncode == 1 + assert "hn-search already exists" in finished.stderr + assert (folder / "main.py").read_text() == "# mine\n" + + +@pytest.mark.unit +def test_new_works_on_a_machine_without_git(cli, tmp_path) -> None: + """The author field is left to fill in, rather than new failing.""" + folder = tmp_path / "plugins" / "hn-search" + + finished = cli("new", str(folder), environment={"PATH": ""}) + + assert finished.returncode == 0, finished.stderr + assert load_manifest(folder / "manifest.json").author == "" diff --git a/plugins/core/cli/uv.lock b/plugins/core/cli/uv.lock new file mode 100644 index 000000000..e0d03c26b --- /dev/null +++ b/plugins/core/cli/uv.lock @@ -0,0 +1,303 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "surfsense-plugin-cli" +version = "0.0.0" +source = { editable = "." } +dependencies = [ + { name = "surfsense-plugin-manifest" }, + { name = "typer" }, + { name = "uv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "surfsense-plugin-manifest", editable = "../manifest" }, + { name = "typer", specifier = ">=0.15" }, + { name = "uv", specifier = ">=0.8" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "surfsense-plugin-manifest" +version = "0.0.0" +source = { editable = "../manifest" } +dependencies = [ + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [{ name = "pydantic", specifier = ">=2.7" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "typer" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uv" +version = "0.12.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/86/a1c3183aa0b41f24f6faecf44ef9383fcb4d1bf6c4c4446c2293ebe9b3e1/uv-0.12.21.tar.gz", hash = "sha256:ff77ff843bd3fd0920a18558882532a27079c1f465a4d1781648aff176a180cb", size = 7136775, upload-time = "2026-09-29T21:12:54.498Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/a0/ac841d6a628b7b5d5108cd797f6200f317259d2d02b6b83918d3cc183112/uv-0.12.21-py3-none-linux_armv6l.whl", hash = "sha256:d5ce9a01d7123760c8da049a5e1cfb6d341357c09baf06181f618ee4948e9242", size = 22672670, upload-time = "2026-09-29T21:11:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/51/f0/8673a5aeb771f99aadd269aa069874b10b8ed43fcb3a08a88d3770c27ad9/uv-0.12.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9c6fab087f35c8f8c0c79ad4c8a94f6804b983369c2887250fb753a70d8080fd", size = 20942550, upload-time = "2026-09-29T21:11:55.067Z" }, + { url = "https://files.pythonhosted.org/packages/66/72/389b430ec12bd15547d4d3641622479cbf7a5c94af0ffb14fee2ed58f891/uv-0.12.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eb4f75e8ed770e1f142e03a134b9507e7f4710c4b2cec385d4bf8c5810a91094", size = 17124497, upload-time = "2026-09-29T21:11:58.029Z" }, + { url = "https://files.pythonhosted.org/packages/0e/05/6c536d9cf7977d3c55b8835a90d1e9e58def00309bec0e2ad062157d4499/uv-0.12.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b7db8b4913e6cd34e6aabbe08ff82fbac7c1edae57747ea23118ca460f060d44", size = 22122033, upload-time = "2026-09-29T21:12:01.92Z" }, + { url = "https://files.pythonhosted.org/packages/09/93/5662923c9dbe1d4faf11983e226c247f1f651059afb1a227d2571940a183/uv-0.12.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:7a0f0f21ce252dc1bf3b2b3634b76b2fa0e3014a6b4bc5d908af408bb83d61e3", size = 22155782, upload-time = "2026-09-29T21:12:05.234Z" }, + { url = "https://files.pythonhosted.org/packages/fd/61/370eb13eb6a4578abd4f27d0013647aaee137c6a21e7bdf54ce1e610a138/uv-0.12.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:14f21c497a85bb19b3158f36a966d8619246548651d82a72fca599fdab0752d0", size = 22211042, upload-time = "2026-09-29T21:12:08.684Z" }, + { url = "https://files.pythonhosted.org/packages/40/8e/b69ff0ca272c049a12926a3cc61b520e7e0441039341087b8f4f737e015e/uv-0.12.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:618f422e4544049c589f1e61110293da1f26949fbb266a4c2f22a03880d441a9", size = 22888639, upload-time = "2026-09-29T21:12:12.228Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f9/5bbca3d1916562669311ffd2e203637291a972bfc92e0dee5cfc0df2856b/uv-0.12.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a90f4bb6da0880b7fbe770d9d598d6cf0a6672da28c921ce397383b1134e37a", size = 24232948, upload-time = "2026-09-29T21:12:15.82Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/4b9fb1c2bd3358298fa8342fbb90feeaf88a7a102debfc559f4d9054fad1/uv-0.12.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6988f1025a776cf75d9ad2eb2b9bd26cde38cc1a86dab0787433d7185306bba", size = 23916436, upload-time = "2026-09-29T21:12:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2b/8700206efe193fc279fe32c11ab9a502c8364e7882e3f6f36a00a6cc8af7/uv-0.12.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0083454d25479f1cd03547c8c98c235b4af92bd07f0e61c3eae5d1cbb0c1834c", size = 20438857, upload-time = "2026-09-29T21:12:23.715Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5a/09c78f5864c33958cd6d2682f71cd03d44265669a5c3cd426abecc369000/uv-0.12.21-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fcd28fd87972f677f0e12d577842deb5d445adf36bcd4cd1ebaaec4c67788d7f", size = 19809618, upload-time = "2026-09-29T21:12:27.293Z" }, + { url = "https://files.pythonhosted.org/packages/3f/66/4b9cbfb6d6e8acc9617681fb4c540c531d420637aee6e49810fc758e4e89/uv-0.12.21-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:8e1e2c602e9402b1d739c7278a5008b15a42349b26732c1f919f0d7cc751dda1", size = 23004634, upload-time = "2026-09-29T21:12:30.906Z" }, + { url = "https://files.pythonhosted.org/packages/d9/78/084b6fe10aa8041da99f40b7e41d71224a69934ad87132b2f123cac915bf/uv-0.12.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6bda0425c9e9dab543d43afd329ed0983d31ff687a49ca76508d708b4f35e1ef", size = 23126185, upload-time = "2026-09-29T21:12:34.457Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/089aec72d4eabad95bc2613994b527be3a6048138d41a5272486888a596a/uv-0.12.21-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8c321dc01220ce5c8dcc938b145762945ecccfb26068aad8163d18a544cc03e4", size = 22033476, upload-time = "2026-09-29T21:12:37.965Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/d359f667826d67bf1acd2a80ce4e2fe0bb661e796c296a8b7ff6c386db9f/uv-0.12.21-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:26aaa1dce3ae90502b2d7aa15577587aa185e5ba13480afd03df6c95dc2bb55b", size = 23403470, upload-time = "2026-09-29T21:12:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c9/d80c0baa16ccb4bcf03b97032bf4f9a8bec5e4f8e4770800563892fc9fa2/uv-0.12.21-py3-none-win32.whl", hash = "sha256:448df56d46151b69c7ae210f24827eddea74c2dba2e37cc069db4385d3e458f6", size = 20054450, upload-time = "2026-09-29T21:12:45.335Z" }, + { url = "https://files.pythonhosted.org/packages/2a/06/bda383b338dd21bcd74c9b4ba47301315573fdb5aba46fe40835f8299482/uv-0.12.21-py3-none-win_amd64.whl", hash = "sha256:8c7c3eb32d84c711906dedd09df7379cf4d9f5bb84b8e58a1244ca0a8a1209d4", size = 18087242, upload-time = "2026-09-29T21:12:48.587Z" }, + { url = "https://files.pythonhosted.org/packages/2c/76/1b6478ff9dcc3b58b2025828e00f06bac4915b7442c8254d5b6265707561/uv-0.12.21-py3-none-win_arm64.whl", hash = "sha256:79dc0c11e7f169f1b32cf6838b6a0d651ba054cc8013e0b2e5fe34392c1d1528", size = 19369672, upload-time = "2026-09-29T21:12:52.037Z" }, +] diff --git a/plugins/core/policy/reserved-plugin-ids.txt b/plugins/core/policy/reserved-plugin-ids.txt new file mode 100644 index 000000000..f5bd37c12 --- /dev/null +++ b/plugins/core/policy/reserved-plugin-ids.txt @@ -0,0 +1 @@ +core diff --git a/plugins/example/example/__init__.py b/plugins/example/example/__init__.py new file mode 100644 index 000000000..6d11185d4 --- /dev/null +++ b/plugins/example/example/__init__.py @@ -0,0 +1,2 @@ +"""The word counter's own code, in a package named after it, so no module of its +own can clash with a library.""" diff --git a/plugins/example/example/count_words.py b/plugins/example/example/count_words.py new file mode 100644 index 000000000..7ea5baaef --- /dev/null +++ b/plugins/example/example/count_words.py @@ -0,0 +1,26 @@ +"""The count-words action: a text's most frequent words, kept as a note.""" + +import re +from collections import Counter + +from surfsense_plugin_sdk import action, document +from tabulate import tabulate + +from example.notes_added import next_note_number + +# How many words the table lists when the user leaves it empty. +TOP_WORDS = 10 + + +@action("count-words") +def count_words(text: str, top: float | None) -> None: + """Adds a note with the text and a table of its most frequent words.""" + words = Counter(re.findall(r"\w+", text.lower())) + table = tabulate( + words.most_common(int(top or TOP_WORDS)), + headers=["Word", "Count"], + tablefmt="github", + ) + document.add( + title=f"Word count #{next_note_number()}", content=f"{text}\n\n{table}\n" + ) diff --git a/plugins/example/example/notes_added.py b/plugins/example/example/notes_added.py new file mode 100644 index 000000000..cc84ff7f2 --- /dev/null +++ b/plugins/example/example/notes_added.py @@ -0,0 +1,11 @@ +"""How many notes the word counter has added, kept in its own data folder.""" + +from surfsense_plugin_sdk import data + + +def next_note_number() -> int: + """One more than last time; the folder survives updates, so the count does too.""" + counter = data() / "notes-added.txt" + number = int(counter.read_text()) + 1 if counter.exists() else 1 + counter.write_text(str(number)) + return number diff --git a/plugins/example/main.py b/plugins/example/main.py new file mode 100644 index 000000000..08047182c --- /dev/null +++ b/plugins/example/main.py @@ -0,0 +1,6 @@ +"""Where SurfSense starts the word counter. + +Importing a module registers the actions it marks with @action. +""" + +import example.count_words # noqa: F401 diff --git a/plugins/example/manifest.json b/plugins/example/manifest.json new file mode 100644 index 000000000..3c0039846 --- /dev/null +++ b/plugins/example/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "example", + "name": "Word counter", + "description": "Counts the words in a text and keeps the count as a note.", + "author": "SurfSense", + "access": "free", + "hosts": [], + "actions": [ + { + "name": "count-words", + "title": "Count words", + "inputs": [ + { "name": "text", "title": "Text", "kind": "string", "required": true }, + { "name": "top", "title": "How many words to list", "kind": "number" } + ] + } + ] +} diff --git a/plugins/example/requirements.in b/plugins/example/requirements.in new file mode 100644 index 000000000..a5d51591e --- /dev/null +++ b/plugins/example/requirements.in @@ -0,0 +1 @@ +tabulate diff --git a/plugins/example/requirements.txt b/plugins/example/requirements.txt new file mode 100644 index 000000000..3e8b5caf7 --- /dev/null +++ b/plugins/example/requirements.txt @@ -0,0 +1,6 @@ +# This file was autogenerated by uv via the following command: +# surfsense-plugins pin-dependencies example +tabulate==0.10.0 \ + --hash=sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d \ + --hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3 + # via -r requirements.in diff --git a/surfsense_local/electron/src/main/api-url/announce-api-url.test.ts b/surfsense_local/electron/src/main/api-url/announce-api-url.test.ts new file mode 100644 index 000000000..99e62e5aa --- /dev/null +++ b/surfsense_local/electron/src/main/api-url/announce-api-url.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict" +import { existsSync, mkdtempSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" + +import { announceApiUrl, withdrawApiUrl } from "./announce-api-url.ts" + +test("writes the API's address where a plugin author's tools look for it", () => { + const dataDir = mkdtempSync(join(tmpdir(), "api-url-")) + + announceApiUrl(dataDir, "http://127.0.0.1:5123") + + assert.equal(readFileSync(join(dataDir, "api-url"), "utf8"), "http://127.0.0.1:5123") +}) + +test("removes it on quit, so nothing points at an app that is gone", () => { + const dataDir = mkdtempSync(join(tmpdir(), "api-url-")) + announceApiUrl(dataDir, "http://127.0.0.1:5123") + + withdrawApiUrl(dataDir) + + assert.equal(existsSync(join(dataDir, "api-url")), false) +}) + +test("removing it when it is already gone is harmless", () => { + const dataDir = mkdtempSync(join(tmpdir(), "api-url-")) + + assert.doesNotThrow(() => withdrawApiUrl(dataDir)) +}) diff --git a/surfsense_local/electron/src/main/api-url/announce-api-url.ts b/surfsense_local/electron/src/main/api-url/announce-api-url.ts new file mode 100644 index 000000000..cb7ac5257 --- /dev/null +++ b/surfsense_local/electron/src/main/api-url/announce-api-url.ts @@ -0,0 +1,17 @@ +// Where the API answers, for tools outside the app: `surfsense-plugins invoke` +// reads it, so a plugin author never looks up a port that changes every launch. +// It discloses nothing: loopback already answers a port scan. +import { rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +const FILE = "api-url" + +/** Written once the API answers, in the app's own data folder. */ +export function announceApiUrl(dataDir: string, apiUrl: string): void { + writeFileSync(join(dataDir, FILE), apiUrl) +} + +/** Removed on quit, so no tool finds an app that is gone. */ +export function withdrawApiUrl(dataDir: string): void { + rmSync(join(dataDir, FILE), { force: true }) +} diff --git a/surfsense_local/electron/src/main/index.ts b/surfsense_local/electron/src/main/index.ts index 1c6dc13f9..59f694402 100644 --- a/surfsense_local/electron/src/main/index.ts +++ b/surfsense_local/electron/src/main/index.ts @@ -62,6 +62,7 @@ import { type ThemePreference, } from "./theme-prefs.ts" import { loadWindowState, saveWindowState } from "./window-state.ts" +import { announceApiUrl, withdrawApiUrl } from "./api-url/announce-api-url.ts" import { applyLocalePreference } from "./i18n/app-locale.ts" import { loadLocalePreference } from "./i18n/locale-prefs.ts" import { registerLocaleHandlers } from "./i18n/locale-ipc.ts" @@ -549,6 +550,7 @@ function createWindow(apiUrl: string): void { async function shutdown(): Promise { if (shuttingDown) return shuttingDown = true + withdrawApiUrl(DATA_DIR) if (sidecars) await stopAll(sidecars) } @@ -592,6 +594,7 @@ function main(): void { applyLocalePreference(loadLocalePreference()) applyDevAppIdentity() const boot = await bootSidecars() + announceApiUrl(boot.dataDir, boot.apiUrl) registerDocumentHandlers(boot.dataDir) registerLocaleHandlers({ isTrusted: (sender) =>