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 ADDED
File without changes
rssd/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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>&lt;p&gt;Revision {n}. Every version is kept on disk.&lt;/p&gt;</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()