add dev-mode
This commit is contained in:
parent
0803617e62
commit
31e1da937f
7 changed files with 146 additions and 51 deletions
10
README.md
10
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
31
repub/web.py
31
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/<path:feed_path>")
|
||||
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
|
||||
|
|
|
|||
71
tests/test_dev_mode.py
Normal file
71
tests/test_dev_mode.py
Normal file
|
|
@ -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("<rss/>\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) == "<rss/>\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("<rss/>\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())
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue