cwms-tools 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. cwms_tools/__init__.py +12 -0
  2. cwms_tools/cli/__init__.py +1 -0
  3. cwms_tools/cli/app.py +127 -0
  4. cwms_tools/cli/commands/__init__.py +1 -0
  5. cwms_tools/cli/commands/config.py +73 -0
  6. cwms_tools/cli/commands/env.py +62 -0
  7. cwms_tools/cli/commands/fingerprint.py +35 -0
  8. cwms_tools/cli/commands/mcp.py +129 -0
  9. cwms_tools/cli/commands/place.py +232 -0
  10. cwms_tools/cli/commands/publisher.py +52 -0
  11. cwms_tools/cli/commands/region.py +100 -0
  12. cwms_tools/cli/commands/schema.py +157 -0
  13. cwms_tools/cli/commands/value.py +228 -0
  14. cwms_tools/cli/commands/whoami.py +30 -0
  15. cwms_tools/cli/exit_codes.py +45 -0
  16. cwms_tools/cli/render.py +97 -0
  17. cwms_tools/core/__init__.py +1 -0
  18. cwms_tools/core/_workarounds.py +29 -0
  19. cwms_tools/core/cache.py +188 -0
  20. cwms_tools/core/catalog.py +448 -0
  21. cwms_tools/core/concurrency.py +85 -0
  22. cwms_tools/core/errors.py +193 -0
  23. cwms_tools/core/fingerprint.py +84 -0
  24. cwms_tools/core/geo.py +80 -0
  25. cwms_tools/core/levels.py +338 -0
  26. cwms_tools/core/locations.py +108 -0
  27. cwms_tools/core/models.py +414 -0
  28. cwms_tools/core/offices.py +119 -0
  29. cwms_tools/core/overview.py +178 -0
  30. cwms_tools/core/places.py +526 -0
  31. cwms_tools/core/projects.py +182 -0
  32. cwms_tools/core/publishers.py +190 -0
  33. cwms_tools/core/publishers_index.py +198 -0
  34. cwms_tools/core/session.py +195 -0
  35. cwms_tools/core/timeseries.py +212 -0
  36. cwms_tools/core/values.py +243 -0
  37. cwms_tools/data/__init__.py +1 -0
  38. cwms_tools/data/cwms-overview.md +925 -0
  39. cwms_tools/mcp/__init__.py +1 -0
  40. cwms_tools/mcp/fastmcp_capabilities.py +60 -0
  41. cwms_tools/mcp/resources.py +200 -0
  42. cwms_tools/mcp/server.py +249 -0
  43. cwms_tools/mcp/tools.py +489 -0
  44. cwms_tools/py.typed +0 -0
  45. cwms_tools-0.1.0.dist-info/METADATA +266 -0
  46. cwms_tools-0.1.0.dist-info/RECORD +49 -0
  47. cwms_tools-0.1.0.dist-info/WHEEL +4 -0
  48. cwms_tools-0.1.0.dist-info/entry_points.txt +3 -0
  49. cwms_tools-0.1.0.dist-info/licenses/LICENSE +21 -0
cwms_tools/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """cwms-tools — agent-friendly tools for the USACE CWMS Data API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("cwms-tools")
9
+ except PackageNotFoundError: # pragma: no cover
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ """Typer CLI adapter over the core."""
cwms_tools/cli/app.py ADDED
@@ -0,0 +1,127 @@
1
+ """Main Typer application. Subcommands are registered in `commands/`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from cwms_tools import __version__
10
+ from cwms_tools.cli.commands import config as config_cmd
11
+ from cwms_tools.cli.commands import env as env_cmd
12
+ from cwms_tools.cli.commands import fingerprint as fingerprint_cmd
13
+ from cwms_tools.cli.commands import mcp as mcp_cmd
14
+ from cwms_tools.cli.commands import place as place_cmd
15
+ from cwms_tools.cli.commands import publisher as publisher_cmd
16
+ from cwms_tools.cli.commands import region as region_cmd
17
+ from cwms_tools.cli.commands import schema as schema_cmd
18
+ from cwms_tools.cli.commands import value as value_cmd
19
+ from cwms_tools.cli.commands import whoami as whoami_cmd
20
+ from cwms_tools.cli.render import set_isolated, set_machine, set_no_cache
21
+
22
+
23
+ def _version_callback(value: bool) -> None:
24
+ if value:
25
+ typer.echo(f"cwms-tools {__version__}")
26
+ raise typer.Exit(code=0)
27
+
28
+
29
+ app = typer.Typer(
30
+ name="cwms-tools",
31
+ help=(
32
+ "Read-only CLI and MCP server for the USACE CWMS Data API. "
33
+ "Task-completing commands (search a place, get current value with "
34
+ "status context, browse an office's catalog) and an MCP server "
35
+ "(`cwms-tools mcp serve`) share one behavioral core."
36
+ ),
37
+ no_args_is_help=True,
38
+ rich_markup_mode="rich",
39
+ )
40
+
41
+
42
+ @app.callback(invoke_without_command=True)
43
+ def _root(
44
+ _version: Annotated[
45
+ bool,
46
+ typer.Option(
47
+ "--version",
48
+ "-V",
49
+ help="Print the cwms-tools version and exit.",
50
+ is_eager=True,
51
+ callback=_version_callback,
52
+ ),
53
+ ] = False,
54
+ machine: Annotated[
55
+ bool,
56
+ typer.Option(
57
+ "--machine",
58
+ help=(
59
+ "Machine-readable output: compact JSON on stdout, no color, "
60
+ "no progress indicators, no interactive prompts. "
61
+ "Auto-enabled when stdout is not a terminal."
62
+ ),
63
+ ),
64
+ ] = False,
65
+ json_flag: Annotated[
66
+ bool,
67
+ typer.Option(
68
+ "--json",
69
+ help=(
70
+ "Alias for --machine. Provided so callers used to the convention "
71
+ "of passing --json can use it without learning a new flag."
72
+ ),
73
+ ),
74
+ ] = False,
75
+ no_cache: Annotated[
76
+ bool,
77
+ typer.Option(
78
+ "--no-cache",
79
+ help=(
80
+ "Bypass the on-disk catalog cache for this invocation. "
81
+ "Environment variables and resolved session config still apply."
82
+ ),
83
+ ),
84
+ ] = False,
85
+ isolated: Annotated[
86
+ bool,
87
+ typer.Option(
88
+ "--isolated",
89
+ help=(
90
+ "Bypass on-disk cache and ignore CWMS_TOOLS_* environment "
91
+ "variables. Useful for reproducibility checks."
92
+ ),
93
+ ),
94
+ ] = False,
95
+ ) -> None:
96
+ """Root command. Subcommands listed below; -h on any subcommand for details."""
97
+ if machine or json_flag:
98
+ set_machine(True)
99
+ if no_cache:
100
+ set_no_cache(True)
101
+ if isolated:
102
+ set_isolated(True)
103
+
104
+
105
+ # Inspection affordances required by agent-friendly-cli when ambient state is read.
106
+ app.add_typer(whoami_cmd.app, name="whoami")
107
+ app.add_typer(env_cmd.app, name="env")
108
+ app.add_typer(config_cmd.app, name="config")
109
+ app.add_typer(fingerprint_cmd.app, name="fingerprint")
110
+ app.add_typer(schema_cmd.app, name="schema")
111
+
112
+ # Place / region task tools (M4).
113
+ app.add_typer(place_cmd.app, name="place")
114
+ app.add_typer(region_cmd.app, name="region")
115
+
116
+ # Value task tools (M5).
117
+ app.add_typer(value_cmd.app, name="value")
118
+
119
+ # Publisher index helper (M6).
120
+ app.add_typer(publisher_cmd.app, name="publisher")
121
+
122
+ # MCP server (M7).
123
+ app.add_typer(mcp_cmd.app, name="mcp")
124
+
125
+
126
+ if __name__ == "__main__": # pragma: no cover
127
+ app()
@@ -0,0 +1 @@
1
+ """CLI subcommand modules. Each noun (place, value, region, ...) lives in its own module."""
@@ -0,0 +1,73 @@
1
+ """`cwms-tools config show --resolved` — emit effective config after precedence merge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from cwms_tools.cli.commands.env import READ_VARS, SECRET_VARS
10
+ from cwms_tools.cli.render import emit
11
+ from cwms_tools.core.cache import resolve_cache_dir
12
+ from cwms_tools.core.concurrency import MAX_WORKERS
13
+ from cwms_tools.core.session import resolve_session_config
14
+
15
+ app = typer.Typer(
16
+ name="config",
17
+ help=(
18
+ "Inspect the cwms-tools configuration after the flag > env > default "
19
+ "precedence merge has been applied."
20
+ ),
21
+ )
22
+
23
+
24
+ def _redacted(name: str, value: str | None) -> str | None:
25
+ if value is None or name not in SECRET_VARS:
26
+ return value
27
+ return f"***{value[-4:]}" if len(value) > 8 else "***"
28
+
29
+
30
+ @app.command("show")
31
+ def show(
32
+ resolved: Annotated[
33
+ bool,
34
+ typer.Option(
35
+ "--resolved",
36
+ help=(
37
+ "Show the merged effective configuration after flags, "
38
+ "environment variables, and defaults are applied."
39
+ ),
40
+ ),
41
+ ] = False,
42
+ ) -> None:
43
+ """Print the resolved CLI configuration.
44
+
45
+ Precedence: explicit flags > CWMS_TOOLS_* environment variables >
46
+ built-in defaults. The `--resolved` flag is required so this
47
+ command can later grow a separate raw-config mode without changing
48
+ its contract.
49
+ """
50
+ if not resolved:
51
+ emit(
52
+ {
53
+ "error": "usage_error",
54
+ "message": "Run `cwms-tools config show --resolved`.",
55
+ "hint": "Pass --resolved to print the merged effective configuration.",
56
+ }
57
+ )
58
+ raise typer.Exit(code=2)
59
+
60
+ cfg = resolve_session_config()
61
+ payload = {
62
+ "api_root": cfg.api_root,
63
+ "cache_dir": str(resolve_cache_dir()),
64
+ "workers": MAX_WORKERS,
65
+ "user_agent": cfg.user_agent,
66
+ "operator_email": cfg.operator_email,
67
+ "env_inputs_read": list(READ_VARS),
68
+ }
69
+ # Redact any secret env vars surfaced inline (none today, but defensive).
70
+ for k in SECRET_VARS & set(payload.keys()):
71
+ raw = payload[k]
72
+ payload[k] = _redacted(k, raw if isinstance(raw, str) else None)
73
+ emit(payload)
@@ -0,0 +1,62 @@
1
+ """`cwms-tools env` — print the CWMS_TOOLS_* env vars the CLI reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import typer
8
+
9
+ from cwms_tools.cli.render import emit
10
+
11
+ app = typer.Typer(
12
+ name="env",
13
+ help=(
14
+ "List the CWMS_TOOLS_* environment variables the CLI reads, their "
15
+ "resolved values, and which are secret (redacted in output)."
16
+ ),
17
+ )
18
+
19
+ # Single source of truth for which env vars we read. Used both here and by the
20
+ # `config show --resolved` command (M3) and (eventually) `cwms-tools schema`.
21
+ READ_VARS: tuple[str, ...] = (
22
+ "CWMS_TOOLS_API_ROOT",
23
+ "CWMS_TOOLS_CACHE_DIR",
24
+ "CWMS_TOOLS_WORKERS",
25
+ "CWMS_TOOLS_REPO_URL",
26
+ "CWMS_TOOLS_USER_AGENT_EXTRA",
27
+ "CWMS_TOOLS_OPERATOR_EMAIL",
28
+ "CWMS_TOOLS_MAX_RPS", # declared, not enforced in v0.1.0
29
+ "CWMS_API_KEY", # declared, unused in v0.1.0
30
+ "CWMS_TOKEN", # declared, unused in v0.1.0
31
+ )
32
+
33
+ # Vars whose values must be redacted in output (tail-only preserved).
34
+ SECRET_VARS: frozenset[str] = frozenset({"CWMS_API_KEY", "CWMS_TOKEN"})
35
+
36
+
37
+ def _redacted(name: str, value: str) -> str:
38
+ if name not in SECRET_VARS:
39
+ return value
40
+ if len(value) <= 8:
41
+ return "***"
42
+ return f"***{value[-4:]}"
43
+
44
+
45
+ @app.callback(invoke_without_command=True)
46
+ def env_cmd() -> None:
47
+ """Print each tracked env var, whether it is set, and its (redacted) value."""
48
+ rows: list[dict[str, str | None]] = []
49
+ for name in READ_VARS:
50
+ raw = os.environ.get(name)
51
+ rows.append(
52
+ {
53
+ "name": name,
54
+ "value": _redacted(name, raw) if raw is not None else None,
55
+ "set": str(raw is not None).lower(),
56
+ "secret": str(name in SECRET_VARS).lower(),
57
+ }
58
+ )
59
+ emit({"variables": rows})
60
+
61
+
62
+ __all__ = ["READ_VARS", "SECRET_VARS"]
@@ -0,0 +1,35 @@
1
+ """`cwms-tools fingerprint` — emit the capability fingerprint alone."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from cwms_tools.cli.render import emit
8
+ from cwms_tools.core import fingerprint as fp
9
+ from cwms_tools.mcp.resources import RESOURCE_INVENTORY, TOOL_INVENTORY
10
+
11
+ app = typer.Typer(
12
+ name="fingerprint",
13
+ help=(
14
+ "Print the capability fingerprint — a SHA-256 over the tool list, "
15
+ "schemas, resource catalog, error codes, bundled overview, and "
16
+ "configured CDA root. Clients cache by this value to detect when "
17
+ "anything in the agent-visible surface has changed."
18
+ ),
19
+ )
20
+
21
+
22
+ @app.callback(invoke_without_command=True)
23
+ def fingerprint_cmd() -> None:
24
+ """Compute and print the capability fingerprint."""
25
+ # We pass the tool inventory as a stable list of names (no per-tool schema
26
+ # introspection on the CLI side) — that matches what the MCP server sees
27
+ # because the tool surface in v0.1.0 is statically registered.
28
+ tools = {name: {"name": name} for name in TOOL_INVENTORY}
29
+ digest = fp.compute(tools=tools, resources=RESOURCE_INVENTORY)
30
+ emit(
31
+ {
32
+ "fingerprint": digest,
33
+ "scope": fp.FINGERPRINT_SCOPE,
34
+ }
35
+ )
@@ -0,0 +1,129 @@
1
+ """`cwms-tools mcp serve` — launch the FastMCP server over stdio or streamable HTTP.
2
+
3
+ stdio is the only transport where stdout is reserved for the JSON-RPC stream
4
+ (agent-friendly-mcp §1). The subcommand suppresses every Typer / rich / log
5
+ write to stdout and routes them to stderr, and installs a `sys.stdout` guard
6
+ that errors loudly if anything outside FastMCP writes to stdout during the
7
+ serve loop.
8
+
9
+ streamable HTTP is the network-deployment transport; output rules don't
10
+ apply because there's no shared stdout channel with the client.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ import sys
18
+ from typing import Annotated, Any
19
+
20
+ import typer
21
+
22
+ from cwms_tools.cli.render import diagnostic
23
+ from cwms_tools.mcp.server import build_server
24
+
25
+ app = typer.Typer(
26
+ name="mcp",
27
+ help=(
28
+ "Run the cwms-tools MCP server. Exposes the same task tools as "
29
+ "the CLI to MCP-aware agent runtimes (Claude Code, Codex, etc.)."
30
+ ),
31
+ no_args_is_help=True,
32
+ )
33
+
34
+
35
+ class _StdoutGuard:
36
+ """Wraps `sys.stdout` and forbids non-FastMCP writes during stdio serve."""
37
+
38
+ def __init__(self, real: Any) -> None:
39
+ self._real = real
40
+ self._warned = False
41
+
42
+ def write(self, s: str) -> int:
43
+ if not s:
44
+ return 0
45
+ sys.stderr.write(s)
46
+ if not self._warned and s.strip():
47
+ sys.stderr.write(
48
+ "\n[cwms-tools mcp serve] non-FastMCP stdout write redirected to stderr\n"
49
+ )
50
+ self._warned = True
51
+ return len(s)
52
+
53
+ def flush(self) -> None:
54
+ sys.stderr.flush()
55
+
56
+ def __getattr__(self, name: str) -> Any:
57
+ return getattr(self._real, name)
58
+
59
+
60
+ @app.command("serve")
61
+ def serve(
62
+ transport: Annotated[
63
+ str,
64
+ typer.Option(
65
+ "--transport",
66
+ help=(
67
+ "MCP transport. 'stdio' speaks JSON-RPC over stdin/stdout "
68
+ "and is the right choice for local agent runtimes. "
69
+ "'streamable-http' binds an HTTP listener for shared/remote use."
70
+ ),
71
+ case_sensitive=False,
72
+ ),
73
+ ] = "stdio",
74
+ host: Annotated[
75
+ str,
76
+ typer.Option(
77
+ "--host",
78
+ help="HTTP bind host. Only used when --transport is streamable-http.",
79
+ ),
80
+ ] = "127.0.0.1",
81
+ port: Annotated[
82
+ int,
83
+ typer.Option(
84
+ "--port",
85
+ help="HTTP bind port. Only used when --transport is streamable-http.",
86
+ ),
87
+ ] = 8765,
88
+ ) -> None:
89
+ """Launch the cwms-tools MCP server.
90
+
91
+ Local example: cwms-tools mcp serve --transport stdio
92
+ Remote example: cwms-tools mcp serve --transport streamable-http --port 8765
93
+ """
94
+ transport_norm = transport.lower().strip()
95
+ server: Any = build_server()
96
+
97
+ if transport_norm in {"stdio", "stdin/stdout"}:
98
+ _serve_stdio(server)
99
+ elif transport_norm in {"http", "streamable-http", "streamable_http"}:
100
+ _serve_http(server, host=host, port=port)
101
+ else:
102
+ diagnostic(f"unknown transport {transport!r}; use stdio or streamable-http.")
103
+ raise typer.Exit(code=2)
104
+
105
+
106
+ def _serve_stdio(server: Any) -> None:
107
+ """Install the stdout guard, route Typer/rich/log to stderr, then run."""
108
+ os.environ.setdefault("NO_COLOR", "1")
109
+ os.environ.setdefault("CLICOLOR", "0")
110
+ os.environ.setdefault("TYPER_DEFAULT_FORCE_TERMINAL_WIDTH", "200")
111
+
112
+ logging.basicConfig(level=logging.WARNING, stream=sys.stderr, force=True)
113
+
114
+ sys.stdout = _StdoutGuard(sys.__stdout__)
115
+ try:
116
+ server.run(transport="stdio", show_banner=False)
117
+ finally:
118
+ sys.stdout = sys.__stdout__
119
+
120
+
121
+ def _serve_http(server: Any, *, host: str, port: int) -> None:
122
+ """Run streamable-http transport."""
123
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr, force=True)
124
+ server.run(
125
+ transport="streamable-http",
126
+ host=host,
127
+ port=port,
128
+ show_banner=False,
129
+ )
@@ -0,0 +1,232 @@
1
+ """`cwms-tools place ...` — place/location commands.
2
+
3
+ Subcommands:
4
+ - `cwms-tools place search <query> --office <O>`
5
+ - `cwms-tools place describe <office>/<name>`
6
+ - `cwms-tools place parameters <office>/<name>`
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from cwms_tools.cli.exit_codes import from_error_code
16
+ from cwms_tools.cli.render import emit
17
+ from cwms_tools.core import places
18
+ from cwms_tools.core.errors import CwmsToolsError, ErrorCode
19
+ from cwms_tools.core.models import Detail
20
+
21
+ app = typer.Typer(
22
+ name="place",
23
+ help=(
24
+ "Resolve and describe CWMS locations: name search, full place "
25
+ "description (location + project + publishers + freshness), and "
26
+ "per-location parameter listing."
27
+ ),
28
+ no_args_is_help=True,
29
+ )
30
+
31
+
32
+ def _parse_office_slash_name(spec: str) -> tuple[str, str]:
33
+ """Split `OFFICE/NAME` into (office, name); raises typer.Exit(2) on bad shape."""
34
+ if "/" not in spec:
35
+ emit(
36
+ {
37
+ "ok": False,
38
+ "error": {
39
+ "code": ErrorCode.USAGE_ERROR.value,
40
+ "message": "Expected `OFFICE/NAME` form, e.g. `NWDM/FTPK`.",
41
+ "field": "spec",
42
+ "offending_value": spec,
43
+ },
44
+ }
45
+ )
46
+ raise typer.Exit(code=2)
47
+ office, name = spec.split("/", 1)
48
+ return office.strip(), name.strip()
49
+
50
+
51
+ @app.command("search")
52
+ def search(
53
+ query: Annotated[
54
+ str,
55
+ typer.Argument(help="Name fragment to match, case-insensitive."),
56
+ ],
57
+ office: Annotated[
58
+ list[str] | None,
59
+ typer.Option(
60
+ "--office",
61
+ "-o",
62
+ help=(
63
+ "USACE office code. Repeat to fan out across multiple "
64
+ "offices (e.g. `-o NWDP -o NWDM`). Omit to use offices "
65
+ "already cached this session; unbounded discovery is "
66
+ "intentionally avoided. Overflow beyond the per-call "
67
+ "budget lands in `offices_skipped_for_budget`."
68
+ ),
69
+ ),
70
+ ] = None,
71
+ parameter: Annotated[
72
+ str | None,
73
+ typer.Option(
74
+ "--parameter",
75
+ "-p",
76
+ help=(
77
+ "Filter to locations publishing this parameter "
78
+ "(e.g. Temp-Water, Elev, Flow-In). When set, non-publishing "
79
+ "rows are dropped — except barren parents whose `data_at` "
80
+ "siblings publish it. `nearby_non_matching_count` reflects "
81
+ "what was filtered out."
82
+ ),
83
+ ),
84
+ ] = None,
85
+ limit: Annotated[
86
+ int,
87
+ typer.Option(
88
+ "--limit",
89
+ "-n",
90
+ help=(
91
+ "Cap on the number of results (default 50). Broad searches "
92
+ "like 'Temp String' on a big office can match hundreds of "
93
+ "rows; the cap keeps responses small. Pass `0` to return "
94
+ "every match (no cap). When the cap kicks in the response "
95
+ "carries `truncated: true` and `total_count`."
96
+ ),
97
+ ),
98
+ ] = places.DEFAULT_SEARCH_LIMIT,
99
+ detail: Annotated[
100
+ Detail,
101
+ typer.Option(
102
+ "--detail",
103
+ help="Response density. 'summary' drops verbose upstream fields; 'full' keeps them.",
104
+ ),
105
+ ] = Detail.SUMMARY,
106
+ ) -> None:
107
+ """Search for places by name in one office.
108
+
109
+ Each result is enriched with parameter_count (0 = ghost record),
110
+ active publishers, last data timestamp, co-located variants, and
111
+ `data_at` — when a barren parent has a co-located sibling that
112
+ publishes data (e.g. the Lake Washington `UBLW_S1` parent has no
113
+ ts ids but `UBLW_S1-D21,0ft` does), `data_at` names that sibling
114
+ so the agent doesn't have to walk the co_located list to find it.
115
+ Data-bearing records sort first.
116
+ """
117
+ if limit < 0:
118
+ emit(
119
+ {
120
+ "ok": False,
121
+ "error": {
122
+ "code": ErrorCode.USAGE_ERROR.value,
123
+ "message": "--limit must be a non-negative integer.",
124
+ "field": "limit",
125
+ "offending_value": limit,
126
+ },
127
+ }
128
+ )
129
+ raise typer.Exit(code=2)
130
+ effective_limit = None if limit == 0 else limit
131
+ # Typer passes repeatable Options as a list[str] (even when one value
132
+ # was given). Collapse to a single string when only one office is
133
+ # present, so the response's `office` field echoes the simpler shape.
134
+ office_arg: str | list[str] | None
135
+ if not office:
136
+ office_arg = None
137
+ elif len(office) == 1:
138
+ office_arg = office[0]
139
+ else:
140
+ office_arg = list(office)
141
+ try:
142
+ payload = places.search_places(
143
+ query,
144
+ office=office_arg,
145
+ parameter=parameter,
146
+ limit=effective_limit,
147
+ )
148
+ except CwmsToolsError as err:
149
+ emit({"ok": False, "error": err.envelope.model_dump(mode="json")})
150
+ raise typer.Exit(code=from_error_code(err.envelope.code)) from err
151
+ if detail is Detail.SUMMARY:
152
+ payload = {
153
+ **payload,
154
+ "results": [{k: v for k, v in r.items() if k != "raw"} for r in payload["results"]],
155
+ }
156
+ emit(payload)
157
+
158
+
159
+ @app.command("describe")
160
+ def describe(
161
+ spec: Annotated[
162
+ str,
163
+ typer.Argument(help="Place id in OFFICE/NAME form, e.g. NWDM/FTPK or SWT/FOSS."),
164
+ ],
165
+ detail: Annotated[
166
+ Detail,
167
+ typer.Option(
168
+ "--detail",
169
+ help=(
170
+ "'summary' returns the triage subset of the location DTO; "
171
+ "'full' returns every field."
172
+ ),
173
+ ),
174
+ ] = Detail.SUMMARY,
175
+ ) -> None:
176
+ """Print everything about one place in a single call.
177
+
178
+ Combines the location record, project metadata (when present), the
179
+ parameters published at the location grouped by publisher, and the
180
+ most recent data timestamp. Sets `partial: true` when any
181
+ underlying lookup degrades.
182
+ """
183
+ office, name = _parse_office_slash_name(spec)
184
+ try:
185
+ payload = places.describe_place(office, name)
186
+ except CwmsToolsError as err:
187
+ emit({"ok": False, "error": err.envelope.model_dump(mode="json")})
188
+ raise typer.Exit(code=from_error_code(err.envelope.code)) from err
189
+ if detail is Detail.SUMMARY and isinstance(payload.get("location"), dict):
190
+ loc = payload["location"]
191
+ payload = {
192
+ **payload,
193
+ "location": {
194
+ k: loc.get(k)
195
+ for k in (
196
+ "office-id",
197
+ "name",
198
+ "location-kind",
199
+ "latitude",
200
+ "longitude",
201
+ "public-name",
202
+ "long-name",
203
+ "horizontal-datum",
204
+ "state-initial",
205
+ "nearest-city",
206
+ "timezone-name",
207
+ )
208
+ if k in loc
209
+ },
210
+ }
211
+ emit(payload)
212
+
213
+
214
+ @app.command("parameters")
215
+ def parameters(
216
+ spec: Annotated[
217
+ str,
218
+ typer.Argument(help="Place id in OFFICE/NAME form, e.g. SWT/FOSS."),
219
+ ],
220
+ ) -> None:
221
+ """List the parameters published at a place, grouped by publisher.
222
+
223
+ The cheapest probe for distinguishing data-bearing locations from
224
+ ghost catalog records: a ghost returns ts_count=0 and an empty
225
+ by_publisher list.
226
+ """
227
+ office, name = _parse_office_slash_name(spec)
228
+ try:
229
+ emit(places.list_parameters(office, name))
230
+ except CwmsToolsError as err:
231
+ emit({"ok": False, "error": err.envelope.model_dump(mode="json")})
232
+ raise typer.Exit(code=from_error_code(err.envelope.code)) from err