diff --git a/AGENTS.md b/AGENTS.md index 9a6c84d..20ad020 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,8 @@ Each `CommandSpec.name` is globally unique and unchanged (routing key). The `lea - `src/fredq/api.py` -> public synchronous library surface (entity classes + module functions). - `src/fredq/frames.py` -> polars-backed Frame containers for bulk tabular payloads (library only). - `src/fredq/cli.py` -> argparse command tree and stdout/stderr behavior. +- `src/fredq/skills/content/` -> Agent Skills-standard skill (SKILL.md router + domain docs) teaching agents fredq's library and CLI, shipped as package data. +- `src/fredq/skills/_install.py` -> copy-only installer for the skill content (resolve_roots/install/uninstall/status) targeting every major agent's documented skill-discovery root. - `tests/` -> pytest tests mirroring `src/fredq/`. ## Rules — CLI layer @@ -54,6 +56,7 @@ Each `CommandSpec.name` is globally unique and unchanged (routing key). The `lea - Use `regex` instead of standard library `re` for regular expressions. - Never log or print the FRED API key. - Keep runtime dependencies narrow; do not add TUI, ORM, web framework, or rich formatting libraries. +- The skills command group is packaging/installer surface: human-readable output, outside both the raw-JSON contract and the no-discovery-commands rule. ## Rules — library layer - The library layer (`api.py`, `_core.py`, `_bridge.py`, `frames.py`, `models/`) parses and types FRED responses; the raw-JSON law above does not apply to it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2841c15..fddee51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,15 @@ group (see Removed). checkers see the library's real return types without stubs. - New optional extra: `pip install "fredq[pandas]"` (pandas + pyarrow) for `Observations.to_pandas()` / `.to_arrow()`. +- An installable agent skill (Agent Skills standard: `SKILL.md` plus four + markdown domains — observations, revisions, catalog, dataframes — with + corpus-dated sharp edges), shipped inside the wheel under `fredq.skills`. +- A `fredq skills` CLI group: `install`/`uninstall`/`list` with explicit + `--agent` targeting (`claude`/`codex`/`copilot`/`cursor`/`gemini`/`pi`, + comma-separable), `--project` for repository-level directories, and a + `--to PATH` escape hatch. Installs are copy-only, stamped with the + installing version (surfaced by `list` as current/stale), and ownership- + checked: a directory not created by fredq is never replaced or removed. ### Changed diff --git a/README.md b/README.md index 7b8d9c9..8831b2d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,27 @@ pip install "fredq[pandas]" A free FRED API key is required — see [Auth](#auth). +## Agent skill + +fredq ships an Agent Skills–standard skill (a `SKILL.md` package readable +by Claude Code, Codex CLI, Cursor, Gemini CLI, Copilot, Pi, and other +agents) that teaches coding agents both the library and the CLI: idiomatic +patterns, endpoint routing, and corpus-dated sharp edges. Install it into +an agent's skill directory by name: + +```powershell +fredq skills install --agent claude +``` + +Named targets: `claude`, `codex`, `copilot`, `cursor`, `gemini`, `pi` +(comma-separable). Add `--project` to install into the current repository's +agent directories instead of your user-level ones, or `--to PATH` for any +other skills root. Installs are plain copies — re-run `fredq skills +install` after upgrading fredq. `fredq skills list` shows every named +location with its installed version, and `fredq skills uninstall` removes +installs (it never touches a directory it does not own). See +`fredq skills --help`. + ## Library quickstart ```python diff --git a/cspell.json b/cspell.json index 762dc91..e146422 100644 --- a/cspell.json +++ b/cspell.json @@ -48,12 +48,16 @@ ], "words": [ "ACMR", + "asof", "autouse", + "backtest", + "backtesting", "backtests", "caplog", "capsys", "coro", "CPIAUCSL", + "dataframes", "datetimes", "DEXCAUS", "dogfood", @@ -80,7 +84,9 @@ "reprs", "reraises", "sdists", + "shapefile", "stlouisfed", + "twexb", "ungated", "unmodeled", "unpivot", diff --git a/pyproject.toml b/pyproject.toml index 09baa96..b387269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,6 +105,14 @@ reportUnknownMemberType = false reportUnknownArgumentType = false reportUnknownVariableType = false +[[tool.pyright.executionEnvironments]] +# Same rationale as tests/test_frames.py above: this file's +# to_pandas()/to_arrow() pinning also probes the optional extra. +root = "tests/test_skill_content.py" +reportMissingImports = false +reportUnusedImport = false +reportUnknownMemberType = false + [tool.pytest.ini_options] pythonpath = ["src", "."] testpaths = ["tests"] diff --git a/src/fredq/cli.py b/src/fredq/cli.py index 6b70189..9c9ebc8 100644 --- a/src/fredq/cli.py +++ b/src/fredq/cli.py @@ -25,6 +25,11 @@ enforce_cross_param_rules, parse_boolean, ) +from fredq.skills import AGENT_TARGETS, TargetReport +from fredq.skills import install as skills_install +from fredq.skills import resolve_roots as skills_resolve_roots +from fredq.skills import status as skills_status +from fredq.skills import uninstall as skills_uninstall if TYPE_CHECKING: from collections.abc import Sequence @@ -293,6 +298,106 @@ def _set_command_parser(parser: argparse.ArgumentParser, command: CommandSpec) - _add_parquet_negative_guards(parser) +# Directory name the installer copies the skill content under (see +# fredq.skills._install.SKILL_DIR_NAME); duplicated here as a literal rather +# than imported so the CLI's report-line formatting stays a pure string +# operation independent of the installer's internals. +_SKILL_DIR_NAME: Final[str] = "fredq" + + +def _add_skills_targeting_options(parser: argparse.ArgumentParser) -> None: + """Register ``--agent``/``--to``/``--project`` on an install/uninstall subparser.""" + + parser.add_argument( + "--agent", + dest="agent", + default=None, + metavar="NAME[,NAME...]", + help=( + f"Comma-separated named agent targets ({', '.join(sorted(AGENT_TARGETS))})." + ), + ) + parser.add_argument( + "--to", + dest="to", + type=Path, + default=None, + metavar="PATH", + help="Additional skills root to install into or remove from.", + ) + parser.add_argument( + "--project", + dest="project", + action="store_true", + default=False, + help="Use project-level roots (relative to the current directory).", + ) + + +def _add_skills_command_group( + subparsers: Any, # noqa: ANN401 +) -> argparse.ArgumentParser: + """Build the ``fredq skills {install,uninstall,list}`` subparser tree. + + Kept separate from the ``COMMANDS``-driven loop in + :func:`_build_parser_impl`: skills is packaging/installer surface (pure + filesystem, no FRED endpoint) rather than a modeled FRED command. + + Returns: + argparse.ArgumentParser: The ``skills`` group parser, so callers can + print its help when no subcommand is given (same convention as + the noun-group parsers). + """ + + skills_parser = subparsers.add_parser( + "skills", + help="Install, remove, or list the fredq agent skill.", + description=( + "Manage the fredq agent skill in agent skill directories. See " + "`fredq skills --help` for each operation." + ), + formatter_class=_HelpFormatter, + ) + skills_subparsers = skills_parser.add_subparsers( + dest="skills_action", metavar="SUBCOMMAND" + ) + + install_parser = skills_subparsers.add_parser( + "install", + help="Install the fredq agent skill into agent skill directories.", + description=( + "Copy the fredq agent skill into the named agent skill " + "directories, stamping the installed package version. Refuses " + "to overwrite a directory it does not own." + ), + formatter_class=_HelpFormatter, + ) + _add_skills_targeting_options(install_parser) + + uninstall_parser = skills_subparsers.add_parser( + "uninstall", + help="Remove the fredq agent skill from agent skill directories.", + description=( + "Remove the fredq agent skill from the named agent skill " + "directories. Refuses to remove a directory it does not own." + ), + formatter_class=_HelpFormatter, + ) + _add_skills_targeting_options(uninstall_parser) + + skills_subparsers.add_parser( + "list", + help="Show where the fredq agent skill is installed.", + description=( + "Show the fredq agent skill's install state across every named " + "agent, at both user and project scope." + ), + formatter_class=_HelpFormatter, + ) + + return skills_parser + + # Type alias used by _build_parser_impl to keep the return annotation concise. _GroupParsers = dict[str, argparse.ArgumentParser] @@ -369,6 +474,12 @@ def _build_parser_impl() -> tuple[argparse.ArgumentParser, _GroupParsers]: ) _set_command_parser(group_sub, command) + # Packaging/installer surface: not a FRED command, so it is not driven by + # COMMANDS. Registered into group_parsers under the same convention as the + # noun groups above so a bare `fredq skills` (no subcommand) prints its + # help via the existing "group without subcommand" fallback in main(). + group_parsers["skills"] = _add_skills_command_group(subparsers) + return parser, group_parsers @@ -552,6 +663,132 @@ def _dispatch_command( return 0 +def _skills_agents_from_namespace(args: argparse.Namespace) -> list[str]: + """Parse ``--agent`` into a name list, mirroring ``params._coerce_csv_param``. + + Empty comma-separated items (``"claude,,codex"``, or a bare ``","``) are + a hard usage error rather than silently dropped, matching the CSV + contract every other ``--`` CSV option follows. + + Returns: + list[str]: Agent names in the order given, or ``[]`` when ``--agent`` + was not supplied. + + Raises: + ValueError: If any comma-separated item is empty. + """ + + raw = (getattr(args, "agent", None) or "").strip() + if not raw: + return [] + items = [item.strip() for item in raw.split(",")] + if any(not item for item in items): + message = "--agent cannot contain empty comma-separated values" + raise ValueError(message) + return items + + +def _skills_roots_or_usage_error( + args: argparse.Namespace, err: TextIO +) -> list[Path] | None: + """Resolve ``--agent``/``--to``/``--project`` into skills roots. + + Returns: + list[Path] | None: The resolved roots, or ``None`` when a usage + error was already written to ``err`` (caller returns exit code + 2). + """ + + to = getattr(args, "to", None) + try: + agents = _skills_agents_from_namespace(args) + except ValueError as exc: + err.write(f"{exc}\n") + return None + if not agents and to is None: + err.write("one of --agent or --to is required\n") + return None + try: + return skills_resolve_roots(agents, project=args.project, to=to) + except ValueError as exc: + err.write(f"{exc}\n") + return None + + +def _skills_install_report_line(report: TargetReport) -> str: + skill_dir = report.root / _SKILL_DIR_NAME + if report.action == "refused": + return f"skipped (not the fredq skill): {skill_dir}" + return f"installed: {skill_dir} (fredq {report.detail})" + + +def _skills_uninstall_report_line(report: TargetReport) -> str: + skill_dir = report.root / _SKILL_DIR_NAME + if report.action == "refused": + return f"skipped (not the fredq skill): {skill_dir}" + if report.action == "removed": + return f"removed: {skill_dir}" + return f"absent: {skill_dir}" + + +def _skills_list_report_line(report: TargetReport) -> str: + """Format one status() report as an ``agent scope root state`` line. + + ``report.detail`` carries ``"agent scope"`` for an absent target, or + ``"agent scope version"`` for a current/stale one (see + ``fredq.skills.status``). + + Returns: + str: The formatted status line for one named target. + """ + + if report.action == "absent": + return f"{report.detail} {report.root} absent" + label, _, version = report.detail.rpartition(" ") + if report.action == "current": + return f"{label} {report.root} installed {version} (current)" + return ( + f"{label} {report.root} installed {version} (stale; current is {__version__})" + ) + + +def _dispatch_skills(args: argparse.Namespace, out: TextIO, err: TextIO) -> int: + """Run a ``fredq skills`` subcommand: pure filesystem, no FRED client. + + Called from :func:`main` before any client construction or API-key + resolution, so ``fredq skills list`` (and install/uninstall) work with no + ``FRED_API_KEY`` configured. + + Returns: + int: ``0`` on a clean run, ``1`` if any target refused (an existing + directory that is not the fredq skill), ``2`` on a usage error + (bad ``--agent`` value, or neither ``--agent`` nor ``--to``). + """ + + action = args.skills_action + if action == "list": + for report in skills_status(): + out.write(_skills_list_report_line(report)) + out.write("\n") + return 0 + + roots = _skills_roots_or_usage_error(args, err) + if roots is None: + return 2 + + if action == "install": + reports = skills_install(roots) + line_for = _skills_install_report_line + else: + reports = skills_uninstall(roots) + line_for = _skills_uninstall_report_line + + for report in reports: + out.write(line_for(report)) + out.write("\n") + return 1 if any(report.action == "refused" for report in reports) else 0 + + def _resolve_client( args: argparse.Namespace, client: _FredClientProtocol | None, @@ -635,8 +872,18 @@ def main( if args.verbose: logging.basicConfig(level=logging.DEBUG) - command_name = getattr(args, "command_name", None) top_command = getattr(args, "_top_command", None) + skills_action = getattr(args, "skills_action", None) + # Skills is packaging/installer surface (pure filesystem, no FRED + # endpoint): dispatch it before command_name resolution, parquet-pairing + # enforcement, and (crucially) _resolve_client — so it never constructs a + # FredClient or touches API-key resolution. A bare `fredq skills` (no + # skills_action) falls through to the ordinary "group without + # subcommand" handling below via group_parsers["skills"]. + if top_command == "skills" and skills_action is not None: + return _dispatch_skills(args, out, err) + + command_name = getattr(args, "command_name", None) # command_name is None when no leaf command was resolved: # - No argument at all → top_command is also None → root help. # - User typed a group name but omitted the subcommand → top_command is diff --git a/src/fredq/skills/__init__.py b/src/fredq/skills/__init__.py new file mode 100644 index 0000000..90cc80d --- /dev/null +++ b/src/fredq/skills/__init__.py @@ -0,0 +1,32 @@ +"""Agent Skills packaging for fredq: skill content plus its installer. + +``content/`` under this package holds the fredq Agent Skill: a +standards-shaped ``SKILL.md`` router plus domain docs (observations, +revisions, catalog, dataframes) that teach an LLM agent this library's +Python surface and CLI. The files are plain package data — hatchling +ships everything under ``src/fredq`` in the wheel — and are meant to be +read, not imported; nothing in this package requires fredq's other +runtime dependencies (httpx2, polars, pydantic) to be importable. +``fredq.skills._install`` copies that content tree into named agent +skill directories (see the ``fredq skills`` CLI group). +""" + +from __future__ import annotations + +from fredq.skills._install import ( + AGENT_TARGETS, + TargetReport, + install, + resolve_roots, + status, + uninstall, +) + +__all__ = [ + "AGENT_TARGETS", + "TargetReport", + "install", + "resolve_roots", + "status", + "uninstall", +] diff --git a/src/fredq/skills/_install.py b/src/fredq/skills/_install.py new file mode 100644 index 0000000..50ad7a3 --- /dev/null +++ b/src/fredq/skills/_install.py @@ -0,0 +1,168 @@ +"""Copy-only installer for the fredq agent skill (spec: agent-skills design).""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path + +import regex + +from fredq import __version__ + +SKILL_DIR_NAME = "fredq" +CONTENT_DIR = Path(__file__).parent / "content" + +AGENT_TARGETS: dict[str, tuple[str, str]] = { + # name -> (user-level root relative to home, project-level root relative + # to cwd). Each pair is the agent's DOCUMENTED skill-discovery location, + # not a name-derived guess: Codex discovers only `.agents/skills` roots + # (developers.openai.com/codex/skills), and Copilot's project-level + # discovery is `.github/skills` (its user level is `~/.copilot/skills`; + # a project `.copilot/skills` is not scanned — docs.github.com Copilot + # CLI "add skills"). Verified 2026-07-06 against agent documentation, + # see yoghurt ab12d4f. + "claude": (".claude/skills", ".claude/skills"), + "codex": (".agents/skills", ".agents/skills"), + "copilot": (".copilot/skills", ".github/skills"), + "cursor": (".cursor/skills", ".cursor/skills"), + "gemini": (".gemini/skills", ".gemini/skills"), + "pi": (".pi/agent/skills", ".pi/skills"), +} + + +@dataclass(frozen=True) +class TargetReport: + """Outcome of one install/uninstall/status operation on one skills root.""" + + root: Path + action: str # installed | removed | absent | refused | stale | current + detail: str = "" + + +def resolve_roots(agents: list[str], *, project: bool, to: Path | None) -> list[Path]: + """Turn --agent names (+ optional --to) into skills-root paths. + + Returns: + list[Path]: One resolved skills root per named agent, plus ``to`` + appended if given. + + Raises: + ValueError: For an unrecognized agent name. + """ + roots: list[Path] = [] + for name in agents: + try: + user_rel, project_rel = AGENT_TARGETS[name] + except KeyError: + known = ", ".join(sorted(AGENT_TARGETS)) + message = f"unknown agent {name!r} (known: {known})" + raise ValueError(message) from None + base = Path.cwd() / project_rel if project else Path.home() / user_rel + roots.append(base) + if to is not None: + roots.append(to) + return roots + + +def _installed_name(skill_dir: Path) -> str | None: + skill_md = skill_dir / "SKILL.md" + if not skill_md.is_file(): + return None + match = regex.search( + r"^name:\s*(\S+)", + skill_md.read_text(encoding="utf-8"), + flags=regex.MULTILINE, + ) + return match.group(1) if match else None + + +def _installed_version(skill_dir: Path) -> str | None: + skill_md = skill_dir / "SKILL.md" + if not skill_md.is_file(): + return None + match = regex.search( + r"^\s+version:\s*(\S+)", + skill_md.read_text(encoding="utf-8"), + flags=regex.MULTILINE, + ) + return match.group(1) if match else None + + +def _stamp_version(skill_md: Path) -> None: + text = skill_md.read_text(encoding="utf-8") + stamped = text.replace( + "\n---\n", f"\nmetadata:\n version: {__version__}\n---\n", 1 + ) + skill_md.write_text(stamped, encoding="utf-8", newline="\n") + + +def install(roots: list[Path]) -> list[TargetReport]: + """Copy the skill into each root, replacing only installs we own. + + Returns: + list[TargetReport]: One report per requested root. + """ + reports: list[TargetReport] = [] + for root in roots: + skill_dir = root / SKILL_DIR_NAME + if skill_dir.exists() and _installed_name(skill_dir) != SKILL_DIR_NAME: + reports.append( + TargetReport( + root, "refused", "existing directory is not the fredq skill" + ) + ) + continue + if skill_dir.exists(): + shutil.rmtree(skill_dir) + shutil.copytree(CONTENT_DIR, skill_dir) + _stamp_version(skill_dir / "SKILL.md") + reports.append(TargetReport(root, "installed", __version__)) + return reports + + +def uninstall(roots: list[Path]) -> list[TargetReport]: + """Remove the skill from each root; only dirs we own. + + Returns: + list[TargetReport]: One report per requested root. + """ + reports: list[TargetReport] = [] + for root in roots: + skill_dir = root / SKILL_DIR_NAME + if not skill_dir.exists(): + reports.append(TargetReport(root, "absent")) + elif _installed_name(skill_dir) != SKILL_DIR_NAME: + reports.append( + TargetReport( + root, "refused", "existing directory is not the fredq skill" + ) + ) + else: + shutil.rmtree(skill_dir) + reports.append(TargetReport(root, "removed")) + return reports + + +def status() -> list[TargetReport]: + """Walk every named target x {user, project} and report install state. + + Returns: + list[TargetReport]: One report per (agent, scope) pair. + """ + reports: list[TargetReport] = [] + for name in sorted(AGENT_TARGETS): + user_rel, project_rel = AGENT_TARGETS[name] + for scope, base in ( + ("user", Path.home() / user_rel), + ("project", Path.cwd() / project_rel), + ): + skill_dir = base / SKILL_DIR_NAME + label = f"{name} {scope}" + if _installed_name(skill_dir) != SKILL_DIR_NAME: + reports.append(TargetReport(base, "absent", label)) + continue + version = _installed_version(skill_dir) or "unknown" + action = "current" if version == __version__ else "stale" + reports.append(TargetReport(base, action, f"{label} {version}")) + return reports diff --git a/src/fredq/skills/content/SKILL.md b/src/fredq/skills/content/SKILL.md new file mode 100644 index 0000000..92352db --- /dev/null +++ b/src/fredq/skills/content/SKILL.md @@ -0,0 +1,96 @@ +--- +name: fredq +description: Fetch and analyze FRED (Federal Reserve Economic Data) from the Federal Reserve Bank of St. Louis — time series observations, ALFRED point-in-time vintages and revisions, and the categories/releases/sources/tags catalog — through the fredq Python library (typed pydantic models, polars-backed frames) or its raw-JSON CLI. Use when a task needs U.S. or international economic and financial time series, historical data revisions or point-in-time (as-of) analysis, series/category/release/source/tag discovery, or any FRED API access. +--- + +# fredq + +FRED economic data: observations, ALFRED vintages and revisions, and the +categories/releases/sources/tags catalog — as a typed Python library or a +raw-JSON CLI. + +## Quickstart + +```bash +pip install fredq +``` + +A free FRED API key is required — request one at + (no billing, no scopes, +just the key). Set it once and every call picks it up: + +```bash +export FRED_API_KEY=your-key-here +``` + +```python +import fredq + +obs = fredq.Series("DGS10").observations(observation_start="2024-01-01") +``` + +No install needed for one-off shell use: + +```bash +uvx fredq series observations DGS10 --observation-start 2024-01-01 +``` + +## Two surfaces, one vocabulary + +**Library primary:** `import fredq` — typed pydantic models, typed errors, +a polars-backed `Observations` frame for series data. + +**CLI secondary:** shell one-offs and no-dependency contexts +(`uvx fredq …`). Same command names as the library; flags mirror kwargs +mechanically: `--observation-start` ↔ `observation_start=`, `--units pch` +↔ `units="pch"`. The CLI prints FRED's raw wire JSON to stdout; the +library returns typed models (or a typed frame for observations) and +raises typed errors for the same call. + +Tag-name lists use FRED's own `;`-separated wire format and need shell +quoting: `"usa;quarterly"`. + +```bash +fredq series observations CPIAUCSL --units pch +``` + +```python +pch = fredq.Series("CPIAUCSL").observations(units="pch") +``` + +## Routing table + +| Task | Use | +| --- | --- | +| Data bound to one series, category, release, or source | `Series`/`Category`/`Release`/`Source` methods | +| Catalog-wide search or listing (no single ID to bind) | module-level functions (`search_series`, `releases`, `sources`, `tags`, ...) | +| Point-in-time / ALFRED vintages | `realtime_start`/`realtime_end` kwargs plus `Series.vintage_dates()` | +| An endpoint with no typed wrapper | `raw(command, **params)` | + +## Errors + +One contract everywhere: every FRED request-level failure (unknown id, bad +parameter value, bad API key) raises `fredq.FredApiError` (`.status_code`, +`.error_code`, `.error_message`) — the same shape for every 400 cause, by +design; there is deliberately no not-found subclass (see +[catalog/SHARP-EDGES.md](catalog/SHARP-EDGES.md)). Transport failures +without FRED's error body raise `FredRequestError`/`FredUnavailableError`; +caller mistakes caught before any request is sent raise +`FredClientUsageError`. A query with zero matches returns an **empty +result** — an empty `seriess`/`categories` list (or `observations: []`) is +a value FRED sent, never an exception. + +## Parameters + +Do not guess parameter names or values. Run `fredq --help` +— it is generated, complete, and authoritative for both surfaces via the +flag↔kwarg mirror rule. + +## Domain index + +| Domain | Read when… | +| --- | --- | +| [observations](observations/README.md) | fetching series data: windows, units transforms, frequency aggregation, missing values | +| [revisions](revisions/README.md) | working with ALFRED realtime windows, vintage dates, or revision analysis | +| [catalog](catalog/README.md) | searching series, walking the category tree, or browsing releases/sources/tags | +| [dataframes](dataframes/README.md) | converting, joining, or exporting `Observations` frames | diff --git a/src/fredq/skills/content/catalog/README.md b/src/fredq/skills/content/catalog/README.md new file mode 100644 index 0000000..26cab4e --- /dev/null +++ b/src/fredq/skills/content/catalog/README.md @@ -0,0 +1,90 @@ +# Catalog + +Finding a series, releases, sources, and tags — everything upstream of +pulling actual data. Every ID-taking call elsewhere in this skill +(`Series`, `Category`, `Release`, `Source`) expects an ID discovered here +first. + +## Searching for a series + +```python +hits = fredq.search_series("monetary") +series_id = hits.seriess[0].id +info = fredq.Series(series_id).info() +``` + +`search_series()` returns a `SeriesListResult`: `.seriess` is the ranked +hit list (each a full `SeriesInfo`), `.count` is FRED's total match count +(often far larger than `.seriess` — page with `limit`/`offset`). Chain a +hit's `id` into `Series(...)` for the rest of this skill's calls. + +CLI equivalent: + +```bash +fredq series search "monetary" --limit 10 +``` + +## Walking the category tree + +Category ID `0` is FRED's tree root — walk down from there, or jump +straight to a known category ID: + +```python +top_level = fredq.Category(0).children() +``` + +`children()`/`related()` both return a `CategoriesResult` (`.categories`, +a plain list — no pagination envelope). `Category(0).children()` lists +FRED's top-level subject areas (Money, Banking, & Finance; Prices; +National Accounts; ...). + +CLI equivalent: + +```bash +fredq category children 0 +``` + +## Releases and their sources + +```python +release = fredq.Release(53).info() +sources = fredq.Release(53).sources() +``` + +`Release.info()` returns a `ReleaseInfo` (name, `press_release` flag, +publisher link); `Release.sources()` returns a `ReleaseSourcesResult` +listing the publishing agencies (53 = GDP, from the Bureau of Economic +Analysis). + +CLI equivalent: + +```bash +fredq release show 53 +fredq release sources 53 +``` + +## Tags + +```python +matches = fredq.tag_series(["usa", "quarterly"]) +``` + +`tag_series()` returns a `SeriesListResult`; tag names are ANDed, not +ORed — this finds series carrying **both** `usa` and `quarterly`. + +CLI equivalent (semicolon-joined, matching FRED's wire format): + +```bash +fredq tag series "usa;quarterly" --limit 10 +``` + +## Parameters + +Full parameter lists, defaults, and examples live in `--help`, not here: + +```bash +fredq series search --help +fredq tag series --help +``` + +See [SHARP-EDGES.md](SHARP-EDGES.md) for proven pitfalls in this domain. diff --git a/src/fredq/skills/content/catalog/SHARP-EDGES.md b/src/fredq/skills/content/catalog/SHARP-EDGES.md new file mode 100644 index 0000000..3bd561f --- /dev/null +++ b/src/fredq/skills/content/catalog/SHARP-EDGES.md @@ -0,0 +1,120 @@ +# Sharp edges: catalog + +## Category ID `0` is the tree root, not "no category" + +**Severity:** low + +Wrong way: treating `Category(0)` as an empty or sentinel value, or +guessing that category `1` is the root because it's the smallest "real" +looking ID. + +Right way: `Category(0).children()` returns FRED's eight top-level +subject categories; walk the tree down from there. `Category(0).info()` +even names itself back as `"Categories"`, `parent_id=0` — the root is its +own parent. + +Evidence: 2026-07-05, corpus-confirmed (`category/root`, +`category-children/root`). + +## Zero search hits is a value, not an error + +**Severity:** medium + +Wrong way: wrapping `search_series()` (or `Category.children()`, +`Category.related()`, and friends) in a `try`/`except`, expecting an +exception when a query has no matches. + +Right way: check `result.seriess`/`result.categories` (or `.count`) — an +empty list at HTTP 200 is FRED's real answer for "nothing matched." +Nothing is raised. + +Evidence: 2026-07-05, corpus-confirmed (`series-search/EMPTY_RESULT`; +also `category-children/125_maybe-leaf`, `category-related/125_maybe-empty`). + +## Unknown tag names raise, they don't come back empty + +**Severity:** medium + +Wrong way: passing a typo'd or made-up tag name to `tag_series()`, +`tags()`, or `related_tags()` and expecting an empty result list back, +the way an unmatched search behaves. + +Right way: catch `FredApiError` — FRED rejects an unrecognized tag name +with HTTP 400 before it ever gets to filtering series. + +Evidence: 2026-07-05, corpus-confirmed (`tags-series/ERR_bogus-tag`). + +## A `tag_group_id` filter can reject an otherwise-fine tag name + +**Severity:** medium + +Wrong way: assuming a `tag_names` value that works alone will also work +once you add `tag_group_id`, or concluding a tag doesn't exist anywhere +just because one `tag_names`/`tag_group_id` combination was rejected. + +Right way: treat `tag_names` and `tag_group_id` as a joint query — an +unrecognized combination 400s with the identical error shape as a +genuinely nonexistent tag, so don't infer global nonexistence from one +rejected combination; retry without the group filter to check. + +Evidence: 2026-07-05, corpus-confirmed (`related-tags/monetary_group-geo`: +`tag_names=monetary` combined with `tag_group_id=geo` → HTTP 400). + +## Tag statistics drift between identical calls, seconds apart + +**Severity:** low + +Wrong way: asserting two back-to-back calls to `tags()`/`Series.tags()` +return byte-identical `popularity`/`series_count` for the same tag, or +caching those numbers indefinitely as if they were static metadata. + +Right way: treat `popularity` and `series_count` as eventually-consistent +counters FRED updates server-side in bursts, not stable identifiers — +diff-check with a tolerance, or refetch instead of trusting a cached +value. + +Evidence: 2026-07-07, dogfooding (two consecutive `tag list` calls in the +same session returned different counts for the same tag). + +## One `FredApiError` shape for every 400 cause — there is no not-found subclass + +**Severity:** high + +Wrong way: catching a hypothetical `SeriesNotFoundError`/ +`CategoryNotFoundError`, or branching on the exception's message text to +tell "does not exist" apart from "bad parameter value" or "bad API key." + +Right way: catch `fredq.FredApiError` and, if you need to branch, use +`.status_code`/`.error_code` — never message wording. FRED reports every +4xx cause with the identical `{"error_code": ..., "error_message": ...}` +shape, indistinguishably, and fredq deliberately does not add a +not-found subclass on top of it. + +```python +from fredq import FredApiError + +try: + fredq.Category(999999999).info() +except FredApiError as exc: + print(exc.status_code, exc.error_code, exc.error_message) +``` + +Evidence: 2026-07-05, corpus-confirmed (`category/ERR_invalid-id`, +`series/ERR_invalid-id`, `series/ERR_bad-api-key` all share one shape). + +## GeoFRED is gone — there is no regional/shapefile data + +**Severity:** medium + +Wrong way: calling (or asking an agent trained on older FRED +documentation to call) `fredq.raw("series-group", ...)` or any +`geofred`-family command for map or regional data. + +Right way: those commands do not exist in fredq. FRED itself sunset the +GeoFRED site in 2022, and fredq removed the entire `geofred` command +group in 0.4.0 — regional and shapefile data is not reachable through +this library at all, typed or `raw()`. + +Evidence: 2026-07-06, library design constraint (removed in Part 5 of the +library-api feature; see `tests/fixtures/corpus/README.md` in the fredq +repository). diff --git a/src/fredq/skills/content/dataframes/README.md b/src/fredq/skills/content/dataframes/README.md new file mode 100644 index 0000000..81659d4 --- /dev/null +++ b/src/fredq/skills/content/dataframes/README.md @@ -0,0 +1,69 @@ +# Dataframes + +The polars-backed `Frame`/`Observations` vocabulary that +`Series.observations()` returns: conversions, the `pandas` extra, Parquet +export, and joining series that don't share a frequency. + +## Conversion vocabulary + +```python +obs = fredq.Series("TWEXB").observations() +obs.to_polars() +obs.to_pandas() # requires: pip install fredq[pandas] +obs.to_arrow() # requires: pip install fredq[pandas] +obs.to_dicts() +obs.save_parquet("twexb.parquet") +``` + +`Observations` is a `Frame`: five conversions, one vocabulary, no +reshaping in between. `to_pandas()`/`to_arrow()` raise `ImportError` with +an actionable install message until `fredq[pandas]` is installed; the +other three work out of the box. + +CLI equivalent (writes a typed Parquet file instead of stdout JSON, +`series observations` only): + +```bash +fredq series observations TWEXB --format parquet --out twexb.parquet +``` + +## The response envelope + +Every field FRED sent alongside the observation rows survives as `.meta`, +not just the rows themselves: + +```python +obs = fredq.Series("DGS10").observations( + observation_start="2024-01-01", observation_end="2024-12-31" +) +meta = obs.meta +``` + +`meta` is an `ObservationsMeta`: the requested/echoed `units`, the +realtime window FRED answered with, `count`/`limit`/`offset`, and +`observation_start`/`observation_end` as typed `date`s — everything +you'd otherwise have to re-derive from the request you sent. + +## Joining series of different frequencies + +Two `Observations` frames are ordinary polars `DataFrame`s once you call +`.to_polars()` — join them like any other table: + +```python +ten_year = fredq.Series("DGS10").observations(frequency="m").to_polars() +cpi_pch = fredq.Series("CPIAUCSL").observations(units="pch").to_polars() +combined = ten_year.join(cpi_pch, on="date", how="full", suffix="_cpi") +``` + +Use `how="full"` (or `"left"` anchored on the more-current frame), not +`"inner"` — see [SHARP-EDGES.md](SHARP-EDGES.md). + +## Parameters + +Full parameter lists, defaults, and examples live in `--help`, not here: + +```bash +fredq series observations --help +``` + +See [SHARP-EDGES.md](SHARP-EDGES.md) for proven pitfalls in this domain. diff --git a/src/fredq/skills/content/dataframes/SHARP-EDGES.md b/src/fredq/skills/content/dataframes/SHARP-EDGES.md new file mode 100644 index 0000000..8e19fd6 --- /dev/null +++ b/src/fredq/skills/content/dataframes/SHARP-EDGES.md @@ -0,0 +1,26 @@ +# Sharp edges: dataframes + +## Inner joins silently drop revised-in rows + +**Severity:** medium + +`Observations.to_polars()` gives you a plain polars `DataFrame`, so +nothing stops an `how="inner"` join between two vintages of the same +series — but polars' `inner` semantics apply exactly, with no revision +awareness. + +Wrong way: joining an older-vintage `Observations` frame to a +newer-vintage one on `date` with `how="inner"` to compare them. Any +`date` that only exists in the newer vintage — a point FRED added or +revised into existence since the older fetch — has no matching row in the +older frame, so `inner` drops it from the result without a warning. + +Right way: use `how="full"` (or `"left"` anchored on whichever frame is +more current) and check for post-join nulls to see exactly what changed +between vintages, the same join shape shown in +[README.md](README.md#joining-series-of-different-frequencies). + +Evidence: 2026-07-07, live-measured — polars join semantics confirmed +against real revision data +(`series-observations/UNRATE_vintage-2001`, which itself carries two +distinct realtime spans for the same observation date). diff --git a/src/fredq/skills/content/observations/README.md b/src/fredq/skills/content/observations/README.md new file mode 100644 index 0000000..89a8b7f --- /dev/null +++ b/src/fredq/skills/content/observations/README.md @@ -0,0 +1,87 @@ +# Observations + +Fetching the actual data values for a FRED series: request windows, units +transforms, frequency aggregation, and how FRED spells "no value here." + +## Fetching observations + +```python +obs = fredq.Series("GNPCA").observations() +``` + +`observations()` returns an `Observations` frame (a polars-backed `Frame` +subclass): rows via `.to_polars()`/`.to_pandas()`/`.to_arrow()`/`.to_dicts()`, +plus the full response envelope as typed `.meta`. Omitted +`observation_start`/`observation_end` default to a series' entire history. + +CLI equivalent (raw JSON to stdout): + +```bash +fredq series observations GNPCA +``` + +## Units transforms + +FRED can transform the raw values server-side instead of you doing it +client-side — percent change, log, year-over-year, and more: + +```python +pch = fredq.Series("CPIAUCSL").observations( + units="pch", observation_start="2020-01-01", observation_end="2022-12-31" +) +``` + +`units` mirrors FRED's wire codes exactly (`"lin"` default, `"pch"`, +`"log"`, `"chg"`, ...) — see `--help` for the full set. + +CLI equivalent: + +```bash +fredq series observations CPIAUCSL --units pch --observation-start 2020-01-01 --observation-end 2022-12-31 +``` + +## Frequency aggregation + +`frequency` resamples the series to a coarser cadence server-side (e.g. +FRED's own daily 10-year yield, aggregated to monthly): + +```python +monthly = fredq.Series("DGS10").observations( + frequency="m", observation_start="2023-01-01", observation_end="2024-12-31" +) +``` + +CLI equivalent: + +```bash +fredq series observations DGS10 --frequency m --observation-start 2023-01-01 --observation-end 2024-12-31 +``` + +## Missing values + +```python +obs = fredq.Series("DEXCAUS").observations( + observation_start="2023-12-20", observation_end="2024-01-05" +) +``` + +Not every calendar day in the window has a value — bank holidays and +market closures come back as a row whose `value` is `None`, not `0.0` and +not `NaN`. See [SHARP-EDGES.md](SHARP-EDGES.md) for how fredq parses +FRED's missing-value sentinel. + +CLI equivalent: + +```bash +fredq series observations DEXCAUS --observation-start 2023-12-20 --observation-end 2024-01-05 +``` + +## Parameters + +Full parameter lists, defaults, and examples live in `--help`, not here: + +```bash +fredq series observations --help +``` + +See [SHARP-EDGES.md](SHARP-EDGES.md) for proven pitfalls in this domain. diff --git a/src/fredq/skills/content/observations/SHARP-EDGES.md b/src/fredq/skills/content/observations/SHARP-EDGES.md new file mode 100644 index 0000000..c97586e --- /dev/null +++ b/src/fredq/skills/content/observations/SHARP-EDGES.md @@ -0,0 +1,39 @@ +# Sharp edges: observations + +## FRED's missing-value sentinel is the string `"."`, never `NaN` + +**Severity:** medium + +FRED encodes a missing observation as the literal string `"."` on the +wire, for a market closure, a bank holiday, or a not-yet-published point +inside an otherwise-populated window. + +Wrong way: testing for a missing value with `value != value` (the classic +NaN check) or assuming every row parses to a Python `float`. + +Right way: `Observations`' `value` column is `float | None` — fredq +parses `"."` to `None` at build time (`fredq.frames.build_observations`). +Test with `is None`, and remember polars will show it as a `null` cell, +not `NaN`, in `.to_polars()`. + +Evidence: 2026-07-05, corpus-confirmed +(`series-observations/DEXCAUS_holidays`: 2023-12-25, Christmas, is a `"."` +row between two priced days). + +## Future observation windows return 200 + empty, not an error + +**Severity:** low + +Asking for a window that has not happened yet is not a client mistake as +far as FRED is concerned. + +Wrong way: wrapping an `observations()` call for a not-yet-elapsed date +range in a `try`/`except FredApiError`, expecting FRED to reject it. + +Right way: check `obs.meta.count` (or `obs.to_polars().height`) — `0` is +a valid, successful answer when the requested window is entirely in the +future; nothing raised, nothing to catch. + +Evidence: 2026-07-05, corpus-confirmed +(`series-observations/DGS10_future-window`, a 2030 window: HTTP 200, +`"observations": []`). diff --git a/src/fredq/skills/content/revisions/README.md b/src/fredq/skills/content/revisions/README.md new file mode 100644 index 0000000..3e38e40 --- /dev/null +++ b/src/fredq/skills/content/revisions/README.md @@ -0,0 +1,84 @@ +# Revisions + +ALFRED, the archival companion to FRED: the list of dates a series was +revised on, point-in-time ("as of a past date") observations, and how to +reason about revision size and cadence before treating fredq's numbers as +ground truth for a backtest. + +## Vintage dates + +```python +dates = fredq.Series("GNPCA").vintage_dates() +``` + +`vintage_dates()` returns a `VintageDatesResult`: `.vintage_dates` is the +full list of `datetime.date`s FRED published a revision on, `.count` is +how many. Real GNP (`GNPCA`) has been revised well over a hundred times +since ALFRED's archive begins. + +CLI equivalent: + +```bash +fredq series vintage-dates GNPCA +``` + +## Point-in-time observations + +Pass `realtime_start`/`realtime_end` to `observations()` to see the data +exactly as FRED would have answered on a past date. When a revision +happened inside the window you asked for, the same observation `date` can +come back more than once — once per realtime span: + +```python +asof = fredq.Series("UNRATE").observations( + realtime_start="2001-01-01", + realtime_end="2001-12-31", + observation_start="2000-01-01", + observation_end="2000-12-31", +) +``` + +Querying UNRATE's realtime year 2001 for calendar year 2000 returns 14 +rows for 12 months: March and April 2000 were each revised once inside +that window, so both the pre- and post-revision value show up as separate +rows with adjoining `realtime_start`/`realtime_end` spans. + +CLI equivalent: + +```bash +fredq series observations UNRATE --realtime-start 2001-01-01 --realtime-end 2001-12-31 --observation-start 2000-01-01 --observation-end 2000-12-31 +``` + +## Revision cadence is series-specific + +Don't assume every series revises the same way. GDP-family estimates go +through a handful of scheduled estimate rounds (advance, second, third) +rather than continuous smoothing: + +```python +gdp = fredq.Series("GDP").observations( + observation_start="2015-01-01", observation_end="2024-12-31" +) +``` + +See [SHARP-EDGES.md](SHARP-EDGES.md) for the measured cadence differences +across series like RSAFS, INDPRO, PAYEMS, GDP, UNRATE, and CPIAUCSL, and +for a look-ahead caveat worth knowing before backtesting on observation +dates alone. + +CLI equivalent: + +```bash +fredq series observations GDP --observation-start 2015-01-01 --observation-end 2024-12-31 +``` + +## Parameters + +Full parameter lists, defaults, and examples live in `--help`, not here: + +```bash +fredq series vintage-dates --help +fredq series observations --help +``` + +See [SHARP-EDGES.md](SHARP-EDGES.md) for proven pitfalls in this domain. diff --git a/src/fredq/skills/content/revisions/SHARP-EDGES.md b/src/fredq/skills/content/revisions/SHARP-EDGES.md new file mode 100644 index 0000000..c9a8a5b --- /dev/null +++ b/src/fredq/skills/content/revisions/SHARP-EDGES.md @@ -0,0 +1,44 @@ +# Sharp edges: revisions + +## Revision cadence is series-specific, not a library constant + +**Severity:** medium + +How much (and how often) a series revises varies enormously by series, +and fredq applies no smoothing or normalization across that variance. + +Wrong way: assuming every series carries the same amount of revision +risk, or hard-coding one "look back N vintages" window and applying it to +every series in a pipeline. + +Right way: check `Series(id).vintage_dates()` for the series you actually +depend on before assuming a backtest is revision-safe. Retail sales +(`RSAFS`) and industrial production (`INDPRO`) revise heavily and often; +nonfarm payrolls (`PAYEMS`) revises roughly its prior two months on every +monthly release; GDP-family series revise in a handful of scheduled +estimate rounds rather than continuously; the unemployment rate +(`UNRATE`) and CPI (`CPIAUCSL`) mostly hold steady between their own +annual (seasonal-adjustment) revision cycles. + +Evidence: 2026-07-07, live-measured (vintage-date histories compared +across RSAFS, INDPRO, PAYEMS, GDP, UNRATE, and CPIAUCSL). + +## An observation's `date` does not tell you when it became public + +**Severity:** medium + +The observation `date` is the period the value describes, not the day +FRED first published it — the two can be close together, not a month or +more apart, for some series. + +Wrong way: assuming an observation dated the 1st of month M was not +knowable until month M closed, and structuring a backtest so that a +signal is only used starting the following month. + +Right way: for employment-situation-cadence series like `UNRATE`, the +reading for month M is typically public within the first days of month +M+1 — check the actual publication date via the series' own release +(`Series.release()` then `Release(id).dates()`, or `release_calendar()`) +rather than inferring it from the observation `date`. + +Evidence: 2026-07-07, live-measured. diff --git a/tests/test_cli_skills.py b/tests/test_cli_skills.py new file mode 100644 index 0000000..b3a41e8 --- /dev/null +++ b/tests/test_cli_skills.py @@ -0,0 +1,515 @@ +"""Tests for the `fredq skills` CLI group (spec: agent-skills design, Task 3). + +Filesystem operations run against ``tmp_path``: ``pathlib.Path.home`` is +monkeypatched directly, and the CWD is redirected via ``monkeypatch.chdir``, +mirroring ``tests/test_skills_install.py``. No ``FRED_API_KEY`` is ever set +in this module: the `skills` group is packaging/installer surface that must +work with no API key configured (AGENTS.md CLI-layer rules). +""" + +from __future__ import annotations + +from io import StringIO +from pathlib import Path + +import pytest + +import fredq +from fredq.cli import main +from fredq.skills import AGENT_TARGETS + +ARGPARSE_ERROR = 2 + + +@pytest.fixture +def home_and_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]: + """Point Path.home()/Path.cwd() at isolated tmp_path subdirectories. + + Also clears FRED_API_KEY and points HOME/USERPROFILE at tmp_path so no + real ``~/.fredq/api_key`` can leak into a test, and skills commands can + be shown to run without any API key configured. + + Returns: + tuple[Path, Path]: The fake (home, cwd) directories. + """ + + home = tmp_path / "home" + cwd = tmp_path / "project" + home.mkdir() + cwd.mkdir() + monkeypatch.setattr(Path, "home", lambda: home) + monkeypatch.chdir(cwd) + monkeypatch.delenv("FRED_API_KEY", raising=False) + monkeypatch.delenv("FREDQ_DISABLE_KEY_FILE", raising=False) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + return home, cwd + + +def _write_foreign_skill(skill_dir: Path, *, name: str = "other") -> None: + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: not ours\n---\nbody\n", encoding="utf-8" + ) + + +# --------------------------------------------------------------------------- +# skills install +# --------------------------------------------------------------------------- + + +def test_skills_install_agent_comma_list_prints_one_line_per_target( + home_and_cwd: tuple[Path, Path], +) -> None: + """--agent claude,codex installs both and prints one installed line each.""" + + home, _cwd = home_and_cwd + stdout = StringIO() + stderr = StringIO() + + exit_code = main( + ["skills", "install", "--agent", "claude,codex"], + stdout=stdout, + stderr=stderr, + ) + + assert exit_code == 0 + assert not stderr.getvalue() + claude_root = home / ".claude" / "skills" + codex_root = home / ".agents" / "skills" + lines = stdout.getvalue().splitlines() + assert lines == [ + f"installed: {claude_root / 'fredq'} (fredq {fredq.__version__})", + f"installed: {codex_root / 'fredq'} (fredq {fredq.__version__})", + ] + assert (claude_root / "fredq" / "SKILL.md").is_file() + assert (codex_root / "fredq" / "SKILL.md").is_file() + + +def test_skills_install_requires_agent_or_to( + home_and_cwd: tuple[Path, Path], +) -> None: + """Neither --agent nor --to is a usage error mentioning both flags.""" + + del home_and_cwd + stdout = StringIO() + stderr = StringIO() + + exit_code = main(["skills", "install"], stdout=stdout, stderr=stderr) + + assert exit_code == ARGPARSE_ERROR + message = stderr.getvalue() + assert "--agent" in message + assert "--to" in message + + +def test_skills_install_bogus_agent_names_offender_and_known_agents( + home_and_cwd: tuple[Path, Path], +) -> None: + """--agent bogus exits 2 naming the bogus value and known agents.""" + + del home_and_cwd + stdout = StringIO() + stderr = StringIO() + + exit_code = main( + ["skills", "install", "--agent", "bogus"], stdout=stdout, stderr=stderr + ) + + assert exit_code == ARGPARSE_ERROR + message = stderr.getvalue() + assert "bogus" in message + for agent in AGENT_TARGETS: + assert agent in message + + +def test_skills_install_empty_comma_item_is_a_usage_error( + home_and_cwd: tuple[Path, Path], +) -> None: + """--agent "claude,,codex" exits 2 like a CSV param would, not silently. + + Mirrors ``params._coerce_csv_param``'s CSV contract: empty + comma-separated values are rejected, never dropped. + """ + + home, _cwd = home_and_cwd + stdout = StringIO() + stderr = StringIO() + + exit_code = main( + ["skills", "install", "--agent", "claude,,codex"], + stdout=stdout, + stderr=stderr, + ) + + assert exit_code == ARGPARSE_ERROR + assert "empty comma-separated" in stderr.getvalue() + assert not (home / ".claude" / "skills" / "fredq").exists() + + +def test_skills_install_only_commas_is_a_usage_error( + home_and_cwd: tuple[Path, Path], +) -> None: + """--agent "," exits 2 as empty items, not as a missing flag.""" + + del home_and_cwd + stdout = StringIO() + stderr = StringIO() + + exit_code = main( + ["skills", "install", "--agent", ","], stdout=stdout, stderr=stderr + ) + + assert exit_code == ARGPARSE_ERROR + assert "empty comma-separated" in stderr.getvalue() + + +def test_skills_install_project_switches_to_project_roots( + home_and_cwd: tuple[Path, Path], +) -> None: + """--project resolves pi's project-level .pi/skills root.""" + + _home, cwd = home_and_cwd + stdout = StringIO() + + exit_code = main( + ["skills", "install", "--agent", "pi", "--project"], + stdout=stdout, + ) + + assert exit_code == 0 + project_root = cwd / ".pi" / "skills" + assert stdout.getvalue().splitlines() == [ + f"installed: {project_root / 'fredq'} (fredq {fredq.__version__})", + ] + assert (project_root / "fredq" / "SKILL.md").is_file() + + +def test_skills_install_to_adds_a_root( + home_and_cwd: tuple[Path, Path], tmp_path: Path +) -> None: + """--to PATH adds an extra root alongside any --agent roots.""" + + del home_and_cwd + stdout = StringIO() + custom_root = tmp_path / "custom-root" + + exit_code = main( + ["skills", "install", "--to", str(custom_root)], + stdout=stdout, + ) + + assert exit_code == 0 + assert stdout.getvalue().splitlines() == [ + f"installed: {custom_root / 'fredq'} (fredq {fredq.__version__})", + ] + assert (custom_root / "fredq" / "SKILL.md").is_file() + + +def test_skills_install_refused_target_reports_and_continues( + home_and_cwd: tuple[Path, Path], tmp_path: Path +) -> None: + """A refused target prints skipped and other targets still install; exit 1.""" + + home, _cwd = home_and_cwd + foreign_root = tmp_path / "foreign" + _write_foreign_skill(foreign_root / "fredq", name="other") + stdout = StringIO() + + exit_code = main( + ["skills", "install", "--agent", "claude", "--to", str(foreign_root)], + stdout=stdout, + ) + + assert exit_code == 1 + claude_root = home / ".claude" / "skills" + lines = stdout.getvalue().splitlines() + assert f"installed: {claude_root / 'fredq'} (fredq {fredq.__version__})" in lines + assert f"skipped (not the fredq skill): {foreign_root / 'fredq'}" in lines + + +# --------------------------------------------------------------------------- +# skills uninstall +# --------------------------------------------------------------------------- + + +def test_skills_uninstall_removed_and_absent_lines( + home_and_cwd: tuple[Path, Path], +) -> None: + """Uninstall mirrors targeting and prints removed:/absent: lines.""" + + home, _cwd = home_and_cwd + main(["skills", "install", "--agent", "claude"]) + stdout = StringIO() + + exit_code = main( + ["skills", "uninstall", "--agent", "claude,codex"], + stdout=stdout, + ) + + assert exit_code == 0 + claude_root = home / ".claude" / "skills" + codex_root = home / ".agents" / "skills" + assert stdout.getvalue().splitlines() == [ + f"removed: {claude_root / 'fredq'}", + f"absent: {codex_root / 'fredq'}", + ] + assert not (claude_root / "fredq").exists() + + +def test_skills_uninstall_refused_target_reports_exit_1( + home_and_cwd: tuple[Path, Path], tmp_path: Path +) -> None: + """A foreign directory refuses uninstall and sets exit code 1.""" + + del home_and_cwd + foreign_root = tmp_path / "foreign" + _write_foreign_skill(foreign_root / "fredq", name="other") + stdout = StringIO() + + exit_code = main( + ["skills", "uninstall", "--to", str(foreign_root)], + stdout=stdout, + ) + + assert exit_code == 1 + assert ( + f"skipped (not the fredq skill): {foreign_root / 'fredq'}" in stdout.getvalue() + ) + assert (foreign_root / "fredq" / "SKILL.md").exists() + + +def test_skills_uninstall_requires_agent_or_to( + home_and_cwd: tuple[Path, Path], +) -> None: + """Uninstall also requires at least one of --agent/--to.""" + + del home_and_cwd + stdout = StringIO() + stderr = StringIO() + + exit_code = main(["skills", "uninstall"], stdout=stdout, stderr=stderr) + + assert exit_code == ARGPARSE_ERROR + message = stderr.getvalue() + assert "--agent" in message + assert "--to" in message + + +# --------------------------------------------------------------------------- +# skills list +# --------------------------------------------------------------------------- + + +def test_skills_list_reports_absent_for_every_named_target( + home_and_cwd: tuple[Path, Path], +) -> None: + """List prints one absent line per named target x scope on a clean machine.""" + + del home_and_cwd + stdout = StringIO() + + exit_code = main(["skills", "list"], stdout=stdout) + + assert exit_code == 0 + lines = stdout.getvalue().splitlines() + assert len(lines) == len(AGENT_TARGETS) * 2 + for agent in AGENT_TARGETS: + assert any( + line.startswith(f"{agent} user ") and line.endswith(" absent") + for line in lines + ) + assert any( + line.startswith(f"{agent} project ") and line.endswith(" absent") + for line in lines + ) + + +def test_skills_list_reports_installed_current_and_stale( + home_and_cwd: tuple[Path, Path], +) -> None: + """List reports installed (current) after a fresh install.""" + + home, _cwd = home_and_cwd + main(["skills", "install", "--agent", "claude"]) + stdout = StringIO() + + exit_code = main(["skills", "list"], stdout=stdout) + + assert exit_code == 0 + claude_root = home / ".claude" / "skills" + expected = f"claude user {claude_root} installed {fredq.__version__} (current)" + assert expected in stdout.getvalue().splitlines() + + +def test_skills_list_reports_stale_with_current_version( + home_and_cwd: tuple[Path, Path], +) -> None: + """List reports a stale install with a message naming the current version.""" + + home, _cwd = home_and_cwd + main(["skills", "install", "--agent", "claude"]) + skill_md = home / ".claude" / "skills" / "fredq" / "SKILL.md" + text = skill_md.read_text(encoding="utf-8") + doctored = text.replace(f"version: {fredq.__version__}", "version: 0.0.0-old") + skill_md.write_text(doctored, encoding="utf-8", newline="\n") + stdout = StringIO() + + exit_code = main(["skills", "list"], stdout=stdout) + + assert exit_code == 0 + claude_root = home / ".claude" / "skills" + expected = ( + f"claude user {claude_root} installed 0.0.0-old " + f"(stale; current is {fredq.__version__})" + ) + assert expected in stdout.getvalue().splitlines() + + +def test_skills_list_exits_zero_even_with_no_flags( + home_and_cwd: tuple[Path, Path], +) -> None: + """List takes no targeting flags and always exits 0.""" + + del home_and_cwd + exit_code = main(["skills", "list"]) + + assert exit_code == 0 + + +# --------------------------------------------------------------------------- +# No API key required — skills is packaging/installer surface +# --------------------------------------------------------------------------- + + +def test_skills_list_succeeds_with_no_api_key_and_no_key_file( + home_and_cwd: tuple[Path, Path], +) -> None: + """``fredq skills list`` works with FRED_API_KEY unset and no ~/.fredq/api_key. + + Dispatch happens before any FredClient construction / auth resolution, + so the skills group must never require an API key. + """ + + home, _cwd = home_and_cwd + assert not (home / ".fredq" / "api_key").exists() + stdout = StringIO() + stderr = StringIO() + + exit_code = main(["skills", "list"], stdout=stdout, stderr=stderr) + + assert exit_code == 0 + assert not stderr.getvalue() + assert stdout.getvalue() + + +def test_skills_install_succeeds_with_no_api_key_and_no_key_file( + home_and_cwd: tuple[Path, Path], +) -> None: + """``fredq skills install`` also works with no API key configured.""" + + home, _cwd = home_and_cwd + assert not (home / ".fredq" / "api_key").exists() + stdout = StringIO() + stderr = StringIO() + + exit_code = main( + ["skills", "install", "--agent", "claude"], stdout=stdout, stderr=stderr + ) + + assert exit_code == 0 + assert not stderr.getvalue() + + +# --------------------------------------------------------------------------- +# Help surface +# --------------------------------------------------------------------------- + + +def test_skills_group_appears_in_top_level_help( + capsys: pytest.CaptureFixture[str], +) -> None: + """Top-level help lists the skills command group.""" + + with pytest.raises(SystemExit) as exc_info: + main(["--help"]) + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "skills" in captured.out + + +def test_skills_bare_group_prints_group_help_and_exits_2( + home_and_cwd: tuple[Path, Path], +) -> None: + """`fredq skills` alone (no subcommand) prints skills group help; exit 2.""" + + del home_and_cwd + stdout = StringIO() + + exit_code = main(["skills"], stdout=stdout) + + assert exit_code == ARGPARSE_ERROR + out = stdout.getvalue() + assert "install" in out + assert "uninstall" in out + assert "list" in out + + +def test_skills_group_help_lists_pinned_subcommand_summaries( + capsys: pytest.CaptureFixture[str], +) -> None: + """Skills --help lists the three pinned per-AGENTS.md summary strings.""" + + with pytest.raises(SystemExit) as exc_info: + main(["skills", "--help"]) + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "Install the fredq agent skill into agent skill directories." in captured.out + assert "Remove the fredq agent skill from agent skill directories." in captured.out + assert "Show where the fredq agent skill is installed." in captured.out + + +def test_skills_install_help_documents_targeting_flags( + capsys: pytest.CaptureFixture[str], +) -> None: + """Skills install --help documents --agent/--to/--project.""" + + with pytest.raises(SystemExit) as exc_info: + main(["skills", "install", "--help"]) + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "--agent" in captured.out + assert "--to" in captured.out + assert "--project" in captured.out + + +def test_skills_uninstall_help_documents_targeting_flags( + capsys: pytest.CaptureFixture[str], +) -> None: + """Skills uninstall --help documents --agent/--to/--project.""" + + with pytest.raises(SystemExit) as exc_info: + main(["skills", "uninstall", "--help"]) + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "--agent" in captured.out + assert "--to" in captured.out + assert "--project" in captured.out + + +def test_skills_list_help_takes_no_targeting_flags( + capsys: pytest.CaptureFixture[str], +) -> None: + """Skills list --help documents no targeting flags (list walks everything).""" + + with pytest.raises(SystemExit) as exc_info: + main(["skills", "list", "--help"]) + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "--agent" not in captured.out + assert "--to" not in captured.out diff --git a/tests/test_packaging.py b/tests/test_packaging.py index a06c287..e8215ec 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -111,6 +111,18 @@ def test_wheel_contains_the_typed_library_surface( assert "fredq/frames.py" in names +@pytest.mark.timeout(_BUILD_TIMEOUT_SECONDS) +def test_wheel_contains_the_agent_skill_content( + built_distributions: _BuiltDistributions, +) -> None: + """The wheel ships the agent skill's router, a domain, and the installer.""" + + names = built_distributions.wheel_names + assert "fredq/skills/content/SKILL.md" in names + assert "fredq/skills/content/observations/README.md" in names + assert "fredq/skills/_install.py" in names + + @pytest.mark.timeout(_BUILD_TIMEOUT_SECONDS) def test_wheel_contains_a_spot_check_of_model_modules( built_distributions: _BuiltDistributions, diff --git a/tests/test_skill_content.py b/tests/test_skill_content.py new file mode 100644 index 0000000..5b2dc08 --- /dev/null +++ b/tests/test_skill_content.py @@ -0,0 +1,639 @@ +"""Integrity gates for the agent-skill content tree (Task 1: content + gates). + +Two families of tests live here: + +1. **Structural gates** (frontmatter, tree shape, domain index links, + relative-link resolution, sharp-edge shape) -- content-integrity checks + that fail on any regression to the tree this task authored. +2. **Snippet pinning** -- every fenced ``python`` block in the content + tree is pinned so a future edit cannot silently drift from working + code. One snippet is a byte-identical mirror of README.md's own + library-quickstart line; it is asserted verbatim-only. The rest each + get their own offline, corpus-backed behavioral test using the same + ``_get_client`` monkeypatch seam ``tests/test_core.py`` establishes. +""" + +from __future__ import annotations + +import re +from datetime import date +from pathlib import Path +from typing import Final + +import pytest + +import fredq +import fredq._core as core +from fredq.exceptions import FredApiError, FredRequestError + +CONTENT: Final[Path] = ( + Path(__file__).parent.parent / "src" / "fredq" / "skills" / "content" +) +DOMAINS: Final[list[str]] = ["catalog", "dataframes", "observations", "revisions"] +_MAX_DESCRIPTION_LENGTH: Final[int] = 1024 + +_CORPUS_ROOT: Final[Path] = Path(__file__).parent / "fixtures" / "corpus" +_README: Final[Path] = Path(__file__).parent.parent / "README.md" + + +def _frontmatter() -> dict[str, str]: + """Parse SKILL.md's YAML frontmatter into a flat dict. + + Returns: + dict[str, str]: Frontmatter field name -> value. + """ + + text = (CONTENT / "SKILL.md").read_text(encoding="utf-8") + match = re.match(r"---\n(.*?)\n---\n", text, flags=re.DOTALL) + assert match, "SKILL.md must open with YAML frontmatter" + fields: dict[str, str] = {} + for line in match.group(1).splitlines(): + key, sep, value = line.partition(":") + if sep: + fields[key.strip()] = value.strip() + return fields + + +def test_frontmatter_name_matches_skill_directory_contract() -> None: + """The standard requires name == installed directory name (fredq).""" + + assert _frontmatter()["name"] == "fredq" + + +def test_frontmatter_description_within_standard_limit() -> None: + """The description must be non-empty and within the standard's limit.""" + + description = _frontmatter()["description"] + assert description + assert len(description) <= _MAX_DESCRIPTION_LENGTH + + +def test_content_tree_has_exactly_the_spec_files() -> None: + """One SKILL.md + README/SHARP-EDGES per domain; nothing else.""" + + files = sorted( + p.relative_to(CONTENT).as_posix() for p in CONTENT.rglob("*") if p.is_file() + ) + expected = sorted( + ["SKILL.md"] + + [f"{d}/README.md" for d in DOMAINS] + + [f"{d}/SHARP-EDGES.md" for d in DOMAINS] + ) + assert files == expected + + +def test_every_domain_is_linked_from_skill_md() -> None: + """Every domain directory has a link from SKILL.md's domain index.""" + + body = (CONTENT / "SKILL.md").read_text(encoding="utf-8") + for domain in DOMAINS: + assert f"{domain}/README.md" in body, f"SKILL.md must index {domain}" + + +@pytest.mark.parametrize( + "md", sorted(CONTENT.rglob("*.md")), ids=lambda p: p.name + "/" + p.parent.name +) +def test_relative_links_resolve(md: Path) -> None: + """Every relative link in a content markdown file points to a real file.""" + + body = md.read_text(encoding="utf-8") + for target in re.findall(r"\]\((?!https?://|#)([^)]+?)(?:#[^)]*)?\)", body): + assert (md.parent / target).exists(), f"{md.name}: dead link {target}" + + +@pytest.mark.parametrize("domain", DOMAINS) +def test_sharp_edges_entries_follow_the_fixed_shape(domain: str) -> None: + """Every `## ` entry carries a Severity line and an Evidence line.""" + + body = (CONTENT / domain / "SHARP-EDGES.md").read_text(encoding="utf-8") + entries = re.split(r"\n## ", body)[1:] + assert entries, f"{domain}: SHARP-EDGES.md has no entries" + for entry in entries: + title = entry.splitlines()[0] + assert re.search(r"\*\*Severity:\*\* (high|medium|low)", entry), ( + domain, + title, + ) + assert "Evidence:" in entry, (domain, title) + + +# --------------------------------------------------------------------------- +# Snippet pinning +# +# Every fenced ``python`` block across SKILL.md + the four domains' README +# and SHARP-EDGES files is accounted for below: either as a README-mirror +# verbatim-only assertion, or as its own offline, corpus-backed behavioral +# test. Two commands (``series-search``/``series`` in the catalog +# search-then-info example, ``release``/``release-sources`` in the catalog +# release example) are chained in one snippet, so those two tests use a +# path-keyed fake client instead of a single canned body. +# --------------------------------------------------------------------------- + + +def _corpus_text(relative_path: str) -> str: + """Read a corpus fixture body as text. + + Returns: + str: The raw fixture file contents. + """ + + return (_CORPUS_ROOT / relative_path).read_text(encoding="utf-8") + + +class _FakeClient: + """Minimal stand-in for FredClient that returns one canned body always. + + Mirrors ``tests/test_core.py``'s ``_StubClient``: the established seam + for pinning library-API calls offline against a corpus fixture, reused + here for the skill content snippets. + """ + + def __init__(self, body: str) -> None: + """Store the canned response body.""" + + self.body = body + + async def get( + self, + path: str, + params: dict[str, object], + *, + base_url: str | None = None, + ) -> str: + """Return the canned body regardless of the request. + + Returns: + str: The canned response body. + """ + + del path, params, base_url + return self.body + + async def aclose(self) -> None: + """No-op close.""" + + +class _PathFakeClient: + """Stand-in that answers a different canned body per request path. + + For snippets that chain two different commands in one example (e.g. + search, then look up the top hit): each command has a distinct + ``CommandSpec.path``, so a single canned body cannot serve both calls. + """ + + def __init__(self, bodies: dict[str, str]) -> None: + """Store the path -> canned body mapping.""" + + self.bodies = bodies + + async def get( + self, + path: str, + params: dict[str, object], + *, + base_url: str | None = None, + ) -> str: + """Return the canned body registered for ``path``. + + Returns: + str: The canned response body for this path. + """ + + del params, base_url + return self.bodies[path] + + async def aclose(self) -> None: + """No-op close.""" + + +class _ErrorFakeClient: + """Stand-in whose ``get()`` raises, mirroring a real FRED HTTP rejection.""" + + def __init__(self, status_code: int, body: str) -> None: + """Store the canned error status and body.""" + + self.status_code = status_code + self.body = body + + async def get( + self, + path: str, + params: dict[str, object], + *, + base_url: str | None = None, + ) -> str: + """Raise a FredRequestError carrying the canned error body. + + Raises: + FredRequestError: Always -- the fixture is a real FRED 4xx + capture, so the caller's error-handling path is what's + under test, not a successful response. + """ + + del params, base_url + message_url = f"https://api.stlouisfed.org{path}" + raise FredRequestError(self.status_code, message_url, body=self.body) + + async def aclose(self) -> None: + """No-op close.""" + + +def _install_fake(monkeypatch: pytest.MonkeyPatch, body: str) -> None: + """Patch the core client seam with a fake returning ``body`` for every call.""" + + monkeypatch.setattr(core, "_get_client", lambda: _FakeClient(body)) + + +def _install_path_fake(monkeypatch: pytest.MonkeyPatch, bodies: dict[str, str]) -> None: + """Patch the core client seam with a per-path fake.""" + + monkeypatch.setattr(core, "_get_client", lambda: _PathFakeClient(bodies)) + + +def _install_error_fake( + monkeypatch: pytest.MonkeyPatch, status_code: int, body: str +) -> None: + """Patch the core client seam with a fake that raises an HTTP error.""" + + monkeypatch.setattr( + core, "_get_client", lambda: _ErrorFakeClient(status_code, body) + ) + + +def _assert_in_content(domain: str | None, filename: str, snippet: str) -> None: + """Assert ``snippet`` appears verbatim in a content file.""" + + path = CONTENT / domain / filename if domain else CONTENT / filename + body = path.read_text(encoding="utf-8") + assert snippet in body, f"{path}: expected snippet not found verbatim" + + +# --- SKILL.md (2) ------------------------------------------------------ + + +def test_skill_md_quickstart_snippet_mirrors_readme() -> None: + """SKILL.md's quickstart line is a byte-identical mirror of README.md's. + + Verbatim-only: the call itself is exercised behaviorally by every + other test below that drives ``Series(...).observations()`` against a + corpus fixture, so only the mirror needs pinning here. + """ + + snippet = 'obs = fredq.Series("DGS10").observations(observation_start="2024-01-01")' + assert snippet in _README.read_text(encoding="utf-8") + _assert_in_content(None, "SKILL.md", snippet) + + +def test_skill_md_two_surfaces_snippet(monkeypatch: pytest.MonkeyPatch) -> None: + """SKILL.md's two-surfaces example: ``Series(...).observations(units="pch")``.""" + + snippet = 'pch = fredq.Series("CPIAUCSL").observations(units="pch")' + _assert_in_content(None, "SKILL.md", snippet) + + _install_fake(monkeypatch, _corpus_text("series-observations/CPIAUCSL_pch.json")) + pch = fredq.Series("CPIAUCSL").observations(units="pch") + assert pch.meta.units == "pch" + assert pch.to_polars().height == 36 # noqa: PLR2004 - corpus-pinned row count + + +# --- observations/README.md (4) ----------------------------------------- + + +def test_observations_readme_fetching(monkeypatch: pytest.MonkeyPatch) -> None: + """observations/README.md: the plain ``Series(...).observations()`` call.""" + + snippet = 'obs = fredq.Series("GNPCA").observations()' + _assert_in_content("observations", "README.md", snippet) + + _install_fake(monkeypatch, _corpus_text("series-observations/GNPCA.json")) + obs = fredq.Series("GNPCA").observations() + assert obs.to_polars().height == 97 # noqa: PLR2004 - corpus-pinned row count + + +def test_observations_readme_units_transform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """observations/README.md: the ``units="pch"`` transform example.""" + + snippet = ( + 'pch = fredq.Series("CPIAUCSL").observations(\n' + ' units="pch", observation_start="2020-01-01", ' + 'observation_end="2022-12-31"\n' + ")" + ) + _assert_in_content("observations", "README.md", snippet) + + _install_fake(monkeypatch, _corpus_text("series-observations/CPIAUCSL_pch.json")) + pch = fredq.Series("CPIAUCSL").observations( + units="pch", observation_start="2020-01-01", observation_end="2022-12-31" + ) + assert pch.meta.units == "pch" + + +def test_observations_readme_frequency_aggregation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """observations/README.md: the ``frequency="m"`` aggregation example.""" + + snippet = ( + 'monthly = fredq.Series("DGS10").observations(\n' + ' frequency="m", observation_start="2023-01-01", ' + 'observation_end="2024-12-31"\n' + ")" + ) + _assert_in_content("observations", "README.md", snippet) + + _install_fake(monkeypatch, _corpus_text("series-observations/DGS10_freq-m.json")) + monthly = fredq.Series("DGS10").observations( + frequency="m", observation_start="2023-01-01", observation_end="2024-12-31" + ) + assert monthly.to_polars().height == 24 # noqa: PLR2004 - corpus-pinned count + + +def test_observations_readme_missing_values(monkeypatch: pytest.MonkeyPatch) -> None: + """observations/README.md: the DEXCAUS holiday-gap example.""" + + snippet = ( + 'obs = fredq.Series("DEXCAUS").observations(\n' + ' observation_start="2023-12-20", observation_end="2024-01-05"\n' + ")" + ) + _assert_in_content("observations", "README.md", snippet) + + _install_fake( + monkeypatch, _corpus_text("series-observations/DEXCAUS_holidays.json") + ) + obs = fredq.Series("DEXCAUS").observations( + observation_start="2023-12-20", observation_end="2024-01-05" + ) + assert any(row["value"] is None for row in obs.to_dicts()) + + +# --- revisions/README.md (3) --------------------------------------------- + + +def test_revisions_readme_vintage_dates(monkeypatch: pytest.MonkeyPatch) -> None: + """revisions/README.md: ``Series(...).vintage_dates()``.""" + + snippet = 'dates = fredq.Series("GNPCA").vintage_dates()' + _assert_in_content("revisions", "README.md", snippet) + + _install_fake(monkeypatch, _corpus_text("series-vintagedates/GNPCA.json")) + dates = fredq.Series("GNPCA").vintage_dates() + assert dates.count == 188 # noqa: PLR2004 - corpus-pinned + assert len(dates.vintage_dates) == 188 # noqa: PLR2004 - corpus-pinned + + +def test_revisions_readme_point_in_time_observations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """revisions/README.md: the UNRATE 2001-realtime-window example.""" + + snippet = ( + 'asof = fredq.Series("UNRATE").observations(\n' + ' realtime_start="2001-01-01",\n' + ' realtime_end="2001-12-31",\n' + ' observation_start="2000-01-01",\n' + ' observation_end="2000-12-31",\n' + ")" + ) + _assert_in_content("revisions", "README.md", snippet) + + _install_fake( + monkeypatch, _corpus_text("series-observations/UNRATE_vintage-2001.json") + ) + asof = fredq.Series("UNRATE").observations( + realtime_start="2001-01-01", + realtime_end="2001-12-31", + observation_start="2000-01-01", + observation_end="2000-12-31", + ) + df = asof.to_polars() + assert df.height == 14 # noqa: PLR2004 - corpus-pinned row count + assert (df["date"] == date(2000, 3, 1)).sum() == 2 # noqa: PLR2004 - two vintages + + +def test_revisions_readme_revision_cadence(monkeypatch: pytest.MonkeyPatch) -> None: + """revisions/README.md: the GDP quarterly-estimate-rounds example.""" + + snippet = ( + 'gdp = fredq.Series("GDP").observations(\n' + ' observation_start="2015-01-01", observation_end="2024-12-31"\n' + ")" + ) + _assert_in_content("revisions", "README.md", snippet) + + _install_fake(monkeypatch, _corpus_text("series-observations/GDP_quarterly.json")) + gdp = fredq.Series("GDP").observations( + observation_start="2015-01-01", observation_end="2024-12-31" + ) + assert gdp.to_polars().height == 40 # noqa: PLR2004 - corpus-pinned row count + + +# --- catalog/README.md (4) ------------------------------------------------- + + +def test_catalog_readme_search_then_info(monkeypatch: pytest.MonkeyPatch) -> None: + """catalog/README.md: search, then look up the top hit's info. + + Two different commands (``series-search`` then ``series``), so a + single canned body cannot serve both -- a path-keyed fake answers + each. Fixture note: no corpus capture exists for the literal top + "monetary" hit's own ``series show``; ``series/GNPCA.json`` is a + structurally identical series-show response used as a stand-in for + the second call. The claim under test is that the two-step + search-then-info flow routes both calls and both responses validate, + not that GNPCA is genuinely the top "monetary" hit. + """ + + snippets = [ + 'hits = fredq.search_series("monetary")', + "series_id = hits.seriess[0].id", + "info = fredq.Series(series_id).info()", + ] + for snippet in snippets: + _assert_in_content("catalog", "README.md", snippet) + + _install_path_fake( + monkeypatch, + { + "/fred/series/search": _corpus_text("series-search/monetary.json"), + "/fred/series": _corpus_text("series/GNPCA.json"), + }, + ) + hits = fredq.search_series("monetary") + series_id = hits.seriess[0].id + info = fredq.Series(series_id).info() + assert series_id + assert info.title + + +def test_catalog_readme_category_tree(monkeypatch: pytest.MonkeyPatch) -> None: + """catalog/README.md: ``Category(0).children()``.""" + + snippet = "top_level = fredq.Category(0).children()" + _assert_in_content("catalog", "README.md", snippet) + + _install_fake(monkeypatch, _corpus_text("category-children/root.json")) + top_level = fredq.Category(0).children() + assert len(top_level.categories) == 8 # noqa: PLR2004 - corpus-pinned + + +def test_catalog_readme_release_and_sources(monkeypatch: pytest.MonkeyPatch) -> None: + """catalog/README.md: ``Release(53).info()`` then ``Release(53).sources()``. + + Two different commands (``release`` then ``release-sources``); a + path-keyed fake answers each with its own real capture. + """ + + snippets = [ + "release = fredq.Release(53).info()", + "sources = fredq.Release(53).sources()", + ] + for snippet in snippets: + _assert_in_content("catalog", "README.md", snippet) + + _install_path_fake( + monkeypatch, + { + "/fred/release": _corpus_text("release/53.json"), + "/fred/release/sources": _corpus_text("release-sources/53.json"), + }, + ) + release = fredq.Release(53).info() + sources = fredq.Release(53).sources() + assert release.name == "Gross Domestic Product" + assert sources.sources + + +def test_catalog_readme_tag_series(monkeypatch: pytest.MonkeyPatch) -> None: + """catalog/README.md: ``tag_series(["usa", "quarterly"])``.""" + + snippet = 'matches = fredq.tag_series(["usa", "quarterly"])' + _assert_in_content("catalog", "README.md", snippet) + + _install_fake(monkeypatch, _corpus_text("tags-series/usa-quarterly.json")) + matches = fredq.tag_series(["usa", "quarterly"]) + assert matches.count == 64576 # noqa: PLR2004 - corpus-pinned + + +# --- catalog/SHARP-EDGES.md (1) --------------------------------------------- + + +def test_catalog_sharp_edges_one_error_shape_snippet( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """catalog/SHARP-EDGES.md's ``FredApiError`` catch-and-branch example.""" + + snippet = ( + "from fredq import FredApiError\n" + "\n" + "try:\n" + " fredq.Category(999999999).info()\n" + "except FredApiError as exc:\n" + " print(exc.status_code, exc.error_code, exc.error_message)" + ) + _assert_in_content("catalog", "SHARP-EDGES.md", snippet) + + _install_error_fake(monkeypatch, 400, _corpus_text("category/ERR_invalid-id.json")) + with pytest.raises(FredApiError) as exc_info: + fredq.Category(999999999).info() + assert exc_info.value.status_code == 400 # noqa: PLR2004 - corpus-pinned + assert exc_info.value.error_code == 400 # noqa: PLR2004 - corpus-pinned + assert "does not exist" in exc_info.value.error_message + + +# --- dataframes/README.md (3) ----------------------------------------------- + + +def test_dataframes_readme_conversion_vocabulary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """dataframes/README.md: the five ``Frame`` conversion methods. + + ``to_pandas()``/``to_arrow()`` need the optional ``pandas`` extra; + mirrors ``tests/test_frames.py``'s own try/except-ImportError + convention (never skip -- the ImportError path IS the behavior under + test in the base env). ``save_parquet()`` is asserted verbatim only + here (it writes a file; the call shape is what's documented, not disk + I/O), matching this module's other file-write-free pinning style. + """ + + for snippet in ( + 'obs = fredq.Series("TWEXB").observations()', + "obs.to_polars()", + "obs.to_pandas() # requires: pip install fredq[pandas]", + "obs.to_arrow() # requires: pip install fredq[pandas]", + "obs.to_dicts()", + 'obs.save_parquet("twexb.parquet")', + ): + _assert_in_content("dataframes", "README.md", snippet) + + _install_fake(monkeypatch, _corpus_text("series-observations/TWEXB.json")) + obs = fredq.Series("TWEXB").observations() + assert obs.to_polars().height == 1305 # noqa: PLR2004 - corpus-pinned + assert obs.to_dicts() + + try: + import pandas # noqa: F401, ICN001, PLC0415 + except ImportError: + with pytest.raises(ImportError, match=r"fredq\[pandas\]"): + obs.to_pandas() + else: + assert obs.to_pandas().shape[0] == 1305 # noqa: PLR2004 - corpus-pinned + + try: + import pyarrow # noqa: F401, ICN001, PLC0415 + except ImportError: + with pytest.raises(ImportError, match=r"fredq\[pandas\]"): + obs.to_arrow() + else: + assert obs.to_arrow().num_rows == 1305 # noqa: PLR2004 - corpus-pinned + + +def test_dataframes_readme_meta_envelope(monkeypatch: pytest.MonkeyPatch) -> None: + """dataframes/README.md: the ``.meta`` envelope example.""" + + snippets = [ + ( + 'obs = fredq.Series("DGS10").observations(\n' + ' observation_start="2024-01-01", observation_end="2024-12-31"\n' + ")" + ), + "meta = obs.meta", + ] + for snippet in snippets: + _assert_in_content("dataframes", "README.md", snippet) + + _install_fake(monkeypatch, _corpus_text("series-observations/DGS10_2024.json")) + obs = fredq.Series("DGS10").observations( + observation_start="2024-01-01", observation_end="2024-12-31" + ) + meta = obs.meta + assert meta.units == "lin" + + +def test_dataframes_readme_joining_frequencies( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """dataframes/README.md: joining two ``Observations`` frames on ``date``. + + Both calls hit the same command (``series-observations``), so one + canned body serves both -- the claim under test is that the join call + succeeds against real polars/date dtypes, not that DGS10 and CPIAUCSL + genuinely share these exact dates. + """ + + snippets = [ + 'ten_year = fredq.Series("DGS10").observations(frequency="m").to_polars()', + 'cpi_pch = fredq.Series("CPIAUCSL").observations(units="pch").to_polars()', + 'combined = ten_year.join(cpi_pch, on="date", how="full", suffix="_cpi")', + ] + for snippet in snippets: + _assert_in_content("dataframes", "README.md", snippet) + + _install_fake(monkeypatch, _corpus_text("series-observations/DGS10_freq-m.json")) + ten_year = fredq.Series("DGS10").observations(frequency="m").to_polars() + cpi_pch = fredq.Series("CPIAUCSL").observations(units="pch").to_polars() + combined = ten_year.join(cpi_pch, on="date", how="full", suffix="_cpi") + assert combined.height == 24 # noqa: PLR2004 - corpus-pinned row count diff --git a/tests/test_skills_install.py b/tests/test_skills_install.py new file mode 100644 index 0000000..5f1b7e4 --- /dev/null +++ b/tests/test_skills_install.py @@ -0,0 +1,493 @@ +"""Tests for the copy-only agent-skill installer (spec: agent-skills design). + +All filesystem operations run against ``tmp_path``: ``pathlib.Path.home`` is +monkeypatched directly, and the CWD is redirected via ``monkeypatch.chdir`` +(rather than patching ``Path.cwd``, which ``chdir`` makes unnecessary and +which is fragile to patch correctly across platforms). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import regex + +import fredq +from fredq.skills import ( + AGENT_TARGETS, + TargetReport, + install, + resolve_roots, + status, + uninstall, +) + +CONTENT = Path(__file__).parent.parent / "src" / "fredq" / "skills" / "content" +DOMAIN_FILES = [ + "catalog/README.md", + "catalog/SHARP-EDGES.md", + "dataframes/README.md", + "dataframes/SHARP-EDGES.md", + "observations/README.md", + "observations/SHARP-EDGES.md", + "revisions/README.md", + "revisions/SHARP-EDGES.md", +] + + +@pytest.fixture +def home_and_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]: + """Point Path.home()/Path.cwd() at isolated tmp_path subdirectories. + + Returns: + tuple[Path, Path]: The fake (home, cwd) directories. + """ + + home = tmp_path / "home" + cwd = tmp_path / "project" + home.mkdir() + cwd.mkdir() + monkeypatch.setattr(Path, "home", lambda: home) + monkeypatch.chdir(cwd) + return home, cwd + + +def _write_foreign_skill(skill_dir: Path, *, name: str = "other") -> None: + """Write a SKILL.md with a different frontmatter name, to test ownership checks.""" + + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: not ours\n---\nbody\n", encoding="utf-8" + ) + + +# --------------------------------------------------------------------------- +# resolve_roots +# --------------------------------------------------------------------------- + + +def test_resolve_roots_user_level_maps_every_named_agent( + home_and_cwd: tuple[Path, Path], +) -> None: + """Every named agent maps to its exact user-level root.""" + + home, _cwd = home_and_cwd + expected = { + "claude": home / ".claude" / "skills", + "codex": home / ".agents" / "skills", + "copilot": home / ".copilot" / "skills", + "cursor": home / ".cursor" / "skills", + "gemini": home / ".gemini" / "skills", + "pi": home / ".pi" / "agent" / "skills", + } + for agent, root in expected.items(): + assert resolve_roots([agent], project=False, to=None) == [root] + + +def test_resolve_roots_project_level_maps_every_named_agent( + home_and_cwd: tuple[Path, Path], +) -> None: + """project=True maps every named agent to its project-level root (CWD-relative).""" + + _home, cwd = home_and_cwd + expected = { + "claude": cwd / ".claude" / "skills", + "codex": cwd / ".agents" / "skills", + "copilot": cwd / ".github" / "skills", + "cursor": cwd / ".cursor" / "skills", + "gemini": cwd / ".gemini" / "skills", + "pi": cwd / ".pi" / "skills", + } + for agent, root in expected.items(): + assert resolve_roots([agent], project=True, to=None) == [root] + + +def test_resolve_roots_pi_asymmetry_is_pinned_explicitly( + home_and_cwd: tuple[Path, Path], +) -> None: + """Pi's user root nests under agent/; its project root does not.""" + + home, cwd = home_and_cwd + assert resolve_roots(["pi"], project=False, to=None) == [ + home / ".pi" / "agent" / "skills" + ] + assert resolve_roots(["pi"], project=True, to=None) == [cwd / ".pi" / "skills"] + + +def test_resolve_roots_codex_and_copilot_documented_paths_are_pinned( + home_and_cwd: tuple[Path, Path], +) -> None: + """Codex and Copilot map to their DOCUMENTED discovery roots. + + Codex discovers only ``.agents/skills`` roots (user and project); a + ``.codex/skills`` install would never be found. Copilot's user root is + ``~/.copilot/skills`` but its project-level discovery is + ``.github/skills`` — a project ``.copilot/skills`` is not scanned. + Verified against both agents' documentation 2026-07-06 (yoghurt fix + ab12d4f, PR #25 review finding). + """ + + home, cwd = home_and_cwd + assert resolve_roots(["codex"], project=False, to=None) == [ + home / ".agents" / "skills" + ] + assert resolve_roots(["codex"], project=True, to=None) == [ + cwd / ".agents" / "skills" + ] + assert resolve_roots(["copilot"], project=False, to=None) == [ + home / ".copilot" / "skills" + ] + assert resolve_roots(["copilot"], project=True, to=None) == [ + cwd / ".github" / "skills" + ] + + +def test_resolve_roots_unknown_agent_raises_value_error_naming_offender_and_known( + home_and_cwd: tuple[Path, Path], +) -> None: + """An unknown agent raises ValueError naming the offender and known agents.""" + + del home_and_cwd + with pytest.raises(ValueError, match="bogus") as exc_info: + resolve_roots(["bogus"], project=False, to=None) + message = str(exc_info.value) + for agent in AGENT_TARGETS: + assert agent in message + + +def test_resolve_roots_to_appends_extra_root( + home_and_cwd: tuple[Path, Path], tmp_path: Path +) -> None: + """to= appends the extra root after the named-agent roots.""" + + home, _cwd = home_and_cwd + extra = tmp_path / "custom-root" + roots = resolve_roots(["claude"], project=False, to=extra) + assert roots == [home / ".claude" / "skills", extra] + + +def test_resolve_roots_empty_agents_and_no_to_returns_empty_list( + home_and_cwd: tuple[Path, Path], +) -> None: + """Empty agents + to=None returns []; the CLI enforces at-least-one, not us.""" + + del home_and_cwd + assert resolve_roots([], project=False, to=None) == [] + + +def test_resolve_roots_multiple_agents_in_order( + home_and_cwd: tuple[Path, Path], +) -> None: + """Multiple agent names resolve to roots in the same order they were given.""" + + home, _cwd = home_and_cwd + roots = resolve_roots(["claude", "codex"], project=False, to=None) + assert roots == [home / ".claude" / "skills", home / ".agents" / "skills"] + + +# --------------------------------------------------------------------------- +# install +# --------------------------------------------------------------------------- + + +def test_install_creates_missing_root_with_parents(tmp_path: Path) -> None: + """install() creates a missing root, including missing parent directories.""" + + root = tmp_path / "does" / "not" / "exist" / "yet" + assert not root.exists() + + reports = install([root]) + + assert root.is_dir() + assert reports == [TargetReport(root, "installed", fredq.__version__)] + + +def test_install_copies_the_full_content_tree(tmp_path: Path) -> None: + """install() copies SKILL.md and all 8 domain files into the target root.""" + + root = tmp_path / "root" + + install([root]) + + installed = root / "fredq" + assert (installed / "SKILL.md").is_file() + for rel in DOMAIN_FILES: + assert (installed / rel).is_file(), f"missing {rel}" + + +def test_install_stamps_version_inside_frontmatter_source_stays_unstamped( + tmp_path: Path, +) -> None: + """install() stamps metadata.version inside the frontmatter block only.""" + + root = tmp_path / "root" + + install([root]) + + installed_text = (root / "fredq" / "SKILL.md").read_text(encoding="utf-8") + match = regex.match(r"---\n(.*?)\n---\n", installed_text, flags=regex.DOTALL) + assert match, "installed SKILL.md must still open with frontmatter" + frontmatter = match.group(1) + assert f"metadata:\n version: {fredq.__version__}" in frontmatter + + # The stamp must land INSIDE the frontmatter block, i.e. before the + # closing '---' -- not appended after it as body content. + closing_index = installed_text.index("\n---\n", 4) + stamp_index = installed_text.index("metadata:") + assert stamp_index < closing_index + + source_text = (CONTENT / "SKILL.md").read_text(encoding="utf-8") + assert "metadata:" not in source_text + assert "version:" not in source_text + + +def test_install_reinstall_over_prior_install_succeeds_and_drops_stale_extra( + tmp_path: Path, +) -> None: + """Reinstalling over a prior install succeeds and removes old extra files.""" + + root = tmp_path / "root" + install([root]) + + stray = root / "fredq" / "leftover-from-old-version.md" + stray.write_text("stale", encoding="utf-8") + assert stray.exists() + + reports = install([root]) + + assert reports == [TargetReport(root, "installed", fredq.__version__)] + assert not stray.exists() + assert (root / "fredq" / "SKILL.md").is_file() + + +def test_install_refuses_foreign_dir_with_mismatched_skill_md_name( + tmp_path: Path, +) -> None: + """A foreign dir with a SKILL.md naming a different skill is refused, unremoved.""" + + root = tmp_path / "root" + _write_foreign_skill(root / "fredq", name="other") + + reports = install([root]) + + assert len(reports) == 1 + assert reports[0].root == root + assert reports[0].action == "refused" + # Refused, not deleted. + assert ( + (root / "fredq" / "SKILL.md") + .read_text(encoding="utf-8") + .startswith("---\nname: other") + ) + + +def test_install_refuses_foreign_dir_with_no_skill_md(tmp_path: Path) -> None: + """A foreign dir with no SKILL.md at all is refused, not deleted.""" + + root = tmp_path / "root" + foreign_dir = root / "fredq" + foreign_dir.mkdir(parents=True) + (foreign_dir / "some-other-file.txt").write_text("hello", encoding="utf-8") + + reports = install([root]) + + assert len(reports) == 1 + assert reports[0].action == "refused" + # Refused, not deleted. + assert (foreign_dir / "some-other-file.txt").is_file() + assert not (foreign_dir / "SKILL.md").exists() + + +def test_install_refused_target_does_not_block_other_targets(tmp_path: Path) -> None: + """A refused target is reported but other targets still install successfully.""" + + foreign_root = tmp_path / "foreign" + good_root = tmp_path / "good" + _write_foreign_skill(foreign_root / "fredq", name="other") + + reports = install([foreign_root, good_root]) + + by_root = {report.root: report for report in reports} + assert by_root[foreign_root].action == "refused" + assert by_root[good_root].action == "installed" + assert (good_root / "fredq" / "SKILL.md").is_file() + # Foreign dir at the refused target remains untouched. + assert ( + (foreign_root / "fredq" / "SKILL.md") + .read_text(encoding="utf-8") + .startswith("---\nname: other") + ) + + +# --------------------------------------------------------------------------- +# uninstall +# --------------------------------------------------------------------------- + + +def test_uninstall_removes_owned_install(tmp_path: Path) -> None: + """uninstall() removes an install it owns.""" + + root = tmp_path / "root" + install([root]) + assert (root / "fredq").exists() + + reports = uninstall([root]) + + assert reports == [TargetReport(root, "removed")] + assert not (root / "fredq").exists() + + +def test_uninstall_reports_absent_when_missing(tmp_path: Path) -> None: + """uninstall() reports absent when there is nothing installed at the root.""" + + root = tmp_path / "root" + + reports = uninstall([root]) + + assert reports == [TargetReport(root, "absent")] + + +def test_uninstall_refuses_foreign_dir_which_remains_present(tmp_path: Path) -> None: + """uninstall() refuses a foreign dir, which remains present afterward.""" + + root = tmp_path / "root" + _write_foreign_skill(root / "fredq", name="other") + + reports = uninstall([root]) + + assert len(reports) == 1 + assert reports[0].action == "refused" + assert (root / "fredq" / "SKILL.md").exists() + + +# --------------------------------------------------------------------------- +# status +# --------------------------------------------------------------------------- + + +def test_status_reports_absent_for_empty_machine( + home_and_cwd: tuple[Path, Path], +) -> None: + """status() reports absent for every named target x scope on an empty machine.""" + + del home_and_cwd + reports = status() + + assert reports, "status() should cover every named target x scope" + assert all(report.action == "absent" for report in reports) + # Covers user AND project scopes for every named agent. + assert len(reports) == len(AGENT_TARGETS) * 2 + + +def test_status_reports_current_after_install( + home_and_cwd: tuple[Path, Path], +) -> None: + """status() reports current for a target just installed at the running version.""" + + home, _cwd = home_and_cwd + claude_user_root = home / ".claude" / "skills" + install([claude_user_root]) + + reports = status() + + matching = [ + report + for report in reports + if report.root == claude_user_root and report.action != "absent" + ] + assert len(matching) == 1 + assert matching[0].action == "current" + assert fredq.__version__ in matching[0].detail + + +def test_status_reports_stale_after_doctoring_stamped_version( + home_and_cwd: tuple[Path, Path], +) -> None: + """status() reports stale after the stamped version is edited to something else.""" + + home, _cwd = home_and_cwd + claude_user_root = home / ".claude" / "skills" + install([claude_user_root]) + + skill_md = claude_user_root / "fredq" / "SKILL.md" + text = skill_md.read_text(encoding="utf-8") + doctored = text.replace(f"version: {fredq.__version__}", "version: 0.0.0-doctored") + assert doctored != text + skill_md.write_text(doctored, encoding="utf-8", newline="\n") + + reports = status() + + matching = [report for report in reports if report.root == claude_user_root] + assert len(matching) == 1 + assert matching[0].action == "stale" + assert "0.0.0-doctored" in matching[0].detail + + +def test_status_covers_user_and_project_scopes_for_project_install( + home_and_cwd: tuple[Path, Path], +) -> None: + """status() finds a project-scope install as well as user-scope ones.""" + + _home, cwd = home_and_cwd + codex_project_root = cwd / ".agents" / "skills" + install([codex_project_root]) + + reports = status() + + installed = [report for report in reports if report.action != "absent"] + assert len(installed) == 1 + assert installed[0].root == codex_project_root + assert installed[0].action == "current" + + +def test_uninstall_refused_target_does_not_block_other_targets( + tmp_path: Path, +) -> None: + """uninstall() keeps processing targets after refusing a foreign dir.""" + + foreign_root = tmp_path / "foreign" + owned_root = tmp_path / "owned" + _write_foreign_skill(foreign_root / "fredq", name="other") + install([owned_root]) + + reports = uninstall([foreign_root, owned_root]) + + assert [report.action for report in reports] == ["refused", "removed"] + assert (foreign_root / "fredq" / "SKILL.md").exists() + assert not (owned_root / "fredq").exists() + + +def test_status_does_not_track_to_locations( + home_and_cwd: tuple[Path, Path], +) -> None: + """Installs at --to roots stay invisible to status() (spec: not tracked).""" + + home, _cwd = home_and_cwd + to_root = home / "custom-skills" + install(resolve_roots([], project=False, to=to_root)) + assert (to_root / "fredq" / "SKILL.md").exists() + + reports = status() + + assert all(report.action == "absent" for report in reports) + assert to_root not in {report.root for report in reports} + + +def test_status_reports_foreign_dir_as_absent_by_design( + home_and_cwd: tuple[Path, Path], +) -> None: + """A foreign dir at a named target reads as absent in status(). + + Deliberate: status answers "is the fredq skill installed here?" — for + a foreign directory the honest answer is no. The naming collision + surfaces as a refusal at install/uninstall time, when it is actionable + (pinned by the refusal tests above). + """ + + home, _cwd = home_and_cwd + _write_foreign_skill(home / ".gemini" / "skills" / "fredq", name="other") + + reports = status() + + assert all(report.action == "absent" for report in reports)