mannerism 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. mannerism/__init__.py +3 -0
  2. mannerism/__main__.py +3 -0
  3. mannerism/adapters/__init__.py +86 -0
  4. mannerism/adapters/_http.py +134 -0
  5. mannerism/adapters/_social.py +229 -0
  6. mannerism/adapters/_util.py +539 -0
  7. mannerism/adapters/aider.py +303 -0
  8. mannerism/adapters/bluesky.py +343 -0
  9. mannerism/adapters/chatgpt.py +143 -0
  10. mannerism/adapters/claude_code.py +242 -0
  11. mannerism/adapters/cline.py +410 -0
  12. mannerism/adapters/codex.py +272 -0
  13. mannerism/adapters/continue_dev.py +181 -0
  14. mannerism/adapters/crush.py +205 -0
  15. mannerism/adapters/files.py +537 -0
  16. mannerism/adapters/gh.py +127 -0
  17. mannerism/adapters/git.py +203 -0
  18. mannerism/adapters/goose.py +205 -0
  19. mannerism/adapters/hermes.py +249 -0
  20. mannerism/adapters/imessage.py +132 -0
  21. mannerism/adapters/kilo.py +255 -0
  22. mannerism/adapters/markdown.py +213 -0
  23. mannerism/adapters/mastodon.py +370 -0
  24. mannerism/adapters/mbox.py +210 -0
  25. mannerism/adapters/opencode.py +273 -0
  26. mannerism/adapters/pi.py +162 -0
  27. mannerism/adapters/qwen_code.py +189 -0
  28. mannerism/adapters/slack.py +553 -0
  29. mannerism/adapters/telegram.py +162 -0
  30. mannerism/adapters/whatsapp.py +234 -0
  31. mannerism/adapters/whatsapp_db.py +238 -0
  32. mannerism/adapters/wispr.py +124 -0
  33. mannerism/adapters/x_api.py +313 -0
  34. mannerism/adapters/x_archive.py +199 -0
  35. mannerism/api.py +502 -0
  36. mannerism/backfill.py +93 -0
  37. mannerism/bank.py +383 -0
  38. mannerism/blind.py +370 -0
  39. mannerism/capture.py +420 -0
  40. mannerism/cards.py +270 -0
  41. mannerism/cli.py +1172 -0
  42. mannerism/config.py +256 -0
  43. mannerism/connect.py +823 -0
  44. mannerism/core.py +313 -0
  45. mannerism/dashboard.py +656 -0
  46. mannerism/drift.py +91 -0
  47. mannerism/evaluate.py +53 -0
  48. mannerism/exclude.py +686 -0
  49. mannerism/groups.py +266 -0
  50. mannerism/hookroots.py +213 -0
  51. mannerism/hooks.py +218 -0
  52. mannerism/humanness.py +523 -0
  53. mannerism/intake.py +79 -0
  54. mannerism/legacy.py +418 -0
  55. mannerism/lint.py +173 -0
  56. mannerism/mcp_server.py +239 -0
  57. mannerism/packets.py +329 -0
  58. mannerism/plugins.py +170 -0
  59. mannerism/privacy.py +101 -0
  60. mannerism/public.py +226 -0
  61. mannerism/public_server.py +84 -0
  62. mannerism/published.py +301 -0
  63. mannerism/py.typed +0 -0
  64. mannerism/refresh.py +50 -0
  65. mannerism/registers.py +60 -0
  66. mannerism/remote.py +95 -0
  67. mannerism/rules.py +250 -0
  68. mannerism/scan.py +501 -0
  69. mannerism/schedule.py +110 -0
  70. mannerism/skills/mannerism/SKILL.md +59 -0
  71. mannerism/stats.py +273 -0
  72. mannerism/store.py +710 -0
  73. mannerism/stylometrics.py +130 -0
  74. mannerism/sync.py +620 -0
  75. mannerism/tells.py +207 -0
  76. mannerism/validate.py +56 -0
  77. mannerism/web.py +359 -0
  78. mannerism/webui/app.css +198 -0
  79. mannerism/webui/app.js +594 -0
  80. mannerism/webui/index.html +30 -0
  81. mannerism-0.1.0.dist-info/METADATA +372 -0
  82. mannerism-0.1.0.dist-info/RECORD +86 -0
  83. mannerism-0.1.0.dist-info/WHEEL +4 -0
  84. mannerism-0.1.0.dist-info/entry_points.txt +2 -0
  85. mannerism-0.1.0.dist-info/licenses/LICENSE +176 -0
  86. mannerism-0.1.0.dist-info/licenses/NOTICE +64 -0
mannerism/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """mannerism: your agents write in your voice."""
2
+
3
+ __version__ = "0.1.0"
mannerism/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,86 @@
1
+ """Source adapters. One module per source; each defines a class implementing `mannerism.core.Source`.
2
+
3
+ Adapters keep only text the user wrote. Other people's text goes only into Unit.context, masked
4
+ with `core.context_line`, at most `core.CONTEXT_LINES` lines. Adapters use the standard library only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib import import_module
10
+
11
+ from ..core import Source
12
+
13
+ # name -> "module:Class". Order is the order `init` shows sources in.
14
+ REGISTRY = {
15
+ "claude_code": "claude_code:ClaudeCode",
16
+ "codex": "codex:Codex",
17
+ "git": "git:Git",
18
+ "gh": "gh:GitHub",
19
+ "bluesky": "bluesky:Bluesky",
20
+ "mastodon": "mastodon:Mastodon",
21
+ "x": "x_api:XApi",
22
+ "slack": "slack:Slack",
23
+ "markdown": "markdown:Markdown",
24
+ "files": "files:Files",
25
+ "chatgpt": "chatgpt:ChatGPT",
26
+ "telegram": "telegram:Telegram",
27
+ "whatsapp": "whatsapp:WhatsApp",
28
+ "whatsapp_db": "whatsapp_db:WhatsAppDB",
29
+ "hermes": "hermes:Hermes",
30
+ "x_archive": "x_archive:XArchive",
31
+ "mbox": "mbox:Mbox",
32
+ "imessage": "imessage:IMessage",
33
+ "wispr": "wispr:Wispr",
34
+ "opencode": "opencode:OpenCode",
35
+ "qwen_code": "qwen_code:QwenCode",
36
+ "goose": "goose:Goose",
37
+ "aider": "aider:Aider",
38
+ "crush": "crush:Crush",
39
+ "pi": "pi:Pi",
40
+ "continue": "continue_dev:Continue",
41
+ "cline": "cline:Cline",
42
+ "kilo": "kilo:Kilo",
43
+ }
44
+
45
+
46
+ # Built-in adapters that can be configured as several named instances: `[sources.<instance>]` with
47
+ # `adapter = "<key>"`. The instance name becomes the source name (units, cursors and `forget --source`
48
+ # all use it), so an instance name must not equal a built-in source or an installed plugin.
49
+ INSTANCE_ADAPTERS = {"files": "files:Files"}
50
+
51
+
52
+ def load(name: str) -> Source:
53
+ mod, cls = REGISTRY[name].split(":")
54
+ return getattr(import_module(f"{__name__}.{mod}"), cls)()
55
+
56
+
57
+ def load_for(name: str, entry: dict | None = None, *, warnings: list[str] | None = None) -> Source:
58
+ """The source for config entry `[sources.<name>]`: a built-in, an instance of one, or an enabled plugin.
59
+
60
+ Plain built-ins go through `load(name)`. A plugin is imported here, so callers that must never run plugin
61
+ code (`sync --quick`) check `entry.get("plugin")` before calling."""
62
+ entry = entry or {}
63
+ if entry.get("plugin"):
64
+ from .. import plugins
65
+
66
+ try:
67
+ return plugins.load(name, entry, warnings=warnings)
68
+ except plugins.PluginError as e:
69
+ raise ImportError(str(e)) from None
70
+ kind = entry.get("adapter")
71
+ if kind:
72
+ if kind not in INSTANCE_ADAPTERS:
73
+ raise KeyError(f"adapter {kind!r} cannot be configured as an instance")
74
+ mod, cls = INSTANCE_ADAPTERS[kind].split(":")
75
+ return getattr(import_module(f"{__name__}.{mod}"), cls)(name)
76
+ return load(name)
77
+
78
+
79
+ def all_sources() -> list[Source]:
80
+ out = []
81
+ for name in REGISTRY:
82
+ try:
83
+ out.append(load(name))
84
+ except (ImportError, AttributeError):
85
+ continue # not implemented yet
86
+ return out
@@ -0,0 +1,134 @@
1
+ """Minimal, strict HTTPS GET for the network sources (standard library only).
2
+
3
+ Rules every network source gets from here: https only, a finite timeout, a `mannerism/<version>` User-Agent, a cap on
4
+ the JSON body, redirects followed only inside the original origin (never to another host: credentials stay put), and
5
+ errors reduced to a `NetError` that carries a kind, a status and a retry hint. Error text never contains headers,
6
+ bodies or query strings, so a credential cannot leak into a log or a stored cursor.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from collections.abc import Mapping
16
+ from datetime import UTC, datetime
17
+ from email.utils import parsedate_to_datetime
18
+
19
+ from .. import __version__
20
+
21
+ TIMEOUT_S = 10.0
22
+ MAX_JSON_BYTES = 8 * 1024 * 1024
23
+ MAX_REDIRECTS = 3
24
+ DEFAULT_RETRY_S = 900 # a 429 without any reset header
25
+ MAX_RETRY_S = 24 * 3600
26
+ USER_AGENT = f"mannerism/{__version__}"
27
+
28
+
29
+ class NetError(Exception):
30
+ """A failed request. `kind`: timeout | network | http | rate_limit | redirect | too_large | bad_json | url."""
31
+
32
+ def __init__(self, kind: str, message: str, status: int | None = None, retry_after_s: float | None = None):
33
+ super().__init__(message)
34
+ self.kind = kind
35
+ self.status = status
36
+ self.retry_after_s = retry_after_s
37
+
38
+
39
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
40
+ def redirect_request(self, *args, **kwargs): # noqa: ARG002 - a 3xx surfaces as an HTTPError; get_json decides
41
+ return None
42
+
43
+
44
+ _OPENER = urllib.request.build_opener(_NoRedirect())
45
+
46
+
47
+ def urlopen(req: urllib.request.Request, timeout: float):
48
+ """The single place a socket is opened (tests replace this function)."""
49
+ return _OPENER.open(req, timeout=timeout)
50
+
51
+
52
+ def retry_after_s(headers: Mapping | None, now: datetime | None = None) -> float:
53
+ """Seconds to wait, from Retry-After, x-rate-limit-reset (X, epoch), ratelimit-reset (Bluesky, epoch) or
54
+ X-RateLimit-Reset (Mastodon, ISO-8601). Falls back to DEFAULT_RETRY_S; always within [1, MAX_RETRY_S]."""
55
+ now = now or datetime.now(UTC)
56
+ get = (lambda k: headers.get(k)) if headers is not None else (lambda k: None)
57
+ for key in ("retry-after", "x-rate-limit-reset", "ratelimit-reset", "x-ratelimit-reset"):
58
+ raw = get(key)
59
+ if raw is None:
60
+ continue
61
+ raw = str(raw).strip()
62
+ try:
63
+ x = float(raw)
64
+ wait = x - now.timestamp() if x > 1e9 else x
65
+ except ValueError:
66
+ try:
67
+ dt = parsedate_to_datetime(raw) if not raw[:4].isdigit() else datetime.fromisoformat(raw)
68
+ except (TypeError, ValueError):
69
+ continue
70
+ wait = (dt if dt.tzinfo else dt.replace(tzinfo=UTC)).timestamp() - now.timestamp()
71
+ return min(max(wait, 1.0), float(MAX_RETRY_S))
72
+ return float(DEFAULT_RETRY_S)
73
+
74
+
75
+ def _origin(url: str) -> tuple[str, str, int | None]:
76
+ p = urllib.parse.urlsplit(url)
77
+ return p.scheme, (p.hostname or "").lower(), p.port
78
+
79
+
80
+ def build_url(host: str, path: str, params: Mapping[str, str | int | list] | None = None) -> str:
81
+ """https://host/path?query. A list value repeats the parameter (`uris=a&uris=b`); None values are left out."""
82
+ q = urllib.parse.urlencode(
83
+ {k: ([str(x) for x in v] if isinstance(v, list) else str(v)) for k, v in (params or {}).items() if v is not None},
84
+ safe=",", doseq=True)
85
+ return f"https://{host}{path}" + (f"?{q}" if q else "")
86
+
87
+
88
+ def get_json(url: str, headers: Mapping[str, str] | None = None, *, timeout: float = TIMEOUT_S,
89
+ max_bytes: int = MAX_JSON_BYTES) -> tuple[object, Mapping]:
90
+ """GET `url` (https only) and return (parsed JSON, response headers). Raises NetError."""
91
+ start = _origin(url)
92
+ if start[0] != "https" or not start[1]:
93
+ raise NetError("url", "refusing a non-https URL")
94
+ hdrs = {"User-Agent": USER_AGENT, "Accept": "application/json", **(headers or {})}
95
+ cur = url
96
+ for _hop in range(MAX_REDIRECTS + 1):
97
+ host = _origin(cur)[1]
98
+ try:
99
+ with urlopen(urllib.request.Request(cur, headers=hdrs, method="GET"), timeout) as resp:
100
+ body = resp.read(max_bytes + 1)
101
+ status = getattr(resp, "status", 200)
102
+ resp_headers = resp.headers
103
+ except urllib.error.HTTPError as e:
104
+ if e.code in (301, 302, 303, 307, 308):
105
+ target = urllib.parse.urljoin(cur, (e.headers.get("Location") if e.headers else None) or "")
106
+ if _origin(target) != start:
107
+ raise NetError("redirect", f"{host}: refused a redirect to another origin", e.code) from None
108
+ cur = target
109
+ continue
110
+ if e.code == 429:
111
+ raise NetError("rate_limit", f"{host}: HTTP 429 (rate limited)", 429,
112
+ retry_after_s(e.headers)) from None
113
+ raise NetError("http", f"{host}: HTTP {e.code}", e.code) from None
114
+ except TimeoutError:
115
+ raise NetError("timeout", f"{host}: timed out") from None
116
+ except (urllib.error.URLError, OSError, ValueError) as e:
117
+ raise NetError("network", f"{host}: {type(e).__name__}") from None
118
+ if status in (301, 302, 303, 307, 308): # an opener that did not raise for a redirect
119
+ target = urllib.parse.urljoin(cur, resp_headers.get("Location") or "")
120
+ if _origin(target) != start:
121
+ raise NetError("redirect", f"{host}: refused a redirect to another origin", status)
122
+ cur = target
123
+ continue
124
+ if status == 429:
125
+ raise NetError("rate_limit", f"{host}: HTTP 429 (rate limited)", 429, retry_after_s(resp_headers))
126
+ if status >= 400:
127
+ raise NetError("http", f"{host}: HTTP {status}", status)
128
+ if len(body) > max_bytes:
129
+ raise NetError("too_large", f"{host}: response over {max_bytes} bytes")
130
+ try:
131
+ return json.loads(body.decode("utf-8")), resp_headers
132
+ except (ValueError, UnicodeDecodeError):
133
+ raise NetError("bad_json", f"{host}: response is not JSON") from None
134
+ raise NetError("redirect", "too many redirects")
@@ -0,0 +1,229 @@
1
+ """Shared machinery for the continuous social-post sources (Bluesky, Mastodon, X): config gate, status in the cursor,
2
+ text hygiene and the paged newest-first scan.
3
+
4
+ The scan keeps two marks apart. `high_water` is the newest item a COMPLETED scan has seen. While a scan runs, its
5
+ state lives in `scan` = {token, candidate, fetched}: the continuation token for the next page, the newest item seen
6
+ so far (it becomes the high-water mark only when the scan finishes) and how many items were fetched. A timeout or
7
+ error in the middle returns the units gathered so far together with the `scan` continuation, so the next read resumes
8
+ at the same page: nothing is lost (the units and the cursor are stored in one transaction) and nothing is read twice.
9
+ A first run (no high-water mark yet) stops after `initial` items instead of reading a whole history.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import re
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from datetime import UTC, datetime, timedelta
19
+
20
+ from ..core import Unit
21
+ from ._http import NetError
22
+
23
+ CURSOR_VERSION = 1
24
+ MAX_PAGES_PER_READ = 5 # a read never makes more page requests than this; the rest continues next time
25
+ DEFAULT_INITIAL_POSTS = 200 # free sources: how much history the first run reads
26
+ DEFAULT_RECONCILE_INTERVAL_S = 7 * 24 * 3600 # free sources: how often deletes and edits are looked for
27
+ RECONCILE_ITEMS = 200 # the recent window a reconcile re-reads
28
+ RECONCILE_CHECKS = 50 # at most this many vanished-looking posts are confirmed one by one per reconcile
29
+ MAX_SEEN = 400 # refs remembered in the cursor (newest first): enough to cover the reconcile window
30
+ REJECTED_TOKEN_STATUSES = (400, 404, 410) # a stored continuation token the service no longer accepts
31
+
32
+
33
+ def utcnow() -> datetime:
34
+ return datetime.now(UTC)
35
+
36
+
37
+ def network_enabled(config: dict | None) -> bool:
38
+ """`allow_network` must be exactly true; anything else (missing, "yes", 1) keeps the source off."""
39
+ return isinstance(config, dict) and config.get("allow_network") is True
40
+
41
+
42
+ def int_option(config: dict | None, key: str, default: int, low: int = 1) -> int:
43
+ try:
44
+ v = int((config or {}).get(key, default))
45
+ except (TypeError, ValueError):
46
+ return default
47
+ return v if v >= low else default
48
+
49
+
50
+ # --- status in the cursor -----------------------------------------------------------------------------------------
51
+
52
+
53
+ def with_status(cursor: dict, message: str, retry_after_s: float | None = None) -> dict:
54
+ """`cursor` plus a `status` entry; everything else in the cursor is untouched. The engine stores it as is, so the
55
+ scheduler can read `status.error` / `status.retry_after` to back off."""
56
+ now = utcnow()
57
+ status = {"error": message[:300], "at": now.isoformat(timespec="seconds")}
58
+ if retry_after_s is not None:
59
+ status["retry_after"] = (now + timedelta(seconds=retry_after_s)).isoformat(timespec="seconds")
60
+ return {**cursor, "status": status}
61
+
62
+
63
+ def backoff_active(cursor: dict) -> bool:
64
+ """True while a stored `status.retry_after` is still in the future (no request should be made)."""
65
+ st = cursor.get("status")
66
+ if not isinstance(st, dict) or not st.get("retry_after"):
67
+ return False
68
+ try:
69
+ return datetime.fromisoformat(st["retry_after"]) > utcnow()
70
+ except ValueError:
71
+ return False
72
+
73
+
74
+ def clean_cursor(cursor: dict | None) -> dict:
75
+ """The cursor if it has this version's shape, else a fresh one (an unknown shape is re-read from scratch; units
76
+ dedupe by id). Always carries `v`."""
77
+ if isinstance(cursor, dict) and cursor.get("v") == CURSOR_VERSION:
78
+ return dict(cursor)
79
+ return {"v": CURSOR_VERSION}
80
+
81
+
82
+ # --- text hygiene -------------------------------------------------------------------------------------------------
83
+
84
+ _HANDLE = r"@\w+(?:[.-]\w+)*(?:@\w+(?:[.-]\w+)*)?"
85
+ _LEAD_MENTIONS = re.compile(rf"^(?:\s*{_HANDLE})+\s*")
86
+ _MENTION = re.compile(rf"(?<![\w/@]){_HANDLE}")
87
+ MENTION_MARK = "" # an adapter-inserted placeholder for a structured mention (facet / mention link)
88
+ _LEAD_MARKS = re.compile(rf"^(?:\s*(?:{MENTION_MARK}|{_HANDLE}))+\s*")
89
+
90
+
91
+ def finish_text(text: str, reply: bool) -> str:
92
+ """Reply: drop the leading @mentions. Everywhere else a mention of another person becomes `@[user]`, so a third
93
+ party's handle is not stored. Collapses stray spacing."""
94
+ if reply:
95
+ text = _LEAD_MARKS.sub("", text)
96
+ text = text.replace(MENTION_MARK, "@[user]")
97
+ text = _MENTION.sub("@[user]", text)
98
+ return re.sub(r"[ \t]+\n", "\n", re.sub(r"[ \t]{2,}", " ", text)).strip()
99
+
100
+
101
+ def context_text(text: str) -> str:
102
+ """Another person's text on its way into one masked context line: handles masked, spacing collapsed."""
103
+ return _MENTION.sub("@[user]", " ".join(str(text).replace(MENTION_MARK, "@[user]").split()))
104
+
105
+
106
+ def text_hash(text: str) -> str:
107
+ """Short fingerprint of the cleaned text (a reconcile compares it to see an edit)."""
108
+ return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:10]
109
+
110
+
111
+ def reconcile_due(cursor: dict, interval_s: int, now: datetime | None = None) -> bool:
112
+ """True when the last reconcile is at least `interval_s` old. A cursor that never had one is NOT due: the first
113
+ completed scan only stamps `reconciled_at` (it has just read the recent window anyway)."""
114
+ raw = cursor.get("reconciled_at")
115
+ try:
116
+ last = datetime.fromisoformat(raw) if isinstance(raw, str) else None
117
+ except ValueError:
118
+ last = None
119
+ return last is not None and ((now or utcnow()) - last).total_seconds() >= interval_s
120
+
121
+
122
+ def month_key(now: datetime) -> str:
123
+ return now.strftime("%Y-%m")
124
+
125
+
126
+ def next_month_start(now: datetime) -> datetime:
127
+ y, m = (now.year + 1, 1) if now.month == 12 else (now.year, now.month + 1)
128
+ return datetime(y, m, 1, tzinfo=UTC)
129
+
130
+
131
+ # --- the scan -----------------------------------------------------------------------------------------------------
132
+
133
+
134
+ @dataclass
135
+ class ScanResult:
136
+ units: list[Unit]
137
+ cursor: dict # the state fields (high_water / scan) advanced; no status set
138
+ error: NetError | None = None
139
+ completed: bool = False
140
+ fetched: int = 0
141
+ dropped: bool = False # a stored continuation token was rejected: the scan state was dropped
142
+
143
+
144
+ def scan(
145
+ cursor: dict,
146
+ *,
147
+ fetch: Callable[[str | None, int], tuple[list, str | None]],
148
+ marker: Callable[[object], object | None],
149
+ at_high_water: Callable[[object, object], bool],
150
+ convert: Callable[[object], list[Unit]],
151
+ page_size: int,
152
+ initial: int,
153
+ max_pages: int | None = None,
154
+ promote_on_drop: bool = False,
155
+ ) -> ScanResult:
156
+ """Walk a newest-first feed page by page.
157
+
158
+ `fetch(token, limit)` -> (items, next_token) or raises NetError. `marker(item)` -> the item's ordering mark, or
159
+ None for items that carry none (a pinned post). `at_high_water(item, hw)` -> True when the item is not newer than the
160
+ stored high-water mark (the scan is complete). `convert(item)` -> units. With no stored high-water mark the scan
161
+ stops after `initial` items.
162
+
163
+ A fetch that fails with 400/404/410 while it carried a continuation token means the service no longer accepts that
164
+ token: the `scan` state is dropped (the `high_water` mark is kept) so the next read starts a fresh scan instead of
165
+ retrying the same token forever. `promote_on_drop` (the paid source): on a first run that already read something,
166
+ the newest item seen becomes the high-water mark, so a restart does not read, and pay for, the same posts again."""
167
+ hw = cursor.get("high_water")
168
+ state = cursor.get("scan") if isinstance(cursor.get("scan"), dict) else {}
169
+ token = state.get("token")
170
+ candidate = state.get("candidate")
171
+ fetched = int(state.get("fetched") or 0)
172
+ max_pages = MAX_PAGES_PER_READ if max_pages is None else max_pages
173
+ units: list[Unit] = []
174
+ error: NetError | None = None
175
+ complete = False
176
+ dropped = False
177
+ pages = 0
178
+ while not complete:
179
+ if pages >= max_pages:
180
+ break
181
+ limit = page_size
182
+ if hw is None:
183
+ if fetched >= initial:
184
+ complete = True
185
+ break
186
+ limit = min(page_size, initial - fetched)
187
+ try:
188
+ items, nxt = fetch(token, limit)
189
+ except NetError as e:
190
+ error = e
191
+ dropped = token is not None and e.kind == "http" and e.status in REJECTED_TOKEN_STATUSES
192
+ break
193
+ pages += 1
194
+ for item in items:
195
+ m = marker(item)
196
+ if m is not None and hw is not None and at_high_water(item, hw):
197
+ complete = True
198
+ break
199
+ if hw is None and fetched >= initial:
200
+ complete = True
201
+ break
202
+ fetched += 1
203
+ if m is not None and candidate is None:
204
+ candidate = m
205
+ units.extend(convert(item))
206
+ if complete:
207
+ break
208
+ if not items or nxt is None:
209
+ complete = True
210
+ break
211
+ token = nxt
212
+ new = {k: v for k, v in cursor.items() if k not in ("scan", "status")}
213
+ if complete:
214
+ if candidate is not None:
215
+ new["high_water"] = candidate
216
+ elif dropped:
217
+ if promote_on_drop and hw is None and candidate is not None:
218
+ new["high_water"] = candidate
219
+ elif token is not None or candidate is not None or fetched:
220
+ new["scan"] = {"token": token, "candidate": candidate, "fetched": fetched}
221
+ return ScanResult(units, new, error, complete, fetched, dropped)
222
+
223
+
224
+ def cap_seen(seen: dict, order_key: Callable[[str, object], object], limit: int = MAX_SEEN) -> dict:
225
+ """The `limit` newest entries of a {ref: value} map, newest by `order_key(ref, value)`."""
226
+ if len(seen) <= limit:
227
+ return seen
228
+ keep = sorted(seen.items(), key=lambda kv: order_key(*kv), reverse=True)[:limit]
229
+ return dict(keep)