diff --git a/README.md b/README.md index 706b052..bde7dee 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,22 @@ uv sync --all-groups uv run repub ``` +With no arguments, `uv run repub` starts the web UI in local dev mode and serves published feed files from `/feeds/...` out of `out/feeds/...`. + By default the UI listens on `127.0.0.1:8080`. You can override that with `REPUBLISHER_HOST` and `REPUBLISHER_PORT`, or with: ```sh uv run repub serve --host 0.0.0.0 --port 8080 ``` +If you invoke the `serve` subcommand explicitly, use `--dev-mode` to expose published feeds directly from the Quart app: + +```sh +uv run repub serve --dev-mode +``` + +In `--dev-mode`, requests under `/feeds/...` are served from `out/feeds/...`. + Important: the admin UI has no built-in authentication. Keep it bound to localhost or put it behind a trusted network layer such as Tailscale. Once the UI is running: diff --git a/repub/entrypoint.py b/repub/entrypoint.py index 12ce84c..71861a6 100644 --- a/repub/entrypoint.py +++ b/repub/entrypoint.py @@ -42,6 +42,11 @@ def parse_args(argv: list[str] | None = None) -> tuple[str, argparse.Namespace]: default=os.environ.get("REPUBLISHER_PORT", "8080"), help="Port for the web UI", ) + serve_parser.add_argument( + "--dev-mode", + action="store_true", + help="Serve published feeds from /feeds for local development", + ) crawl_parser = subparsers.add_parser("crawl", help="Run the feed crawler once") crawl_parser.add_argument( @@ -51,11 +56,11 @@ def parse_args(argv: list[str] | None = None) -> tuple[str, argparse.Namespace]: help="Path to runtime config TOML file", ) if not raw_args: - raw_args = ["serve"] + raw_args = ["serve", "--dev-mode"] elif raw_args[0] in {"-c", "--config"}: raw_args = ["crawl", *raw_args] elif raw_args[0] not in {"serve", "crawl"}: - raw_args = ["serve", *raw_args] + raw_args = ["serve", "--dev-mode", *raw_args] args = parser.parse_args(raw_args) command = args.command or "serve" @@ -75,7 +80,7 @@ def entrypoint(argv: list[str] | None = None) -> int: logger.error("Invalid REPUBLISHER_PORT/--port value: %s", args.port) return 2 - app = create_app() + app = create_app(dev_mode=bool(args.dev_mode)) app.run(host=args.host, port=port) return 0 diff --git a/repub/jobs.py b/repub/jobs.py index 0912089..5774195 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -61,16 +61,10 @@ class JobRuntime: self, *, log_dir: str | Path, - worker_duration_seconds: float = 20.0, - worker_stats_interval_seconds: float = 1.0, - worker_failure_probability: float = 0.3, refresh_callback: Callable[[], None] | None = None, graceful_stop_seconds: float = 15.0, ) -> None: self.log_dir = Path(log_dir) - self.worker_duration_seconds = worker_duration_seconds - self.worker_stats_interval_seconds = worker_stats_interval_seconds - self.worker_failure_probability = worker_failure_probability self.refresh_callback = refresh_callback self.graceful_stop_seconds = graceful_stop_seconds self.scheduler = BackgroundScheduler(timezone=UTC) diff --git a/repub/web.py b/repub/web.py index 06341d3..0b3e1cd 100644 --- a/repub/web.py +++ b/repub/web.py @@ -13,7 +13,7 @@ from datastar_py.quart import DatastarResponse, read_signals from datastar_py.sse import DatastarEvent from htpy import Renderable from peewee import IntegrityError -from quart import Quart, Response, request, url_for +from quart import Quart, Response, request, send_from_directory, url_for from repub.datastar import RefreshBroker, render_stream from repub.jobs import ( @@ -46,6 +46,7 @@ from repub.pages.sources import PANGEA_CONTENT_FORMATS, PANGEA_CONTENT_TYPES REFRESH_BROKER_KEY = "repub.refresh_broker" JOB_RUNTIME_KEY = "repub.job_runtime" DEFAULT_LOG_DIR = Path("out/logs") +DEFAULT_FEEDS_DIR = Path("out/feeds") RenderFunction = Callable[[], Awaitable[Renderable]] @@ -95,16 +96,27 @@ def _render_shim_page( return body, etag -def create_app() -> Quart: +def create_app(*, dev_mode: bool = False) -> Quart: app = Quart(__name__) app.config["REPUB_DB_PATH"] = str(initialize_database()) app.config.setdefault("REPUB_LOG_DIR", DEFAULT_LOG_DIR) - app.config.setdefault("REPUB_JOB_WORKER_DURATION_SECONDS", 20.0) - app.config.setdefault("REPUB_JOB_WORKER_STATS_INTERVAL_SECONDS", 1.0) - app.config.setdefault("REPUB_JOB_WORKER_FAILURE_PROBABILITY", 0.3) + app.config.setdefault("REPUB_FEEDS_DIR", DEFAULT_FEEDS_DIR) + app.config["REPUB_DEV_MODE"] = dev_mode app.extensions[REFRESH_BROKER_KEY] = RefreshBroker() app.extensions[JOB_RUNTIME_KEY] = None + @app.get("/feeds/") + async def published_feed(feed_path: str) -> Response: + if not bool(app.config["REPUB_DEV_MODE"]): + return Response(status=404) + response = await send_from_directory( + str(Path(app.config["REPUB_FEEDS_DIR"])), + feed_path, + ) + if Path(feed_path).suffix == ".rss": + response.mimetype = "application/rss+xml" + return response + @app.get("/") @app.get("/sources") @app.get("/sources/create") @@ -257,15 +269,6 @@ def get_job_runtime(app: Quart) -> JobRuntime: if runtime is None: runtime = JobRuntime( log_dir=app.config["REPUB_LOG_DIR"], - worker_duration_seconds=float( - app.config["REPUB_JOB_WORKER_DURATION_SECONDS"] - ), - worker_stats_interval_seconds=float( - app.config["REPUB_JOB_WORKER_STATS_INTERVAL_SECONDS"] - ), - worker_failure_probability=float( - app.config["REPUB_JOB_WORKER_FAILURE_PROBABILITY"] - ), refresh_callback=lambda: trigger_refresh(app), ) app.extensions[JOB_RUNTIME_KEY] = runtime diff --git a/tests/test_dev_mode.py b/tests/test_dev_mode.py new file mode 100644 index 0000000..f58d640 --- /dev/null +++ b/tests/test_dev_mode.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +from repub.web import create_app + + +def test_dev_mode_serves_published_feeds(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "dev-mode.db" + feeds_dir = tmp_path / "out" / "feeds" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app(dev_mode=True) + app.config["REPUB_FEEDS_DIR"] = feeds_dir + feed_path = feeds_dir / "demo-source" / "feed.rss" + feed_path.parent.mkdir(parents=True) + feed_path.write_text("\n", encoding="utf-8") + + client = app.test_client() + response = await client.get("/feeds/demo-source/feed.rss") + + assert response.status_code == 200 + assert response.mimetype == "application/rss+xml" + assert await response.get_data(as_text=True) == "\n" + + asyncio.run(run()) + + +def test_dev_mode_serves_feed_enclosure_assets(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "dev-mode-assets.db" + feeds_dir = tmp_path / "out" / "feeds" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app(dev_mode=True) + app.config["REPUB_FEEDS_DIR"] = feeds_dir + enclosure_path = feeds_dir / "demo-source" / "audio" / "episode.mp3" + enclosure_path.parent.mkdir(parents=True) + enclosure_path.write_bytes(b"mp3-data") + + client = app.test_client() + response = await client.get("/feeds/demo-source/audio/episode.mp3") + + assert response.status_code == 200 + assert await response.get_data() == b"mp3-data" + + asyncio.run(run()) + + +def test_default_mode_does_not_serve_published_feeds( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "default-mode.db" + feeds_dir = tmp_path / "out" / "feeds" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app() + app.config["REPUB_FEEDS_DIR"] = feeds_dir + feed_path = feeds_dir / "demo-source" / "feed.rss" + feed_path.parent.mkdir(parents=True) + feed_path.write_text("\n", encoding="utf-8") + + client = app.test_client() + response = await client.get("/feeds/demo-source/feed.rss") + + assert response.status_code == 404 + + asyncio.run(run()) diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index a13b5c5..bc0e6a0 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -31,6 +31,20 @@ def test_parse_args_uses_republisher_host_and_port_env_vars(monkeypatch) -> None assert args.port == "9090" +def test_parse_args_supports_dev_mode_flag() -> None: + command, args = parse_args(["serve", "--dev-mode"]) + + assert command == "serve" + assert args.dev_mode is True + + +def test_parse_args_defaults_to_dev_mode_when_no_args() -> None: + command, args = parse_args([]) + + assert command == "serve" + assert args.dev_mode is True + + def test_entrypoint_rejects_invalid_republisher_port(monkeypatch) -> None: monkeypatch.setenv("REPUBLISHER_PORT", "not-a-number") stream = io.StringIO() @@ -49,3 +63,25 @@ def test_entrypoint_rejects_invalid_republisher_port(monkeypatch) -> None: assert exit_code == 2 assert "Invalid REPUBLISHER_PORT/--port value" in stream.getvalue() + + +def test_entrypoint_passes_dev_mode_to_create_app(monkeypatch) -> None: + recorded: dict[str, object] = {} + + class StubApp: + def run(self, *, host: str, port: int) -> None: + recorded["host"] = host + recorded["port"] = port + + def fake_create_app(*, dev_mode: bool) -> StubApp: + recorded["dev_mode"] = dev_mode + return StubApp() + + monkeypatch.setattr("repub.entrypoint.create_app", fake_create_app) + + exit_code = entrypoint( + ["serve", "--dev-mode", "--host", "0.0.0.0", "--port", "9090"] + ) + + assert exit_code == 0 + assert recorded == {"dev_mode": True, "host": "0.0.0.0", "port": 9090} diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index 22f9144..d9964ff 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -59,12 +59,7 @@ def test_job_runtime_syncs_enabled_jobs_into_apscheduler(tmp_path: Path) -> None enabled_job = Job.get(Job.source == enabled_source) disabled_job = Job.get(Job.source == disabled_source) - runtime = JobRuntime( - log_dir=tmp_path / "out" / "logs", - worker_duration_seconds=0.4, - worker_stats_interval_seconds=0.05, - worker_failure_probability=0.0, - ) + runtime = JobRuntime(log_dir=tmp_path / "out" / "logs") try: runtime.start() runtime.sync_jobs() @@ -104,12 +99,7 @@ def test_job_runtime_run_now_writes_log_and_stats_and_marks_success( ) job = Job.get(Job.source == source) - runtime = JobRuntime( - log_dir=tmp_path / "out" / "logs", - worker_duration_seconds=0.35, - worker_stats_interval_seconds=0.05, - worker_failure_probability=0.0, - ) + runtime = JobRuntime(log_dir=tmp_path / "out" / "logs") try: runtime.start() execution_id = runtime.run_job_now(job.id, reason="manual") @@ -164,12 +154,7 @@ def test_job_runtime_cancel_marks_execution_canceled(tmp_path: Path) -> None: ) job = Job.get(Job.source == source) - runtime = JobRuntime( - log_dir=tmp_path / "out" / "logs", - worker_duration_seconds=2.0, - worker_stats_interval_seconds=0.1, - worker_failure_probability=0.0, - ) + runtime = JobRuntime(log_dir=tmp_path / "out" / "logs") try: runtime.start() execution_id = runtime.run_job_now(job.id, reason="manual") @@ -227,12 +212,7 @@ def test_job_runtime_start_reconciles_stale_running_execution(tmp_path: Path) -> encoding="utf-8", ) - runtime = JobRuntime( - log_dir=tmp_path / "out" / "logs", - worker_duration_seconds=0.5, - worker_stats_interval_seconds=0.05, - worker_failure_probability=0.0, - ) + runtime = JobRuntime(log_dir=tmp_path / "out" / "logs") try: runtime.start() reconciled_execution = JobExecution.get_by_id(execution.get_id()) @@ -344,10 +324,6 @@ def test_render_runs_uses_database_backed_jobs_and_executions( app = create_app() app.config["REPUB_LOG_DIR"] = log_dir - app.config["REPUB_JOB_WORKER_DURATION_SECONDS"] = 0.35 - app.config["REPUB_JOB_WORKER_STATS_INTERVAL_SECONDS"] = 0.05 - app.config["REPUB_JOB_WORKER_FAILURE_PROBABILITY"] = 0.0 - source = create_source( name="Runs page source", slug="runs-page-source",