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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- 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
|
tlgr/processors/regex.py
ADDED
|
@@ -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)
|
tlgr/processors/text.py
ADDED
|
@@ -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
|