diff --git a/AGENTS.md b/AGENTS.md index 7bc31c0..4421b9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ Read these before changing behaviour in those areas, and update them if you do. ## Pitfalls - **Background IMAP thread:** `initialize_imap_polling` is skipped when `app.config['TESTING']` is set. In tests always call `create_app(..., one_off_call=True)` — see `tests/conftest.py`. -- **SCSS at startup:** The app shells out to `sass` on startup. `sass` must be on PATH for the production server but is not needed for tests (`one_off_call=True` skips it). +- **SCSS at startup:** The app compiles SCSS on startup, preferring `sass` on PATH but falling back to the bundled `sass-embedded` package if it's not found. Not needed for tests (`one_off_call=True` skips it). - **EmailIn composite PK:** `email_in.(message_id, list_id)` is a composite PK. `email_out` holds a compound FK to both columns — handle carefully in queries and migrations. - **Soft-delete:** `MailingList` is never hard-deleted. Use `.deactivate()` / `.reactivate()`. - **IMAP in tests:** Use the `fixture_mailbox_stub` fixture (`MailboxStub`) — never open real IMAP connections in tests. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 615e759..6bdcaa0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -157,7 +157,7 @@ scripts/ Utility scripts - Tests run with an in-memory SQLite database - The app is created with `one_off_call=True` to avoid starting background IMAP threads - IMAP interactions are mocked using the `MailboxStub` fixture in `tests/conftest.py` -- SCSS compilation requires `sass` on PATH; tests skip this via `one_off_call=True` +- SCSS compilation prefers `sass` on PATH but falls back to the bundled `sass-embedded` package if unavailable; tests skip both compiler paths entirely via `one_off_call=True` ## Submitting changes diff --git a/README.md b/README.md index 0e1fd2a..265c6fd 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,11 @@ CastMail2List is not a replacement for Mailman in large-scale or enterprise setu - **Internationalization** — UI available in English and German; extensible via standard gettext. - **Database migrations** — schema changes handled automatically via Alembic/Flask-Migrate. +## Requirements + +- Python 3.10+ +- Optional: `sass`. CastMail2List compiles its bundled SCSS to CSS on startup. If no system-wide `sass` binary is found on `PATH`, it automatically falls back to the bundled [`sass-embedded`](https://pypi.org/project/sass-embedded/) Python package, which downloads a pinned Dart Sass binary into the virtual environment on first run (cached afterward, no repeated downloads). Installing system `sass` is optional but avoids that one-time download, keeps the virtual environment smaller, and allows the user to define which version is being used. + ## Installation ### From PyPI diff --git a/castmail2list/static/scss/main.scss b/castmail2list/static/scss/main.scss index 3ebe808..3ec907c 100644 --- a/castmail2list/static/scss/main.scss +++ b/castmail2list/static/scss/main.scss @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Max Mehl // SPDX-License-Identifier: Apache-2.0 -// Import Pico CSS framework with custom theme color +// Import Pico CSS framework (version 2.1.1 with minimal fixes) with custom theme color @use "pico" with ($theme-color: "amber"); @use 'sass:list'; diff --git a/castmail2list/static/scss/pico/components/_modal.scss b/castmail2list/static/scss/pico/components/_modal.scss index d17943b..54358b3 100644 --- a/castmail2list/static/scss/pico/components/_modal.scss +++ b/castmail2list/static/scss/pico/components/_modal.scss @@ -34,9 +34,8 @@ // Content > article { $close-selector: if( - $enable-classes, - ".close, :is(a, button)[rel=prev]", - ":is(a, button)[rel=prev]" + sass($enable-classes): ".close, :is(a, button)[rel=prev]"; + else: ":is(a, button)[rel=prev]", ); width: 100%; max-height: calc(100vh - var(#{$css-var-prefix}spacing) * 2); diff --git a/castmail2list/utils.py b/castmail2list/utils.py index 108b094..02505be 100644 --- a/castmail2list/utils.py +++ b/castmail2list/utils.py @@ -7,6 +7,7 @@ import logging import os import re +import shutil import subprocess import sys import uuid @@ -28,10 +29,16 @@ from .models import EmailIn, EmailOut, Logs, MailingList, Subscriber, db -def compile_scss(compiler: str, scss_input: str, css_output: str) -> None: - """Compile SCSS files to CSS using an external compiler.""" +def _compile_scss_system(compiler: str, scss_input: str, css_output: str) -> None: + """Compile SCSS files to CSS using the system-installed Sass compiler. + + Args: + compiler (str): Path/name of the system Sass executable (e.g. "sass"). + scss_input (str): Absolute path to the SCSS input file. + css_output (str): Absolute path to the CSS output file. + """ try: - logging.info("Compiling %s to %s", scss_input, css_output) + logging.info("Compiling %s to %s (system sass)", scss_input, css_output) subprocess.run([compiler, scss_input, css_output], check=True) # noqa: S603 except subprocess.CalledProcessError as e: logging.critical("Error compiling %s: %s", scss_input, e) @@ -43,6 +50,48 @@ def compile_scss(compiler: str, scss_input: str, css_output: str) -> None: sys.exit(1) +def _compile_scss_embedded(scss_input: str, css_output: str) -> None: + """Compile SCSS files to CSS using the bundled sass-embedded package. + + Used as a fallback when no system Sass compiler is available on PATH. On first use, this + downloads a pinned Dart Sass binary into the virtual environment; subsequent calls reuse the + cached binary. + + Args: + scss_input (str): Absolute path to the SCSS input file. + css_output (str): Absolute path to the CSS output file. + """ + from sass_embedded import compile_file # noqa: PLC0415 + from sass_embedded.dart_sass.installer import install as install_dart_sass # noqa: PLC0415 + + try: + logging.info("Compiling %s to %s (bundled sass-embedded)", scss_input, css_output) + install_dart_sass() # idempotent; no-op if already cached in the venv + compile_file(Path(scss_input), Path(css_output)) + except Exception as e: # noqa: BLE001 + logging.critical("Error compiling %s with sass-embedded: %s", scss_input, e) + sys.exit(1) + + +def _compile_scss(scss_input: str, css_output: str) -> None: + """Compile SCSS to CSS, preferring a system Sass compiler if available. + + If a `sass` executable is found on PATH, it is used. Otherwise, this falls back to the + bundled `sass-embedded` package, which downloads a pinned Dart Sass binary into the virtual + environment on first use. + + Args: + scss_input (str): Absolute path to the SCSS input file. + css_output (str): Absolute path to the CSS output file. + """ + if system_sass := shutil.which("sass"): + logging.info("Using system sass binary at %s", system_sass) + _compile_scss_system(system_sass, scss_input=scss_input, css_output=css_output) + else: + logging.info("System sass not found on PATH; falling back to bundled sass-embedded") + _compile_scss_embedded(scss_input=scss_input, css_output=css_output) + + def compile_scss_on_startup(scss_files: list[tuple[str, str]]) -> list[tuple[str, str]]: """Compile SCSS to CSS on application startup. @@ -56,7 +105,7 @@ def compile_scss_on_startup(scss_files: list[tuple[str, str]]) -> list[tuple[str for scss_input, css_output in scss_files: scss_input_abs = str(curpath / Path(scss_input)) css_output_abs = str(curpath / Path(css_output)) - compile_scss("sass", scss_input=scss_input_abs, css_output=css_output_abs) + _compile_scss(scss_input=scss_input_abs, css_output=css_output_abs) compiled_files.append((scss_input_abs, css_output_abs)) return compiled_files diff --git a/pyproject.toml b/pyproject.toml index 6249e63..9dd2f5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dependencies = [ "gunicorn>=26,<27", "platformdirs>=4.5.0,<5", "ago>=0.1.1", + "sass-embedded>=0.1.5", ] [project.urls] diff --git a/tests/test_utils.py b/tests/test_utils.py index 7dc01f5..fdd436b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -729,3 +729,135 @@ def test_create_app_raises_on_missing_secret_key() -> None: }, one_off_call=True, ) + + +# ---------------------- SCSS Compilation Tests ---------------------- + + +def test_compile_scss_uses_system_sass_when_available(monkeypatch: MonkeyPatch) -> None: + """If a system sass binary is found on PATH, it should be used via subprocess.""" + monkeypatch.setattr(utils.shutil, "which", lambda _name: "/usr/bin/sass") + + calls: list[list[str]] = [] + + def fake_run(cmd: list[str], check: bool) -> None: + calls.append(cmd) + + monkeypatch.setattr(utils.subprocess, "run", fake_run) + + utils._compile_scss(scss_input="/tmp/in.scss", css_output="/tmp/out.css") + + assert calls == [["/usr/bin/sass", "/tmp/in.scss", "/tmp/out.css"]] + + +def test_compile_scss_falls_back_to_embedded_when_no_system_sass( + monkeypatch: MonkeyPatch, +) -> None: + """If no system sass binary is found on PATH, fall back to sass-embedded.""" + monkeypatch.setattr(utils.shutil, "which", lambda _name: None) + + embedded_calls: list[tuple[str, str]] = [] + + def fake_compile_scss_embedded(scss_input: str, css_output: str) -> None: + embedded_calls.append((scss_input, css_output)) + + monkeypatch.setattr(utils, "_compile_scss_embedded", fake_compile_scss_embedded) + + utils._compile_scss(scss_input="/tmp/in.scss", css_output="/tmp/out.css") + + assert embedded_calls == [("/tmp/in.scss", "/tmp/out.css")] + + +def test_compile_scss_embedded_installs_and_compiles(monkeypatch: MonkeyPatch) -> None: + """compile_scss_embedded should install Dart Sass (idempotent), then compile via + sass_embedded. + """ + install_calls = [] + compile_calls = [] + + fake_sass_embedded = type( + "FakeModule", + (), + {"compile_file": staticmethod(lambda source, dest: compile_calls.append((source, dest)))}, + )() + fake_installer = type( + "FakeInstaller", (), {"install": staticmethod(lambda: install_calls.append(True))} + )() + + monkeypatch.setitem(__import__("sys").modules, "sass_embedded", fake_sass_embedded) + monkeypatch.setitem( + __import__("sys").modules, "sass_embedded.dart_sass.installer", fake_installer + ) + + utils._compile_scss_embedded(scss_input="/tmp/in.scss", css_output="/tmp/out.css") + + assert install_calls == [True] + assert compile_calls == [(Path("/tmp/in.scss"), Path("/tmp/out.css"))] + + +def test_compile_scss_system_exits_on_missing_compiler(monkeypatch: MonkeyPatch) -> None: + """compile_scss_system should log critical and exit if the compiler binary is not found.""" + + def fake_run(cmd: list[str], check: bool) -> NoReturn: + raise FileNotFoundError + + monkeypatch.setattr(utils.subprocess, "run", fake_run) + + with pytest.raises(SystemExit) as exc_info: + utils._compile_scss_system("sass", scss_input="/tmp/in.scss", css_output="/tmp/out.css") + + assert exc_info.value.code == 1 + + +def test_compile_scss_system_exits_on_compile_error(monkeypatch: MonkeyPatch) -> None: + """compile_scss_system should log critical and exit if compilation fails.""" + + def fake_run(cmd: list[str], check: bool) -> NoReturn: + raise subprocess.CalledProcessError(returncode=1, cmd=cmd) + + monkeypatch.setattr(utils.subprocess, "run", fake_run) + + with pytest.raises(SystemExit) as exc_info: + utils._compile_scss_system("sass", scss_input="/tmp/in.scss", css_output="/tmp/out.css") + + assert exc_info.value.code == 1 + + +def test_compile_scss_embedded_exits_on_compile_error(monkeypatch: MonkeyPatch) -> None: + """compile_scss_embedded should log critical and exit if sass-embedded compilation fails.""" + + def fake_compile_file(source: Path, dest: Path) -> NoReturn: + msg = "boom" + raise RuntimeError(msg) + + fake_sass_embedded = type("FakeModule", (), {"compile_file": staticmethod(fake_compile_file)})() + fake_installer = type("FakeInstaller", (), {"install": staticmethod(lambda: None)})() + + monkeypatch.setitem(__import__("sys").modules, "sass_embedded", fake_sass_embedded) + monkeypatch.setitem( + __import__("sys").modules, "sass_embedded.dart_sass.installer", fake_installer + ) + + with pytest.raises(SystemExit) as exc_info: + utils._compile_scss_embedded(scss_input="/tmp/in.scss", css_output="/tmp/out.css") + + assert exc_info.value.code == 1 + + +def test_compile_scss_on_startup_resolves_paths_and_compiles(monkeypatch: MonkeyPatch) -> None: + """compile_scss_on_startup should resolve absolute paths and delegate to compile_scss.""" + calls: list[tuple[str, str]] = [] + + def fake_compile_scss(scss_input: str, css_output: str) -> None: + calls.append((scss_input, css_output)) + + monkeypatch.setattr(utils, "_compile_scss", fake_compile_scss) + + result = utils.compile_scss_on_startup([("static/scss/main.scss", "static/css/main.css")]) + + curpath = Path(utils.__file__).parent.resolve() + expected_input = str(curpath / "static/scss/main.scss") + expected_output = str(curpath / "static/css/main.css") + + assert calls == [(expected_input, expected_output)] + assert result == [(expected_input, expected_output)] diff --git a/uv.lock b/uv.lock index 6d5aaae..25e98d3 100644 --- a/uv.lock +++ b/uv.lock @@ -57,6 +57,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "bbpb" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/74/e12935d1718c8020e2dcd18205ad949daef98931598fa9bdc88998676d6f/bbpb-1.4.2.tar.gz", hash = "sha256:03446991bc500cfc9dd2049e6cc9489979e157c5ecb793e27936ab3d579d3496", size = 36339, upload-time = "2025-03-14T15:18:12.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/f4/d37dcd3692c058942a64793b9d7f85979fd78fca92934e7ad4a95cec5a89/bbpb-1.4.2-py3-none-any.whl", hash = "sha256:ca5ae8c820a12616f0f33b5647d9889515b751ace5820741213fd3f85c7485c0", size = 50074, upload-time = "2025-03-14T15:18:10.388Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -108,6 +120,7 @@ dependencies = [ { name = "jsonschema" }, { name = "platformdirs" }, { name = "pyyaml" }, + { name = "sass-embedded" }, { name = "wtforms", extra = ["email"] }, ] @@ -139,6 +152,7 @@ requires-dist = [ { name = "jsonschema", specifier = ">=4.25.1,<5" }, { name = "platformdirs", specifier = ">=4.5.0,<5" }, { name = "pyyaml", specifier = ">=6.0.1,<7" }, + { name = "sass-embedded", specifier = ">=0.1.5" }, { name = "wtforms", extras = ["email"], specifier = ">=3.2.1,<4" }, ] @@ -849,6 +863,21 @@ 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 = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1305,6 +1334,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, ] +[[package]] +name = "sass-embedded" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bbpb" }, + { name = "packaging" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/bf/3848a84a207a6239d04985ed9349bafe50fa463d89231d759f2847a1a410/sass_embedded-0.1.5.tar.gz", hash = "sha256:117ed468c2391cf7c7e8432da97837ce8939af2bf4015a01cefc0b2ffef7fb5c", size = 86135, upload-time = "2026-07-04T16:16:47.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/7f/87bd943883c7162818411e0cf9e18cd781f78d636ce1ab2b5c308cc02e20/sass_embedded-0.1.5-py3-none-any.whl", hash = "sha256:45996d9db2a8526b5005874ab2a413334a7e3e8672a780314cf26010f9adb2b8", size = 18785, upload-time = "2026-07-04T16:16:35.487Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/ce91b6ddc19161ca9d31accb5d015056e01940ffafbb220faee7ada843bd/sass_embedded-0.1.5-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:a995cb114f1529328fd2298fed4650a35d4375a58c05cc8cae9c105d04583b3d", size = 4312328, upload-time = "2026-07-04T16:16:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/9b/06/33dde44740e6a410e4a1aff431e275cd3724eafdbf23fb1e4f5475d841bd/sass_embedded-0.1.5-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:c1e9ff4ae682e73f823d85343f5c6b47fdad75fa55fd9f453444762c1c6b148c", size = 4469562, upload-time = "2026-07-04T16:16:38.716Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/b9a2506db4aa2b0a531d0c8902c145a87ad5f82df42a7d061d1887da7461/sass_embedded-0.1.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d28a69da01c8a2cd5a229bfe65a2ca3ba8aad55a081021d8869a68bf81abba5c", size = 4446160, upload-time = "2026-07-04T16:16:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/0c1ed69c187672776b9f2269c2ca366195669ef653b66b78fcaf7468b025/sass_embedded-0.1.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2f2fa05a4f3f953b73dffcf310f14b2021f8d2be022b48de397fa6e60ede40d9", size = 4619994, upload-time = "2026-07-04T16:16:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bc/eb0f5a81e6944e8498a6b245d5004f1a37fdd3be8ce58989560795caf47b/sass_embedded-0.1.5-py3-none-win_amd64.whl", hash = "sha256:30366da101b4bffde5b872beebbdf7b1ad50256aef2b8d60b3fa97cfe5061fdf", size = 4405400, upload-time = "2026-07-04T16:16:43.567Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/fb385202a49081f13a31f1b39a56adc1d7ff7c0171d14506dfb7026288c1/sass_embedded-0.1.5-py3-none-win_arm64.whl", hash = "sha256:d2481ff762bf35e2e7b8a26c644c4d35b0f76301b19c53eef3555abf1afda180", size = 4283216, upload-time = "2026-07-04T16:16:45.358Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +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.4"