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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
pm/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pm.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
pm/_alias.py ADDED
@@ -0,0 +1,164 @@
1
+ """Click group with command-name aliases + fuzzy "Did you mean?".
2
+
3
+ pm-tool introduces canonical verbs (`list`, `create`, `delete`, `get`) that
4
+ replace older non-REST names (`ls`, `add`, `remove`). The legacy names stay
5
+ as soft aliases — both forms work — so existing CLI users aren't broken.
6
+ Same pattern for the plural→singular group rename (`reports`→`report`).
7
+
8
+ When neither a direct match nor an alias resolves, the group attempts a
9
+ fuzzy suggestion via difflib so typos like ``pm blcok`` get a hint
10
+ toward ``block`` instead of a bare "No such command" error.
11
+
12
+ Usage:
13
+
14
+ from pm._alias import AliasedGroup
15
+
16
+ @click.group(name="user", cls=AliasedGroup, aliases={"add": "create"})
17
+ def user():
18
+ ...
19
+
20
+ @user.command(name="create")
21
+ def create_user(...):
22
+ ...
23
+
24
+ Now ``pm user create`` and ``pm user add`` both resolve to ``create_user``.
25
+ Aliases are NOT listed in ``--help`` (to keep the canonical surface clean);
26
+ ``pm help`` (pm-foundation) is the agent-facing help system and will
27
+ mention compatibility aliases explicitly when relevant.
28
+
29
+ Groups that don't need legacy aliases still benefit from the fuzzy
30
+ suggestion behavior — pass ``aliases={}`` (the default) and the
31
+ suggestion path is the only added behavior over a plain ``click.Group``.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import difflib
36
+ from typing import Optional
37
+
38
+ import click
39
+
40
+
41
+ # Fuzzy threshold. Codex review round 2 (2026-06-09) showed that 0.7
42
+ # is too tight for 3-char verbs — common transpositions/substitutions
43
+ # like ``gte→get`` and ``rnu→run`` score about 0.67. 0.6 picks those
44
+ # up while still rejecting genuinely-different tokens (``xyz`` vs
45
+ # any verb scores ~0.0). The MIN_LEN floor was also dropped: short
46
+ # tokens like ``ge → get`` (0.8) are HELPFUL suggestions, not noise.
47
+ _FUZZY_CUTOFF = 0.6
48
+
49
+
50
+ class AliasedGroup(click.Group):
51
+ """Click group that resolves a configurable map of legacy command
52
+ names to their canonical equivalents.
53
+
54
+ Aliases are checked AFTER direct command lookup, so a real command
55
+ with the same name as an alias would always win (defensive — no
56
+ silent shadowing). Unknown commands trigger a fuzzy suggestion
57
+ when one is available; otherwise Click's standard error renders.
58
+
59
+ Subgroups created via ``@aliased_group_instance.group()`` inherit
60
+ this class via the ``group()`` override below — without it, nested
61
+ ``@cli.group()`` declarations default back to ``click.Group`` and
62
+ miss the typo behavior. Codex review round 2 catch on
63
+ ``cli/pm/cli.py:85`` (``@cli.group(name="config")``).
64
+ """
65
+
66
+ def __init__(self, *args, aliases: Optional[dict[str, str]] = None, **kwargs):
67
+ super().__init__(*args, **kwargs)
68
+ self._aliases: dict[str, str] = aliases or {}
69
+
70
+ def group(self, *args, **kwargs):
71
+ """Default subgroup class to ``type(self)`` so nested
72
+ ``@aliased_group.group()`` declarations inherit the runtime
73
+ class — transitively, for subclasses of ``AliasedGroup``. Codex
74
+ round 3 catch: hard-coding ``AliasedGroup`` here lost subclass
75
+ behavior on subgroups (a hypothetical ``FancyAliasedGroup``
76
+ would have nested groups fall back to plain ``AliasedGroup``).
77
+ Callers can still override with ``cls=`` if they need a
78
+ different group class for a specific subgroup.
79
+ """
80
+ kwargs.setdefault("cls", type(self))
81
+ return super().group(*args, **kwargs)
82
+
83
+ def get_command(self, ctx: click.Context, cmd_name: str):
84
+ rv = super().get_command(ctx, cmd_name)
85
+ if rv is not None:
86
+ return rv
87
+ canonical = self._aliases.get(cmd_name)
88
+ if canonical is not None:
89
+ return super().get_command(ctx, canonical)
90
+ return None
91
+
92
+ def resolve_command(self, ctx: click.Context, args):
93
+ """Run Click's normal resolution; on a missing-command
94
+ ``UsageError``, attempt a fuzzy suggestion.
95
+
96
+ Codex round 2 catch: catching every ``UsageError`` over-fires
97
+ on option errors like ``pm --block`` ("No such option"), which
98
+ would mis-render as "Did you mean 'block'?". Filter by the
99
+ ``No such command`` prefix Click uses for unknown-command
100
+ errors so option errors fall through to the standard renderer.
101
+ """
102
+ try:
103
+ return super().resolve_command(ctx, args)
104
+ except click.UsageError as exc:
105
+ if not _is_no_such_command_error(exc):
106
+ raise
107
+ attempted = args[0] if args else ""
108
+ suggestion = self._fuzzy_suggest(ctx, attempted)
109
+ if suggestion is None:
110
+ raise
111
+ raise click.UsageError(
112
+ f"No such command {attempted!r}. "
113
+ f"Did you mean {suggestion!r}?",
114
+ ctx=ctx,
115
+ ) from exc
116
+
117
+ def _fuzzy_suggest(self, ctx: click.Context, attempted: str) -> Optional[str]:
118
+ """Return the best fuzzy match for ``attempted`` against this
119
+ group's registered commands, or None when no candidate scores
120
+ above the cutoff.
121
+
122
+ Codex round 2 catch: aliases are included in the candidate
123
+ SEARCH set so typos near a legacy alias still resolve, but
124
+ the returned suggestion is the canonical command — never the
125
+ alias. This matches the project's "aliases work but aren't
126
+ advertised in --help" stance.
127
+
128
+ Codex round 3 catch: defensive check that the resolved
129
+ canonical actually exists in ``list_commands``. A malformed
130
+ alias map (``aliases={"ls": "nonexistent"}``) would otherwise
131
+ surface ``"nonexistent"`` as the suggestion — a bad UX on
132
+ top of a config error.
133
+ """
134
+ if not attempted:
135
+ return None
136
+ registered = set(self.list_commands(ctx))
137
+ candidates = list(registered) + list(self._aliases.keys())
138
+ matches = difflib.get_close_matches(
139
+ attempted, candidates, n=1, cutoff=_FUZZY_CUTOFF,
140
+ )
141
+ if not matches:
142
+ return None
143
+ match = matches[0]
144
+ # Resolve alias → canonical. If ``match`` is a real command,
145
+ # ``self._aliases.get(match)`` returns None and we keep the
146
+ # original — the get() default lookup pattern.
147
+ resolved = self._aliases.get(match, match)
148
+ # Defensive: drop dangling-alias suggestions rather than
149
+ # render a misleading "Did you mean ..." for a command that
150
+ # doesn't exist.
151
+ if resolved not in registered:
152
+ return None
153
+ return resolved
154
+
155
+
156
+ def _is_no_such_command_error(exc: click.UsageError) -> bool:
157
+ """Click renders unknown-command errors with a message starting
158
+ ``"No such command"``. Option errors start ``"No such option"`` and
159
+ other UsageErrors (missing args, invalid values) have unrelated
160
+ prefixes. Filter strictly so the fuzzy suggestion only attaches
161
+ where it makes sense.
162
+ """
163
+ message = exc.message if exc.message else ""
164
+ return message.lower().startswith("no such command")
pm/auth/__init__.py ADDED
File without changes
pm/auth/device_flow.py ADDED
@@ -0,0 +1,187 @@
1
+ """Auth0 Device Authorization Flow (RFC 8628)."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import time
6
+ import webbrowser
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+ import requests
11
+
12
+ from pm.log import log_request, log_response, redact_secrets
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class DeviceFlowError(RuntimeError):
18
+ pass
19
+
20
+
21
+ # auth0_domain is optional on a profile (token login never needs it), so the
22
+ # device flow is where its absence is caught: before any URL is built from it.
23
+ MISSING_DOMAIN_MESSAGE = (
24
+ "Browser login needs auth0_domain on this profile. Set it with "
25
+ "`pm config set <profile> auth0_domain auth.pushmetrics.io`, or use "
26
+ "`pm login --token` instead."
27
+ )
28
+
29
+
30
+ def _require_domain(domain: Optional[str]) -> str:
31
+ if not domain:
32
+ raise DeviceFlowError(MISSING_DOMAIN_MESSAGE)
33
+ return domain
34
+
35
+
36
+ @dataclass
37
+ class DeviceCodePayload:
38
+ device_code: str
39
+ user_code: str
40
+ verification_uri: str
41
+ verification_uri_complete: str
42
+ expires_in: int
43
+ interval: int
44
+
45
+
46
+ @dataclass
47
+ class TokenPayload:
48
+ access_token: str
49
+ refresh_token: Optional[str]
50
+ id_token: Optional[str]
51
+ expires_in: int
52
+ token_type: str
53
+ scope: Optional[str]
54
+
55
+
56
+ def request_device_code(
57
+ domain: Optional[str],
58
+ client_id: str,
59
+ audience: Optional[str],
60
+ scope: str = "openid profile email offline_access",
61
+ ) -> DeviceCodePayload:
62
+ url = f"https://{_require_domain(domain)}/oauth/device/code"
63
+ started = log_request(logger, "POST", url)
64
+ response = requests.post(
65
+ url,
66
+ data={
67
+ "client_id": client_id,
68
+ "scope": scope,
69
+ "audience": audience,
70
+ },
71
+ timeout=30,
72
+ )
73
+ log_response(logger, response, started)
74
+ if response.status_code != 200:
75
+ raise DeviceFlowError(
76
+ f"Auth0 device code request failed: {response.status_code} {redact_secrets(response.text[:300])}"
77
+ )
78
+ body = response.json()
79
+ return DeviceCodePayload(
80
+ device_code=body["device_code"],
81
+ user_code=body["user_code"],
82
+ verification_uri=body["verification_uri"],
83
+ verification_uri_complete=body.get(
84
+ "verification_uri_complete", body["verification_uri"]
85
+ ),
86
+ expires_in=body.get("expires_in", 900),
87
+ interval=body.get("interval", 5),
88
+ )
89
+
90
+
91
+ def poll_for_token(
92
+ domain: Optional[str],
93
+ client_id: str,
94
+ payload: DeviceCodePayload,
95
+ *,
96
+ no_browser: bool = False,
97
+ ) -> TokenPayload:
98
+ """Poll Auth0 until the user approves the device code or it expires."""
99
+ domain = _require_domain(domain)
100
+ if not no_browser:
101
+ try:
102
+ webbrowser.open(payload.verification_uri_complete)
103
+ except Exception:
104
+ pass
105
+
106
+ deadline = time.monotonic() + payload.expires_in
107
+ interval = max(1, payload.interval)
108
+
109
+ while True:
110
+ if time.monotonic() >= deadline:
111
+ raise DeviceFlowError("Device code expired before authorization completed.")
112
+
113
+ time.sleep(interval)
114
+
115
+ url = f"https://{domain}/oauth/token"
116
+ started = log_request(logger, "POST", url)
117
+ response = requests.post(
118
+ url,
119
+ data={
120
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
121
+ "device_code": payload.device_code,
122
+ "client_id": client_id,
123
+ },
124
+ timeout=30,
125
+ )
126
+ log_response(logger, response, started)
127
+
128
+ if response.status_code == 200:
129
+ body = response.json()
130
+ return TokenPayload(
131
+ access_token=body["access_token"],
132
+ refresh_token=body.get("refresh_token"),
133
+ id_token=body.get("id_token"),
134
+ expires_in=body.get("expires_in", 0),
135
+ token_type=body.get("token_type", "Bearer"),
136
+ scope=body.get("scope"),
137
+ )
138
+
139
+ try:
140
+ err = response.json()
141
+ err_code = err.get("error", "")
142
+ except Exception:
143
+ err_code = ""
144
+
145
+ if err_code == "authorization_pending":
146
+ continue
147
+ if err_code == "slow_down":
148
+ interval = min(interval + 5, 60)
149
+ continue
150
+ if err_code == "expired_token":
151
+ raise DeviceFlowError("Authorization timed out — the user code expired.")
152
+ if err_code == "access_denied":
153
+ raise DeviceFlowError("Authorization was denied by the user.")
154
+ raise DeviceFlowError(
155
+ f"Auth0 token poll failed: {response.status_code} {redact_secrets(response.text[:300])}"
156
+ )
157
+
158
+
159
+ def refresh_auth0_token(
160
+ domain: Optional[str], client_id: str, refresh_token: str
161
+ ) -> TokenPayload:
162
+ """Exchange a refresh token for a fresh access token."""
163
+ url = f"https://{_require_domain(domain)}/oauth/token"
164
+ started = log_request(logger, "POST", url)
165
+ response = requests.post(
166
+ url,
167
+ data={
168
+ "grant_type": "refresh_token",
169
+ "client_id": client_id,
170
+ "refresh_token": refresh_token,
171
+ },
172
+ timeout=30,
173
+ )
174
+ log_response(logger, response, started)
175
+ if response.status_code != 200:
176
+ raise DeviceFlowError(
177
+ f"Auth0 refresh failed: {response.status_code} {redact_secrets(response.text[:300])}"
178
+ )
179
+ body = response.json()
180
+ return TokenPayload(
181
+ access_token=body["access_token"],
182
+ refresh_token=body.get("refresh_token", refresh_token),
183
+ id_token=body.get("id_token"),
184
+ expires_in=body.get("expires_in", 0),
185
+ token_type=body.get("token_type", "Bearer"),
186
+ scope=body.get("scope"),
187
+ )
pm/auth/exchange.py ADDED
@@ -0,0 +1,79 @@
1
+ """Exchange an Auth0 access token for a PushMetrics session.
2
+
3
+ This hits POST /api/v1/security/login with an `Authorization: Bearer <auth0_token>`
4
+ header. The response sets the Flask session cookie (carrying workspace_id for
5
+ Postgres RLS) onto the provided requests.Session, AND returns PushMetrics-issued
6
+ JWT access/refresh tokens in the body.
7
+
8
+ This is the Auth0 device-flow (cookie) login path, one of the CLI's three
9
+ auth modes. The session cookie is what authenticates subsequent calls in
10
+ this mode; the returned JWTs are stored for silent re-login. Programmatic
11
+ callers use ``PM_API_TOKEN`` (PAT) or ``PM_API_JWT`` (delegation) instead and
12
+ never hit this path - see ``session.py`` for the resolution order.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass
18
+ from typing import Optional
19
+
20
+ import requests
21
+
22
+ from pm.log import log_request, log_response, redact_secrets
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class LoginError(RuntimeError):
28
+ pass
29
+
30
+
31
+ @dataclass
32
+ class PMLoginResult:
33
+ pm_access_token: Optional[str]
34
+ pm_refresh_token: Optional[str]
35
+
36
+
37
+ def exchange_for_session(
38
+ session: requests.Session,
39
+ api_url: str,
40
+ auth0_access_token: str,
41
+ *,
42
+ want_refresh: bool = True,
43
+ ) -> PMLoginResult:
44
+ url = f"{api_url}/api/v1/security/login"
45
+ started = log_request(logger, "POST", url)
46
+ resp = session.post(
47
+ url,
48
+ json={"refresh": want_refresh},
49
+ headers={"Authorization": f"Bearer {auth0_access_token}"},
50
+ timeout=30,
51
+ )
52
+ log_response(logger, resp, started)
53
+ if resp.status_code != 200:
54
+ raise LoginError(
55
+ f"PushMetrics login failed: {resp.status_code} {redact_secrets(resp.text[:300])}"
56
+ )
57
+
58
+ try:
59
+ body = resp.json() if resp.content else {}
60
+ except ValueError:
61
+ raise LoginError(
62
+ "PushMetrics login returned a non-JSON response (got HTML/redirect). "
63
+ "This usually means the backend rejected the Auth0 token "
64
+ "(verify_auth0_token failed) and fell back to the standard login page. "
65
+ f"First 300 chars: {redact_secrets(resp.text[:300])!r}"
66
+ )
67
+
68
+ pm_access_token = body.get("access_token")
69
+ if not pm_access_token:
70
+ raise LoginError(
71
+ "PushMetrics login succeeded (200) but returned no access_token. "
72
+ "The Auth0 token was not accepted by /security/login. "
73
+ f"Body: {redact_secrets(repr(body))}"
74
+ )
75
+
76
+ return PMLoginResult(
77
+ pm_access_token=pm_access_token,
78
+ pm_refresh_token=body.get("refresh_token"),
79
+ )
pm/auth/jwt_cache.py ADDED
@@ -0,0 +1,163 @@
1
+ """Per-profile cache of the JWT exchanged from a Personal Access Token.
2
+
3
+ ``PMSession`` exchanges the PAT once per process. Every ``pm`` invocation is
4
+ a process, and the exchange endpoint is rate-limited per IP (10/min in
5
+ prod), so a shell loop over ``pm`` commands hit 429 on the 11th call. This
6
+ module keeps the short-lived JWT across processes with the same safety as
7
+ the PAT itself: OS keyring first, else a ``0600`` fallback file.
8
+
9
+ A record is only a hit when ALL of these hold; anything else is a miss:
10
+
11
+ - the profile name matches (the record key);
12
+ - ``api_url`` matches, so a JWT minted for prod never rides along to a host
13
+ given with ``--api-url`` / ``PM_API_URL`` under the same profile;
14
+ - the PAT fingerprint matches, so a changed ``PM_API_TOKEN`` or a fresh
15
+ ``pm login --token`` never reuses the previous token's JWT;
16
+ - the wall-clock expiry (exchange time + ``expires_in`` - margin) is in the
17
+ future. The TTL is the server's; it is never extended client-side.
18
+
19
+ The raw PAT is never stored here, only ``sha256(pat)[:16]``. The JWT value
20
+ is never logged: hit/miss lines carry the profile name only. Delegation
21
+ mode (``PM_API_JWT``) never reads or writes this cache.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import logging
28
+ import re
29
+ import time
30
+ from dataclasses import asdict, dataclass
31
+ from typing import Optional
32
+
33
+ from pm.auth.secret_files import read_private_json, write_private_json
34
+ from pm.config import CONFIG_DIR, keyring_account
35
+ from pm.log import timed
36
+
37
+ try:
38
+ import keyring
39
+ _KEYRING_AVAILABLE = True
40
+ except Exception:
41
+ _KEYRING_AVAILABLE = False
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # Distinct from the PAT ("pushmetrics-cli-pat") and cookie ("pushmetrics-cli")
46
+ # services so the cached JWT can never overwrite or shadow either.
47
+ KEYRING_SERVICE = "pushmetrics-cli-jwt"
48
+ FALLBACK_FILE = CONFIG_DIR / "jwt_cache.json"
49
+
50
+ # A JWT is base64url segments joined by dots; anything with whitespace or
51
+ # control characters must never reach a header. Shared with PM_API_JWT.
52
+ JWT_SHAPE_RE = re.compile(r"^[A-Za-z0-9._\-]+$")
53
+
54
+ # Seconds taken off the server TTL so a JWT is never presented in the last
55
+ # moments before the server stops accepting it.
56
+ EXPIRY_MARGIN_SECONDS = 60
57
+
58
+
59
+ @dataclass
60
+ class CachedJwt:
61
+ jwt: str
62
+ expires_at: float
63
+ api_url: str
64
+ pat_fingerprint: str
65
+
66
+ def seconds_left(self, now: Optional[float] = None) -> float:
67
+ return self.expires_at - (time.time() if now is None else now)
68
+
69
+
70
+ def fingerprint(raw_pat: str) -> str:
71
+ """Non-reversible identity of a PAT; the raw value is never persisted."""
72
+ return hashlib.sha256(raw_pat.encode("utf-8")).hexdigest()[:16]
73
+
74
+
75
+ def load(profile: str, api_url: str, raw_pat: str) -> Optional[CachedJwt]:
76
+ """Return the cached JWT bound to this profile, host and PAT, if still valid."""
77
+ record = _read_record(profile)
78
+ if record is None:
79
+ logger.debug("jwt cache miss for profile %s", profile)
80
+ return None
81
+ if record.api_url != api_url or record.pat_fingerprint != fingerprint(raw_pat):
82
+ logger.debug("jwt cache miss for profile %s (different host or token)", profile)
83
+ return None
84
+ if record.seconds_left() <= 0:
85
+ logger.debug("jwt cache miss for profile %s (expired)", profile)
86
+ return None
87
+ logger.debug("jwt cache hit for profile %s", profile)
88
+ return record
89
+
90
+
91
+ def save(profile: str, api_url: str, raw_pat: str, jwt: str, expires_in: int) -> None:
92
+ record = CachedJwt(
93
+ jwt=jwt,
94
+ expires_at=time.time() + max(0, expires_in - EXPIRY_MARGIN_SECONDS),
95
+ api_url=api_url,
96
+ pat_fingerprint=fingerprint(raw_pat),
97
+ )
98
+ _write_record(profile, record)
99
+
100
+
101
+ def clear(profile: str) -> None:
102
+ """Forget the cached JWT for ``profile`` (keyring and file). Idempotent."""
103
+ if _KEYRING_AVAILABLE:
104
+ try:
105
+ keyring.delete_password(KEYRING_SERVICE, keyring_account(profile))
106
+ except Exception as exc:
107
+ logger.debug("keyring jwt delete for %s: %s", profile, type(exc).__name__)
108
+ data = read_private_json(FALLBACK_FILE)
109
+ if profile in data:
110
+ del data[profile]
111
+ write_private_json(FALLBACK_FILE, data)
112
+
113
+
114
+ # ── storage ─────────────────────────────────────────────────────────
115
+
116
+
117
+ def _read_record(profile: str) -> Optional[CachedJwt]:
118
+ raw = None
119
+ if _KEYRING_AVAILABLE:
120
+ try:
121
+ with timed(logger, "keyring.load_jwt"):
122
+ raw = keyring.get_password(KEYRING_SERVICE, keyring_account(profile))
123
+ except Exception as exc:
124
+ logger.debug("keyring jwt load failed for %s: %s", profile, type(exc).__name__)
125
+ if raw:
126
+ return _parse(raw, profile)
127
+ data = read_private_json(FALLBACK_FILE).get(profile)
128
+ return _parse(data, profile) if data else None
129
+
130
+
131
+ def _parse(raw, profile: str) -> Optional[CachedJwt]:
132
+ """A corrupt record is a miss, never a traceback."""
133
+ try:
134
+ data = json.loads(raw) if isinstance(raw, str) else raw
135
+ record = CachedJwt(
136
+ jwt=str(data["jwt"]),
137
+ expires_at=float(data["expires_at"]),
138
+ api_url=str(data["api_url"]),
139
+ pat_fingerprint=str(data["pat_fingerprint"]),
140
+ )
141
+ except (KeyError, TypeError, ValueError):
142
+ logger.debug("jwt cache record for %s is corrupt; treating as miss", profile)
143
+ return None
144
+ if not JWT_SHAPE_RE.fullmatch(record.jwt):
145
+ # A hand-edited record would otherwise surface as a transport error
146
+ # ("Could not reach ... (InvalidHeader)") and misdirect the user.
147
+ logger.debug("jwt cache record for %s is malformed; treating as miss", profile)
148
+ return None
149
+ return record
150
+
151
+
152
+ def _write_record(profile: str, record: CachedJwt) -> None:
153
+ storable = asdict(record)
154
+ if _KEYRING_AVAILABLE:
155
+ try:
156
+ with timed(logger, "keyring.save_jwt"):
157
+ keyring.set_password(KEYRING_SERVICE, keyring_account(profile), json.dumps(storable))
158
+ return
159
+ except Exception as exc:
160
+ logger.debug("keyring jwt save failed for %s: %s", profile, type(exc).__name__)
161
+ data = read_private_json(FALLBACK_FILE)
162
+ data[profile] = storable
163
+ write_private_json(FALLBACK_FILE, data)
@@ -0,0 +1,58 @@
1
+ """Private JSON files for the credential fallback stores.
2
+
3
+ One implementation of the "0600 from creation" rule for every file that can
4
+ hold a secret (``credentials.json``, ``tokens.json``, ``jwt_cache.json``):
5
+
6
+ - the data is written to a sibling temp file opened ``O_CREAT`` with mode
7
+ ``0o600``, so there is no window in which a world-readable file holds the
8
+ secret (write-then-chmod has one, and a crash between the two steps leaves
9
+ it open for good);
10
+ - the temp file is then ``os.replace``d over the target, so a crash mid-write
11
+ leaves the previous record intact rather than a torn file;
12
+ - a corrupt or missing file reads as empty, never as a traceback.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import secrets
20
+ from pathlib import Path
21
+ from typing import Any, Dict
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ PRIVATE_MODE = 0o600
26
+
27
+
28
+ def read_private_json(path: Path) -> Dict[str, Any]:
29
+ """Return the file's JSON object, or ``{}`` when it is missing or unreadable."""
30
+ if not path.exists():
31
+ return {}
32
+ try:
33
+ data = json.loads(path.read_text(encoding="utf-8"))
34
+ except (OSError, ValueError) as exc:
35
+ logger.debug("ignoring unreadable %s: %s", path.name, type(exc).__name__)
36
+ return {}
37
+ return data if isinstance(data, dict) else {}
38
+
39
+
40
+ def write_private_json(path: Path, data: Dict[str, Any]) -> None:
41
+ """Atomically replace ``path`` with ``data``, owner-readable only."""
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ # Unique name + O_EXCL: a leftover temp file from a crashed process is
44
+ # never reopened with whatever mode it had.
45
+ tmp = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
46
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_MODE)
47
+ try:
48
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
49
+ f.write(json.dumps(data))
50
+ os.replace(tmp, path)
51
+ except BaseException:
52
+ # Best-effort rollback: an unlink failure must never replace the
53
+ # original write/replace error the caller needs to see.
54
+ try:
55
+ os.unlink(tmp)
56
+ except OSError as unlink_exc:
57
+ logger.debug("could not remove %s: %s", tmp.name, type(unlink_exc).__name__)
58
+ raise