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/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
|
pm/models/workspaces.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 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
|
+
)
|