trap-cli 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. trap_cli-0.0.1/.gitignore +45 -0
  2. trap_cli-0.0.1/LICENSE +21 -0
  3. trap_cli-0.0.1/PKG-INFO +88 -0
  4. trap_cli-0.0.1/README.md +55 -0
  5. trap_cli-0.0.1/examples/echo/task/README.md +39 -0
  6. trap_cli-0.0.1/pyproject.toml +107 -0
  7. trap_cli-0.0.1/src/trap/__init__.py +6 -0
  8. trap_cli-0.0.1/src/trap/_version.py +24 -0
  9. trap_cli-0.0.1/src/trap/auth/__init__.py +15 -0
  10. trap_cli-0.0.1/src/trap/auth/client.py +56 -0
  11. trap_cli-0.0.1/src/trap/auth/login.py +39 -0
  12. trap_cli-0.0.1/src/trap/auth/oauth.py +82 -0
  13. trap_cli-0.0.1/src/trap/auth/store.py +39 -0
  14. trap_cli-0.0.1/src/trap/cli/__init__.py +274 -0
  15. trap_cli-0.0.1/src/trap/cli/_auth.py +103 -0
  16. trap_cli-0.0.1/src/trap/cost/__init__.py +3 -0
  17. trap_cli-0.0.1/src/trap/cost/calculator.py +41 -0
  18. trap_cli-0.0.1/src/trap/cost/providers.py +122 -0
  19. trap_cli-0.0.1/src/trap/cost/proxy.py +186 -0
  20. trap_cli-0.0.1/src/trap/display/__init__.py +4 -0
  21. trap_cli-0.0.1/src/trap/display/progress.py +61 -0
  22. trap_cli-0.0.1/src/trap/display/submit.py +20 -0
  23. trap_cli-0.0.1/src/trap/environment/__init__.py +3 -0
  24. trap_cli-0.0.1/src/trap/environment/detector.py +81 -0
  25. trap_cli-0.0.1/src/trap/git_ops/__init__.py +13 -0
  26. trap_cli-0.0.1/src/trap/git_ops/base.py +10 -0
  27. trap_cli-0.0.1/src/trap/git_ops/local.py +60 -0
  28. trap_cli-0.0.1/src/trap/git_ops/remote.py +63 -0
  29. trap_cli-0.0.1/src/trap/git_ops/rev.py +119 -0
  30. trap_cli-0.0.1/src/trap/git_ops/url.py +98 -0
  31. trap_cli-0.0.1/src/trap/loader/__init__.py +5 -0
  32. trap_cli-0.0.1/src/trap/loader/errors.py +7 -0
  33. trap_cli-0.0.1/src/trap/loader/trap_yaml.py +99 -0
  34. trap_cli-0.0.1/src/trap/loader/traptask_yaml.py +85 -0
  35. trap_cli-0.0.1/src/trap/models/__init__.py +36 -0
  36. trap_cli-0.0.1/src/trap/models/cost.py +36 -0
  37. trap_cli-0.0.1/src/trap/models/environment.py +24 -0
  38. trap_cli-0.0.1/src/trap/models/provenance.py +20 -0
  39. trap_cli-0.0.1/src/trap/models/report.py +61 -0
  40. trap_cli-0.0.1/src/trap/models/results.py +17 -0
  41. trap_cli-0.0.1/src/trap/models/trap_yaml.py +64 -0
  42. trap_cli-0.0.1/src/trap/models/traptask_yaml.py +58 -0
  43. trap_cli-0.0.1/src/trap/report/__init__.py +19 -0
  44. trap_cli-0.0.1/src/trap/report/base.py +8 -0
  45. trap_cli-0.0.1/src/trap/report/handle.py +54 -0
  46. trap_cli-0.0.1/src/trap/report/json.py +11 -0
  47. trap_cli-0.0.1/src/trap/report/rich.py +116 -0
  48. trap_cli-0.0.1/src/trap/runner/__init__.py +5 -0
  49. trap_cli-0.0.1/src/trap/runner/capture.py +34 -0
  50. trap_cli-0.0.1/src/trap/runner/grader.py +36 -0
  51. trap_cli-0.0.1/src/trap/runner/judge.py +50 -0
  52. trap_cli-0.0.1/src/trap/runner/layout.py +36 -0
  53. trap_cli-0.0.1/src/trap/runner/proc.py +122 -0
  54. trap_cli-0.0.1/src/trap/runner/solution.py +80 -0
  55. trap_cli-0.0.1/src/trap/runner/task.py +102 -0
@@ -0,0 +1,45 @@
1
+ LOCAL*
2
+ .env
3
+ # Python
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.pyo
7
+ *.pyd
8
+ *.so
9
+
10
+ # Distribution / packaging
11
+ dist/
12
+ build/
13
+ *.egg-info/
14
+ .eggs/
15
+
16
+ # uv / virtual envs
17
+ .venv/
18
+ uv.lock~
19
+
20
+ # hatch-vcs generated
21
+ src/trap/_version.py
22
+
23
+ # trap runtime workspace
24
+ .trap/
25
+
26
+ # MkDocs generated site
27
+ site/
28
+
29
+
30
+ # Testing
31
+ .pytest_cache/
32
+ .coverage
33
+ htmlcov/
34
+ .deepeval
35
+
36
+ # Ruff
37
+ .ruff_cache/
38
+
39
+ # macOS
40
+ .DS_Store
41
+
42
+ # Editors
43
+ .idea/
44
+ .vscode/
45
+ *.swp
trap_cli-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 trapstreet.run
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: trap-cli
3
+ Version: 0.0.1
4
+ Summary: tp — non-invasive CLI testing framework for AI workflows. Submits results to trapstreet.run.
5
+ Project-URL: Homepage, https://trapstreet.run
6
+ Project-URL: Repository, https://github.com/trapstreet/trap
7
+ Project-URL: Issues, https://github.com/trapstreet/trap/issues
8
+ Author-email: "trapstreet.run" <founder@trapstreet.run>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,ai,benchmark,eval,llm,trapstreet,workflow
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Requires-Python: >=3.13
23
+ Requires-Dist: gitpython>=3.1
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: psutil>=7.2.2
26
+ Requires-Dist: py-cpuinfo>=9.0.0
27
+ Requires-Dist: pydantic>=2.0
28
+ Requires-Dist: pyyaml>=6.0
29
+ Requires-Dist: rich>=13.0
30
+ Requires-Dist: tokencost>=0.1.26
31
+ Requires-Dist: typer>=0.12
32
+ Description-Content-Type: text/markdown
33
+
34
+ # trap
35
+
36
+ [![CI](https://github.com/trapstreet/trap/actions/workflows/ci.yml/badge.svg)](https://github.com/trapstreet/trap/actions/workflows/ci.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/trap-cli)](https://pypi.org/project/trap-cli/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/trap-cli)](https://pypi.org/project/trap-cli/)
39
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
40
+
41
+ **Non-invasive CLI testing framework for AI prompts, agents, and workflows.**
42
+
43
+ trap treats any solution as a black box — it invokes it as a subprocess, captures outputs, then optionally scores them through a language-agnostic judge and grader. The solution doesn't need to import trap or know it exists.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ # requires uv — https://docs.astral.sh/uv/getting-started/installation/
49
+
50
+ # from PyPI
51
+ uv tool install trap-cli
52
+
53
+ # from git (latest main)
54
+ uv tool install "git+https://github.com/trapstreet/trap.git"
55
+ ```
56
+
57
+ The command is `tp`.
58
+
59
+ ## How it works
60
+
61
+ trap runs your solution as a subprocess with one env var (`TRAP_MANIFEST` — an input dir and
62
+ an output dir), captures what it writes, then optionally scores it through a judge (per case)
63
+ and a grader (overall). See the [full flow and IO contract](docs/index.md).
64
+
65
+ Two roles, two directories, one IO contract:
66
+
67
+ - **Solution author** — writes `trap.yaml` and the solution code
68
+ - **Task author** — writes `traptask.yaml`, `inputs/`, `expected/`, and optional judge/grader scripts
69
+
70
+ ## Quick start
71
+
72
+ ```bash
73
+ # from examples/echo/solution/
74
+ tp run # run all cases
75
+ tp run -t smoke # run only cases tagged `smoke`
76
+ ```
77
+
78
+ ## Documentation
79
+
80
+ - [Quick start](docs/quickstart.md)
81
+ - [Writing a solution](docs/guides/writing-solution.md)
82
+ - [Writing a task](docs/guides/writing-task.md)
83
+ - [CLI reference](docs/reference/cli.md)
84
+ - [IO contract](docs/reference/io-contract.md)
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,55 @@
1
+ # trap
2
+
3
+ [![CI](https://github.com/trapstreet/trap/actions/workflows/ci.yml/badge.svg)](https://github.com/trapstreet/trap/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/trap-cli)](https://pypi.org/project/trap-cli/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/trap-cli)](https://pypi.org/project/trap-cli/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ **Non-invasive CLI testing framework for AI prompts, agents, and workflows.**
9
+
10
+ trap treats any solution as a black box — it invokes it as a subprocess, captures outputs, then optionally scores them through a language-agnostic judge and grader. The solution doesn't need to import trap or know it exists.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ # requires uv — https://docs.astral.sh/uv/getting-started/installation/
16
+
17
+ # from PyPI
18
+ uv tool install trap-cli
19
+
20
+ # from git (latest main)
21
+ uv tool install "git+https://github.com/trapstreet/trap.git"
22
+ ```
23
+
24
+ The command is `tp`.
25
+
26
+ ## How it works
27
+
28
+ trap runs your solution as a subprocess with one env var (`TRAP_MANIFEST` — an input dir and
29
+ an output dir), captures what it writes, then optionally scores it through a judge (per case)
30
+ and a grader (overall). See the [full flow and IO contract](docs/index.md).
31
+
32
+ Two roles, two directories, one IO contract:
33
+
34
+ - **Solution author** — writes `trap.yaml` and the solution code
35
+ - **Task author** — writes `traptask.yaml`, `inputs/`, `expected/`, and optional judge/grader scripts
36
+
37
+ ## Quick start
38
+
39
+ ```bash
40
+ # from examples/echo/solution/
41
+ tp run # run all cases
42
+ tp run -t smoke # run only cases tagged `smoke`
43
+ ```
44
+
45
+ ## Documentation
46
+
47
+ - [Quick start](docs/quickstart.md)
48
+ - [Writing a solution](docs/guides/writing-solution.md)
49
+ - [Writing a task](docs/guides/writing-task.md)
50
+ - [CLI reference](docs/reference/cli.md)
51
+ - [IO contract](docs/reference/io-contract.md)
52
+
53
+ ## License
54
+
55
+ MIT
@@ -0,0 +1,39 @@
1
+ # Echo Task
2
+
3
+ Your solution receives a JSON object via stdin and must echo the `message` field to stdout.
4
+
5
+ ## Input
6
+
7
+ A JSON object delivered via stdin:
8
+
9
+ ```json
10
+ {"message": "Hello, World!"}
11
+ ```
12
+
13
+ ## Output
14
+
15
+ The value of `message`, printed to stdout (with a trailing newline).
16
+
17
+ ```
18
+ Hello, World!
19
+ ```
20
+
21
+ ## Error handling
22
+
23
+ If the `message` field is missing, print an error to stderr and exit with code 1.
24
+
25
+ ## Evaluation
26
+
27
+ Each case declares one or more assertions in its `expected.json` file.
28
+ The judge (`judge.py`) checks each declared assertion against the solution's stdout and exit code:
29
+
30
+
31
+ | Assertion | Passes when |
32
+ |---------------|------------------------------------------------------|
33
+ | `contains` | stdout contains the substring (case-insensitive) |
34
+ | `not_contains`| stdout does not contain the substring |
35
+ | `exact` | stdout matches exactly (after strip) |
36
+ | `regex` | stdout matches the regex pattern |
37
+ | `exit_code` | process exit code equals the value |
38
+
39
+ All declared assertions must pass for a case to pass.
@@ -0,0 +1,107 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "trap-cli"
7
+ dynamic = ["version"]
8
+ description = "tp — non-invasive CLI testing framework for AI workflows. Submits results to trapstreet.run."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{ name = "trapstreet.run", email = "founder@trapstreet.run" }]
12
+ keywords = ["eval", "ai", "llm", "benchmark", "trapstreet", "agent", "workflow"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Software Development :: Testing",
24
+ ]
25
+ requires-python = ">=3.13"
26
+ dependencies = [
27
+ "typer>=0.12",
28
+ "pydantic>=2.0",
29
+ "pyyaml>=6.0",
30
+ "rich>=13.0",
31
+ "httpx>=0.27",
32
+ "tokencost>=0.1.26",
33
+ "gitpython>=3.1",
34
+ "psutil>=7.2.2",
35
+ "py-cpuinfo>=9.0.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://trapstreet.run"
40
+ Repository = "https://github.com/trapstreet/trap"
41
+ Issues = "https://github.com/trapstreet/trap/issues"
42
+
43
+ [project.scripts]
44
+ tp = "trap.cli:app"
45
+
46
+ [tool.hatch.version]
47
+ source = "vcs"
48
+
49
+ # Version comes from git tags shaped like `vX.Y.Z`. Options are forwarded
50
+ # to setuptools_scm; `fallback_version` keeps untagged builds working.
51
+ [tool.hatch.version.raw-options]
52
+ tag_regex = "^v(?P<version>.+)$"
53
+ git_describe_command = "git describe --dirty --tags --long --match v*"
54
+ fallback_version = "0.0.0.dev0"
55
+
56
+ [tool.hatch.build.hooks.vcs]
57
+ version-file = "src/trap/_version.py"
58
+
59
+ [tool.hatch.build.targets.wheel]
60
+ packages = ["src/trap"]
61
+
62
+ [tool.hatch.build.targets.sdist]
63
+ include = ["src/trap", "README.md", "pyproject.toml"]
64
+
65
+ [dependency-groups]
66
+ dev = [
67
+ "pytest>=8.0",
68
+ "pytest-cov>=5.0",
69
+ "ruff>=0.9",
70
+ ]
71
+
72
+ [tool.uv.workspace]
73
+ exclude = ["examples/*"]
74
+
75
+ [tool.pyright]
76
+ # Type-check the package; tests intentionally feed pydantic models loose input
77
+ # (scalars/dicts its validators coerce), which strict typing would flag.
78
+ include = ["src"]
79
+
80
+ [tool.ruff]
81
+ line-length = 110
82
+ target-version = "py313"
83
+ extend-exclude = ["src/trap/_version.py"] # generated by hatch-vcs
84
+
85
+ [tool.ruff.lint]
86
+ select = ["E", "F", "I", "UP", "B", "C4", "RUF"]
87
+
88
+ [tool.ruff.lint.per-file-ignores]
89
+ "src/trap/_version.py" = ["RUF022"]
90
+
91
+ [tool.pytest.ini_options]
92
+ testpaths = ["tests"]
93
+
94
+ [tool.coverage.run]
95
+ source = ["trap"]
96
+ branch = true
97
+ omit = ["src/trap/_version.py"]
98
+
99
+ [tool.coverage.report]
100
+ show_missing = true
101
+ fail_under = 100
102
+ exclude_also = [
103
+ "if TYPE_CHECKING:",
104
+ "raise NotImplementedError",
105
+ "if __name__ == .__main__.:",
106
+ "@(abc\\.)?abstractmethod",
107
+ ]
@@ -0,0 +1,6 @@
1
+ try:
2
+ from trap._version import __version__
3
+ except ImportError: # pragma: no cover - only when built without hatch-vcs
4
+ __version__ = "0.0.0+unknown"
5
+
6
+ __all__ = ["__version__"]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1'
22
+ __version_tuple__ = version_tuple = (0, 0, 1)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,15 @@
1
+ from trap.auth.client import ApiClient, ApiError
2
+ from trap.auth.login import BrowserProvider, TokenProvider
3
+ from trap.auth.oauth import OAuthCallbackServer
4
+ from trap.auth.store import DEFAULT_SERVER, AuthData, AuthStore
5
+
6
+ __all__ = [
7
+ "DEFAULT_SERVER",
8
+ "ApiClient",
9
+ "ApiError",
10
+ "AuthData",
11
+ "AuthStore",
12
+ "BrowserProvider",
13
+ "OAuthCallbackServer",
14
+ "TokenProvider",
15
+ ]
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cached_property
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ class ApiError(Exception):
11
+ """A trapstreet API call failed — bad status, unreachable server, or invalid token.
12
+ Carries a user-facing message; the CLI maps it to a clean error (no traceback)."""
13
+
14
+
15
+ class ApiClient:
16
+ """Authenticated HTTP client for the trapstreet API."""
17
+
18
+ def __init__(self, server: str, api_key: str, timeout: int = 30) -> None:
19
+ self._server = server.rstrip("/")
20
+ self._api_key = api_key
21
+ self._timeout = timeout
22
+
23
+ @cached_property
24
+ def _client(self) -> httpx.Client:
25
+ return httpx.Client(
26
+ base_url=self._server,
27
+ headers={
28
+ "authorization": f"Bearer {self._api_key}",
29
+ "content-type": "application/json",
30
+ },
31
+ timeout=self._timeout,
32
+ )
33
+
34
+ def get_me(self) -> dict[str, Any]:
35
+ try:
36
+ resp = self._client.get("/api/me", timeout=10)
37
+ resp.raise_for_status()
38
+ return resp.json()
39
+ except httpx.HTTPStatusError as e:
40
+ if e.response.status_code == 401:
41
+ raise ApiError("token is invalid") from None
42
+ raise ApiError(f"server error ({e.response.status_code})") from None
43
+ except httpx.RequestError:
44
+ raise ApiError("server unreachable") from None
45
+
46
+ def submit(self, report_path: Path) -> dict[str, Any]:
47
+ # Content-addressed ingest: the task identity travels inside the report
48
+ # (provenance.task.{repo,commit}), not the URL — so no task_id path segment.
49
+ try:
50
+ resp = self._client.post("/api/submit", content=report_path.read_bytes())
51
+ resp.raise_for_status()
52
+ return resp.json()
53
+ except httpx.HTTPStatusError as e:
54
+ raise ApiError(f"http {e.response.status_code}: {e.response.text}") from None
55
+ except httpx.RequestError as e:
56
+ raise ApiError(f"connection error: {e}") from None
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from trap.auth.oauth import OAuthCallbackServer
4
+ from trap.auth.store import DEFAULT_SERVER, AuthData
5
+
6
+
7
+ class TokenProvider:
8
+ def __init__(self, server: str, token: str) -> None:
9
+ self._server = server
10
+ self._token = token
11
+
12
+ @property
13
+ def pre_message(self) -> str:
14
+ return "authenticating with api_key"
15
+
16
+ def acquire(self) -> AuthData:
17
+ if not self._token:
18
+ raise ValueError("empty api_key")
19
+ return AuthData(server=self._server, api_key=self._token)
20
+
21
+
22
+ class BrowserProvider:
23
+ def __init__(self, server: str, timeout: int) -> None:
24
+ if server != DEFAULT_SERVER:
25
+ raise ValueError(
26
+ f"browser login is only supported on {DEFAULT_SERVER}. Use --with-token for custom servers."
27
+ )
28
+ self._cb = OAuthCallbackServer(server)
29
+ self._timeout = timeout
30
+
31
+ @property
32
+ def pre_message(self) -> str:
33
+ return f"opening [link={self._cb.auth_url}]{self._cb.auth_url}[/link]"
34
+
35
+ def acquire(self) -> AuthData:
36
+ if not self._cb.run(self._timeout):
37
+ raise TimeoutError(f"timed out after {self._timeout}s waiting for browser approval")
38
+ assert self._cb.auth_data is not None
39
+ return self._cb.auth_data
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import http.server
4
+ import socketserver
5
+ import threading
6
+ import urllib.parse
7
+ import webbrowser
8
+ from typing import Any
9
+
10
+ from trap.auth.store import AuthData
11
+
12
+
13
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
14
+ _SUCCESS_HTML = (
15
+ b"<!doctype html><meta charset=utf-8>"
16
+ b"<title>logged in</title>"
17
+ b"<style>body{font-family:ui-monospace,monospace;"
18
+ b"background:#0a0a0a;color:#ededed;padding:3em;}"
19
+ b"h1{color:#ff5f1f}</style>"
20
+ b"<h1>logged in</h1>"
21
+ b"<p>You can close this tab.</p>"
22
+ )
23
+
24
+ def log_message(self, format: str, *args: Any) -> None:
25
+ return
26
+
27
+ def _respond(self, status: int, content_type: str, body: bytes) -> None:
28
+ self.send_response(status)
29
+ self.send_header("content-type", content_type)
30
+ self.end_headers()
31
+ self.wfile.write(body)
32
+
33
+ def do_GET(self) -> None:
34
+ params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
35
+
36
+ api_key = (params.get("api_key") or [None])[0]
37
+ if not api_key:
38
+ self._respond(400, "text/plain", b"missing api_key in query string")
39
+ return
40
+
41
+ solution = (params.get("solution") or [None])[0]
42
+ srv: OAuthCallbackServer = self.server # type: ignore[assignment]
43
+ srv._receive(AuthData(server=srv._server_url, api_key=api_key, solution=solution))
44
+ self._respond(200, "text/html; charset=utf-8", self._SUCCESS_HTML)
45
+
46
+
47
+ class OAuthCallbackServer(socketserver.TCPServer):
48
+ """Local HTTP server that receives the OAuth callback with api_key."""
49
+
50
+ def __init__(self, server_url: str) -> None:
51
+ super().__init__(("127.0.0.1", 0), _CallbackHandler)
52
+ self._server_url = server_url.rstrip("/")
53
+ self._auth_data: AuthData | None = None
54
+ self._received = threading.Event()
55
+
56
+ def _receive(self, data: AuthData) -> None:
57
+ self._auth_data = data
58
+ self._received.set()
59
+
60
+ @property
61
+ def auth_data(self) -> AuthData | None:
62
+ return self._auth_data
63
+
64
+ @property
65
+ def port(self) -> int:
66
+ return self.server_address[1]
67
+
68
+ @property
69
+ def auth_url(self) -> str:
70
+ return f"{self._server_url}/cli/authorize?return=http://localhost:{self.port}/callback"
71
+
72
+ def run(self, timeout: int) -> bool:
73
+ """Start server, open browser, wait for callback, shut down. Returns True if api_key received."""
74
+ threading.Thread(target=self.serve_forever, daemon=True).start()
75
+ try:
76
+ webbrowser.open(self.auth_url)
77
+ except Exception:
78
+ pass
79
+ received = self._received.wait(timeout)
80
+ self.shutdown()
81
+ self.server_close()
82
+ return received
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import json as _json
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, ValidationError
7
+
8
+ DEFAULT_SERVER = "https://trapstreet.run"
9
+
10
+
11
+ class AuthData(BaseModel):
12
+ server: str
13
+ api_key: str
14
+ solution: str | None = None
15
+
16
+
17
+ class AuthStore:
18
+ PATH = Path.home() / ".config" / "trapstreet" / "auth.json"
19
+
20
+ def load(self) -> AuthData | None:
21
+ if not self.exists:
22
+ return None
23
+ try:
24
+ return AuthData.model_validate(_json.loads(self.PATH.read_text()))
25
+ except (OSError, _json.JSONDecodeError, ValidationError):
26
+ return None
27
+
28
+ def save(self, data: AuthData) -> Path:
29
+ self.PATH.parent.mkdir(parents=True, exist_ok=True)
30
+ self.PATH.write_text(data.model_dump_json(exclude_none=True, indent=2) + "\n")
31
+ self.PATH.chmod(0o600)
32
+ return self.PATH
33
+
34
+ @property
35
+ def exists(self) -> bool:
36
+ return self.PATH.exists()
37
+
38
+ def delete(self) -> None:
39
+ self.PATH.unlink()