Development Guide¶
Setup¶
- Clone the repository:
git clone https://github.com/jr2804/teddi-tool.git
cd teddi-tool
- Install pinned tools and sync dependencies (Python 3.13+,
uv,mise):
mise install # toolchain from .config/mise/config.toml
mise dev # uv sync -U --dev --all-extras --all-groups
Tasks¶
| Task | Purpose |
|---|---|
mise dev |
Sync all dependency groups |
mise test |
pytest --cov=teddi_tool |
mise lint |
ruff check src/ tests/ --fix --unsafe-fixes |
mise lint-check |
ruff check src/ tests/ without fixes |
mise typecheck |
ty check src/ tests/ |
mise spell |
codespell src/ tests/ |
mise format |
pyreorder, ruff format, import sort |
mise format-md |
rumdl over Markdown |
mise docs |
Build the Zensical site |
mise release-notes |
Generate notes for the latest tag |
mise clean |
Remove build artefacts and caches |
mise all |
test, lint, spell, format, format-md, docs |
mise all does not run mise typecheck; run it in the same pass (CI does).
Run a single test file directly:
uv run pytest tests/test_parser.py -v
Tests never call TEDDI. Client tests inject an AsyncMock in place of
httpx2.AsyncClient (see tests/test_client.py); tests/test_http_client.py
inspects the cache factory's storage, timeout and headers instead of performing
requests. Cache paths use the cache_dir fixture, never the repository
.cache/.
Code Quality¶
uv run ruff check src/ tests/ # lint only
uv run ruff format --check src/ tests/
uv run ty check src/ tests/
ruff.toml and ty.toml are the sources of truth; both target Python 3.13.
mise lint applies fixes, so use mise lint-check (or the uv run command
above) when you want a read-only check.
Project Layout¶
The package lives in src/teddi_tool/; module-level contracts are documented
in src/teddi_tool/AGENTS.md,
test conventions in
tests/AGENTS.md,
and entry points plus everyday commands in
.agents/ONBOARDING.md.
For a current file list, run rg --files instead of relying on a checked-in
tree.
| Module | Responsibility |
|---|---|
models.py |
Dataclasses and StrEnum enums |
client.py |
TeddiSource protocol and TeddiClient |
parser.py |
TEDDI HTML parsing with TB grouping |
http_client.py |
hishel-cached httpx2 client factory |
search_utils.py |
Shared search execution and output rendering |
cli/app.py, cli/commands.py, cli/args.py |
Cyclopts entry point, commands, option aliases |
server.py |
FastMCP server |
Key Design Patterns¶
Protocol-Based Abstraction¶
TeddiSource (src/teddi_tool/client.py) is the seam between consumers and
TEDDI, which keeps the client mockable:
class TeddiSource(Protocol):
async def search_terms(self, request: SearchRequest) -> SearchResponse: ...
async def get_available_technical_bodies(self) -> list[str]: ...
async def fetch_document(self, url: str) -> bytes: ...
TeddiClient implements it and must be used as an async context manager.
HTTP Caching¶
Requests go through create_cached_teddi_async_client():
- SQLite store:
.cache/teddi_http.sqlite3(or the givencache_dir) -
Ttl
2 hours by default for search responses, 90 days for the technical-body menu; both configurable via
TeddiClient(cache_ttl=..., menu_cache_ttl=...), the CLI (--cache-ttl/--menu-cache-ttl, envTEDDI_TOOL_CACHE_TTL/TEDDI_TOOL_MENU_CACHE_TTL), or the MCP server environment. TTL is fixed when the entry is written (hishel 2.x has no refresh-on-access). - 30 s timeout, browser User-Agent
There is no retry transport; failed requests surface as httpx2.HTTPError.
TEDDI Search Form¶
Search submits a form to https://webapp.etsi.org/Teddi/Main.asp:
| Field | Meaning |
|---|---|
qWhatToSearch |
Search term |
qWhatOption |
Scope: 1 abbreviations, 2 definitions, 3 both |
qWhereOption |
Pattern: 1 exact, 2 all occurrences, 3 starting with, 4 ending with |
qShowTBs |
Comma-separated TB codes, or ALL |
The list behind qShowTBs lives in https://webapp.etsi.org/Teddi/Menu.asp.
TeddiClient.get_available_technical_bodies() parses that menu through a
dedicated cached client with a 90-day TTL (TEDDI_MENU_CACHE_TTL); there is no
bundled fallback — if the menu cannot be fetched or parsed, the error
propagates and the search aborts. Filter validation uses the live list, so
new TEDDI codes work without a release.
TB Grouping in Parser¶
TEDDI results use nested tables. The parser handles TB inheritance:
- Empty TB cell inherits the previous row's technical body
- Document sub-tables become
DocumentRefentries
Dual API: MCP + CLI¶
MCP tools and CLIs share the same execution path:
search_teddi/search-term— search with filterslist_technical_bodies/list-bodies— list technical bodies
fetch_document() exists on the client but is not exposed as a tool or
command.
Adding Features¶
New Search Filter¶
- Define/extend the enum in
src/teddi_tool/models.py(new technical-body codes need no code change - TEDDI's menu list is fetched live) - Add the field to
SearchRequest - Map it to a TEDDI form field in
src/teddi_tool/client.py - Expose the option in
src/teddi_tool/cli/args.pyand use it insrc/teddi_tool/cli/commands.py - Expose it in the MCP tool in
src/teddi_tool/server.py - Add tests
Releasing¶
Versions are CalVer: YYYY.MM.N (e.g. 2026.11.1), where N counts releases
within the month. Versions come from git tags (uv-dynamic-versioning), so no
version string is edited by hand.
Every push to main triggers .github/workflows/release.yml:
- Run the test suite on the commit; a failing build stops the release.
- Compute the next calendar version and create the annotated tag when HEAD is not tagged yet.
- Build sdist and wheel with
uv build(the version comes from the new tag). - Generate release notes:
scripts/release_notes.pysummarizes the commits since the previous tag with an OpenAI-compatible chat endpoint whenOPENAI_API_KEY(and optionallyOPENAI_BASE_URL,OPENAI_MODEL) is set; otherwise the job falls back to a plain commit list. - Create the GitHub release named after the version.
- Publish to PyPI with
pypa/gh-action-pypi-publishusing OIDC trusted publishing (no token stored). One-time setup: on PyPI, add a trusted publisher forjr2804/teddi-toolwith workflowrelease.ymland environmentpypi.
To release a commit that was already pushed without a version bump, re-run the
workflow from the Actions tab (workflow_dispatch). A commit whose tag already
exists is not published twice.
Run the notes script locally with:
uv run python scripts/release_notes.py --from-tag 2026.11.1
Template Updates¶
The project is generated from
copier-uv-plus. Answers live in
.copier-answers.yml; apply upstream changes with:
uvx --with copier-templates-extensions copier update --trust --defaults
Implementation Notes¶
- TEDDI HTML-driven: the search form was reverse-engineered; the parser may need updates if the TEDDI UI changes.
- Async-first: all client methods are
async; the Cyclopts command wrappers call them viaasyncio.run(). - Offline tests: cache paths in tests are temporary (
cache_dirfixture), never the repository.cache/.