diff --git a/.claude/drift-log/applied/2026-06-29-mcp-output-unions-must-be-discriminated.md b/.claude/drift-log/applied/2026-06-29-mcp-output-unions-must-be-discriminated.md new file mode 100644 index 0000000..95f3c60 --- /dev/null +++ b/.claude/drift-log/applied/2026-06-29-mcp-output-unions-must-be-discriminated.md @@ -0,0 +1,70 @@ +--- +date: 2026-06-29 +status: APPLIED +disposition: applied +applied_date: 2026-06-29 +applied_in: src/ycli/yandex/status/models.py, src/ycli/yandex/status/reporter.py, docs/conventions/resources.md (§5) +priority: MEDIUM +trigger: 8 +session_context: round-4 follow-up — status_get me-union mis-hydration found in final review +affected_source: + - docs/conventions/resources.md + - src/ycli/yandex/status/models.py +--- + +## What diverged + +The `status_get` MCP tool returns an `AuthReport` whose per-service `me` field was typed as an +**undiscriminated** union — `TrackerMe | WikiMe | FormsMe | None`. Because all three `me` +models are fully optional and ignore extra keys (`APIModel` is `extra="ignore"`), every payload +validates against every member of that union. That is harmless on the CLI/SDK path, where the +real model instance is carried through and serialized directly. + +It is **not** harmless across MCP. fastmcp rebuilds `CallToolResult.data` from the tool's output +JSON schema, and for an undiscriminated `anyOf` it picks the *first* branch that validates. A +wiki payload (`{"username": ...}`) was therefore reconstructed into the **tracker** shape +(`{uid, login, display, email}`) and its fields — including `username` — were silently dropped. +The on-the-wire `structured_content` stayed correct, so the data loss only showed up when a +Python consumer read `result.data`. The repo had no rule against undiscriminated unions in MCP +tool outputs, and `docs/conventions/resources.md` did not mention the constraint, so the next +heterogeneous MCP result would have hit the same trap. + +## Why it seemed better + +A bare union reads naturally and needed no extra scaffolding: each service's probe just stored +its native `me` model, and `extra="ignore"` made every payload "just validate". The ambiguity +is invisible in unit tests that assert against the freshly-built Python object or against +`structured_content` — only the fastmcp `result.data` reconstruction exposes it, which is an +easy seam to overlook. + +## Proposed change + +1. Type heterogeneous MCP-tool-output unions as **discriminated** unions: give each member a + `Literal` tag field and annotate the union with `Field(discriminator=...)`. For `AuthReport`, + split `ServiceAuthStatus` into `TrackerAuthStatus | WikiAuthStatus | FormsAuthStatus` keyed on + a `Literal` `service` tag. This makes the output schema self-describing and loss-free across + the fastmcp round-trip. +2. Add a short convention to `docs/conventions/resources.md` (MCP section): + + ``` + ### Heterogeneous MCP output unions must be discriminated + + fastmcp rebuilds `result.data` from the tool's output JSON schema and picks the first + matching branch of an undiscriminated `anyOf` — silently reshaping one member into another + and dropping fields. Any union returned by an MCP tool must carry a `Literal` discriminator + tag (`Field(discriminator="...")`). The CLI/SDK path is unaffected, but MCP consumers are not. + ``` + +## Resolution + +`ServiceAuthStatus` was split into `TrackerAuthStatus | WikiAuthStatus | FormsAuthStatus`, +each carrying a `Literal` `service` tag and its own typed `me`, combined as +`Annotated[…, Field(discriminator="service")]` in `src/ycli/yandex/status/models.py`. The +shared fields live on a `_ServiceAuthStatus` base; the subclasses override `service`/`me` +so field order is preserved. `StatusReporter._probe` now routes each probe through a +`TypeAdapter(ServiceAuthStatus)` (the discriminator is the single source of truth for +service→model), so no separate name→class map is needed. A fastmcp round-trip test in +`tests/yandex/status/test_mcp.py` now asserts `wiki.me.username` survives on `result.data` +(previously it had to read `structured_content` because the undiscriminated union dropped it). +The convention is documented in `docs/conventions/resources.md` §5 and listed in the +enforcement table. The CLI/SDK path is unchanged — it still carries the bare native `me` model. diff --git a/.claude/drift-log/applied/INDEX-2026-06.md b/.claude/drift-log/applied/INDEX-2026-06.md new file mode 100644 index 0000000..2408917 --- /dev/null +++ b/.claude/drift-log/applied/INDEX-2026-06.md @@ -0,0 +1,32 @@ +# Applied drift-log entries — 2026-06 + +Theme-grouped summary of entries resolved this month. Original entry files remain in place +alongside this index as the immutable record. + +## Documentation drift (codifying conventions that lived only in memory / live research) + +- **2026-06-29 — [full-self-documenting-names-not-in-claude-md](full-self-documenting-names-not-in-claude-md.md)** + (`applied`). The "spell identifiers and env-var names out in full — never abbreviate" rule lived + only in personal agent memory. Added a **Naming** bullet to `CLAUDE.md` → "Project-Specific + Conventions" so a fresh agent/contributor sees it. *Codified in:* `CLAUDE.md`. + +- **2026-06-29 — [mcp-client-di-cache-factory-pattern](mcp-client-di-cache-factory-pattern.md)** + (`applied`). The way one client is shared across a mounted MCP domain's tools was discovered by + live research and undocumented. Documented the **as-shipped** pattern — `make_cached_client` + (a `functools.cache`d zero-arg provider in `ycli.yandex._mcp`) consumed via `Depends(...)`, with + the fastmcp `mount()` lifespan rationale — rather than the raw `@functools.cache def` in the + proposal. *Codified in:* `docs/conventions/resources.md` §3, `ARCHITECTURE.md`. + +- **2026-06-29 — [docs-drift-from-purged-idioms](docs-drift-from-purged-idioms.md)** + (`applied`, HIGH). Purged idioms (`from_env`) and stale invariant counts kept surviving in prose + after the code moved on. Added **ARCH-11 — no purged idioms in docs** and a grep test. *Codified + in:* `ARCHITECTURE.md`, `tests/test_architecture.py::test_arch11_no_purged_idioms_in_live_docs`. + +## MCP output typing + +- **2026-06-29 — [mcp-output-unions-must-be-discriminated](2026-06-29-mcp-output-unions-must-be-discriminated.md)** + (`applied`). `status_get` returned an *undiscriminated* `me` union; fastmcp's `result.data` + reconstruction picked the first branch and dropped fields (wiki `username`). Split into + discriminated `Literal`-tagged subclasses (`Field(discriminator="service")`), routed via a + `TypeAdapter`; added a round-trip regression test and a convention. *Codified in:* + `src/ycli/yandex/status/{models,reporter}.py`, `docs/conventions/resources.md` §5. diff --git a/.claude/drift-log/open/full-self-documenting-names-not-in-claude-md.md b/.claude/drift-log/applied/full-self-documenting-names-not-in-claude-md.md similarity index 83% rename from .claude/drift-log/open/full-self-documenting-names-not-in-claude-md.md rename to .claude/drift-log/applied/full-self-documenting-names-not-in-claude-md.md index 0b4402d..4fe5242 100644 --- a/.claude/drift-log/open/full-self-documenting-names-not-in-claude-md.md +++ b/.claude/drift-log/applied/full-self-documenting-names-not-in-claude-md.md @@ -1,6 +1,9 @@ --- date: 2026-06-29 -status: OPEN +status: APPLIED +disposition: applied +applied_date: 2026-06-29 +applied_in: CLAUDE.md priority: MEDIUM trigger: 3 session_context: round-2 internals-cleanup / round-3 arch-tooling refactor sessions @@ -41,3 +44,9 @@ Add one line to the "Project-Specific Conventions" section of `CLAUDE.md`: (`timeout_seconds` not `timeout_s`, `organization_id` not `org_id`, `YANDEX_ID_OAUTH_TOKEN` is already correct). ``` + +## Resolution + +Added the **Naming** bullet to the "Project-Specific Conventions" section of `CLAUDE.md` +(verbatim from the proposed change). The convention is now visible to any agent or human +contributor reading `CLAUDE.md` alone, not only to sessions with the personal memory injected. diff --git a/.claude/drift-log/open/mcp-client-di-cache-factory-pattern.md b/.claude/drift-log/applied/mcp-client-di-cache-factory-pattern.md similarity index 75% rename from .claude/drift-log/open/mcp-client-di-cache-factory-pattern.md rename to .claude/drift-log/applied/mcp-client-di-cache-factory-pattern.md index b5a1213..7894818 100644 --- a/.claude/drift-log/open/mcp-client-di-cache-factory-pattern.md +++ b/.claude/drift-log/applied/mcp-client-di-cache-factory-pattern.md @@ -1,6 +1,9 @@ --- date: 2026-06-29 -status: OPEN +status: APPLIED +disposition: applied +applied_date: 2026-06-29 +applied_in: docs/conventions/resources.md (§3), ARCHITECTURE.md priority: MEDIUM trigger: 4 session_context: round-3 arch-tooling refactor — MCP server wiring research @@ -51,3 +54,13 @@ To share one client instance across tools in a mounted domain server, define a MCP tool functions receive the client via `Depends(tracker_client)`. This is the only approved pattern; `import_server` is deprecated and must not be used. ``` + +## Resolution + +Documented in `docs/conventions/resources.md` §3 ("Why `_client` is a cached provider") +and in the `ARCHITECTURE.md` layout prose (the `_mcp.py` line). The codified docs reflect the +pattern as it actually shipped: each `_deps` calls `make_cached_client(ClientCls)` from +`ycli.yandex._mcp` — a helper that wraps a `functools.cache`d zero-arg factory and reads +`Credentials()` from the env once — rather than the raw per-module `@functools.cache def` +sketched in the proposal above. The rationale (fastmcp `mount()` not propagating lifespan +context; `import_server` deprecated) and the `Depends(...)` consumption are unchanged. diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml index 2207982..843328e 100644 --- a/.github/workflows/demo.yml +++ b/.github/workflows/demo.yml @@ -31,7 +31,7 @@ jobs: python-version: "3.12" enable-cache: true - name: Install project - run: uv sync --locked --dev + run: uv sync --locked --dev --extra mcp # mcp extra: the tape's `mcp methods` step # Install VHS + ttyd ourselves (ffmpeg is preinstalled on ubuntu-latest). # The charmbracelet/vhs-action bundles flaky font/ffmpeg downloaders that @@ -53,8 +53,13 @@ jobs: - name: Commit regenerated GIF uses: stefanzweifel/git-auto-commit-action@v7.1.0 with: - # [skip ci]: the GIF-only commit shouldn't spin up CI or the release workflow. - commit_message: "docs: regenerate demo GIF [skip ci]" + # No CI-bypass marker in the message: recursion is already prevented + # structurally (this job triggers on docs/demo/** while the output lives in + # docs/assets/, so the GIF commit never re-renders), and the auto-commit + # rides the default GITHUB_TOKEN, whose pushes don't start new runs. A bypass + # marker here would instead ride a squash-merge into main and silently cancel + # the python-semantic-release run — the v0.8.0 release incident. + commit_message: "docs: regenerate demo GIF" file_pattern: docs/assets/demo.gif commit_user_name: vhs-action 📼 commit_user_email: actions@github.com diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 61f6699..76fb109 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,11 +9,11 @@ and `tests/test_snapshots.py`. A failing build names the violated invariant. ``` src/ycli/ -├── cli.py · mcp.py · output.py · log.py · context.py · settings.py # roots +├── cli/ · mcp/ · log.py · settings.py # roots (cli/ = app · context · output) └── yandex/ - ├── base.py · transport.py · pagination.py · _mcp.py # shared + ├── base.py · transport.py · pagination.py · mcp.py # shared (mcp.py = MCP helpers) └── / # tracker · wiki · forms - ├── _base.py · _deps.py · _args.py · client.py · cli.py · mcp.py + ├── base.py · dependencies.py · typedefs.py · utils.py · client.py · cli.py · mcp.py └── / # issues · pages · surveys · … ├── client.py # uplink SDK — the ONLY place HTTP happens ├── cli.py # Typer — output via Serializer.serialize @@ -25,27 +25,35 @@ src/ycli/ Notable shared pieces: - `src/ycli/settings.py` — `AppConfig` + `Credentials` pydantic-settings models (app-wide config) - `src/ycli/yandex/models.py` — `APIModel` base (lenient parse config, no serialization logic) -- `src/ycli/context.py` — `AppContext` (typed composition root for the CLI) +- `src/ycli/cli/context.py` — `AppContext` (typed composition root for the CLI) - `src/ycli/yandex/pagination.py` — `PaginationStrategy` ABC + concrete strategies -- `src/ycli/yandex/_mcp.py` — shared MCP annotation helpers (`RO`) -- `src/ycli/yandex//_args.py` — deduplicated CLI argument/option type aliases +- `src/ycli/yandex/mcp.py` — shared MCP annotation helpers (`RO`) plus the `@cache`d client/config + providers (`make_cached_client`, `app_config`) that share one client across a mounted domain's tools +- `src/ycli/yandex//typedefs.py` — deduplicated CLI argument/option type aliases; + `utils.py` — shared CLI helpers where a domain needs them (tracker: request-body builders, + `--field` JSON coercion) ## Invariants (ARCH-1..11) - **ARCH-1 — Four-surface symmetry.** Every `yandex///` directory contains `__init__.py`, `client.py`, `cli.py`, `mcp.py`, `models.py`. Use `/new-endpoint` to scaffold. + *Carve-out:* `yandex/status/` and the `ycli/mcp/` server package are cross-cutting surfaces, + not `/` dirs — the four-surface rule and the `_resource_dirs()` check + (which scans only `tracker/wiki/forms`) do not apply to them. - **ARCH-2 — HTTP confinement.** `cli.py`, `mcp.py`, and `models.py` never import `requests` or `uplink`. All HTTP lives in `client.py` / `base.py` / `transport.py`. -- **ARCH-3 — MCP is read-only.** `fastmcp` is imported only in modules named `mcp.py`. Every MCP +- **ARCH-3 — MCP is read-only.** `fastmcp` is imported only in modules named `mcp.py` and in the + `ycli.mcp` server package (`src/ycli/mcp/server.py`; its `__init__.py` stays fastmcp-free so the + base install loads the CLI sub-app without the extra). Every MCP tool's verb (last `_`-segment of its name) must be in a fail-closed read-verb **allow-list** - (`get/list/count/full/search/descendants/meta` — a new read adds its verb deliberately), it + (`get/list/count/search/descendants/meta` — a new read adds its verb deliberately), it carries `readOnlyHint=True` (via the `RO` annotation), and no `mcp.py` may call a client write method (`.create/.update/.add/.execute/…`). - **ARCH-4 — Serialization confinement.** Model→output rendering happens only through `output.Serializer.serialize(...)`; `model_dump_json`, `yaml.safe_dump`, and `json.dumps` - appear only in `src/ycli/output.py`. Models stay plain data (no serialize method); the - strategies live only in `output.py`. Unmodeled API dicts are wrapped in `RawMapping` - (a `RootModel[dict]` in `ycli.yandex.models`) before being passed to the Serializer. + appear only in `src/ycli/cli/output.py`. Models stay plain data (no serialize method); the + strategies live only in `output.py`. Every rendered value is a typed pydantic model — there + is no raw-dict/`RawMapping` escape hatch. *Carve-out:* a bare `print(int)` for a scalar `count` result is fine — it is not model output and needs no Serializer wrapping. *Check:* `model_dump_json` / `yaml.safe_dump` / `json.dumps` only in `output.py`; CLI command bodies render via `Serializer.serialize`. @@ -106,7 +114,7 @@ review cover the rest): ## Resource conventions (models, naming, MCP imports) The conventions that ARCH-1..10 do not capture — `APIModel` inheritance, `XList`/`XResponse` -naming, the `_deps` import path, and the raw-accessor pattern — are documented in +naming and the `dependencies` import path — are documented in [`docs/conventions/resources.md`](docs/conventions/resources.md). ## Changing an invariant diff --git a/CLAUDE.md b/CLAUDE.md index 3c57047..7b36ba8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,11 +25,14 @@ Claude Code **plugin** under `plugins/yandex-360/`. Published on PyPI as `yandex - **Dependencies:** add with `uv add ` (runtime) / `uv add --dev ` (dev) — never hand-edit `pyproject.toml` dependency lists. +- **Naming:** spell identifiers and env-var names out in full — never abbreviate + (`timeout_seconds` not `timeout_s`, `organization_id` not `org_id`; `YANDEX_ID_OAUTH_TOKEN` + is already correct). - **Tests:** `uv run pytest`. Async MCP tests rely on `asyncio_mode = "auto"`; HTTP is stubbed with `responses` (no live network). Mark CLI/MCP wiring tests with `@pytest.mark.integration`. - **Auth:** credentials (`YANDEX_ID_OAUTH_TOKEN` / `YANDEX_ID_ORGANIZATION_ID`) are read once at the composition root — `Credentials()` / `AppConfig()` in `AppContext` for the CLI, or - the `_deps` cached factory in each domain's MCP module — and passed as raw `oauth_token` / + the `dependencies` cached factory in each domain's MCP module — and passed as raw `oauth_token` / `organization_id` constructor arguments to each client. There is no `from_env` or `session_from_env`; never hardcode credentials. Header casing differs per service (Tracker `X-Org-ID`, Wiki/Forms `X-Org-Id`). diff --git a/README.md b/README.md index 9ac9748..31cbeca 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ Drive **Tracker**, **Wiki**, and **Forms** from a CLI, an MCP server, a Python SDK, or a Claude Code plugin. Built for AI agents first — pleasant for humans too. -[![CI](https://img.shields.io/github/actions/workflow/status/bim-ba/ycli/ci.yml?branch=main&style=flat-square&label=ci&color=555)](https://github.com/bim-ba/ycli/actions/workflows/ci.yml) -[![Coverage](https://img.shields.io/badge/coverage-100%25-555?style=flat-square)](https://github.com/bim-ba/ycli) -[![PyPI](https://img.shields.io/pypi/v/yandex-cli?style=flat-square&color=555&label=pypi)](https://pypi.org/project/yandex-cli/) -[![Python](https://img.shields.io/badge/python-3.12%2B-555?style=flat-square)](https://www.python.org/) -[![License](https://img.shields.io/badge/license-MIT-555?style=flat-square)](LICENSE) +[![CI](https://img.shields.io/github/actions/workflow/status/bim-ba/ycli/ci.yml?branch=main&logo=githubactions&logoColor=white&label=ci)](https://github.com/bim-ba/ycli/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen?logo=pytest&logoColor=white)](https://github.com/bim-ba/ycli) +[![PyPI](https://img.shields.io/pypi/v/yandex-cli?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/yandex-cli/) +[![Python](https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/badge/license-MIT-lightgrey?logo=opensourceinitiative&logoColor=white)](LICENSE) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bim-ba/ycli) ycli in action diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif index 024dece..09dff9f 100644 Binary files a/docs/assets/demo.gif and b/docs/assets/demo.gif differ diff --git a/docs/conventions/resources.md b/docs/conventions/resources.md index 9856d13..700905b 100644 --- a/docs/conventions/resources.md +++ b/docs/conventions/resources.md @@ -47,50 +47,40 @@ appear in the public `client.list()` signature or in MCP tool return types. --- -## 3. MCP `RO` / `TAGS` / `_client` come from the domain `_deps` +## 3. MCP `RO` / `TAGS` / `_client` come from the domain `dependencies` Every `mcp.py` imports `RO`, `TAGS`, and the domain client provider from the domain's -`_deps` module — not from the shared `ycli.yandex._mcp`: +`dependencies` module — not from the shared `ycli.yandex.mcp`: ```python # src/ycli/yandex/tracker/issues/mcp.py -from ycli.yandex.tracker._deps import RO, TAGS, tracker_client +from ycli.yandex.tracker.dependencies import RO, TAGS, tracker_client ``` -The `_deps` module re-exports `RO` (from `ycli.yandex._mcp`) in its `__all__`, so +The `dependencies` module re-exports `RO` (from `ycli.yandex.mcp`) in its `__all__`, so import-linter and IDEs resolve the canonical source correctly. The scaffold (`scripts/new_endpoint.py`) generates this single-line import automatically. ---- - -## 4. Raw / full unpruned accessor (`_raw` / `full` MCP tool) +### Why `_client` is a cached provider -When a resource's pruned model omits fields that callers might need, offer a companion -accessor that returns the raw `dict[str, Any]`: +fastmcp's `mount()` does not propagate lifespan context across server boundaries, so a +mounted domain server cannot receive a shared client through startup state. Each `dependencies` +module therefore builds its provider with `make_cached_client` (in `ycli.yandex.mcp`), which +wraps a `functools.cache`d zero-arg factory: ```python -# client.py -@uplink.returns.json() -@uplink.get("issues/{key}") -def get_raw(self, key: uplink.Path) -> dict: # ty: ignore[empty-body] - """GET one issue — raw dict, all fields.""" +# src/ycli/yandex/tracker/dependencies.py +tracker_client = make_cached_client(TrackerClient) ``` -```python -# mcp.py — exposed as a separate tool with the _full verb -@mcp.tool(name="issues_full", annotations={**RO, "title": "Get full Tracker issue (raw)"}, tags=TAGS) -def full(key: str, client: TrackerClient = Depends(tracker_client)) -> dict[str, Any]: - """A single Tracker issue as a raw dict (all fields).""" - return client.issues.get_raw(key) -``` - -Wrap the dict in `RawMapping` before passing it to `Serializer.serialize` in `cli.py` -(ARCH-4). Only add the raw accessor when the pruned model is intentionally incomplete -and users are known to need the omitted fields. +The provider reads credentials from the env once and returns the same client for every tool +in the domain; `app_config()` is the matching `@cache`d config provider. MCP tools consume +them via `Depends(tracker_client)`. This is the only approved sharing pattern — fastmcp's +deprecated `import_server` must not be used. --- -## 5. MCP tool-metadata standard +## 4. MCP tool-metadata standard Every MCP tool MUST satisfy the following metadata contract. fastmcp auto-derives `description` from the docstring and `outputSchema` from the return type annotation — @@ -135,13 +125,36 @@ field `outputSchema`, exposed as camelCase by fastmcp 3.4.x). --- +## 5. Heterogeneous MCP output unions must be discriminated + +fastmcp rebuilds `result.data` from the tool's output JSON schema and, for an undiscriminated +`anyOf`, picks the *first* branch that validates — silently reshaping one member into another +and dropping fields. Any union a tool returns must carry a `Literal` discriminator tag via +`Field(discriminator=…)`: + +```python +class TrackerAuthStatus(_ServiceAuthStatus): + service: Literal["tracker"] = "tracker" + me: TrackerMe | None = None +# … WikiAuthStatus, FormsAuthStatus … +ServiceAuthStatus = Annotated[ + TrackerAuthStatus | WikiAuthStatus | FormsAuthStatus, Field(discriminator="service") +] +``` + +The CLI/SDK path carries the native model instance and is unaffected; only the MCP +`result.data` reconstruction depends on the schema being self-describing. + +--- + ## 6. Where these rules are enforced | Rule | Enforced by | |---|---| | `APIModel` base | code review only — no automated check (ARCH-1 verifies the files exist, not what they subclass) | | `XList` / `XResponse` naming | code review only — model class names are not snapshotted (snapshots track command/tool names) | -| `_deps` import path | `scripts/new_endpoint.py` scaffold + code review | +| `dependencies` import path | `scripts/new_endpoint.py` scaffold + code review | | Read-only MCP | `tests/test_architecture.py` ARCH-3 | | Serialization confinement | `tests/test_architecture.py` ARCH-4 | +| Discriminated MCP output unions | code review + regression test (`status_get` me round-trip) | | MCP tool description + output schema | `tests/test_architecture.py::test_every_mcp_tool_has_description_and_output_schema` | diff --git a/docs/demo/bin/ycli b/docs/demo/bin/ycli index 8788ade..c2dc4f1 100755 --- a/docs/demo/bin/ycli +++ b/docs/demo/bin/ycli @@ -1,31 +1,19 @@ #!/usr/bin/env bash -# Demo shim used ONLY by docs/demo/demo.tape. Real `--help` output (no creds, -# no network); baked safe sample data for the data commands. Keeps the recorded -# GIF reproducible and leak-free. Not installed; not on a user's PATH. +# Demo shim used ONLY by docs/demo/demo.tape. Real `--help` and a real `mcp methods` +# tool list; the data commands render committed fixtures through the REAL ycli via +# docs/demo/render.py (no network, no credentials). Keeps the GIF reproducible and +# leak-free. Not installed; not on a user's PATH. +# +# `--no-sync`: the tape's Hide block runs `uv sync --extra mcp --dev` once up front, so +# no command here re-resolves the environment mid-recording (which would print install +# noise into the GIF, and would otherwise drop the mcp extra that `mcp methods` needs). case "$*" in "--help"|"") - exec uv run ycli --help ;; - "tracker issues get TRACKER-1") - cat <<'OUT' -TRACKER-1 · Set up project scaffolding -status: In Progress assignee: alice -priority: Normal updated: 2026-06-20 -OUT - ;; - "wiki pages get onboarding") - cat <<'OUT' -onboarding · Team Onboarding Guide -author: bob revision: 7 children: 4 -OUT - ;; - "mcp start") - cat <<'OUT' -Starting MCP server on stdio … -Tools: tracker_issues_get, tracker_issues_list, tracker_issues_search, - wiki_pages_get, wiki_pages_descendants, wiki_pages_meta, - forms_surveys_list, forms_answers_list -OUT - ;; + exec uv run --no-sync ycli --help ;; + "tracker issues get DEMO-42"|"wiki pages get onboarding") + exec uv run --no-sync python docs/demo/render.py "$@" ;; + "mcp methods") + exec uv run --no-sync ycli mcp methods ;; *) - exec uv run ycli "$@" ;; + exec uv run --no-sync ycli "$@" ;; esac diff --git a/docs/demo/demo.tape b/docs/demo/demo.tape index 6dba5d7..ccf760f 100644 --- a/docs/demo/demo.tape +++ b/docs/demo/demo.tape @@ -3,23 +3,28 @@ # Regenerate the GIF: # vhs docs/demo/demo.tape # Requires: vhs + ttyd + ffmpeg on PATH (see CONTRIBUTING / README). +# The Hide block below syncs the `mcp` extra up front, so `mcp methods` works and no +# command prints install noise into the recording. # -# The recording runs the REAL `ycli --help` and leak-free baked sample data via -# the shims in docs/demo/bin (no network, no credentials). The shims are put -# first on PATH inside a Hide block so the demo types plain `ycli ...` commands. +# The recording runs the REAL `ycli --help`, renders committed JSON fixtures +# via docs/demo/render.py for the two data commands (no network, no credentials), +# and shows the real `ycli mcp methods` tool list. The shims in docs/demo/bin put +# the real ycli first on PATH inside a Hide block so the demo types plain `ycli ...`. Output docs/assets/demo.gif Set Shell bash Set FontSize 17 Set Width 960 -Set Height 720 +Set Height 900 Set Padding 24 Set TypingSpeed 55ms Set Theme { "name": "Catppuccin Mocha", "background": "#1e1e2e", "foreground": "#cdd6f4", "cursor": "#f5e0dc", "selection": "#585b70", "black": "#45475a", "brightBlack": "#585b70", "red": "#f38ba8", "brightRed": "#f38ba8", "green": "#a6e3a1", "brightGreen": "#a6e3a1", "yellow": "#f9e2af", "brightYellow": "#f9e2af", "blue": "#89b4fa", "brightBlue": "#89b4fa", "magenta": "#cba6f7", "brightMagenta": "#cba6f7", "cyan": "#94e2d5", "brightCyan": "#94e2d5", "white": "#bac2de", "brightWhite": "#a6adc8" } -# Set up a clean prompt and put the demo shims first on PATH (off-camera). +# Sync the mcp extra once, set a clean prompt, and put the demo shims first on PATH +# (all off-camera) so the recorded commands run instantly with no install noise. Hide +Type 'uv sync --extra mcp --dev >/dev/null 2>&1' Enter Type 'export PATH="$PWD/docs/demo/bin:$PATH"' Enter Type 'export PS1="❯ "' Enter Type 'clear' Enter @@ -29,13 +34,13 @@ Show Type "ycli --help" Sleep 500ms Enter Sleep 2.5s -# Baked, leak-free sample data. -Type "ycli tracker issues get TRACKER-1" Sleep 500ms Enter +# Fixture-rendered data (committed JSON via render.py — deterministic, offline). +Type "ycli tracker issues get DEMO-42" Sleep 500ms Enter Sleep 2s Type "ycli wiki pages get onboarding" Sleep 500ms Enter Sleep 2s -# Read-only MCP server banner with real tool names. -Type "ycli mcp start" Sleep 500ms Enter +# Real read-only MCP tool list (no creds, no network). +Type "ycli mcp methods" Sleep 500ms Enter Sleep 3s diff --git a/docs/demo/fixtures/tracker-issue.json b/docs/demo/fixtures/tracker-issue.json new file mode 100644 index 0000000..2279e9a --- /dev/null +++ b/docs/demo/fixtures/tracker-issue.json @@ -0,0 +1,10 @@ +{ + "key": "DEMO-42", + "summary": "🚀 Ship the new onboarding flow", + "queue": {"key": "DEMO", "display": "Demo Project"}, + "type": {"key": "task", "display": "Task"}, + "status": {"key": "inProgress", "display": "In Progress"}, + "priority": {"key": "critical", "display": "Critical"}, + "assignee": {"display": "Alice Ivanova"}, + "tags": ["onboarding", "q3-roadmap"] +} diff --git a/docs/demo/fixtures/wiki-page.json b/docs/demo/fixtures/wiki-page.json new file mode 100644 index 0000000..2045d1d --- /dev/null +++ b/docs/demo/fixtures/wiki-page.json @@ -0,0 +1,6 @@ +{ + "id": 1, + "slug": "onboarding", + "title": "Team Onboarding 🚀", + "content": "# 👋 Welcome to the team!\n\nGlad to have you aboard. Here's your first week at a glance:\n\n## ✅ Day 1 — Setup\n- Get your accounts: **Tracker**, **Wiki**, Calendar\n- Say hi in **#team-general**\n\n## 📚 Day 2–3 — Ramp up\n- Skim the architecture docs\n- Pair with your onboarding buddy\n\n## 🚀 Day 4–5 — Ship\n- Grab a starter task from the **DEMO** queue\n- Open your first pull request!\n\n> 💡 Stuck on anything? Ask anyone — we're here to help.\n" +} diff --git a/docs/demo/render.py b/docs/demo/render.py new file mode 100644 index 0000000..12bf328 --- /dev/null +++ b/docs/demo/render.py @@ -0,0 +1,80 @@ +"""Render real `ycli` CLI output from a committed fixture — the demo's leak-free data source. + +Used only by docs/demo/bin/ycli (the vhs shim). Stubs the matching API endpoint with +`responses`, sets dummy creds, and invokes the real Typer app in-process so the printed +output is genuine rendering of committed data — deterministic, offline, no real org data. + + python docs/demo/render.py tracker issues get DEMO-42 + python docs/demo/render.py wiki pages get onboarding + +Notes: +- wiki pages get: GET /pages?slug=&fields=content (query param, not path segment). + The CLI prints page.content directly (raw markdown) — --format has no effect on it. +- tracker issues get: GET /issues/ (path param). Rendered with --format pretty so the + demo shows the interactive table a user actually sees (auto would pick JSON here because + CliRunner has no TTY); FORCE_COLOR=1 makes rich keep the ANSI colors through the pipe. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import responses +from typer.testing import CliRunner + +HERE = Path(__file__).resolve().parent +FIXTURES = HERE / "fixtures" +TRACKER = "https://api.tracker.yandex.net/v3" +WIKI = "https://api.wiki.yandex.net/v1" + +# Map a demo command (argv tuple) to (HTTP method, URL, fixture file, cli_argv). +# wiki pages get: the client calls GET /pages?slug=onboarding&fields=content. +# responses matches on URL prefix by default; the query params are matched separately +# via match_querystring=False (default), so stub URL needs no query string. +ROUTES: dict[tuple[str, ...], tuple[str, str, str, list[str]]] = { + ("tracker", "issues", "get", "DEMO-42"): ( + responses.GET, + f"{TRACKER}/issues/DEMO-42", + "tracker-issue.json", + ["--format", "pretty", "tracker", "issues", "get", "DEMO-42"], + ), + ("wiki", "pages", "get", "onboarding"): ( + responses.GET, + f"{WIKI}/pages", + "wiki-page.json", + ["wiki", "pages", "get", "onboarding"], + ), +} + + +def main(argv: list[str]) -> int: + route = ROUTES.get(tuple(argv)) + if route is None: + print(f"demo render: unknown command {argv}", file=sys.stderr) + return 2 + method, url, fixture, cli_argv = route + body = json.loads((FIXTURES / fixture).read_text(encoding="utf-8")) + + import ycli.cli.app as cli + + runner = CliRunner() + with responses.RequestsMock() as rsps: + rsps.add(method, url, json=body, status=200) + # Dummy creds satisfy Credentials(); responses intercepts the call (no real network). + # FORCE_COLOR keeps rich's ANSI colors through CliRunner's pipe; COLUMNS gives the + # pretty table room so it isn't wrapped in the recording. + env = { + "YANDEX_ID_OAUTH_TOKEN": "demo", + "YANDEX_ID_ORGANIZATION_ID": "demo", + "FORCE_COLOR": "1", + "COLUMNS": "80", + } + result = runner.invoke(cli.app, cli_argv, env=env) + sys.stdout.write(result.stdout) + return result.exit_code + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/docs/superpowers/plans/2026-06-29-round-4-architecture.md b/docs/superpowers/plans/2026-06-29-round-4-architecture.md new file mode 100644 index 0000000..ddbcce3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-round-4-architecture.md @@ -0,0 +1,1253 @@ +# Round-4 Architecture Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Drop the one-off `RawMapping`/`full` raw accessor, turn `status` and `mcp` into proper packages (status gains a `status_get` MCP tool and returns the bare native `me`), tighten pagination types, and make the demo GIF render real CLI output from committed fixtures. + +**Architecture:** Six independent tasks (T1–T6), each a single commit ending in a green gate. Public-surface changes regenerate `tests/snapshots/` on purpose (ARCH-6); each invariant edit changes `ARCHITECTURE.md` together with its enforcing check. The 100% coverage gate stays green throughout — dead code is deleted with its dead tests. + +**Tech Stack:** Python ≥3.12 · uv · uplink+requests · typer · fastmcp (read-only) · pydantic v2 · ruff · ty 0.0.55 · pytest + `responses` · vhs. + +**Spec:** `docs/superpowers/specs/2026-06-29-round-4-architecture-design.md` + +## Global Constraints + +- `client.py` / `_base.py` modules MUST NOT use `from __future__ import annotations` (uplink reads runtime annotations). Other modules may. +- Credentials enter only at a composition root (`Credentials()`/`AppConfig()` for CLI via `AppContext`; the `_deps` cached providers for MCP) as raw `oauth_token`/`organization_id`. No `from_env`. Never hardcode `YANDEX_ID_*` (ARCH-5/7/8). +- MCP is read-only (ARCH-3): a tool's verb (last `_`-segment) must be in `READ_VERBS = {"get","list","count","search","descendants","meta"}` (note: `"full"` is removed in T2) and carry `readOnlyHint=True` via the `RO` annotation; no `mcp.py` calls a client write method. +- Output only via `output.Serializer.serialize(...)` (ARCH-4). +- Self-documenting names, no abbreviations. +- 100% coverage: `uv run pytest` enforces `--cov-fail-under=100`. +- Final gate for every task: `uv run ruff format --check . && uv run ruff check . && uv run lint-imports && uv run ty check && uv run pytest`. +- Commits end with `Co-Authored-By: Claude Opus 4.8 `. Branch `refactor/round-4-architecture` (already created off main). No direct push to main. No skip-ci token in any commit message. + +--- + +### Task 1: `ycli/mcp/` package (W-C) + +Turn the two root MCP modules into a package. The package `__init__.py` MUST stay free of a top-level `fastmcp` import so the base install (no `mcp` extra) can import `ycli.mcp.cli` — the server lives in `server.py`, exposed lazily. + +**Files:** +- Create: `src/ycli/mcp/__init__.py` (lazy re-export, fastmcp-free) +- Create: `src/ycli/mcp/server.py` (the FastMCP server — body of the old `src/ycli/mcp.py`) +- Create: `src/ycli/mcp/cli.py` (the `ycli mcp` Typer app — body of the old `src/ycli/mcp_cli.py`) +- Create: `src/ycli/mcp/__main__.py` (so `python -m ycli.mcp` runs the server) +- Delete: `src/ycli/mcp.py`, `src/ycli/mcp_cli.py` +- Modify: `src/ycli/cli.py:14` import +- Modify: `ARCHITECTURE.md` ARCH-3 prose +- Test: `tests/test_yandex_mcp.py`, `tests/test_yandex_cli.py` (existing — must stay green unchanged) + +**Interfaces:** +- Produces: `from ycli.mcp import mcp, main` (lazy via `__getattr__`); `from ycli.mcp.cli import app`; `python -m ycli.mcp`. T3 mounts a status subserver in `server.py`. +- Consumes: nothing new. + +- [ ] **Step 1: Add a base-install guard test (fastmcp-free import path)** + +Add to `tests/test_yandex_mcp.py`: + +```python +def test_base_install_imports_cli_without_fastmcp(): + """`ycli.mcp.cli` (and `ycli.cli`) must import without pulling fastmcp — base install.""" + import subprocess + import sys + + code = "import ycli.cli, ycli.mcp.cli, sys; assert 'fastmcp' not in sys.modules" + proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run pytest tests/test_yandex_mcp.py::test_base_install_imports_cli_without_fastmcp -v` +Expected: FAIL — `ycli.mcp.cli` does not exist yet (ModuleNotFoundError). + +- [ ] **Step 3: Create `src/ycli/mcp/server.py`** (verbatim body of the current `src/ycli/mcp.py`) + +```python +"""Root Yandex 360 FastMCP server — mounts the per-domain subservers. + +Run over stdio for LLM-agent clients: ``ycli mcp start`` (or ``python -m ycli.mcp``). +Tools are namespaced per domain: ``wiki_*``, ``tracker_*``, ``forms_*``. Reads-only. +""" + +from fastmcp import FastMCP + +from ycli.log import configure +from ycli.settings import AppConfig +from ycli.yandex.forms.mcp import mcp as forms_mcp +from ycli.yandex.tracker.mcp import mcp as tracker_mcp +from ycli.yandex.wiki.mcp import mcp as wiki_mcp + +mcp = FastMCP( + "yandex", + instructions=( + "Read-only access to Yandex 360: Tracker (issues, comments, worklog, …), " + "Wiki (pages, attachments), and Forms. Tools are namespaced wiki_*, tracker_*, " + "forms_*, and are all read-only — create/update happens via the ycli CLI/SDK, not " + "here. Credentials come from the YANDEX_ID_OAUTH_TOKEN and " + "YANDEX_ID_ORGANIZATION_ID environment variables." + ), +) +mcp.mount(wiki_mcp, namespace="wiki") +mcp.mount(tracker_mcp, namespace="tracker") +mcp.mount(forms_mcp, namespace="forms") + + +def main() -> None: + """Run the root server over stdio (the console-script entry point). + + Example: + >>> main() # doctest: +SKIP + """ + configure( + level=AppConfig().log_level + ) # match the CLI: single stderr sink, stdout stays clean for the protocol + mcp.run() + + +if __name__ == "__main__": # pragma: no cover + main() +``` + +- [ ] **Step 4: Create `src/ycli/mcp/__init__.py`** (lazy, fastmcp-free) + +```python +"""The ``ycli mcp`` surface — the read-only FastMCP server plus its CLI sub-app. + +``__init__`` stays import-light so the base install (no ``mcp`` extra) can load +``ycli.mcp.cli`` without importing fastmcp; ``mcp`` and ``main`` resolve lazily on +attribute access, preserving ``from ycli.mcp import mcp, main`` for every call site. +""" + +from __future__ import annotations + +from typing import Any + +__all__ = ["main", "mcp"] + + +def __getattr__(name: str) -> Any: + if name in {"mcp", "main"}: + from ycli.mcp import server + + return getattr(server, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +``` + +- [ ] **Step 5: Create `src/ycli/mcp/cli.py`** (verbatim body of the current `src/ycli/mcp_cli.py`) + +```python +"""``ycli mcp`` sub-app: run the server and list its tools. Importable without the mcp extra.""" + +from __future__ import annotations + +import typer + +app = typer.Typer(name="mcp", help="Read-only MCP server control.", no_args_is_help=True) + +_MISSING = ( + "The MCP server requires the 'mcp' extra. Install it with: " + "uv add 'yandex-cli[mcp]' (or: uv tool install 'yandex-cli[mcp]')." +) + + +@app.callback() +def _group() -> None: + """Group anchor — forces subcommand dispatch (no eager import, --help stays extra-free).""" + + +@app.command() +def start() -> None: + """Run the read-only MCP server over stdio (tools namespaced wiki_*, tracker_*, forms_*).""" + try: + from ycli.mcp import main as run_server + except ModuleNotFoundError as exc: # pragma: no cover - only without the extra + raise typer.BadParameter(_MISSING) from exc + run_server() + + +@app.command() +def methods() -> None: + """List the MCP tool names exposed by the server.""" + import asyncio + + try: + from fastmcp import Client + + from ycli.mcp import mcp + except ModuleNotFoundError as exc: # pragma: no cover - only without the extra + raise typer.BadParameter(_MISSING) from exc + + async def _list() -> None: + async with Client(mcp) as client: + for tool in sorted(t.name for t in await client.list_tools()): + typer.echo(tool) + + asyncio.run(_list()) +``` + +- [ ] **Step 6: Create `src/ycli/mcp/__main__.py`** + +```python +"""``python -m ycli.mcp`` — run the read-only MCP server over stdio.""" + +from ycli.mcp.server import main + +if __name__ == "__main__": # pragma: no cover + main() +``` + +- [ ] **Step 7: Delete the old modules** + +```bash +git rm src/ycli/mcp.py src/ycli/mcp_cli.py +``` + +- [ ] **Step 8: Update `src/ycli/cli.py:14`** + +Old: +```python +from ycli.mcp_cli import app as mcp_app +``` +New: +```python +from ycli.mcp.cli import app as mcp_app +``` + +- [ ] **Step 9: Update `ARCHITECTURE.md` ARCH-3 prose** + +In the ARCH-3 bullet, replace the opening sentence: +``` +- **ARCH-3 — MCP is read-only.** `fastmcp` is imported only in modules named `mcp.py`. Every MCP +``` +with: +``` +- **ARCH-3 — MCP is read-only.** `fastmcp` is imported only in modules named `mcp.py` and in the + `ycli.mcp` server package (`src/ycli/mcp/server.py`; its `__init__.py` stays fastmcp-free so the + base install loads the CLI sub-app without the extra). Every MCP +``` +(Note: the import-linter contract in `pyproject.toml` already permits this — `ycli.mcp*` is not in the ARCH-3 `source_modules` forbidden list — so only the prose changes. Confirm with `uv run lint-imports` in Step 11.) + +- [ ] **Step 10: Run the base-install guard + the MCP server tests** + +Run: `uv run pytest tests/test_yandex_mcp.py tests/test_yandex_cli.py -v` +Expected: PASS — including `test_base_install_imports_cli_without_fastmcp`, `test_root_mounts_all_domains_with_namespaces` (still 25 tools), `test_mcp_start_launches_server` (patches `ycli.mcp.main`), `test_mcp_methods_lists_tool_names`. + +- [ ] **Step 11: Full gate + smoke test** + +Run: `uv run ruff format --check . && uv run ruff check . && uv run lint-imports && uv run ty check && uv run pytest` +Expected: all green. Then build-free smoke check: +Run: `uv run python -c "from ycli.mcp import mcp, main; from ycli.mcp.cli import app; print('ok')"` +Expected: `ok`. And `uv run python -m ycli.mcp --help`-equivalent is not applicable (server runs stdio); instead verify the entry resolves: `uv run python -c "import ycli.mcp.__main__"` → no error. + +- [ ] **Step 12: Commit** + +```bash +git add -A +git commit -m "refactor: move the MCP server + CLI into a ycli.mcp package + +Server lives in ycli/mcp/server.py; the package __init__ stays fastmcp-free and +re-exports mcp/main lazily so the base install loads ycli.mcp.cli without the extra. +python -m ycli.mcp runs the server via __main__. Updates ARCH-3 prose. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: Remove `RawMapping` / `full` / `get_raw` / `issues_full` (W-A) + +Delete the one-off raw accessor across all four surfaces and its tests, update the two invariants that mention it, and regenerate the snapshots. + +**Files:** +- Modify: `src/ycli/yandex/models.py` (delete `RawMapping`) +- Modify: `src/ycli/yandex/tracker/issues/client.py` (delete `get_raw`) +- Modify: `src/ycli/yandex/tracker/issues/cli.py` (delete `full` command + `RawMapping` import) +- Modify: `src/ycli/yandex/tracker/issues/mcp.py` (delete `issues_full` tool + unused `Any` import if newly unused) +- Modify: `tests/yandex/tracker/issues/test_client.py`, `test_mcp.py`, `test_cli.py`, `tests/yandex/tracker/test_mcp.py`, `tests/test_yandex_mcp.py` +- Modify: `tests/test_architecture.py` (`READ_VERBS`) +- Modify: `ARCHITECTURE.md` (ARCH-4), `docs/conventions/resources.md` (§4) +- Regenerate: `tests/snapshots/mcp_tools.txt`, `tests/snapshots/cli_tree.txt` + +**Interfaces:** +- Produces: a smaller public surface (no `tracker issues full` CLI, no `issues_full` tool, no `get_raw` SDK method, no `RawMapping`). T3 re-adds one tool (`status_get`) restoring the MCP total to 25. + +- [ ] **Step 1: Delete the dead tests first (red baseline)** + +In `tests/yandex/tracker/issues/test_client.py` delete `test_get_raw_returns_dict` (the whole `@responses.activate` function asserting `_client().get_raw("DE-1") == {...}`). + +In `tests/yandex/tracker/issues/test_mcp.py`: +- Delete `test_issues_full_tool_returns_raw_dict` (the function calling `client.call_tool("issues_full", ...)`). +- In `test_issue_tools_registered_read_only`, change the asserted set from + `{"issues_get", "issues_full", "issues_list", "issues_search", "issues_count"}` to + `{"issues_get", "issues_list", "issues_search", "issues_count"}`. + +In `tests/yandex/tracker/issues/test_cli.py` delete both `test_full_renders_raw_dict_as_json` and `test_full_renders_raw_dict_as_yaml` (the latter holds the in-function `import yaml`; deleting it removes that smell — do NOT hoist `import yaml` to module top, no surviving test uses yaml, it would be an unused import). + +In `tests/yandex/tracker/test_mcp.py`: +- In `test_all_fourteen_read_tools_registered`, remove `"issues_full",` from the asserted set. +- Rename the function to `test_all_thirteen_read_tools_registered` and update the module docstring line 1 from `14 reads-only tools` to `13 reads-only tools`. + +In `tests/test_yandex_mcp.py`, in `test_root_mounts_all_domains_with_namespaces` change: +```python + assert len([n for n in names if n.startswith("tracker_")]) == 14 +``` +to +```python + assert len([n for n in names if n.startswith("tracker_")]) == 13 +``` +and +```python + assert len(names) == 25 +``` +to +```python + assert len(names) == 24 +``` +(T3 will restore this to 25 by adding `status_get`.) + +- [ ] **Step 2: Run the suite to confirm the deleted-feature tests are gone and the rest still reference live code** + +Run: `uv run pytest tests/yandex/tracker -q` +Expected: FAIL — surviving tests still pass, but the source still defines `full`/`get_raw`/`issues_full`, so the snapshot tests and `test_all_thirteen...`/count asserts now mismatch (source has 14 tracker tools, tests expect 13). This is the red state that the source deletion (next steps) turns green. + +- [ ] **Step 3: Delete `RawMapping` from `src/ycli/yandex/models.py`** + +Remove the class and the now-unused `RootModel` / `Any` imports: +```python +from typing import Any + +from pydantic import BaseModel, ConfigDict, RootModel +``` +becomes +```python +from pydantic import BaseModel, ConfigDict +``` +and delete: +```python +class RawMapping(RootModel[dict[str, Any]]): + """Wraps an unmodeled API dict so it renders through the Serializer (honoring --format).""" +``` + +- [ ] **Step 4: Delete `get_raw` from `src/ycli/yandex/tracker/issues/client.py`** + +Remove the whole method (the `@uplink.returns.json()` + `@uplink.get("issues/{key}")` + `def get_raw(...)` block with its docstring). + +- [ ] **Step 5: Delete the `full` command from `src/ycli/yandex/tracker/issues/cli.py`** + +Remove the `full` command: +```python +@app.command() +def full(ctx: typer.Context, key: KeyArg) -> None: + """Print the raw API dict for KEY (no pydantic projection).""" + app_ctx = AppContext.from_typer_context(ctx) + Serializer.serialize( + RawMapping(app_ctx.tracker.issues.get_raw(key)), app_ctx.strategy, app_ctx.console + ) +``` +and remove its now-unused import line: +```python +from ycli.yandex.models import RawMapping +``` + +- [ ] **Step 6: Delete the `issues_full` tool from `src/ycli/yandex/tracker/issues/mcp.py`** + +Remove: +```python +@mcp.tool( + name="issues_full", annotations={**RO, "title": "Get full Tracker issue (raw)"}, tags=TAGS +) +def full(key: str, client: TrackerClient = Depends(tracker_client)) -> dict[str, Any]: + """A single Tracker issue as a raw dict (all fields).""" + return client.issues.get_raw(key) +``` +Then check whether `Any` is still used in the file (the `from typing import Any` import). After removing `full`, grep the file: if `Any` no longer appears, delete `from typing import Any`. + +- [ ] **Step 7: Update `READ_VERBS` in `tests/test_architecture.py:24`** + +Old: +```python +READ_VERBS = {"get", "list", "count", "full", "search", "descendants", "meta"} +``` +New: +```python +READ_VERBS = {"get", "list", "count", "search", "descendants", "meta"} +``` + +- [ ] **Step 8: Update ARCH-4 in `ARCHITECTURE.md`** + +Replace: +``` + strategies live only in `output.py`. Unmodeled API dicts are wrapped in `RawMapping` + (a `RootModel[dict]` in `ycli.yandex.models`) before being passed to the Serializer. + *Carve-out:* a bare `print(int)` for a scalar `count` result is fine — it is not model +``` +with: +``` + strategies live only in `output.py`. Every rendered value is a typed pydantic model — there + is no raw-dict/`RawMapping` escape hatch. + *Carve-out:* a bare `print(int)` for a scalar `count` result is fine — it is not model +``` + +- [ ] **Step 9: Remove §4 from `docs/conventions/resources.md`** + +Delete the entire `## 4. Raw / full unpruned accessor (...)` section (its heading through just before `## 5. MCP tool-metadata standard`). Renumber the subsequent headings: `## 5.` → `## 4.`, `## 6.` → `## 5.`. Update any in-document cross-reference to those numbers if present (grep the file for `§4`/`§5`/`§6` / `section 5`). + +- [ ] **Step 10: Regenerate the snapshots** + +Run: `uv run python -m tests.snapshots --update` +Expected output: `wrote cli_tree.txt` and `wrote mcp_tools.txt`. Verify the diff removes exactly `tracker issues full` from `cli_tree.txt` and `tracker_issues_full` from `mcp_tools.txt` (and nothing else): +Run: `git diff tests/snapshots/` + +- [ ] **Step 11: Full gate** + +Run: `uv run ruff format --check . && uv run ruff check . && uv run lint-imports && uv run ty check && uv run pytest` +Expected: all green (24 MCP tools now; `test_arch3_mcp_tools_are_read_only` passes with no `_full` verb; `unused-ignore-comment` clean). + +- [ ] **Step 12: Commit** + +```bash +git add -A +git commit -m "feat!: remove the raw issues 'full' accessor and RawMapping + +BREAKING CHANGE: drops the 'tracker issues full' CLI command, the issues_full MCP +tool, IssuesClient.get_raw, and the RawMapping model. Every resource is a typed model. +Updates ARCH-3 read-verb allow-list, ARCH-4, resources.md, and the surface snapshots. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3: `status` package + native `me` + `status_get` MCP tool (W-B, W-F note, W-7) + +Explode `src/ycli/yandex/status.py` into a package, drop `ServiceProbe` + identity lambdas, return the bare native `me`, and add a read-only `status_get` MCP tool. + +**Files:** +- Create: `src/ycli/yandex/status/__init__.py`, `models.py`, `reporter.py`, `cli.py`, `mcp.py` +- Delete: `src/ycli/yandex/status.py` +- Modify: `src/ycli/mcp/server.py` (mount status subserver) +- Modify: `tests/test_yandex_mcp.py` (total 24→25, add `status_` assertion) +- Modify: `ARCHITECTURE.md` (ARCH-1 clarifying note) +- Regenerate: `tests/snapshots/mcp_tools.txt` +- Create: `tests/yandex/status/__init__.py`, `tests/yandex/status/test_mcp.py` +- Keep: `tests/yandex/test_status.py` (CLI behavior — must stay green unchanged) + +**Interfaces:** +- Consumes: each domain's `me` client (`TrackerClient.me` etc.), the cached `_deps` providers (`tracker_client`, `wiki_client`, `forms_client`), `RO` from `ycli.yandex._mcp`. +- Produces: `from ycli.yandex.status import app` (the `auth` Typer app — unchanged import for `cli.py:18`); the `status_get` MCP tool (verb `get`, namespace `status`); `ServiceAuthStatus(service, valid, me, detail)`, `AuthReport(configured, organization_id, services)`, `StatusReporter(me_clients).report(configured=, organization_id=)`. + +- [ ] **Step 1: Create the package `__init__.py`** + +`src/ycli/yandex/status/__init__.py`: +```python +"""Cross-cutting auth-status surface — the `auth status` CLI plus the `status_get` MCP tool. + +Not a `/` package (ARCH-1 four-surface symmetry does not apply): it +aggregates the three domains' `me` probes into one report. +""" + +from ycli.yandex.status.cli import app + +__all__ = ["app"] +``` + +- [ ] **Step 2: Create `models.py`** + +`src/ycli/yandex/status/models.py`: +```python +"""Models for `ycli auth status` and the `status_get` MCP tool.""" + +from __future__ import annotations + +from pydantic import Field + +from ycli.yandex.forms.me.models import User as FormsMe +from ycli.yandex.models import APIModel +from ycli.yandex.tracker.me.models import Me as TrackerMe +from ycli.yandex.wiki.me.models import Me as WikiMe + + +class ServiceAuthStatus(APIModel): + """One service's auth probe — the bare native `me` on success, else why it failed.""" + + service: str + valid: bool = False + me: TrackerMe | WikiMe | FormsMe | None = None + detail: str = "" + + +class AuthReport(APIModel): + """Whether the env credentials are set and work, per service.""" + + configured: bool + organization_id: str = "" + services: list[ServiceAuthStatus] = Field(default_factory=list) +``` + +Note: the three `me` models share a class name (`Me`/`Me`/`User`), hence the `as TrackerMe`/`as WikiMe`/`as FormsMe` aliases. The reporter passes model *instances* (not dumped dicts) so pydantic's smart-union keeps each one's concrete type. + +- [ ] **Step 3: Create `reporter.py`** + +`src/ycli/yandex/status/reporter.py`: +```python +"""Probe each service's identity endpoint and assemble an AuthReport (shared by CLI + MCP).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from ycli.yandex.errors import YandexAuthError, YandexError +from ycli.yandex.status.models import AuthReport, ServiceAuthStatus + +if TYPE_CHECKING: + from collections.abc import Mapping + + from ycli.yandex.models import APIModel + + +class MeProbe(Protocol): + """Structural type for a domain `me` client: a zero-arg `get()` returning an API model.""" + + def get(self) -> APIModel: ... + + +class StatusReporter: + """Given each service's `me` client, probe identity and build a per-service AuthReport.""" + + def __init__(self, me_clients: Mapping[str, MeProbe]) -> None: + self._me_clients = me_clients + + def report(self, *, configured: bool, organization_id: str) -> AuthReport: + services = [self._probe(name, client) for name, client in self._me_clients.items()] + return AuthReport( + configured=configured, organization_id=organization_id, services=services + ) + + @staticmethod + def _probe(name: str, me_client: MeProbe) -> ServiceAuthStatus: + try: + me = me_client.get() + except YandexAuthError: + return ServiceAuthStatus(service=name, valid=False, detail="token invalid or expired") + except YandexError as exc: + return ServiceAuthStatus(service=name, valid=False, detail=str(exc)) + return ServiceAuthStatus(service=name, valid=True, me=me) +``` + +- [ ] **Step 4: Create `cli.py`** (the `auth` app — builds clients via `AppContext`, which is typed, so no `ty: ignore`) + +`src/ycli/yandex/status/cli.py`: +```python +"""`ycli auth status` — validate credentials against each service's identity endpoint.""" + +from __future__ import annotations + +import typer +from pydantic import ValidationError + +from ycli.context import AppContext +from ycli.output import Serializer +from ycli.settings import Credentials +from ycli.yandex.status.models import AuthReport +from ycli.yandex.status.reporter import StatusReporter + +app = typer.Typer(name="auth", help="Inspect Yandex 360 credentials.", no_args_is_help=True) + +_ENV_NAMES = { + "oauth_token": "YANDEX_ID_OAUTH_TOKEN", + "organization_id": "YANDEX_ID_ORGANIZATION_ID", +} + + +@app.command() +def status(ctx: typer.Context) -> None: + """Report whether the env credentials are set and actually work, per service.""" + app_ctx = AppContext.from_typer_context(ctx) + try: + credentials = Credentials() # ty: ignore[missing-argument] + except ValidationError as exc: + missing = ", ".join( + _ENV_NAMES.get(str(e["loc"][0]), str(e["loc"][0])) for e in exc.errors() + ) + typer.secho(f"not configured — missing {missing}", fg=typer.colors.RED, err=True) + Serializer.serialize( + AuthReport(configured=False, services=[]), app_ctx.strategy, app_ctx.console + ) + raise typer.Exit(1) from None + + me_clients = { + "tracker": app_ctx.tracker.me, + "wiki": app_ctx.wiki.me, + "forms": app_ctx.forms.me, + } + report = StatusReporter(me_clients).report( + configured=True, organization_id=credentials.organization_id + ) + Serializer.serialize(report, app_ctx.strategy, app_ctx.console) + if not all(s.valid for s in report.services): + raise typer.Exit(1) +``` + +- [ ] **Step 5: Create `mcp.py`** (read-only `status_get`) + +`src/ycli/yandex/status/mcp.py`: +```python +"""Status FastMCP tool (read-only) — aggregate auth probe across all three services.""" + +from fastmcp import FastMCP +from fastmcp.dependencies import Depends + +from ycli.yandex._mcp import RO +from ycli.yandex.forms._deps import forms_client +from ycli.yandex.forms.client import FormsClient +from ycli.yandex.status.models import AuthReport +from ycli.yandex.status.reporter import StatusReporter +from ycli.yandex.tracker._deps import tracker_client +from ycli.yandex.tracker.client import TrackerClient +from ycli.yandex.wiki._deps import wiki_client +from ycli.yandex.wiki.client import WikiClient + +mcp = FastMCP("status") +TAGS: set[str] = {"status"} + + +@mcp.tool(name="get", annotations={**RO, "title": "Check Yandex 360 auth status"}, tags=TAGS) +def get( + tracker: TrackerClient = Depends(tracker_client), + wiki: WikiClient = Depends(wiki_client), + forms: FormsClient = Depends(forms_client), +) -> AuthReport: + """Probe each service's identity endpoint; report which credentials work. + + ``organization_id`` is left blank here — the per-service ``me`` already identifies the + authenticated user; the CLI ``auth status`` carries the org id. + """ + me_clients = {"tracker": tracker.me, "wiki": wiki.me, "forms": forms.me} + return StatusReporter(me_clients).report(configured=True, organization_id="") +``` + +- [ ] **Step 6: Delete the old module** + +```bash +git rm src/ycli/yandex/status.py +``` + +- [ ] **Step 7: Mount the status subserver in `src/ycli/mcp/server.py`** + +Add the import alongside the others: +```python +from ycli.yandex.forms.mcp import mcp as forms_mcp +from ycli.yandex.status.mcp import mcp as status_mcp +from ycli.yandex.tracker.mcp import mcp as tracker_mcp +from ycli.yandex.wiki.mcp import mcp as wiki_mcp +``` +and add the mount after the three domain mounts: +```python +mcp.mount(wiki_mcp, namespace="wiki") +mcp.mount(tracker_mcp, namespace="tracker") +mcp.mount(forms_mcp, namespace="forms") +mcp.mount(status_mcp, namespace="status") +``` + +- [ ] **Step 8: Write the `status_get` MCP test** + +Create `tests/yandex/status/__init__.py`: +```python +``` +(empty file is fine — it is a test package, not a `yandex/` resource). + +Create `tests/yandex/status/test_mcp.py`: +```python +"""status_get MCP tool — aggregates the three /me probes into one read-only report.""" + +import pytest +import responses +from fastmcp import Client + +from ycli.yandex.status import mcp as status_mcp + +TRACKER_ME = "https://api.tracker.yandex.net/v3/myself" +FORMS_ME = "https://api.forms.yandex.net/v1/users/me" +WIKI_ME = "https://api.wiki.yandex.net/v1/users/me" + + +@pytest.fixture +def creds(monkeypatch): + monkeypatch.setenv("YANDEX_ID_OAUTH_TOKEN", "t") + monkeypatch.setenv("YANDEX_ID_ORGANIZATION_ID", "o") + + +@responses.activate +async def test_status_get_reports_all_valid(creds): + responses.add(responses.GET, TRACKER_ME, json={"login": "alice"}, status=200) + responses.add(responses.GET, WIKI_ME, json={"username": "alice"}, status=200) + responses.add(responses.GET, FORMS_ME, json={"id": 1, "email": "alice@x"}, status=200) + async with Client(status_mcp.mcp) as client: + result = await client.call_tool("get", {}) + services = {s.service: s for s in result.data.services} + assert services["tracker"].valid is True + assert services["tracker"].me.login == "alice" + assert services["forms"].me.email == "alice@x" + + +@responses.activate +async def test_status_get_marks_invalid_on_401(creds): + responses.add(responses.GET, TRACKER_ME, status=401) + responses.add(responses.GET, WIKI_ME, json={"username": "alice"}, status=200) + responses.add(responses.GET, FORMS_ME, json={"id": 1, "email": "alice@x"}, status=200) + async with Client(status_mcp.mcp) as client: + result = await client.call_tool("get", {}) + services = {s.service: s for s in result.data.services} + assert services["tracker"].valid is False + assert services["tracker"].detail == "token invalid or expired" + + +async def test_status_get_is_read_only(): + async with Client(status_mcp.mcp) as client: + tools = {t.name: t for t in await client.list_tools()} + assert "get" in tools + assert tools["get"].annotations.readOnlyHint is True +``` + +Note: the cached `_deps` providers (`tracker_client` etc.) memoize via `functools.cache`; if a later test needs fresh creds, call `tracker_client.cache_clear()` — the existing per-domain MCP tests in the suite already exercise this pattern, so the cache is exercised consistently. These three tests use one cred set, so no clear is required. + +- [ ] **Step 9: Update `tests/test_yandex_mcp.py` totals (restore to 25, add status)** + +In `test_root_mounts_all_domains_with_namespaces` change the total back to 25 and add the status assertions: +```python + assert "forms_surveys_get" in names + assert "status_get" in names + assert len([n for n in names if n.startswith("wiki_")]) == 6 + assert len([n for n in names if n.startswith("tracker_")]) == 13 + assert len([n for n in names if n.startswith("forms_")]) == 5 + assert len([n for n in names if n.startswith("status_")]) == 1 + assert len(names) == 25 +``` + +- [ ] **Step 10: Add the ARCH-1 clarifying note in `ARCHITECTURE.md`** + +Append to the ARCH-1 bullet (after `Use /new-endpoint to scaffold.`): +``` + *Carve-out:* `yandex/status/` and the `ycli/mcp/` server package are cross-cutting surfaces, + not `/` dirs — the four-surface rule and the `_resource_dirs()` check + (which scans only `tracker/wiki/forms`) do not apply to them. +``` + +- [ ] **Step 11: Regenerate snapshots** + +Run: `uv run python -m tests.snapshots --update` +Expected: `mcp_tools.txt` gains `status_get` (sorted — it lands between `forms_*` and `tracker_*`); `cli_tree.txt` is unchanged (`auth`/`auth status` already present, `status_get` is MCP-only). Verify: +Run: `git diff tests/snapshots/` +Expected: only `+status_get` in `mcp_tools.txt`. + +- [ ] **Step 12: Run the status tests + full gate** + +Run: `uv run pytest tests/yandex/test_status.py tests/yandex/status/ tests/test_yandex_mcp.py -v` +Expected: PASS — existing CLI status tests still green (output still contains `"valid":true` ×3; the new `me` field adds keys but the asserts hold), new MCP tests green, 25 tools. +Run: `uv run ruff format --check . && uv run ruff check . && uv run lint-imports && uv run ty check && uv run pytest` +Expected: all green. + +- [ ] **Step 13: Commit** + +```bash +git add -A +git commit -m "feat: status package with native me + read-only status_get MCP tool + +Explodes yandex/status.py into a package; drops ServiceProbe and the per-service +identity lambdas — each ServiceAuthStatus now carries the bare native me model. +Adds the status_get MCP tool (namespace status, read-only). ARCH-1 carve-out note. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 4: Pagination generics + fold `collect_single_page` (W-D) + +Add PEP 695 generics over (page `P`, item `T`) and a wrapped-result type, and fold the module-level `collect_single_page` into `SinglePageStrategy` as a classmethod. Behavior is unchanged; the dict-driven tests must stay valid, so the strategies stay generic over the page type via the injected callables (NOT a structural protocol requiring page attributes). + +**Files:** +- Modify: `src/ycli/yandex/pagination.py` +- Modify call sites: `src/ycli/yandex/forms/surveys/client.py`, `src/ycli/yandex/wiki/attachments/client.py`, `src/ycli/yandex/wiki/comments/client.py` +- Modify: `tests/yandex/test_pagination.py` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `PaginationStrategy[P, T]` (generic ABC, `collect(...) -> list[T]`); `SinglePageStrategy.collect_wrapped(page_fn, *, extract, wrap, limit) -> R` (classmethod replacing the free `collect_single_page`). `CursorStrategy[P, T]`, `NextUrlStrategy[P, T]` unchanged in behavior. + +- [ ] **Step 1: Update the pagination test for the folded API** + +In `tests/yandex/test_pagination.py`: +- Change the import (drop `collect_single_page`): +```python +from ycli.yandex.pagination import ( + CursorStrategy, + NextUrlStrategy, + SinglePageStrategy, +) +``` +- Replace `test_collect_single_page_extracts_wraps_and_bounds` with: +```python +def test_single_page_collect_wrapped_extracts_wraps_and_bounds(): + pages = {"a": [1, 2, 3]} + out = SinglePageStrategy.collect_wrapped( + lambda cursor: pages, extract=lambda p: p["a"], wrap=list, limit=2 + ) + assert out == [1, 2] +``` +(All other tests are unchanged — they drive the strategies with plain `dict` pages, which the generic-over-`P` callables still accept.) + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run pytest tests/yandex/test_pagination.py -v` +Expected: FAIL — `SinglePageStrategy.collect_wrapped` does not exist yet; `collect_single_page` import removed. + +- [ ] **Step 3: Rewrite `src/ycli/yandex/pagination.py`** + +```python +"""Pagination strategies — drain an API's page mechanics into a bounded flat list. + +Each strategy owns ONE cursor mechanic and accepts injected page-access callables, so the +public client method never exposes a cursor: it picks a strategy, says how to read a page, +and gets back a list capped at ``limit`` (``None`` = uncapped). Pure — no HTTP here. + +Generic over the page type ``P`` (whatever ``fetch_page`` returns — a pydantic model in +production, a plain ``dict`` in tests) and the item type ``T``. The injected callables do +all structural access, so no page Protocol is imposed. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + +class PaginationStrategy[P, T](ABC): + @abstractmethod + def collect(self, fetch_page: Callable[[str | None], P], limit: int | None) -> list[T]: + """Accumulate items by driving ``fetch_page`` until exhausted or ``limit`` reached.""" + + +class SinglePageStrategy[P, T](PaginationStrategy[P, T]): + def __init__(self, *, extract: Callable[[P], list[T]]) -> None: + self._extract = extract + + def collect(self, fetch_page: Callable[[str | None], P], limit: int | None) -> list[T]: + items = list(self._extract(fetch_page(None))) + return items if limit is None else items[:limit] + + @classmethod + def collect_wrapped[R]( + cls, + page_fn: Callable[[str | None], P], + *, + extract: Callable[[P], list[T]], + wrap: Callable[[list[T]], R], + limit: int | None = None, + ) -> R: + """Single-page envelope -> bounded, wrapped flat collection (the wiki/forms list shape).""" + return wrap(cls(extract=extract).collect(page_fn, limit)) + + +class CursorStrategy[P, T](PaginationStrategy[P, T]): + def __init__( + self, *, extract: Callable[[P], list[T]], next_of: Callable[[P], str | None] + ) -> None: + self._extract = extract + self._next_of = next_of + + def collect(self, fetch_page: Callable[[str | None], P], limit: int | None) -> list[T]: + items: list[T] = [] + cursor: str | None = None + while True: + page = fetch_page(cursor) + items.extend(self._extract(page)) + if limit is not None and len(items) >= limit: + return items[:limit] + cursor = self._next_of(page) + if cursor is None: + return items + + +class NextUrlStrategy[P, T](PaginationStrategy[P, T]): + """HATEOAS: the first page comes from ``fetch_page``; subsequent ones from ``fetch_url``.""" + + def __init__( + self, + *, + extract: Callable[[P], list[T]], + next_url_of: Callable[[P], str | None], + fetch_url: Callable[[str], P], + ) -> None: + self._extract = extract + self._next_url_of = next_url_of + self._fetch_url = fetch_url + + def collect(self, fetch_page: Callable[[str | None], P], limit: int | None) -> list[T]: + page = fetch_page(None) + items: list[T] = list(self._extract(page)) + seen: set[str] = set() + url = self._next_url_of(page) + while url is not None and url not in seen: + if limit is not None and len(items) >= limit: + break + seen.add(url) + page = self._fetch_url(url) + items.extend(self._extract(page)) + url = self._next_url_of(page) + return items if limit is None else items[:limit] +``` + +Behavioral note: `CursorStrategy` previously stopped on `if not cursor` (falsy → also empty string); the new `if cursor is None` matches the API contract (`next_cursor` is `null` when exhausted, per `DescendantsResponse`) and the existing test (`next_cursor: None`). `NextUrlStrategy` previously stopped on `while url and ...`; `while url is not None and ...` is equivalent for the `str | None` shape the call site produces. The existing tests pass a dict whose `next_url_of` returns `None` at the end, so both stay green. + +- [ ] **Step 4: Update call site — surveys** (`src/ycli/yandex/forms/surveys/client.py`) + +Old import: +```python +from ycli.yandex.pagination import collect_single_page +``` +New: +```python +from ycli.yandex.pagination import SinglePageStrategy +``` +Old body of `list`: +```python + return collect_single_page( + lambda cursor: self._list_page(), + extract=lambda page: page.result, + wrap=SurveyList, + limit=limit, + ) +``` +New: +```python + return SinglePageStrategy.collect_wrapped( + lambda cursor: self._list_page(), + extract=lambda page: page.result, + wrap=SurveyList, + limit=limit, + ) +``` + +- [ ] **Step 5: Update call site — attachments** (`src/ycli/yandex/wiki/attachments/client.py`) + +Old import `from ycli.yandex.pagination import collect_single_page` → `from ycli.yandex.pagination import SinglePageStrategy`. Old body: +```python + return collect_single_page( + lambda cursor: self._list_page(page_id, page_size=100), + extract=lambda page: page.results, + wrap=AttachmentList, + limit=limit, + ) +``` +New: +```python + return SinglePageStrategy.collect_wrapped( + lambda cursor: self._list_page(page_id, page_size=100), + extract=lambda page: page.results, + wrap=AttachmentList, + limit=limit, + ) +``` + +- [ ] **Step 6: Update call site — comments** (`src/ycli/yandex/wiki/comments/client.py`) + +Identical transform to Step 5 (import + `collect_single_page(...)` → `SinglePageStrategy.collect_wrapped(...)`), with `wrap=CommentList`. + +- [ ] **Step 7: Run pagination + the three resource tests** + +Run: `uv run pytest tests/yandex/test_pagination.py tests/yandex/forms/surveys tests/yandex/wiki/attachments tests/yandex/wiki/comments tests/yandex/wiki/pages tests/yandex/forms/answers -v` +Expected: PASS — `wiki/pages` (CursorStrategy) and `forms/answers` (NextUrlStrategy) unchanged at the call sites and still green. + +- [ ] **Step 8: Full gate** + +Run: `uv run ruff format --check . && uv run ruff check . && uv run lint-imports && uv run ty check && uv run pytest` +Expected: all green. `ty check` is the load-bearing check here — the generics must resolve at all five call sites with no new `ty: ignore` (and `unused-ignore-comment = warn` + `error-on-warning = true` means no stale ignores). + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "refactor: type pagination strategies with PEP 695 generics + +Strategies are generic over page type P and item T; collect returns list[T]. +Folds the free collect_single_page into SinglePageStrategy.collect_wrapped. +Cursor/url termination uses 'is None' to match the null-cursor API contract. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 5: Docstrings for the empty `__init__.py` files (W-E) + +The four empty `__init__.py` exist for ARCH-1 but carry no docstring. Give them one (do NOT delete them). The `import yaml` smell from the spec was resolved in Task 2 (its only user, `test_full_renders_raw_dict_as_yaml`, was deleted). + +**Files:** +- Modify: `src/ycli/yandex/__init__.py`, `src/ycli/yandex/wiki/attachments/__init__.py`, `src/ycli/yandex/wiki/comments/__init__.py`, `src/ycli/yandex/wiki/pages/__init__.py` + +- [ ] **Step 1: Add docstrings** (one module-docstring line each, matching the repo style `""" / resource package."""`) + +`src/ycli/yandex/__init__.py`: +```python +"""Yandex 360 SDK — per-domain clients (tracker, wiki, forms) plus shared model/MCP bases.""" +``` +`src/ycli/yandex/wiki/attachments/__init__.py`: +```python +"""Wiki /pages/{id}/attachments resource package.""" +``` +`src/ycli/yandex/wiki/comments/__init__.py`: +```python +"""Wiki /pages/{id}/comments resource package.""" +``` +`src/ycli/yandex/wiki/pages/__init__.py`: +```python +"""Wiki /pages resource package.""" +``` + +- [ ] **Step 2: Full gate** + +Run: `uv run ruff format --check . && uv run ruff check . && uv run ty check && uv run pytest` +Expected: all green (docstrings don't change behavior or coverage). + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "docs: add module docstrings to the four empty __init__.py files + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 6: Reproducible demo output (W-G) + +Replace the hand-typed `cat <` → prints real CLI output for the fixture; exit 0. + +- [ ] **Step 1: Write the render test (TDD)** + +Create `tests/test_demo_render.py`: +```python +"""The demo render harness emits real CLI output from committed fixtures (leak-free).""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +RENDER = REPO / "docs" / "demo" / "render.py" + +pytestmark = pytest.mark.integration + + +def _run(args): + return subprocess.run( + [sys.executable, str(RENDER), *args], + capture_output=True, + text=True, + cwd=REPO, + ) + + +def test_render_tracker_issue_get_emits_fixture_key(): + proc = _run(["tracker", "issues", "get", "TRACKER-1"]) + assert proc.returncode == 0, proc.stderr + assert "TRACKER-1" in proc.stdout + + +def test_render_wiki_page_get_emits_fixture_title(): + proc = _run(["wiki", "pages", "get", "onboarding"]) + assert proc.returncode == 0, proc.stderr + assert "onboarding" in proc.stdout +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run pytest tests/test_demo_render.py -v` +Expected: FAIL — `docs/demo/render.py` does not exist. + +- [ ] **Step 3: Create the fixtures** + +`docs/demo/fixtures/tracker-issue.json` (fake, leak-free — fields the `Issue` model renders): +```json +{ + "key": "TRACKER-1", + "summary": "Set up project scaffolding", + "status": {"key": "inProgress", "display": "In Progress"}, + "assignee": {"display": "Alice"}, + "priority": {"key": "normal", "display": "Normal"} +} +``` +`docs/demo/fixtures/wiki-page.json` (fields the wiki `pages get` model renders — match the real `PageDetails` shape; the implementer confirms keys against `src/ycli/yandex/wiki/pages/models.py`): +```json +{ + "slug": "onboarding", + "title": "Team Onboarding Guide", + "author": {"display": "Bob"}, + "revision": 7 +} +``` + +- [ ] **Step 4: Create `docs/demo/render.py`** + +```python +"""Render real `ycli` CLI output from a committed fixture — the demo's leak-free data source. + +Used only by docs/demo/bin/ycli (the vhs shim). Stubs the matching API endpoint with +`responses`, sets dummy creds, and invokes the real Typer app in-process so the printed +output is genuine rendering of committed data — deterministic, offline, no real org data. + + python docs/demo/render.py tracker issues get TRACKER-1 + python docs/demo/render.py wiki pages get onboarding +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import responses +from typer.testing import CliRunner + +HERE = Path(__file__).resolve().parent +FIXTURES = HERE / "fixtures" +TRACKER = "https://api.tracker.yandex.net/v3" +WIKI = "https://api.wiki.yandex.net/v1" + +# Map a demo command (argv tuple) to (HTTP method, URL, fixture file). +ROUTES = { + ("tracker", "issues", "get", "TRACKER-1"): ( + responses.GET, + f"{TRACKER}/issues/TRACKER-1", + "tracker-issue.json", + ), + ("wiki", "pages", "get", "onboarding"): ( + responses.GET, + f"{WIKI}/pages/onboarding", + "wiki-page.json", + ), +} + + +def main(argv: list[str]) -> int: + route = ROUTES.get(tuple(argv)) + if route is None: + print(f"demo render: unknown command {argv}", file=sys.stderr) + return 2 + method, url, fixture = route + body = json.loads((FIXTURES / fixture).read_text(encoding="utf-8")) + + from ycli import cli + + runner = CliRunner() + with responses.RequestsMock() as rsps: + rsps.add(method, url, json=body, status=200) + # Dummy creds satisfy Credentials(); responses intercepts the call (no real network). + env = {"YANDEX_ID_OAUTH_TOKEN": "demo", "YANDEX_ID_ORGANIZATION_ID": "demo"} + result = runner.invoke(cli.app, ["--format", "pretty", *argv], env=env) + sys.stdout.write(result.stdout) + return result.exit_code + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) +``` + +Implementer note: confirm the wiki `pages get` URL path (`/pages/{slug}` vs a query param) against `src/ycli/yandex/wiki/pages/client.py`; adjust the `ROUTES` URL (and the `--format pretty` choice if pretty needs a TTY — fall back to `json` if `pretty` renders empty under `CliRunner`). The test in Step 1 is the gate. + +- [ ] **Step 5: Run the render test** + +Run: `uv run pytest tests/test_demo_render.py -v` +Expected: PASS — both commands emit the fixture's key field. If `--format pretty` yields empty output under `CliRunner`, switch the `render.py` invoke to `["--format", "json", *argv]` and re-run. + +- [ ] **Step 6: Update the shim `docs/demo/bin/ycli`** + +```bash +#!/usr/bin/env bash +# Demo shim used ONLY by docs/demo/demo.tape. Real `--help` and a real `mcp methods` +# tool list; the data commands render committed fixtures through the REAL ycli via +# docs/demo/render.py (no network, no credentials). Keeps the GIF reproducible and +# leak-free. Not installed; not on a user's PATH. +case "$*" in + "--help"|"") + exec uv run ycli --help ;; + "tracker issues get TRACKER-1"|"wiki pages get onboarding") + exec uv run python docs/demo/render.py "$@" ;; + "mcp methods") + exec uv run --extra mcp ycli mcp methods ;; + *) + exec uv run ycli "$@" ;; +esac +``` + +- [ ] **Step 7: Update `docs/demo/demo.tape`** + +Replace the `mcp start` step (which faked a tool list) with the real `mcp methods`, and keep the two fixture-rendered data commands. Change: +``` +# Read-only MCP server banner with real tool names. +Type "ycli mcp start" Sleep 500ms Enter +Sleep 3s +``` +to: +``` +# Real read-only MCP tool list (no creds, no network). +Type "ycli mcp methods" Sleep 500ms Enter +Sleep 3s +``` +Also bump `Set Height 720` to `Set Height 900` (the real list is ~24 lines) so the tool list is not clipped. Update the tape header comment block: the data commands now render committed fixtures via `docs/demo/render.py`, and `mcp methods` needs the `mcp` extra at regeneration time. + +- [ ] **Step 8: Full gate** + +Run: `uv run ruff format --check . && uv run ruff check . && uv run lint-imports && uv run ty check && uv run pytest` +Expected: all green. (`render.py` lives under `docs/`, not `src/ycli`, so it is outside the coverage source — the `test_demo_render.py` subprocess test guards it from rot without affecting the 100% gate.) + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "build: render demo output from committed fixtures, not hand-typed text + +docs/demo/render.py runs the real ycli in-process against committed JSON fixtures via +responses (deterministic, leak-free, offline); the demo's MCP tool list now comes from +the real 'ycli mcp methods' instead of a baked, drift-prone list. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- W-A (RawMapping/full) → Task 2 ✓ +- W-B (status package + native me + status_get) → Task 3 ✓ +- W-C (ycli/mcp package) → Task 1 ✓ (with the fastmcp-free `__init__` refinement) +- W-D (pagination generics + Envelope) → Task 4 ✓ (Envelope refined to generic `P`/`T` params, not a structural protocol, to keep dict-driven tests valid — documented in the task) +- W-E (smell sweep) → Task 5 ✓ (the `import yaml` item is obviated by Task 2; only the four docstrings remain) +- W-F (ARCH docs + snapshots) → folded into Tasks 1/2/3 (ARCH-3 in T1, ARCH-4 + READ_VERBS + resources §4 in T2, ARCH-1 note in T3; snapshots regenerated in T2/T3) ✓ +- W-G (demo) → Task 6 ✓ + +**2. Placeholder scan:** No TBD/TODO. Two explicit implementer confirmations remain (wiki page URL shape in T6 Step 4; `--format pretty` vs `json` under CliRunner in T6 Step 5) — both are guarded by the Step 1 test, not open-ended directives. The `me` union fallback to `dict` (T3) is a stated contingency, not a placeholder. + +**3. Type consistency:** `StatusReporter(me_clients).report(configured=, organization_id=)` is used identically in `status/cli.py` and `status/mcp.py`. `ServiceAuthStatus(service, valid, me, detail)` and `AuthReport(configured, organization_id, services)` match across T3. `SinglePageStrategy.collect_wrapped(page_fn, *, extract, wrap, limit)` matches the three call sites and the test in T4. `from ycli.mcp import mcp, main` (lazy) is consumed by `_surface.py`, `test_architecture.py`, `test_yandex_mcp.py`, and `mcp/cli.py` — all unchanged. + +**Cross-task ordering:** T1 (mcp package) precedes T3 (mounts status in `mcp/server.py`). T2 sets the MCP total to 24; T3 restores it to 25 — both edit `test_yandex_mcp.py` sequentially. No task depends on a later task. diff --git a/docs/superpowers/specs/2026-06-29-round-4-architecture-design.md b/docs/superpowers/specs/2026-06-29-round-4-architecture-design.md new file mode 100644 index 0000000..da9fcfb --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-round-4-architecture-design.md @@ -0,0 +1,275 @@ +# Round-4 Architecture Refactor — Design + +**Status:** approved decisions, pending user review of this spec +**Branch:** `refactor/round-4-architecture` (off `main` @ v0.8.1) +**Date:** 2026-06-29 + +## Goal + +Close the architecture debt surfaced in review points 3–7 plus a demo-reproducibility +smell: drop the one-off `RawMapping`/`full` raw accessor, turn the two cross-cutting +modules (`status`, `mcp`) into proper packages with the missing surfaces, tighten +pagination types, sweep small smells, and make the demo GIF render real CLI output from +committed fixtures instead of hand-typed `cat <//` dirs. `yandex/status/` + is a domain with **no resource subdirectory**, so the letter does not bind it — but confirm the + enforcing test's glob does not treat `yandex/status/` as a resource missing surfaces. If it does, + add an explicit carve-out in `ARCHITECTURE.md` + the test naming `status` as a cross-cutting + domain (client-less, resource-less). + +**Tests:** model shape (`me` populated on success, `None` + `detail` on auth failure), CLI exit +codes (1 when unconfigured / any service invalid), MCP `status_get` read-only + returns report. +Snapshots gain `status_get`. + +--- + +## W-C — `mcp` → package (`ycli/mcp/`) (point 4) + +**Decision:** turn the two root MCP modules into a package; keep both public import paths. + +**Current state:** `src/ycli/mcp.py` (root FastMCP server: `mcp` + `main`) and `src/ycli/mcp_cli.py` +(the `ycli mcp` Typer sub-app: `start`, `methods`). + +**Target package `src/ycli/mcp/`:** +- `__init__.py` ← former `mcp.py`. Keeps `from ycli.mcp import main, mcp` working (the console + entry point and `mcp_cli`'s lazy import both rely on these). Mounts wiki/tracker/forms **and** + the new `status` subserver (W-B). +- `cli.py` ← former `mcp_cli.py` (the `mcp` Typer app). Update `cli.py:14` import from + `ycli.mcp_cli` → `ycli.mcp.cli`. +- `__main__.py` — `from ycli.mcp import main; main()` so `python -m ycli.mcp` (documented in the + server docstring) still runs the server now that `mcp` is a package, not a module. + +**ARCH impact (carve-out required):** +- ARCH-3 says `fastmcp` is imported only in modules **named `mcp.py`**. The server now lives in + `ycli/mcp/__init__.py`. Edit ARCH-3 text + its enforcing check to allow `fastmcp` in + `ycli/mcp/__init__.py` (the package initializer of the `mcp` package) in addition to `mcp.py` + modules. Same change to the import-linter contract if it pins module names. + +**Tests:** `from ycli.mcp import main, mcp` resolves; `python -m ycli.mcp` entry exists; +`ycli mcp start` / `ycli mcp methods` unchanged. The smoke test (`registered_groups` `mcp` check) +stays valid. + +--- + +## W-D — Pagination typing + Envelope protocol (point 6) + +**Decision:** add PEP 695 generics + a typed page `Envelope` protocol; fold the free +`collect_single_page` function into the class hierarchy (OOP-encapsulation preference). + +**Current state:** `src/ycli/yandex/pagination.py` — ABC strategies returning untyped `list`, +`Callable[[Any], Any]` page accessors, `cursor: Any`, and a module-level `collect_single_page` +free function. + +**Target:** +- `PaginationStrategy[T]` generic; `collect(...) -> list[T]`. +- An `Envelope[T]` `Protocol` (or typed `Callable` aliases) describing the page-access surface: + `extract: Callable[[E], list[T]]`, `next_of: Callable[[P], str | None]`, + `next_url_of: Callable[[P], str | None]`, `fetch_url: Callable[[str], P]`. +- Replace `cursor: Any = None` with a typed cursor; `if cursor is None` / `if url is None` checks + instead of truthiness where a typed `str | None` makes it precise. +- Fold `collect_single_page` into `SinglePageStrategy` (e.g. a classmethod/`collect_wrapped`) + so the wiki/forms call sites use the class, not a free function. +- Remove `Any`/`ty: ignore` that the generics make unnecessary; use `@overload` where ty supports it. + +**Constraint:** `pagination.py` is not a `client.py`, so `from __future__ import annotations` stays +fine; PEP 695 `type`/`class C[T]` syntax needs no future import. + +**Tests:** existing pagination tests stay green; add a type-level assertion only if cheap. Behavior +is unchanged — this is a typing/encapsulation refactor. + +--- + +## W-E — Smell sweep (point 5) + +**Decisions (from review):** +- The 4 empty `__init__.py` (`yandex/__init__.py`, `wiki/attachments`, `wiki/comments`, + `wiki/pages`) get **docstrings**, not deletion — ARCH-1 requires the files to exist. +- Move the in-body `import yaml` to module top in `tests/yandex/tracker/issues/test_cli.py`. +- `collect_single_page` free function → handled in W-D. +- `ServiceProbe` god-ish indirection → removed in W-B. + +**Out of scope:** `_deps.py` per-domain boilerplate stays (intentional pattern, not a smell). + +**Tests:** none new; the sweep must not change behavior or coverage. + +--- + +## W-F — ARCHITECTURE docs + snapshots + drift gate + +Consolidation workstream — every invariant edit lands with its enforcing check: +- ARCH-4: drop the `RawMapping` sentence (W-A). +- ARCH-3: drop `full` from the read-verb allow-list (W-A); add the `ycli/mcp/__init__.py` fastmcp + carve-out (W-C). +- ARCH-1: status carve-out if the test glob requires it (W-B). +- `docs/conventions/resources.md`: delete §4 (W-A). +- Regenerate `tests/snapshots/` (CLI tree gains nothing/loses `full`; MCP list loses + `tracker_issues_full`, gains `status_get`). +- Run the ARCH-11 doc-drift guard + full `uv run pytest` gate. + +--- + +## W-G — Reproducible demo output (new) + +**Decision:** variant **B** — render real CLI output from committed fixtures via in-process +`responses`; derive the MCP tool list from the live `ycli mcp methods`. No hand-typed output. + +**Proven smell:** `docs/demo/bin/ycli` bakes a `cat <`; replace the baked `mcp start` tool list with the + real `ycli mcp methods` output (no creds/network; requires the `[mcp]` extra at regeneration — + document in the tape header). +- `docs/demo/demo.tape`: adjust the `mcp` step to show real `ycli mcp methods`; retune + Sleep/Height if the 24-tool list needs it. + +**Tests:** `render.py` is demo tooling, not shipped in the dist. Add a lightweight test that +`render.py ` exits 0 and emits the fixture's key field, so the demo can't silently rot +(keeps coverage honest without a GIF in CI). + +--- + +## Out of scope + +- Making `base_url` env-configurable (the clients hardcode it as a `ClassVar`; W-G does not need + it — `responses` intercepts in-process). Defer unless a later need appears. +- Any new Yandex resource (that is `/new-endpoint`'s job). +- Token-leak scanning (separate work, per ARCH-5 scope note). + +## Testing strategy + +TDD per task. `responses` stubs all HTTP (no live network). MCP wiring tests marked +`@pytest.mark.integration`. Snapshots regenerated only on purpose. Final gate: +`uv run pytest` (100% cov) + `ruff format --check` + `ruff check` + `lint-imports` + `ty check`. + +## Release + +Public-surface **removal** (CLI `full`, MCP `issues_full`) is breaking; the `status_get` tool is an +addition. On a pre-1.0 line (0.8.1) semantic-release maps a breaking change to a **minor** bump +→ **0.9.0**. Squash-merge title decided at merge (likely `feat!:` with a `BREAKING CHANGE:` footer +naming the removed `full` command/tool). After release: `uv lock` + `build:` sync commit (PSR +bumps pyproject but not the lock). + +## Decisions locked (from review) + +| Point | Decision | +|-------|----------| +| 3 RawMapping | Delete `full` + `RawMapping` + `get_raw` entirely | +| 4 status | `yandex/status/` package + read-only `status_get` MCP tool | +| 4 mcp_cli | `ycli/mcp/` package (server in `__init__.py`, cli in `cli.py`, `__main__.py`) | +| 6 pagination | PEP 695 generics + `Envelope` protocol; fold `collect_single_page` into a class | +| 7 status `me` | Bare native `me` object (drop `ServiceProbe` + identity lambdas; keep valid/detail) | +| MCP naming | `status_get` in new `status` namespace | +| demo | Variant B (fixtures + real CLI render via `responses`; mcp list from live `methods`) | diff --git a/pyproject.toml b/pyproject.toml index b045f0a..47b0b75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,8 +37,8 @@ dependencies = [ mcp = ["fastmcp>=3.4.2"] [project.scripts] -yandex-cli = "ycli.cli:main" -ycli = "ycli.cli:main" +yandex-cli = "ycli.cli.app:main" +ycli = "ycli.cli.app:main" [project.urls] Homepage = "https://github.com/bim-ba/ycli" @@ -173,7 +173,7 @@ name = "ARCH-3: fastmcp is imported only in mcp modules" type = "forbidden" source_modules = [ "ycli.cli", - "ycli.output", + "ycli.cli.**", "ycli.yandex.**.cli", "ycli.yandex.**.client", "ycli.yandex.**.models", diff --git a/scripts/new_endpoint.py b/scripts/new_endpoint.py index 81b8489..d4ae4bb 100644 --- a/scripts/new_endpoint.py +++ b/scripts/new_endpoint.py @@ -36,7 +36,7 @@ class {cls}(APIModel): """ import uplink -from ycli.yandex.{domain}._base import {domain_cls}Resource +from ycli.yandex.{domain}.base import {domain_cls}Resource from ycli.yandex.{domain}.{resource}.models import {cls} @@ -80,7 +80,7 @@ def get(ctx: typer.Context, item_id: str) -> None: from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.{domain}._deps import RO, TAGS, {domain}_client +from ycli.yandex.{domain}.dependencies import RO, TAGS, {domain}_client from ycli.yandex.{domain}.client import {domain_cls}Client from ycli.yandex.{domain}.{resource}.models import {cls} diff --git a/smoke_test.py b/smoke_test.py index 0eed19e..e0dc144 100644 --- a/smoke_test.py +++ b/smoke_test.py @@ -12,15 +12,16 @@ from importlib import resources import ycli -from ycli import cli +import ycli.cli.app as cli # Verify the BASE install (no 'mcp' extra): the package and the CLI entry point # import without pulling in fastmcp. The `ycli mcp` subcommand is registered here # but only imports the server lazily, so this must not require the extra. assert callable(cli.main), "ycli entry point missing" -assert any(g.typer_instance.info.name == "mcp" for g in cli.app.registered_groups), ( - "mcp subcommand missing" -) +assert any( + g.typer_instance is not None and g.typer_instance.info.name == "mcp" + for g in cli.app.registered_groups +), "mcp subcommand missing" # The PEP 561 marker must survive the build into the installed package, or # downstream type checkers won't see ycli's types. diff --git a/src/ycli/cli/__init__.py b/src/ycli/cli/__init__.py new file mode 100644 index 0000000..77572cf --- /dev/null +++ b/src/ycli/cli/__init__.py @@ -0,0 +1,9 @@ +"""The ``ycli`` CLI surface — the Typer root app (``app``), its composition root +(``context``), and output rendering (``output``), grouped as a package. + +This ``__init__`` is intentionally empty. Domain ``cli.py`` modules import +``ycli.cli.context`` / ``ycli.cli.output``, which runs this package init; importing the +root ``app`` here would pull in every domain app and form an import cycle. Call sites +reference the modules explicitly instead — ``from ycli.cli.app import app`` and the +console entry point ``ycli.cli.app:main``. +""" diff --git a/src/ycli/cli/__main__.py b/src/ycli/cli/__main__.py new file mode 100644 index 0000000..e4fef49 --- /dev/null +++ b/src/ycli/cli/__main__.py @@ -0,0 +1,6 @@ +"""``python -m ycli.cli`` — run the CLI.""" + +from ycli.cli.app import main + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/ycli/cli.py b/src/ycli/cli/app.py similarity index 90% rename from src/ycli/cli.py rename to src/ycli/cli/app.py index c4abe42..ce23547 100644 --- a/src/ycli/cli.py +++ b/src/ycli/cli/app.py @@ -9,10 +9,10 @@ import typer -from ycli.context import AppContext +from ycli.cli.context import AppContext +from ycli.cli.output import OutputFormat from ycli.log import configure -from ycli.mcp_cli import app as mcp_app -from ycli.output import OutputFormat +from ycli.mcp.cli import app as mcp_app from ycli.settings import AppConfig from ycli.yandex.forms.cli import app as forms_app from ycli.yandex.status import app as auth_app @@ -62,7 +62,3 @@ def main() -> None: # pragma: no cover except (YandexError, ValidationError) as exc: typer.secho(f"Error: {exc}", fg=typer.colors.RED, err=True) raise typer.Exit(1) from exc - - -if __name__ == "__main__": # pragma: no cover - main() diff --git a/src/ycli/context.py b/src/ycli/cli/context.py similarity index 97% rename from src/ycli/context.py rename to src/ycli/cli/context.py index c7d5c83..a2c0c4e 100644 --- a/src/ycli/context.py +++ b/src/ycli/cli/context.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: import typer -from ycli.output import OutputFormat, SerializationStrategy +from ycli.cli.output import OutputFormat, SerializationStrategy from ycli.settings import AppConfig, Credentials from ycli.yandex.factory import ClientFactory from ycli.yandex.forms.client import FormsClient diff --git a/src/ycli/output.py b/src/ycli/cli/output.py similarity index 52% rename from src/ycli/output.py rename to src/ycli/cli/output.py index 06aa927..51bf6d0 100644 --- a/src/ycli/output.py +++ b/src/ycli/cli/output.py @@ -9,7 +9,6 @@ from __future__ import annotations import enum -import json from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any @@ -60,59 +59,69 @@ def render(self, result: BaseModel, console: Console) -> None: console.file.write(yaml.safe_dump(data, sort_keys=False, allow_unicode=True)) -class RichCell: - """A single rendered cell: value → display text.""" - - def __init__(self, text: str) -> None: - self.text = text - - @classmethod - def of(cls, value: Any) -> RichCell: - if isinstance(value, (dict, list)): - return cls(json.dumps(value, ensure_ascii=False)) - if value is None: - return cls("") - return cls(str(value)) - - class PrettyStrategy(SerializationStrategy): - def render(self, result: BaseModel, console: Console) -> None: - console.print(self._prettify(result.model_dump(by_alias=True, mode="json"))) + """Render a model as a readable rich table — recursively, by structure, model-agnostic. - def _prettify(self, data: Any) -> Any: - if isinstance(data, list): - return self._list_table(data) - if isinstance(data, dict): - return self._kv_table(data) - return str(data) + Presentation only — the model layer already flattens API wrappers to scalars (see + ``ycli.yandex.models`` ``KeyStr``/``IdStr``/``DisplayStr``), so this just lays data out: + - a scalar renders as its text; a ``None`` / empty object / empty list field is *omitted* + from the table (the data is unchanged — JSON/YAML still carry it); + - an object becomes a key/value table (nested recursively); + - a list of scalars joins with ``, ``; a list of objects becomes a column table whose + all-empty columns are dropped. + """ - def _kv_table(self, data: dict[str, Any]) -> Table: + def render(self, result: BaseModel, console: Console) -> None: + rendered = self._render(result.model_dump(by_alias=True, mode="json")) + console.print("" if rendered is None else rendered) + + def _render(self, value: Any) -> Any: + """Value → a rich renderable (``str`` or ``Table``), or ``None`` to omit it.""" + if isinstance(value, dict): + return self._render_object(value) + if isinstance(value, list): + return self._render_list(value) + if value is None: + return None + return str(value) + + def _render_object(self, data: dict[str, Any]) -> Any: + fields = [(key, self._render(value)) for key, value in data.items()] + fields = [(key, rendered) for key, rendered in fields if rendered is not None] + if not fields: + return None table = Table(show_header=False, box=None, pad_edge=False) table.add_column(style="cyan", no_wrap=True) table.add_column(overflow="fold") - for key, value in data.items(): - table.add_row(str(key), RichCell.of(value).text) + for key, rendered in fields: + table.add_row(key, rendered) return table - def _list_table(self, items: list[Any]) -> Table: - if items and isinstance(items[0], dict): - return self._list_of_dicts_table(items) - return self._list_of_scalars_table(items) + def _render_list(self, items: list[Any]) -> Any: + rendered = [r for r in (self._render(item) for item in items) if r is not None] + if not rendered: + return None + if all(isinstance(r, str) for r in rendered): + return ", ".join(rendered) + return self._render_object_list(items) - def _list_of_dicts_table(self, items: list[dict[str, Any]]) -> Table: - table = Table() - columns = list(items[0].keys()) - for column in columns: - table.add_column(str(column), style="cyan", overflow="fold") + def _render_object_list(self, items: list[Any]) -> Table: + columns: list[str] = [] for item in items: - table.add_row(*[RichCell.of(item.get(c)).text for c in columns]) - return table - - def _list_of_scalars_table(self, items: list[Any]) -> Table: + if isinstance(item, dict): + columns.extend(key for key in item if key not in columns) + cells = { + column: [ + self._render(item.get(column)) if isinstance(item, dict) else None for item in items + ] + for column in columns + } + columns = [c for c in columns if any(value is not None for value in cells[c])] table = Table() - table.add_column("value", overflow="fold") - for item in items: - table.add_row(RichCell.of(item).text) + for column in columns: + table.add_column(column, style="cyan", overflow="fold") + for row in zip(*(cells[c] for c in columns), strict=True): + table.add_row(*["" if value is None else value for value in row]) return table diff --git a/src/ycli/mcp/__init__.py b/src/ycli/mcp/__init__.py new file mode 100644 index 0000000..7b6e9a1 --- /dev/null +++ b/src/ycli/mcp/__init__.py @@ -0,0 +1,20 @@ +"""The ``ycli mcp`` surface — the read-only FastMCP server plus its CLI sub-app. + +``__init__`` stays import-light so the base install (no ``mcp`` extra) can load +``ycli.mcp.cli`` without importing fastmcp; ``mcp`` and ``main`` resolve lazily on +attribute access, preserving ``from ycli.mcp import mcp, main`` for every call site. +""" + +from __future__ import annotations + +from typing import Any + +__all__ = ["main", "mcp"] + + +def __getattr__(name: str) -> Any: + if name in {"mcp", "main"}: + from ycli.mcp import server + + return getattr(server, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/ycli/mcp/__main__.py b/src/ycli/mcp/__main__.py new file mode 100644 index 0000000..0909859 --- /dev/null +++ b/src/ycli/mcp/__main__.py @@ -0,0 +1,6 @@ +"""``python -m ycli.mcp`` — run the read-only MCP server over stdio.""" + +from ycli.mcp.server import main + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/ycli/mcp_cli.py b/src/ycli/mcp/cli.py similarity index 100% rename from src/ycli/mcp_cli.py rename to src/ycli/mcp/cli.py diff --git a/src/ycli/mcp.py b/src/ycli/mcp/server.py similarity index 88% rename from src/ycli/mcp.py rename to src/ycli/mcp/server.py index 655cb7e..942deb3 100644 --- a/src/ycli/mcp.py +++ b/src/ycli/mcp/server.py @@ -1,6 +1,6 @@ """Root Yandex 360 FastMCP server — mounts the per-domain subservers. -Run over stdio for LLM-agent clients: ``ycli mcp`` (or ``python -m ycli.mcp``). +Run over stdio for LLM-agent clients: ``ycli mcp start`` (or ``python -m ycli.mcp``). Tools are namespaced per domain: ``wiki_*``, ``tracker_*``, ``forms_*``. Reads-only. """ @@ -9,6 +9,7 @@ from ycli.log import configure from ycli.settings import AppConfig from ycli.yandex.forms.mcp import mcp as forms_mcp +from ycli.yandex.status.mcp import mcp as status_mcp from ycli.yandex.tracker.mcp import mcp as tracker_mcp from ycli.yandex.wiki.mcp import mcp as wiki_mcp @@ -25,6 +26,7 @@ mcp.mount(wiki_mcp, namespace="wiki") mcp.mount(tracker_mcp, namespace="tracker") mcp.mount(forms_mcp, namespace="forms") +mcp.mount(status_mcp, namespace="status") def main() -> None: diff --git a/src/ycli/yandex/__init__.py b/src/ycli/yandex/__init__.py index e69de29..92921d5 100644 --- a/src/ycli/yandex/__init__.py +++ b/src/ycli/yandex/__init__.py @@ -0,0 +1 @@ +"""Yandex 360 SDK — per-domain clients (tracker, wiki, forms) plus shared model/MCP bases.""" diff --git a/src/ycli/yandex/factory.py b/src/ycli/yandex/factory.py index 9534f1c..dd740fe 100644 --- a/src/ycli/yandex/factory.py +++ b/src/ycli/yandex/factory.py @@ -1,7 +1,7 @@ """The single client-construction site — maps app config + credentials to raw client args. Env-free by design (ARCH-7/8): callers at the composition roots (AppContext, the MCP -``_deps`` providers) read the environment and hand instances here. +``dependencies`` providers) read the environment and hand instances here. """ from __future__ import annotations diff --git a/src/ycli/yandex/forms/answers/cli.py b/src/ycli/yandex/forms/answers/cli.py index 5b2c145..14b676d 100644 --- a/src/ycli/yandex/forms/answers/cli.py +++ b/src/ycli/yandex/forms/answers/cli.py @@ -6,9 +6,9 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer -from ycli.yandex.forms._args import ( +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer +from ycli.yandex.forms.typedefs import ( SurveyIdArg, # noqa: TC001 # typer evaluates Annotated args at runtime via get_type_hints() ) diff --git a/src/ycli/yandex/forms/answers/client.py b/src/ycli/yandex/forms/answers/client.py index d4129ad..fe2065a 100644 --- a/src/ycli/yandex/forms/answers/client.py +++ b/src/ycli/yandex/forms/answers/client.py @@ -7,8 +7,8 @@ import uplink -from ycli.yandex.forms._base import FormsResource from ycli.yandex.forms.answers.models import AnswersResponse +from ycli.yandex.forms.base import FormsResource from ycli.yandex.pagination import NextUrlStrategy diff --git a/src/ycli/yandex/forms/answers/mcp.py b/src/ycli/yandex/forms/answers/mcp.py index f4a6d4c..dc20310 100644 --- a/src/ycli/yandex/forms/answers/mcp.py +++ b/src/ycli/yandex/forms/answers/mcp.py @@ -4,9 +4,9 @@ from fastmcp.dependencies import Depends from ycli.settings import AppConfig -from ycli.yandex.forms._deps import RO, TAGS, app_config, forms_client from ycli.yandex.forms.answers.models import AnswersResponse from ycli.yandex.forms.client import FormsClient +from ycli.yandex.forms.dependencies import RO, TAGS, app_config, forms_client mcp = FastMCP("forms-answers") diff --git a/src/ycli/yandex/forms/_base.py b/src/ycli/yandex/forms/base.py similarity index 100% rename from src/ycli/yandex/forms/_base.py rename to src/ycli/yandex/forms/base.py diff --git a/src/ycli/yandex/forms/_deps.py b/src/ycli/yandex/forms/dependencies.py similarity index 55% rename from src/ycli/yandex/forms/_deps.py rename to src/ycli/yandex/forms/dependencies.py index 65f68c8..93340e3 100644 --- a/src/ycli/yandex/forms/_deps.py +++ b/src/ycli/yandex/forms/dependencies.py @@ -1,7 +1,7 @@ -"""Cached forms MCP client provider (see ycli.yandex._mcp.make_cached_client).""" +"""Cached forms MCP client provider (see ycli.yandex.mcp.make_cached_client).""" -from ycli.yandex._mcp import RO, app_config, make_cached_client from ycli.yandex.forms.client import FormsClient +from ycli.yandex.mcp import RO, app_config, make_cached_client TAGS: set[str] = {"forms"} forms_client = make_cached_client(FormsClient) diff --git a/src/ycli/yandex/forms/me/cli.py b/src/ycli/yandex/forms/me/cli.py index f68eac3..b5a2204 100644 --- a/src/ycli/yandex/forms/me/cli.py +++ b/src/ycli/yandex/forms/me/cli.py @@ -4,8 +4,8 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer app = typer.Typer(name="me", help="Forms authenticated user.", no_args_is_help=True) diff --git a/src/ycli/yandex/forms/me/client.py b/src/ycli/yandex/forms/me/client.py index 06052dd..7e5aba8 100644 --- a/src/ycli/yandex/forms/me/client.py +++ b/src/ycli/yandex/forms/me/client.py @@ -6,7 +6,7 @@ import uplink -from ycli.yandex.forms._base import FormsResource +from ycli.yandex.forms.base import FormsResource from ycli.yandex.forms.me.models import User diff --git a/src/ycli/yandex/forms/me/mcp.py b/src/ycli/yandex/forms/me/mcp.py index 62db80e..3495e46 100644 --- a/src/ycli/yandex/forms/me/mcp.py +++ b/src/ycli/yandex/forms/me/mcp.py @@ -3,8 +3,8 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.forms._deps import RO, TAGS, forms_client from ycli.yandex.forms.client import FormsClient +from ycli.yandex.forms.dependencies import RO, TAGS, forms_client from ycli.yandex.forms.me.models import User mcp = FastMCP("forms-me") diff --git a/src/ycli/yandex/forms/questions/cli.py b/src/ycli/yandex/forms/questions/cli.py index a8dd2f4..edbfdf2 100644 --- a/src/ycli/yandex/forms/questions/cli.py +++ b/src/ycli/yandex/forms/questions/cli.py @@ -4,9 +4,9 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer -from ycli.yandex.forms._args import ( +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer +from ycli.yandex.forms.typedefs import ( SurveyIdArg, # noqa: TC001 # typer evaluates Annotated args at runtime via get_type_hints() ) diff --git a/src/ycli/yandex/forms/questions/client.py b/src/ycli/yandex/forms/questions/client.py index 1a09232..1ee1ed6 100644 --- a/src/ycli/yandex/forms/questions/client.py +++ b/src/ycli/yandex/forms/questions/client.py @@ -5,7 +5,7 @@ import uplink -from ycli.yandex.forms._base import FormsResource +from ycli.yandex.forms.base import FormsResource from ycli.yandex.forms.questions.models import QuestionsResponse diff --git a/src/ycli/yandex/forms/questions/mcp.py b/src/ycli/yandex/forms/questions/mcp.py index 34e7cfa..5191e9a 100644 --- a/src/ycli/yandex/forms/questions/mcp.py +++ b/src/ycli/yandex/forms/questions/mcp.py @@ -3,8 +3,8 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.forms._deps import RO, TAGS, forms_client from ycli.yandex.forms.client import FormsClient +from ycli.yandex.forms.dependencies import RO, TAGS, forms_client from ycli.yandex.forms.questions.models import QuestionsResponse mcp = FastMCP("forms-questions") diff --git a/src/ycli/yandex/forms/surveys/cli.py b/src/ycli/yandex/forms/surveys/cli.py index 7644aee..d9ea7f9 100644 --- a/src/ycli/yandex/forms/surveys/cli.py +++ b/src/ycli/yandex/forms/surveys/cli.py @@ -4,9 +4,9 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer -from ycli.yandex.forms._args import ( +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer +from ycli.yandex.forms.typedefs import ( SurveyIdArg, # noqa: TC001 # typer evaluates Annotated args at runtime via get_type_hints() ) diff --git a/src/ycli/yandex/forms/surveys/client.py b/src/ycli/yandex/forms/surveys/client.py index 5d41e31..a310cbe 100644 --- a/src/ycli/yandex/forms/surveys/client.py +++ b/src/ycli/yandex/forms/surveys/client.py @@ -5,9 +5,9 @@ import uplink -from ycli.yandex.forms._base import FormsResource +from ycli.yandex.forms.base import FormsResource from ycli.yandex.forms.surveys.models import Survey, SurveyList, SurveysResponse -from ycli.yandex.pagination import collect_single_page +from ycli.yandex.pagination import SinglePageStrategy class SurveysClient(FormsResource): @@ -26,7 +26,7 @@ def list(self, *, limit: int | None = None) -> SurveyList: >>> client.surveys.list().root[0].name # doctest: +SKIP 'Новая задача' """ - return collect_single_page( + return SinglePageStrategy.collect_wrapped( lambda cursor: self._list_page(), extract=lambda page: page.result, wrap=SurveyList, diff --git a/src/ycli/yandex/forms/surveys/mcp.py b/src/ycli/yandex/forms/surveys/mcp.py index 6dcfd33..368a12a 100644 --- a/src/ycli/yandex/forms/surveys/mcp.py +++ b/src/ycli/yandex/forms/surveys/mcp.py @@ -3,8 +3,8 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.forms._deps import RO, TAGS, forms_client from ycli.yandex.forms.client import FormsClient +from ycli.yandex.forms.dependencies import RO, TAGS, forms_client from ycli.yandex.forms.surveys.models import Survey, SurveyList mcp = FastMCP("forms-surveys") diff --git a/src/ycli/yandex/forms/_args.py b/src/ycli/yandex/forms/typedefs.py similarity index 81% rename from src/ycli/yandex/forms/_args.py rename to src/ycli/yandex/forms/typedefs.py index d579286..7cf4394 100644 --- a/src/ycli/yandex/forms/_args.py +++ b/src/ycli/yandex/forms/typedefs.py @@ -1,4 +1,4 @@ -"""Shared forms CLI arg types.""" +"""Shared forms CLI argument type aliases.""" from __future__ import annotations diff --git a/src/ycli/yandex/_mcp.py b/src/ycli/yandex/mcp.py similarity index 100% rename from src/ycli/yandex/_mcp.py rename to src/ycli/yandex/mcp.py diff --git a/src/ycli/yandex/models.py b/src/ycli/yandex/models.py index 9afc68e..969171a 100644 --- a/src/ycli/yandex/models.py +++ b/src/ycli/yandex/models.py @@ -1,15 +1,19 @@ -"""Shared pydantic base for every Yandex API model — lenient parsing only, no behavior. +"""Shared pydantic base + ref-flattening annotations for every Yandex API model. -Consolidates the per-domain ``_Lenient`` bases. ``extra="ignore"`` keeps unknown API -fields from raising; ``populate_by_name=True`` lets a field be set by its Python name as -well as its serialization alias. Serialization is NOT a model concern — see ``output.py``. +``APIModel`` is the lenient parse base. ``KeyStr`` / ``IdStr`` / ``DisplayStr`` normalize the +API's single-field wrapper objects (``{"key": "x"}`` / ``{"id": "x"}`` / ``{"display": "x"}``) +down to a bare string at parse time via ``BeforeValidator`` — so models expose plain scalars and +need no per-model flattening property. Serialization is NOT a model concern — see ``output.py``. """ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Annotated, Any -from pydantic import BaseModel, ConfigDict, RootModel +from pydantic import BaseModel, BeforeValidator, ConfigDict + +if TYPE_CHECKING: + from collections.abc import Callable class APIModel(BaseModel): @@ -18,5 +22,19 @@ class APIModel(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) -class RawMapping(RootModel[dict[str, Any]]): - """Wraps an unmodeled API dict so it renders through the Serializer (honoring --format).""" +def _extract(field: str) -> Callable[[Any], Any]: + """A ``BeforeValidator`` that pulls ``field`` out of an API wrapper object. + + The Yandex APIs wrap many references as ``{"": "value", …}``; this returns the bare + value and passes a scalar or ``None`` through untouched (so the field stays ``str | None``). + """ + + def pull(value: Any) -> Any: + return value.get(field) if isinstance(value, dict) else value + + return pull + + +KeyStr = Annotated[str | None, BeforeValidator(_extract("key"))] +IdStr = Annotated[str | None, BeforeValidator(_extract("id"))] +DisplayStr = Annotated[str | None, BeforeValidator(_extract("display"))] diff --git a/src/ycli/yandex/pagination.py b/src/ycli/yandex/pagination.py index fce6353..099fa32 100644 --- a/src/ycli/yandex/pagination.py +++ b/src/ycli/yandex/pagination.py @@ -3,70 +3,88 @@ Each strategy owns ONE cursor mechanic and accepts injected page-access callables, so the public client method never exposes a cursor: it picks a strategy, says how to read a page, and gets back a list capped at ``limit`` (``None`` = uncapped). Pure — no HTTP here. + +Generic over the page type ``P`` (whatever ``fetch_page`` returns — a pydantic model in +production, a plain ``dict`` in tests) and the item type ``T``. The injected callables do +all structural access, so no page Protocol is imposed. """ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable -class PaginationStrategy(ABC): +class PaginationStrategy[P, T](ABC): @abstractmethod - def collect(self, fetch_page: Callable[[Any], Any], limit: int | None) -> list: + def collect(self, fetch_page: Callable[[str | None], P], limit: int | None) -> list[T]: """Accumulate items by driving ``fetch_page`` until exhausted or ``limit`` reached.""" -class SinglePageStrategy(PaginationStrategy): - def __init__(self, *, extract: Callable[[Any], list]) -> None: +class SinglePageStrategy[P, T](PaginationStrategy[P, T]): + def __init__(self, *, extract: Callable[[P], list[T]]) -> None: self._extract = extract - def collect(self, fetch_page: Callable[[Any], Any], limit: int | None) -> list: + def collect(self, fetch_page: Callable[[str | None], P], limit: int | None) -> list[T]: items = list(self._extract(fetch_page(None))) return items if limit is None else items[:limit] + @classmethod + def collect_wrapped[R]( + cls, + page_fn: Callable[[str | None], P], + *, + extract: Callable[[P], list[T]], + wrap: Callable[[list[T]], R], + limit: int | None = None, + ) -> R: + """Single-page envelope -> bounded, wrapped flat collection (the wiki/forms list shape).""" + return wrap(cls(extract=extract).collect(page_fn, limit)) + -class CursorStrategy(PaginationStrategy): - def __init__(self, *, extract: Callable[[Any], list], next_of: Callable[[Any], Any]) -> None: +class CursorStrategy[P, T](PaginationStrategy[P, T]): + def __init__( + self, *, extract: Callable[[P], list[T]], next_of: Callable[[P], str | None] + ) -> None: self._extract = extract self._next_of = next_of - def collect(self, fetch_page: Callable[[Any], Any], limit: int | None) -> list: - items: list = [] - cursor: Any = None + def collect(self, fetch_page: Callable[[str | None], P], limit: int | None) -> list[T]: + items: list[T] = [] + cursor: str | None = None while True: page = fetch_page(cursor) items.extend(self._extract(page)) if limit is not None and len(items) >= limit: return items[:limit] cursor = self._next_of(page) - if not cursor: + if cursor is None: return items -class NextUrlStrategy(PaginationStrategy): +class NextUrlStrategy[P, T](PaginationStrategy[P, T]): """HATEOAS: the first page comes from ``fetch_page``; subsequent ones from ``fetch_url``.""" def __init__( self, *, - extract: Callable[[Any], list], - next_url_of: Callable[[Any], Any], - fetch_url: Callable[[str], Any], + extract: Callable[[P], list[T]], + next_url_of: Callable[[P], str | None], + fetch_url: Callable[[str], P], ) -> None: self._extract = extract self._next_url_of = next_url_of self._fetch_url = fetch_url - def collect(self, fetch_page: Callable[[Any], Any], limit: int | None) -> list: + def collect(self, fetch_page: Callable[[str | None], P], limit: int | None) -> list[T]: page = fetch_page(None) - items: list = list(self._extract(page)) + items: list[T] = list(self._extract(page)) seen: set[str] = set() url = self._next_url_of(page) - while url and url not in seen: + while url is not None and url not in seen: if limit is not None and len(items) >= limit: break seen.add(url) @@ -74,15 +92,3 @@ def collect(self, fetch_page: Callable[[Any], Any], limit: int | None) -> list: items.extend(self._extract(page)) url = self._next_url_of(page) return items if limit is None else items[:limit] - - -def collect_single_page( - page_fn: Callable[[Any], Any], - *, - extract: Callable[[Any], list], - wrap: Callable[[list], Any], - limit: int | None = None, -) -> Any: - """Single-page envelope -> bounded, wrapped flat collection (the wiki/forms list shape).""" - items = SinglePageStrategy(extract=extract).collect(page_fn, limit) - return wrap(items) diff --git a/src/ycli/yandex/status.py b/src/ycli/yandex/status.py deleted file mode 100644 index 908be98..0000000 --- a/src/ycli/yandex/status.py +++ /dev/null @@ -1,91 +0,0 @@ -"""`ycli auth status` — validate credentials against each service's identity endpoint.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -import typer - -if TYPE_CHECKING: - from collections.abc import Callable -from pydantic import Field, ValidationError - -from ycli.context import AppContext -from ycli.output import Serializer -from ycli.settings import AppConfig, Credentials -from ycli.yandex.errors import YandexAuthError, YandexError -from ycli.yandex.factory import ClientFactory -from ycli.yandex.forms.client import FormsClient -from ycli.yandex.models import APIModel -from ycli.yandex.tracker.client import TrackerClient -from ycli.yandex.wiki.client import WikiClient - -app = typer.Typer(name="auth", help="Inspect Yandex 360 credentials.", no_args_is_help=True) - - -class ServiceAuthStatus(APIModel): - service: str - valid: bool = False - identity: str | None = None - detail: str = "" - - -class AuthReport(APIModel): - configured: bool - organization_id: str = "" - services: list[ServiceAuthStatus] = Field(default_factory=list) - - -class ServiceProbe: - """One service's identity check — name, client class, and identity extractor together.""" - - def __init__( - self, name: str, client_cls: type, identity_of: Callable[[Any], str | None] - ) -> None: - self._name, self._client_cls, self._identity_of = name, client_cls, identity_of - - def run(self, credentials: Credentials) -> ServiceAuthStatus: - client = ClientFactory.build(self._client_cls, credentials, AppConfig()) - try: - me = client.me.get() # ty: ignore[unresolved-attribute] - except YandexAuthError: - return ServiceAuthStatus( - service=self._name, valid=False, detail="token invalid or expired" - ) - except YandexError as exc: - return ServiceAuthStatus(service=self._name, valid=False, detail=str(exc)) - return ServiceAuthStatus(service=self._name, valid=True, identity=self._identity_of(me)) - - -PROBES: list[ServiceProbe] = [ - ServiceProbe("tracker", TrackerClient, lambda me: me.login), - ServiceProbe("wiki", WikiClient, lambda me: me.username), - ServiceProbe("forms", FormsClient, lambda me: me.email), -] - - -@app.command() -def status(ctx: typer.Context) -> None: - """Report whether the env credentials are set and actually work, per service.""" - app_ctx = AppContext.from_typer_context(ctx) - env_names = { - "oauth_token": "YANDEX_ID_OAUTH_TOKEN", - "organization_id": "YANDEX_ID_ORGANIZATION_ID", - } - try: - credentials = Credentials() # ty: ignore[missing-argument] - except ValidationError as exc: - missing = ", ".join(env_names.get(str(e["loc"][0]), str(e["loc"][0])) for e in exc.errors()) - typer.secho(f"not configured — missing {missing}", fg=typer.colors.RED, err=True) - Serializer.serialize( - AuthReport(configured=False, services=[]), app_ctx.strategy, app_ctx.console - ) - raise typer.Exit(1) from None - - services = [p.run(credentials) for p in PROBES] - report = AuthReport( - configured=True, organization_id=credentials.organization_id, services=services - ) - Serializer.serialize(report, app_ctx.strategy, app_ctx.console) - if not all(s.valid for s in services): - raise typer.Exit(1) diff --git a/src/ycli/yandex/status/__init__.py b/src/ycli/yandex/status/__init__.py new file mode 100644 index 0000000..40f90dd --- /dev/null +++ b/src/ycli/yandex/status/__init__.py @@ -0,0 +1,9 @@ +"""Cross-cutting auth-status surface — the `auth status` CLI plus the `status_get` MCP tool. + +Not a `/` package (ARCH-1 four-surface symmetry does not apply): it +aggregates the three domains' `me` probes into one report. +""" + +from ycli.yandex.status.cli import app + +__all__ = ["app"] diff --git a/src/ycli/yandex/status/cli.py b/src/ycli/yandex/status/cli.py new file mode 100644 index 0000000..3cc7b50 --- /dev/null +++ b/src/ycli/yandex/status/cli.py @@ -0,0 +1,48 @@ +"""`ycli auth status` — validate credentials against each service's identity endpoint.""" + +from __future__ import annotations + +import typer +from pydantic import ValidationError + +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer +from ycli.settings import Credentials +from ycli.yandex.status.models import AuthReport +from ycli.yandex.status.reporter import StatusReporter + +app = typer.Typer(name="auth", help="Inspect Yandex 360 credentials.", no_args_is_help=True) + +_ENV_NAMES = { + "oauth_token": "YANDEX_ID_OAUTH_TOKEN", + "organization_id": "YANDEX_ID_ORGANIZATION_ID", +} + + +@app.command() +def status(ctx: typer.Context) -> None: + """Report whether the env credentials are set and actually work, per service.""" + app_ctx = AppContext.from_typer_context(ctx) + try: + credentials = Credentials() # ty: ignore[missing-argument] + except ValidationError as exc: + missing = ", ".join( + _ENV_NAMES.get(str(e["loc"][0]), str(e["loc"][0])) for e in exc.errors() + ) + typer.secho(f"not configured — missing {missing}", fg=typer.colors.RED, err=True) + Serializer.serialize( + AuthReport(configured=False, services=[]), app_ctx.strategy, app_ctx.console + ) + raise typer.Exit(1) from None + + me_clients = { + "tracker": app_ctx.tracker.me, + "wiki": app_ctx.wiki.me, + "forms": app_ctx.forms.me, + } + report = StatusReporter(me_clients).report( + configured=True, organization_id=credentials.organization_id + ) + Serializer.serialize(report, app_ctx.strategy, app_ctx.console) + if not all(s.valid for s in report.services): + raise typer.Exit(1) diff --git a/src/ycli/yandex/status/mcp.py b/src/ycli/yandex/status/mcp.py new file mode 100644 index 0000000..aa07221 --- /dev/null +++ b/src/ycli/yandex/status/mcp.py @@ -0,0 +1,32 @@ +"""Status FastMCP tool (read-only) — aggregate auth probe across all three services.""" + +from fastmcp import FastMCP +from fastmcp.dependencies import Depends + +from ycli.yandex.forms.client import FormsClient +from ycli.yandex.forms.dependencies import forms_client +from ycli.yandex.mcp import RO +from ycli.yandex.status.models import AuthReport +from ycli.yandex.status.reporter import StatusReporter +from ycli.yandex.tracker.client import TrackerClient +from ycli.yandex.tracker.dependencies import tracker_client +from ycli.yandex.wiki.client import WikiClient +from ycli.yandex.wiki.dependencies import wiki_client + +mcp = FastMCP("status") +TAGS: set[str] = {"status"} + + +@mcp.tool(name="get", annotations={**RO, "title": "Check Yandex 360 auth status"}, tags=TAGS) +def get( + tracker: TrackerClient = Depends(tracker_client), + wiki: WikiClient = Depends(wiki_client), + forms: FormsClient = Depends(forms_client), +) -> AuthReport: + """Probe each service's identity endpoint; report which credentials work. + + ``organization_id`` is left blank here — the per-service ``me`` already identifies the + authenticated user; the CLI ``auth status`` carries the org id. + """ + me_clients = {"tracker": tracker.me, "wiki": wiki.me, "forms": forms.me} + return StatusReporter(me_clients).report(configured=True, organization_id="") diff --git a/src/ycli/yandex/status/models.py b/src/ycli/yandex/status/models.py new file mode 100644 index 0000000..1b909fb --- /dev/null +++ b/src/ycli/yandex/status/models.py @@ -0,0 +1,68 @@ +"""Models for `ycli auth status` and the `status_get` MCP tool. + +The per-service `me` payloads (Tracker/Wiki/Forms) are all-optional and ignore extras, +so an *undiscriminated* ``me`` union is ambiguous: every payload validates against every +member. fastmcp rebuilds ``result.data`` from the tool's output JSON schema and, on an +undiscriminated ``anyOf``, picks the first matching branch — silently reshaping a wiki +payload into the tracker shape and dropping fields like ``username``. Tagging each status +with a ``Literal`` ``service`` discriminator makes the schema self-describing, so the +round-trip stays loss-free. The CLI/SDK path keeps the bare native ``me`` model instance. +""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import Field + +from ycli.yandex.forms.me.models import ( + User as FormsMe, # noqa: TC001 # pydantic resolves field types via get_type_hints() at runtime +) +from ycli.yandex.models import APIModel +from ycli.yandex.tracker.me.models import ( + Me as TrackerMe, # noqa: TC001 # pydantic resolves field types via get_type_hints() at runtime +) +from ycli.yandex.wiki.me.models import ( + Me as WikiMe, # noqa: TC001 # pydantic resolves field types via get_type_hints() at runtime +) + + +class _ServiceAuthStatus(APIModel): + """One service's auth probe — the bare native `me` on success, else why it failed. + + Subclasses narrow ``service`` to a ``Literal`` tag (the union discriminator) and ``me`` + to that service's model; field order is preserved through the overrides. + """ + + service: str + valid: bool = False + me: TrackerMe | WikiMe | FormsMe | None = None + detail: str = "" + + +class TrackerAuthStatus(_ServiceAuthStatus): + service: Literal["tracker"] = "tracker" + me: TrackerMe | None = None + + +class WikiAuthStatus(_ServiceAuthStatus): + service: Literal["wiki"] = "wiki" + me: WikiMe | None = None + + +class FormsAuthStatus(_ServiceAuthStatus): + service: Literal["forms"] = "forms" + me: FormsMe | None = None + + +ServiceAuthStatus = Annotated[ + TrackerAuthStatus | WikiAuthStatus | FormsAuthStatus, Field(discriminator="service") +] + + +class AuthReport(APIModel): + """Whether the env credentials are set and work, per service.""" + + configured: bool + organization_id: str = "" + services: list[ServiceAuthStatus] = Field(default_factory=list) diff --git a/src/ycli/yandex/status/reporter.py b/src/ycli/yandex/status/reporter.py new file mode 100644 index 0000000..f45a925 --- /dev/null +++ b/src/ycli/yandex/status/reporter.py @@ -0,0 +1,55 @@ +"""Probe each service's identity endpoint and assemble an AuthReport (shared by CLI + MCP).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from pydantic import TypeAdapter + +from ycli.yandex.errors import YandexAuthError, YandexError +from ycli.yandex.forms.me.models import ( + User as FormsMe, # noqa: TC001 # used in the MeProbe protocol annotation +) +from ycli.yandex.status.models import AuthReport, ServiceAuthStatus +from ycli.yandex.tracker.me.models import ( + Me as TrackerMe, # noqa: TC001 # used in the MeProbe protocol annotation +) +from ycli.yandex.wiki.me.models import ( + Me as WikiMe, # noqa: TC001 # used in the MeProbe protocol annotation +) + +if TYPE_CHECKING: + from collections.abc import Mapping + +# Routes a probe result to the right discriminated `ServiceAuthStatus` member by `service` +# tag — the single source of truth for service→model is the Literal discriminator itself. +_STATUS = TypeAdapter(ServiceAuthStatus) + + +class MeProbe(Protocol): + """Structural type for a domain `me` client: a zero-arg `get()` returning an API model.""" + + def get(self) -> TrackerMe | WikiMe | FormsMe: ... + + +class StatusReporter: + """Given each service's `me` client, probe identity and build a per-service AuthReport.""" + + def __init__(self, me_clients: Mapping[str, MeProbe]) -> None: + self._me_clients = me_clients + + def report(self, *, configured: bool, organization_id: str) -> AuthReport: + services = [self._probe(name, client) for name, client in self._me_clients.items()] + return AuthReport(configured=configured, organization_id=organization_id, services=services) + + @staticmethod + def _probe(name: str, me_client: MeProbe) -> ServiceAuthStatus: + try: + me = me_client.get() + except YandexAuthError: + return _STATUS.validate_python( + {"service": name, "valid": False, "detail": "token invalid or expired"} + ) + except YandexError as exc: + return _STATUS.validate_python({"service": name, "valid": False, "detail": str(exc)}) + return _STATUS.validate_python({"service": name, "valid": True, "me": me}) diff --git a/src/ycli/yandex/tracker/_models.py b/src/ycli/yandex/tracker/_models.py deleted file mode 100644 index d1e137a..0000000 --- a/src/ycli/yandex/tracker/_models.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Shared pydantic sub-models for Tracker resources (key/id/display refs). - -The three ref models flatten the API's ``{key}`` / ``{id}`` / ``{display}`` wrapper -objects that recur across issues, links, changelog, etc. See each class's own -``Example`` below. -""" - -from __future__ import annotations - -from ycli.yandex.models import APIModel - - -class _KeyRef(APIModel): - """A reference object carrying a ``key`` string. - - Example: - >>> _KeyRef.model_validate({"key": "task"}).key - 'task' - """ - - key: str | None = None - - -class _IdRef(APIModel): - """A reference object carrying an ``id`` string. - - Example: - >>> _IdRef.model_validate({"id": "relates"}).id - 'relates' - """ - - id: str | None = None - - -class _DisplayRef(APIModel): - """A reference object carrying a ``display`` string. - - Example: - >>> _DisplayRef.model_validate({"display": "Сава"}).display - 'Сава' - """ - - display: str | None = None diff --git a/src/ycli/yandex/tracker/_base.py b/src/ycli/yandex/tracker/base.py similarity index 100% rename from src/ycli/yandex/tracker/_base.py rename to src/ycli/yandex/tracker/base.py diff --git a/src/ycli/yandex/tracker/changelog/cli.py b/src/ycli/yandex/tracker/changelog/cli.py index d38e2d8..74c2ad4 100644 --- a/src/ycli/yandex/tracker/changelog/cli.py +++ b/src/ycli/yandex/tracker/changelog/cli.py @@ -6,9 +6,9 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer -from ycli.yandex.tracker._args import ( +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer +from ycli.yandex.tracker.typedefs import ( KeyArg, # noqa: TC001 # typer evaluates Annotated args at runtime via get_type_hints() ) diff --git a/src/ycli/yandex/tracker/changelog/client.py b/src/ycli/yandex/tracker/changelog/client.py index bd1fa1c..3b3f507 100644 --- a/src/ycli/yandex/tracker/changelog/client.py +++ b/src/ycli/yandex/tracker/changelog/client.py @@ -5,7 +5,7 @@ import uplink -from ycli.yandex.tracker._base import TrackerResource +from ycli.yandex.tracker.base import TrackerResource from ycli.yandex.tracker.changelog.models import ChangelogList @@ -25,6 +25,6 @@ def list( >>> client = TrackerClient(oauth_token="…", organization_id="…") # doctest: +SKIP >>> client.changelog.list(key="DATAENGINEERING-1", per_page=50).root[ ... 0 - ... ].author_display # doctest: +SKIP + ... ].updated_by # doctest: +SKIP 'Сава Знатнов' """ diff --git a/src/ycli/yandex/tracker/changelog/mcp.py b/src/ycli/yandex/tracker/changelog/mcp.py index 436df95..77bbefa 100644 --- a/src/ycli/yandex/tracker/changelog/mcp.py +++ b/src/ycli/yandex/tracker/changelog/mcp.py @@ -3,9 +3,9 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.tracker._deps import RO, TAGS, tracker_client from ycli.yandex.tracker.changelog.models import ChangelogList from ycli.yandex.tracker.client import TrackerClient +from ycli.yandex.tracker.dependencies import RO, TAGS, tracker_client mcp = FastMCP("tracker-changelog") diff --git a/src/ycli/yandex/tracker/changelog/models.py b/src/ycli/yandex/tracker/changelog/models.py index e456019..ec094f1 100644 --- a/src/ycli/yandex/tracker/changelog/models.py +++ b/src/ycli/yandex/tracker/changelog/models.py @@ -6,10 +6,10 @@ from pydantic import Field, RootModel -from ycli.yandex.models import APIModel -from ycli.yandex.tracker._models import ( # noqa: TC001 # pydantic resolves field types via get_type_hints() at runtime - _DisplayRef, - _IdRef, +from ycli.yandex.models import ( # pydantic resolves field types via get_type_hints() at runtime + APIModel, + DisplayStr, + IdStr, ) @@ -20,19 +20,14 @@ class ChangeField(APIModel): field that changed) — typed ``Any`` and passed through verbatim. Example: - >>> ChangeField.model_validate({"field": {"id": "status"}, "to": {"key": "open"}}).field_id + >>> ChangeField.model_validate({"field": {"id": "status"}, "to": {"key": "open"}}).field 'status' """ - field: _IdRef | None = None + field: IdStr = None from_: Any = Field(default=None, alias="from") to: Any = None - @property - def field_id(self) -> str | None: - """``field.id`` or ``None``.""" - return self.field.id if self.field else None - class ChangelogEntry(APIModel): """A changelog event (``/issues/{key}/changelog`` item). @@ -40,21 +35,16 @@ class ChangelogEntry(APIModel): Example: >>> ChangelogEntry.model_validate( ... {"id": "1", "updatedBy": {"display": "Сава"}, "fields": []} - ... ).author_display + ... ).updated_by 'Сава' """ id: str | None = None updated_at: str | None = Field(default=None, alias="updatedAt") - updated_by: _DisplayRef | None = Field(default=None, alias="updatedBy") + updated_by: DisplayStr = Field(default=None, alias="updatedBy") type: str | None = None fields: list[ChangeField] = Field(default_factory=list) - @property - def author_display(self) -> str | None: - """``updatedBy.display`` or ``None``.""" - return self.updated_by.display if self.updated_by else None - class ChangelogList(RootModel[list[ChangelogEntry]]): """A bare JSON array of changelog entries. diff --git a/src/ycli/yandex/tracker/comments/cli.py b/src/ycli/yandex/tracker/comments/cli.py index c73c424..95920d2 100644 --- a/src/ycli/yandex/tracker/comments/cli.py +++ b/src/ycli/yandex/tracker/comments/cli.py @@ -6,9 +6,9 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer -from ycli.yandex.tracker._args import ( +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer +from ycli.yandex.tracker.typedefs import ( KeyArg, # noqa: TC001 # typer evaluates Annotated args at runtime via get_type_hints() ) diff --git a/src/ycli/yandex/tracker/comments/client.py b/src/ycli/yandex/tracker/comments/client.py index a7cd0ab..ac93c17 100644 --- a/src/ycli/yandex/tracker/comments/client.py +++ b/src/ycli/yandex/tracker/comments/client.py @@ -5,7 +5,7 @@ import uplink -from ycli.yandex.tracker._base import TrackerResource +from ycli.yandex.tracker.base import TrackerResource from ycli.yandex.tracker.comments.models import Comment, CommentList @@ -19,9 +19,7 @@ def list(self, key: uplink.Path) -> CommentList: # ty: ignore[empty-body] Example: >>> client = TrackerClient(oauth_token="…", organization_id="…") # doctest: +SKIP - >>> client.comments.list(key="DATAENGINEERING-1").root[ - ... 0 - ... ].created_by_display # doctest: +SKIP + >>> client.comments.list(key="DATAENGINEERING-1").root[0].created_by # doctest: +SKIP 'Сава Знатнов' """ diff --git a/src/ycli/yandex/tracker/comments/mcp.py b/src/ycli/yandex/tracker/comments/mcp.py index bfce01a..6c37bc3 100644 --- a/src/ycli/yandex/tracker/comments/mcp.py +++ b/src/ycli/yandex/tracker/comments/mcp.py @@ -3,9 +3,9 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.tracker._deps import RO, TAGS, tracker_client from ycli.yandex.tracker.client import TrackerClient from ycli.yandex.tracker.comments.models import CommentList +from ycli.yandex.tracker.dependencies import RO, TAGS, tracker_client mcp = FastMCP("tracker-comments") diff --git a/src/ycli/yandex/tracker/comments/models.py b/src/ycli/yandex/tracker/comments/models.py index 64672a8..05c6e69 100644 --- a/src/ycli/yandex/tracker/comments/models.py +++ b/src/ycli/yandex/tracker/comments/models.py @@ -4,9 +4,9 @@ from pydantic import Field, RootModel -from ycli.yandex.models import APIModel -from ycli.yandex.tracker._models import ( - _DisplayRef, # noqa: TC001 # pydantic resolves field types via get_type_hints() at runtime +from ycli.yandex.models import ( # pydantic resolves field types via get_type_hints() at runtime + APIModel, + DisplayStr, ) @@ -20,14 +20,9 @@ class Comment(APIModel): id: int | str | None = None created_at: str | None = Field(default=None, alias="createdAt") - created_by: _DisplayRef | None = Field(default=None, alias="createdBy") + created_by: DisplayStr = Field(default=None, alias="createdBy") text: str | None = None - @property - def created_by_display(self) -> str | None: - """``createdBy.display`` or ``None``.""" - return self.created_by.display if self.created_by else None - class CommentList(RootModel[list[Comment]]): """A bare JSON array of comments. diff --git a/src/ycli/yandex/tracker/_deps.py b/src/ycli/yandex/tracker/dependencies.py similarity index 56% rename from src/ycli/yandex/tracker/_deps.py rename to src/ycli/yandex/tracker/dependencies.py index 321b9c7..e5664fb 100644 --- a/src/ycli/yandex/tracker/_deps.py +++ b/src/ycli/yandex/tracker/dependencies.py @@ -1,6 +1,6 @@ -"""Cached tracker MCP client provider (see ycli.yandex._mcp.make_cached_client).""" +"""Cached tracker MCP client provider (see ycli.yandex.mcp.make_cached_client).""" -from ycli.yandex._mcp import RO, app_config, make_cached_client +from ycli.yandex.mcp import RO, app_config, make_cached_client from ycli.yandex.tracker.client import TrackerClient TAGS: set[str] = {"tracker"} diff --git a/src/ycli/yandex/tracker/issues/cli.py b/src/ycli/yandex/tracker/issues/cli.py index 375c9bf..e2d2c99 100644 --- a/src/ycli/yandex/tracker/issues/cli.py +++ b/src/ycli/yandex/tracker/issues/cli.py @@ -6,10 +6,12 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer -from ycli.yandex.models import RawMapping -from ycli.yandex.tracker._args import KeyArg, count_body, parse_fields +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer +from ycli.yandex.tracker.typedefs import ( + KeyArg, # noqa: TC001 # typer evaluates Annotated args at runtime via get_type_hints() +) +from ycli.yandex.tracker.utils import count_body, parse_fields app = typer.Typer(name="issues", help="Tracker issues.", no_args_is_help=True) @@ -26,15 +28,6 @@ def get(ctx: typer.Context, key: KeyArg) -> None: Serializer.serialize(app_ctx.tracker.issues.get(key), app_ctx.strategy, app_ctx.console) -@app.command() -def full(ctx: typer.Context, key: KeyArg) -> None: - """Print the raw API dict for KEY (no pydantic projection).""" - app_ctx = AppContext.from_typer_context(ctx) - Serializer.serialize( - RawMapping(app_ctx.tracker.issues.get_raw(key)), app_ctx.strategy, app_ctx.console - ) - - @app.command("list") def list_( ctx: typer.Context, diff --git a/src/ycli/yandex/tracker/issues/client.py b/src/ycli/yandex/tracker/issues/client.py index c229794..35c7423 100644 --- a/src/ycli/yandex/tracker/issues/client.py +++ b/src/ycli/yandex/tracker/issues/client.py @@ -6,7 +6,7 @@ import uplink -from ycli.yandex.tracker._base import TrackerResource +from ycli.yandex.tracker.base import TrackerResource from ycli.yandex.tracker.issues.models import Issue, IssueList @@ -20,24 +20,10 @@ def get(self, key: uplink.Path) -> Issue: # ty: ignore[empty-body] Example: >>> client = TrackerClient(oauth_token="…", organization_id="…") # doctest: +SKIP - >>> client.issues.get(key="DATAENGINEERING-1").status_key # doctest: +SKIP + >>> client.issues.get(key="DATAENGINEERING-1").status # doctest: +SKIP 'inProgress' """ - @uplink.returns.json() - @uplink.get("issues/{key}") - def get_raw(self, key: uplink.Path) -> dict: # ty: ignore[empty-body] - """``GET /issues/{key}`` → raw JSON dict (no pydantic pruning). - - Bare ``dict`` (not ``dict[str, Any]``) — uplink raises ``TypeError`` trying to - instantiate ``typing.Any`` for an empty-body method. - - Example: - >>> client = TrackerClient(oauth_token="…", organization_id="…") # doctest: +SKIP - >>> client.issues.get_raw(key="DATAENGINEERING-1")["key"] # doctest: +SKIP - 'DATAENGINEERING-1' - """ - @uplink.returns.json() @uplink.json @uplink.post("issues/_search") @@ -90,6 +76,6 @@ def update(self, key: uplink.Path, body: uplink.Body) -> Issue: # ty: ignore[em >>> client = TrackerClient(oauth_token="…", organization_id="…") # doctest: +SKIP >>> client.issues.update( ... key="DATAENGINEERING-1", body={"priority": {"key": "critical"}} - ... ).priority_key # doctest: +SKIP + ... ).priority # doctest: +SKIP 'critical' """ diff --git a/src/ycli/yandex/tracker/issues/mcp.py b/src/ycli/yandex/tracker/issues/mcp.py index a63dfe0..f77ae88 100644 --- a/src/ycli/yandex/tracker/issues/mcp.py +++ b/src/ycli/yandex/tracker/issues/mcp.py @@ -1,14 +1,12 @@ """Tracker /issues FastMCP tools (reads-only) — Depends DI, native error handling.""" -from typing import Any - from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.tracker._args import count_body -from ycli.yandex.tracker._deps import RO, TAGS, tracker_client from ycli.yandex.tracker.client import TrackerClient +from ycli.yandex.tracker.dependencies import RO, TAGS, tracker_client from ycli.yandex.tracker.issues.models import Issue, IssueList +from ycli.yandex.tracker.utils import count_body mcp = FastMCP("tracker-issues") @@ -28,14 +26,6 @@ def get(key: str, client: TrackerClient = Depends(tracker_client)) -> Issue: return result -@mcp.tool( - name="issues_full", annotations={**RO, "title": "Get full Tracker issue (raw)"}, tags=TAGS -) -def full(key: str, client: TrackerClient = Depends(tracker_client)) -> dict[str, Any]: - """A single Tracker issue as a raw dict (all fields).""" - return client.issues.get_raw(key) - - @mcp.tool(name="issues_list", annotations={**RO, "title": "List Tracker issues"}, tags=TAGS) def list_( queue: str = "", diff --git a/src/ycli/yandex/tracker/issues/models.py b/src/ycli/yandex/tracker/issues/models.py index df6c66f..66f8a81 100644 --- a/src/ycli/yandex/tracker/issues/models.py +++ b/src/ycli/yandex/tracker/issues/models.py @@ -4,70 +4,32 @@ from pydantic import Field, RootModel -from ycli.yandex.models import APIModel -from ycli.yandex.tracker._models import ( # noqa: TC001 # pydantic resolves field types via get_type_hints() at runtime - _DisplayRef, - _KeyRef, +from ycli.yandex.models import ( # pydantic resolves field types via get_type_hints() at runtime + APIModel, + DisplayStr, + KeyStr, ) class Issue(APIModel): """A Yandex Tracker issue (``/issues/{key}`` response). - Nested ``type``/``status``/``priority``/``epic``/``parent``/``queue`` each expose a - ``.key`` accessor; ``assignee`` exposes ``.display``. - Example: - >>> Issue.model_validate({"key": "DE-1", "type": {"key": "task"}}).type_key + >>> Issue.model_validate({"key": "DE-1", "type": {"key": "task"}}).type 'task' """ key: str | None = None summary: str | None = None - type: _KeyRef | None = None - status: _KeyRef | None = None - priority: _KeyRef | None = None - epic: _KeyRef | None = None - parent: _KeyRef | None = None - queue: _KeyRef | None = None - assignee: _DisplayRef | None = None + type: KeyStr = None + status: KeyStr = None + priority: KeyStr = None + epic: KeyStr = None + parent: KeyStr = None + queue: KeyStr = None + assignee: DisplayStr = None tags: list[str] = Field(default_factory=list) - @property - def type_key(self) -> str | None: - """``type.key`` or ``None``.""" - return self.type.key if self.type else None - - @property - def status_key(self) -> str | None: - """``status.key`` or ``None``.""" - return self.status.key if self.status else None - - @property - def priority_key(self) -> str | None: - """``priority.key`` or ``None``.""" - return self.priority.key if self.priority else None - - @property - def epic_key(self) -> str | None: - """``epic.key`` or ``None``.""" - return self.epic.key if self.epic else None - - @property - def parent_key(self) -> str | None: - """``parent.key`` or ``None``.""" - return self.parent.key if self.parent else None - - @property - def queue_key(self) -> str | None: - """``queue.key`` or ``None``.""" - return self.queue.key if self.queue else None - - @property - def assignee_display(self) -> str | None: - """``assignee.display`` or ``None``.""" - return self.assignee.display if self.assignee else None - class IssueList(RootModel[list[Issue]]): """A bare JSON array of issues (``POST /issues/_search`` response). diff --git a/src/ycli/yandex/tracker/issuetypes/cli.py b/src/ycli/yandex/tracker/issuetypes/cli.py index aa08299..c2c3b17 100644 --- a/src/ycli/yandex/tracker/issuetypes/cli.py +++ b/src/ycli/yandex/tracker/issuetypes/cli.py @@ -4,8 +4,8 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer app = typer.Typer(name="issuetypes", help="Tracker issue types.", no_args_is_help=True) diff --git a/src/ycli/yandex/tracker/issuetypes/client.py b/src/ycli/yandex/tracker/issuetypes/client.py index 142256d..68377d4 100644 --- a/src/ycli/yandex/tracker/issuetypes/client.py +++ b/src/ycli/yandex/tracker/issuetypes/client.py @@ -5,7 +5,7 @@ import uplink -from ycli.yandex.tracker._base import TrackerResource +from ycli.yandex.tracker.base import TrackerResource from ycli.yandex.tracker.issuetypes.models import IssueTypeList diff --git a/src/ycli/yandex/tracker/issuetypes/mcp.py b/src/ycli/yandex/tracker/issuetypes/mcp.py index af4e6bd..87faec6 100644 --- a/src/ycli/yandex/tracker/issuetypes/mcp.py +++ b/src/ycli/yandex/tracker/issuetypes/mcp.py @@ -3,8 +3,8 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.tracker._deps import RO, TAGS, tracker_client from ycli.yandex.tracker.client import TrackerClient +from ycli.yandex.tracker.dependencies import RO, TAGS, tracker_client from ycli.yandex.tracker.issuetypes.models import IssueTypeList mcp = FastMCP("tracker-issuetypes") diff --git a/src/ycli/yandex/tracker/links/cli.py b/src/ycli/yandex/tracker/links/cli.py index cada2b5..a491bb8 100644 --- a/src/ycli/yandex/tracker/links/cli.py +++ b/src/ycli/yandex/tracker/links/cli.py @@ -7,9 +7,9 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer -from ycli.yandex.tracker._args import ( +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer +from ycli.yandex.tracker.typedefs import ( KeyArg, # noqa: TC001 # typer evaluates Annotated args at runtime via get_type_hints() ) diff --git a/src/ycli/yandex/tracker/links/client.py b/src/ycli/yandex/tracker/links/client.py index 3b59fdc..31404ab 100644 --- a/src/ycli/yandex/tracker/links/client.py +++ b/src/ycli/yandex/tracker/links/client.py @@ -5,7 +5,7 @@ import uplink -from ycli.yandex.tracker._base import TrackerResource +from ycli.yandex.tracker.base import TrackerResource from ycli.yandex.tracker.links.models import Link, LinkList diff --git a/src/ycli/yandex/tracker/links/mcp.py b/src/ycli/yandex/tracker/links/mcp.py index a2cc69b..4d960a1 100644 --- a/src/ycli/yandex/tracker/links/mcp.py +++ b/src/ycli/yandex/tracker/links/mcp.py @@ -3,8 +3,8 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.tracker._deps import RO, TAGS, tracker_client from ycli.yandex.tracker.client import TrackerClient +from ycli.yandex.tracker.dependencies import RO, TAGS, tracker_client from ycli.yandex.tracker.links.models import LinkList mcp = FastMCP("tracker-links") diff --git a/src/ycli/yandex/tracker/links/models.py b/src/ycli/yandex/tracker/links/models.py index 2a4ace4..b8f9313 100644 --- a/src/ycli/yandex/tracker/links/models.py +++ b/src/ycli/yandex/tracker/links/models.py @@ -4,9 +4,9 @@ from pydantic import RootModel -from ycli.yandex.models import APIModel -from ycli.yandex.tracker._models import ( - _IdRef, # noqa: TC001 # pydantic resolves field types via get_type_hints() at runtime +from ycli.yandex.models import ( # pydantic resolves field types via get_type_hints() at runtime + APIModel, + IdStr, ) @@ -28,20 +28,15 @@ class Link(APIModel): Example: >>> Link.model_validate( ... {"id": 7, "type": {"id": "relates"}, "object": {"key": "DE-2"}} - ... ).type_id + ... ).type 'relates' """ id: int | str | None = None - type: _IdRef | None = None + type: IdStr = None direction: str | None = None object: LinkObject | None = None - @property - def type_id(self) -> str | None: - """``type.id`` or ``None``.""" - return self.type.id if self.type else None - @property def object_key(self) -> str | None: """``object.key`` or ``None``.""" diff --git a/src/ycli/yandex/tracker/linktypes/cli.py b/src/ycli/yandex/tracker/linktypes/cli.py index 80a2e48..8c081c2 100644 --- a/src/ycli/yandex/tracker/linktypes/cli.py +++ b/src/ycli/yandex/tracker/linktypes/cli.py @@ -4,8 +4,8 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer app = typer.Typer(name="linktypes", help="Tracker link types.", no_args_is_help=True) diff --git a/src/ycli/yandex/tracker/linktypes/client.py b/src/ycli/yandex/tracker/linktypes/client.py index ffa1f08..8ffcabd 100644 --- a/src/ycli/yandex/tracker/linktypes/client.py +++ b/src/ycli/yandex/tracker/linktypes/client.py @@ -5,7 +5,7 @@ import uplink -from ycli.yandex.tracker._base import TrackerResource +from ycli.yandex.tracker.base import TrackerResource from ycli.yandex.tracker.linktypes.models import LinkTypeList diff --git a/src/ycli/yandex/tracker/linktypes/mcp.py b/src/ycli/yandex/tracker/linktypes/mcp.py index 65a3856..6a3c3e6 100644 --- a/src/ycli/yandex/tracker/linktypes/mcp.py +++ b/src/ycli/yandex/tracker/linktypes/mcp.py @@ -3,8 +3,8 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.tracker._deps import RO, TAGS, tracker_client from ycli.yandex.tracker.client import TrackerClient +from ycli.yandex.tracker.dependencies import RO, TAGS, tracker_client from ycli.yandex.tracker.linktypes.models import LinkTypeList mcp = FastMCP("tracker-linktypes") diff --git a/src/ycli/yandex/tracker/me/cli.py b/src/ycli/yandex/tracker/me/cli.py index 18aa061..4cfdb81 100644 --- a/src/ycli/yandex/tracker/me/cli.py +++ b/src/ycli/yandex/tracker/me/cli.py @@ -4,8 +4,8 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer app = typer.Typer(name="me", help="Tracker authenticated user.", no_args_is_help=True) diff --git a/src/ycli/yandex/tracker/me/client.py b/src/ycli/yandex/tracker/me/client.py index 2730ce4..3eaf4a3 100644 --- a/src/ycli/yandex/tracker/me/client.py +++ b/src/ycli/yandex/tracker/me/client.py @@ -2,7 +2,7 @@ import uplink -from ycli.yandex.tracker._base import TrackerResource +from ycli.yandex.tracker.base import TrackerResource from ycli.yandex.tracker.me.models import Me diff --git a/src/ycli/yandex/tracker/me/mcp.py b/src/ycli/yandex/tracker/me/mcp.py index cf729c3..0943b44 100644 --- a/src/ycli/yandex/tracker/me/mcp.py +++ b/src/ycli/yandex/tracker/me/mcp.py @@ -3,8 +3,8 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.tracker._deps import RO, TAGS, tracker_client from ycli.yandex.tracker.client import TrackerClient +from ycli.yandex.tracker.dependencies import RO, TAGS, tracker_client from ycli.yandex.tracker.me.models import Me mcp = FastMCP("tracker-me") diff --git a/src/ycli/yandex/tracker/priorities/cli.py b/src/ycli/yandex/tracker/priorities/cli.py index 9defac7..eeb7764 100644 --- a/src/ycli/yandex/tracker/priorities/cli.py +++ b/src/ycli/yandex/tracker/priorities/cli.py @@ -4,8 +4,8 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer app = typer.Typer(name="priorities", help="Tracker priorities.", no_args_is_help=True) diff --git a/src/ycli/yandex/tracker/priorities/client.py b/src/ycli/yandex/tracker/priorities/client.py index f2d8a42..c7c5eb0 100644 --- a/src/ycli/yandex/tracker/priorities/client.py +++ b/src/ycli/yandex/tracker/priorities/client.py @@ -5,7 +5,7 @@ import uplink -from ycli.yandex.tracker._base import TrackerResource +from ycli.yandex.tracker.base import TrackerResource from ycli.yandex.tracker.priorities.models import PriorityList diff --git a/src/ycli/yandex/tracker/priorities/mcp.py b/src/ycli/yandex/tracker/priorities/mcp.py index b0a814f..14da265 100644 --- a/src/ycli/yandex/tracker/priorities/mcp.py +++ b/src/ycli/yandex/tracker/priorities/mcp.py @@ -3,8 +3,8 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.tracker._deps import RO, TAGS, tracker_client from ycli.yandex.tracker.client import TrackerClient +from ycli.yandex.tracker.dependencies import RO, TAGS, tracker_client from ycli.yandex.tracker.priorities.models import PriorityList mcp = FastMCP("tracker-priorities") diff --git a/src/ycli/yandex/tracker/transitions/cli.py b/src/ycli/yandex/tracker/transitions/cli.py index 5ea2d67..ab9bd1c 100644 --- a/src/ycli/yandex/tracker/transitions/cli.py +++ b/src/ycli/yandex/tracker/transitions/cli.py @@ -6,9 +6,12 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer -from ycli.yandex.tracker._args import KeyArg, parse_fields +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer +from ycli.yandex.tracker.typedefs import ( + KeyArg, # noqa: TC001 # typer evaluates Annotated args at runtime via get_type_hints() +) +from ycli.yandex.tracker.utils import parse_fields app = typer.Typer(name="transitions", help="Tracker issue transitions.", no_args_is_help=True) diff --git a/src/ycli/yandex/tracker/transitions/client.py b/src/ycli/yandex/tracker/transitions/client.py index d5578a8..840436a 100644 --- a/src/ycli/yandex/tracker/transitions/client.py +++ b/src/ycli/yandex/tracker/transitions/client.py @@ -5,7 +5,7 @@ import uplink -from ycli.yandex.tracker._base import TrackerResource +from ycli.yandex.tracker.base import TrackerResource from ycli.yandex.tracker.transitions.models import TransitionList diff --git a/src/ycli/yandex/tracker/transitions/mcp.py b/src/ycli/yandex/tracker/transitions/mcp.py index adb203a..9737a04 100644 --- a/src/ycli/yandex/tracker/transitions/mcp.py +++ b/src/ycli/yandex/tracker/transitions/mcp.py @@ -3,8 +3,8 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.tracker._deps import RO, TAGS, tracker_client from ycli.yandex.tracker.client import TrackerClient +from ycli.yandex.tracker.dependencies import RO, TAGS, tracker_client from ycli.yandex.tracker.transitions.models import TransitionList mcp = FastMCP("tracker-transitions") diff --git a/src/ycli/yandex/tracker/typedefs.py b/src/ycli/yandex/tracker/typedefs.py new file mode 100644 index 0000000..4dddb5c --- /dev/null +++ b/src/ycli/yandex/tracker/typedefs.py @@ -0,0 +1,9 @@ +"""Shared tracker CLI argument type aliases.""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +KeyArg = Annotated[str, typer.Argument(metavar="KEY", help="Issue key, e.g. DATAENGINEERING-1.")] diff --git a/src/ycli/yandex/tracker/_args.py b/src/ycli/yandex/tracker/utils.py similarity index 88% rename from src/ycli/yandex/tracker/_args.py rename to src/ycli/yandex/tracker/utils.py index fe5c978..dc5c313 100644 --- a/src/ycli/yandex/tracker/_args.py +++ b/src/ycli/yandex/tracker/utils.py @@ -1,14 +1,12 @@ -"""Shared tracker CLI arg types + the ``--field key=value`` JSON-coerce helper.""" +"""Tracker CLI helpers — request-body builders and the ``--field key=value`` JSON coercer.""" from __future__ import annotations import json -from typing import Annotated, Any +from typing import Any import typer -KeyArg = Annotated[str, typer.Argument(metavar="KEY", help="Issue key, e.g. DATAENGINEERING-1.")] - def count_body(query: str = "", queue: str = "", status: str = "") -> dict[str, Any]: """Build the request body for ``POST /issues/_count``. diff --git a/src/ycli/yandex/tracker/worklog/cli.py b/src/ycli/yandex/tracker/worklog/cli.py index 58d80bc..c6154e8 100644 --- a/src/ycli/yandex/tracker/worklog/cli.py +++ b/src/ycli/yandex/tracker/worklog/cli.py @@ -4,9 +4,9 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer -from ycli.yandex.tracker._args import ( +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer +from ycli.yandex.tracker.typedefs import ( KeyArg, # noqa: TC001 # typer evaluates Annotated args at runtime via get_type_hints() ) diff --git a/src/ycli/yandex/tracker/worklog/client.py b/src/ycli/yandex/tracker/worklog/client.py index 21ab20d..edcd297 100644 --- a/src/ycli/yandex/tracker/worklog/client.py +++ b/src/ycli/yandex/tracker/worklog/client.py @@ -5,7 +5,7 @@ import uplink -from ycli.yandex.tracker._base import TrackerResource +from ycli.yandex.tracker.base import TrackerResource from ycli.yandex.tracker.worklog.models import WorklogList diff --git a/src/ycli/yandex/tracker/worklog/mcp.py b/src/ycli/yandex/tracker/worklog/mcp.py index e6916db..88c8f46 100644 --- a/src/ycli/yandex/tracker/worklog/mcp.py +++ b/src/ycli/yandex/tracker/worklog/mcp.py @@ -3,8 +3,8 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.tracker._deps import RO, TAGS, tracker_client from ycli.yandex.tracker.client import TrackerClient +from ycli.yandex.tracker.dependencies import RO, TAGS, tracker_client from ycli.yandex.tracker.worklog.models import WorklogList mcp = FastMCP("tracker-worklog") diff --git a/src/ycli/yandex/tracker/worklog/models.py b/src/ycli/yandex/tracker/worklog/models.py index 9f207f1..c77c697 100644 --- a/src/ycli/yandex/tracker/worklog/models.py +++ b/src/ycli/yandex/tracker/worklog/models.py @@ -4,9 +4,9 @@ from pydantic import Field, RootModel -from ycli.yandex.models import APIModel -from ycli.yandex.tracker._models import ( - _DisplayRef, # noqa: TC001 # pydantic resolves field types via get_type_hints() at runtime +from ycli.yandex.models import ( # pydantic resolves field types via get_type_hints() at runtime + APIModel, + DisplayStr, ) @@ -16,22 +16,17 @@ class Worklog(APIModel): Example: >>> Worklog.model_validate( ... {"id": 5, "createdBy": {"display": "X"}, "duration": "PT2H"} - ... ).author_display + ... ).created_by 'X' """ id: int | str | None = None created_at: str | None = Field(default=None, alias="createdAt") - created_by: _DisplayRef | None = Field(default=None, alias="createdBy") + created_by: DisplayStr = Field(default=None, alias="createdBy") duration: str | None = None start: str | None = None comment: str | None = None - @property - def author_display(self) -> str | None: - """``createdBy.display`` or ``None``.""" - return self.created_by.display if self.created_by else None - class WorklogList(RootModel[list[Worklog]]): """A bare JSON array of worklog entries. diff --git a/src/ycli/yandex/wiki/attachments/__init__.py b/src/ycli/yandex/wiki/attachments/__init__.py index e69de29..562803e 100644 --- a/src/ycli/yandex/wiki/attachments/__init__.py +++ b/src/ycli/yandex/wiki/attachments/__init__.py @@ -0,0 +1 @@ +"""Wiki /pages/{id}/attachments resource package.""" diff --git a/src/ycli/yandex/wiki/attachments/cli.py b/src/ycli/yandex/wiki/attachments/cli.py index 61c804d..85bd6c6 100644 --- a/src/ycli/yandex/wiki/attachments/cli.py +++ b/src/ycli/yandex/wiki/attachments/cli.py @@ -6,8 +6,8 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer app = typer.Typer(name="attachments", help="Wiki page attachments.", no_args_is_help=True) diff --git a/src/ycli/yandex/wiki/attachments/client.py b/src/ycli/yandex/wiki/attachments/client.py index 70c6f95..e01156d 100644 --- a/src/ycli/yandex/wiki/attachments/client.py +++ b/src/ycli/yandex/wiki/attachments/client.py @@ -6,9 +6,9 @@ import uplink -from ycli.yandex.pagination import collect_single_page -from ycli.yandex.wiki._base import WikiResource +from ycli.yandex.pagination import SinglePageStrategy from ycli.yandex.wiki.attachments.models import AttachmentList, AttachmentsResponse +from ycli.yandex.wiki.base import WikiResource class AttachmentsClient(WikiResource): @@ -31,7 +31,7 @@ def list(self, page_id: int, *, limit: int | None = None) -> AttachmentList: >>> client.attachments.list(12345).root[0].name # doctest: +SKIP 'diagram.png' """ - return collect_single_page( + return SinglePageStrategy.collect_wrapped( lambda cursor: self._list_page(page_id, page_size=100), extract=lambda page: page.results, wrap=AttachmentList, diff --git a/src/ycli/yandex/wiki/attachments/mcp.py b/src/ycli/yandex/wiki/attachments/mcp.py index 6e0e913..fce3a88 100644 --- a/src/ycli/yandex/wiki/attachments/mcp.py +++ b/src/ycli/yandex/wiki/attachments/mcp.py @@ -3,9 +3,9 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.wiki._deps import RO, TAGS, wiki_client from ycli.yandex.wiki.attachments.models import AttachmentList from ycli.yandex.wiki.client import WikiClient +from ycli.yandex.wiki.dependencies import RO, TAGS, wiki_client mcp = FastMCP("wiki-attachments") diff --git a/src/ycli/yandex/wiki/_base.py b/src/ycli/yandex/wiki/base.py similarity index 100% rename from src/ycli/yandex/wiki/_base.py rename to src/ycli/yandex/wiki/base.py diff --git a/src/ycli/yandex/wiki/comments/__init__.py b/src/ycli/yandex/wiki/comments/__init__.py index e69de29..577af76 100644 --- a/src/ycli/yandex/wiki/comments/__init__.py +++ b/src/ycli/yandex/wiki/comments/__init__.py @@ -0,0 +1 @@ +"""Wiki /pages/{id}/comments resource package.""" diff --git a/src/ycli/yandex/wiki/comments/cli.py b/src/ycli/yandex/wiki/comments/cli.py index 15daec3..8715c4b 100644 --- a/src/ycli/yandex/wiki/comments/cli.py +++ b/src/ycli/yandex/wiki/comments/cli.py @@ -6,8 +6,8 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer app = typer.Typer(name="comments", help="Wiki page comments.", no_args_is_help=True) diff --git a/src/ycli/yandex/wiki/comments/client.py b/src/ycli/yandex/wiki/comments/client.py index cba355c..04c0891 100644 --- a/src/ycli/yandex/wiki/comments/client.py +++ b/src/ycli/yandex/wiki/comments/client.py @@ -6,8 +6,8 @@ import uplink -from ycli.yandex.pagination import collect_single_page -from ycli.yandex.wiki._base import WikiResource +from ycli.yandex.pagination import SinglePageStrategy +from ycli.yandex.wiki.base import WikiResource from ycli.yandex.wiki.comments.models import CommentList, CommentsResponse @@ -28,10 +28,10 @@ def list(self, page_id: int, *, limit: int | None = None) -> CommentList: Example: >>> client = WikiClient(oauth_token="…", organization_id="…") # doctest: +SKIP - >>> client.comments.list(12345).root[0].author_display # doctest: +SKIP + >>> client.comments.list(12345).root[0].author # doctest: +SKIP 'Сава Знатнов' """ - return collect_single_page( + return SinglePageStrategy.collect_wrapped( lambda cursor: self._list_page(page_id, page_size=100), extract=lambda page: page.results, wrap=CommentList, diff --git a/src/ycli/yandex/wiki/comments/mcp.py b/src/ycli/yandex/wiki/comments/mcp.py index fa7cf83..bd865f4 100644 --- a/src/ycli/yandex/wiki/comments/mcp.py +++ b/src/ycli/yandex/wiki/comments/mcp.py @@ -3,9 +3,9 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.wiki._deps import RO, TAGS, wiki_client from ycli.yandex.wiki.client import WikiClient from ycli.yandex.wiki.comments.models import CommentList +from ycli.yandex.wiki.dependencies import RO, TAGS, wiki_client mcp = FastMCP("wiki-comments") diff --git a/src/ycli/yandex/wiki/comments/models.py b/src/ycli/yandex/wiki/comments/models.py index 9ef3fae..b9aeeed 100644 --- a/src/ycli/yandex/wiki/comments/models.py +++ b/src/ycli/yandex/wiki/comments/models.py @@ -4,29 +4,24 @@ from pydantic import Field, RootModel -from ycli.yandex.models import APIModel - - -class _CommentAuthor(APIModel): - display: str | None = None +from ycli.yandex.models import ( # pydantic resolves field types via get_type_hints() at runtime + APIModel, + DisplayStr, +) class Comment(APIModel): """A wiki page comment (``/pages/{id}/comments`` item). Example: - >>> Comment.model_validate({"author": {"display": "Сава"}, "content": "ok"}).author_display + >>> Comment.model_validate({"author": {"display": "Сава"}, "content": "ok"}).author 'Сава' """ created_at: str | None = None - author: _CommentAuthor | None = None + author: DisplayStr = None content: str | None = None - @property - def author_display(self) -> str | None: - return self.author.display if self.author else None - class CommentsResponse(APIModel): """Envelope for ``GET /pages/{id}/comments`` — ``{results:[Comment]}``. diff --git a/src/ycli/yandex/wiki/_deps.py b/src/ycli/yandex/wiki/dependencies.py similarity index 54% rename from src/ycli/yandex/wiki/_deps.py rename to src/ycli/yandex/wiki/dependencies.py index d1fb455..692833f 100644 --- a/src/ycli/yandex/wiki/_deps.py +++ b/src/ycli/yandex/wiki/dependencies.py @@ -1,6 +1,6 @@ -"""Cached wiki MCP client provider (see ycli.yandex._mcp.make_cached_client).""" +"""Cached wiki MCP client provider (see ycli.yandex.mcp.make_cached_client).""" -from ycli.yandex._mcp import RO, app_config, make_cached_client +from ycli.yandex.mcp import RO, app_config, make_cached_client from ycli.yandex.wiki.client import WikiClient TAGS: set[str] = {"wiki"} diff --git a/src/ycli/yandex/wiki/me/cli.py b/src/ycli/yandex/wiki/me/cli.py index 09363a7..598b2b2 100644 --- a/src/ycli/yandex/wiki/me/cli.py +++ b/src/ycli/yandex/wiki/me/cli.py @@ -4,8 +4,8 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer app = typer.Typer(name="me", help="Wiki authenticated user.", no_args_is_help=True) diff --git a/src/ycli/yandex/wiki/me/client.py b/src/ycli/yandex/wiki/me/client.py index 93353d2..4f84ca0 100644 --- a/src/ycli/yandex/wiki/me/client.py +++ b/src/ycli/yandex/wiki/me/client.py @@ -2,7 +2,7 @@ import uplink -from ycli.yandex.wiki._base import WikiResource +from ycli.yandex.wiki.base import WikiResource from ycli.yandex.wiki.me.models import Me diff --git a/src/ycli/yandex/wiki/me/mcp.py b/src/ycli/yandex/wiki/me/mcp.py index 8417878..4a840c5 100644 --- a/src/ycli/yandex/wiki/me/mcp.py +++ b/src/ycli/yandex/wiki/me/mcp.py @@ -3,8 +3,8 @@ from fastmcp import FastMCP from fastmcp.dependencies import Depends -from ycli.yandex.wiki._deps import RO, TAGS, wiki_client from ycli.yandex.wiki.client import WikiClient +from ycli.yandex.wiki.dependencies import RO, TAGS, wiki_client from ycli.yandex.wiki.me.models import Me mcp = FastMCP("wiki-me") diff --git a/src/ycli/yandex/wiki/pages/__init__.py b/src/ycli/yandex/wiki/pages/__init__.py index e69de29..f368a2a 100644 --- a/src/ycli/yandex/wiki/pages/__init__.py +++ b/src/ycli/yandex/wiki/pages/__init__.py @@ -0,0 +1 @@ +"""Wiki /pages resource package.""" diff --git a/src/ycli/yandex/wiki/pages/cli.py b/src/ycli/yandex/wiki/pages/cli.py index 64cac3c..3310cda 100644 --- a/src/ycli/yandex/wiki/pages/cli.py +++ b/src/ycli/yandex/wiki/pages/cli.py @@ -6,8 +6,8 @@ import typer -from ycli.context import AppContext -from ycli.output import Serializer +from ycli.cli.context import AppContext +from ycli.cli.output import Serializer app = typer.Typer(name="pages", help="Wiki pages.", no_args_is_help=True) diff --git a/src/ycli/yandex/wiki/pages/client.py b/src/ycli/yandex/wiki/pages/client.py index 752f975..4361f93 100644 --- a/src/ycli/yandex/wiki/pages/client.py +++ b/src/ycli/yandex/wiki/pages/client.py @@ -7,7 +7,7 @@ import uplink from ycli.yandex.pagination import CursorStrategy -from ycli.yandex.wiki._base import WikiResource +from ycli.yandex.wiki.base import WikiResource from ycli.yandex.wiki.pages.models import DescendantsResponse, PageDetails, PageRefList diff --git a/src/ycli/yandex/wiki/pages/mcp.py b/src/ycli/yandex/wiki/pages/mcp.py index f439849..580ae93 100644 --- a/src/ycli/yandex/wiki/pages/mcp.py +++ b/src/ycli/yandex/wiki/pages/mcp.py @@ -4,8 +4,8 @@ from fastmcp.dependencies import Depends from ycli.settings import AppConfig -from ycli.yandex.wiki._deps import RO, TAGS, app_config, wiki_client from ycli.yandex.wiki.client import WikiClient +from ycli.yandex.wiki.dependencies import RO, TAGS, app_config, wiki_client from ycli.yandex.wiki.pages.models import PageDetails, PageRefList mcp = FastMCP("wiki-pages") diff --git a/tests/conftest.py b/tests/conftest.py index 683b61e..d220b20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,15 @@ import pytest -from ycli.yandex import _mcp -from ycli.yandex.forms import _deps as forms_deps -from ycli.yandex.tracker import _deps as tracker_deps -from ycli.yandex.wiki import _deps as wiki_deps +from ycli.yandex import mcp +from ycli.yandex.forms import dependencies as forms_deps +from ycli.yandex.tracker import dependencies as tracker_deps +from ycli.yandex.wiki import dependencies as wiki_deps @pytest.fixture(autouse=True) def _reset_mcp_client_caches(): """Each test builds its domain client fresh from its own env (the @cache is process-wide).""" - _mcp.app_config.cache_clear() + mcp.app_config.cache_clear() tracker_deps.tracker_client.cache_clear() wiki_deps.wiki_client.cache_clear() forms_deps.forms_client.cache_clear() diff --git a/tests/snapshots/_surface.py b/tests/snapshots/_surface.py index b844a21..aef9d6d 100644 --- a/tests/snapshots/_surface.py +++ b/tests/snapshots/_surface.py @@ -7,7 +7,7 @@ import typer.main from fastmcp import Client -from ycli.cli import app +from ycli.cli.app import app from ycli.mcp import mcp diff --git a/tests/snapshots/cli_tree.txt b/tests/snapshots/cli_tree.txt index 6a26847..034aabd 100644 --- a/tests/snapshots/cli_tree.txt +++ b/tests/snapshots/cli_tree.txt @@ -22,7 +22,6 @@ tracker comments list tracker issues tracker issues count tracker issues create -tracker issues full tracker issues get tracker issues list tracker issues search diff --git a/tests/snapshots/mcp_tools.txt b/tests/snapshots/mcp_tools.txt index ddf68ca..8fb75ce 100644 --- a/tests/snapshots/mcp_tools.txt +++ b/tests/snapshots/mcp_tools.txt @@ -3,10 +3,10 @@ forms_me_get forms_questions_list forms_surveys_get forms_surveys_list +status_get tracker_changelog_list tracker_comments_list tracker_issues_count -tracker_issues_full tracker_issues_get tracker_issues_list tracker_issues_search diff --git a/tests/test_architecture.py b/tests/test_architecture.py index 2db93cf..c12d352 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -21,7 +21,7 @@ # Allow-list (fail-closed): an MCP tool's verb MUST be a known read. A new read # operation adds its verb here deliberately; any other verb (modify/patch/post/…) # fails, so a write tool can't slip in by naming. Keep in sync with ARCHITECTURE.md. -READ_VERBS = {"get", "list", "count", "full", "search", "descendants", "meta"} +READ_VERBS = {"get", "list", "count", "search", "descendants", "meta"} # Behavioral backstop: even a read-named tool must not call a client write method. _WRITE_CALL_RE = re.compile(r"\.(create|update|add|execute|delete|remove|set)\(") diff --git a/tests/test_demo_render.py b/tests/test_demo_render.py new file mode 100644 index 0000000..e9086ad --- /dev/null +++ b/tests/test_demo_render.py @@ -0,0 +1,45 @@ +"""The demo render harness emits real CLI output from committed fixtures (leak-free).""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +RENDER = REPO / "docs" / "demo" / "render.py" + +pytestmark = pytest.mark.integration + + +def _run(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(RENDER), *args], + capture_output=True, + text=True, + cwd=REPO, + ) + + +def test_render_tracker_issue_get_is_pretty_and_flat(): + proc = _run(["tracker", "issues", "get", "DEMO-42"]) + assert proc.returncode == 0, proc.stderr + assert "DEMO-42" in proc.stdout + # the demo shows the pretty table, not raw JSON (a presentation, not a pipe) + assert not proc.stdout.lstrip().startswith("{") + # refs render flat (model-layer flattening), not as nested {"key": ...} + assert "inProgress" in proc.stdout + assert '{"key"' not in proc.stdout + + +def test_render_wiki_page_get_emits_markdown_body(): + proc = _run(["wiki", "pages", "get", "onboarding"]) + assert proc.returncode == 0, proc.stderr + assert "Welcome to the team" in proc.stdout # raw markdown body of the page + + +def test_render_unknown_command_exits_nonzero(): + # guards against a typo'd ROUTES key silently emitting an error frame into the GIF + proc = _run(["tracker", "issues", "get", "NOPE-1"]) + assert proc.returncode == 2 + assert "unknown command" in proc.stderr diff --git a/tests/test_did_you_mean.py b/tests/test_did_you_mean.py index 4157150..d865e16 100644 --- a/tests/test_did_you_mean.py +++ b/tests/test_did_you_mean.py @@ -4,7 +4,7 @@ from typer.testing import CliRunner -from ycli.cli import app +from ycli.cli.app import app _RUNNER = CliRunner() diff --git a/tests/test_models.py b/tests/test_models.py index c9575db..96908ef 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,4 +1,4 @@ -from ycli.yandex.models import APIModel +from ycli.yandex.models import APIModel, DisplayStr, KeyStr, _extract def test_apimodel_is_lenient_and_alias_friendly(): @@ -8,3 +8,18 @@ def test_apimodel_is_lenient_and_alias_friendly(): # Runtime behaviour, not just the config dict: an unknown field is dropped, not an error. instance = APIModel.model_validate({"unknown_field": "dropped"}) assert not hasattr(instance, "unknown_field") + + +def test_extract_pulls_field_from_wrapper_and_passes_scalars_through(): + pull = _extract("key") + assert pull({"key": "x", "display": "X"}) == "x" # wrapper → bare field + assert pull("already-flat") == "already-flat" # scalar passes through + assert pull(None) is None # None passes through + assert _extract("display")({"display": "d"}) == "d" + + +def test_ref_annotations_accept_a_bare_scalar(): + from pydantic import TypeAdapter + + assert TypeAdapter(KeyStr).validate_python("flat") == "flat" + assert TypeAdapter(DisplayStr).validate_python({"display": "d"}) == "d" diff --git a/tests/test_output.py b/tests/test_output.py index 096a15a..9aa80cb 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -1,4 +1,4 @@ -"""TDD for ycli.output — the `--format` renderer over pydantic results.""" +"""TDD for ycli.cli.output — the `--format` renderer over pydantic results.""" from __future__ import annotations @@ -8,8 +8,9 @@ import yaml from pydantic import BaseModel, RootModel from rich.console import Console +from rich.table import Table -from ycli.output import OutputFormat, PrettyStrategy, RichCell, SerializationStrategy, Serializer +from ycli.cli.output import OutputFormat, PrettyStrategy, SerializationStrategy, Serializer class Item(BaseModel): @@ -66,16 +67,63 @@ def test_pretty_list_renders_table(): assert "name" in out and "a" in out and "b" in out -def test_prettify_dispatch(): +def test_render_scalar_and_null(): strategy = PrettyStrategy() - assert strategy._prettify("scalar") == "scalar" - assert strategy._prettify([1, 2]).row_count == 2 # scalar list → single-column table - assert strategy._prettify({"k": "v"}).row_count == 1 # dict → kv table + assert strategy._render("scalar") == "scalar" + assert strategy._render(3) == "3" + assert strategy._render(None) is None -def test_cell_rendering(): - assert RichCell.of(None).text == "" - assert RichCell.of("x").text == "x" - assert RichCell.of(3).text == "3" - assert RichCell.of({"a": 1}).text == '{"a": 1}' - assert RichCell.of([1, 2]).text == "[1, 2]" +def test_render_object_omits_null_fields(): + strategy = PrettyStrategy() + table = strategy._render({"a": "1", "b": None}) # b dropped; a remains as a one-row table + assert isinstance(table, Table) + assert table.row_count == 1 + assert strategy._render({"a": None}) is None # all-null object is omitted entirely + + +def test_render_multi_field_object_is_kv_table(): + strategy = PrettyStrategy() + table = strategy._render({"a": "1", "b": "2"}) + assert isinstance(table, Table) + assert table.row_count == 2 + + +def test_render_nested_multifield_object_in_cell(): + strategy = PrettyStrategy() + table = strategy._render({"name": "root", "meta": {"a": "1", "b": "2"}}) + assert isinstance(table, Table) + assert table.row_count == 2 # name row + meta row (a nested table in the cell) + + +def test_render_scalar_list_joins_and_empty_is_omitted(): + strategy = PrettyStrategy() + assert strategy._render(["alpha", "beta"]) == "alpha, beta" + assert strategy._render([]) is None + + +def test_render_object_list_table_drops_all_empty_columns(): + strategy = PrettyStrategy() + table = strategy._render( + [{"id": "1", "name": "a", "extra": None}, {"id": "2", "name": "b", "extra": None}] + ) + assert isinstance(table, Table) + assert [column.header for column in table.columns] == ["id", "name"] # all-null 'extra' gone + + +def test_render_object_list_with_mixed_nondict_item(): + strategy = PrettyStrategy() + table = strategy._render([{"a": "1", "b": "2"}, "loose"]) + assert isinstance(table, Table) + assert [column.header for column in table.columns] == ["a", "b"] # columns from the dict only + assert table.row_count == 2 # the non-dict row renders as empty cells + + +def test_render_all_null_model_prints_blank_line(): + console, buf = _console(tty=True) + + class Empty(BaseModel): + a: int | None = None + + PrettyStrategy().render(Empty(), console) + assert buf.getvalue().strip() == "" diff --git a/tests/test_output_strategies.py b/tests/test_output_strategies.py index 01d3ff1..d0ec1c0 100644 --- a/tests/test_output_strategies.py +++ b/tests/test_output_strategies.py @@ -5,12 +5,11 @@ from pydantic import BaseModel from rich.console import Console -from ycli.output import ( +from ycli.cli.output import ( AutoStrategy, JsonStrategy, OutputFormat, PrettyStrategy, - RichCell, SerializationStrategy, Serializer, YamlStrategy, @@ -81,16 +80,6 @@ def test_serializer_dispatches_to_strategy_render(): assert '"key":"DE-1"' in buf.getvalue().replace(" ", "") -def test_richcell_renders_none_as_blank_and_nested_as_json(): - assert RichCell.of(None).text == "" - assert RichCell.of({"a": 1}).text == '{"a": 1}' - assert RichCell.of("DE-1").text == "DE-1" - - -def test_richcell_renders_list_as_json(): - assert RichCell.of([1, 2]).text == "[1, 2]" - - def test_pretty_strategy_renders_list_of_dicts(): class _RowList(BaseModel): rows: list[_Row] @@ -101,13 +90,11 @@ class _RowList(BaseModel): assert "A" in out -def test_pretty_strategy_renders_list_of_scalars(): - strategy = PrettyStrategy() - table = strategy._list_of_scalars_table(["alpha", "beta"]) - assert table is not None +def test_pretty_strategy_renders_scalar_list_as_join(): + console, buf = _console(terminal=True) + class _Tags(BaseModel): + tags: list[str] -def test_pretty_strategy_list_table_empty(): - strategy = PrettyStrategy() - table = strategy._list_table([]) - assert table is not None + PrettyStrategy().render(_Tags(tags=["alpha", "beta"]), console) + assert "alpha, beta" in buf.getvalue() diff --git a/tests/test_yandex_cli.py b/tests/test_yandex_cli.py index 6f689b7..420b202 100644 --- a/tests/test_yandex_cli.py +++ b/tests/test_yandex_cli.py @@ -5,15 +5,20 @@ import pytest from typer.testing import CliRunner -import ycli.cli as cli -from ycli.context import AppContext -from ycli.output import OutputFormat, PrettyStrategy +import ycli.cli.app as cli +from ycli.cli.context import AppContext +from ycli.cli.output import OutputFormat, PrettyStrategy pytestmark = pytest.mark.integration runner = CliRunner() +def test_cli_main_module_importable(): + """``python -m ycli.cli`` entry resolves — covers the __main__.py import line.""" + import ycli.cli.__main__ # noqa: F401 + + # --------------------------------------------------------------------------- # Tracker CLI smoke tests # --------------------------------------------------------------------------- diff --git a/tests/test_yandex_mcp.py b/tests/test_yandex_mcp.py index 5fef13b..399b6c0 100644 --- a/tests/test_yandex_mcp.py +++ b/tests/test_yandex_mcp.py @@ -6,15 +6,27 @@ from ycli.mcp import mcp +def test_base_install_imports_cli_without_fastmcp(): + """`ycli.mcp.cli` (and `ycli.cli`) must import without pulling fastmcp — base install.""" + import subprocess + import sys + + code = "import ycli.cli, ycli.mcp.cli, sys; assert 'fastmcp' not in sys.modules" + proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + + async def test_root_mounts_all_domains_with_namespaces(): async with Client(mcp) as client: names = {t.name for t in await client.list_tools()} assert "wiki_pages_get" in names assert "tracker_issues_get" in names assert "forms_surveys_get" in names + assert "status_get" in names assert len([n for n in names if n.startswith("wiki_")]) == 6 - assert len([n for n in names if n.startswith("tracker_")]) == 14 + assert len([n for n in names if n.startswith("tracker_")]) == 13 assert len([n for n in names if n.startswith("forms_")]) == 5 + assert len([n for n in names if n.startswith("status_")]) == 1 assert len(names) == 25 @@ -24,6 +36,11 @@ def test_main_is_callable(): assert callable(main) +def test_mcp_main_module_importable(): + """``python -m ycli.mcp`` entry resolves — covers the __main__.py import line.""" + import ycli.mcp.__main__ # noqa: F401 + + @pytest.mark.integration def test_mcp_main_honors_log_level(monkeypatch, capsys): monkeypatch.setenv("YCLI_LOG_LEVEL", "ERROR") diff --git a/tests/yandex/forms/answers/test_cli.py b/tests/yandex/forms/answers/test_cli.py index 38d36ab..81cc027 100644 --- a/tests/yandex/forms/answers/test_cli.py +++ b/tests/yandex/forms/answers/test_cli.py @@ -7,7 +7,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.forms.yandex.net/v1" SID = "6818ceffe010db4f59d11329" diff --git a/tests/yandex/forms/me/test_cli.py b/tests/yandex/forms/me/test_cli.py index bf4d925..0092638 100644 --- a/tests/yandex/forms/me/test_cli.py +++ b/tests/yandex/forms/me/test_cli.py @@ -6,7 +6,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.forms.yandex.net/v1" runner = CliRunner() diff --git a/tests/yandex/forms/questions/test_cli.py b/tests/yandex/forms/questions/test_cli.py index b708bfe..a407ced 100644 --- a/tests/yandex/forms/questions/test_cli.py +++ b/tests/yandex/forms/questions/test_cli.py @@ -6,7 +6,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.forms.yandex.net/v1" SID = "6818ceffe010db4f59d11329" diff --git a/tests/yandex/forms/surveys/test_cli.py b/tests/yandex/forms/surveys/test_cli.py index 9b6d011..7cd83c7 100644 --- a/tests/yandex/forms/surveys/test_cli.py +++ b/tests/yandex/forms/surveys/test_cli.py @@ -6,7 +6,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.forms.yandex.net/v1" SID = "6818ceffe010db4f59d11329" diff --git a/tests/yandex/forms/test_client.py b/tests/yandex/forms/test_client.py index b48b0da..3b97677 100644 --- a/tests/yandex/forms/test_client.py +++ b/tests/yandex/forms/test_client.py @@ -16,10 +16,10 @@ def test_composes_subclients_over_shared_authed_session(): @responses.activate def test_forms_deps_factory_builds_from_env(monkeypatch): - """_deps.forms_client() reads env and returns a working FormsClient.""" + """dependencies.forms_client() reads env and returns a working FormsClient.""" monkeypatch.setenv("YANDEX_ID_OAUTH_TOKEN", "tok") monkeypatch.setenv("YANDEX_ID_ORGANIZATION_ID", "org") - from ycli.yandex.forms._deps import forms_client + from ycli.yandex.forms.dependencies import forms_client responses.add( responses.GET, diff --git a/tests/yandex/forms/test_shared.py b/tests/yandex/forms/test_shared.py index 4725cad..46cd2d5 100644 --- a/tests/yandex/forms/test_shared.py +++ b/tests/yandex/forms/test_shared.py @@ -2,7 +2,7 @@ import requests -from ycli.yandex.forms._base import FormsResource +from ycli.yandex.forms.base import FormsResource class _Demo(FormsResource): diff --git a/tests/yandex/status/__init__.py b/tests/yandex/status/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/yandex/status/test_mcp.py b/tests/yandex/status/test_mcp.py new file mode 100644 index 0000000..3e3082e --- /dev/null +++ b/tests/yandex/status/test_mcp.py @@ -0,0 +1,53 @@ +"""status_get MCP tool — aggregates the three /me probes into one read-only report.""" + +import pytest +import responses +from fastmcp import Client + +from ycli.yandex.status import mcp as status_mcp + +TRACKER_ME = "https://api.tracker.yandex.net/v3/myself" +FORMS_ME = "https://api.forms.yandex.net/v1/users/me" +WIKI_ME = "https://api.wiki.yandex.net/v1/users/me" + + +@pytest.fixture +def creds(monkeypatch): + monkeypatch.setenv("YANDEX_ID_OAUTH_TOKEN", "t") + monkeypatch.setenv("YANDEX_ID_ORGANIZATION_ID", "o") + + +@responses.activate +async def test_status_get_reports_all_valid(creds): + responses.add(responses.GET, TRACKER_ME, json={"login": "alice"}, status=200) + responses.add(responses.GET, WIKI_ME, json={"username": "alice"}, status=200) + responses.add(responses.GET, FORMS_ME, json={"id": 1, "email": "alice@x"}, status=200) + async with Client(status_mcp.mcp) as client: + result = await client.call_tool("get", {}) + # The `service` discriminator makes the me union loss-free across the fastmcp round-trip: + # every member round-trips with its own fields intact (wiki keeps `username`). fastmcp + # rebuilds discriminated-union branches as plain dicts, so index them by key. + services = {s["service"]: s for s in result.data.services} + assert services["tracker"]["valid"] is True + assert services["tracker"]["me"]["login"] == "alice" + assert services["forms"]["me"]["email"] == "alice@x" + assert services["wiki"]["me"]["username"] == "alice" + + +@responses.activate +async def test_status_get_marks_invalid_on_401(creds): + responses.add(responses.GET, TRACKER_ME, status=401) + responses.add(responses.GET, WIKI_ME, json={"username": "alice"}, status=200) + responses.add(responses.GET, FORMS_ME, json={"id": 1, "email": "alice@x"}, status=200) + async with Client(status_mcp.mcp) as client: + result = await client.call_tool("get", {}) + services = {s["service"]: s for s in result.data.services} + assert services["tracker"]["valid"] is False + assert services["tracker"]["detail"] == "token invalid or expired" + + +async def test_status_get_is_read_only(): + async with Client(status_mcp.mcp) as client: + tools = {t.name: t for t in await client.list_tools()} + assert "get" in tools + assert tools["get"].annotations.readOnlyHint is True diff --git a/tests/yandex/test_pagination.py b/tests/yandex/test_pagination.py index fd13105..bc3e0e5 100644 --- a/tests/yandex/test_pagination.py +++ b/tests/yandex/test_pagination.py @@ -2,7 +2,6 @@ CursorStrategy, NextUrlStrategy, SinglePageStrategy, - collect_single_page, ) @@ -39,6 +38,22 @@ def test_cursor_strategy_respects_limit(): assert out == [1, 2, 3] # stops without fetching c1 +def test_cursor_strategy_empty_string_cursor_is_not_terminal(): + """Only ``None`` terminates: an empty-string cursor is a real cursor, fetched onward. + + Locks the deliberate ``if cursor is None`` contract (vs the old falsy ``if not cursor``, + which would have stopped at the empty string) against a future regression. + """ + pages = { + None: {"results": [1], "next_cursor": ""}, + "": {"results": [2], "next_cursor": None}, + } + out = CursorStrategy( + extract=lambda p: p["results"], next_of=lambda p: p["next_cursor"] + ).collect(lambda cursor: pages[cursor], limit=None) + assert out == [1, 2] # "" is followed, not treated as exhausted + + def test_next_url_strategy_drains_and_dedupes_self_loops(): pages = { "start": {"answers": [1], "next": {"next_url": "p2"}}, @@ -52,9 +67,11 @@ def test_next_url_strategy_drains_and_dedupes_self_loops(): assert out == [1, 2] -def test_collect_single_page_extracts_wraps_and_bounds(): +def test_single_page_collect_wrapped_extracts_wraps_and_bounds(): pages = {"a": [1, 2, 3]} - out = collect_single_page(lambda cursor: pages, extract=lambda p: p["a"], wrap=list, limit=2) + out = SinglePageStrategy.collect_wrapped( + lambda cursor: pages, extract=lambda p: p["a"], wrap=list, limit=2 + ) assert out == [1, 2] diff --git a/tests/yandex/test_settings.py b/tests/yandex/test_settings.py index b98d109..7efa5bb 100644 --- a/tests/yandex/test_settings.py +++ b/tests/yandex/test_settings.py @@ -49,11 +49,11 @@ def test_settings_read_dotenv(tmp_path, monkeypatch): def test_cli_callback_uses_configured_log_level(monkeypatch): - import ycli.cli as cli + import ycli.cli.app as cli captured = {} monkeypatch.setenv("YCLI_LOG_LEVEL", "ERROR") - monkeypatch.setattr("ycli.cli.configure", lambda level: captured.setdefault("level", level)) + monkeypatch.setattr("ycli.cli.app.configure", lambda level: captured.setdefault("level", level)) from typer.testing import CliRunner # Root --help doesn't trigger the callback in Typer; use a subcommand invocation instead. diff --git a/tests/yandex/test_status.py b/tests/yandex/test_status.py index 4bfa8ce..caf833b 100644 --- a/tests/yandex/test_status.py +++ b/tests/yandex/test_status.py @@ -4,7 +4,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli TRACKER_ME = "https://api.tracker.yandex.net/v3/myself" FORMS_ME = "https://api.forms.yandex.net/v1/users/me" diff --git a/tests/yandex/test_transport.py b/tests/yandex/test_transport.py index 8a73e5a..0ea9d7a 100644 --- a/tests/yandex/test_transport.py +++ b/tests/yandex/test_transport.py @@ -154,7 +154,7 @@ def spy(self, request, **kw): responses.add( responses.GET, "https://api.tracker.yandex.net/v3/priorities", json=[], status=200 ) - from ycli.yandex.tracker._deps import tracker_client + from ycli.yandex.tracker.dependencies import tracker_client tracker_client().priorities.list() assert seen["incoming_timeout"] is None, f"Expected None but got {seen['incoming_timeout']}" diff --git a/tests/yandex/tracker/changelog/test_cli.py b/tests/yandex/tracker/changelog/test_cli.py index 0f5a309..b6450a3 100644 --- a/tests/yandex/tracker/changelog/test_cli.py +++ b/tests/yandex/tracker/changelog/test_cli.py @@ -6,7 +6,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.tracker.yandex.net/v3" runner = CliRunner() diff --git a/tests/yandex/tracker/changelog/test_client.py b/tests/yandex/tracker/changelog/test_client.py index 9a23294..9c852df 100644 --- a/tests/yandex/tracker/changelog/test_client.py +++ b/tests/yandex/tracker/changelog/test_client.py @@ -35,6 +35,6 @@ def test_list_passes_perpage_and_parses_polymorphic_fields(): out = ChangelogClient(session=s).list("DE-1", per_page=50) assert isinstance(out, ChangelogList) e = out.root[0] - assert e.author_display == "Сава" + assert e.updated_by == "Сава" assert e.fields[0].to == {"key": "done", "display": "Готово"} # polymorphic passthrough assert responses.calls[0].request.params["perPage"] == "50" # ty: ignore[unresolved-attribute] diff --git a/tests/yandex/tracker/changelog/test_models.py b/tests/yandex/tracker/changelog/test_models.py index 2772fe2..8eb19b4 100644 --- a/tests/yandex/tracker/changelog/test_models.py +++ b/tests/yandex/tracker/changelog/test_models.py @@ -1,13 +1,13 @@ -"""Property accessors on the changelog models — populated and None branches.""" +"""Changelog ref fields flatten to bare scalars — populated and None branches.""" from ycli.yandex.tracker.changelog.models import ChangeField, ChangelogEntry -def test_field_id_populated_and_none(): - assert ChangeField.model_validate({"field": {"id": "status"}}).field_id == "status" - assert ChangeField.model_validate({}).field_id is None +def test_field_flattens_to_scalar(): + assert ChangeField.model_validate({"field": {"id": "status"}}).field == "status" + assert ChangeField.model_validate({}).field is None -def test_author_display_populated_and_none(): - assert ChangelogEntry.model_validate({"updatedBy": {"display": "X"}}).author_display == "X" - assert ChangelogEntry.model_validate({"id": "1"}).author_display is None +def test_updated_by_flattens_to_scalar(): + assert ChangelogEntry.model_validate({"updatedBy": {"display": "X"}}).updated_by == "X" + assert ChangelogEntry.model_validate({"id": "1"}).updated_by is None diff --git a/tests/yandex/tracker/comments/test_cli.py b/tests/yandex/tracker/comments/test_cli.py index 6505fcd..8e19b7a 100644 --- a/tests/yandex/tracker/comments/test_cli.py +++ b/tests/yandex/tracker/comments/test_cli.py @@ -6,7 +6,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.tracker.yandex.net/v3" runner = CliRunner() diff --git a/tests/yandex/tracker/comments/test_client.py b/tests/yandex/tracker/comments/test_client.py index 4cb776d..2170512 100644 --- a/tests/yandex/tracker/comments/test_client.py +++ b/tests/yandex/tracker/comments/test_client.py @@ -27,7 +27,7 @@ def test_list_returns_commentlist(): ) out = _client().list("DE-1") assert isinstance(out, CommentList) - assert out.root[0].text == "hi" and out.root[0].created_by_display == "Сава" + assert out.root[0].text == "hi" and out.root[0].created_by == "Сава" @responses.activate diff --git a/tests/yandex/tracker/issues/test_cli.py b/tests/yandex/tracker/issues/test_cli.py index fdccc37..14a9dc8 100644 --- a/tests/yandex/tracker/issues/test_cli.py +++ b/tests/yandex/tracker/issues/test_cli.py @@ -6,7 +6,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.tracker.yandex.net/v3" runner = CliRunner() @@ -30,29 +30,7 @@ def test_get_dumps_issue_model(): assert res.exit_code == 0 out = json.loads(res.stdout) assert out["key"] == "DE-1" - assert out["type"] == {"key": "task"} - - -@responses.activate -def test_full_renders_raw_dict_as_json(): - responses.add( - responses.GET, f"{BASE}/issues/DE-1", json={"key": "DE-1", "extra": "field"}, status=200 - ) - res = runner.invoke(cli.app, ["--format", "json", "tracker", "issues", "full", "DE-1"]) - assert res.exit_code == 0 - assert json.loads(res.stdout) == {"key": "DE-1", "extra": "field"} - - -@responses.activate -def test_full_renders_raw_dict_as_yaml(): - responses.add( - responses.GET, f"{BASE}/issues/DE-1", json={"key": "DE-1", "extra": "field"}, status=200 - ) - res = runner.invoke(cli.app, ["--format", "yaml", "tracker", "issues", "full", "DE-1"]) - assert res.exit_code == 0 - import yaml - - assert yaml.safe_load(res.stdout) == {"key": "DE-1", "extra": "field"} + assert out["type"] == "task" @responses.activate diff --git a/tests/yandex/tracker/issues/test_client.py b/tests/yandex/tracker/issues/test_client.py index ead360a..c0dff8a 100644 --- a/tests/yandex/tracker/issues/test_client.py +++ b/tests/yandex/tracker/issues/test_client.py @@ -27,16 +27,7 @@ def test_get_deserializes_issue(): ) i = _client().get("DE-1") assert isinstance(i, Issue) - assert i.key == "DE-1" and i.type_key == "task" - - -@responses.activate -def test_get_raw_returns_dict(): - responses.add( - responses.GET, f"{BASE}/issues/DE-1", json={"key": "DE-1", "extra": "field"}, status=200 - ) - raw = _client().get_raw("DE-1") - assert raw == {"key": "DE-1", "extra": "field"} + assert i.key == "DE-1" and i.type == "task" @responses.activate diff --git a/tests/yandex/tracker/issues/test_mcp.py b/tests/yandex/tracker/issues/test_mcp.py index 6191887..3eb8a59 100644 --- a/tests/yandex/tracker/issues/test_mcp.py +++ b/tests/yandex/tracker/issues/test_mcp.py @@ -42,9 +42,7 @@ async def test_issues_list_tool_returns_rootmodel(creds): async def test_issue_tools_registered_read_only(): async with Client(issues_mcp.mcp) as client: tools = {t.name: t for t in await client.list_tools()} - assert {"issues_get", "issues_full", "issues_list", "issues_search", "issues_count"} <= set( - tools - ) + assert {"issues_get", "issues_list", "issues_search", "issues_count"} <= set(tools) assert tools["issues_get"].annotations.readOnlyHint is True @@ -70,20 +68,6 @@ async def test_issues_get_tool_empty_response_guard(creds): await client.call_tool("issues_get", {"key": "DE-1"}) -@responses.activate -async def test_issues_full_tool_returns_raw_dict(creds): - responses.add( - responses.GET, - f"{BASE}/issues/DE-1", - json={"key": "DE-1", "summary": "S", "extra": "kept"}, - status=200, - ) - async with Client(issues_mcp.mcp) as client: - result = await client.call_tool("issues_full", {"key": "DE-1"}) - assert result.data["key"] == "DE-1" - assert result.data["extra"] == "kept" - - @responses.activate async def test_issues_search_tool(creds): responses.add(responses.POST, f"{BASE}/issues/_search", json=[{"key": "DE-1"}], status=200) diff --git a/tests/yandex/tracker/issues/test_models.py b/tests/yandex/tracker/issues/test_models.py index ad2b330..5151606 100644 --- a/tests/yandex/tracker/issues/test_models.py +++ b/tests/yandex/tracker/issues/test_models.py @@ -1,9 +1,9 @@ -"""Property accessors on the Issue model — both the populated and the None branch.""" +"""Issue ref fields flatten to bare scalars (key/display extracted at parse time).""" from ycli.yandex.tracker.issues.models import Issue -def test_key_and_display_properties_populated(): +def test_ref_fields_flatten_to_scalars(): issue = Issue.model_validate( { "key": "DE-1", @@ -16,21 +16,21 @@ def test_key_and_display_properties_populated(): "assignee": {"display": "Сава"}, } ) - assert issue.type_key == "task" - assert issue.status_key == "open" - assert issue.priority_key == "normal" - assert issue.epic_key == "DE-100" - assert issue.parent_key == "DE-99" - assert issue.queue_key == "DE" - assert issue.assignee_display == "Сава" + assert issue.type == "task" + assert issue.status == "open" + assert issue.priority == "normal" + assert issue.epic == "DE-100" + assert issue.parent == "DE-99" + assert issue.queue == "DE" + assert issue.assignee == "Сава" -def test_key_and_display_properties_none(): +def test_ref_fields_default_to_none(): issue = Issue.model_validate({"key": "DE-1"}) - assert issue.type_key is None - assert issue.status_key is None - assert issue.priority_key is None - assert issue.epic_key is None - assert issue.parent_key is None - assert issue.queue_key is None - assert issue.assignee_display is None + assert issue.type is None + assert issue.status is None + assert issue.priority is None + assert issue.epic is None + assert issue.parent is None + assert issue.queue is None + assert issue.assignee is None diff --git a/tests/yandex/tracker/links/test_cli.py b/tests/yandex/tracker/links/test_cli.py index a487437..c95d324 100644 --- a/tests/yandex/tracker/links/test_cli.py +++ b/tests/yandex/tracker/links/test_cli.py @@ -6,7 +6,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.tracker.yandex.net/v3" runner = CliRunner() diff --git a/tests/yandex/tracker/links/test_client.py b/tests/yandex/tracker/links/test_client.py index 9992c50..c110924 100644 --- a/tests/yandex/tracker/links/test_client.py +++ b/tests/yandex/tracker/links/test_client.py @@ -33,7 +33,7 @@ def test_list_returns_linklist(): ) out = _client().list("DE-1") assert isinstance(out, LinkList) - assert out.root[0].type_id == "relates" and out.root[0].object_key == "DE-2" + assert out.root[0].type == "relates" and out.root[0].object_key == "DE-2" @responses.activate diff --git a/tests/yandex/tracker/links/test_models.py b/tests/yandex/tracker/links/test_models.py index 4f17c7e..d2e8058 100644 --- a/tests/yandex/tracker/links/test_models.py +++ b/tests/yandex/tracker/links/test_models.py @@ -11,13 +11,13 @@ def test_link_properties_populated(): "object": {"key": "DE-2", "display": "Other"}, } ) - assert link.type_id == "relates" + assert link.type == "relates" assert link.object_key == "DE-2" assert link.object_display == "Other" def test_link_properties_none(): link = Link.model_validate({"id": 7}) - assert link.type_id is None + assert link.type is None assert link.object_key is None assert link.object_display is None diff --git a/tests/yandex/tracker/priorities/test_cli.py b/tests/yandex/tracker/priorities/test_cli.py index 4e093b3..220b635 100644 --- a/tests/yandex/tracker/priorities/test_cli.py +++ b/tests/yandex/tracker/priorities/test_cli.py @@ -6,7 +6,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.tracker.yandex.net/v3" runner = CliRunner() diff --git a/tests/yandex/tracker/test_client.py b/tests/yandex/tracker/test_client.py index face25f..173d5aa 100644 --- a/tests/yandex/tracker/test_client.py +++ b/tests/yandex/tracker/test_client.py @@ -26,10 +26,10 @@ def test_composes_subclients_over_shared_authed_session(): @responses.activate def test_tracker_deps_factory_builds_from_env(monkeypatch): - """_deps.tracker_client() reads env and returns a working TrackerClient.""" + """dependencies.tracker_client() reads env and returns a working TrackerClient.""" monkeypatch.setenv("YANDEX_ID_OAUTH_TOKEN", "tok") monkeypatch.setenv("YANDEX_ID_ORGANIZATION_ID", "org") - from ycli.yandex.tracker._deps import tracker_client + from ycli.yandex.tracker.dependencies import tracker_client responses.add( responses.GET, "https://api.tracker.yandex.net/v3/priorities", json=[], status=200 diff --git a/tests/yandex/tracker/test_mcp.py b/tests/yandex/tracker/test_mcp.py index 850d3a8..5eeab49 100644 --- a/tests/yandex/tracker/test_mcp.py +++ b/tests/yandex/tracker/test_mcp.py @@ -1,4 +1,4 @@ -"""Tracker FastMCP domain server — 14 reads-only tools, namespaced _.""" +"""Tracker FastMCP domain server — 13 reads-only tools, namespaced _.""" import pytest import responses @@ -15,13 +15,12 @@ def creds(monkeypatch): monkeypatch.setenv("YANDEX_ID_ORGANIZATION_ID", "o") -async def test_all_fourteen_read_tools_registered(): +async def test_all_thirteen_read_tools_registered(): async with Client(tracker_mcp.mcp) as client: names = {t.name for t in await client.list_tools()} assert names == { "me_get", "issues_get", - "issues_full", "issues_list", "issues_search", "issues_count", diff --git a/tests/yandex/tracker/test_me.py b/tests/yandex/tracker/test_me.py index 01afaa3..d01f738 100644 --- a/tests/yandex/tracker/test_me.py +++ b/tests/yandex/tracker/test_me.py @@ -11,7 +11,7 @@ from fastmcp.exceptions import ToolError from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli from ycli.mcp import mcp as root_mcp from ycli.yandex.tracker.client import TrackerClient from ycli.yandex.tracker.me import mcp as me_mcp_module diff --git a/tests/yandex/tracker/test_shared.py b/tests/yandex/tracker/test_shared.py index 8553eb8..15dcade 100644 --- a/tests/yandex/tracker/test_shared.py +++ b/tests/yandex/tracker/test_shared.py @@ -1,9 +1,8 @@ -"""TDD for the tracker per-domain base + shared sub-models.""" +"""TDD for the tracker per-domain base.""" import requests -from ycli.yandex.tracker._base import TrackerResource -from ycli.yandex.tracker._models import _DisplayRef, _IdRef, _KeyRef +from ycli.yandex.tracker.base import TrackerResource class _Demo(TrackerResource): @@ -13,9 +12,3 @@ class _Demo(TrackerResource): def test_base_url_is_tracker_v3(): c = _Demo(session=requests.Session()) assert str(c.session.base_url).rstrip("/") == "https://api.tracker.yandex.net/v3" - - -def test_shared_refs_extract_scalar(): - assert _KeyRef.model_validate({"key": "task", "x": 1}).key == "task" - assert _IdRef.model_validate({"id": "relates"}).id == "relates" - assert _DisplayRef.model_validate({"display": "Сава"}).display == "Сава" diff --git a/tests/yandex/tracker/test_args.py b/tests/yandex/tracker/test_utils.py similarity index 81% rename from tests/yandex/tracker/test_args.py rename to tests/yandex/tracker/test_utils.py index 8ea909d..6eb5262 100644 --- a/tests/yandex/tracker/test_args.py +++ b/tests/yandex/tracker/test_utils.py @@ -1,9 +1,9 @@ -"""TDD for tracker CLI arg types — parse_fields JSON coercion.""" +"""TDD for tracker CLI helpers — parse_fields JSON coercion.""" import pytest import typer -from ycli.yandex.tracker._args import parse_fields +from ycli.yandex.tracker.utils import parse_fields def test_parse_fields_coerces_json_with_string_fallback(): diff --git a/tests/yandex/tracker/transitions/test_cli.py b/tests/yandex/tracker/transitions/test_cli.py index 117b26c..e5e09bb 100644 --- a/tests/yandex/tracker/transitions/test_cli.py +++ b/tests/yandex/tracker/transitions/test_cli.py @@ -6,7 +6,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.tracker.yandex.net/v3" runner = CliRunner() diff --git a/tests/yandex/tracker/worklog/test_cli.py b/tests/yandex/tracker/worklog/test_cli.py index d32e1c5..f82cb44 100644 --- a/tests/yandex/tracker/worklog/test_cli.py +++ b/tests/yandex/tracker/worklog/test_cli.py @@ -6,7 +6,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.tracker.yandex.net/v3" runner = CliRunner() diff --git a/tests/yandex/tracker/worklog/test_client.py b/tests/yandex/tracker/worklog/test_client.py index be5758a..d5401cd 100644 --- a/tests/yandex/tracker/worklog/test_client.py +++ b/tests/yandex/tracker/worklog/test_client.py @@ -21,4 +21,4 @@ def test_list_returns_workloglist(): ) out = WorklogClient(session=s).list("DE-1") assert isinstance(out, WorklogList) - assert out.root[0].duration == "PT2H" and out.root[0].author_display == "X" + assert out.root[0].duration == "PT2H" and out.root[0].created_by == "X" diff --git a/tests/yandex/wiki/attachments/test_cli.py b/tests/yandex/wiki/attachments/test_cli.py index f694a27..46d10ef 100644 --- a/tests/yandex/wiki/attachments/test_cli.py +++ b/tests/yandex/wiki/attachments/test_cli.py @@ -4,7 +4,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.wiki.yandex.net/v1" runner = CliRunner() diff --git a/tests/yandex/wiki/comments/test_cli.py b/tests/yandex/wiki/comments/test_cli.py index 785c306..09de948 100644 --- a/tests/yandex/wiki/comments/test_cli.py +++ b/tests/yandex/wiki/comments/test_cli.py @@ -4,7 +4,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.wiki.yandex.net/v1" runner = CliRunner() diff --git a/tests/yandex/wiki/comments/test_models.py b/tests/yandex/wiki/comments/test_models.py index 70aee28..631f8c0 100644 --- a/tests/yandex/wiki/comments/test_models.py +++ b/tests/yandex/wiki/comments/test_models.py @@ -1,8 +1,8 @@ -"""Property accessor on the wiki Comment model — populated and None branches.""" +"""Wiki Comment author flattens to a bare scalar — populated and None branches.""" from ycli.yandex.wiki.comments.models import Comment -def test_author_display_populated_and_none(): - assert Comment.model_validate({"author": {"display": "Сава"}}).author_display == "Сава" - assert Comment.model_validate({"content": "ok"}).author_display is None +def test_author_flattens_to_scalar(): + assert Comment.model_validate({"author": {"display": "Сава"}}).author == "Сава" + assert Comment.model_validate({"content": "ok"}).author is None diff --git a/tests/yandex/wiki/pages/test_cli.py b/tests/yandex/wiki/pages/test_cli.py index 0564c3d..0c180b8 100644 --- a/tests/yandex/wiki/pages/test_cli.py +++ b/tests/yandex/wiki/pages/test_cli.py @@ -6,7 +6,7 @@ import responses from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli BASE = "https://api.wiki.yandex.net/v1" runner = CliRunner() diff --git a/tests/yandex/wiki/test_client.py b/tests/yandex/wiki/test_client.py index 9f25745..f77e3a6 100644 --- a/tests/yandex/wiki/test_client.py +++ b/tests/yandex/wiki/test_client.py @@ -20,10 +20,10 @@ def test_composes_subclients_over_shared_authed_session(): @responses.activate def test_wiki_deps_factory_builds_from_env(monkeypatch): - """_deps.wiki_client() reads env and returns a working WikiClient.""" + """dependencies.wiki_client() reads env and returns a working WikiClient.""" monkeypatch.setenv("YANDEX_ID_OAUTH_TOKEN", "tok") monkeypatch.setenv("YANDEX_ID_ORGANIZATION_ID", "org") - from ycli.yandex.wiki._deps import wiki_client + from ycli.yandex.wiki.dependencies import wiki_client responses.add( responses.GET, diff --git a/tests/yandex/wiki/test_me.py b/tests/yandex/wiki/test_me.py index e5faafd..af852e2 100644 --- a/tests/yandex/wiki/test_me.py +++ b/tests/yandex/wiki/test_me.py @@ -6,7 +6,7 @@ from fastmcp.exceptions import ToolError from typer.testing import CliRunner -import ycli.cli as cli +import ycli.cli.app as cli from ycli.yandex.wiki.client import WikiClient from ycli.yandex.wiki.me import mcp as me_mcp_module from ycli.yandex.wiki.me.models import Me