Skip to content

Commit 88693b6

Browse files
committed
add helping scripts and minor changes after production tests
1 parent ed5e9e6 commit 88693b6

18 files changed

Lines changed: 700 additions & 78 deletions

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,6 @@ GUARD_SPACE_ID=
3838
# (OPTIONAL) Read a different env file instead of `.env`
3939
#GUARD_ENV_FILE=.env.staging
4040

41-
# (OPTIONAL) Default media file for the smoke test
42-
# Used only by scripts/smoke.py
43-
#GUARD_MEDIA=tests/fixtures/sample.jpg
41+
# (OPTIONAL) Default media file to analyze
42+
# Used only by scripts/create_activity.py
43+
#GUARD_MEDIA=path/to/photo.jpg

CONTRIBUTING.md

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,35 +124,48 @@ silently when the extra is not installed. Because of this, a green full-suite ru
124124
local engine. If you explicitly name the file, the test runner will loudly warn you if `guard_local` is missing. When
125125
you are finished, running `uv sync` returns you to the default environment.
126126

127-
### Smoke Testing against a Live API
127+
### Testing against a Live API
128128

129-
The test suite is fully mocked and never touches the network. To exercise the real lifecycle end-to-end, use the smoke
130-
script. **It runs against production by default and spends real tokens**. Each run creates two activities. You can point
131-
it elsewhere using the `GUARD_BASE_URL` environment variable.
129+
The test suite is fully mocked and never touches the network. To exercise the real lifecycle end-to-end, create an
130+
activity. **This runs against production by default and spends real tokens** — one activity per run. You can point it
131+
elsewhere using the `GUARD_BASE_URL` environment variable.
132132

133133
```bash
134-
export GUARD_API_KEY=... # a token_raw from POST /api/v1/tokens/
134+
export GUARD_API_KEY=... # a token_raw from POST /api/v1/tokens/
135135
export GUARD_SPACE_ID=...
136136

137-
uv run python scripts/smoke.py path/to/photo.jpg
137+
# see the space and the projected cost, creating nothing
138+
uv run python scripts/create_activity.py path/to/photo.jpg --list-only
139+
140+
uv run python scripts/create_activity.py path/to/photo.jpg
138141

139142
# against a local dev server instead
140-
GUARD_BASE_URL=http://localhost:8000 uv run python scripts/smoke.py path/to/photo.jpg
143+
GUARD_BASE_URL=http://localhost:8000 uv run python scripts/create_activity.py path/to/photo.jpg
144+
145+
# on-device, which needs no space, no network and no tokens
146+
uv run python scripts/create_activity.py path/to/photo.jpg --engine local
141147
```
142148

143149
Credentials resolve the same way everywhere in this client: explicit arguments, followed by `GUARD_* `environment
144150
variables, and finally a `.env` file. You can simply run `cp .env.example .env` and fill in your details instead of
145151
exporting variables. The `.env` file is git-ignored and must stay that way. Please never put a real key in
146152
`.env.example`.
147153

148-
If you do not have a `space_id` yet, two more scripts can help:
154+
If you do not have a `space_id` yet, four more scripts can help:
149155

150156
```bash
151157
# list the spaces your key can see, with their ids
152158
uv run python scripts/list_spaces.py
153159

160+
# the two ids a new space needs: a predictor, and optionally some tasks
161+
uv run python scripts/list_predictors.py
162+
uv run python scripts/list_tasks.py --predictor-id <uuid>
163+
154164
# walk predictors -> tasks -> create a space (--list-only creates nothing)
155165
uv run python scripts/create_space.py --list-only
166+
167+
# and then, with a space id in hand, analyze something in it
168+
uv run python scripts/create_activity.py path/to/photo.jpg --list-only
156169
```
157170

158171
## What to Watch Out For
@@ -172,7 +185,7 @@ this package.
172185
the contract has changed. It is excluded from `ruff format` and carries its own per-file-ignores entry to preserve this.
173186
Please do not reformat it, and if you modify it, be sure to update both copies.
174187

175-
**Everything raised must subclass `GuardError`.** hat is the promise our `except` clauses rely on. The engine's own
188+
**Everything raised must subclass `GuardError`.** That is the promise our `except` clauses rely on. The engine's own
176189
exceptions do not subclass it, so `local.py` translates each one before it escapes using `_map_local_error`. Anything
177190
that does not come from `guard_local` propagates untouched. This is intentional, as a bug in the engine should surface
178191
exactly as the bug it is.

README.md

Lines changed: 18 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -121,43 +121,24 @@ not into a different data shape.
121121

122122
## Development
123123

124-
This project uses [uv](https://docs.astral.sh/uv/) for lightning-fast Python package and environment management.
125-
126-
### Prerequisites
127-
128-
* [uv](https://docs.astral.sh/uv/) (already installed on your system)
129-
130-
### Setup
131-
132-
1. Clone the repository:
133-
```bash
134-
git clone https://github.com/elhio/guard-python.git
135-
cd guard-python
136-
```
137-
138-
2. Sync the environment:
139-
```bash
140-
uv sync
141-
```
142-
*This command automatically creates a `.venv` virtual environment, reads the `uv.lock` file, and installs all core*
143-
*and development dependencies exactly as they were locked.*
144-
145-
3. Run tests:
146-
```bash
147-
uv run pytest
148-
```
149-
150-
4. Formatting, linting and type checking:
151-
```bash
152-
uv run ruff format
153-
uv run ruff check
154-
uv run mypy src/
155-
```
156-
157-
5. Build for production:
158-
```bash
159-
uv build
160-
```
124+
This project uses [uv](https://docs.astral.sh/uv/) for package and environment management. A single `uv sync` creates
125+
the `.venv`, reads `uv.lock`, and installs everything exactly as it was locked. The test suite is fully mocked, so
126+
there is no API key to obtain and no network access at any point.
127+
128+
```bash
129+
# set up the environment
130+
uv sync
131+
132+
# run tests
133+
uv run pytest
134+
135+
# build for production
136+
uv build
137+
```
138+
139+
The [Contributing Guide](https://github.com/elhio/guard-python/blob/main/CONTRIBUTING.md) covers the rest: linting and
140+
type checking, the documentation build, working against the optional local engine and its shared contract suite, and
141+
testing end-to-end against a live API.
161142

162143
## Contributing
163144

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ dependencies = [
4545
local = ["guard-local-detector>=0.0.1"]
4646

4747
[project.urls]
48-
Homepage = "https://github.com/elhio/guard-python"
48+
Homepage = "https://elhio.com/"
4949
Repository = "https://github.com/elhio/guard-python"
5050
Issues = "https://github.com/elhio/guard-python/issues"
5151
Changelog = "https://github.com/elhio/guard-python/releases"

scripts/create_activity.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Create one activity after showing the space and its estimated cost.
4+
5+
This script runs the complete detection lifecycle through `analyze()`. It creates the
6+
activity, uploads the media, confirms it, polls until completion, and prints the final
7+
scores. Be aware that running this against a live API will spend real tokens. You can
8+
pass `--list-only` to see the cost estimate without creating anything, or use
9+
`--engine local` to run the detection on your device for free.
10+
11+
Only `GUARD_API_KEY` and a space ID are required. Everything else resolves through the
12+
standard client precedence using arguments, the environment, or a `.env` file. The
13+
media file is taken from the first argument or the `GUARD_MEDIA` environment variable.
14+
15+
Example:
16+
```bash
17+
# preview the cost without creating anything
18+
uv run python scripts/create_activity.py photo.jpg --list-only
19+
20+
# run the activity against the cloud API
21+
uv run python scripts/create_activity.py photo.jpg
22+
uv run python scripts/create_activity.py photo.jpg --space-id <uuid>
23+
24+
# run on-device instead, which requires no space and consumes no tokens
25+
uv run python scripts/create_activity.py photo.jpg --engine local
26+
```
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import argparse
32+
import os
33+
import sys
34+
from pathlib import Path
35+
from typing import Optional
36+
37+
from guard_client import GuardClient, GuardError, probe_media, read_env_file
38+
39+
40+
def parse_args() -> argparse.Namespace:
41+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
42+
parser.add_argument("media", nargs="?", help="media file; defaults to GUARD_MEDIA")
43+
parser.add_argument("--space-id", help="the space to run in; or GUARD_SPACE_ID")
44+
parser.add_argument("--user-id", help="create a user-owned activity")
45+
parser.add_argument("--account-id", help="create a service-account-owned activity")
46+
parser.add_argument(
47+
"--engine", choices=["cloud", "local"], help="client default: cloud"
48+
)
49+
parser.add_argument(
50+
"--timeout", type=float, help="seconds to wait for processing to finish"
51+
)
52+
parser.add_argument(
53+
"--list-only",
54+
action="store_true",
55+
help="print the space and the estimate, then exit without creating anything",
56+
)
57+
return parser.parse_args()
58+
59+
60+
def from_env(name: str) -> Optional[str]:
61+
"""Read one setting the way the client does: real environment first, then .env."""
62+
return os.environ.get(name) or read_env_file().get(name)
63+
64+
65+
def media_path(argument: Optional[str]) -> Path:
66+
"""The one setting the client does not resolve for us."""
67+
media = argument or from_env("GUARD_MEDIA")
68+
if not media:
69+
sys.exit("Pass a media file path as the first argument or set GUARD_MEDIA")
70+
71+
path = Path(media)
72+
if not path.is_file():
73+
sys.exit(f"No such file: {path}")
74+
return path
75+
76+
77+
def print_media(path: Path) -> None:
78+
"""Show what the probe read out of the file headers."""
79+
info = probe_media(path)
80+
print(f"Media: {path.name}")
81+
print(f" type {info.media_type.value}")
82+
print(f" size {path.stat().st_size} bytes")
83+
print(f" pixels {info.width}x{info.height}")
84+
print(f" frames {info.frames} ({info.duration_seconds:.1f}s)")
85+
86+
87+
def print_space(client: GuardClient, space_id: str, media: Path) -> None:
88+
"""Show the space this will run in, and what it is expected to cost."""
89+
space = client.spaces.get(space_id)
90+
tasks = ", ".join(task.name for task in space.enabled_tasks) or "-"
91+
92+
print(f"\nSpace: {space.name}")
93+
print(f" id {space.id}")
94+
print(f" predictor {space.predictor_name} (x{space.predictor_multiplier})")
95+
print(f" max media {space.max_media_size or '-'} bytes")
96+
print(f" tasks {tasks}")
97+
98+
print(f"\nEstimated cost: {client.estimate_tokens(media, space_id=space_id)}")
99+
print(" An estimate: the API reserves the minimum, payed_tokens is final.")
100+
101+
102+
def main() -> int:
103+
args = parse_args()
104+
media = media_path(args.media)
105+
local = args.engine == "local"
106+
107+
space_id = args.space_id or from_env("GUARD_SPACE_ID")
108+
if not local and not space_id:
109+
sys.exit(
110+
"Pass --space-id or set GUARD_SPACE_ID (scripts/list_spaces.py finds one)"
111+
)
112+
113+
try:
114+
with GuardClient(space_id=space_id, engine=args.engine) as client:
115+
print(f"Connected to {client.base_url}\n")
116+
117+
print_media(media)
118+
if local:
119+
print("\nEngine: local. No space, no network, no tokens.")
120+
else:
121+
print_space(client, str(space_id), media)
122+
123+
if args.list_only:
124+
print("\n--list-only: nothing was created.")
125+
return 0
126+
127+
print(f"\nAnalyzing {media.name}...")
128+
result = client.analyze(
129+
media,
130+
user_id=args.user_id,
131+
account_id=args.account_id,
132+
**({} if args.timeout is None else {"timeout": args.timeout}),
133+
)
134+
135+
print(f"\nDone: engine={result.engine.value}")
136+
print(f" activity {result.activity_id or '-'}")
137+
for item in result.results:
138+
print(f" - {item.label}: {item.score}/100")
139+
if not result.results:
140+
print(" (no results returned)")
141+
print(f" max_score {result.max_score}")
142+
except GuardError as exc:
143+
# Covers a missing API key or space id too, which the client reports itself.
144+
print(f"\nFAILED: {type(exc).__name__}: {exc}", file=sys.stderr)
145+
return 1
146+
147+
return 0
148+
149+
150+
if __name__ == "__main__":
151+
raise SystemExit(main())

0 commit comments

Comments
 (0)