rssd-fs 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.
- rssd/__init__.py +0 -0
- rssd/__main__.py +5 -0
- rssd/cli.py +278 -0
- rssd/config.py +151 -0
- rssd/daemon.py +168 -0
- rssd/diff.py +125 -0
- rssd/events.py +253 -0
- rssd/fetch.py +212 -0
- rssd/fixtures.py +109 -0
- rssd/fulltext.py +86 -0
- rssd/identity.py +148 -0
- rssd/models.py +229 -0
- rssd/parse.py +213 -0
- rssd/pipeline.py +96 -0
- rssd/reader.py +488 -0
- rssd/readercli.py +728 -0
- rssd/render.py +213 -0
- rssd/schedule.py +85 -0
- rssd/scheduler.py +484 -0
- rssd/semantic.py +483 -0
- rssd/state.py +62 -0
- rssd/store.py +292 -0
- rssd/subscriptions.py +190 -0
- rssd/timeutil.py +143 -0
- rssd/tui/__init__.py +0 -0
- rssd/tui/app.py +810 -0
- rssd/userconf.py +153 -0
- rssd/watcher.py +128 -0
- rssd_fs-0.1.0.dist-info/METADATA +248 -0
- rssd_fs-0.1.0.dist-info/RECORD +33 -0
- rssd_fs-0.1.0.dist-info/WHEEL +4 -0
- rssd_fs-0.1.0.dist-info/entry_points.txt +4 -0
- rssd_fs-0.1.0.dist-info/licenses/LICENSE +21 -0
rssd/__init__.py
ADDED
|
File without changes
|
rssd/__main__.py
ADDED
rssd/cli.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Command line surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .config import Config, Limits
|
|
12
|
+
from .models import Subscription
|
|
13
|
+
|
|
14
|
+
#: The reference feeds from SPEC §14. Between them they exercise every code
|
|
15
|
+
#: path: high churn, rich Atom content, thin RSS, an image-only body, and one
|
|
16
|
+
#: feed that sends no cache validators at all.
|
|
17
|
+
REFERENCE_FEEDS: list[tuple[str, str, str]] = [
|
|
18
|
+
("lobsters", "https://lobste.rs/rss", "high churn, ETag + Last-Modified"),
|
|
19
|
+
("rust-blog", "https://blog.rust-lang.org/feed.xml", "Atom, full content, xml:base"),
|
|
20
|
+
("simonw", "https://simonwillison.net/atom/everything/", "rich escaped HTML"),
|
|
21
|
+
("hn", "https://hnrss.org/frontpage", "title + link only, no body"),
|
|
22
|
+
("xkcd", "https://xkcd.com/rss.xml", "body is a single image"),
|
|
23
|
+
("godev", "https://go.dev/blog/feed.atom", "no cache validators at all"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
MUTABLE_FIXTURE = """<?xml version="1.0" encoding="utf-8"?>
|
|
27
|
+
<rss version="2.0">
|
|
28
|
+
<channel>
|
|
29
|
+
<title>rssd demo feed</title>
|
|
30
|
+
<link>http://127.0.0.1:8765/</link>
|
|
31
|
+
<description>A feed you can edit on purpose.</description>
|
|
32
|
+
<item>
|
|
33
|
+
<title>The entry that gets revised</title>
|
|
34
|
+
<link>http://127.0.0.1:8765/demo/1</link>
|
|
35
|
+
<guid isPermaLink="false">rssd-demo-1</guid>
|
|
36
|
+
<pubDate>Mon, 15 Sep 2026 12:00:00 GMT</pubDate>
|
|
37
|
+
<description><p>Revision {n}. Every version is kept on disk.</p></description>
|
|
38
|
+
</item>
|
|
39
|
+
</channel>
|
|
40
|
+
</rss>
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def subscription_xml(name: str, url: str, comment: str, *, fulltext: bool = False) -> str:
|
|
45
|
+
return (
|
|
46
|
+
'<?xml version="1.0" encoding="utf-8"?>\n'
|
|
47
|
+
f"<!-- {comment} -->\n"
|
|
48
|
+
"<subscription>\n"
|
|
49
|
+
f" <url>{url}</url>\n"
|
|
50
|
+
f" <name>{name}</name>\n"
|
|
51
|
+
f" <fulltext>{'true' if fulltext else 'false'}</fulltext>\n"
|
|
52
|
+
"</subscription>\n"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
57
|
+
root = Path(args.root)
|
|
58
|
+
config = Config(root=root)
|
|
59
|
+
config.ensure_dirs()
|
|
60
|
+
|
|
61
|
+
base = args.fixture_base.rstrip("/") if args.fixture_mode else None
|
|
62
|
+
for name, url, comment in REFERENCE_FEEDS:
|
|
63
|
+
target = config.feeds_d / f"{name}.xml"
|
|
64
|
+
if target.exists() and not args.force:
|
|
65
|
+
continue
|
|
66
|
+
effective = f"{base}/{name}.xml" if base else url
|
|
67
|
+
target.write_text(subscription_xml(name, effective, comment))
|
|
68
|
+
|
|
69
|
+
fixtures = root / "fixtures"
|
|
70
|
+
fixtures.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
mutable = fixtures / "mutable.xml"
|
|
72
|
+
if not mutable.exists():
|
|
73
|
+
mutable.write_text(MUTABLE_FIXTURE.format(n=1))
|
|
74
|
+
if args.fixture_mode:
|
|
75
|
+
src = Path(__file__).resolve().parent.parent.parent / "tests" / "fixtures" / "feeds"
|
|
76
|
+
for name, _, _ in REFERENCE_FEEDS:
|
|
77
|
+
candidate = src / f"{name}.xml"
|
|
78
|
+
if candidate.exists():
|
|
79
|
+
shutil.copy2(candidate, fixtures / f"{name}.xml")
|
|
80
|
+
target = config.feeds_d / "mutable.xml"
|
|
81
|
+
if not target.exists():
|
|
82
|
+
target.write_text(
|
|
83
|
+
subscription_xml("mutable", f"{base}/mutable.xml", "editable demo feed")
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
print(f"initialised {root}")
|
|
87
|
+
print(f" feeds.d/ {len(list(config.feeds_d.glob('*.xml')))} subscriptions")
|
|
88
|
+
print(f" store/ (empty until first poll)")
|
|
89
|
+
print(f" var/ events.jsonl appears on first run")
|
|
90
|
+
print()
|
|
91
|
+
print(f"next: rssd daemon --root {root} --poll-now")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
96
|
+
from .subscriptions import load_dir
|
|
97
|
+
|
|
98
|
+
config = _config(args)
|
|
99
|
+
subs, errors = load_dir(config.feeds_d)
|
|
100
|
+
for name in sorted(subs):
|
|
101
|
+
sub = subs[name]
|
|
102
|
+
extra = " [fulltext]" if sub.fulltext else ""
|
|
103
|
+
print(f" ok {name:<14} {sub.url}{extra}")
|
|
104
|
+
for path, message in errors:
|
|
105
|
+
print(f" INVALID {path.name:<14} {message}")
|
|
106
|
+
print(f"\n{len(subs)} valid, {len(errors)} invalid")
|
|
107
|
+
return 1 if errors else 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_prune(args: argparse.Namespace) -> int:
|
|
111
|
+
"""Delete retired feed folders. Explicit, never automatic.
|
|
112
|
+
|
|
113
|
+
The daemon itself will not remove harvested data under any circumstance;
|
|
114
|
+
that decision belongs to a human who typed this command.
|
|
115
|
+
"""
|
|
116
|
+
from .state import load_state
|
|
117
|
+
|
|
118
|
+
config = _config(args)
|
|
119
|
+
removed = 0
|
|
120
|
+
for feed_dir in sorted(config.store.iterdir()) if config.store.exists() else []:
|
|
121
|
+
if not feed_dir.is_dir():
|
|
122
|
+
continue
|
|
123
|
+
state = load_state(config, feed_dir.name)
|
|
124
|
+
if state.health != "retired":
|
|
125
|
+
continue
|
|
126
|
+
if not args.yes:
|
|
127
|
+
print(f" would remove {feed_dir}")
|
|
128
|
+
removed += 1
|
|
129
|
+
continue
|
|
130
|
+
shutil.rmtree(feed_dir)
|
|
131
|
+
config.state_path(feed_dir.name).unlink(missing_ok=True)
|
|
132
|
+
print(f" removed {feed_dir}")
|
|
133
|
+
removed += 1
|
|
134
|
+
if removed and not args.yes:
|
|
135
|
+
print(f"\n{removed} retired feed(s). Re-run with --yes to delete.")
|
|
136
|
+
elif not removed:
|
|
137
|
+
print("nothing to prune")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_serve_fixtures(args: argparse.Namespace) -> int:
|
|
142
|
+
from . import fixtures
|
|
143
|
+
|
|
144
|
+
directory = Path(args.dir) if args.dir else fixtures.FIXTURE_DIR
|
|
145
|
+
server = fixtures.serve(args.port, directory)
|
|
146
|
+
print(f"serving {directory} on http://127.0.0.1:{args.port}")
|
|
147
|
+
for path in sorted(directory.glob("*.xml")):
|
|
148
|
+
print(f" /{path.name}")
|
|
149
|
+
try:
|
|
150
|
+
server.serve_forever()
|
|
151
|
+
except KeyboardInterrupt:
|
|
152
|
+
pass
|
|
153
|
+
finally:
|
|
154
|
+
server.shutdown()
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_demo_mutate(args: argparse.Namespace) -> int:
|
|
159
|
+
"""Force a revision on demand.
|
|
160
|
+
|
|
161
|
+
Waiting for a real publisher to fix a typo is not a demo. This edits the
|
|
162
|
+
served fixture so the next poll sees changed content for an entry it has
|
|
163
|
+
already stored, producing a .r2 file and an entry.revised event.
|
|
164
|
+
"""
|
|
165
|
+
import re
|
|
166
|
+
|
|
167
|
+
path = Path(args.root) / "fixtures" / "mutable.xml"
|
|
168
|
+
if not path.exists():
|
|
169
|
+
print(f"rssd: {path} not found; run `rssd init --fixture-mode` first")
|
|
170
|
+
return 1
|
|
171
|
+
text = path.read_text()
|
|
172
|
+
match = re.search(r"Revision (\d+)\.", text)
|
|
173
|
+
n = int(match.group(1)) + 1 if match else 2
|
|
174
|
+
path.write_text(MUTABLE_FIXTURE.format(n=n))
|
|
175
|
+
print(f"mutable.xml now at revision {n} — next poll will write .r{n}.xml")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_daemon(args: argparse.Namespace) -> int:
|
|
180
|
+
from .daemon import run_daemon
|
|
181
|
+
|
|
182
|
+
return asyncio.run(run_daemon(_config(args)))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_once(args: argparse.Namespace) -> int:
|
|
186
|
+
from .daemon import run_once
|
|
187
|
+
|
|
188
|
+
return asyncio.run(run_once(_config(args)))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_poll(args: argparse.Namespace) -> int:
|
|
192
|
+
from .daemon import poll_one
|
|
193
|
+
|
|
194
|
+
return asyncio.run(poll_one(_config(args), args.feed))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_tui(args: argparse.Namespace) -> int:
|
|
198
|
+
try:
|
|
199
|
+
from .tui.app import run_tui
|
|
200
|
+
except ImportError:
|
|
201
|
+
print("rssd: the TUI needs the [tui] extra — run `uv sync --extra tui`")
|
|
202
|
+
return 1
|
|
203
|
+
return run_tui(_config(args))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _config(args: argparse.Namespace) -> Config:
|
|
207
|
+
return Config(
|
|
208
|
+
root=Path(args.root),
|
|
209
|
+
limits=Limits(),
|
|
210
|
+
poll_now=getattr(args, "poll_now", False),
|
|
211
|
+
fixture_mode=getattr(args, "fixture_mode", False),
|
|
212
|
+
no_fsync=getattr(args, "no_fsync", False),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
217
|
+
parser = argparse.ArgumentParser(
|
|
218
|
+
prog="rssd", description="A file-based RSS daemon. The filesystem is the API."
|
|
219
|
+
)
|
|
220
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
221
|
+
|
|
222
|
+
def with_root(p: argparse.ArgumentParser) -> argparse.ArgumentParser:
|
|
223
|
+
p.add_argument("--root", default=".", help="instance root directory")
|
|
224
|
+
return p
|
|
225
|
+
|
|
226
|
+
p_init = sub.add_parser("init", help="scaffold a new instance")
|
|
227
|
+
p_init.add_argument("root", nargs="?", default=".")
|
|
228
|
+
p_init.add_argument("--force", action="store_true", help="overwrite existing subscriptions")
|
|
229
|
+
p_init.add_argument("--fixture-mode", action="store_true",
|
|
230
|
+
help="point subscriptions at the local fixture server")
|
|
231
|
+
p_init.add_argument("--fixture-base", default="http://127.0.0.1:8765")
|
|
232
|
+
p_init.set_defaults(func=cmd_init)
|
|
233
|
+
|
|
234
|
+
p_daemon = with_root(sub.add_parser("daemon", help="run the daemon"))
|
|
235
|
+
p_daemon.add_argument("--poll-now", action="store_true", help="skip the startup stagger")
|
|
236
|
+
p_daemon.add_argument("--fixture-mode", action="store_true")
|
|
237
|
+
p_daemon.add_argument("--no-fsync", action="store_true", help="unsafe; for slow filesystems")
|
|
238
|
+
p_daemon.set_defaults(func=cmd_daemon)
|
|
239
|
+
|
|
240
|
+
p_once = with_root(sub.add_parser("once", help="one poll pass, then exit"))
|
|
241
|
+
p_once.add_argument("--no-fsync", action="store_true")
|
|
242
|
+
p_once.set_defaults(func=cmd_once)
|
|
243
|
+
|
|
244
|
+
p_poll = with_root(sub.add_parser("poll", help="force-poll a single feed"))
|
|
245
|
+
p_poll.add_argument("feed")
|
|
246
|
+
p_poll.set_defaults(func=cmd_poll)
|
|
247
|
+
|
|
248
|
+
with_root(sub.add_parser("tui", help="terminal visualiser")).set_defaults(func=cmd_tui)
|
|
249
|
+
with_root(sub.add_parser("validate", help="check every subscription parses")).set_defaults(
|
|
250
|
+
func=cmd_validate
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
p_prune = with_root(sub.add_parser("prune", help="delete retired feed folders"))
|
|
254
|
+
p_prune.add_argument("--retired", action="store_true", default=True)
|
|
255
|
+
p_prune.add_argument("--yes", action="store_true", help="actually delete")
|
|
256
|
+
p_prune.set_defaults(func=cmd_prune)
|
|
257
|
+
|
|
258
|
+
p_serve = sub.add_parser("serve-fixtures", help="local HTTP server over fixtures")
|
|
259
|
+
p_serve.add_argument("--port", type=int, default=8765)
|
|
260
|
+
p_serve.add_argument("--dir", default=None)
|
|
261
|
+
p_serve.set_defaults(func=cmd_serve_fixtures)
|
|
262
|
+
|
|
263
|
+
p_mutate = with_root(sub.add_parser("demo-mutate", help="force a revision on demand"))
|
|
264
|
+
p_mutate.set_defaults(func=cmd_demo_mutate)
|
|
265
|
+
|
|
266
|
+
return parser
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def main(argv: list[str] | None = None) -> int:
|
|
270
|
+
args = build_parser().parse_args(argv)
|
|
271
|
+
try:
|
|
272
|
+
return args.func(args)
|
|
273
|
+
except KeyboardInterrupt:
|
|
274
|
+
return 130
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
sys.exit(main())
|
rssd/config.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Root paths, defaults and limits.
|
|
2
|
+
|
|
3
|
+
Every path in rssd is derived from a single root, so a whole instance is one
|
|
4
|
+
directory you can move, tar, or throw away.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
#: Feed folder names double as directory names and appear in every event, so
|
|
14
|
+
#: they are kept boring on purpose.
|
|
15
|
+
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
|
16
|
+
|
|
17
|
+
USER_AGENT = "rssd/0.1 (+https://github.com/allie/rssd)"
|
|
18
|
+
|
|
19
|
+
ACCEPT = (
|
|
20
|
+
"application/atom+xml, application/rss+xml, application/rdf+xml;q=0.9, "
|
|
21
|
+
"application/xml;q=0.8, text/xml;q=0.7, */*;q=0.1"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
#: XML namespace for entry documents.
|
|
25
|
+
NS = "https://rssd.dev/entry/1"
|
|
26
|
+
|
|
27
|
+
#: Temp files carry BOTH a dot prefix and a non-.xml suffix, so that neither
|
|
28
|
+
#: `ls`, nor a `*.xml` glob, nor `find -name '*.xml'` can ever see one.
|
|
29
|
+
TMP_PREFIX = ".rssd-tmp-"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Limits:
|
|
34
|
+
#: Interval bounds. Nothing polls faster than a minute or slower than 6h.
|
|
35
|
+
min_interval: int = 60
|
|
36
|
+
max_interval: int = 6 * 3600
|
|
37
|
+
default_interval: int = 900
|
|
38
|
+
#: Feeds that send no ETag/Last-Modified can't be checked cheaply, so they
|
|
39
|
+
#: are checked less often (SPEC §10.4).
|
|
40
|
+
no_validator_floor: int = 1800
|
|
41
|
+
|
|
42
|
+
#: A broken feed must not be able to write ten thousand files mid-demo.
|
|
43
|
+
max_new_entries_per_poll: int = 100
|
|
44
|
+
max_revisions_per_entry_per_day: int = 8
|
|
45
|
+
max_tracked_entries: int = 50_000
|
|
46
|
+
|
|
47
|
+
#: Rotating-guid detection thresholds (SPEC §9.4).
|
|
48
|
+
churn_new_ratio: float = 0.8
|
|
49
|
+
churn_hash_overlap: float = 0.5
|
|
50
|
+
churn_min_polls: int = 2
|
|
51
|
+
|
|
52
|
+
max_body_bytes: int = 5 * 1024 * 1024
|
|
53
|
+
request_timeout: float = 30.0
|
|
54
|
+
max_redirects: int = 5
|
|
55
|
+
|
|
56
|
+
global_concurrency: int = 6
|
|
57
|
+
per_host_concurrency: int = 2
|
|
58
|
+
|
|
59
|
+
max_failures_before_failing: int = 10
|
|
60
|
+
backoff_cap: int = 6 * 3600
|
|
61
|
+
|
|
62
|
+
event_log_max_bytes: int = 16 * 1024 * 1024
|
|
63
|
+
event_log_keep: int = 10
|
|
64
|
+
|
|
65
|
+
#: Startup stagger so the first minute isn't a thundering herd.
|
|
66
|
+
stagger_step: float = 0.75
|
|
67
|
+
stagger_cap: float = 30.0
|
|
68
|
+
|
|
69
|
+
#: Debounce for feeds.d/ changes, and the grace window that stops a vim
|
|
70
|
+
#: `:w` (unlink-then-create) from retiring a feed.
|
|
71
|
+
watch_debounce: float = 0.3
|
|
72
|
+
unlink_grace: float = 5.0
|
|
73
|
+
|
|
74
|
+
slug_max_len: int = 48
|
|
75
|
+
#: Entries dated further ahead than this are clamped (SPEC §4.2).
|
|
76
|
+
future_clamp: int = 24 * 3600
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Config:
|
|
81
|
+
root: Path
|
|
82
|
+
limits: Limits = field(default_factory=Limits)
|
|
83
|
+
#: Collapse the startup stagger; for demos.
|
|
84
|
+
poll_now: bool = False
|
|
85
|
+
#: Point every subscription at the local fixture server instead of the
|
|
86
|
+
#: real internet. Demo insurance.
|
|
87
|
+
fixture_mode: bool = False
|
|
88
|
+
fixture_base: str = "http://127.0.0.1:8765"
|
|
89
|
+
#: Escape hatch for slow filesystems; never use in production.
|
|
90
|
+
no_fsync: bool = False
|
|
91
|
+
|
|
92
|
+
# ── derived paths ────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def feeds_d(self) -> Path:
|
|
96
|
+
return self.root / "feeds.d"
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def store(self) -> Path:
|
|
100
|
+
return self.root / "store"
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def var(self) -> Path:
|
|
104
|
+
return self.root / "var"
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def state_dir(self) -> Path:
|
|
108
|
+
return self.var / "state"
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def events_path(self) -> Path:
|
|
112
|
+
return self.var / "events.jsonl"
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def seq_path(self) -> Path:
|
|
116
|
+
return self.var / "seq"
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def lock_path(self) -> Path:
|
|
120
|
+
return self.var / "rssd.lock"
|
|
121
|
+
|
|
122
|
+
def feed_dir(self, name: str) -> Path:
|
|
123
|
+
return self.store / name
|
|
124
|
+
|
|
125
|
+
def entries_dir(self, name: str) -> Path:
|
|
126
|
+
return self.store / name / "entries"
|
|
127
|
+
|
|
128
|
+
def feed_xml(self, name: str) -> Path:
|
|
129
|
+
return self.store / name / "feed.xml"
|
|
130
|
+
|
|
131
|
+
def status_xml(self, name: str) -> Path:
|
|
132
|
+
return self.store / name / "status.xml"
|
|
133
|
+
|
|
134
|
+
def state_path(self, name: str) -> Path:
|
|
135
|
+
return self.state_dir / f"{name}.json"
|
|
136
|
+
|
|
137
|
+
def relative(self, path: Path) -> str:
|
|
138
|
+
"""Paths in events are always relative to the root, so a consumer can
|
|
139
|
+
resolve them regardless of where the instance lives."""
|
|
140
|
+
try:
|
|
141
|
+
return str(path.relative_to(self.root))
|
|
142
|
+
except ValueError:
|
|
143
|
+
return str(path)
|
|
144
|
+
|
|
145
|
+
def ensure_dirs(self) -> None:
|
|
146
|
+
for d in (self.feeds_d, self.store, self.var, self.state_dir):
|
|
147
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def valid_name(name: str) -> bool:
|
|
151
|
+
return bool(NAME_RE.match(name))
|
rssd/daemon.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Process lifecycle: the lock, the signals, and the recovery pass."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import fcntl
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .events import EventLog
|
|
14
|
+
from .scheduler import Scheduler
|
|
15
|
+
from .store import sweep_temps
|
|
16
|
+
from .watcher import Reconciler, watch
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LockedError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RootLock:
|
|
24
|
+
"""One daemon per root.
|
|
25
|
+
|
|
26
|
+
Two daemons sharing a tree would interleave revisions and fight over state
|
|
27
|
+
files, and the damage would be silent. An exclusive flock is cheap enough
|
|
28
|
+
that there is no reason not to.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, path: Path) -> None:
|
|
32
|
+
self.path = path
|
|
33
|
+
self._fd: int | None = None
|
|
34
|
+
|
|
35
|
+
def acquire(self) -> None:
|
|
36
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
38
|
+
try:
|
|
39
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
os.close(fd)
|
|
42
|
+
raise LockedError(
|
|
43
|
+
f"another rssd daemon holds {self.path}; refusing to start"
|
|
44
|
+
) from exc
|
|
45
|
+
os.ftruncate(fd, 0)
|
|
46
|
+
os.write(fd, f"{os.getpid()}\n".encode())
|
|
47
|
+
os.fsync(fd)
|
|
48
|
+
self._fd = fd
|
|
49
|
+
|
|
50
|
+
def release(self) -> None:
|
|
51
|
+
if self._fd is not None:
|
|
52
|
+
with contextlib.suppress(OSError):
|
|
53
|
+
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
|
54
|
+
os.close(self._fd)
|
|
55
|
+
self._fd = None
|
|
56
|
+
with contextlib.suppress(OSError):
|
|
57
|
+
self.path.unlink()
|
|
58
|
+
|
|
59
|
+
def __enter__(self) -> RootLock:
|
|
60
|
+
self.acquire()
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, *exc) -> None:
|
|
64
|
+
self.release()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def run_daemon(config: Config) -> int:
|
|
68
|
+
config.ensure_dirs()
|
|
69
|
+
lock = RootLock(config.lock_path)
|
|
70
|
+
try:
|
|
71
|
+
lock.acquire()
|
|
72
|
+
except LockedError as exc:
|
|
73
|
+
print(f"rssd: {exc}")
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
events = EventLog(config)
|
|
77
|
+
events.open()
|
|
78
|
+
stop = asyncio.Event()
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
# A SIGKILL mid-write can only ever leave temp files behind; a real
|
|
82
|
+
# entry path is never partial. Clear them before anything reads the tree.
|
|
83
|
+
swept = sweep_temps(config.store)
|
|
84
|
+
events.emit("daemon.started", None, pid=os.getpid(), root=str(config.root),
|
|
85
|
+
swept_temps=swept)
|
|
86
|
+
|
|
87
|
+
scheduler = Scheduler(config, events)
|
|
88
|
+
reconciler = Reconciler(config=config, scheduler=scheduler, events=events)
|
|
89
|
+
reconciler.reconcile(initial=True)
|
|
90
|
+
|
|
91
|
+
tracked = sum(len(r.seen) for r in scheduler.runners.values())
|
|
92
|
+
events.emit(
|
|
93
|
+
"daemon.reconciled", None,
|
|
94
|
+
feeds=len(scheduler.runners), entries=tracked,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
loop = asyncio.get_running_loop()
|
|
98
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
99
|
+
loop.add_signal_handler(sig, stop.set)
|
|
100
|
+
|
|
101
|
+
tasks = [
|
|
102
|
+
asyncio.create_task(scheduler.run(), name="scheduler"),
|
|
103
|
+
asyncio.create_task(watch(config, reconciler, stop), name="watcher"),
|
|
104
|
+
asyncio.create_task(stop.wait(), name="stop"),
|
|
105
|
+
]
|
|
106
|
+
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
107
|
+
|
|
108
|
+
events.emit("daemon.stopping", None)
|
|
109
|
+
for task in tasks:
|
|
110
|
+
task.cancel()
|
|
111
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
112
|
+
await scheduler.stop()
|
|
113
|
+
return 0
|
|
114
|
+
finally:
|
|
115
|
+
events.close()
|
|
116
|
+
lock.release()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def run_once(config: Config) -> int:
|
|
120
|
+
"""One poll pass over every subscription, then exit.
|
|
121
|
+
|
|
122
|
+
Useful in cron-shaped deployments, and it is what the integration tests
|
|
123
|
+
drive so they never have to reason about timers.
|
|
124
|
+
"""
|
|
125
|
+
config.ensure_dirs()
|
|
126
|
+
events = EventLog(config)
|
|
127
|
+
events.open()
|
|
128
|
+
try:
|
|
129
|
+
sweep_temps(config.store)
|
|
130
|
+
events.emit("daemon.started", None, pid=os.getpid(), mode="once")
|
|
131
|
+
scheduler = Scheduler(config, events)
|
|
132
|
+
reconciler = Reconciler(config=config, scheduler=scheduler, events=events)
|
|
133
|
+
reconciler.reconcile(initial=True)
|
|
134
|
+
runners = list(scheduler.runners.values())
|
|
135
|
+
sem = asyncio.Semaphore(config.limits.global_concurrency)
|
|
136
|
+
|
|
137
|
+
async def _one(runner):
|
|
138
|
+
async with sem:
|
|
139
|
+
try:
|
|
140
|
+
await runner.poll()
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
events.emit("feed.poll-failed", runner.name, error=f"internal: {exc!r}")
|
|
143
|
+
|
|
144
|
+
await asyncio.gather(*(_one(r) for r in runners))
|
|
145
|
+
events.emit("daemon.stopping", None)
|
|
146
|
+
await scheduler.client.aclose()
|
|
147
|
+
return 0
|
|
148
|
+
finally:
|
|
149
|
+
events.close()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def poll_one(config: Config, name: str) -> int:
|
|
153
|
+
config.ensure_dirs()
|
|
154
|
+
events = EventLog(config)
|
|
155
|
+
events.open()
|
|
156
|
+
try:
|
|
157
|
+
scheduler = Scheduler(config, events)
|
|
158
|
+
reconciler = Reconciler(config=config, scheduler=scheduler, events=events)
|
|
159
|
+
reconciler.reconcile(initial=True)
|
|
160
|
+
runner = scheduler.runners.get(name)
|
|
161
|
+
if runner is None:
|
|
162
|
+
print(f"rssd: no such feed: {name}")
|
|
163
|
+
return 1
|
|
164
|
+
await runner.poll()
|
|
165
|
+
await scheduler.client.aclose()
|
|
166
|
+
return 0
|
|
167
|
+
finally:
|
|
168
|
+
events.close()
|