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/models/reports.py ADDED
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class Report(BaseModel):
9
+ model_config = ConfigDict(extra="allow")
10
+
11
+ id: Optional[int] = None
12
+ uid: Optional[str] = None
13
+ name: Optional[str] = None
14
+ type: Optional[str] = None
15
+ created_on: Optional[str] = None
16
+ workflow_enabled: Optional[bool] = None
pm/models/roles.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class Role(BaseModel):
9
+ """Workspace role.
10
+
11
+ Backend exposes ``id`` (integer, used by main_role PUTs), ``uid``
12
+ (hashid, used by assign/revoke endpoints), and ``name``. The three
13
+ main roles are ``Admin``/``User``/``Guest``; everything else listed
14
+ is treated as a group (per ``security/models.py:MainRoles``).
15
+ """
16
+ model_config = ConfigDict(extra="allow")
17
+
18
+ id: Optional[int] = None
19
+ uid: Optional[str] = None
20
+ name: Optional[str] = None
21
+ statistics: Optional[Any] = None
22
+ users_list: Optional[List[Any]] = None
pm/models/runs.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class Run(BaseModel):
9
+ model_config = ConfigDict(extra="allow")
10
+
11
+ id: Optional[int] = None
12
+ uid: Optional[str] = None
13
+ name: Optional[str] = None
14
+ status: Optional[str] = None
15
+ flow_run_id: Optional[str] = None
16
+ parent_id: Optional[int] = None
17
+ start_time: Optional[str] = None
18
+ end_time: Optional[str] = None
19
+ created_on_utc: Optional[str] = None
20
+ re_run_count: Optional[int] = None
21
+
22
+
23
+ class RunLog(BaseModel):
24
+ """A single execution log line from a noteflow run.
25
+
26
+ Mirrors ``NoteflowRunLogRestApi`` list_columns — the timestamp, the
27
+ rendered message, the run linkage keys, and ``effective_level`` (the
28
+ backend-normalised log level). Extra fields are tolerated via
29
+ ``extra='allow'``.
30
+ """
31
+ model_config = ConfigDict(extra="allow")
32
+
33
+ uid: Optional[str] = None
34
+ created_on: Optional[str] = None
35
+ message: Optional[str] = None
36
+ flow_run_id: Optional[str] = None
37
+ noteflow_run_id: Optional[int] = None
38
+ task_run_id: Optional[str] = None
39
+ effective_level: Optional[str] = None
40
+
41
+
42
+ class RunTaskResult(BaseModel):
43
+ """Per-block (per-task) execution result inside a noteflow run.
44
+
45
+ Mirrors the backend ``task_to_result`` payload — ``block_uid``,
46
+ ``status``, ``results_key``, and the parent run ``uid``. Other
47
+ fields (block_type, timing, error) may appear depending on source
48
+ and are kept via ``extra='allow'``.
49
+ """
50
+ model_config = ConfigDict(extra="allow")
51
+
52
+ block_uid: Optional[str] = None
53
+ status: Optional[str] = None
54
+ results_key: Optional[str] = None
55
+ uid: Optional[str] = None
56
+ error: Optional[Any] = None
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class SavedBlock(BaseModel):
9
+ """Template wrapper around a block, with a parameter schema for reuse."""
10
+ model_config = ConfigDict(extra="allow")
11
+
12
+ uid: Optional[str] = None
13
+ block_id: Optional[int] = None
14
+ block: Optional[Any] = None # nested block object (uid, name, type, content_json)
15
+ parameters_schema: Optional[Any] = None
pm/models/users.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Optional, Union
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ # Re-exported from ``pm.models.roles`` so ``pm.models.users.Role`` and
8
+ # ``pm.models.roles.Role`` are the same class — previously this file
9
+ # defined its own slimmer ``Role`` which silently dropped fields like
10
+ # ``uid`` whenever a role was deserialised through ``User.roles``.
11
+ from pm.models.roles import Role
12
+
13
+
14
+ class User(BaseModel):
15
+ model_config = ConfigDict(extra="allow")
16
+
17
+ id: Optional[int] = None
18
+ uid: Optional[str] = None
19
+ first_name: Optional[str] = None
20
+ last_name: Optional[str] = None
21
+ username: Optional[str] = None
22
+ email: Optional[str] = None
23
+ avatar: Optional[str] = None
24
+ # Backend may serialize these as nested objects (GET list/show), as
25
+ # bare strings, or — on the POST add response — as bare integer
26
+ # IDs (FAB serialises FK lists as ids in add_model_schema.dump).
27
+ main_role: Optional[Union[Role, str, int]] = None
28
+ roles: Optional[List[Union[Role, str, int]]] = None
29
+ last_login: Optional[str] = None
30
+ login_count: Optional[int] = None
31
+ mfa_enabled: Optional[bool] = None
32
+ created_on: Optional[str] = None
33
+ accepted_terms_on: Optional[str] = None
34
+ current_workspace_id: Optional[int] = None
35
+ current_workspace_uid: Optional[str] = None
36
+ current_workspace_name: Optional[str] = None
pm/models/webhooks.py ADDED
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional, Union
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class Webhook(BaseModel):
9
+ model_config = ConfigDict(extra="allow")
10
+
11
+ id: Optional[int] = None
12
+ uid: Optional[str] = None
13
+ token: Optional[str] = None
14
+ active: Optional[bool] = None
15
+ # `details` / `recipients` / `slack_recipients` are JSON-stringified on
16
+ # input; backend also returns parsed *_json variants. Accept anything.
17
+ details: Optional[Union[str, dict, list]] = None
18
+ details_json: Optional[Any] = None
19
+ recipients: Optional[Union[str, list]] = None
20
+ recipients_json: Optional[Any] = None
21
+ slack_recipients: Optional[Union[str, list]] = None
22
+ slack_recipients_json: Optional[Any] = None
23
+ report_id: Optional[int] = None
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class Workspace(BaseModel):
9
+ model_config = ConfigDict(extra="allow")
10
+
11
+ id: Optional[int] = None
12
+ uid: Optional[str] = None
13
+ name: Optional[str] = None
14
+ # `is_active` is injected by the list endpoint when comparing against the
15
+ # caller's session — only present on `GET /workspace/`, not on `current`.
16
+ is_active: Optional[bool] = None
pm/output.py ADDED
@@ -0,0 +1,92 @@
1
+ """Output rendering helpers — JSON mode vs human-readable columns.
2
+
3
+ Accepts both Pydantic models and plain dicts/lists transparently. Models are
4
+ converted via `model_dump()` for both JSON serialization and column extraction,
5
+ so command code can pass either form without caring.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from typing import Any, Iterable, List, Sequence
12
+
13
+
14
+ def _to_jsonable(value: Any) -> Any:
15
+ if hasattr(value, "model_dump"):
16
+ return value.model_dump()
17
+ return value
18
+
19
+
20
+ def _to_dict(row: Any) -> Any:
21
+ if hasattr(row, "model_dump"):
22
+ return row.model_dump()
23
+ return row
24
+
25
+
26
+ def emit(data: Any, *, json_output: bool, columns: Sequence[tuple] = ()) -> None:
27
+ """Print `data` to stdout.
28
+
29
+ - In JSON mode: dumps `data` directly (Pydantic models converted via `model_dump`).
30
+ - Otherwise, if `data` is a list and `columns` is provided, prints a table
31
+ where each column is `(header, key_or_callable)`. Each row is converted
32
+ to a dict before extraction so attribute-style models are transparent.
33
+ - Otherwise prints a `key: value` listing for dicts/models, or json-dumps
34
+ as fallback.
35
+ """
36
+ if json_output:
37
+ if isinstance(data, list):
38
+ data = [_to_jsonable(d) for d in data]
39
+ else:
40
+ data = _to_jsonable(data)
41
+ json.dump(data, sys.stdout, indent=2, default=str)
42
+ sys.stdout.write("\n")
43
+ return
44
+
45
+ if isinstance(data, list) and columns:
46
+ _print_table(data, columns)
47
+ return
48
+
49
+ data = _to_dict(data)
50
+ if isinstance(data, dict):
51
+ for key, value in data.items():
52
+ print(f"{key}: {_scalar(value)}")
53
+ return
54
+
55
+ json.dump(data, sys.stdout, indent=2, default=str)
56
+ sys.stdout.write("\n")
57
+
58
+
59
+ def _scalar(value: Any) -> str:
60
+ """Render one ``key: value`` cell: nested dicts/lists as compact JSON
61
+ (never a Python repr with single quotes), None as empty."""
62
+ if value is None:
63
+ return ""
64
+ if isinstance(value, (dict, list)):
65
+ return json.dumps(_to_jsonable(value), default=str, separators=(",", ":"))
66
+ return str(value)
67
+
68
+
69
+ def _print_table(rows: Iterable[Any], columns: Sequence[tuple]) -> None:
70
+ rows = [_to_dict(r) for r in rows]
71
+ headers = [c[0] for c in columns]
72
+
73
+ def _extract(row: dict, key: Any) -> str:
74
+ if row is None:
75
+ return ""
76
+ value = key(row) if callable(key) else row.get(key, "")
77
+ return "" if value is None else str(value)
78
+
79
+ string_rows: List[List[str]] = [
80
+ [_extract(row, col[1]) for col in columns] for row in rows
81
+ ]
82
+
83
+ widths = [len(h) for h in headers]
84
+ for row in string_rows:
85
+ for i, cell in enumerate(row):
86
+ widths[i] = max(widths[i], len(cell))
87
+
88
+ fmt = " ".join(f"{{:<{w}}}" for w in widths)
89
+ print(fmt.format(*headers))
90
+ print(fmt.format(*["-" * w for w in widths]))
91
+ for row in string_rows:
92
+ print(fmt.format(*row))
pm/types.py ADDED
@@ -0,0 +1,130 @@
1
+ """Block-type canonicalization with did-you-mean errors (pm-tool plan §2.7).
2
+
3
+ Resolution order:
4
+ 1. Exact match against ``CANONICAL_TYPES``.
5
+ 2. Explicit alias dict (``TYPE_ALIASES``) — case-insensitive lookup.
6
+ 3. Ambiguous inputs (``AMBIGUOUS_INPUTS``) — refuse to guess; surface
7
+ the candidate list as ``did_you_mean``.
8
+ 4. Fuzzy fallback via stdlib ``difflib.get_close_matches`` (Ratcliff /
9
+ Obershelp similarity; cutoff=0.6). Garbage input returns no
10
+ suggestions and still errors — no false positives.
11
+
12
+ Single source of truth: every block type pm-tool surfaces lives in
13
+ ``CANONICAL_TYPES``. Adding a new type is data, not code — the
14
+ canonicalizer picks it up automatically and the fuzzy matcher includes
15
+ it in suggestions.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import difflib
20
+
21
+ from pm.errors import PMError
22
+
23
+
24
+ # Block types pm-tool surfaces. Singular; matches `block.type` values
25
+ # in the backend (block_tools / block_runner).
26
+ CANONICAL_TYPES: frozenset[str] = frozenset({
27
+ "sql",
28
+ "email",
29
+ "slack",
30
+ "sftp",
31
+ "plotly",
32
+ "metrics_view",
33
+ "explore",
34
+ "agent_skill",
35
+ "agent_memory",
36
+ "tableau",
37
+ # The backend stores Google Slides blocks as `google_slides` (see
38
+ # `prefect/queryme/noteflow/template_block/google_slides.py`). The
39
+ # user-facing CLI group is `pm slides` but the persisted block.type is
40
+ # `google_slides`; `slides` is a TYPE_ALIAS so `--type slides` still
41
+ # canonicalizes correctly.
42
+ "google_slides",
43
+ })
44
+
45
+
46
+ # Explicit silent aliases — common mismatches we know about. The key is
47
+ # what the user (or LLM) might pass; the value is the canonical type.
48
+ # Case-insensitive lookup is applied on top of this dict.
49
+ TYPE_ALIASES: dict[str, str] = {
50
+ "SQL": "sql",
51
+ "sql_query": "sql",
52
+ "query": "sql",
53
+ "plot": "plotly",
54
+ "chart": "plotly",
55
+ "plotly_chart": "plotly",
56
+ "metrics": "metrics_view",
57
+ "metric_view": "metrics_view",
58
+ "metric": "metrics_view",
59
+ # Google Slides canonical form mirrors the backend block.type
60
+ "slides": "google_slides",
61
+ "google-slides": "google_slides",
62
+ }
63
+
64
+
65
+ # Strings we deliberately refuse to canonicalize — multiple plausible
66
+ # resolutions, so we error with the candidate list rather than guess.
67
+ AMBIGUOUS_INPUTS: dict[str, list[str]] = {
68
+ "view": ["metrics_view", "saved_view"],
69
+ }
70
+
71
+
72
+ # difflib cutoff. 0.6 catches single-char typos and case-different spellings
73
+ # but rejects unrelated inputs. Empirically the right balance for short
74
+ # block-type names (most are 3–13 chars).
75
+ _FUZZY_CUTOFF = 0.6
76
+ _FUZZY_MAX_SUGGESTIONS = 3
77
+
78
+
79
+ def canonicalize_type(raw: str) -> str:
80
+ """Return the canonical block-type name for ``raw``. Raises
81
+ :class:`pm.errors.PMError` with ``error_code='unknown_block_type'`` if
82
+ no canonical form is reachable; the error carries ``did_you_mean``
83
+ suggestions when fuzzy matching finds plausible candidates.
84
+ """
85
+ # Layer 1: exact match against the canonical set
86
+ if raw in CANONICAL_TYPES:
87
+ return raw
88
+
89
+ # Layer 2: explicit aliases (exact + case-insensitive)
90
+ if raw in TYPE_ALIASES:
91
+ return TYPE_ALIASES[raw]
92
+ lowered = raw.lower()
93
+ for alias, canonical in TYPE_ALIASES.items():
94
+ if alias.lower() == lowered:
95
+ return canonical
96
+
97
+ # Layer 3: ambiguous strings — refuse to guess
98
+ if raw in AMBIGUOUS_INPUTS:
99
+ raise PMError(
100
+ error_code="unknown_block_type",
101
+ message=f"Ambiguous type {raw!r}: multiple candidates",
102
+ received=raw,
103
+ did_you_mean=AMBIGUOUS_INPUTS[raw],
104
+ allowed=sorted(CANONICAL_TYPES),
105
+ help="pm help block",
106
+ )
107
+
108
+ # Layer 4: fuzzy fallback — match against canonical types AND aliases,
109
+ # then map alias hits to their canonical form so the suggestions the
110
+ # agent sees are always real block types.
111
+ candidates = sorted(CANONICAL_TYPES | set(TYPE_ALIASES))
112
+ matches = difflib.get_close_matches(
113
+ raw,
114
+ candidates,
115
+ n=_FUZZY_MAX_SUGGESTIONS,
116
+ cutoff=_FUZZY_CUTOFF,
117
+ )
118
+ suggestions: list[str] = []
119
+ for m in matches:
120
+ canonical = TYPE_ALIASES.get(m, m)
121
+ if canonical not in suggestions:
122
+ suggestions.append(canonical)
123
+ raise PMError(
124
+ error_code="unknown_block_type",
125
+ message=f"Unknown type {raw!r}",
126
+ received=raw,
127
+ did_you_mean=suggestions,
128
+ allowed=sorted(CANONICAL_TYPES),
129
+ help="pm help block",
130
+ )