pushmetrics-cli 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 (86) hide show
  1. pm/__init__.py +1 -0
  2. pm/__main__.py +4 -0
  3. pm/_alias.py +164 -0
  4. pm/auth/__init__.py +0 -0
  5. pm/auth/device_flow.py +187 -0
  6. pm/auth/exchange.py +79 -0
  7. pm/auth/jwt_cache.py +163 -0
  8. pm/auth/secret_files.py +58 -0
  9. pm/auth/session.py +440 -0
  10. pm/auth/store.py +160 -0
  11. pm/auth/token.py +289 -0
  12. pm/cli.py +282 -0
  13. pm/client/__init__.py +68 -0
  14. pm/client/access_tokens.py +54 -0
  15. pm/client/base.py +229 -0
  16. pm/client/blocks.py +461 -0
  17. pm/client/databases.py +147 -0
  18. pm/client/favorites.py +14 -0
  19. pm/client/folders.py +10 -0
  20. pm/client/integrations.py +14 -0
  21. pm/client/organizations.py +20 -0
  22. pm/client/recipients.py +92 -0
  23. pm/client/reports.py +148 -0
  24. pm/client/roles.py +100 -0
  25. pm/client/runs.py +134 -0
  26. pm/client/saved_blocks.py +13 -0
  27. pm/client/tableau.py +108 -0
  28. pm/client/users.py +71 -0
  29. pm/client/webhooks.py +43 -0
  30. pm/client/workspaces.py +34 -0
  31. pm/commands/__init__.py +0 -0
  32. pm/commands/_block_factory.py +762 -0
  33. pm/commands/_helpers.py +178 -0
  34. pm/commands/auth.py +222 -0
  35. pm/commands/block.py +26 -0
  36. pm/commands/chart.py +20 -0
  37. pm/commands/db.py +291 -0
  38. pm/commands/email.py +13 -0
  39. pm/commands/explore.py +20 -0
  40. pm/commands/favorites.py +77 -0
  41. pm/commands/folders.py +113 -0
  42. pm/commands/help.py +643 -0
  43. pm/commands/integrations.py +95 -0
  44. pm/commands/metrics_view.py +195 -0
  45. pm/commands/organizations.py +56 -0
  46. pm/commands/recipient.py +226 -0
  47. pm/commands/reports.py +364 -0
  48. pm/commands/roles.py +102 -0
  49. pm/commands/runs.py +214 -0
  50. pm/commands/saved_blocks.py +97 -0
  51. pm/commands/sftp.py +19 -0
  52. pm/commands/slack.py +13 -0
  53. pm/commands/slides.py +216 -0
  54. pm/commands/sql.py +17 -0
  55. pm/commands/tableau.py +148 -0
  56. pm/commands/token.py +107 -0
  57. pm/commands/users.py +262 -0
  58. pm/commands/webhooks.py +132 -0
  59. pm/commands/workspace.py +156 -0
  60. pm/config.py +192 -0
  61. pm/errors.py +228 -0
  62. pm/log.py +63 -0
  63. pm/models/__init__.py +31 -0
  64. pm/models/access_tokens.py +25 -0
  65. pm/models/blocks.py +21 -0
  66. pm/models/databases.py +15 -0
  67. pm/models/favorites.py +14 -0
  68. pm/models/folders.py +14 -0
  69. pm/models/integrations.py +20 -0
  70. pm/models/organizations.py +13 -0
  71. pm/models/recipients.py +41 -0
  72. pm/models/reports.py +16 -0
  73. pm/models/roles.py +22 -0
  74. pm/models/runs.py +56 -0
  75. pm/models/saved_blocks.py +15 -0
  76. pm/models/users.py +36 -0
  77. pm/models/webhooks.py +23 -0
  78. pm/models/workspaces.py +16 -0
  79. pm/output.py +92 -0
  80. pm/types.py +130 -0
  81. pm/validators.py +499 -0
  82. pushmetrics_cli-0.1.0.dist-info/METADATA +221 -0
  83. pushmetrics_cli-0.1.0.dist-info/RECORD +86 -0
  84. pushmetrics_cli-0.1.0.dist-info/WHEEL +5 -0
  85. pushmetrics_cli-0.1.0.dist-info/entry_points.txt +2 -0
  86. pushmetrics_cli-0.1.0.dist-info/top_level.txt +1 -0
pm/config.py ADDED
@@ -0,0 +1,192 @@
1
+ """Profile configuration for the PushMetrics CLI.
2
+
3
+ Profiles live at `$PM_CONFIG_HOME/config.toml` (defaults to `~/.pm/config.toml`).
4
+ Built-in profile names (`default`, `local`, plus the harness-only `agent`)
5
+ provide sensible api_url and auth0_domain values. A profile needs only
6
+ `api_url` for token login (`pm login --token`, PM_API_TOKEN); auth0_domain
7
+ and auth0_client_id matter only for browser (device-flow) login. A `staging`
8
+ built-in existed until 2026-07 but pointed at an environment that was never
9
+ stood up (no DNS, no deploy pipeline).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import os
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any, Dict, Optional
19
+
20
+ import click
21
+
22
+ if sys.version_info >= (3, 11):
23
+ import tomllib
24
+ else:
25
+ import tomli as tomllib
26
+
27
+
28
+ DEFAULT_CONFIG_DIR = Path.home() / ".pm"
29
+ CONFIG_DIR = Path(os.environ.get("PM_CONFIG_HOME") or DEFAULT_CONFIG_DIR)
30
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
31
+
32
+
33
+ def is_default_config_home() -> bool:
34
+ return CONFIG_DIR.resolve() == DEFAULT_CONFIG_DIR.resolve()
35
+
36
+
37
+ def keyring_account(profile: str) -> str:
38
+ """The OS-keyring account name for ``profile`` in the active config home.
39
+
40
+ The keyring is per user, not per directory, so two config homes (a
41
+ scratch PM_CONFIG_HOME, CI, a second tool) used to share one slot per
42
+ profile name: a process with its own config home authenticated with the
43
+ user's stored PAT, and its `pm logout` deleted it (security S9). The
44
+ default home keeps the bare profile name, so every existing stored PAT,
45
+ cookie and cached JWT keeps working with no re-login; any other home
46
+ gets its own namespace derived from its resolved path.
47
+ """
48
+ if is_default_config_home():
49
+ return profile
50
+ digest = hashlib.sha256(str(CONFIG_DIR.resolve()).encode("utf-8")).hexdigest()[:12]
51
+ return f"{profile}@{digest}"
52
+
53
+
54
+ def describe_config_home() -> str:
55
+ """One line naming the active config home, for diagnosing which
56
+ credentials a command can see."""
57
+ if is_default_config_home():
58
+ return f"{CONFIG_DIR} (default)"
59
+ return f"{CONFIG_DIR} (PM_CONFIG_HOME; credentials stored separately from {DEFAULT_CONFIG_DIR})"
60
+
61
+ # Every key a profile section may carry; the single source `config set`
62
+ # validates against. Matches the Profile dataclass fields below (minus name).
63
+ PROFILE_KEYS = frozenset({"api_url", "auth0_domain", "auth0_client_id", "auth0_audience"})
64
+
65
+ DEFAULT_PROFILES: Dict[str, Dict[str, Any]] = {
66
+ "default": {
67
+ # The API lives on the apex host. app.pushmetrics.io answers every
68
+ # path with a 301 to https://pushmetrics.io/app (path dropped), and
69
+ # the CLI never follows redirects (it would re-send the Authorization
70
+ # header), so a first-run `pm login --token` against the old host
71
+ # failed with "Token exchange failed: 301".
72
+ "api_url": "https://pushmetrics.io",
73
+ "auth0_domain": "auth.pushmetrics.io",
74
+ },
75
+ "local": {
76
+ # 127.0.0.1 (not "localhost") — Windows resolves localhost via IPv6 first
77
+ # and a missing AAAA can add ~2s per fresh connection.
78
+ "api_url": "http://127.0.0.1:8090",
79
+ "auth0_domain": "staging.auth.pushmetrics.io",
80
+ },
81
+ # Profile used by the prefect agent when running pm under a delegation
82
+ # JWT (PM_API_JWT). api_url is normally overridden by the PM_API_URL env
83
+ # var in the container; the auth0 fields are unused because the
84
+ # credential is supplied — never the device-flow login.
85
+ "agent": {
86
+ "api_url": "http://127.0.0.1:8090",
87
+ "auth0_domain": "agent.local",
88
+ },
89
+ }
90
+
91
+
92
+ @dataclass
93
+ class Profile:
94
+ name: str
95
+ api_url: str
96
+ # Browser (device-flow) login only; token login never reads these.
97
+ auth0_domain: Optional[str] = None
98
+ auth0_client_id: Optional[str] = None
99
+ auth0_audience: Optional[str] = None
100
+
101
+ @property
102
+ def audience(self) -> Optional[str]:
103
+ if self.auth0_audience:
104
+ return self.auth0_audience
105
+ return f"https://{self.auth0_domain}/api/v2/" if self.auth0_domain else None
106
+
107
+
108
+ def _ensure_dir() -> None:
109
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
110
+
111
+
112
+ def _serialize_toml(data: Dict[str, Dict[str, Any]]) -> str:
113
+ """Tiny TOML writer for our flat profile schema. Avoids pulling in tomli_w."""
114
+ lines = []
115
+ for profile_name, fields in data.items():
116
+ lines.append(f"[{profile_name}]")
117
+ for key, value in fields.items():
118
+ if value is None:
119
+ continue
120
+ if isinstance(value, bool):
121
+ lines.append(f"{key} = {str(value).lower()}")
122
+ elif isinstance(value, (int, float)):
123
+ lines.append(f"{key} = {value}")
124
+ else:
125
+ escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
126
+ lines.append(f'{key} = "{escaped}"')
127
+ lines.append("")
128
+ return "\n".join(lines)
129
+
130
+
131
+ def load_all() -> Dict[str, Dict[str, Any]]:
132
+ if not CONFIG_FILE.exists():
133
+ return {}
134
+ with CONFIG_FILE.open("rb") as f:
135
+ return tomllib.load(f)
136
+
137
+
138
+ def save_all(data: Dict[str, Dict[str, Any]]) -> None:
139
+ _ensure_dir()
140
+ CONFIG_FILE.write_text(_serialize_toml(data), encoding="utf-8")
141
+
142
+
143
+ def get_profile(name: str) -> Profile:
144
+ """Resolve a profile by name. Layering: env vars > config file > built-in defaults."""
145
+ file_data = load_all()
146
+ file_profile = file_data.get(name, {})
147
+ default_profile = DEFAULT_PROFILES.get(name, {})
148
+
149
+ if not file_profile and not default_profile:
150
+ known = sorted(set(file_data) | set(DEFAULT_PROFILES))
151
+ raise click.ClickException(
152
+ f"Unknown profile: {name!r}. Known profiles: {', '.join(known)}. "
153
+ f"Create it with `pm config set {name} api_url https://pushmetrics.io`."
154
+ )
155
+
156
+ merged: Dict[str, Any] = {**default_profile, **file_profile}
157
+
158
+ api_url = os.environ.get("PM_API_URL") or merged.get("api_url")
159
+ auth0_domain = os.environ.get("PM_AUTH0_DOMAIN") or merged.get("auth0_domain")
160
+ auth0_client_id = os.environ.get("PM_AUTH0_CLIENT_ID") or merged.get("auth0_client_id")
161
+ auth0_audience = merged.get("auth0_audience")
162
+
163
+ # api_url is the only field every command needs. auth0_domain is checked
164
+ # where browser login uses it (pm.auth.device_flow), so a profile created
165
+ # with the one line the "Unknown profile" error suggests works for
166
+ # token login as written.
167
+ if not api_url:
168
+ raise click.ClickException(
169
+ f"Profile {name!r} has no api_url. "
170
+ f"Set it with `pm config set {name} api_url https://pushmetrics.io`, "
171
+ f"or set PM_API_URL."
172
+ )
173
+
174
+ return Profile(
175
+ name=name,
176
+ api_url=api_url.rstrip("/"),
177
+ auth0_domain=auth0_domain,
178
+ auth0_client_id=auth0_client_id,
179
+ auth0_audience=auth0_audience,
180
+ )
181
+
182
+
183
+ def set_profile_field(profile_name: str, key: str, value: str) -> None:
184
+ """Write one profile key to the config file; unknown keys are refused."""
185
+ if key not in PROFILE_KEYS:
186
+ raise click.ClickException(
187
+ f"Unknown profile key {key!r}. Valid keys: {', '.join(sorted(PROFILE_KEYS))}."
188
+ )
189
+ data = load_all()
190
+ block = data.setdefault(profile_name, {})
191
+ block[key] = value
192
+ save_all(data)
pm/errors.py ADDED
@@ -0,0 +1,228 @@
1
+ """Structured error envelope for the pm CLI (pm-tool plan §2.7).
2
+
3
+ CLI errors return a parseable JSON shape with an explicit `retry:` field so
4
+ the agent (or a human looking at JSON output) can recover without re-fetching
5
+ help. Used for validation failures, allowlist denials, replay-guard blocks,
6
+ type canonicalization mismatches, and any other structured failure.
7
+
8
+ Envelope shape::
9
+
10
+ {
11
+ "ok": false,
12
+ "error_code": "<closed-set string>",
13
+ "message": "<human-readable summary>",
14
+ "received": "<what the user passed, if relevant>",
15
+ "did_you_mean": ["<suggestion>", ...],
16
+ "allowed": ["<valid value>", ...],
17
+ "retry": "<exact next command line, if available>",
18
+ "example": {<inline body example>},
19
+ "help": "pm help <relevant verb>"
20
+ }
21
+
22
+ Optional fields are omitted from the envelope when not set — keeps the
23
+ output small for the common case.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import sys
29
+ from dataclasses import dataclass, field
30
+ from typing import Any
31
+
32
+ import click
33
+
34
+
35
+ # Closed set of error codes. New entries go here; call sites can't invent
36
+ # their own. Keeps the agent-facing surface stable for retry / recovery.
37
+ ERROR_CODES: frozenset[str] = frozenset({
38
+ # Type discriminator
39
+ "unknown_block_type",
40
+ "ambiguous_type_override",
41
+ # Body validation
42
+ "block_schema_validation_failed",
43
+ "type_body_mismatch",
44
+ # Resource state
45
+ "not_found",
46
+ # Authorization / policy
47
+ "forbidden",
48
+ "replay_disabled",
49
+ "action_denied",
50
+ # CLI grammar
51
+ "argv_invalid",
52
+ })
53
+
54
+
55
+ @dataclass
56
+ class PMError(Exception):
57
+ """Base for structured CLI errors. Construct with the relevant fields;
58
+ raise where the failure happens; catch at the command handler boundary
59
+ and call ``emit_error(err)`` to print the envelope + exit non-zero.
60
+
61
+ Subclasses can preset ``error_code`` for ergonomics; the dataclass
62
+ fields stay flexible so call sites pick exactly the envelope shape
63
+ they want.
64
+ """
65
+ error_code: str
66
+ message: str
67
+ received: str | None = None
68
+ did_you_mean: list[str] = field(default_factory=list)
69
+ allowed: list[str] = field(default_factory=list)
70
+ retry: str | None = None
71
+ example: Any = None
72
+ help: str | None = None
73
+
74
+ def __post_init__(self) -> None:
75
+ if self.error_code not in ERROR_CODES:
76
+ raise ValueError(
77
+ f"Unknown error_code: {self.error_code!r}. "
78
+ f"Add to ERROR_CODES in cli/pm/errors.py first."
79
+ )
80
+ # Exception.__init__ wants a message arg — the dataclass doesn't
81
+ # call it for us.
82
+ super().__init__(self.message)
83
+
84
+ def envelope(self) -> dict[str, Any]:
85
+ """Return the JSON-serializable envelope dict. Optional fields
86
+ omitted when not set."""
87
+ out: dict[str, Any] = {
88
+ "ok": False,
89
+ "error_code": self.error_code,
90
+ "message": self.message,
91
+ }
92
+ if self.received is not None:
93
+ out["received"] = self.received
94
+ if self.did_you_mean:
95
+ out["did_you_mean"] = self.did_you_mean
96
+ if self.allowed:
97
+ out["allowed"] = self.allowed
98
+ if self.retry is not None:
99
+ out["retry"] = self.retry
100
+ if self.example is not None:
101
+ out["example"] = self.example
102
+ if self.help is not None:
103
+ out["help"] = self.help
104
+ return out
105
+
106
+
107
+ def emit_error(err: PMError, *, exit_code: int = 1) -> None:
108
+ """Print the structured envelope to stderr as a single JSON line and
109
+ exit with a non-zero status. Used by command handlers' exception path.
110
+
111
+ JSON goes to stderr (not stdout) so that stdout stays clean for piped
112
+ success results; the agent reads both streams, but humans piping
113
+ output expect errors out-of-band.
114
+ """
115
+ click.echo(json.dumps(err.envelope(), separators=(",", ":")), err=True)
116
+ sys.exit(exit_code)
117
+
118
+
119
+ # ── Human-facing API / network error rendering ───────────────────────
120
+ #
121
+ # Every command ends up at the API; when the API says no, the first line
122
+ # keeps the exact ``API <METHOD> <url> failed: <status> <body>`` text
123
+ # (CONTRACT: the prefect cli_adapter scrapes ``failed:\s*(\d{3})`` from
124
+ # stderr to classify auth denials), and a second ``Hint:`` line names the
125
+ # next action a person can take. The hint is what makes a first run with
126
+ # only a PAT recoverable without reading the source.
127
+
128
+ SUPPORT_EMAIL = "support@pushmetrics.io"
129
+
130
+
131
+ def _hint_for_status(status: int, group: str | None) -> str | None:
132
+ listing = f"`pm {group} list`" if group else "the matching `pm <resource> list`"
133
+ if status == 400:
134
+ return (
135
+ "The server rejected the request body or flags. "
136
+ + (f"`pm {group} --help` lists what it accepts." if group else "")
137
+ ).strip()
138
+ if status == 403:
139
+ return (
140
+ "You do not have permission for this in the active workspace. "
141
+ "Check which user, token and workspace you are using "
142
+ "(`pm whoami`, `pm config list`)."
143
+ )
144
+ if status == 404 and group == "workspace":
145
+ return "`pm workspace list` shows the workspaces you can access."
146
+ if status == 404:
147
+ return (
148
+ f"Nothing with that id is visible in the active workspace. "
149
+ f"{listing} shows what exists."
150
+ )
151
+ if status == 429:
152
+ return "Too many requests. Wait a minute and retry."
153
+ if status >= 500:
154
+ return (
155
+ "PushMetrics returned a server error. Retry in a minute; if it "
156
+ f"keeps failing, email {SUPPORT_EMAIL} with the command and the time."
157
+ )
158
+ return None
159
+
160
+
161
+ # FAB's bare status phrases (response_403() -> {"message": "Forbidden"},
162
+ # response_404() -> {"message": "Not found"}) explain nothing; they keep the
163
+ # generic next-action hint. Anything else is a sentence written for the user.
164
+ _BARE_STATUS_MESSAGES = frozenset({
165
+ "forbidden", "not found", "unauthorized", "bad request",
166
+ # The one textual 403 reachable from pm (non-member workspace switch):
167
+ # a fact with no next action, so the hint follows it.
168
+ "you don't belong to this workspace",
169
+ })
170
+
171
+
172
+ def _is_bare_status_message(message: str) -> bool:
173
+ return message.strip().rstrip(".").lower() in _BARE_STATUS_MESSAGES
174
+
175
+
176
+ # A server message longer than this is a stack trace or an error page, not
177
+ # a sentence for a person; cap it so it cannot flood a terminal or an agent
178
+ # transcript (same bound pm.log applies to logged bodies).
179
+ _SERVER_MESSAGE_MAX = 500
180
+
181
+
182
+ def _server_message(body: str) -> str | None:
183
+ """The sentence a PushMetrics/FAB error body carries, if any: under
184
+ ``message`` (FAB's response_4xx helpers) or ``error`` (older PushMetrics
185
+ views, e.g. the non-member workspace switch)."""
186
+ try:
187
+ parsed = json.loads(body or "")
188
+ except ValueError:
189
+ return None
190
+ if not isinstance(parsed, dict):
191
+ return None
192
+ message = next(
193
+ (v for v in (parsed.get("message"), parsed.get("error")) if isinstance(v, str) and v.strip()),
194
+ None,
195
+ )
196
+ if message is None:
197
+ return None
198
+ message = message.strip()
199
+ if len(message) > _SERVER_MESSAGE_MAX:
200
+ message = message[:_SERVER_MESSAGE_MAX] + "…"
201
+ return message
202
+
203
+
204
+ def describe_api_error(exc: Exception, *, group: str | None = None) -> str:
205
+ """Render a ``PMApiError`` for a person: the original line, then a hint.
206
+
207
+ When the body carries a server ``message`` it replaces the raw JSON on
208
+ the first line, and on 403 a message that is more than FAB's bare
209
+ "Forbidden" also replaces the generic hint: a policy refusal ("This
210
+ token is bound to workspace 3 ...") is already the explanation. The
211
+ ``failed: <status>`` text stays in place either way.
212
+
213
+ ``exc`` is typed loosely so this module stays importable without the
214
+ client package; callers pass ``pm.client.base.PMApiError``.
215
+ """
216
+ status = int(getattr(exc, "status", 0) or 0)
217
+ message = _server_message(getattr(exc, "body", "") or "")
218
+ if message:
219
+ method = getattr(exc, "method", "") or ""
220
+ path = getattr(exc, "path", "") or ""
221
+ from pm.client.base import api_error_prefix
222
+
223
+ first_line = f"{api_error_prefix(method, path)}: {status} {message}"
224
+ else:
225
+ first_line = str(exc)
226
+ explained = bool(message) and not _is_bare_status_message(message)
227
+ hint = None if (explained and status == 403) else _hint_for_status(status, group)
228
+ return f"{first_line}\nHint: {hint}" if hint else first_line
pm/log.py ADDED
@@ -0,0 +1,63 @@
1
+ """Helpers for HTTP-call logging at DEBUG level.
2
+
3
+ Logs method/URL on the way out, status + duration on the way back. Includes the
4
+ response body only on 4xx/5xx so successful responses (which often carry tokens)
5
+ don't get written to stderr.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import logging
11
+ import re
12
+ import time
13
+
14
+ import requests
15
+
16
+ MAX_BODY = 500
17
+
18
+ # The ONE secret pattern for the CLI: PATs, anything after "Bearer", and bare
19
+ # JWTs (three base64url segments starting with the `{"` header prefix `eyJ`),
20
+ # which a 4xx/5xx body or an Auth0 error page can echo verbatim. pm.auth.*
21
+ # import redact_secrets from here; there is no second copy (security S4).
22
+ _SECRET_RE = re.compile(
23
+ r"(pmpat_[A-Za-z0-9_\-]+"
24
+ r"|[Bb]earer\s+[A-Za-z0-9._\-]+"
25
+ r"|eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+)"
26
+ )
27
+
28
+
29
+ def redact_secrets(text: str) -> str:
30
+ """Replace PAT / Bearer-token / JWT substrings with a redaction marker."""
31
+ return _SECRET_RE.sub("<redacted>", text or "")
32
+
33
+
34
+ def log_request(logger: logging.Logger, method: str, url: str) -> float:
35
+ """Log an outgoing request and return a perf_counter to feed into `log_response`."""
36
+ logger.debug("-> %s %s", method, url)
37
+ return time.perf_counter()
38
+
39
+
40
+ def log_response(
41
+ logger: logging.Logger,
42
+ resp: requests.Response,
43
+ started: float | None = None,
44
+ ) -> None:
45
+ suffix = ""
46
+ if started is not None:
47
+ suffix = f" ({(time.perf_counter() - started) * 1000:.0f}ms)"
48
+ if resp.status_code >= 400:
49
+ body = redact_secrets((resp.text or "")[:MAX_BODY])
50
+ logger.debug("<- %d %s%s -- %s", resp.status_code, resp.url, suffix, body)
51
+ else:
52
+ logger.debug("<- %d %s%s", resp.status_code, resp.url, suffix)
53
+
54
+
55
+ @contextlib.contextmanager
56
+ def timed(logger: logging.Logger, label: str):
57
+ """Context manager: logs `[label] Nms` at DEBUG when the block exits."""
58
+ start = time.perf_counter()
59
+ try:
60
+ yield
61
+ finally:
62
+ elapsed = (time.perf_counter() - start) * 1000
63
+ logger.debug("[%s] %.0fms", label, elapsed)
pm/models/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Typed models for PushMetrics API responses.
2
+
3
+ All models use `extra="allow"` so the backend can add fields without breaking
4
+ the CLI; they're preserved through `model_dump()` so `--json` output stays
5
+ complete.
6
+ """
7
+ from pm.models.databases import Database
8
+ from pm.models.favorites import Favorite
9
+ from pm.models.folders import Folder
10
+ from pm.models.organizations import Organization
11
+ from pm.models.reports import Report
12
+ from pm.models.runs import Run, RunTaskResult
13
+ from pm.models.saved_blocks import SavedBlock
14
+ from pm.models.users import Role, User
15
+ from pm.models.webhooks import Webhook
16
+ from pm.models.workspaces import Workspace
17
+
18
+ __all__ = [
19
+ "Database",
20
+ "Favorite",
21
+ "Folder",
22
+ "Organization",
23
+ "Report",
24
+ "Role",
25
+ "Run",
26
+ "RunTaskResult",
27
+ "SavedBlock",
28
+ "User",
29
+ "Webhook",
30
+ "Workspace",
31
+ ]
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class AccessToken(BaseModel):
9
+ """A Personal Access Token's metadata (never the secret).
10
+
11
+ The backend (``AccessTokenRestApi._serialize``) exposes ``id`` as a hashid
12
+ string (used by the revoke route), plus the display ``token_prefix`` and the
13
+ lifecycle timestamps. The raw token itself is returned exactly once, at
14
+ create time, in a separate top-level ``token`` field — not on this model.
15
+ """
16
+ model_config = ConfigDict(extra="allow")
17
+
18
+ id: Optional[str] = None
19
+ name: Optional[str] = None
20
+ token_prefix: Optional[str] = None
21
+ kind: Optional[str] = None
22
+ workspace_id: Optional[int] = None
23
+ created_on: Optional[str] = None
24
+ last_used_on: Optional[str] = None
25
+ expires_on: Optional[str] = None
pm/models/blocks.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class Block(BaseModel):
9
+ """Block — the unit of work in a noteflow report.
10
+
11
+ `content_json` on the wire is a JSON-encoded string. `BlocksClient`
12
+ accepts it as a dict on create/update and serializes; on read it stays
13
+ a string so callers can parse on demand.
14
+ """
15
+ model_config = ConfigDict(extra="allow")
16
+
17
+ uid: Optional[str] = None
18
+ name: Optional[str] = None
19
+ verbose_name: Optional[str] = None
20
+ type: Optional[str] = None
21
+ content_json: Optional[Any] = None
pm/models/databases.py ADDED
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class Database(BaseModel):
9
+ model_config = ConfigDict(extra="allow")
10
+
11
+ id: Optional[int] = None
12
+ uid: Optional[str] = None
13
+ database_name: Optional[str] = None
14
+ backend: Optional[str] = None
15
+ expose_in_sqllab: Optional[bool] = None
pm/models/favorites.py ADDED
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class Favorite(BaseModel):
9
+ model_config = ConfigDict(extra="allow")
10
+
11
+ uid: Optional[str] = None
12
+ class_name: Optional[str] = None # e.g. "Report"
13
+ obj_id: Optional[int] = None
14
+ item: Optional[Any] = None # populated by post_add with the favorited object
pm/models/folders.py ADDED
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class Folder(BaseModel):
9
+ model_config = ConfigDict(extra="allow")
10
+
11
+ id: Optional[int] = None
12
+ uid: Optional[str] = None
13
+ name: Optional[str] = None
14
+ parent_id: Optional[int] = None
@@ -0,0 +1,20 @@
1
+ """Pydantic model for `pm integration` responses.
2
+
3
+ Mirrors what the backend's IntegrationRestApi (resource_name="integration",
4
+ list_columns at superset/pushmetrics/views/integration/api.py:116) returns.
5
+ Extra fields are allowed so backend additions don't break the CLI client.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ from pydantic import BaseModel, ConfigDict
12
+
13
+
14
+ class Integration(BaseModel):
15
+ model_config = ConfigDict(extra="allow")
16
+
17
+ id: Optional[int] = None
18
+ uid: Optional[str] = None
19
+ name: Optional[str] = None
20
+ type: Optional[str] = None
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class Organization(BaseModel):
9
+ model_config = ConfigDict(extra="allow")
10
+
11
+ id: Optional[int] = None
12
+ uid: Optional[str] = None
13
+ name: Optional[str] = None
@@ -0,0 +1,41 @@
1
+ """Recipient model — workspace CRM endpoint for message delivery targets.
2
+
3
+ A Recipient is a named person/team with one or more destinations
4
+ (email address, Slack channel). Used by email/slack block sends as
5
+ the delivery target; also gated by ``tools_json.messaging_policy``
6
+ (``recipients_only`` / ``recipients_only_with_approval``) on the
7
+ agent side — see ``pm_tool._apply_messaging_policy``.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, List, Optional
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+
16
+ class RecipientDestination(BaseModel):
17
+ """One delivery channel attached to a Recipient."""
18
+
19
+ model_config = ConfigDict(extra="allow")
20
+
21
+ uid: Optional[str] = None
22
+ id: Optional[int] = None
23
+ destination_type: Optional[str] = None # "email" | "slack" | ...
24
+ endpoint: Optional[str] = None
25
+ extra: Optional[Any] = None
26
+
27
+
28
+ class Recipient(BaseModel):
29
+ model_config = ConfigDict(extra="allow")
30
+
31
+ uid: Optional[str] = None
32
+ name: Optional[str] = None
33
+ # ``default_factory=list`` rather than ``= []`` — Pydantic handles
34
+ # mutable defaults safely but the bare literal still reads as a
35
+ # shared-state hazard to new readers; factory makes intent explicit.
36
+ destinations: List[RecipientDestination] = Field(default_factory=list)
37
+ tags: Optional[List[Any]] = None
38
+ extra: Optional[Any] = None
39
+ user_uid: Optional[str] = None
40
+ created_on_utc: Optional[str] = None
41
+ changed_on_utc: Optional[str] = None