diff --git a/.env.example b/.env.example index 61cb94d..bfa9e7c 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,10 @@ COMPUTE_ID=compute-env-id WORK_SPACE=workspace-id WORK_DIR=s3://bucket/workdir +# NCI Gadi project code used for PBS submissions (clusterOptions) across all +# Nextflow workflows. Optional; defaults to "yz52" if unset. +GADI_PROJECT=yz52 + # AWS S3 Configuration for file uploads AWS_ACCESS_KEY_ID=your-access-key-id AWS_SECRET_ACCESS_KEY=your-secret-access-key diff --git a/app/routes/workflows.py b/app/routes/workflows.py index 5bf2e54..8500277 100644 --- a/app/routes/workflows.py +++ b/app/routes/workflows.py @@ -14,7 +14,6 @@ from pydantic import ValidationError from sqlalchemy import CursorResult, func, select, update from sqlalchemy.orm import Session -from unidecode import unidecode from ..db.models import QueuedJob from ..db.models.core import AppUser, RunInput, RunMetric, S3Object, Workflow, WorkflowRun @@ -234,28 +233,16 @@ async def launch_workflow( ) user = db_session.execute( - select(AppUser.email, AppUser.name).where(AppUser.id == current_user_id) + select(AppUser.email).where(AppUser.id == current_user_id) ).one_or_none() if not user or not user.email: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not retrieve user details required for workflow launch.", ) - user_email = user.email - # removes everything that isn't a letter, digit, or space - name = unidecode(user.name or "") - full_name = re.sub(r"[^a-zA-Z0-9 ]", "", name).replace(" ", "_") - institute = user_email.split("@")[-1] if "@" in user_email else None - ip_address: str | None = launch_ip or None - - full_name = _require_launch_var("full_name", full_name) - institute = _require_launch_var("institute", institute) - ip_address = _require_launch_var("ip_address", ip_address) user_details = WorkflowUserDetails( - user_email=user_email, - full_name=full_name, - institute=institute, - ip_address=ip_address, + user_email=user.email, + ip_address=_require_launch_var("ip_address", launch_ip or None), ) # Authoritative credit cost (server-side, non-spoofable). Only charged for @@ -406,7 +393,7 @@ async def launch_workflow( .values( credit=AppUser.credit - run_credit_cost, credit_updated_at=datetime.now(UTC), - credit_updated_by=user_email, + credit_updated_by=user_details.user_email, ) ), ) diff --git a/app/run_scheduler.py b/app/run_scheduler.py index 5f1779e..bd0d609 100644 --- a/app/run_scheduler.py +++ b/app/run_scheduler.py @@ -3,10 +3,17 @@ import typer from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger +from dotenv import load_dotenv from loguru import logger -from app.scheduler import SCHEDULER -from app.scheduler.jobs import refresh_user_credits, submit_pending_jobs +# Load .env before importing scheduler modules, which read required env vars (e.g. +# SEQERA_API_URL) at call time. Unlike app/main.py, this entrypoint runs standalone +# (`python -m app.run_scheduler`) and never imports main.py, so without this call +# .env values are never loaded into the process environment. +load_dotenv() + +from app.scheduler import SCHEDULER # noqa: E402 +from app.scheduler.jobs import refresh_user_credits, submit_pending_jobs # noqa: E402 SUBMIT_INTERVAL = IntervalTrigger(minutes=5) MONTHLY_TRIGGER = CronTrigger(day=1, hour=1, minute=0, timezone="UTC") diff --git a/app/schemas/workflows.py b/app/schemas/workflows.py index 0dca971..a19370d 100644 --- a/app/schemas/workflows.py +++ b/app/schemas/workflows.py @@ -97,8 +97,6 @@ class WorkflowUserDetails(BaseModel): """ user_email: str = Field(..., description="Email address of the user") - full_name: str = Field(..., description="Full name of the user") - institute: str = Field(..., description="Institute of the user") ip_address: str = Field(..., description="IP address of the user") diff --git a/app/services/bindflow_config.py b/app/services/bindflow_config.py index 750bde2..359342f 100644 --- a/app/services/bindflow_config.py +++ b/app/services/bindflow_config.py @@ -5,13 +5,14 @@ from typing import Any from ..schemas.workflows import WorkflowUserDetails +from .cluster_utils import GADI_PROJECT, encode_ip from .workflow_config_fetcher import fetch_workflow_config def get_bindflow_default_params(out_dir: str, samplesheet_url: str) -> dict[str, Any]: """Get default parameters for bindflow workflow.""" return { - "project": "yz52", + "project": GADI_PROJECT, "outdir": out_dir, "input": samplesheet_url, } @@ -25,17 +26,16 @@ def get_bindflow_config_profiles() -> list[str]: def get_bindflow_config_text( config_file_path: str, *, - job_id: str, user_details: WorkflowUserDetails, - timestamp: str, ) -> str: """Read bindflow base config and append a process override block with runtime values.""" base = fetch_workflow_config(config_file_path) - cluster_opts = ( - f"-v JOB_ID={job_id},USER_NAME={user_details.user_email}," - f"TIMESTAMP={timestamp},FULL_NAME={user_details.full_name}," - f"INSTITUTE={user_details.institute},IP_ADDRESS={user_details.ip_address}" + account = ( + f"{user_details.user_email}:{encode_ip(user_details.ip_address)}" + if user_details.ip_address + else user_details.user_email ) + cluster_opts = f"-A {account}" override = f'\nprocess {{\n clusterOptions = "{cluster_opts}"\n}}\n' return base + override diff --git a/app/services/bindflow_executor.py b/app/services/bindflow_executor.py index 9296160..a29ca05 100644 --- a/app/services/bindflow_executor.py +++ b/app/services/bindflow_executor.py @@ -58,14 +58,9 @@ async def prepare_bindflow_workflow( # pylint: disable=too-many-locals raise SeqeraConfigurationError("Missing output identifier for workflow launch") out_dir = f"s3://{s3_bucket}/{output_key}" - timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") - dataset_url = f"s3://{s3_bucket}/{s3_input_key}" default_params = get_bindflow_default_params(out_dir, dataset_url) - default_params["job_id"] = run_name - default_params["user_name"] = user_details.user_email - default_params["timestamp"] = timestamp default_params["mode"] = mode # Merge any tool-specific params forwarded from the frontend form @@ -91,9 +86,7 @@ async def prepare_bindflow_workflow( # pylint: disable=too-many-locals "configProfiles": get_bindflow_config_profiles(), "configText": get_bindflow_config_text( config_path, - job_id=run_name, user_details=user_details, - timestamp=timestamp, ), "resume": False, } diff --git a/app/services/cluster_utils.py b/app/services/cluster_utils.py new file mode 100644 index 0000000..6d0af78 --- /dev/null +++ b/app/services/cluster_utils.py @@ -0,0 +1,13 @@ +"""Utilities for building HPC cluster option strings.""" + +from __future__ import annotations + +import base64 +import os + +GADI_PROJECT: str = os.getenv("GADI_PROJECT", "yz52") + + +def encode_ip(ip_address: str) -> str: + """Return the base64 encoding of an IP address string.""" + return base64.b64encode(ip_address.encode()).decode() diff --git a/app/services/proteinfold_config.py b/app/services/proteinfold_config.py index 8843a13..4241c49 100644 --- a/app/services/proteinfold_config.py +++ b/app/services/proteinfold_config.py @@ -5,6 +5,7 @@ from typing import Any from ..schemas.workflows import WorkflowUserDetails +from .cluster_utils import GADI_PROJECT, encode_ip from .workflow_config_fetcher import fetch_workflow_config @@ -12,7 +13,7 @@ def get_proteinfold_default_params( out_dir: str, samplesheet_url: str, mode: str = "alphafold2" ) -> dict[str, Any]: """Get default parameters for proteinfold workflow.""" - return {"input": samplesheet_url, "outdir": out_dir, "project": "yz52", "mode": mode} + return {"input": samplesheet_url, "outdir": out_dir, "project": GADI_PROJECT, "mode": mode} def get_proteinfold_config_profiles() -> list[str]: @@ -23,17 +24,16 @@ def get_proteinfold_config_profiles() -> list[str]: def get_proteinfold_config_text( config_file_path: str, *, - job_id: str, user_details: WorkflowUserDetails, - timestamp: str, ) -> str: """Read proteinfold base config and append a process override block with runtime values.""" base = fetch_workflow_config(config_file_path) - cluster_opts = ( - f"-P yz52 -v JOB_ID={job_id},USER_NAME={user_details.user_email}," - f"TIMESTAMP={timestamp},FULL_NAME={user_details.full_name}," - f"INSTITUTE={user_details.institute},IP_ADDRESS={user_details.ip_address}" + account = ( + f"{user_details.user_email}:{encode_ip(user_details.ip_address)}" + if user_details.ip_address + else user_details.user_email ) + cluster_opts = f"-P {GADI_PROJECT} -A {account}" override = f'\nprocess {{\n clusterOptions = "{cluster_opts}"\n}}\n' return base + override diff --git a/app/services/proteinfold_executor.py b/app/services/proteinfold_executor.py index cfb99cc..59002fb 100644 --- a/app/services/proteinfold_executor.py +++ b/app/services/proteinfold_executor.py @@ -50,14 +50,11 @@ def _build_params_text( mode: str, form_data: WorkflowFormData | None, custom_params: str | None, - extra_params: dict[str, Any] | None = None, ) -> str: """Build the YAML params string for the Seqera launch payload.""" params = get_proteinfold_default_params(out_dir, samplesheet_url, mode) if form_data: params.update(_tool_params(form_data)) - if extra_params: - params.update(extra_params) params_text = params_to_yaml_text(params) if custom_params and custom_params.strip(): params_text = f"{params_text}\n{custom_params.rstrip()}" @@ -89,9 +86,7 @@ async def prepare_proteinfold_workflow( raise SeqeraConfigurationError("Missing output identifier for workflow launch") out_dir = f"s3://{s3_bucket}/{output_id.strip()}" - timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") - job_id = (form.runName or "").strip() - if not job_id: + if not form.runName or not form.runName.strip(): raise SeqeraConfigurationError("Missing run name for workflow launch") sheet_url = f"s3://{s3_bucket}/{s3_input_key}" @@ -101,11 +96,6 @@ async def prepare_proteinfold_workflow( mode, form_data, form.paramsText, - extra_params={ - "job_id": job_id, - "user_name": user_details.user_email, - "timestamp": timestamp, - }, ) launch_payload: dict[str, Any] = { @@ -119,9 +109,7 @@ async def prepare_proteinfold_workflow( "configProfiles": get_proteinfold_config_profiles(), "configText": get_proteinfold_config_text( config_path, - job_id=job_id, user_details=user_details, - timestamp=timestamp, ), "resume": False, } diff --git a/app/services/wisps_config.py b/app/services/wisps_config.py index e246c28..6f18c65 100644 --- a/app/services/wisps_config.py +++ b/app/services/wisps_config.py @@ -5,6 +5,7 @@ from typing import Any, Literal from ..schemas.workflows import WorkflowUserDetails +from .cluster_utils import GADI_PROJECT, encode_ip from .workflow_config_fetcher import fetch_workflow_config WispsMode = Literal["g1-g2", "manual"] @@ -39,9 +40,7 @@ def get_wisps_config_profiles() -> list[str]: def get_wisps_config_text( config_file_path: str, *, - job_id: str, user_details: WorkflowUserDetails, - timestamp: str, ) -> str: """Read wisps config and append a process override block with runtime values. @@ -50,10 +49,11 @@ def get_wisps_config_text( """ base = fetch_workflow_config(config_file_path) - cluster_opts = ( - f"-P yz52 -v JOB_ID={job_id},USER_NAME={user_details.user_email}," - f"TIMESTAMP={timestamp},FULL_NAME={user_details.full_name}," - f"INSTITUTE={user_details.institute},IP_ADDRESS={user_details.ip_address}" + account = ( + f"{user_details.user_email}:{encode_ip(user_details.ip_address)}" + if user_details.ip_address + else user_details.user_email ) + cluster_opts = f"-P {GADI_PROJECT} -A {account}" override = f'\nprocess {{\n clusterOptions = "{cluster_opts}"\n}}\n' return base + override diff --git a/app/services/wisps_executor.py b/app/services/wisps_executor.py index a0a4208..7abe379 100644 --- a/app/services/wisps_executor.py +++ b/app/services/wisps_executor.py @@ -54,7 +54,6 @@ async def prepare_wisps_workflow( raise SeqeraConfigurationError("Missing output identifier for workflow launch") out_dir = f"s3://{s3_bucket}/{output_id.strip()}" - timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") job_id = (form.runName or "").strip() if not job_id: raise SeqeraConfigurationError("Missing run name for workflow launch") @@ -72,9 +71,7 @@ async def prepare_wisps_workflow( config_text = get_wisps_config_text( config_path, - job_id=job_id, user_details=user_details, - timestamp=timestamp, ) launch_payload: dict[str, Any] = { diff --git a/pyproject.toml b/pyproject.toml index 9ed45c7..91b0591 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ dependencies = [ "cachetools~=5.5", "python-jose[cryptography]~=3.5", "starlette-admin~=0.16", - "unidecode~=1.3", "apscheduler>=3.11.3", "loguru>=0.7.3", "typer>=0.26.8", diff --git a/tests/test_proteinfold_coverage.py b/tests/test_proteinfold_coverage.py index ecd9d80..b96a77b 100644 --- a/tests/test_proteinfold_coverage.py +++ b/tests/test_proteinfold_coverage.py @@ -35,8 +35,6 @@ _USER_DETAILS = WorkflowUserDetails( user_email="user@ex.com", - full_name="Test_User", - institute="USYD", ip_address="1.2.3.4", ) @@ -369,7 +367,6 @@ async def test_prepare_proteinfold_workflow_writes_expected_queued_job( user_details=_USER_DETAILS.model_copy( update={ "user_email": "test@example.com", - "institute": "example.com", "ip_address": "127.0.0.1", } ), @@ -544,33 +541,36 @@ def test_get_proteinfold_config_text_appends_process_block(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_proteinfold_config_text( "/fake/proteinfold.config", - job_id="my-job", user_details=_USER_DETAILS, - timestamp="20240101_120000", ) assert "process {" in result assert "clusterOptions" in result -def test_get_proteinfold_config_text_contains_job_fields(): +def test_get_proteinfold_config_text_contains_email_and_encoded_ip(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_proteinfold_config_text( "/fake/proteinfold.config", - job_id="my-job", user_details=_USER_DETAILS, - timestamp="20240101_120000", ) - assert "my-job" in result assert "user@ex.com" in result - assert "20240101_120000" in result + assert "MS4yLjMuNA==" in result + + +def test_get_proteinfold_config_text_without_ip_uses_email_only(): + with patch("builtins.open", mock_open(read_data="base_config")): + result = get_proteinfold_config_text( + "/fake/proteinfold.config", + user_details=_USER_DETAILS.model_copy(update={"ip_address": ""}), + ) + assert "-A user@ex.com" in result + assert ":" not in result.split("clusterOptions = ")[1] def test_get_proteinfold_config_text_contains_base_config(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_proteinfold_config_text( "/fake/proteinfold.config", - job_id="my-job", user_details=_USER_DETAILS, - timestamp="20240101_120000", ) assert "base_config" in result diff --git a/tests/test_services_bindflow_config.py b/tests/test_services_bindflow_config.py index 3ff9f77..95935c0 100644 --- a/tests/test_services_bindflow_config.py +++ b/tests/test_services_bindflow_config.py @@ -18,12 +18,11 @@ from app.services.launch_payloads import get_executor_script _SHEET_URL = "https://api.seqera.test/workspaces/ws1/datasets/ds1/v/1/n/samplesheet.csv" -_USER_DETAILS = WorkflowUserDetails( - user_email="user@example.com", - full_name="", - institute="", - ip_address="", -) + + +def _user_details(email: str, ip_address: str = "") -> WorkflowUserDetails: + return WorkflowUserDetails(user_email=email, ip_address=ip_address) + # ============================================================================= # Tests for get_bindflow_default_params() @@ -125,9 +124,7 @@ def test_get_bindflow_config_text_includes_base_config(): with patch("builtins.open", mock_open(read_data="base_config_content")): result = get_bindflow_config_text( "/fake/bindflow.config", - job_id="run-1", - user_details=_USER_DETAILS, - timestamp="20260507_120000", + user_details=_user_details("user@example.com"), ) assert "base_config_content" in result @@ -136,43 +133,38 @@ def test_get_bindflow_config_text_appends_process_block(): with patch("builtins.open", mock_open(read_data="")): result = get_bindflow_config_text( "/fake/bindflow.config", - job_id="run-1", - user_details=_USER_DETAILS, - timestamp="20260507_120000", + user_details=_user_details("user@example.com"), ) assert "process {" in result assert "clusterOptions" in result -def test_get_bindflow_config_text_interpolates_job_fields(): +def test_get_bindflow_config_text_interpolates_email(): + with patch("builtins.open", mock_open(read_data="")): + result = get_bindflow_config_text( + "/fake/bindflow.config", + user_details=_user_details("alice@example.com"), + ) + assert "-A alice@example.com" in result + + +def test_get_bindflow_config_text_without_ip_address_omits_encoding(): with patch("builtins.open", mock_open(read_data="")): result = get_bindflow_config_text( "/fake/bindflow.config", - job_id="my-run", - user_details=_USER_DETAILS.model_copy(update={"user_email": "alice@example.com"}), - timestamp="20260507_090000", + user_details=_user_details("user@example.com"), ) - assert "my-run" in result - assert "alice@example.com" in result - assert "20260507_090000" in result + assert "-A user@example.com" in result + assert ":" not in result.split("clusterOptions = ")[1] -def test_get_bindflow_config_text_optional_fields(): +def test_get_bindflow_config_text_with_ip_address_appends_encoded_ip(): with patch("builtins.open", mock_open(read_data="")): result = get_bindflow_config_text( "/fake/bindflow.config", - job_id="run-1", - user_details=WorkflowUserDetails( - user_email="user@example.com", - full_name="Alice Smith", - institute="BioCommons", - ip_address="1.2.3.4", - ), - timestamp="ts", + user_details=_user_details("user@example.com", ip_address="1.2.3.4"), ) - assert "Alice Smith" in result - assert "BioCommons" in result - assert "1.2.3.4" in result + assert "-A user@example.com:MS4yLjMuNA==" in result def test_get_bindflow_config_text_url_fetching(): @@ -182,9 +174,7 @@ def test_get_bindflow_config_text_url_fetching(): ) result = get_bindflow_config_text( "https://raw.githubusercontent.com/org/repo/main/bindflow.config", - job_id="run-url", - user_details=_USER_DETAILS, - timestamp="20260507_120000", + user_details=_user_details("user@example.com"), ) assert "remote_base_config" in result assert "clusterOptions" in result @@ -198,7 +188,5 @@ def test_get_bindflow_config_text_url_error_raises(): with pytest.raises(httpx.HTTPStatusError): get_bindflow_config_text( "https://raw.githubusercontent.com/org/repo/main/bindflow.config", - job_id="run-1", - user_details=_USER_DETAILS, - timestamp="ts", + user_details=_user_details("user@example.com"), ) diff --git a/tests/test_services_cluster_utils.py b/tests/test_services_cluster_utils.py new file mode 100644 index 0000000..fad9453 --- /dev/null +++ b/tests/test_services_cluster_utils.py @@ -0,0 +1,42 @@ +"""Tests for cluster_utils.py.""" + +# pylint: disable=missing-function-docstring +from __future__ import annotations + +import base64 +import importlib + +from app.services import cluster_utils +from app.services.cluster_utils import encode_ip + + +def test_encode_ip_returns_base64_of_ip_string(): + assert encode_ip("1.2.3.4") == base64.b64encode(b"1.2.3.4").decode() + + +def test_encode_ip_empty_string(): + assert encode_ip("") == "" + + +def test_encode_ip_ipv6_address(): + ip = "2001:db8::1" + assert encode_ip(ip) == base64.b64encode(ip.encode()).decode() + + +def test_gadi_project_defaults_to_yz52(monkeypatch): + monkeypatch.delenv("GADI_PROJECT", raising=False) + reloaded = importlib.reload(cluster_utils) + try: + assert reloaded.GADI_PROJECT == "yz52" + finally: + importlib.reload(cluster_utils) + + +def test_gadi_project_reads_env_var(monkeypatch): + monkeypatch.setenv("GADI_PROJECT", "ab12") + reloaded = importlib.reload(cluster_utils) + try: + assert reloaded.GADI_PROJECT == "ab12" + finally: + monkeypatch.delenv("GADI_PROJECT", raising=False) + importlib.reload(cluster_utils) diff --git a/tests/test_services_seqera.py b/tests/test_services_seqera.py index f67c7a2..fad8757 100644 --- a/tests/test_services_seqera.py +++ b/tests/test_services_seqera.py @@ -26,8 +26,6 @@ _CONFIG_PATH = "/some/bindflow.config" _USER_DETAILS = WorkflowUserDetails( user_email="test@example.com", - full_name="Test_User", - institute="example.com", ip_address="127.0.0.1", ) diff --git a/tests/test_wisps_coverage.py b/tests/test_wisps_coverage.py index d269ced..f842c61 100644 --- a/tests/test_wisps_coverage.py +++ b/tests/test_wisps_coverage.py @@ -41,8 +41,6 @@ _USER_DETAILS = WorkflowUserDetails( user_email="user@ex.com", - full_name="Test User", - institute="USYD", ip_address="1.2.3.4", ) @@ -258,33 +256,37 @@ def test_get_wisps_config_text_appends_process_block(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_wisps_config_text( config_file_path="/fake/path.config", - job_id="my-job", user_details=_USER_DETAILS, - timestamp="20240101_120000", ) assert "process {" in result assert "clusterOptions" in result -def test_get_wisps_config_text_contains_job_fields(): +def test_get_wisps_config_text_contains_email_and_encoded_ip(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_wisps_config_text( config_file_path="/fake/path.config", - job_id="my-job", user_details=_USER_DETAILS, - timestamp="20240101_120000", ) - assert "my-job" in result assert "user@ex.com" in result + assert "MS4yLjMuNA==" in result + + +def test_get_wisps_config_text_without_ip_uses_email_only(): + with patch("builtins.open", mock_open(read_data="base_config")): + result = get_wisps_config_text( + config_file_path="/fake/path.config", + user_details=_USER_DETAILS.model_copy(update={"ip_address": ""}), + ) + assert "-A user@ex.com" in result + assert ":" not in result.split("clusterOptions = ")[1] def test_get_wisps_config_text_contains_base_config(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_wisps_config_text( config_file_path="/fake/path.config", - job_id="my-job", user_details=_USER_DETAILS, - timestamp="20240101_120000", ) assert "base_config" in result diff --git a/uv.lock b/uv.lock index 14d44fb..ec6b5d1 100644 --- a/uv.lock +++ b/uv.lock @@ -1083,7 +1083,6 @@ dependencies = [ { name = "sqlalchemy" }, { name = "starlette-admin" }, { name = "typer" }, - { name = "unidecode" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -1126,7 +1125,6 @@ requires-dist = [ { name = "sqlalchemy", specifier = "~=2.0" }, { name = "starlette-admin", specifier = "~=0.16" }, { name = "typer", specifier = ">=0.26.8" }, - { name = "unidecode", specifier = "~=1.3" }, { name = "uvicorn", extras = ["standard"], specifier = "~=0.49" }, ] @@ -1320,15 +1318,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, ] -[[package]] -name = "unidecode" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, -] - [[package]] name = "urllib3" version = "2.7.0"