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.
- pm/__init__.py +1 -0
- pm/__main__.py +4 -0
- pm/_alias.py +164 -0
- pm/auth/__init__.py +0 -0
- pm/auth/device_flow.py +187 -0
- pm/auth/exchange.py +79 -0
- pm/auth/jwt_cache.py +163 -0
- pm/auth/secret_files.py +58 -0
- pm/auth/session.py +440 -0
- pm/auth/store.py +160 -0
- pm/auth/token.py +289 -0
- pm/cli.py +282 -0
- pm/client/__init__.py +68 -0
- pm/client/access_tokens.py +54 -0
- pm/client/base.py +229 -0
- pm/client/blocks.py +461 -0
- pm/client/databases.py +147 -0
- pm/client/favorites.py +14 -0
- pm/client/folders.py +10 -0
- pm/client/integrations.py +14 -0
- pm/client/organizations.py +20 -0
- pm/client/recipients.py +92 -0
- pm/client/reports.py +148 -0
- pm/client/roles.py +100 -0
- pm/client/runs.py +134 -0
- pm/client/saved_blocks.py +13 -0
- pm/client/tableau.py +108 -0
- pm/client/users.py +71 -0
- pm/client/webhooks.py +43 -0
- pm/client/workspaces.py +34 -0
- pm/commands/__init__.py +0 -0
- pm/commands/_block_factory.py +762 -0
- pm/commands/_helpers.py +178 -0
- pm/commands/auth.py +222 -0
- pm/commands/block.py +26 -0
- pm/commands/chart.py +20 -0
- pm/commands/db.py +291 -0
- pm/commands/email.py +13 -0
- pm/commands/explore.py +20 -0
- pm/commands/favorites.py +77 -0
- pm/commands/folders.py +113 -0
- pm/commands/help.py +643 -0
- pm/commands/integrations.py +95 -0
- pm/commands/metrics_view.py +195 -0
- pm/commands/organizations.py +56 -0
- pm/commands/recipient.py +226 -0
- pm/commands/reports.py +364 -0
- pm/commands/roles.py +102 -0
- pm/commands/runs.py +214 -0
- pm/commands/saved_blocks.py +97 -0
- pm/commands/sftp.py +19 -0
- pm/commands/slack.py +13 -0
- pm/commands/slides.py +216 -0
- pm/commands/sql.py +17 -0
- pm/commands/tableau.py +148 -0
- pm/commands/token.py +107 -0
- pm/commands/users.py +262 -0
- pm/commands/webhooks.py +132 -0
- pm/commands/workspace.py +156 -0
- pm/config.py +192 -0
- pm/errors.py +228 -0
- pm/log.py +63 -0
- pm/models/__init__.py +31 -0
- pm/models/access_tokens.py +25 -0
- pm/models/blocks.py +21 -0
- pm/models/databases.py +15 -0
- pm/models/favorites.py +14 -0
- pm/models/folders.py +14 -0
- pm/models/integrations.py +20 -0
- pm/models/organizations.py +13 -0
- pm/models/recipients.py +41 -0
- pm/models/reports.py +16 -0
- pm/models/roles.py +22 -0
- pm/models/runs.py +56 -0
- pm/models/saved_blocks.py +15 -0
- pm/models/users.py +36 -0
- pm/models/webhooks.py +23 -0
- pm/models/workspaces.py +16 -0
- pm/output.py +92 -0
- pm/types.py +130 -0
- pm/validators.py +499 -0
- pushmetrics_cli-0.1.0.dist-info/METADATA +221 -0
- pushmetrics_cli-0.1.0.dist-info/RECORD +86 -0
- pushmetrics_cli-0.1.0.dist-info/WHEEL +5 -0
- pushmetrics_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
pm/models/recipients.py
ADDED
|
@@ -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
|