publinker-cli 0.6.2__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.
@@ -0,0 +1,14 @@
1
+ """publinker-cli — per-user OAuth CLI over publinker-api.
2
+
3
+ Public surface:
4
+ - `PublinkerClient` — authenticated HTTP client (importable for the future MCP wrapper).
5
+ - `app` (in `publinker_cli.__main__`) — Typer CLI entry point.
6
+ """
7
+
8
+ # Defined before the client import: client.py reads it for the User-Agent.
9
+ # Bump together with pyproject.toml and the server-side latest-version constant.
10
+ __version__ = "0.6.2"
11
+
12
+ from publinker_cli.client import PublinkerClient # noqa: E402
13
+
14
+ __all__ = ["PublinkerClient", "__version__"]
@@ -0,0 +1,83 @@
1
+ """publinker — CLI entry point.
2
+
3
+ `pip install publinker-cli` registers this module as the `publinker` console script
4
+ via the [project.scripts] section of pyproject.toml.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from publinker_cli import __version__
12
+ from publinker_cli.commands import auth_cmds, use as use_cmd
13
+ from publinker_cli.commands import report_options, token_cmd, update as update_cmd
14
+ from publinker_cli.commands import skill as skill_cmd, write as write_cmd
15
+ from publinker_cli.dispatch_cli import register_routes
16
+ from publinker_cli.manifest import load_manifest
17
+
18
+ app = typer.Typer(
19
+ name="publinker",
20
+ help="CLI for publinker-api. Per-user Auth0 OAuth, OS-keychain token storage.",
21
+ add_completion=True,
22
+ no_args_is_help=True,
23
+ )
24
+
25
+ # Hand-written top-level commands (auth lifecycle + site selector).
26
+ app.command("login")(auth_cmds.login)
27
+ app.command("logout")(auth_cmds.logout)
28
+ app.command("whoami")(auth_cmds.whoami)
29
+ app.command(
30
+ "token",
31
+ help=(
32
+ "Print a live access token (stdout only) for curl/browser debugging. "
33
+ "--id for the id_token, --json for the full bundle."
34
+ ),
35
+ )(token_cmd.token_command)
36
+ app.command(
37
+ "use",
38
+ help="Set or clear the active publinker site (persisted across sessions).",
39
+ )(use_cmd.use_command)
40
+ app.command(
41
+ "update",
42
+ help="Check for (--check) or install a newer publinker-cli. No login needed.",
43
+ )(update_cmd.update_command)
44
+ app.add_typer(write_cmd.app, name="write")
45
+ app.add_typer(skill_cmd.app, name="skill")
46
+
47
+ # Everything else (manifest-defined reads and guarded mutations) is materialized
48
+ # from publinker_cli/routes.yaml. Adding an API operation is a YAML edit plus a
49
+ # passing test run; no command boilerplate is required.
50
+ _groups = register_routes(app, load_manifest())
51
+
52
+ # Hand-written companion to `site report get` — surfaces the platform +
53
+ # dimensions_keys enums we've verified against prod.
54
+ _report_group = _groups.get(("site", "report"))
55
+ if _report_group is not None:
56
+ _report_group.command(
57
+ "list-options",
58
+ help="Show verified platform + dimensions_keys values for `report get`.",
59
+ )(report_options.list_options)
60
+
61
+
62
+ def _version_callback(value: bool) -> None:
63
+ if value:
64
+ typer.echo(f"publinker {__version__}")
65
+ raise typer.Exit()
66
+
67
+
68
+ @app.callback()
69
+ def _root(
70
+ version: bool = typer.Option(
71
+ None,
72
+ "--version",
73
+ "-V",
74
+ callback=_version_callback,
75
+ is_eager=True,
76
+ help="Show version and exit.",
77
+ ),
78
+ ) -> None:
79
+ """publinker — CLI for publinker-api."""
80
+
81
+
82
+ if __name__ == "__main__":
83
+ app()
@@ -0,0 +1,73 @@
1
+ """Which slice of the route manifest this build exposes (DEV-1566).
2
+
3
+ The manifest describes 130 routes, including the whole `/overwatch` admin
4
+ surface, billing, invoices, subscriptions and Auth0 user management. Shipping
5
+ all of it to an external customer is an information-disclosure problem — the
6
+ wheel becomes a map of the internal API — and the audit that produced
7
+ DEV-1566 flagged it as a blocker for external release.
8
+
9
+ Every route therefore carries `audience: customer | internal`. **`internal` is
10
+ the default and the safe one**: a route added without an explicit audience
11
+ never reaches a customer build, so forgetting the field fails closed.
12
+
13
+ Resolution order, first match wins:
14
+
15
+ 1. ``PUBLINKER_CLI_AUDIENCE`` in the environment — for tests and for an
16
+ internal user who wants to see the customer surface without reinstalling.
17
+ 2. ``_BUILD_AUDIENCE`` below, rewritten by ``scripts/build-customer-wheel.sh``
18
+ when packaging the customer wheel.
19
+ 3. ``internal`` — a plain ``pip install -e .`` from the repo is an internal
20
+ developer install.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+
27
+ INTERNAL = "internal"
28
+ CUSTOMER = "customer"
29
+ VALID = (CUSTOMER, INTERNAL)
30
+
31
+ ENV_VAR = "PUBLINKER_CLI_AUDIENCE"
32
+
33
+ # Rewritten at package time by scripts/build-customer-wheel.sh. Leave as
34
+ # "internal" in the repo — the committed state is the developer install.
35
+ _BUILD_AUDIENCE = "customer"
36
+
37
+
38
+ class AudienceError(ValueError):
39
+ """Raised when an audience value is not one we recognise."""
40
+
41
+
42
+ def _validate(value: str, source: str) -> str:
43
+ if value not in VALID:
44
+ raise AudienceError(
45
+ f"{source} is {value!r}; expected one of {', '.join(VALID)}"
46
+ )
47
+ return value
48
+
49
+
50
+ def current_audience() -> str:
51
+ """The audience this process should expose."""
52
+ override = os.environ.get(ENV_VAR, "").strip().lower()
53
+ if override:
54
+ return _validate(override, f"${ENV_VAR}")
55
+ return _validate(_BUILD_AUDIENCE, "the packaged build audience")
56
+
57
+
58
+ def is_customer_build() -> bool:
59
+ return current_audience() == CUSTOMER
60
+
61
+
62
+ def route_is_visible(route_audience: str | None, audience: str | None = None) -> bool:
63
+ """Whether a route with this `audience` ships in the given build.
64
+
65
+ An internal build sees everything. A customer build sees only routes
66
+ explicitly marked `customer` — anything missing, misspelled or `internal`
67
+ is hidden, so the failure mode is a missing command rather than an
68
+ exposed admin endpoint.
69
+ """
70
+ audience = audience or current_audience()
71
+ if audience == INTERNAL:
72
+ return True
73
+ return route_audience == CUSTOMER
publinker_cli/auth.py ADDED
@@ -0,0 +1,152 @@
1
+ """Auth0 Device Authorization Flow (RFC 8628).
2
+
3
+ Two operations:
4
+ - `device_login()` — runs the full device flow end-to-end; returns a TokenBundle.
5
+ - `refresh_token(...)` — exchanges a refresh token for a new access token.
6
+
7
+ Both are pure: no keyring writes. The caller decides what to persist.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ import webbrowser
14
+ from dataclasses import dataclass
15
+ from typing import Callable, Optional
16
+
17
+ import requests
18
+
19
+ from publinker_cli.config import Auth0Config, auth0_config
20
+ from publinker_cli.keyring_store import TokenBundle
21
+
22
+
23
+ class AuthError(Exception):
24
+ """Raised when the Auth0 flow fails terminally (user denied, expired, server error)."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class DeviceCode:
29
+ device_code: str
30
+ user_code: str
31
+ verification_uri_complete: str
32
+ expires_in: int # seconds
33
+ interval: int # poll seconds
34
+
35
+
36
+ def request_device_code(cfg: Optional[Auth0Config] = None) -> DeviceCode:
37
+ """Step 1 of RFC 8628 — request a device code from Auth0."""
38
+ cfg = cfg or auth0_config()
39
+ resp = requests.post(
40
+ cfg.device_code_url,
41
+ data={
42
+ "client_id": cfg.client_id,
43
+ "scope": cfg.scopes,
44
+ "audience": cfg.audience,
45
+ },
46
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
47
+ timeout=10,
48
+ )
49
+ if resp.status_code != 200:
50
+ raise AuthError(f"device code request failed: {resp.status_code} {resp.text}")
51
+ body = resp.json()
52
+ return DeviceCode(
53
+ device_code=body["device_code"],
54
+ user_code=body["user_code"],
55
+ verification_uri_complete=body["verification_uri_complete"],
56
+ expires_in=int(body["expires_in"]),
57
+ interval=int(body["interval"]),
58
+ )
59
+
60
+
61
+ def poll_for_token(
62
+ device_code: DeviceCode,
63
+ cfg: Optional[Auth0Config] = None,
64
+ sleep: Callable[[float], None] = time.sleep,
65
+ now: Callable[[], float] = time.monotonic,
66
+ ) -> TokenBundle:
67
+ """Step 2 of RFC 8628 — poll the token endpoint until the user authorizes or it expires.
68
+
69
+ `sleep` and `now` are injectable for tests.
70
+ """
71
+ cfg = cfg or auth0_config()
72
+ interval = device_code.interval
73
+ deadline = now() + device_code.expires_in
74
+
75
+ while now() < deadline:
76
+ sleep(interval)
77
+ resp = requests.post(
78
+ cfg.token_url,
79
+ data={
80
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
81
+ "device_code": device_code.device_code,
82
+ "client_id": cfg.client_id,
83
+ },
84
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
85
+ timeout=10,
86
+ )
87
+ if resp.status_code == 200:
88
+ return TokenBundle.from_token_response(resp.json())
89
+
90
+ # 4xx with `error` field — either keep polling (pending/slow_down) or bail.
91
+ try:
92
+ err = resp.json().get("error", "")
93
+ except ValueError:
94
+ err = ""
95
+
96
+ if err == "authorization_pending":
97
+ continue
98
+ if err == "slow_down":
99
+ interval += 5
100
+ continue
101
+ if err in {"access_denied", "expired_token"}:
102
+ raise AuthError(f"login failed: {err}")
103
+ raise AuthError(
104
+ f"unexpected token endpoint response {resp.status_code}: {resp.text}"
105
+ )
106
+
107
+ raise AuthError("device code expired before user completed login")
108
+
109
+
110
+ def refresh_access_token(
111
+ refresh_token: str, cfg: Optional[Auth0Config] = None
112
+ ) -> TokenBundle:
113
+ """Exchange a refresh token for a new access token (and rotated refresh token)."""
114
+ cfg = cfg or auth0_config()
115
+ resp = requests.post(
116
+ cfg.token_url,
117
+ data={
118
+ "grant_type": "refresh_token",
119
+ "client_id": cfg.client_id,
120
+ "refresh_token": refresh_token,
121
+ },
122
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
123
+ timeout=10,
124
+ )
125
+ if resp.status_code != 200:
126
+ raise AuthError(f"refresh failed: {resp.status_code} {resp.text}")
127
+ body = resp.json()
128
+ # Auth0 rotates refresh tokens — the response may contain a NEW refresh_token, or
129
+ # (rarely) reuse the old one. Carry forward the prior refresh token if the response
130
+ # omits it, so the caller never loses ability to refresh again.
131
+ if "refresh_token" not in body:
132
+ body["refresh_token"] = refresh_token
133
+ return TokenBundle.from_token_response(body)
134
+
135
+
136
+ def device_login(
137
+ cfg: Optional[Auth0Config] = None,
138
+ open_browser: bool = True,
139
+ on_user_code: Optional[Callable[[DeviceCode], None]] = None,
140
+ ) -> TokenBundle:
141
+ """Run the full device flow end-to-end. Returns the new token bundle.
142
+
143
+ `on_user_code` is invoked with the device code so the caller can print the
144
+ user_code + verification URL. If `open_browser` is True the verification URL
145
+ is also opened in the default browser.
146
+ """
147
+ code = request_device_code(cfg)
148
+ if on_user_code is not None:
149
+ on_user_code(code)
150
+ if open_browser:
151
+ webbrowser.open(code.verification_uri_complete)
152
+ return poll_for_token(code, cfg)
@@ -0,0 +1,166 @@
1
+ """Authenticated HTTP client for publinker-api.
2
+
3
+ Importable by both the CLI commands and the future MCP server. Handles silent
4
+ token refresh on 401 — the caller never sees an auth retry.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+ from typing import Any, Optional
13
+
14
+ import requests
15
+
16
+ from publinker_cli import auth, keyring_store, version
17
+ from publinker_cli.config import DEFAULT_STAGE, api_base_url
18
+
19
+ # A ready access token (e.g. from `publinker token` on a logged-in machine) for
20
+ # CI and containers. Used as-is: never stored, never refreshed.
21
+ TOKEN_ENV_VAR = "PUBLINKER_TOKEN"
22
+
23
+
24
+ class NotAuthenticatedError(Exception):
25
+ """No token in keyring (or it's been revoked and can't be refreshed)."""
26
+
27
+
28
+ class APIError(Exception):
29
+ """publinker-api returned a non-2xx response."""
30
+
31
+ def __init__(self, status: int, body: str, path: str) -> None:
32
+ super().__init__(f"{path} → HTTP {status}: {body}")
33
+ self.status = status
34
+ self.body = body
35
+ self.path = path
36
+
37
+
38
+ class PublinkerClient:
39
+ """Thin authenticated wrapper over publinker-api.
40
+
41
+ Auto-refreshes the access token when it's about to expire (60s leeway) and on
42
+ a 401 response (one retry). After a failed refresh the bundle is cleared from
43
+ the keyring so the next operation triggers a fresh `login`.
44
+ """
45
+
46
+ def __init__(
47
+ self, stage: str = DEFAULT_STAGE, session: Optional[requests.Session] = None
48
+ ) -> None:
49
+ self.stage = stage
50
+ self.base_url = api_base_url(stage).rstrip("/")
51
+ self._session = session or requests.Session()
52
+
53
+ # ─── Auth token management ──────────────────────────────────────────────
54
+
55
+ def _bundle(self) -> keyring_store.TokenBundle:
56
+ env_token = os.environ.get(TOKEN_ENV_VAR, "").strip()
57
+ if env_token:
58
+ return keyring_store.TokenBundle(env_token, "", "", 2**31 - 1)
59
+ bundle = keyring_store.load()
60
+ if bundle is None:
61
+ raise NotAuthenticatedError("not logged in — run `publinker login`")
62
+ if bundle.is_expired():
63
+ bundle = self._refresh(bundle)
64
+ return bundle
65
+
66
+ def current_bundle(self) -> keyring_store.TokenBundle:
67
+ """Return a token bundle guaranteed valid right now.
68
+
69
+ Public counterpart to `_bundle()` for callers that need the token itself
70
+ rather than an API call (e.g. `publinker token`). Refreshes and re-saves
71
+ when expired; raises NotAuthenticatedError when the session is gone.
72
+ """
73
+ return self._bundle()
74
+
75
+ def _refresh(self, bundle: keyring_store.TokenBundle) -> keyring_store.TokenBundle:
76
+ if os.environ.get(TOKEN_ENV_VAR, "").strip():
77
+ # Leave the stored login alone: the env token is the one that failed.
78
+ raise NotAuthenticatedError(
79
+ f"${TOKEN_ENV_VAR} was rejected (expired?) — "
80
+ "mint a new one with `publinker token`"
81
+ )
82
+ if not bundle.refresh_token:
83
+ keyring_store.clear()
84
+ raise NotAuthenticatedError("session expired — run `publinker login`")
85
+ try:
86
+ new_bundle = auth.refresh_access_token(bundle.refresh_token)
87
+ except auth.AuthError as exc:
88
+ keyring_store.clear()
89
+ raise NotAuthenticatedError(
90
+ f"session expired ({exc}) — run `publinker login`"
91
+ ) from exc
92
+ keyring_store.save(new_bundle)
93
+ return new_bundle
94
+
95
+ # ─── HTTP ───────────────────────────────────────────────────────────────
96
+
97
+ def _request(self, method: str, path: str, **kwargs: Any) -> dict:
98
+ bundle = self._bundle()
99
+ url = f"{self.base_url}{path}"
100
+ headers = kwargs.pop("headers", {})
101
+ headers["Authorization"] = f"Bearer {bundle.access_token}"
102
+ headers["User-Agent"] = version.USER_AGENT
103
+
104
+ resp = self._session.request(method, url, headers=headers, timeout=30, **kwargs)
105
+ if resp.status_code == 401:
106
+ # The token wasn't actually expired by our clock, but the API rejected it.
107
+ # Force a refresh and retry once.
108
+ bundle = self._refresh(bundle)
109
+ headers["Authorization"] = f"Bearer {bundle.access_token}"
110
+ resp = self._session.request(
111
+ method, url, headers=headers, timeout=30, **kwargs
112
+ )
113
+ if not resp.ok:
114
+ raise APIError(resp.status_code, resp.text, path)
115
+ version.observe(resp.headers)
116
+
117
+ # The API returns 204 No Content for some endpoints; tolerate empty bodies.
118
+ if not resp.content:
119
+ return {}
120
+ try:
121
+ return resp.json()
122
+ except ValueError:
123
+ return {"raw": resp.text}
124
+
125
+ def get(self, path: str, **kwargs: Any) -> dict:
126
+ return self._request("GET", path, **kwargs)
127
+
128
+ def request(self, method: str, path: str, **kwargs: Any) -> dict:
129
+ """Public method/path entry point used by the manifest dispatcher."""
130
+ return self._request(method, path, **kwargs)
131
+
132
+ # ─── v0 resource shortcuts ──────────────────────────────────────────────
133
+
134
+ def list_sites(self) -> list[str]:
135
+ """`GET /site` → list of site domain strings."""
136
+ return self.get("/site").get("sites", [])
137
+
138
+ def list_companies(self) -> list[str]:
139
+ """`GET /company` → list of company id strings."""
140
+ return self.get("/company").get("companyIds", [])
141
+
142
+ # ─── Local profile ──────────────────────────────────────────────────────
143
+
144
+ def whoami(self) -> dict:
145
+ """Decode the id_token locally — no API call, no token cost.
146
+
147
+ Returns the JWT claims (email, name, sub, etc.). The id_token's signature
148
+ is NOT verified here — we issued it ourselves to ourselves; tampering would
149
+ only fool the user, not the API.
150
+ """
151
+ bundle = keyring_store.load()
152
+ if bundle is None or not bundle.id_token:
153
+ raise NotAuthenticatedError("not logged in — run `publinker login`")
154
+ return _decode_jwt_payload(bundle.id_token)
155
+
156
+
157
+ def _decode_jwt_payload(token: str) -> dict:
158
+ """Decode the payload segment of a JWT without verifying the signature."""
159
+ try:
160
+ _, payload_b64, _ = token.split(".")
161
+ except ValueError as exc:
162
+ raise ValueError(f"malformed JWT: {exc}") from exc
163
+ # JWT uses base64url without padding — re-pad before decoding.
164
+ padding = "=" * (-len(payload_b64) % 4)
165
+ decoded = base64.urlsafe_b64decode(payload_b64 + padding)
166
+ return json.loads(decoded)
File without changes
@@ -0,0 +1,69 @@
1
+ """login / logout / whoami commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from publinker_cli import auth, keyring_store, state, write_gate
10
+ from publinker_cli.client import NotAuthenticatedError, PublinkerClient
11
+
12
+ console = Console()
13
+
14
+
15
+ def login() -> None:
16
+ """Authenticate via Auth0 Device Authorization Flow."""
17
+
18
+ def on_user_code(code: auth.DeviceCode) -> None:
19
+ console.print(
20
+ "\n[bold]Confirm the code in your browser:[/bold] "
21
+ f"[cyan bold]{code.user_code}[/cyan bold]"
22
+ )
23
+ console.print(f"[dim]URL: {code.verification_uri_complete}[/dim]")
24
+ console.print(
25
+ f"[dim]Opening your browser... (expires in {code.expires_in // 60} min)[/dim]\n"
26
+ )
27
+
28
+ with console.status("[bold green]Waiting for authorization...", spinner="dots"):
29
+ try:
30
+ bundle = auth.device_login(on_user_code=on_user_code)
31
+ except auth.AuthError as exc:
32
+ console.print(f"[red]Login failed:[/red] {exc}")
33
+ raise typer.Exit(code=1)
34
+ keyring_store.save(bundle)
35
+ console.print("[green]✓ Logged in.[/green]")
36
+
37
+
38
+ def logout() -> None:
39
+ """Clear the cached token and any retired file-backed write state."""
40
+ keyring_store.clear()
41
+ write_gate.clear_legacy_state()
42
+ console.print("[green]✓ Logged out.[/green]")
43
+
44
+
45
+ def whoami() -> None:
46
+ """Show the identity behind the current token (no API call)."""
47
+ try:
48
+ client = PublinkerClient()
49
+ claims = client.whoami()
50
+ except NotAuthenticatedError as exc:
51
+ console.print(f"[red]{exc}[/red]")
52
+ raise typer.Exit(code=1)
53
+
54
+ table = Table(show_header=False, box=None)
55
+ table.add_column(style="cyan", no_wrap=True)
56
+ table.add_column()
57
+ for key in ("email", "name", "sub", "nickname", "email_verified"):
58
+ if key in claims:
59
+ table.add_row(key, str(claims[key]))
60
+
61
+ selected = state.active_site()
62
+ if selected:
63
+ table.add_row("active site", selected)
64
+ else:
65
+ table.add_row(
66
+ "active site",
67
+ "[dim]none (run `publinker use <domain>` to set one)[/dim]",
68
+ )
69
+ console.print(table)
@@ -0,0 +1,100 @@
1
+ """`publinker site report list-options` — quick reference for report filters.
2
+
3
+ The `/site/{site}/report` endpoint's `platform` and `dimensions_keys` filters
4
+ have no enum on the server (whatever the ETL bucketed by, the API will look
5
+ up). This command surfaces the values we've actually seen in the codebase
6
+ and verified against prod, so new users don't have to fish in DDB.
7
+
8
+ If the set grows or shifts, update the constants here — they're
9
+ intentionally hand-curated rather than introspected, because the API
10
+ itself has no listing endpoint to query.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ console = Console()
19
+
20
+
21
+ # (platform, source / extractor, account_id format example)
22
+ _PLATFORMS: tuple[tuple[str, str, str], ...] = (
23
+ (
24
+ "google_admanager",
25
+ "Google Ad Manager reporting API",
26
+ "12345678 (GAM network code)",
27
+ ),
28
+ (
29
+ "google_ads",
30
+ "Google Ads API",
31
+ "1234567890 (Ads customer id)",
32
+ ),
33
+ (
34
+ "active_campaign",
35
+ "ActiveCampaign API",
36
+ "<AC account>",
37
+ ),
38
+ (
39
+ "google_postmaster",
40
+ "Google Postmaster Tools",
41
+ "<sender domain>",
42
+ ),
43
+ (
44
+ "escalated",
45
+ "Publinker escalated-issues pipeline",
46
+ "publinker",
47
+ ),
48
+ )
49
+
50
+
51
+ # (dimensions_keys, what each row represents, typical platform)
52
+ _DIMENSIONS: tuple[tuple[str, str, str], ...] = (
53
+ ("date", "one row per day (aggregate)", "any"),
54
+ (
55
+ "date,channel",
56
+ "one row per (day, traffic channel)",
57
+ "google_admanager / google_ads",
58
+ ),
59
+ ("date,channel,adunit", "one row per (day, channel, ad unit)", "google_admanager"),
60
+ (
61
+ "date,channel,country_code,country_name,url,adunit",
62
+ "full detail — one row per URL × adunit per day",
63
+ "google_admanager",
64
+ ),
65
+ ("date,domain,device,country", "escalated-issues breakdown", "escalated"),
66
+ (
67
+ "date,channel,country_code,country_name,url,adunit:report_type=adspeed",
68
+ "adspeed-style report (separate fact set)",
69
+ "google_admanager",
70
+ ),
71
+ )
72
+
73
+
74
+ def list_options() -> None:
75
+ """Print the verified platform + dimensions_keys values."""
76
+ platforms = Table(
77
+ title="platform values (publinker site report get --platform <X>)"
78
+ )
79
+ platforms.add_column("value", style="cyan")
80
+ platforms.add_column("source")
81
+ platforms.add_column("account_id example", style="dim")
82
+ for row in _PLATFORMS:
83
+ platforms.add_row(*row)
84
+ console.print(platforms)
85
+
86
+ dims = Table(
87
+ title="dimensions_keys values (publinker site report get --dimensions-keys <X>)"
88
+ )
89
+ dims.add_column("value", style="cyan", overflow="fold")
90
+ dims.add_column("granularity")
91
+ dims.add_column("platform", style="dim")
92
+ for row in _DIMENSIONS:
93
+ dims.add_row(*row)
94
+ console.print(dims)
95
+
96
+ console.print(
97
+ "\n[dim]The set is what we've verified against prod; the API itself "
98
+ "has no listing endpoint. If you find a working combination not "
99
+ "listed here, please add it to cli/publinker_cli/commands/report_options.py.[/dim]"
100
+ )