From 5a8162c876d0e94d138803a24c6d99de18026b4a Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Sun, 29 Mar 2026 14:44:45 +0200 Subject: [PATCH 1/4] repub: support slugged feeds and imported TOML feed configs --- PLAN.md | 79 ++++++++++++++++++++++ README.md | 20 ++++-- demo/README.md | 14 +++- demo/repub.toml | 6 +- repub/config.py | 139 ++++++++++++++++++++++++++++++--------- repub/entrypoint.py | 32 +++++---- tests/test_config.py | 102 +++++++++++++++++++++------- tests/test_file_feeds.py | 3 +- tests/test_pipelines.py | 5 +- 9 files changed, 324 insertions(+), 76 deletions(-) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..35261ae --- /dev/null +++ b/PLAN.md @@ -0,0 +1,79 @@ +# Plan + +## 1. Task Record + +The work spans two local repositories: + +- `/home/abel/src/gitlab.com/guardianproject-ops/pygea` +- `/home/abel/src/guardianproject/anynews/republisher-redux` + +Requested outcome: + +1. Refactor `pygea` so it no longer hardcodes feed inputs in `pygea/main.py`. +2. Make `pygea` accept a TOML config file in the same general style as `republisher-redux` instead of pygea.ini +3. Replace tuple-based feed definitions such as `("Titulares", True, None)` with proper keyed data shaped like: + `{"name": "Titulares", "only_newest": True, "content_type": None}`. +4. Add a required user-provided `slug` field alongside each feed name in `pygea`. like: + `{"name": "Titulares", "only_newest": True, "content_type": None, "titulares"}`. +5. Stop using the hash-based subdirectory name in `pygea`; use the configured slug instead. +6. Create a `demo/` directory in `pygea` with an example config, similar to `republisher-redux`. +7. Change `pygea` output from `manifest.json` to `manifest.toml`. +8. Make `pygea` write `manifest.toml` in `[[feeds]]` format that `republisher-redux` can consume directly. +9. Each generated manifest feed entry must include: + - `name` + - `slug` + - `url` +10. The manifest `url` must be an absolute `file://` URI pointing to that feed's `rss.xml`. +11. Extend `republisher-redux` so its runtime config can load additional feed definitions from a separate TOML file, specifically the `pygea`-generated manifest file. +12. Keep current `republisher-redux` features intact while adding the extra feed-config source. +13. Update docs in both repos so the new workflow is discoverable. +14. Add or update tests in both repos. +15. Verify both projects are working. +16. Stage the resulting changes. +17. Draft a commit message, but do not commit. + +Operational context and nuance to preserve: + +- The intended deployment is two `systemd` services on the same machine, one for `pygea` and one for `republisher-redux`. +- The user will handle the `systemd` units; this task is only about application/config/docs/test changes. +- The purpose of `slug` is operational clarity and stable filesystem paths, especially for wiring `pygea` output into `republisher-redux`. +- `slug` must be user-supplied, not auto-generated. +- `name` may remain human-facing, including strings that are awkward for filesystem paths. +- `republisher-redux` should be able to merge feeds declared directly in its own config with feeds loaded from the external TOML manifest. +- Final validation should include formatter and flake checks, and work should be staged but not committed. + +## 2. Execution Plan + +1. Finish refactoring `pygea` runtime configuration: + - Introduce a TOML config loader and validation. + - Replace import-time config reads and hardcoded feed tuples. + - Make feed definitions explicit objects with `name`, `slug`, `only_newest`, and `content_type`. + +2. Finish refactoring `pygea` output behavior: + - Write feed output under slug-based directories instead of hash-based directories. + - Emit `manifest.toml` in `[[feeds]]` format with absolute `file://` URLs. + - Add `demo/` examples and update docs. + +3. Add `pygea` tests and packaging/check updates: + - Cover config parsing, manifest generation, and slug-based output behavior. + - Update `pyproject.toml`, `flake.nix`, and related files as needed so tests are part of normal validation. + +4. Update `republisher-redux` config handling: + - Extend feed definitions to include `slug`. + - Use `slug` for path/log/output naming while preserving `name` as the user-facing label. + - Add a config option for loading additional feed definitions from one or more external TOML files. + - Merge direct feeds and imported feeds with duplicate detection. + +5. Update `republisher-redux` tests and docs: + - Cover slug-aware feed config loading and external TOML feed imports. + - Document how to consume a `pygea` manifest. + +6. Validate both repos: + - Run formatting where required. + - Run repo tests. + - Run `nix flake check` in both repos. + +7. Finalize without committing: + - Review diffs. + - Stage the intended files only. + - Draft a commit message for user review. diff --git a/README.md b/README.md index 31584d0..3ea7876 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,22 @@ cat > repub.toml <<'EOF' out_dir = "out" [[feeds]] -name = "gp-pod" +name = "Guardian Project Podcast" +slug = "gp-pod" url = "https://guardianproject.info/podcast/podcast.xml" [[feeds]] -name = "nasa" +name = "NASA Breaking News" +slug = "nasa" url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" EOF uv run repub --config repub.toml ``` `out_dir` may be relative or absolute. Relative paths are resolved against the -directory containing the config file. Optional Scrapy runtime overrides can be -set in the same file: +directory containing the config file. Each feed now needs a user-provided +`slug`, which is used for output paths and filenames. Optional Scrapy runtime +overrides can be set in the same file: ```toml [scrapy.settings] @@ -27,6 +30,15 @@ LOG_LEVEL = "DEBUG" DOWNLOAD_TIMEOUT = 30 ``` +Additional feed definitions can also be imported from one or more TOML files, +including a `pygea`-generated `manifest.toml`: + +```toml +feed_config_files = ["/absolute/path/to/pygea/feed/manifest.toml"] +``` + +Imported files only need `[[feeds]]` entries with `name`, `slug`, and `url`. + See [`demo/README.md`](/home/abel/src/guardianproject/anynews/republisher-redux/demo/README.md) for a self-contained example config. ## TODO diff --git a/demo/README.md b/demo/README.md index 7a2d23d..4cca777 100644 --- a/demo/README.md +++ b/demo/README.md @@ -14,7 +14,7 @@ Because `out_dir` in [`demo/repub.toml`](/home/abel/src/guardianproject/anynews/ ## Files -- `repub.toml`: example runtime config with feed definitions and Scrapy overrides +- `repub.toml`: example runtime config with feed definitions, slugs, and Scrapy overrides - `fixtures/local-feed.rss`: simple local RSS fixture for `file://` feed testing ## Local File Feed @@ -29,6 +29,16 @@ Then use that value in a config entry: ```toml [[feeds]] -name = "local-demo" +name = "Local Demo" +slug = "local-demo" url = "file:///absolute/path/to/demo/fixtures/local-feed.rss" ``` + +## Pygea Import + +`repub` can also load additional `[[feeds]]` entries from a separate TOML file, +such as `pygea`'s generated `manifest.toml`: + +```toml +feed_config_files = ["/absolute/path/to/pygea/feed/manifest.toml"] +``` diff --git a/demo/repub.toml b/demo/repub.toml index 6540f33..951a47f 100644 --- a/demo/repub.toml +++ b/demo/repub.toml @@ -1,11 +1,13 @@ out_dir = "out" [[feeds]] -name = "gp-pod" +name = "Guardian Project Podcast" +slug = "gp-pod" url = "https://guardianproject.info/podcast/podcast.xml" [[feeds]] -name = "nasa" +name = "NASA Breaking News" +slug = "nasa" url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" [scrapy.settings] diff --git a/repub/config.py b/repub/config.py index 81038a9..38cbf56 100644 --- a/repub/config.py +++ b/repub/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import tomllib from dataclasses import dataclass from pathlib import Path @@ -11,11 +12,13 @@ IMAGE_DIR = "images" VIDEO_DIR = "video" AUDIO_DIR = "audio" FILE_DIR = "files" +SLUG_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") @dataclass(frozen=True) class FeedConfig: name: str + slug: str url: str @@ -27,38 +30,114 @@ class RepublisherConfig: scrapy_settings: dict[str, Any] +def _resolve_path(base_path: Path, value: str) -> Path: + path = Path(value).expanduser() + if not path.is_absolute(): + path = (base_path.parent / path).resolve() + return path + + +def _load_toml(path: Path) -> dict[str, Any]: + with path.open("rb") as config_file: + raw_config = tomllib.load(config_file) + if not isinstance(raw_config, dict): + raise ValueError(f"Config file {path} must contain a TOML table") + return raw_config + + +def _parse_feed_config_paths( + raw_config: dict[str, Any], *, config_path: Path +) -> tuple[Path, ...]: + raw_paths = raw_config.get("feed_config_files", []) + if raw_paths is None: + return () + if isinstance(raw_paths, str): + raw_paths = [raw_paths] + if not isinstance(raw_paths, list): + raise ValueError("Config field 'feed_config_files' must be a string or list") + + paths: list[Path] = [] + for index, raw_path in enumerate(raw_paths, start=1): + if not isinstance(raw_path, str) or not raw_path: + raise ValueError( + f"Config field 'feed_config_files[{index}]' must be a non-empty string" + ) + paths.append(_resolve_path(config_path, raw_path)) + return tuple(paths) + + +def _parse_feed_tables(raw_feeds: Any, *, source_path: Path) -> tuple[FeedConfig, ...]: + if raw_feeds is None: + return () + if not isinstance(raw_feeds, list): + raise ValueError(f"Config file {source_path} field 'feeds' must be an array") + + feeds: list[FeedConfig] = [] + for raw_feed in raw_feeds: + if not isinstance(raw_feed, dict): + raise ValueError( + f"Config file {source_path} has a non-table [[feeds]] entry" + ) + name = raw_feed.get("name") + slug = raw_feed.get("slug") + url = raw_feed.get("url") + if not isinstance(name, str) or not name: + raise ValueError( + f"Config file {source_path} has a [[feeds]] entry without a valid 'name'" + ) + if not isinstance(slug, str) or not slug: + raise ValueError(f"Feed {name!r} in {source_path} needs a non-empty 'slug'") + if SLUG_PATTERN.fullmatch(slug) is None: + raise ValueError( + f"Feed slug {slug!r} in {source_path} must match {SLUG_PATTERN.pattern!r}" + ) + if not isinstance(url, str) or not url: + raise ValueError(f"Feed {name!r} in {source_path} needs a non-empty 'url'") + feeds.append(FeedConfig(name=name, slug=slug, url=url)) + return tuple(feeds) + + +def _merge_feeds(feed_groups: list[tuple[FeedConfig, ...]]) -> tuple[FeedConfig, ...]: + feeds: list[FeedConfig] = [] + feed_names: set[str] = set() + feed_slugs: set[str] = set() + for group in feed_groups: + for feed in group: + if feed.name in feed_names: + raise ValueError(f"Feed name {feed.name!r} is duplicated") + if feed.slug in feed_slugs: + raise ValueError(f"Feed slug {feed.slug!r} is duplicated") + feed_names.add(feed.name) + feed_slugs.add(feed.slug) + feeds.append(feed) + return tuple(feeds) + + def load_config(path: str | Path) -> RepublisherConfig: config_path = Path(path).expanduser().resolve() - with config_path.open("rb") as config_file: - raw_config = tomllib.load(config_file) + raw_config = _load_toml(config_path) out_dir_value = raw_config.get("out_dir", "out") if not isinstance(out_dir_value, str) or not out_dir_value: raise ValueError("Config field 'out_dir' must be a non-empty string") + out_dir = _resolve_path(config_path, out_dir_value) - out_dir = Path(out_dir_value).expanduser() - if not out_dir.is_absolute(): - out_dir = (config_path.parent / out_dir).resolve() + feed_config_paths = _parse_feed_config_paths(raw_config, config_path=config_path) + feed_groups = [_parse_feed_tables(raw_config.get("feeds"), source_path=config_path)] + for feed_config_path in feed_config_paths: + imported_config = _load_toml(feed_config_path) + feed_groups.append( + _parse_feed_tables( + imported_config.get("feeds"), + source_path=feed_config_path, + ) + ) - raw_feeds = raw_config.get("feeds") - if not isinstance(raw_feeds, list) or not raw_feeds: - raise ValueError("Config must include at least one [[feeds]] entry") - - feeds: list[FeedConfig] = [] - feed_names: set[str] = set() - for raw_feed in raw_feeds: - if not isinstance(raw_feed, dict): - raise ValueError("Each [[feeds]] entry must be a table") - name = raw_feed.get("name") - url = raw_feed.get("url") - if not isinstance(name, str) or not name: - raise ValueError("Each [[feeds]] entry needs a non-empty 'name'") - if not isinstance(url, str) or not url: - raise ValueError(f"Feed {name!r} needs a non-empty 'url'") - if name in feed_names: - raise ValueError(f"Feed name {name!r} is duplicated") - feed_names.add(name) - feeds.append(FeedConfig(name=name, url=url)) + feeds = _merge_feeds(feed_groups) + if not feeds: + raise ValueError( + "Config must include at least one [[feeds]] entry or feed_config_files import" + ) raw_scrapy = raw_config.get("scrapy", {}) if raw_scrapy is None: @@ -75,7 +154,7 @@ def load_config(path: str | Path) -> RepublisherConfig: return RepublisherConfig( config_path=config_path, out_dir=out_dir, - feeds=tuple(feeds), + feeds=feeds, scrapy_settings=scrapy_settings, ) @@ -92,9 +171,9 @@ def build_feed_settings( base_settings: Settings, *, out_dir: Path, - feed_name: str, + feed_slug: str, ) -> Settings: - feed_dir = out_dir / feed_name + feed_dir = out_dir / feed_slug image_dir = base_settings.get("REPUBLISHER_IMAGE_DIR", IMAGE_DIR) video_dir = base_settings.get("REPUBLISHER_VIDEO_DIR", VIDEO_DIR) audio_dir = base_settings.get("REPUBLISHER_AUDIO_DIR", AUDIO_DIR) @@ -113,14 +192,14 @@ def build_feed_settings( { "REPUBLISHER_OUT_DIR": str(out_dir), "FEEDS": { - str(out_dir / f"{feed_name}.rss"): { + str(out_dir / f"{feed_slug}.rss"): { "format": "rss", "postprocessing": [], - "feed_name": feed_name, + "feed_name": feed_slug, } }, "ITEM_PIPELINES": item_pipelines, - "LOG_FILE": str(out_dir / "logs" / f"{feed_name}.log"), + "LOG_FILE": str(out_dir / "logs" / f"{feed_slug}.log"), "HTTPCACHE_DIR": str(out_dir / "httpcache"), "REPUBLISHER_IMAGE_DIR": image_dir, "REPUBLISHER_VIDEO_DIR": video_dir, diff --git a/repub/entrypoint.py b/repub/entrypoint.py index 79cbb46..390d106 100644 --- a/repub/entrypoint.py +++ b/repub/entrypoint.py @@ -62,8 +62,8 @@ def create_feed_crawler( feed: FeedConfig, init_reactor: bool, ) -> Crawler: - prepare_output_dirs(out_dir, feed.name) - settings = build_feed_settings(base_settings, out_dir=out_dir, feed_name=feed.name) + prepare_output_dirs(out_dir, feed.slug) + settings = build_feed_settings(base_settings, out_dir=out_dir, feed_slug=feed.slug) return Crawler(RssFeedSpider, settings, init_reactor=init_reactor) @@ -88,7 +88,7 @@ def run_feeds( reactor.stop() return - logger.info("Starting feed %s", feed.name) + logger.info("Starting feed %s (%s)", feed.name, feed.slug) crawler = create_feed_crawler( base_settings=base_settings, out_dir=out_dir, @@ -97,17 +97,17 @@ def run_feeds( ) needs_reactor_init = False - deferred = process.crawl(crawler, feed_name=feed.name, url=feed.url) + deferred = process.crawl(crawler, feed_name=feed.slug, url=feed.url) def handle_success(_: object) -> None: - logger.info("Feed %s completed successfully", feed.name) - results.append((feed.name, None)) + logger.info("Feed %s (%s) completed successfully", feed.name, feed.slug) + results.append((feed.slug, None)) return None def handle_error(failure: Failure) -> None: - logger.error("Feed %s encountered an error", feed.name) + logger.error("Feed %s (%s) encountered an error", feed.name, feed.slug) logger.critical("%s", failure.getTraceback()) - results.append((feed.name, failure)) + results.append((feed.slug, failure)) return None deferred.addCallbacks(handle_success, handle_error) @@ -123,9 +123,19 @@ def entrypoint(argv: list[str] | None = None) -> int: args = parse_args(argv) try: config = load_config(args.config) - except FileNotFoundError: - logger.error("Config file not found: %s", Path(args.config).expanduser()) - logger.error("Use --config PATH or create repub.toml in the project root") + except FileNotFoundError as error: + missing_path = ( + Path(error.filename).expanduser() + if error.filename + else Path(args.config).expanduser() + ) + logger.error("Config file not found: %s", missing_path) + logger.error( + "Use --config PATH, create repub.toml in the project root, or fix feed_config_files" + ) + return 2 + except ValueError as error: + logger.error("Invalid config: %s", error) return 2 base_settings = build_base_settings(config) diff --git a/tests/test_config.py b/tests/test_config.py index adf1ebf..55d7063 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,8 @@ +from os import path as os_path from pathlib import Path +import pytest + from repub.config import ( FeedConfig, RepublisherConfig, @@ -9,22 +12,34 @@ from repub.config import ( ) -def test_load_config_resolves_relative_out_dir_against_config_path( +def test_load_config_resolves_relative_out_dir_and_merges_imported_feeds( tmp_path: Path, ) -> None: + manifest_path = tmp_path / "imports" / "manifest.toml" + manifest_path.parent.mkdir(parents=True) + manifest_path.write_text( + """ +[[feeds]] +name = "Info Martí " +slug = "info-marti" +url = "file:///srv/pygea/info-marti/rss.xml" +""".strip() + + "\n", + encoding="utf-8", + ) + config_path = tmp_path / "configs" / "repub.toml" config_path.parent.mkdir(parents=True) + manifest_ref = os_path.relpath(manifest_path, start=config_path.parent) config_path.write_text( - """ + f""" out_dir = "../mirror" +feed_config_files = ["{manifest_ref}"] [[feeds]] -name = "gp-pod" +name = "Guardian Project Podcast" +slug = "gp-pod" url = "https://guardianproject.info/podcast/podcast.xml" - -[[feeds]] -name = "nasa" -url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" """.strip() + "\n", encoding="utf-8", @@ -35,12 +50,14 @@ url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" assert config.out_dir == (tmp_path / "mirror").resolve() assert config.feeds == ( FeedConfig( - name="gp-pod", + name="Guardian Project Podcast", + slug="gp-pod", url="https://guardianproject.info/podcast/podcast.xml", ), FeedConfig( - name="nasa", - url="https://www.nasa.gov/rss/dyn/breaking_news.rss", + name="Info Martí ", + slug="info-marti", + url="file:///srv/pygea/info-marti/rss.xml", ), ) @@ -53,7 +70,8 @@ def test_load_config_preserves_absolute_out_dir(tmp_path: Path) -> None: out_dir = "{absolute_out_dir}" [[feeds]] -name = "nasa" +name = "NASA Breaking News" +slug = "nasa" url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" """.strip() + "\n", @@ -65,15 +83,50 @@ url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" assert config.out_dir == absolute_out_dir -def test_build_feed_settings_derives_output_paths_from_out_dir(tmp_path: Path) -> None: +def test_load_config_rejects_duplicate_imported_slugs(tmp_path: Path) -> None: + manifest_path = tmp_path / "manifest.toml" + manifest_path.write_text( + """ +[[feeds]] +name = "Imported Feed" +slug = "shared-slug" +url = "file:///srv/pygea/shared-slug/rss.xml" +""".strip() + + "\n", + encoding="utf-8", + ) + + config_path = tmp_path / "repub.toml" + config_path.write_text( + f""" +out_dir = "out" +feed_config_files = ["{manifest_path.name}"] + +[[feeds]] +name = "Local Feed" +slug = "shared-slug" +url = "https://example.com/feed.xml" +""".strip() + + "\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Feed slug"): + load_config(config_path) + + +def test_build_feed_settings_derives_output_paths_from_feed_slug( + tmp_path: Path, +) -> None: out_dir = (tmp_path / "mirror").resolve() config = RepublisherConfig( config_path=tmp_path / "repub.toml", out_dir=out_dir, feeds=( FeedConfig( - name="nasa", - url="https://www.nasa.gov/rss/dyn/breaking_news.rss", + name="Info Martí ", + slug="info-marti", + url="file:///srv/pygea/info-marti/rss.xml", ), ), scrapy_settings={"LOG_LEVEL": "DEBUG"}, @@ -81,22 +134,22 @@ def test_build_feed_settings_derives_output_paths_from_out_dir(tmp_path: Path) - base_settings = build_base_settings(config) feed_settings = build_feed_settings( - base_settings, out_dir=out_dir, feed_name="nasa" + base_settings, out_dir=out_dir, feed_slug="info-marti" ) assert base_settings["LOG_LEVEL"] == "DEBUG" assert feed_settings["REPUBLISHER_OUT_DIR"] == str(out_dir) - assert feed_settings["LOG_FILE"] == str(out_dir / "logs" / "nasa.log") + assert feed_settings["LOG_FILE"] == str(out_dir / "logs" / "info-marti.log") assert feed_settings["HTTPCACHE_DIR"] == str(out_dir / "httpcache") - assert feed_settings["IMAGES_STORE"] == str(out_dir / "nasa" / "images") - assert feed_settings["AUDIO_STORE"] == str(out_dir / "nasa" / "audio") - assert feed_settings["VIDEO_STORE"] == str(out_dir / "nasa" / "video") - assert feed_settings["FILES_STORE"] == str(out_dir / "nasa" / "files") + assert feed_settings["IMAGES_STORE"] == str(out_dir / "info-marti" / "images") + assert feed_settings["AUDIO_STORE"] == str(out_dir / "info-marti" / "audio") + assert feed_settings["VIDEO_STORE"] == str(out_dir / "info-marti" / "video") + assert feed_settings["FILES_STORE"] == str(out_dir / "info-marti" / "files") assert feed_settings["FEEDS"] == { - str(out_dir / "nasa.rss"): { + str(out_dir / "info-marti.rss"): { "format": "rss", "postprocessing": [], - "feed_name": "nasa", + "feed_name": "info-marti", } } @@ -108,7 +161,8 @@ def test_build_feed_settings_uses_runtime_media_dir_overrides(tmp_path: Path) -> out_dir=out_dir, feeds=( FeedConfig( - name="gp-pod", + name="Guardian Project Podcast", + slug="gp-pod", url="https://guardianproject.info/podcast/podcast.xml", ), ), @@ -122,7 +176,7 @@ def test_build_feed_settings_uses_runtime_media_dir_overrides(tmp_path: Path) -> feed_settings = build_feed_settings( base_settings, out_dir=out_dir, - feed_name="gp-pod", + feed_slug="gp-pod", ) assert feed_settings["REPUBLISHER_VIDEO_DIR"] == "videos-custom" diff --git a/tests/test_file_feeds.py b/tests/test_file_feeds.py index 584562a..835bc8e 100644 --- a/tests/test_file_feeds.py +++ b/tests/test_file_feeds.py @@ -13,7 +13,8 @@ def test_entrypoint_supports_file_feed_urls(tmp_path: Path, monkeypatch) -> None out_dir = "out" [[feeds]] -name = "local-file" +name = "Local Demo" +slug = "local-file" url = "{fixture_path.as_uri()}" [scrapy.settings] diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index 1bc27f2..60485c5 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -19,14 +19,15 @@ def build_test_crawler(tmp_path: Path) -> SimpleNamespace: out_dir=out_dir, feeds=( FeedConfig( - name="nasa", + name="NASA Breaking News", + slug="nasa", url="https://www.nasa.gov/rss/dyn/breaking_news.rss", ), ), scrapy_settings={}, ) base_settings = build_base_settings(config) - settings = build_feed_settings(base_settings, out_dir=out_dir, feed_name="nasa") + settings = build_feed_settings(base_settings, out_dir=out_dir, feed_slug="nasa") return SimpleNamespace(settings=settings, request_fingerprinter=object()) From 086b6fa017d205e018f527a4a7d1a56d385422d2 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Sun, 29 Mar 2026 14:44:45 +0200 Subject: [PATCH 2/4] repub: support slugged feeds and imported TOML feed configs --- README.md | 20 ++++-- demo/README.md | 14 +++- demo/repub.toml | 6 +- repub/config.py | 139 ++++++++++++++++++++++++++++++--------- repub/entrypoint.py | 32 +++++---- tests/test_config.py | 102 +++++++++++++++++++++------- tests/test_file_feeds.py | 3 +- tests/test_pipelines.py | 5 +- 8 files changed, 245 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 31584d0..3ea7876 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,22 @@ cat > repub.toml <<'EOF' out_dir = "out" [[feeds]] -name = "gp-pod" +name = "Guardian Project Podcast" +slug = "gp-pod" url = "https://guardianproject.info/podcast/podcast.xml" [[feeds]] -name = "nasa" +name = "NASA Breaking News" +slug = "nasa" url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" EOF uv run repub --config repub.toml ``` `out_dir` may be relative or absolute. Relative paths are resolved against the -directory containing the config file. Optional Scrapy runtime overrides can be -set in the same file: +directory containing the config file. Each feed now needs a user-provided +`slug`, which is used for output paths and filenames. Optional Scrapy runtime +overrides can be set in the same file: ```toml [scrapy.settings] @@ -27,6 +30,15 @@ LOG_LEVEL = "DEBUG" DOWNLOAD_TIMEOUT = 30 ``` +Additional feed definitions can also be imported from one or more TOML files, +including a `pygea`-generated `manifest.toml`: + +```toml +feed_config_files = ["/absolute/path/to/pygea/feed/manifest.toml"] +``` + +Imported files only need `[[feeds]]` entries with `name`, `slug`, and `url`. + See [`demo/README.md`](/home/abel/src/guardianproject/anynews/republisher-redux/demo/README.md) for a self-contained example config. ## TODO diff --git a/demo/README.md b/demo/README.md index 7a2d23d..4cca777 100644 --- a/demo/README.md +++ b/demo/README.md @@ -14,7 +14,7 @@ Because `out_dir` in [`demo/repub.toml`](/home/abel/src/guardianproject/anynews/ ## Files -- `repub.toml`: example runtime config with feed definitions and Scrapy overrides +- `repub.toml`: example runtime config with feed definitions, slugs, and Scrapy overrides - `fixtures/local-feed.rss`: simple local RSS fixture for `file://` feed testing ## Local File Feed @@ -29,6 +29,16 @@ Then use that value in a config entry: ```toml [[feeds]] -name = "local-demo" +name = "Local Demo" +slug = "local-demo" url = "file:///absolute/path/to/demo/fixtures/local-feed.rss" ``` + +## Pygea Import + +`repub` can also load additional `[[feeds]]` entries from a separate TOML file, +such as `pygea`'s generated `manifest.toml`: + +```toml +feed_config_files = ["/absolute/path/to/pygea/feed/manifest.toml"] +``` diff --git a/demo/repub.toml b/demo/repub.toml index 6540f33..951a47f 100644 --- a/demo/repub.toml +++ b/demo/repub.toml @@ -1,11 +1,13 @@ out_dir = "out" [[feeds]] -name = "gp-pod" +name = "Guardian Project Podcast" +slug = "gp-pod" url = "https://guardianproject.info/podcast/podcast.xml" [[feeds]] -name = "nasa" +name = "NASA Breaking News" +slug = "nasa" url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" [scrapy.settings] diff --git a/repub/config.py b/repub/config.py index 81038a9..38cbf56 100644 --- a/repub/config.py +++ b/repub/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import tomllib from dataclasses import dataclass from pathlib import Path @@ -11,11 +12,13 @@ IMAGE_DIR = "images" VIDEO_DIR = "video" AUDIO_DIR = "audio" FILE_DIR = "files" +SLUG_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") @dataclass(frozen=True) class FeedConfig: name: str + slug: str url: str @@ -27,38 +30,114 @@ class RepublisherConfig: scrapy_settings: dict[str, Any] +def _resolve_path(base_path: Path, value: str) -> Path: + path = Path(value).expanduser() + if not path.is_absolute(): + path = (base_path.parent / path).resolve() + return path + + +def _load_toml(path: Path) -> dict[str, Any]: + with path.open("rb") as config_file: + raw_config = tomllib.load(config_file) + if not isinstance(raw_config, dict): + raise ValueError(f"Config file {path} must contain a TOML table") + return raw_config + + +def _parse_feed_config_paths( + raw_config: dict[str, Any], *, config_path: Path +) -> tuple[Path, ...]: + raw_paths = raw_config.get("feed_config_files", []) + if raw_paths is None: + return () + if isinstance(raw_paths, str): + raw_paths = [raw_paths] + if not isinstance(raw_paths, list): + raise ValueError("Config field 'feed_config_files' must be a string or list") + + paths: list[Path] = [] + for index, raw_path in enumerate(raw_paths, start=1): + if not isinstance(raw_path, str) or not raw_path: + raise ValueError( + f"Config field 'feed_config_files[{index}]' must be a non-empty string" + ) + paths.append(_resolve_path(config_path, raw_path)) + return tuple(paths) + + +def _parse_feed_tables(raw_feeds: Any, *, source_path: Path) -> tuple[FeedConfig, ...]: + if raw_feeds is None: + return () + if not isinstance(raw_feeds, list): + raise ValueError(f"Config file {source_path} field 'feeds' must be an array") + + feeds: list[FeedConfig] = [] + for raw_feed in raw_feeds: + if not isinstance(raw_feed, dict): + raise ValueError( + f"Config file {source_path} has a non-table [[feeds]] entry" + ) + name = raw_feed.get("name") + slug = raw_feed.get("slug") + url = raw_feed.get("url") + if not isinstance(name, str) or not name: + raise ValueError( + f"Config file {source_path} has a [[feeds]] entry without a valid 'name'" + ) + if not isinstance(slug, str) or not slug: + raise ValueError(f"Feed {name!r} in {source_path} needs a non-empty 'slug'") + if SLUG_PATTERN.fullmatch(slug) is None: + raise ValueError( + f"Feed slug {slug!r} in {source_path} must match {SLUG_PATTERN.pattern!r}" + ) + if not isinstance(url, str) or not url: + raise ValueError(f"Feed {name!r} in {source_path} needs a non-empty 'url'") + feeds.append(FeedConfig(name=name, slug=slug, url=url)) + return tuple(feeds) + + +def _merge_feeds(feed_groups: list[tuple[FeedConfig, ...]]) -> tuple[FeedConfig, ...]: + feeds: list[FeedConfig] = [] + feed_names: set[str] = set() + feed_slugs: set[str] = set() + for group in feed_groups: + for feed in group: + if feed.name in feed_names: + raise ValueError(f"Feed name {feed.name!r} is duplicated") + if feed.slug in feed_slugs: + raise ValueError(f"Feed slug {feed.slug!r} is duplicated") + feed_names.add(feed.name) + feed_slugs.add(feed.slug) + feeds.append(feed) + return tuple(feeds) + + def load_config(path: str | Path) -> RepublisherConfig: config_path = Path(path).expanduser().resolve() - with config_path.open("rb") as config_file: - raw_config = tomllib.load(config_file) + raw_config = _load_toml(config_path) out_dir_value = raw_config.get("out_dir", "out") if not isinstance(out_dir_value, str) or not out_dir_value: raise ValueError("Config field 'out_dir' must be a non-empty string") + out_dir = _resolve_path(config_path, out_dir_value) - out_dir = Path(out_dir_value).expanduser() - if not out_dir.is_absolute(): - out_dir = (config_path.parent / out_dir).resolve() + feed_config_paths = _parse_feed_config_paths(raw_config, config_path=config_path) + feed_groups = [_parse_feed_tables(raw_config.get("feeds"), source_path=config_path)] + for feed_config_path in feed_config_paths: + imported_config = _load_toml(feed_config_path) + feed_groups.append( + _parse_feed_tables( + imported_config.get("feeds"), + source_path=feed_config_path, + ) + ) - raw_feeds = raw_config.get("feeds") - if not isinstance(raw_feeds, list) or not raw_feeds: - raise ValueError("Config must include at least one [[feeds]] entry") - - feeds: list[FeedConfig] = [] - feed_names: set[str] = set() - for raw_feed in raw_feeds: - if not isinstance(raw_feed, dict): - raise ValueError("Each [[feeds]] entry must be a table") - name = raw_feed.get("name") - url = raw_feed.get("url") - if not isinstance(name, str) or not name: - raise ValueError("Each [[feeds]] entry needs a non-empty 'name'") - if not isinstance(url, str) or not url: - raise ValueError(f"Feed {name!r} needs a non-empty 'url'") - if name in feed_names: - raise ValueError(f"Feed name {name!r} is duplicated") - feed_names.add(name) - feeds.append(FeedConfig(name=name, url=url)) + feeds = _merge_feeds(feed_groups) + if not feeds: + raise ValueError( + "Config must include at least one [[feeds]] entry or feed_config_files import" + ) raw_scrapy = raw_config.get("scrapy", {}) if raw_scrapy is None: @@ -75,7 +154,7 @@ def load_config(path: str | Path) -> RepublisherConfig: return RepublisherConfig( config_path=config_path, out_dir=out_dir, - feeds=tuple(feeds), + feeds=feeds, scrapy_settings=scrapy_settings, ) @@ -92,9 +171,9 @@ def build_feed_settings( base_settings: Settings, *, out_dir: Path, - feed_name: str, + feed_slug: str, ) -> Settings: - feed_dir = out_dir / feed_name + feed_dir = out_dir / feed_slug image_dir = base_settings.get("REPUBLISHER_IMAGE_DIR", IMAGE_DIR) video_dir = base_settings.get("REPUBLISHER_VIDEO_DIR", VIDEO_DIR) audio_dir = base_settings.get("REPUBLISHER_AUDIO_DIR", AUDIO_DIR) @@ -113,14 +192,14 @@ def build_feed_settings( { "REPUBLISHER_OUT_DIR": str(out_dir), "FEEDS": { - str(out_dir / f"{feed_name}.rss"): { + str(out_dir / f"{feed_slug}.rss"): { "format": "rss", "postprocessing": [], - "feed_name": feed_name, + "feed_name": feed_slug, } }, "ITEM_PIPELINES": item_pipelines, - "LOG_FILE": str(out_dir / "logs" / f"{feed_name}.log"), + "LOG_FILE": str(out_dir / "logs" / f"{feed_slug}.log"), "HTTPCACHE_DIR": str(out_dir / "httpcache"), "REPUBLISHER_IMAGE_DIR": image_dir, "REPUBLISHER_VIDEO_DIR": video_dir, diff --git a/repub/entrypoint.py b/repub/entrypoint.py index 79cbb46..390d106 100644 --- a/repub/entrypoint.py +++ b/repub/entrypoint.py @@ -62,8 +62,8 @@ def create_feed_crawler( feed: FeedConfig, init_reactor: bool, ) -> Crawler: - prepare_output_dirs(out_dir, feed.name) - settings = build_feed_settings(base_settings, out_dir=out_dir, feed_name=feed.name) + prepare_output_dirs(out_dir, feed.slug) + settings = build_feed_settings(base_settings, out_dir=out_dir, feed_slug=feed.slug) return Crawler(RssFeedSpider, settings, init_reactor=init_reactor) @@ -88,7 +88,7 @@ def run_feeds( reactor.stop() return - logger.info("Starting feed %s", feed.name) + logger.info("Starting feed %s (%s)", feed.name, feed.slug) crawler = create_feed_crawler( base_settings=base_settings, out_dir=out_dir, @@ -97,17 +97,17 @@ def run_feeds( ) needs_reactor_init = False - deferred = process.crawl(crawler, feed_name=feed.name, url=feed.url) + deferred = process.crawl(crawler, feed_name=feed.slug, url=feed.url) def handle_success(_: object) -> None: - logger.info("Feed %s completed successfully", feed.name) - results.append((feed.name, None)) + logger.info("Feed %s (%s) completed successfully", feed.name, feed.slug) + results.append((feed.slug, None)) return None def handle_error(failure: Failure) -> None: - logger.error("Feed %s encountered an error", feed.name) + logger.error("Feed %s (%s) encountered an error", feed.name, feed.slug) logger.critical("%s", failure.getTraceback()) - results.append((feed.name, failure)) + results.append((feed.slug, failure)) return None deferred.addCallbacks(handle_success, handle_error) @@ -123,9 +123,19 @@ def entrypoint(argv: list[str] | None = None) -> int: args = parse_args(argv) try: config = load_config(args.config) - except FileNotFoundError: - logger.error("Config file not found: %s", Path(args.config).expanduser()) - logger.error("Use --config PATH or create repub.toml in the project root") + except FileNotFoundError as error: + missing_path = ( + Path(error.filename).expanduser() + if error.filename + else Path(args.config).expanduser() + ) + logger.error("Config file not found: %s", missing_path) + logger.error( + "Use --config PATH, create repub.toml in the project root, or fix feed_config_files" + ) + return 2 + except ValueError as error: + logger.error("Invalid config: %s", error) return 2 base_settings = build_base_settings(config) diff --git a/tests/test_config.py b/tests/test_config.py index adf1ebf..55d7063 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,8 @@ +from os import path as os_path from pathlib import Path +import pytest + from repub.config import ( FeedConfig, RepublisherConfig, @@ -9,22 +12,34 @@ from repub.config import ( ) -def test_load_config_resolves_relative_out_dir_against_config_path( +def test_load_config_resolves_relative_out_dir_and_merges_imported_feeds( tmp_path: Path, ) -> None: + manifest_path = tmp_path / "imports" / "manifest.toml" + manifest_path.parent.mkdir(parents=True) + manifest_path.write_text( + """ +[[feeds]] +name = "Info Martí " +slug = "info-marti" +url = "file:///srv/pygea/info-marti/rss.xml" +""".strip() + + "\n", + encoding="utf-8", + ) + config_path = tmp_path / "configs" / "repub.toml" config_path.parent.mkdir(parents=True) + manifest_ref = os_path.relpath(manifest_path, start=config_path.parent) config_path.write_text( - """ + f""" out_dir = "../mirror" +feed_config_files = ["{manifest_ref}"] [[feeds]] -name = "gp-pod" +name = "Guardian Project Podcast" +slug = "gp-pod" url = "https://guardianproject.info/podcast/podcast.xml" - -[[feeds]] -name = "nasa" -url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" """.strip() + "\n", encoding="utf-8", @@ -35,12 +50,14 @@ url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" assert config.out_dir == (tmp_path / "mirror").resolve() assert config.feeds == ( FeedConfig( - name="gp-pod", + name="Guardian Project Podcast", + slug="gp-pod", url="https://guardianproject.info/podcast/podcast.xml", ), FeedConfig( - name="nasa", - url="https://www.nasa.gov/rss/dyn/breaking_news.rss", + name="Info Martí ", + slug="info-marti", + url="file:///srv/pygea/info-marti/rss.xml", ), ) @@ -53,7 +70,8 @@ def test_load_config_preserves_absolute_out_dir(tmp_path: Path) -> None: out_dir = "{absolute_out_dir}" [[feeds]] -name = "nasa" +name = "NASA Breaking News" +slug = "nasa" url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" """.strip() + "\n", @@ -65,15 +83,50 @@ url = "https://www.nasa.gov/rss/dyn/breaking_news.rss" assert config.out_dir == absolute_out_dir -def test_build_feed_settings_derives_output_paths_from_out_dir(tmp_path: Path) -> None: +def test_load_config_rejects_duplicate_imported_slugs(tmp_path: Path) -> None: + manifest_path = tmp_path / "manifest.toml" + manifest_path.write_text( + """ +[[feeds]] +name = "Imported Feed" +slug = "shared-slug" +url = "file:///srv/pygea/shared-slug/rss.xml" +""".strip() + + "\n", + encoding="utf-8", + ) + + config_path = tmp_path / "repub.toml" + config_path.write_text( + f""" +out_dir = "out" +feed_config_files = ["{manifest_path.name}"] + +[[feeds]] +name = "Local Feed" +slug = "shared-slug" +url = "https://example.com/feed.xml" +""".strip() + + "\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Feed slug"): + load_config(config_path) + + +def test_build_feed_settings_derives_output_paths_from_feed_slug( + tmp_path: Path, +) -> None: out_dir = (tmp_path / "mirror").resolve() config = RepublisherConfig( config_path=tmp_path / "repub.toml", out_dir=out_dir, feeds=( FeedConfig( - name="nasa", - url="https://www.nasa.gov/rss/dyn/breaking_news.rss", + name="Info Martí ", + slug="info-marti", + url="file:///srv/pygea/info-marti/rss.xml", ), ), scrapy_settings={"LOG_LEVEL": "DEBUG"}, @@ -81,22 +134,22 @@ def test_build_feed_settings_derives_output_paths_from_out_dir(tmp_path: Path) - base_settings = build_base_settings(config) feed_settings = build_feed_settings( - base_settings, out_dir=out_dir, feed_name="nasa" + base_settings, out_dir=out_dir, feed_slug="info-marti" ) assert base_settings["LOG_LEVEL"] == "DEBUG" assert feed_settings["REPUBLISHER_OUT_DIR"] == str(out_dir) - assert feed_settings["LOG_FILE"] == str(out_dir / "logs" / "nasa.log") + assert feed_settings["LOG_FILE"] == str(out_dir / "logs" / "info-marti.log") assert feed_settings["HTTPCACHE_DIR"] == str(out_dir / "httpcache") - assert feed_settings["IMAGES_STORE"] == str(out_dir / "nasa" / "images") - assert feed_settings["AUDIO_STORE"] == str(out_dir / "nasa" / "audio") - assert feed_settings["VIDEO_STORE"] == str(out_dir / "nasa" / "video") - assert feed_settings["FILES_STORE"] == str(out_dir / "nasa" / "files") + assert feed_settings["IMAGES_STORE"] == str(out_dir / "info-marti" / "images") + assert feed_settings["AUDIO_STORE"] == str(out_dir / "info-marti" / "audio") + assert feed_settings["VIDEO_STORE"] == str(out_dir / "info-marti" / "video") + assert feed_settings["FILES_STORE"] == str(out_dir / "info-marti" / "files") assert feed_settings["FEEDS"] == { - str(out_dir / "nasa.rss"): { + str(out_dir / "info-marti.rss"): { "format": "rss", "postprocessing": [], - "feed_name": "nasa", + "feed_name": "info-marti", } } @@ -108,7 +161,8 @@ def test_build_feed_settings_uses_runtime_media_dir_overrides(tmp_path: Path) -> out_dir=out_dir, feeds=( FeedConfig( - name="gp-pod", + name="Guardian Project Podcast", + slug="gp-pod", url="https://guardianproject.info/podcast/podcast.xml", ), ), @@ -122,7 +176,7 @@ def test_build_feed_settings_uses_runtime_media_dir_overrides(tmp_path: Path) -> feed_settings = build_feed_settings( base_settings, out_dir=out_dir, - feed_name="gp-pod", + feed_slug="gp-pod", ) assert feed_settings["REPUBLISHER_VIDEO_DIR"] == "videos-custom" diff --git a/tests/test_file_feeds.py b/tests/test_file_feeds.py index 584562a..835bc8e 100644 --- a/tests/test_file_feeds.py +++ b/tests/test_file_feeds.py @@ -13,7 +13,8 @@ def test_entrypoint_supports_file_feed_urls(tmp_path: Path, monkeypatch) -> None out_dir = "out" [[feeds]] -name = "local-file" +name = "Local Demo" +slug = "local-file" url = "{fixture_path.as_uri()}" [scrapy.settings] diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index 1bc27f2..60485c5 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -19,14 +19,15 @@ def build_test_crawler(tmp_path: Path) -> SimpleNamespace: out_dir=out_dir, feeds=( FeedConfig( - name="nasa", + name="NASA Breaking News", + slug="nasa", url="https://www.nasa.gov/rss/dyn/breaking_news.rss", ), ), scrapy_settings={}, ) base_settings = build_base_settings(config) - settings = build_feed_settings(base_settings, out_dir=out_dir, feed_name="nasa") + settings = build_feed_settings(base_settings, out_dir=out_dir, feed_slug="nasa") return SimpleNamespace(settings=settings, request_fingerprinter=object()) From 40da4384b20cedc60feb072a81c278f87216e80f Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 11:32:11 +0200 Subject: [PATCH 3/4] update readme --- README.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3ea7876..b7353ec 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,20 @@ -# republisher-redux +# AnyNews Republisher + +The AnyNews Republisher is a tool for mirroring news content to alternative distribution points to avoid censorship or make content available to communities suffering from high Internet cost, slow or limited access, or natural disaster. + +The organization with the original news content is the "publisher". + +The AnyNews Republisher can be configured with various publisher news sources. Then on an interval the Republisher crawls the sources, mirrors the content (text and media) offline into an RSS feed. + +The [AnyNews app][app] can then be configured to use this mirror (or more than one such mirror). + +The Republisher currently accepts the following source input types: + +- RSS Feeds + +[app]: https://gitlab.com/guardianproject/anynews/anynews-web-client + + ``` shell nix develop @@ -49,15 +65,17 @@ See [`demo/README.md`](/home/abel/src/guardianproject/anynews/republisher-redux/ - [x] Image normalization (JPG, RGB) - [x] Audio transcoding - [x] Video transcoding -- [ ] Image compression - Do we want this? +- [ ] Image compression - Do we want this? -> DEFERED for now - [x] Download and rewrite media embedded in content/CDATA fields - [x] Config file to drive the program +- [ ] Add sqlite database and simple admin UI to replace config +- [ ] Integrate pygea as input source - [ ] Daemonize the program - [ ] Operationalize with metrics and error reporting ## License -republisher-redux, a tool to mirror RSS/ATOM feeds completely offline +republisher, a tool to mirror RSS/ATOM feeds completely offline Copyright (C) 2024-2026 Abel Luck From 4b376c54a2795f04f5438c4a05599b2b992d9fd4 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 11:42:13 +0200 Subject: [PATCH 4/4] start webui --- AGENTS.md | 11 ++ flake.nix | 32 +++ pyproject.toml | 56 ++++-- repub/crawl.py | 127 ++++++++++++ repub/entrypoint.py | 161 +++++---------- repub/web.py | 27 +++ uv.lock | 470 +++++++++++++++++++++++++++++++++++++------- 7 files changed, 678 insertions(+), 206 deletions(-) create mode 100644 repub/crawl.py create mode 100644 repub/web.py diff --git a/AGENTS.md b/AGENTS.md index 442cd0a..bdbb433 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,17 @@ - Sync Python dependencies with `uv sync --all-groups`. - Run the app with `uv run repub`. +```sh +uv sync --all-groups +uv run pytest +uv run flake8 repub/ tests/ +uv run pyright +nix fmt +nix flake check +uv run repub +uv run repub crawl -c repub.toml +``` + ## Validation - Run `nix fmt` after changing repo files that are covered by treefmt. diff --git a/flake.nix b/flake.nix index d049b21..2618716 100644 --- a/flake.nix +++ b/flake.nix @@ -60,6 +60,18 @@ workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; }; overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; pyprojectOverrides = final: prev: { + feedgen = prev.feedgen.overrideAttrs (old: { + nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ]; + }); + pygea = prev.pygea.overrideAttrs (old: { + nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ + final.hatchling + final.packaging + final.pathspec + final.pluggy + final.trove-classifiers + ]; + }); sgmllib3k = prev.sgmllib3k.overrideAttrs (old: { nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ]; }); @@ -222,6 +234,23 @@ touch "$out/passed" ''; }; + pyrightCheck = pkgs.stdenv.mkDerivation { + name = "republisher-redux-pyright"; + inherit src; + dontConfigure = true; + dontBuild = true; + nativeBuildInputs = [ testVenv ]; + checkPhase = '' + runHook preCheck + pyright + runHook postCheck + ''; + doCheck = true; + installPhase = '' + mkdir -p "$out" + touch "$out/passed" + ''; + }; in { devshell-default = self.devShells.${system}.default; @@ -232,14 +261,17 @@ black = blackCheck; flake8 = flake8Check; isort = isortCheck; + pyright = pyrightCheck; } ); devShells = forAllSystems (pkgs: { default = pkgs.mkShell { packages = [ + pkgs.tailwindcss_4 pkgs.python313 pkgs.uv + pkgs.pyright (mkFfmpegPackage pkgs) ]; env.LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ diff --git a/pyproject.toml b/pyproject.toml index 3872d99..474e97b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,13 @@ dependencies = [ "lxml>=5.2.1,<6.0.0", "pillow>=10.3.0,<11.0.0", "ffmpeg-python>=0.2.0,<0.3.0", + "Quart>=0.20.0,<0.21.0", + "apscheduler>=3.11.0,<4.0.0", + "aiosqlite>=0.21.0,<0.22.0", + "datastar-py>=0.8.0,<0.9.0", + "greenlet>=3.2.4,<4.0.0", + "peewee>=3.19.0,<4.0.0", + "pygea @ git+https://guardianproject.dev/anynews/pygea.git", ] [project.scripts] @@ -24,8 +31,8 @@ dev = [ "pytest>=8.1.1,<9.0.0", "black>=24.4.0,<25.0.0", "flake8>=7.0.0,<8.0.0", - "mypy>=1.9.0,<2.0.0", "bandit>=1.7.8,<2.0.0", + "pyright>=1.1.403,<2.0.0", "types-PyYAML>=6.0.12.20240311,<7.0.0", "isort>=5.13.2,<6.0.0", "flake8-black>=0.3.6,<0.4.0", @@ -53,18 +60,37 @@ src_paths = ["repub", "tests"] [tool.black] line-length = 88 target-version = ['py313'] +[tool.pylint.format] +max-line-length = "88" -[tool.mypy] -files = "repub,tests" -ignore_missing_imports = true -follow_imports = "normal" -disallow_untyped_calls = true -disallow_any_generics = true -disallow_subclassing_any = true -warn_return_any = true -warn_redundant_casts = true -warn_unused_ignores = true -warn_unused_configs = true -warn_unreachable = true -show_error_codes = true -no_implicit_optional = true +[tool.pyright] +include = ["repub", "tests"] +pythonVersion = "3.13" +typeCheckingMode = "basic" +reportMissingImports = false + +[tool.flake8] +ignore = [ + "D100", + "D101", + "D102", + "D104", + "D107", +] +exclude = [ + ".git", + "__pycache__", + "docs/source/conf.py", + "build", + "dist", + "data/db/", + "kealytics/tests/", + "*.pyc", + "*.egg-info", + ".cache", + ".eggs", +] +max-complexity = 10 +import-order-style = "cryptography" +application-import-names = ["repub"] +format = "${cyan}%(path)s${reset}:${yellow_bold}%(row)d${reset}:${green_bold}%(col)d${reset}: ${red_bold}%(code)s${reset} %(text)s" diff --git a/repub/crawl.py b/repub/crawl.py new file mode 100644 index 0000000..8b36142 --- /dev/null +++ b/repub/crawl.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from scrapy.crawler import Crawler, CrawlerProcess +from scrapy.settings import Settings +from twisted.python.failure import Failure + +from repub.config import ( + FeedConfig, + build_base_settings, + build_feed_settings, + load_config, +) +from repub.media import check_runtime +from repub.spiders.rss_spider import RssFeedSpider + +logger = logging.getLogger(__name__) + + +class FeedNameFilter: + def __init__(self, feed_options): + self.feed_options = feed_options + + def accepts(self, item): + return item.feed_name == self.feed_options["feed_name"] + + +def prepare_output_dirs(out_dir: Path, feed_name: str) -> None: + (out_dir / "logs").mkdir(parents=True, exist_ok=True) + (out_dir / "httpcache").mkdir(parents=True, exist_ok=True) + (out_dir / feed_name).mkdir(parents=True, exist_ok=True) + + +def create_feed_crawler( + *, + base_settings: Settings, + out_dir: Path, + feed: FeedConfig, + init_reactor: bool, +) -> Crawler: + prepare_output_dirs(out_dir, feed.slug) + settings = build_feed_settings(base_settings, out_dir=out_dir, feed_slug=feed.slug) + return Crawler(RssFeedSpider, settings, init_reactor=init_reactor) + + +def run_feeds( + base_settings: Settings, + out_dir: Path, + feeds: tuple[FeedConfig, ...], +) -> int: + process = CrawlerProcess(base_settings) + results: list[tuple[str, Failure | None]] = [] + feed_iter = iter(feeds) + needs_reactor_init = True + + def crawl_next(_: object | None = None) -> None: + nonlocal needs_reactor_init + + try: + feed = next(feed_iter) + except StopIteration: + from twisted.internet import reactor + + reactor.stop() + return + + logger.info("Starting feed %s (%s)", feed.name, feed.slug) + crawler = create_feed_crawler( + base_settings=base_settings, + out_dir=out_dir, + feed=feed, + init_reactor=needs_reactor_init, + ) + needs_reactor_init = False + + deferred = process.crawl(crawler, feed_name=feed.slug, url=feed.url) + + def handle_success(_: object) -> None: + logger.info("Feed %s (%s) completed successfully", feed.name, feed.slug) + results.append((feed.slug, None)) + return None + + def handle_error(failure: Failure) -> None: + logger.error("Feed %s (%s) encountered an error", feed.name, feed.slug) + logger.critical("%s", failure.getTraceback()) + results.append((feed.slug, failure)) + return None + + deferred.addCallbacks(handle_success, handle_error) + deferred.addBoth(crawl_next) + + crawl_next() + process.start(stop_after_crawl=False) + + return 1 if any(failure is not None for _, failure in results) else 0 + + +def crawl_from_config(config_path: str) -> int: + try: + config = load_config(config_path) + except FileNotFoundError as error: + missing_path = ( + Path(error.filename).expanduser() + if error.filename + else Path(config_path).expanduser() + ) + logger.error("Config file not found: %s", missing_path) + logger.error( + "Use --config PATH, create repub.toml in the project root, or fix feed_config_files" + ) + return 2 + except ValueError as error: + logger.error("Invalid config: %s", error) + return 2 + + base_settings = build_base_settings(config) + + if not check_runtime( + base_settings.get("REPUBLISHER_FFMPEG_ENCODERS"), + base_settings.get("REPUBLISHER_FFMPEG_CODECS"), + ): + logger.error("Runtime dependencies not met") + return 1 + + return run_feeds(base_settings, config.out_dir, config.feeds) diff --git a/repub/entrypoint.py b/repub/entrypoint.py index 390d106..d0de180 100644 --- a/repub/entrypoint.py +++ b/repub/entrypoint.py @@ -2,21 +2,16 @@ from __future__ import annotations import argparse import logging +import os import sys -from pathlib import Path -from scrapy.crawler import Crawler, CrawlerProcess -from scrapy.settings import Settings -from twisted.python.failure import Failure +import repub.crawl as crawl_module +from repub.web import create_app -from repub.config import ( - FeedConfig, - build_base_settings, - build_feed_settings, - load_config, -) -from repub.media import check_runtime -from repub.spiders.rss_spider import RssFeedSpider +FeedNameFilter = crawl_module.FeedNameFilter +check_runtime = crawl_module.check_runtime + +__all__ = ["FeedNameFilter", "check_runtime", "entrypoint", "parse_args"] logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -30,123 +25,59 @@ if not logger.handlers: logger.addHandler(handler) -class FeedNameFilter: - def __init__(self, feed_options): - self.feed_options = feed_options +def parse_args(argv: list[str] | None = None) -> tuple[str, argparse.Namespace]: + raw_args = list(argv) if argv is not None else sys.argv[1:] - def accepts(self, item): - return item.feed_name == self.feed_options["feed_name"] - - -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Mirror RSS and Atom feeds") - parser.add_argument( + subparsers = parser.add_subparsers(dest="command") + + serve_parser = subparsers.add_parser("serve", help="Start the republisher web UI") + serve_parser.add_argument( + "--host", + default=os.environ.get("REPUB_HOST", "127.0.0.1"), + help="Host interface for the web UI", + ) + serve_parser.add_argument( + "--port", + default=os.environ.get("REPUB_PORT", "8080"), + help="Port for the web UI", + ) + + crawl_parser = subparsers.add_parser("crawl", help="Run the feed crawler once") + crawl_parser.add_argument( "-c", "--config", default="repub.toml", help="Path to runtime config TOML file", ) - return parser.parse_args(argv) + if not raw_args: + raw_args = ["serve"] + 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] - -def prepare_output_dirs(out_dir: Path, feed_name: str) -> None: - (out_dir / "logs").mkdir(parents=True, exist_ok=True) - (out_dir / "httpcache").mkdir(parents=True, exist_ok=True) - (out_dir / feed_name).mkdir(parents=True, exist_ok=True) - - -def create_feed_crawler( - *, - base_settings: Settings, - out_dir: Path, - feed: FeedConfig, - init_reactor: bool, -) -> Crawler: - prepare_output_dirs(out_dir, feed.slug) - settings = build_feed_settings(base_settings, out_dir=out_dir, feed_slug=feed.slug) - return Crawler(RssFeedSpider, settings, init_reactor=init_reactor) - - -def run_feeds( - base_settings: Settings, - out_dir: Path, - feeds: tuple[FeedConfig, ...], -) -> int: - process = CrawlerProcess(base_settings) - results: list[tuple[str, Failure | None]] = [] - feed_iter = iter(feeds) - needs_reactor_init = True - - def crawl_next(_: object | None = None) -> None: - nonlocal needs_reactor_init - - try: - feed = next(feed_iter) - except StopIteration: - from twisted.internet import reactor - - reactor.stop() - return - - logger.info("Starting feed %s (%s)", feed.name, feed.slug) - crawler = create_feed_crawler( - base_settings=base_settings, - out_dir=out_dir, - feed=feed, - init_reactor=needs_reactor_init, - ) - needs_reactor_init = False - - deferred = process.crawl(crawler, feed_name=feed.slug, url=feed.url) - - def handle_success(_: object) -> None: - logger.info("Feed %s (%s) completed successfully", feed.name, feed.slug) - results.append((feed.slug, None)) - return None - - def handle_error(failure: Failure) -> None: - logger.error("Feed %s (%s) encountered an error", feed.name, feed.slug) - logger.critical("%s", failure.getTraceback()) - results.append((feed.slug, failure)) - return None - - deferred.addCallbacks(handle_success, handle_error) - deferred.addBoth(crawl_next) - - crawl_next() - process.start(stop_after_crawl=False) - - return 1 if any(failure is not None for _, failure in results) else 0 + args = parser.parse_args(raw_args) + command = args.command or "serve" + return command, args def entrypoint(argv: list[str] | None = None) -> int: - args = parse_args(argv) + command, args = parse_args(argv) + + if command == "crawl": + crawl_module.check_runtime = check_runtime + return crawl_module.crawl_from_config(args.config) + try: - config = load_config(args.config) - except FileNotFoundError as error: - missing_path = ( - Path(error.filename).expanduser() - if error.filename - else Path(args.config).expanduser() - ) - logger.error("Config file not found: %s", missing_path) - logger.error( - "Use --config PATH, create repub.toml in the project root, or fix feed_config_files" - ) + port = int(args.port) + except ValueError: + logger.error("Invalid REPUB_PORT/--port value: %s", args.port) return 2 - except ValueError as error: - logger.error("Invalid config: %s", error) - return 2 - base_settings = build_base_settings(config) - if not check_runtime( - base_settings.get("REPUBLISHER_FFMPEG_ENCODERS"), - base_settings.get("REPUBLISHER_FFMPEG_CODECS"), - ): - logger.error("Runtime dependencies not met") - return 1 - - return run_feeds(base_settings, config.out_dir, config.feeds) + app = create_app() + app.run(host=args.host, port=port) + return 0 if __name__ == "__main__": diff --git a/repub/web.py b/repub/web.py new file mode 100644 index 0000000..c6fe715 --- /dev/null +++ b/repub/web.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from quart import Quart + + +def create_app() -> Quart: + app = Quart(__name__) + + @app.get("/") + async def index() -> str: + return """ + + + + + Republisher + + +
+

Hello, world!

+

Republisher web UI is starting here.

+
+ + +""" + + return app diff --git a/uv.lock b/uv.lock index 023ffa7..a106ad6 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,39 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -35,6 +68,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "black" version = "24.10.0" @@ -55,6 +101,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898, upload-time = "2024-10-07T19:20:48.317Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -270,6 +325,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" }, ] +[[package]] +name = "datastar-py" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e3/28f8b1ba914302378fdec7050a11ca69e937f000ba16b9c9f8aaf8f8667e/datastar_py-0.8.0.tar.gz", hash = "sha256:a6893608da32378ae22640c115c80e50b2e905db1a2adca840d1ee6b1009b308", size = 137445, upload-time = "2025-12-19T19:43:53.802Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9c/2d805013718a5e90e1574e54b5eef6e64955c7c0f319d1ef9fc4dcdd6a79/datastar_py-0.8.0-py3-none-any.whl", hash = "sha256:637b557d163ad31d1b1c8ecf13c02ae33cd5134ec3f606ed38d0cefcac675d54", size = 19023, upload-time = "2025-12-19T19:43:52.626Z" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -279,6 +343,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "feedgen" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/59/be0a6f852b5dfbf19e6c8e962c8f41407697f9f52a7902250ed98683ae89/feedgen-1.0.0.tar.gz", hash = "sha256:d9bd51c3b5e956a2a52998c3708c4d2c729f2fcc311188e1e5d3b9726393546a", size = 258496, upload-time = "2023-12-25T18:04:08.421Z" } + [[package]] name = "feedparser" version = "6.0.12" @@ -339,6 +413,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/e4/4a53c1ed4cd309e804c14cbce9494c2a922db8a14b6c166283637c29e310/flake8_black-0.3.7-py3-none-any.whl", hash = "sha256:366215fbe9ee3d5a7e72710da9be871f52392e568ebd2821a16cd5f3a046d291", size = 9840, upload-time = "2025-09-19T14:56:39.253Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "future" version = "1.0.0" @@ -348,6 +439,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, ] +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "hyperlink" version = "21.0.0" @@ -422,6 +602,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/71/d9cd0e4c6a4aace991009fc47362ce9251be0fbcf2b6c533f918b31854d5/itemloaders-1.4.0-py3-none-any.whl", hash = "sha256:202b6f855299b4cadfdf78bb93a6cf977899e3c40c4c54524e120a444e65b5ac", size = 12188, upload-time = "2026-01-29T12:50:36.148Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jmespath" version = "1.1.0" @@ -431,53 +632,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] -[[package]] -name = "librt" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, -] - [[package]] name = "lxml" version = "5.4.0" @@ -515,6 +669,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -533,33 +739,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mypy" -version = "1.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, -] - [[package]] name = "mypy-extensions" version = "1.1.0" @@ -569,6 +748,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -603,6 +791,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "peewee" +version = "3.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/b0/79462b42e89764998756e0557f2b58a15610a5b4512fbbcccae58fba7237/peewee-3.19.0.tar.gz", hash = "sha256:f88292a6f0d7b906cb26bca9c8599b8f4d8920ebd36124400d0cbaaaf915511f", size = 974035, upload-time = "2026-01-07T17:24:59.597Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/41/19c65578ef9a54b3083253c68a607f099642747168fe00f3a2bceb7c3a34/peewee-3.19.0-py3-none-any.whl", hash = "sha256:de220b94766e6008c466e00ce4ba5299b9a832117d9eb36d45d0062f3cfd7417", size = 411885, upload-time = "2026-01-07T17:24:58.33Z" }, +] + [[package]] name = "pillow" version = "10.4.0" @@ -640,6 +837,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + [[package]] name = "prometheus-client" version = "0.20.0" @@ -715,6 +921,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, ] +[[package]] +name = "pygea" +version = "0.1.0" +source = { git = "https://guardianproject.dev/anynews/pygea.git#897af2872c83ccfb966eda02d64b6812f099fa91" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "feedgen" }, + { name = "python-dateutil" }, + { name = "requests" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -742,6 +959,19 @@ version = "2.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d5/7b/65f55513d3c769fd677f90032d8d8703e3dc17e88a41b6074d2177548bca/PyPyDispatcher-2.1.2.tar.gz", hash = "sha256:b6bec5dfcff9d2535bca2b23c80eae367b1ac250a645106948d315fcfa9130f2", size = 23224, upload-time = "2017-07-03T14:20:51.806Z" } +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -806,6 +1036,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "quart" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "blinker" }, + { name = "click" }, + { name = "flask" }, + { name = "hypercorn" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" }, +] + [[package]] name = "queuelib" version = "1.9.0" @@ -820,13 +1070,20 @@ name = "republisher-redux" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiosqlite" }, + { name = "apscheduler" }, { name = "colorlog" }, + { name = "datastar-py" }, { name = "feedparser" }, { name = "ffmpeg-python" }, + { name = "greenlet" }, { name = "lxml" }, + { name = "peewee" }, { name = "pillow" }, { name = "prometheus-client" }, + { name = "pygea" }, { name = "python-dateutil" }, + { name = "quart" }, { name = "scrapy" }, ] @@ -837,20 +1094,27 @@ dev = [ { name = "flake8" }, { name = "flake8-black" }, { name = "isort" }, - { name = "mypy" }, + { name = "pyright" }, { name = "pytest" }, { name = "types-pyyaml" }, ] [package.metadata] requires-dist = [ + { name = "aiosqlite", specifier = ">=0.21.0,<0.22.0" }, + { name = "apscheduler", specifier = ">=3.11.0,<4.0.0" }, { name = "colorlog", specifier = ">=6.8.2,<7.0.0" }, + { name = "datastar-py", specifier = ">=0.8.0,<0.9.0" }, { name = "feedparser", specifier = ">=6.0.11,<7.0.0" }, { name = "ffmpeg-python", specifier = ">=0.2.0,<0.3.0" }, + { name = "greenlet", specifier = ">=3.2.4,<4.0.0" }, { name = "lxml", specifier = ">=5.2.1,<6.0.0" }, + { name = "peewee", specifier = ">=3.19.0,<4.0.0" }, { name = "pillow", specifier = ">=10.3.0,<11.0.0" }, { name = "prometheus-client", specifier = ">=0.20.0,<0.21.0" }, + { name = "pygea", git = "https://guardianproject.dev/anynews/pygea.git" }, { name = "python-dateutil", specifier = ">=2.9.0.post0,<3.0.0" }, + { name = "quart", specifier = ">=0.20.0,<0.21.0" }, { name = "scrapy", specifier = ">=2.11.1,<3.0.0" }, ] @@ -861,7 +1125,7 @@ dev = [ { name = "flake8", specifier = ">=7.0.0,<8.0.0" }, { name = "flake8-black", specifier = ">=0.3.6,<0.4.0" }, { name = "isort", specifier = ">=5.13.2,<6.0.0" }, - { name = "mypy", specifier = ">=1.9.0,<2.0.0" }, + { name = "pyright", specifier = ">=1.1.403,<2.0.0" }, { name = "pytest", specifier = ">=8.1.1,<9.0.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20240311,<7.0.0" }, ] @@ -965,6 +1229,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "stevedore" version = "5.7.0" @@ -1025,6 +1298,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -1043,6 +1337,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/c3/f8b216cbd742e5b84c40f045204c764ccb7524d2aeab021054ec69446b0a/w3lib-2.4.1-py3-none-any.whl", hash = "sha256:40930132907e68de906a5b89331ab8c8ff4f01bd35b5539ef7896017d814138d", size = 21695, upload-time = "2026-03-20T09:50:26.187Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/43/76ded108b296a49f52de6bac5192ca1c4be84e886f9b5c9ba8427d9694fd/werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351", size = 875700, upload-time = "2026-03-24T01:08:07.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b2/0bba9bbb4596d2d2f285a16c2ab04118f6b957d8441566e1abb892e6a6b2/werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f", size = 226295, upload-time = "2026-03-24T01:08:06.133Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + [[package]] name = "zope-interface" version = "8.2"