tlgr-cli 2.0.1__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 (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/parity.py ADDED
@@ -0,0 +1,386 @@
1
+ """Feature parity, computed from the registry rather than asserted in prose.
2
+
3
+ tlgr v2's goal is stated as a number — every feature of the official Telegram
4
+ clients, reachable from the CLI — so the number has to be checkable. It is:
5
+ the catalog index says what exists, every `OperationSpec` declares the ids it
6
+ `covers`, and this module subtracts one from the other.
7
+
8
+ Three rules keep it honest.
9
+
10
+ * **The denominator is fixed.** Ids whose feasibility is `not-applicable`
11
+ (bot-only, server-side, GUI-only) or `prohibited` (ToS, spam,
12
+ deanonymisation) are excluded once, here, and never again. Anything else
13
+ counts, so coverage cannot be improved by re-labelling work as out of scope.
14
+ * **A waiver names a permanent reason, not a later PR.** Until PR-12 a
15
+ waiver was a promise with a PR number on it; every one of those promises
16
+ has been kept, so `parity_waivers.toml` now holds only ids this build
17
+ genuinely cannot cover, each with a `kind` (`layer-gap`, `absent-method`,
18
+ `prohibited`, `not-applicable`) and the method that is missing. A waived id
19
+ is still in the denominator; it is reported as uncovered-with-a-reason,
20
+ never subtracted.
21
+ * **An unknown id is a build failure.** An op that covers an id the catalog
22
+ has never heard of is a typo, and a typo that inflates a coverage number is
23
+ worse than a gap.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import sys
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ if sys.version_info >= (3, 11):
35
+ import tomllib
36
+ else: # pragma: no cover - exercised on the 3.10 CI leg
37
+ import tomli as tomllib
38
+
39
+ __all__ = [
40
+ "CatalogEntry",
41
+ "ParityReport",
42
+ "Waiver",
43
+ "Waivers",
44
+ "catalog",
45
+ "compute",
46
+ "render_table",
47
+ "unknown_ids",
48
+ "waivers",
49
+ ]
50
+
51
+ DATA = Path(__file__).resolve().parent / "data"
52
+ CATALOG_PATH = DATA / "catalog_index.json"
53
+ WAIVERS_PATH = DATA / "parity_waivers.toml"
54
+
55
+ PRIORITIES = ("P0", "P1", "P2", "P3")
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class CatalogEntry:
60
+ id: str
61
+ group: str
62
+ domain: str
63
+ name: str
64
+ priority: str
65
+ feasibility: str
66
+
67
+ @property
68
+ def required(self) -> bool:
69
+ return self.feasibility in ("full", "partial", "control-only")
70
+
71
+
72
+ #: The only reasons a waiver may give. Anything else is a backlog entry
73
+ #: wearing a waiver's clothes, and `tests/test_parity.py` refuses it.
74
+ KINDS = ("layer-gap", "absent-method", "prohibited", "not-applicable")
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class Waiver:
79
+ """One id that cannot be covered, and why."""
80
+
81
+ kind: str
82
+ reason: str
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class Waivers:
87
+ """What is knowingly uncovered, by id.
88
+
89
+ `domains` survives as a mapping rather than being deleted, so that a
90
+ domain waiver reappearing in the file is something the gate can *see* and
91
+ refuse — "no blanket waivers" is then a rule the file cannot break rather
92
+ than a habit somebody has to remember.
93
+ """
94
+
95
+ catalog_version: str = ""
96
+ final_pr: int = 0
97
+ domains: dict[str, tuple[int, str]] = field(default_factory=dict)
98
+ ids: dict[str, Waiver] = field(default_factory=dict)
99
+
100
+ def reason_for(self, entry: CatalogEntry) -> str:
101
+ found = self.ids.get(entry.id)
102
+ if found is not None:
103
+ return f"waived ({found.kind}): {found.reason}"
104
+ legacy = self.domains.get(entry.domain)
105
+ if legacy is not None: # pragma: no cover - the file carries none
106
+ return f"waived (domain): {legacy[1]}"
107
+ return ""
108
+
109
+
110
+ _catalog_cache: dict[str, CatalogEntry] | None = None
111
+ _waivers_cache: Waivers | None = None
112
+
113
+
114
+ def catalog(path: Path | None = None) -> dict[str, CatalogEntry]:
115
+ """The pruned catalog index, keyed by id. Read once per process.
116
+
117
+ Loaded lazily and never at import: this is 325 KB of JSON and `tlgr
118
+ --help` must not pay for it.
119
+ """
120
+ global _catalog_cache
121
+ if path is None and _catalog_cache is not None:
122
+ return _catalog_cache
123
+ raw = json.loads((path or CATALOG_PATH).read_text(encoding="utf-8"))
124
+ entries = {
125
+ item["id"]: CatalogEntry(
126
+ id=item["id"],
127
+ group=item["group"],
128
+ domain=item["domain"],
129
+ name=item["name"],
130
+ priority=item["priority"],
131
+ feasibility=item["feasibility"],
132
+ )
133
+ for item in raw["entries"]
134
+ }
135
+ if path is None:
136
+ _catalog_cache = entries
137
+ return entries
138
+
139
+
140
+ def catalog_version(path: Path | None = None) -> str:
141
+ raw = json.loads((path or CATALOG_PATH).read_text(encoding="utf-8"))
142
+ return str(raw.get("catalog_version", ""))
143
+
144
+
145
+ def waivers(path: Path | None = None) -> Waivers:
146
+ global _waivers_cache
147
+ if path is None and _waivers_cache is not None:
148
+ return _waivers_cache
149
+ raw = tomllib.loads((path or WAIVERS_PATH).read_text(encoding="utf-8"))
150
+ meta = raw.get("meta", {})
151
+ parsed = Waivers(
152
+ catalog_version=str(meta.get("catalog_version", "")),
153
+ final_pr=int(meta.get("final_pr", 0)),
154
+ domains={
155
+ str(item["name"]): (int(item["pr"]), str(item.get("reason", "")))
156
+ for item in raw.get("domain", [])
157
+ },
158
+ ids={
159
+ str(item["id"]): Waiver(
160
+ kind=str(item.get("kind", "")), reason=str(item.get("reason", ""))
161
+ )
162
+ for item in raw.get("id", [])
163
+ },
164
+ )
165
+ if path is None:
166
+ _waivers_cache = parsed
167
+ return parsed
168
+
169
+
170
+ @dataclass
171
+ class ParityReport:
172
+ catalog_version: str = ""
173
+ required: int = 0
174
+ covered: int = 0
175
+ percent: float = 0.0
176
+ #: covered + waived. The number the "definition of done for a group PR"
177
+ #: is actually about: every id in the domain is either implemented or
178
+ #: has a named owner and a PR number. Reporting only `percent` would make
179
+ #: a group whose remaining ids belong to *other* groups look unfinished.
180
+ accounted: int = 0
181
+ accounted_percent: float = 0.0
182
+ by_priority: dict[str, dict[str, Any]] = field(default_factory=dict)
183
+ by_domain: dict[str, dict[str, Any]] = field(default_factory=dict)
184
+ partial: list[dict[str, str]] = field(default_factory=list)
185
+ uncovered: list[dict[str, str]] = field(default_factory=list)
186
+ excluded: dict[str, int] = field(default_factory=dict)
187
+ unknown: list[dict[str, str]] = field(default_factory=list)
188
+ ops: int = 0
189
+ commands: int = 0
190
+ aliases: int = 0
191
+ waivers: int = 0
192
+
193
+ def to_dict(self) -> dict[str, Any]:
194
+ return {
195
+ "catalog_version": self.catalog_version,
196
+ "required": self.required,
197
+ "covered": self.covered,
198
+ "percent": self.percent,
199
+ "accounted": self.accounted,
200
+ "accounted_percent": self.accounted_percent,
201
+ "by_priority": self.by_priority,
202
+ "by_domain": self.by_domain,
203
+ "partial": self.partial,
204
+ "uncovered": self.uncovered,
205
+ "excluded": self.excluded,
206
+ "unknown": self.unknown,
207
+ "ops": self.ops,
208
+ "commands": self.commands,
209
+ "aliases": self.aliases,
210
+ "waivers": self.waivers,
211
+ }
212
+
213
+
214
+ def _coverage(specs: Any) -> tuple[dict[str, str], dict[str, str]]:
215
+ """`(id → op that fully covers it, id → op that partially covers it)`."""
216
+ full: dict[str, str] = {}
217
+ partial: dict[str, str] = {}
218
+ for spec in specs:
219
+ for entry_id in spec.covers:
220
+ full.setdefault(entry_id, spec.id)
221
+ for entry_id in spec.covers_partial:
222
+ partial.setdefault(entry_id, spec.id)
223
+ return full, partial
224
+
225
+
226
+ def unknown_ids(registry: Any = None) -> list[dict[str, str]]:
227
+ """Ids an op claims to cover that the catalog does not contain (L-COV-1).
228
+
229
+ Not run at import: it would put a 325 KB JSON read in front of every
230
+ `tlgr --help`. It runs in `tests/test_parity.py` and in `make parity`,
231
+ which is where a typo is actually caught.
232
+ """
233
+ from tlgr.registry import REGISTRY
234
+
235
+ known = catalog()
236
+ out: list[dict[str, str]] = []
237
+ for spec in (registry or REGISTRY).values():
238
+ for entry_id in (*spec.covers, *spec.covers_partial):
239
+ if entry_id not in known:
240
+ out.append({"op": spec.id, "id": entry_id})
241
+ return sorted(out, key=lambda item: (item["op"], item["id"]))
242
+
243
+
244
+ def compute(registry: Any = None) -> ParityReport:
245
+ """The whole parity picture, from the registry as it stands right now."""
246
+ from tlgr.registry import REGISTRY
247
+
248
+ specs = list((registry or REGISTRY).values())
249
+ entries = catalog()
250
+ rules = waivers()
251
+ full, partial = _coverage(specs)
252
+ covered_ids = set(full) | set(partial)
253
+
254
+ report = ParityReport(
255
+ catalog_version=catalog_version(),
256
+ ops=len(specs),
257
+ commands=len({name for spec in specs for name in spec.names}),
258
+ aliases=sum(len(spec.aliases) for spec in specs),
259
+ )
260
+
261
+ priority_stats: dict[str, dict[str, Any]] = {
262
+ p: {"required": 0, "covered": 0, "percent": 0.0, "waived": 0, "accounted_percent": 0.0}
263
+ for p in PRIORITIES
264
+ }
265
+ domain_stats: dict[str, dict[str, Any]] = {}
266
+ excluded: dict[str, int] = {}
267
+
268
+ for entry in entries.values():
269
+ if not entry.required:
270
+ excluded[entry.feasibility] = excluded.get(entry.feasibility, 0) + 1
271
+ continue
272
+ report.required += 1
273
+ stats = domain_stats.setdefault(
274
+ entry.domain,
275
+ {
276
+ "required": 0,
277
+ "covered": 0,
278
+ "percent": 0.0,
279
+ "waived": 0,
280
+ "accounted_percent": 0.0,
281
+ "ops": 0,
282
+ },
283
+ )
284
+ stats["required"] += 1
285
+ priority_stats[entry.priority]["required"] += 1
286
+
287
+ if entry.id in covered_ids:
288
+ report.covered += 1
289
+ stats["covered"] += 1
290
+ priority_stats[entry.priority]["covered"] += 1
291
+ if entry.id in partial and entry.id not in full:
292
+ report.partial.append(
293
+ {
294
+ "id": entry.id,
295
+ "op": partial[entry.id],
296
+ "note": _note_for(specs, partial[entry.id]),
297
+ }
298
+ )
299
+ continue
300
+
301
+ reason = rules.reason_for(entry)
302
+ if reason:
303
+ report.waivers += 1
304
+ stats["waived"] += 1
305
+ priority_stats[entry.priority]["waived"] += 1
306
+ report.uncovered.append(
307
+ {
308
+ "id": entry.id,
309
+ "priority": entry.priority,
310
+ "domain": entry.domain,
311
+ "name": entry.name,
312
+ "reason": reason or "not covered and not waived",
313
+ }
314
+ )
315
+
316
+ for domain, stats in domain_stats.items():
317
+ stats["ops"] = sum(
318
+ 1
319
+ for spec in specs
320
+ if any(
321
+ entries[i].domain == domain
322
+ for i in (*spec.covers, *spec.covers_partial)
323
+ if i in entries
324
+ )
325
+ )
326
+ stats["percent"] = _percent(stats["covered"], stats["required"])
327
+ stats["accounted_percent"] = _percent(stats["covered"] + stats["waived"], stats["required"])
328
+ for stats in priority_stats.values():
329
+ stats["percent"] = _percent(stats["covered"], stats["required"])
330
+ stats["accounted_percent"] = _percent(stats["covered"] + stats["waived"], stats["required"])
331
+
332
+ report.accounted = report.covered + report.waivers
333
+ report.accounted_percent = _percent(report.accounted, report.required)
334
+ report.percent = _percent(report.covered, report.required)
335
+ report.by_priority = {p: priority_stats[p] for p in PRIORITIES if p in priority_stats}
336
+ report.by_domain = dict(sorted(domain_stats.items()))
337
+ report.excluded = dict(sorted(excluded.items()))
338
+ report.unknown = unknown_ids(registry)
339
+ report.uncovered.sort(key=lambda item: (item["priority"], item["id"]))
340
+ report.partial.sort(key=lambda item: item["id"])
341
+ return report
342
+
343
+
344
+ def _note_for(specs: list[Any], op_id: str) -> str:
345
+ for spec in specs:
346
+ if spec.id == op_id:
347
+ return str(spec.coverage_note)
348
+ return ""
349
+
350
+
351
+ def _percent(covered: int, required: int) -> float:
352
+ return round(100.0 * covered / required, 1) if required else 100.0
353
+
354
+
355
+ def render_table(report: ParityReport) -> str:
356
+ """The human view: one row per domain, then priorities, then the totals."""
357
+ lines = [
358
+ f"catalog {report.catalog_version} — {report.ops} operations, "
359
+ f"{report.commands} invocable paths",
360
+ "",
361
+ f"{'domain':<28} {'covered':>8} {'req':>6} {'%':>7} {'acct%':>7} ops",
362
+ ]
363
+ for domain, stats in report.by_domain.items():
364
+ lines.append(
365
+ f"{domain:<28} {stats['covered']:>8} {stats['required']:>6} "
366
+ f"{stats['percent']:>6.1f}% {stats['accounted_percent']:>6.1f}% {stats['ops']}"
367
+ )
368
+ lines += ["", f"{'priority':<28} {'covered':>8} {'req':>6} {'%':>7} {'acct%':>7}"]
369
+ for priority, stats in report.by_priority.items():
370
+ lines.append(
371
+ f"{priority:<28} {stats['covered']:>8} {stats['required']:>6} "
372
+ f"{stats['percent']:>6.1f}% {stats['accounted_percent']:>6.1f}%"
373
+ )
374
+ excluded = ", ".join(f"{k} {v}" for k, v in report.excluded.items())
375
+ lines += [
376
+ "",
377
+ f"{'TOTAL':<28} {report.covered:>8} {report.required:>6} "
378
+ f"{report.percent:>6.1f}% {report.accounted_percent:>6.1f}%",
379
+ f"excluded: {excluded}",
380
+ f"uncovered: {len(report.uncovered)} ({report.waivers} waived with a reason)",
381
+ ]
382
+ if report.unknown:
383
+ lines.append(
384
+ f"UNKNOWN IDS: {len(report.unknown)} — an op covers an id the catalog does not have"
385
+ )
386
+ return "\n".join(lines)
@@ -0,0 +1,125 @@
1
+ """Registry-based text processors (formerly "transforms").
2
+
3
+ Every processor is a plain function registered via ``@register_processor``.
4
+ Processors take ``(text, config)`` and return the modified text.
5
+
6
+ Use :class:`ProcessorChain` to run multiple processors in sequence.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ ProcessorFunc = Callable[[str, dict[str, Any]], str]
16
+
17
+ _REGISTRY: dict[str, ProcessorFunc] = {}
18
+
19
+
20
+ def register_processor(name: str):
21
+ """Decorator that registers a processor function under *name*."""
22
+
23
+ def decorator(func: ProcessorFunc) -> ProcessorFunc:
24
+ _REGISTRY[name] = func
25
+ return func
26
+
27
+ return decorator
28
+
29
+
30
+ def get_processor(name: str) -> ProcessorFunc | None:
31
+ return _REGISTRY.get(name)
32
+
33
+
34
+ def list_processors() -> list[str]:
35
+ return list(_REGISTRY.keys())
36
+
37
+
38
+ class ProcessorChain:
39
+ """Ordered pipeline of processors applied to text."""
40
+
41
+ def __init__(self) -> None:
42
+ self.processors: list[tuple[ProcessorFunc, dict[str, Any]]] = []
43
+
44
+ def add(self, name: str, config: dict[str, Any] | None = None) -> ProcessorChain:
45
+ func = get_processor(name)
46
+ if func is None:
47
+ raise ValueError(f"Unknown processor: {name}")
48
+ self.processors.append((func, config or {}))
49
+ return self
50
+
51
+ def add_inline(self, pattern: str, replacement: str = "", flags: str = "") -> ProcessorChain:
52
+ func = get_processor("regex_replace")
53
+ assert func is not None
54
+ self.processors.append(
55
+ (func, {"pattern": pattern, "replacement": replacement, "flags": flags})
56
+ )
57
+ return self
58
+
59
+ def apply(self, text: str) -> str:
60
+ result = text
61
+ for func, config in self.processors:
62
+ result = func(result, config)
63
+ return result
64
+
65
+ def __len__(self) -> int:
66
+ return len(self.processors)
67
+
68
+
69
+ def create_chain_from_spec(spec: str) -> ProcessorChain:
70
+ """Create from spec string like ``'replace_mentions,strip_formatting'``."""
71
+ chain = ProcessorChain()
72
+ if not spec:
73
+ return chain
74
+ parts = re.split(r"(?<!\\),", spec)
75
+ for part in parts:
76
+ part = part.strip().replace("\\,", ",")
77
+ if not part:
78
+ continue
79
+ if ":" in part:
80
+ segments = part.split(":")
81
+ name = segments[0]
82
+ config: dict[str, Any] = {}
83
+ for segment in segments[1:]:
84
+ if "=" in segment:
85
+ key, value = segment.split("=", 1)
86
+ config[key] = value
87
+ chain.add(name, config)
88
+ else:
89
+ chain.add(part)
90
+ return chain
91
+
92
+
93
+ def create_chain_from_list(items: list[str | dict]) -> ProcessorChain:
94
+ """Build a chain from a YAML config list.
95
+
96
+ Items can be:
97
+ - ``"name"`` — plain processor name
98
+ - ``"name:key=val"`` — name with inline config
99
+ - ``{"type": "regex", "pattern": ..., "replacement": ...}`` — inline regex
100
+ """
101
+ chain = ProcessorChain()
102
+ for item in items:
103
+ if isinstance(item, str):
104
+ if ":" in item:
105
+ segments = item.split(":")
106
+ name = segments[0]
107
+ config: dict[str, Any] = {}
108
+ for segment in segments[1:]:
109
+ if "=" in segment:
110
+ k, v = segment.split("=", 1)
111
+ config[k] = v
112
+ chain.add(name, config)
113
+ else:
114
+ chain.add(item)
115
+ elif isinstance(item, dict):
116
+ chain.add_inline(
117
+ item.get("pattern", ""),
118
+ item.get("replacement", ""),
119
+ item.get("flags", ""),
120
+ )
121
+ return chain
122
+
123
+
124
+ # Import built-in processor modules so they self-register.
125
+ from tlgr.processors import regex, text # noqa: E402, F401
@@ -0,0 +1,26 @@
1
+ """Regex-based processor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from tlgr.processors import register_processor
9
+
10
+
11
+ @register_processor("regex_replace")
12
+ def regex_replace(text: str, config: dict[str, Any] | None = None) -> str:
13
+ config = config or {}
14
+ pattern = config.get("pattern")
15
+ if not pattern:
16
+ return text
17
+ replacement = config.get("replacement", "")
18
+ flags_str = config.get("flags", "")
19
+ flags = 0
20
+ if "i" in flags_str:
21
+ flags |= re.IGNORECASE
22
+ if "m" in flags_str:
23
+ flags |= re.MULTILINE
24
+ if "s" in flags_str:
25
+ flags |= re.DOTALL
26
+ return re.sub(pattern, replacement, text, flags=flags)
@@ -0,0 +1,56 @@
1
+ """Text-manipulation processors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from tlgr.processors import register_processor
9
+
10
+
11
+ @register_processor("replace_mentions")
12
+ def replace_mentions(text: str, config: dict[str, Any] | None = None) -> str:
13
+ config = config or {}
14
+ replacement = config.get("replacement", "")
15
+ pattern = config.get("pattern", r"@[a-zA-Z0-9_]+")
16
+ return re.sub(pattern, replacement, text)
17
+
18
+
19
+ @register_processor("remove_links")
20
+ def remove_links(text: str, config: dict[str, Any] | None = None) -> str:
21
+ config = config or {}
22
+ replacement = config.get("replacement", "")
23
+ return re.sub(r"https?://[^\s<>\"']+", replacement, text)
24
+
25
+
26
+ @register_processor("remove_hashtags")
27
+ def remove_hashtags(text: str, config: dict[str, Any] | None = None) -> str:
28
+ config = config or {}
29
+ replacement = config.get("replacement", "")
30
+ return re.sub(r"#[a-zA-Z0-9_]+", replacement, text)
31
+
32
+
33
+ @register_processor("strip_formatting")
34
+ def strip_formatting(text: str, config: dict[str, Any] | None = None) -> str:
35
+ text = re.sub(r"[ \t]+", " ", text)
36
+ text = re.sub(r"\n{3,}", "\n\n", text)
37
+ lines = [line.strip() for line in text.split("\n")]
38
+ return "\n".join(lines).strip()
39
+
40
+
41
+ @register_processor("add_prefix")
42
+ def add_prefix(text: str, config: dict[str, Any] | None = None) -> str:
43
+ config = config or {}
44
+ prefix = config.get("prefix", "")
45
+ if prefix and not prefix.endswith("\n"):
46
+ prefix += "\n"
47
+ return prefix + text
48
+
49
+
50
+ @register_processor("add_suffix")
51
+ def add_suffix(text: str, config: dict[str, Any] | None = None) -> str:
52
+ config = config or {}
53
+ suffix = config.get("suffix", "")
54
+ if suffix and not suffix.startswith("\n"):
55
+ suffix = "\n" + suffix
56
+ return text + suffix