mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-10-19 07:34:58 +00:00
feat(plugins): add the surfsense-plugins command for plugin authors
This commit is contained in:
parent
68ea7105bb
commit
76e59c4897
66 changed files with 2718 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
|
|
|
|||
|
|
@ -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 <id>` refuses an id that breaks the id rule, is taken, or is reserved, and says which. Otherwise it lays down `plugins/<id>/`: 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 <plugin> <requirement>…` and `remove <plugin> <name>…` 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 <plugin> <action> [--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_<NAME>`, else from a hidden prompt, never from a flag, so secrets stay out of shell history.
|
||||
- Installs `requirements.txt` into `plugins/<id>/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/<id>/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 <code>.`, 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.
|
||||
|
|
|
|||
4
plugins/.gitignore
vendored
4
plugins/.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
110
plugins/README.md
Normal file
110
plugins/README.md
Normal file
|
|
@ -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/<id>/`, 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.
|
||||
8
plugins/core/build-targets.json
Normal file
8
plugins/core/build-targets.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
66
plugins/core/cli/pyproject.toml
Normal file
66
plugins/core/cli/pyproject.toml
Normal file
|
|
@ -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"
|
||||
1
plugins/core/cli/surfsense_plugin_cli/__init__.py
Normal file
1
plugins/core/cli/surfsense_plugin_cli/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""The surfsense-plugins command: what authors, CI and maintainers run outside the app."""
|
||||
5
plugins/core/cli/surfsense_plugin_cli/__main__.py
Normal file
5
plugins/core/cli/surfsense_plugin_cli/__main__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""python -m surfsense_plugin_cli, the same command as surfsense-plugins."""
|
||||
|
||||
from surfsense_plugin_cli.main import app
|
||||
|
||||
app()
|
||||
41
plugins/core/cli/surfsense_plugin_cli/build_targets.py
Normal file
41
plugins/core/cli/surfsense_plugin_cli/build_targets.py
Normal file
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""A plugin's dependencies: the short list it asks for, and the pins every platform installs."""
|
||||
32
plugins/core/cli/surfsense_plugin_cli/dependencies/add.py
Normal file
32
plugins/core/cli/surfsense_plugin_cli/dependencies/add.py
Normal file
|
|
@ -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}.")
|
||||
16
plugins/core/cli/surfsense_plugin_cli/dependencies/listed.py
Normal file
16
plugins/core/cli/surfsense_plugin_cli/dependencies/listed.py
Normal file
|
|
@ -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 ""
|
||||
|
|
@ -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} <library>",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
typer.echo(f"{rewrite_and_pin(folder, lines)}.")
|
||||
31
plugins/core/cli/surfsense_plugin_cli/dependencies/remove.py
Normal file
31
plugins/core/cli/surfsense_plugin_cli/dependencies/remove.py
Normal file
|
|
@ -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}.")
|
||||
|
|
@ -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")
|
||||
1
plugins/core/cli/surfsense_plugin_cli/invoke/__init__.py
Normal file
1
plugins/core/cli/surfsense_plugin_cli/invoke/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Runs one action of a plugin once, against the author's running SurfSense."""
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""The author's running SurfSense: finding it, and choosing the workspace to write to."""
|
||||
23
plugins/core/cli/surfsense_plugin_cli/invoke/app/app_api.py
Normal file
23
plugins/core/cli/surfsense_plugin_cli/invoke/app/app_api.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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)
|
||||
33
plugins/core/cli/surfsense_plugin_cli/invoke/app/find_app.py
Normal file
33
plugins/core/cli/surfsense_plugin_cli/invoke/app/find_app.py
Normal file
|
|
@ -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
|
||||
55
plugins/core/cli/surfsense_plugin_cli/invoke/command.py
Normal file
55
plugins/core/cli/surfsense_plugin_cli/invoke/command.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Getting the plugin ready, then running it the way the app does."""
|
||||
|
|
@ -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/<id>/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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""What the author asked for: the action, and the inputs to run it with."""
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}
|
||||
23
plugins/core/cli/surfsense_plugin_cli/main.py
Normal file
23
plugins/core/cli/surfsense_plugin_cli/main.py
Normal file
|
|
@ -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)
|
||||
1
plugins/core/cli/surfsense_plugin_cli/new/__init__.py
Normal file
1
plugins/core/cli/surfsense_plugin_cli/new/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""surfsense-plugins new: a plugin folder that passes the rules and runs as it is."""
|
||||
63
plugins/core/cli/surfsense_plugin_cli/new/command.py
Normal file
63
plugins/core/cli/surfsense_plugin_cli/new/command.py
Normal file
|
|
@ -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/<id>, 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()
|
||||
51
plugins/core/cli/surfsense_plugin_cli/new/template.py
Normal file
51
plugins/core/cli/surfsense_plugin_cli/new/template.py
Normal file
|
|
@ -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'
|
||||
),
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Turns a plugin folder into what a release installs, starting with its dependencies."""
|
||||
|
|
@ -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())
|
||||
32
plugins/core/cli/surfsense_plugin_cli/plugin_folder.py
Normal file
32
plugins/core/cli/surfsense_plugin_cli/plugin_folder.py
Normal file
|
|
@ -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)
|
||||
7
plugins/core/cli/surfsense_plugin_cli/repository.py
Normal file
7
plugins/core/cli/surfsense_plugin_cli/repository.py
Normal file
|
|
@ -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"
|
||||
11
plugins/core/cli/surfsense_plugin_cli/reserved_ids.py
Normal file
11
plugins/core/cli/surfsense_plugin_cli/reserved_ids.py
Normal file
|
|
@ -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)
|
||||
271
plugins/core/cli/tests/conftest.py
Normal file
271
plugins/core/cli/tests/conftest.py
Normal file
|
|
@ -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)
|
||||
48
plugins/core/cli/tests/invoke/test_choosing_the_workspace.py
Normal file
48
plugins/core/cli/tests/invoke/test_choosing_the_workspace.py
Normal file
|
|
@ -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 == ""
|
||||
38
plugins/core/cli/tests/invoke/test_data_folder.py
Normal file
38
plugins/core/cli/tests/invoke/test_data_folder.py
Normal file
|
|
@ -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"
|
||||
111
plugins/core/cli/tests/invoke/test_dependencies.py
Normal file
111
plugins/core/cli/tests/invoke/test_dependencies.py
Normal file
|
|
@ -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 == ""
|
||||
65
plugins/core/cli/tests/invoke/test_feedback.py
Normal file
65
plugins/core/cli/tests/invoke/test_feedback.py
Normal file
|
|
@ -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 == ""
|
||||
82
plugins/core/cli/tests/invoke/test_finding_the_app.py
Normal file
82
plugins/core/cli/tests/invoke/test_finding_the_app.py
Normal file
|
|
@ -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 == ""
|
||||
87
plugins/core/cli/tests/invoke/test_inputs.py
Normal file
87
plugins/core/cli/tests/invoke/test_inputs.py
Normal file
|
|
@ -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
|
||||
)
|
||||
58
plugins/core/cli/tests/invoke/test_running.py
Normal file
58
plugins/core/cli/tests/invoke/test_running.py
Normal file
|
|
@ -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
|
||||
62
plugins/core/cli/tests/invoke/test_secrets.py
Normal file
62
plugins/core/cli/tests/invoke/test_secrets.py
Normal file
|
|
@ -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)
|
||||
27
plugins/core/cli/tests/test_build_targets.py
Normal file
27
plugins/core/cli/tests/test_build_targets.py
Normal file
|
|
@ -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
|
||||
159
plugins/core/cli/tests/test_dependencies.py
Normal file
159
plugins/core/cli/tests/test_dependencies.py
Normal file
|
|
@ -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
|
||||
51
plugins/core/cli/tests/test_example.py
Normal file
51
plugins/core/cli/tests/test_example.py
Normal file
|
|
@ -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)
|
||||
108
plugins/core/cli/tests/test_new.py
Normal file
108
plugins/core/cli/tests/test_new.py
Normal file
|
|
@ -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 == ""
|
||||
303
plugins/core/cli/uv.lock
generated
Normal file
303
plugins/core/cli/uv.lock
generated
Normal file
|
|
@ -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" },
|
||||
]
|
||||
1
plugins/core/policy/reserved-plugin-ids.txt
Normal file
1
plugins/core/policy/reserved-plugin-ids.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
core
|
||||
2
plugins/example/example/__init__.py
Normal file
2
plugins/example/example/__init__.py
Normal file
|
|
@ -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."""
|
||||
26
plugins/example/example/count_words.py
Normal file
26
plugins/example/example/count_words.py
Normal file
|
|
@ -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"
|
||||
)
|
||||
11
plugins/example/example/notes_added.py
Normal file
11
plugins/example/example/notes_added.py
Normal file
|
|
@ -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
|
||||
6
plugins/example/main.py
Normal file
6
plugins/example/main.py
Normal file
|
|
@ -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
|
||||
18
plugins/example/manifest.json
Normal file
18
plugins/example/manifest.json
Normal file
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
plugins/example/requirements.in
Normal file
1
plugins/example/requirements.in
Normal file
|
|
@ -0,0 +1 @@
|
|||
tabulate
|
||||
6
plugins/example/requirements.txt
Normal file
6
plugins/example/requirements.txt
Normal file
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue