pubkit 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.
pubkit/core/adapter.py ADDED
@@ -0,0 +1,206 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The adapter contract, plus the two base classes that do the hard parts.
4
+
5
+ Six methods is the entire surface a new platform has to implement. Everything
6
+ painful — verified transport, anchor resolution, image insertion, fingerprint
7
+ verification, rate limiting, retries — lives in the base classes, because those
8
+ are exactly the things that went wrong the first time and nobody should have to
9
+ rediscover them per platform.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import hashlib
15
+ import random
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Protocol, runtime_checkable
19
+
20
+ from .auth import CredentialError, SessionStore, TokenStore
21
+ from .capabilities import Capabilities, PublishPlan
22
+ from .ir import Document
23
+
24
+
25
+ @dataclass
26
+ class RemoteRef:
27
+ """Where a document lives on a platform before publication."""
28
+
29
+ id: str
30
+ url: str | None = None
31
+ extra: dict[str, Any] = field(default_factory=dict)
32
+
33
+
34
+ @dataclass
35
+ class PublishedRef:
36
+ id: str
37
+ url: str
38
+ published_at: float = field(default_factory=time.time)
39
+
40
+
41
+ @dataclass
42
+ class Fingerprint:
43
+ """A cheap, stable summary of remote state.
44
+
45
+ Compared after a reload to prove a mutation actually stuck. Editors lie:
46
+ a delete that visibly worked came back after a refresh, and DOM edits to
47
+ link hrefs never persisted at all (failures B3, B4).
48
+ """
49
+
50
+ words: int
51
+ headings: list[str]
52
+ images: int
53
+ links: int
54
+ markers: int = 0
55
+
56
+ def digest(self) -> str:
57
+ payload = f"{self.words}|{'|'.join(self.headings)}|{self.images}|{self.links}|{self.markers}"
58
+ return hashlib.blake2b(payload.encode(), digest_size=8).hexdigest()
59
+
60
+ def assert_matches(self, expected: Fingerprint, *, tolerance: float = 0.02) -> None:
61
+ problems = []
62
+ if expected.words and abs(self.words - expected.words) / expected.words > tolerance:
63
+ problems.append(f"words {self.words} vs expected {expected.words}")
64
+ if self.images != expected.images:
65
+ problems.append(f"images {self.images} vs expected {expected.images}")
66
+ if self.markers:
67
+ problems.append(f"{self.markers} unresolved marker(s) still present")
68
+ if expected.headings and self.headings[: len(expected.headings)] != expected.headings:
69
+ problems.append("heading list diverges")
70
+ if problems:
71
+ raise VerificationFailed("; ".join(problems))
72
+
73
+
74
+ class VerificationFailed(RuntimeError):
75
+ """Remote state does not match what we pushed. Never publish past this."""
76
+
77
+
78
+ class AdapterError(RuntimeError):
79
+ pass
80
+
81
+
82
+ class NotRetryable(AdapterError):
83
+ """A failure that retrying cannot fix.
84
+
85
+ A missing credential is not a transient error. Backing off four times
86
+ before telling the user to run `pubkit auth login` wastes 30 seconds and
87
+ buries the one line that actually helps them.
88
+ """
89
+
90
+
91
+ class RateLimited(AdapterError):
92
+ def __init__(self, retry_after: float) -> None:
93
+ super().__init__(f"rate limited, retry after {retry_after:.1f}s")
94
+ self.retry_after = retry_after
95
+
96
+
97
+ @dataclass
98
+ class Context:
99
+ """What an adapter is handed. Deliberately narrow.
100
+
101
+ An adapter gets credentials for its own platform and nothing else.
102
+ """
103
+
104
+ platform: str
105
+ tokens: TokenStore
106
+ sessions: SessionStore
107
+ dry_run: bool = False
108
+ confirm: bool = False
109
+ headless: bool = True
110
+ workdir: str = ".pubkit"
111
+ options: dict[str, Any] = field(default_factory=dict)
112
+
113
+
114
+ @runtime_checkable
115
+ class Adapter(Protocol):
116
+ name: str
117
+ capabilities: Capabilities
118
+
119
+ async def authenticate(self, ctx: Context) -> None: ...
120
+ async def ensure_draft(self, doc: Document, ctx: Context) -> RemoteRef: ...
121
+ async def push_content(self, doc: Document, plan: PublishPlan, ref: RemoteRef, ctx: Context) -> None: ...
122
+ async def push_media(self, doc: Document, plan: PublishPlan, ref: RemoteRef, ctx: Context) -> None: ...
123
+ async def verify(self, doc: Document, plan: PublishPlan, ref: RemoteRef, ctx: Context) -> Fingerprint: ...
124
+ async def publish(self, doc: Document, ref: RemoteRef, ctx: Context) -> PublishedRef: ...
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # rate limiting + retry, shared by every adapter
129
+ # ---------------------------------------------------------------------------
130
+ class TokenBucket:
131
+ def __init__(self, rate: float, burst: int) -> None:
132
+ self.rate, self.burst = rate, burst
133
+ self._tokens = float(burst)
134
+ self._last = time.monotonic()
135
+ self._lock = asyncio.Lock()
136
+
137
+ async def acquire(self, n: int = 1) -> None:
138
+ async with self._lock:
139
+ while True:
140
+ now = time.monotonic()
141
+ self._tokens = min(self.burst, self._tokens + (now - self._last) * self.rate)
142
+ self._last = now
143
+ if self._tokens >= n:
144
+ self._tokens -= n
145
+ return
146
+ await asyncio.sleep((n - self._tokens) / self.rate)
147
+
148
+
149
+ async def with_retry(fn, *, attempts: int = 4, base: float = 1.0, cap: float = 30.0, on_retry=None):
150
+ """Bounded exponential backoff with full jitter.
151
+
152
+ Full jitter rather than fixed backoff on purpose: when five platform legs
153
+ fail at once because a shared dependency blipped, synchronised retries make
154
+ it worse.
155
+ """
156
+ last: Exception | None = None
157
+ for attempt in range(attempts):
158
+ try:
159
+ return await fn()
160
+ except (NotRetryable, PermissionError, CredentialError):
161
+ raise
162
+ except RateLimited as exc:
163
+ delay = exc.retry_after
164
+ except AdapterError as exc:
165
+ last = exc
166
+ delay = min(cap, base * 2**attempt) * random.random()
167
+ except Exception as exc: # noqa: BLE001 - adapters raise anything
168
+ last = exc
169
+ delay = min(cap, base * 2**attempt) * random.random()
170
+ else: # pragma: no cover
171
+ break
172
+ if attempt == attempts - 1:
173
+ break
174
+ if on_retry:
175
+ on_retry(attempt, delay, last)
176
+ await asyncio.sleep(delay)
177
+ raise AdapterError(f"gave up after {attempts} attempts: {last}") from last
178
+
179
+
180
+ class BaseAdapter:
181
+ """Shared plumbing. Adapters subclass either this or a browser/API base."""
182
+
183
+ name: str = "base"
184
+ capabilities: Capabilities = Capabilities()
185
+ #: Requests per second and burst, tuned per platform.
186
+ rate: float = 1.0
187
+ burst: int = 3
188
+
189
+ def __init__(self) -> None:
190
+ self.bucket = TokenBucket(self.rate, self.burst)
191
+
192
+ async def authenticate(self, ctx: Context) -> None: # pragma: no cover - default
193
+ return None
194
+
195
+ async def push_media(self, doc, plan, ref, ctx) -> None: # pragma: no cover - default
196
+ return None
197
+
198
+ def guard_publish(self, ctx: Context) -> None:
199
+ """Publishing is public and hard to undo (failure D3)."""
200
+ if ctx.dry_run:
201
+ raise AdapterError("refusing to publish during a dry run")
202
+ if not ctx.confirm:
203
+ raise AdapterError(
204
+ "publishing requires explicit confirmation: pass --confirm "
205
+ "(or set PUBKIT_CONFIRM=1 in CI)"
206
+ )
pubkit/core/anchors.py ADDED
@@ -0,0 +1,89 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Finding a place in a document you do not control.
4
+
5
+ Rich-text editors normalise what you give them. A caption written as
6
+
7
+ Where 80 GB goes. The cores never run out - the memory does.
8
+
9
+ came back from the editor as
10
+
11
+ Where 80 GB goes. The cores never run out — the memory does.
12
+
13
+ —hair spaces around an em dash. Exact string equality failed, the anchor was
14
+ not found, and one image out of fifteen silently did not get placed. Matching
15
+ text against a live editor needs to be forgiving in exactly the ways editors
16
+ are aggressive.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import unicodedata
22
+ from difflib import SequenceMatcher
23
+
24
+ _DASHES = dict.fromkeys(map(ord, "‐‑‒–—―−"), "-")
25
+ _QUOTES = {
26
+ ord("‘"): "'", ord("’"): "'", ord("‚"): "'", ord("‛"): "'",
27
+ ord("“"): '"', ord("”"): '"', ord("„"): '"', ord("″"): '"',
28
+ }
29
+ _SPACES = dict.fromkeys(
30
+ map(ord, "             "),
31
+ " ",
32
+ )
33
+
34
+
35
+ def normalise(s: str) -> str:
36
+ """Fold everything an editor is likely to have changed."""
37
+ s = unicodedata.normalize("NFKC", s)
38
+ s = s.translate(_SPACES).translate(_DASHES).translate(_QUOTES)
39
+ s = s.replace("​", "").replace("", "")
40
+ s = re.sub(r"\s*-\s*", " - ", s)
41
+ s = re.sub(r"\s+", " ", s)
42
+ return s.strip().casefold()
43
+
44
+
45
+ def similarity(a: str, b: str) -> float:
46
+ return SequenceMatcher(None, normalise(a), normalise(b)).ratio()
47
+
48
+
49
+ def find_anchor(
50
+ candidates: list[str],
51
+ target: str,
52
+ *,
53
+ floor: float = 0.82,
54
+ used: set[int] | None = None,
55
+ ) -> int:
56
+ """Locate `target` among `candidates`, returning an index or -1.
57
+
58
+ Three passes, cheapest first: exact-after-normalisation, prefix, then
59
+ fuzzy above `floor`. `used` lets a caller place several identical captions
60
+ without every one resolving to the first match.
61
+ """
62
+ used = used or set()
63
+ norm = [normalise(c) for c in candidates]
64
+ t = normalise(target)
65
+
66
+ for i, c in enumerate(norm):
67
+ if i not in used and c == t:
68
+ return i
69
+
70
+ if len(t) >= 16:
71
+ for i, c in enumerate(norm):
72
+ if i not in used and (c.startswith(t[:40]) or t.startswith(c[:40])):
73
+ return i
74
+
75
+ best, best_score = -1, floor
76
+ for i, c in enumerate(norm):
77
+ if i in used:
78
+ continue
79
+ score = SequenceMatcher(None, c, t).ratio()
80
+ if score > best_score:
81
+ best, best_score = i, score
82
+ return best
83
+
84
+
85
+ class AnchorNotFound(LookupError):
86
+ def __init__(self, target: str, closest: str | None, score: float) -> None:
87
+ hint = f" closest was {closest!r} at {score:.2f}" if closest else ""
88
+ super().__init__(f"no anchor for {target[:60]!r}.{hint}")
89
+ self.target, self.closest, self.score = target, closest, score
pubkit/core/auth.py ADDED
@@ -0,0 +1,209 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Credentials.
4
+
5
+ Design rule, and it is not negotiable: **pubkit never accepts a password.**
6
+
7
+ For API platforms the user pastes a token once and it goes into the OS
8
+ keychain. For browser platforms the user signs in themselves, in a real visible
9
+ browser window, and pubkit persists only the resulting session state — which it
10
+ encrypts, with the key in the keychain.
11
+
12
+ This is not only a security posture. It is also the only thing that works:
13
+ platforms increasingly gate login behind CAPTCHA, device confirmation and MFA,
14
+ none of which an automation layer should be trying to get past.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import json
20
+ import os
21
+ import secrets
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ try: # pragma: no cover - environment dependent
26
+ import keyring
27
+
28
+ _HAVE_KEYRING = True
29
+ except Exception: # pragma: no cover
30
+ keyring = None # type: ignore[assignment]
31
+ _HAVE_KEYRING = False
32
+
33
+ try: # pragma: no cover - environment dependent
34
+ from cryptography.fernet import Fernet
35
+
36
+ _HAVE_FERNET = True
37
+ except Exception: # pragma: no cover
38
+ Fernet = None # type: ignore[assignment]
39
+ _HAVE_FERNET = False
40
+
41
+ SERVICE = "pubkit"
42
+
43
+
44
+ class CredentialError(RuntimeError):
45
+ """Missing or unusable credentials. Never retried — see adapter.NotRetryable."""
46
+
47
+
48
+ @dataclass
49
+ class TokenStore:
50
+ """API tokens. Keychain first, encrypted vault as the CI fallback."""
51
+
52
+ vault_path: Path = Path(".pubkit/vault.json")
53
+ service: str = SERVICE
54
+
55
+ # ------------------------------------------------------------------ read
56
+ def get(self, platform: str, key: str = "token") -> str | None:
57
+ env = os.environ.get(f"PUBKIT_{platform.upper()}_{key.upper()}")
58
+ if env:
59
+ return env
60
+ if _HAVE_KEYRING:
61
+ try:
62
+ val = keyring.get_password(self.service, f"{platform}:{key}")
63
+ if val:
64
+ return val
65
+ except Exception:
66
+ pass
67
+ return self._vault_get(platform, key)
68
+
69
+ def require(self, platform: str, key: str = "token") -> str:
70
+ val = self.get(platform, key)
71
+ if not val:
72
+ raise CredentialError(
73
+ f"no {key} for {platform}. Run `pubkit auth login {platform}`, "
74
+ f"or set PUBKIT_{platform.upper()}_{key.upper()}."
75
+ )
76
+ return val
77
+
78
+ # ----------------------------------------------------------------- write
79
+ def set(self, platform: str, value: str, key: str = "token") -> None:
80
+ if _HAVE_KEYRING:
81
+ try:
82
+ keyring.set_password(self.service, f"{platform}:{key}", value)
83
+ return
84
+ except Exception:
85
+ pass
86
+ self._vault_set(platform, key, value)
87
+
88
+ def delete(self, platform: str, key: str = "token") -> None:
89
+ if _HAVE_KEYRING:
90
+ try:
91
+ keyring.delete_password(self.service, f"{platform}:{key}")
92
+ except Exception:
93
+ pass
94
+ data = self._vault_read()
95
+ data.pop(f"{platform}:{key}", None)
96
+ self._vault_write(data)
97
+
98
+ # ----------------------------------------------------------------- vault
99
+ def _vault_key(self) -> bytes:
100
+ env = os.environ.get("PUBKIT_VAULT_KEY")
101
+ if env:
102
+ return env.encode()
103
+ if _HAVE_KEYRING:
104
+ k = keyring.get_password(self.service, "_vault_key")
105
+ if not k:
106
+ k = Fernet.generate_key().decode() if _HAVE_FERNET else base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
107
+ keyring.set_password(self.service, "_vault_key", k)
108
+ return k.encode()
109
+ raise CredentialError(
110
+ "no keychain and no PUBKIT_VAULT_KEY set; cannot encrypt the vault. "
111
+ "In CI, set PUBKIT_VAULT_KEY from your secret store."
112
+ )
113
+
114
+ def _vault_read(self) -> dict[str, str]:
115
+ if not self.vault_path.exists():
116
+ return {}
117
+ blob = self.vault_path.read_bytes()
118
+ if not _HAVE_FERNET:
119
+ raise CredentialError("cryptography is required to read the vault")
120
+ return json.loads(Fernet(self._vault_key()).decrypt(blob))
121
+
122
+ def _vault_write(self, data: dict[str, str]) -> None:
123
+ if not _HAVE_FERNET:
124
+ raise CredentialError("cryptography is required to write the vault")
125
+ self.vault_path.parent.mkdir(parents=True, exist_ok=True)
126
+ blob = Fernet(self._vault_key()).encrypt(json.dumps(data).encode())
127
+ self.vault_path.write_bytes(blob)
128
+ os.chmod(self.vault_path, 0o600)
129
+
130
+ def _vault_get(self, platform: str, key: str) -> str | None:
131
+ try:
132
+ return self._vault_read().get(f"{platform}:{key}")
133
+ except Exception:
134
+ return None
135
+
136
+ def _vault_set(self, platform: str, key: str, value: str) -> None:
137
+ data = self._vault_read()
138
+ data[f"{platform}:{key}"] = value
139
+ self._vault_write(data)
140
+
141
+
142
+ @dataclass
143
+ class SessionStore:
144
+ """Persisted Playwright `storage_state`, encrypted at rest.
145
+
146
+ The flow is deliberately human-in-the-loop:
147
+
148
+ pubkit auth login medium
149
+ → opens a real browser window at the platform's login page
150
+ → you sign in (password manager, MFA, whatever it takes)
151
+ → pubkit waits for the post-login URL, saves the session, closes
152
+
153
+ pubkit sees cookies, never credentials.
154
+ """
155
+
156
+ dir: Path = Path(".pubkit/sessions")
157
+ tokens: TokenStore = None # type: ignore[assignment]
158
+
159
+ def __post_init__(self) -> None:
160
+ self.dir.mkdir(parents=True, exist_ok=True)
161
+ if self.tokens is None:
162
+ self.tokens = TokenStore()
163
+
164
+ def _path(self, platform: str) -> Path:
165
+ return self.dir / f"{platform}.session"
166
+
167
+ def exists(self, platform: str) -> bool:
168
+ return self._path(platform).exists()
169
+
170
+ def save(self, platform: str, storage_state: dict) -> None:
171
+ raw = json.dumps(storage_state).encode()
172
+ if _HAVE_FERNET:
173
+ raw = Fernet(self.tokens._vault_key()).encrypt(raw)
174
+ p = self._path(platform)
175
+ p.write_bytes(raw)
176
+ os.chmod(p, 0o600)
177
+
178
+ def load(self, platform: str) -> dict | None:
179
+ p = self._path(platform)
180
+ if not p.exists():
181
+ return None
182
+ raw = p.read_bytes()
183
+ if _HAVE_FERNET:
184
+ try:
185
+ raw = Fernet(self.tokens._vault_key()).decrypt(raw)
186
+ except Exception as exc: # corrupted or key rotated
187
+ raise CredentialError(
188
+ f"cannot decrypt the saved {platform} session; "
189
+ f"run `pubkit auth login {platform}` again ({exc})"
190
+ ) from exc
191
+ return json.loads(raw)
192
+
193
+ def forget(self, platform: str) -> None:
194
+ self._path(platform).unlink(missing_ok=True)
195
+
196
+
197
+ class RedactingFilter:
198
+ """Log filter that strips secrets by construction, not by discipline."""
199
+
200
+ def __init__(self, secrets_: list[str]) -> None:
201
+ self._secrets = [s for s in secrets_ if s and len(s) >= 8]
202
+
203
+ def filter(self, record) -> bool: # pragma: no cover - trivial
204
+ msg = str(record.getMessage())
205
+ for s in self._secrets:
206
+ if s in msg:
207
+ record.msg = msg.replace(s, "***redacted***")
208
+ record.args = ()
209
+ return True