diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1300114 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: Python CI + +on: + push: + branches: + - '**' + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run linting + run: uv run ruff check . + + - name: Run tests + run: uv run pytest diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2c7f77c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,37 @@ +name: Python Deployment + +on: + push: + tags: + - '*' + +jobs: + deploy: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run tests + run: uv run pytest + + - name: Build package + run: uv build + + - name: Publish to PyPI + run: uv publish + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..6f53c19 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,23 @@ +name: Close inactive issues +on: + schedule: + - cron: "30 1 * * *" + +jobs: + close-issues: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v5 + with: + days-before-issue-stale: 365 + days-before-issue-close: 14 + exempt-issue-labels: "pinned, security" + stale-issue-label: "stale" + stale-issue-message: "This issue is stale because it has been open for a year with no activity. It will be closed if no further activity occurs. Thank you for your contributions." + close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale. Feel free to re-open it if it is still relevant." + days-before-pr-stale: -1 + days-before-pr-close: -1 + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index da4a22c..50525e9 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,11 @@ coverage *.sln *.sw? -.serena/ +# VSCode +.history __pycache__ + +debug.py +.serena/ +plans/ .venv/ diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..e50328f --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,46 @@ +# Ruff configuration file + +# Exclude a variety of commonly ignored directories. +extend-exclude = [ + "__pycache__", + ".git", + ".venv", + ".eggs", + ".nox", + ".tox", + ".svn", + ".hg", + "build", + "dist", + ".mypy_cache", + ".pytest_cache", +] + +# Assume Python 3.10. +target-version = "py310" + +# Line length with preview to format +line-length = 120 +preview = true + +[lint] +# Enable flake8-bugbear rules +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "SIM", # flake8-simplify +] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] + +# Ignore B008 for Typer - typer.Option in argument defaults is standard Typer practice +# Ignore E501 - long lines in help text are acceptable +ignore = ["B008", "E501"] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" \ No newline at end of file diff --git a/Makefile b/Makefile index 8810746..d48a08e 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,28 @@ install: - uv sync + uv sync --all-extras test: - uv run pytest + uv run --all-extras pytest + +lint: + uv run ruff check . + +fix: + uv run ruff check . --fix + +format: + uv run ruff format . + +check: format fix build: uv build -publish: - uv publish - clean: rm -rf dist -local-install: +local-install: clean build pip install ./dist/datashield-*.tar.gz + +local-install-force: clean build + pip install ./dist/datashield-*.tar.gz --break-system-packages diff --git a/datashield/__init__.py b/datashield/__init__.py index 92d1e58..0874d1e 100644 --- a/datashield/__init__.py +++ b/datashield/__init__.py @@ -1,2 +1,7 @@ -from datashield.interface import DSConnection, DSLoginInfo, DSDriver, DSError -from datashield.api import DSLoginBuilder, DSSession \ No newline at end of file +from datashield.interface import ( + DSConnection as DSConnection, + DSLoginInfo as DSLoginInfo, + DSDriver as DSDriver, + DSError as DSError, +) +from datashield.api import DSLoginBuilder as DSLoginBuilder, DSSession as DSSession diff --git a/datashield/api.py b/datashield/api.py index 8a974a8..9af31f5 100644 --- a/datashield/api.py +++ b/datashield/api.py @@ -1,18 +1,29 @@ """ DataSHIELD API. """ + from datashield.interface import DSLoginInfo, DSConnection, DSDriver, DSError import time + class DSLoginBuilder: """ Helper class to formalize DataSHIELD login arguments for a set of servers. """ def __init__(self): - self.items: list[DSLoginInfo] = list() - - def add(self, name: str, url: str, user: str = None, password: str = None, token: str = None, profile: str = 'default', driver: str = 'datashield_opal.OpalDriver'): + self.items: list[DSLoginInfo] = [] + + def add( + self, + name: str, + url: str, + user: str = None, + password: str = None, + token: str = None, + profile: str = "default", + driver: str = "datashield_opal.OpalDriver", + ): """ Add DataSHIELD login information. @@ -23,20 +34,20 @@ def add(self, name: str, url: str, user: str = None, password: str = None, token :param token: The access token (required if user is None) :param profile: The DataSHIELD profile name to be used :param driver: The DataSHIELD connection driver class full name - :return Itself + :return Itself """ if name is None: - raise ValueError('Server name is missing') + raise ValueError("Server name is missing") if url is None: - raise ValueError('Server URL is missing') + raise ValueError("Server URL is missing") found = [x for x in self.items if x.name == name] if len(found) > 0: - raise ValueError('Server name must be unique: %s' % name) + raise ValueError(f"Server name must be unique: {name}") if user is None and token is None: - raise ValueError('Either user or token must be provided') + raise ValueError("Either user or token must be provided") self.items.append(DSLoginInfo(name, url, user, password, token, profile, driver)) return self - + def remove(self, name: str): """ Remove the DataSHIELD login information by its name, if it exists. @@ -46,7 +57,7 @@ def remove(self, name: str): """ self.items = [x for x in self.items if x.name != name] return self - + def build(self) -> list[DSLoginInfo]: """ Get the list of DataSHIELD login info. @@ -55,6 +66,7 @@ def build(self) -> list[DSLoginInfo]: """ return self.items + class DSSession: """ DataSHIELD session, establishes connections with remote servers and performs commands. @@ -76,12 +88,12 @@ def open(self, restore: str = None, failSafe: bool = False) -> None: :param restore: The workspace name to be restored """ - self.conns = list() - self.errors = dict() + self.conns = [] + self.errors = {} for info in self.logins: try: driver = DSDriver.load_class(info.driver) - conn = driver.new_connection(info, restore = restore) + conn = driver.new_connection(info, restore=restore) self.conns.append(conn) except Exception as e: if failSafe: @@ -101,13 +113,13 @@ def close(self, save: str = None) -> None: :param cons: The list of connections to close. :param save: The name of the workspace to save before closing the connections. """ - self.errors = dict() + self.errors = {} for conn in self.conns: try: if save: conn.save_workspace(f"{conn.name}:{save}") conn.disconnect() - except DSError as e: + except DSError: # silently fail pass self.conns = None @@ -117,13 +129,13 @@ def has_connections(self) -> bool: Check if some connections were opened. """ return len(self.conns) > 0 - + def get_connection_names(self) -> list[str]: """ Get the opened connection names. """ if self.conns: - return map(lambda conn: conn.name, self.conns) + return [conn.name for conn in self.conns] else: return None @@ -132,7 +144,7 @@ def has_errors(self) -> bool: Check if last command execution has produced errors. """ return len(self.errors) > 0 - + def get_errors(self) -> dict: """ Get the last command execution errors, per remote server name. @@ -179,7 +191,7 @@ def packages(self) -> dict: rval[conn.name] = conn.list_packages() return rval - def methods(self, type: str = 'aggregate') -> dict: + def methods(self, type: str = "aggregate") -> dict: """ Get the list of DataSHIELD methods that have been configured on the remote data repository. @@ -263,9 +275,17 @@ def rm(self, symbol: str): self._append_error(conn, e) self._check_errors() - def assign_table(self, symbol: str, table: str = None, tables: dict = None, variables: list = None, - missings: bool = False, identifiers: str = None, - id_name: str = None, asynchronous: bool = True) -> None: + def assign_table( + self, + symbol: str, + table: str = None, + tables: dict = None, + variables: list = None, + missings: bool = False, + identifiers: str = None, + id_name: str = None, + asynchronous: bool = True, + ) -> None: """ Assign a data table from the data repository to a symbol in the DataSHIELD R session. @@ -289,7 +309,9 @@ def assign_table(self, symbol: str, table: str = None, tables: dict = None, vari self._do_wait(cmd) self._check_errors() - def assign_resource(self, symbol: str, resource: str = None, resources: dict = None, asynchronous: bool = True) -> None: + def assign_resource( + self, symbol: str, resource: str = None, resources: dict = None, asynchronous: bool = True + ) -> None: """ Assign a resource from the data repository to a symbol in the DataSHIELD R session. @@ -332,7 +354,6 @@ def assign_expr(self, symbol: str, expr: str, asynchronous: bool = True) -> None self._do_wait(cmd) self._check_errors() - def aggregate(self, expr: str, asynchronous: bool = True) -> dict: """ Aggregate some data from the DataSHIELD R session using a valid R expression. The @@ -354,7 +375,7 @@ def aggregate(self, expr: str, asynchronous: bool = True) -> dict: rval = self._do_wait(cmd) self._check_errors() return rval - + # # Private functions # @@ -384,7 +405,7 @@ def _init_errors(self) -> None: """ Prepare for storing errors. """ - self.errors = dict() + self.errors = {} def _append_error(self, conn: DSConnection, error: Exception) -> None: """ @@ -397,4 +418,4 @@ def _check_errors(self) -> None: Prepare for storing errors. """ if self.errors: - raise DSError("There are some errors, please check them with DSSession.get_errors().") \ No newline at end of file + raise DSError("There are some errors, please check them with DSSession.get_errors().") diff --git a/datashield/interface.py b/datashield/interface.py index fc737e9..1f9cc42 100644 --- a/datashield/interface.py +++ b/datashield/interface.py @@ -4,20 +4,31 @@ import importlib + class DSLoginInfo: """ Helper class with DataSHIELD login details. """ - def __init__(self, name: str, url: str, user: str = None, password: str = None, token: str = None, profile: str = 'default', driver: str = 'datashield_opal.OpalDriver'): - self.items = list() + def __init__( + self, + name: str, + url: str, + user: str = None, + password: str = None, + token: str = None, + profile: str = "default", + driver: str = "datashield_opal.OpalDriver", + ): + self.items = [] self.name = name self.url = url self.user = user self.password = password self.token = token - self.profile = profile if profile is not None else 'default' - self.driver = driver if driver is not None else 'datashield_opal.OpalDriver' + self.profile = profile if profile is not None else "default" + self.driver = driver if driver is not None else "datashield_opal.OpalDriver" + class DSResult: """ @@ -32,7 +43,7 @@ def is_completed(self) -> bool: wait for the completion, immediate response is expected. Once the result is identified as being completed, the raw result the operation can be get directly. """ - raise NotImplementedError('DSResult function not available') + raise NotImplementedError("DSResult function not available") def fetch(self) -> any: """ @@ -40,7 +51,7 @@ def fetch(self) -> any: run asynchronously, in which case it is a one-shot call. When the assignment or aggregation operation was not asynchronous, the result is wrapped in the object and can be fetched multiple times. """ - raise NotImplementedError('DSResult function not available') + raise NotImplementedError("DSResult function not available") class DSConnection: @@ -56,7 +67,7 @@ def list_tables(self) -> list: """ List available table names from the data repository. """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def has_table(self, name: str) -> bool: """ @@ -66,13 +77,13 @@ def has_table(self, name: str) -> bool: ---------- :param name: The name of the table to check """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def list_resources(self) -> list: """ List available resource names from the data repository. """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def has_resource(self, name: str) -> bool: """ @@ -80,15 +91,22 @@ def has_resource(self, name: str) -> bool: :param name: The name of the resource to check """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") # # Assign # - def assign_table(self, symbol: str, table: str, variables: list = None, - missings: bool = False, identifiers: str = None, - id_name: str = None, asynchronous: bool = True) -> DSResult: + def assign_table( + self, + symbol: str, + table: str, + variables: list = None, + missings: bool = False, + identifiers: str = None, + id_name: str = None, + asynchronous: bool = True, + ) -> DSResult: """ Assign a data table from the data repository to a symbol in the DataSHIELD R session. @@ -96,7 +114,7 @@ def assign_table(self, symbol: str, table: str, variables: list = None, :param table: The name of the table to assign :param asynchronous: Whether the operation is asynchronous (if supported by the DataSHIELD server) """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def assign_resource(self, symbol: str, resource: str, asynchronous: bool = True) -> DSResult: """ @@ -106,7 +124,7 @@ def assign_resource(self, symbol: str, resource: str, asynchronous: bool = True) :param resource: The name of the resource to assign :param asynchronous: Whether the operation is asynchronous (if supported by the DataSHIELD server) """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def assign_expr(self, symbol: str, expr: str, asynchronous: bool = True) -> DSResult: """ @@ -116,7 +134,7 @@ def assign_expr(self, symbol: str, expr: str, asynchronous: bool = True) -> DSRe :param expr: The R expression to evaluate and which result will be assigned :param asynchronous: Whether the operation is asynchronous (if supported by the DataSHIELD server) """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") # # Aggregate @@ -130,7 +148,7 @@ def aggregate(self, expr: str, asynchronous: bool = True) -> DSResult: :param expr: The R expression to evaluate and which result will be returned :param asynchronous: Whether the operation is asynchronous (if supported by the DataSHIELD server) """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") # # Symbols @@ -140,7 +158,7 @@ def list_symbols(self) -> list: """ After assignments have been performed, some symbols live in the DataSHIELD R session on the server side. """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def rm_symbol(self, name: str) -> None: """ @@ -148,7 +166,7 @@ def rm_symbol(self, name: str) -> None: :param name: The name of symbol to remove """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") # # DataSHIELD config @@ -158,21 +176,21 @@ def list_profiles(self) -> list: """ List available DataSHIELD profile names in the data repository. """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") - def list_methods(self, type: str = 'aggregate') -> list: + def list_methods(self, type: str = "aggregate") -> list: """ Get the list of DataSHIELD methods that have been configured on the remote data repository. :param type: The type of method, either "aggregate" (default) or "assign" """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def list_packages(self) -> list: """ Get the list of DataSHIELD packages with their version, that have been configured on the remote data repository. """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") # # Workspaces @@ -182,7 +200,7 @@ def list_workspaces(self) -> list: """ Get the list of DataSHIELD workspaces, that have been saved on the remote data repository. """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def save_workspace(self, name: str) -> list: """ @@ -190,7 +208,7 @@ def save_workspace(self, name: str) -> list: :param name: The name of the workspace """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def restore_workspace(self, name: str) -> list: """ @@ -199,7 +217,7 @@ def restore_workspace(self, name: str) -> list: :param name: The name of the workspace """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def rm_workspace(self, name: str) -> list: """ @@ -208,7 +226,7 @@ def rm_workspace(self, name: str) -> list: :param name: The name of the workspace """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") # # Utils @@ -222,7 +240,7 @@ def is_async(self) -> dict: aggregation operation ('aggregate'), table assignment operation ('assign_table'), resource assignment operation ('assign_resource') and expression assignment operation ('assign_expr'). """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def keep_alive(self) -> None: """ @@ -230,13 +248,13 @@ def keep_alive(self) -> None: idle connections alive while others are working. Any communication failure must be silently processed. """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") def disconnect(self) -> None: """ This closes the connection, discards all pending work, and frees resources (e.g., memory, sockets). """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") class DSDriver: @@ -252,7 +270,7 @@ def new_connection(cls, args: DSLoginInfo, restore: str = None) -> DSConnection: :param args: The connection arguments, as a DSLoginInfo object :param restore: The workspace name to be restored """ - raise NotImplementedError('DSConnection function not available') + raise NotImplementedError("DSConnection function not available") @classmethod def load_class(cls, name: str) -> any: @@ -262,24 +280,29 @@ def load_class(cls, name: str) -> any: :param name: The driver class name :return: The class of the driver on which the ``new_connection()`` function will be called """ - names = name.split('.') + names = name.split(".") className = names.pop() - moduleName = '.'.join(names) + moduleName = ".".join(names) return getattr(importlib.import_module(moduleName), className) + class DSError(Exception): """ DataSHIELD error report. """ + def __init__(self, message: str = None): super().__init__(message) self.message = message def get_error(self) -> dict: + """Get the error details as a named list with keys 'message', 'code' and 'details'.""" pass def is_client_error(self) -> bool: + """Get whether the error is a client error (e.g., invalid request, authentication failure, etc.).""" pass - def is_client_error(self) -> bool: - pass \ No newline at end of file + def is_server_error(self) -> bool: + """Get whether the error is a server error (e.g., internal error, service unavailable, etc.).""" + pass diff --git a/pyproject.toml b/pyproject.toml index 5d24fd2..617da44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,37 @@ [project] name = "datashield" -version = "0.2.0" +version = "1.0.0" description = "DataSHIELD Client Interface in Python." -authors = [{name = "Yannick Marcon", email = "yannick.marcon@obiba.org"}] -maintainers = [{name = "Yannick Marcon", email = "yannick.marcon@obiba.org"}] +authors = [ + {name = "Yannick Marcon", email = "yannick.marcon@obiba.org"} +] +maintainers = [ + {name = "Yannick Marcon", email = "yannick.marcon@obiba.org"} +] license = {text = "LGPL"} readme = "README.md" requires-python = ">=3.10" +keywords = ["datashield", "data"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] dependencies = [] +[project.optional-dependencies] +test = [ + "pytest>=7.2.2", +] +dev = [ + "ruff>=0.10.0", +] + [project.urls] Homepage = "https://www.datashield.org" Repository = "https://github.com/datashield/datashield-python" @@ -18,3 +41,6 @@ Documentation = "https://datashield.github.io/datashield-python" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["datashield"] diff --git a/tests/test_logins.py b/tests/test_logins.py index 92d76d9..912976e 100644 --- a/tests/test_logins.py +++ b/tests/test_logins.py @@ -1,47 +1,47 @@ +import pytest from datashield import DSLoginBuilder + def test_logins(): - builder = DSLoginBuilder().add('server1', 'https://opal-demo.obba.org', 'dsuser', 'P@ssw0rd').add('server2', 'https://demo.datashield.org', token = '1234abcd', profile = 'omics') + builder = ( + DSLoginBuilder() + .add("server1", "https://opal-demo.obba.org", "dsuser", "P@ssw0rd") + .add("server2", "https://demo.datashield.org", token="1234abcd", profile="omics") + ) logins = builder.build() assert len(logins) == 2 - assert logins[0].name == 'server1' + assert logins[0].name == "server1" assert logins[0].user is not None assert logins[0].password is not None assert logins[0].token is None - assert logins[0].profile == 'default' - assert logins[0].driver == 'datashield_opal.OpalDriver' - assert logins[1].name == 'server2' + assert logins[0].profile == "default" + assert logins[0].driver == "datashield_opal.OpalDriver" + assert logins[1].name == "server2" assert logins[1].user is None assert logins[1].password is None assert logins[1].token is not None - assert logins[1].profile == 'omics' - builder.remove('server1') + assert logins[1].profile == "omics" + builder.remove("server1") logins = builder.build() assert len(logins) == 1 - assert logins[0].name == 'server2' - + assert logins[0].name == "server2" + + def test_login_validations(): # name not missing - try: - DSLoginBuilder().add(None, 'https://opal-demo.obba.org', 'dsuser', 'P@ssw0rd') - assert False - except ValueError as e: - assert True + with pytest.raises(ValueError, match="Server name is missing"): + DSLoginBuilder().add(None, "https://opal-demo.obba.org", "dsuser", "P@ssw0rd") + # url not missing - try: - DSLoginBuilder().add('server1', None, 'dsuser', 'P@ssw0rd') - assert False - except ValueError as e: - assert True + with pytest.raises(ValueError, match="Server URL is missing"): + DSLoginBuilder().add("server1", None, "dsuser", "P@ssw0rd") + # name is unique - try: - DSLoginBuilder().add('server1', 'https://opal-demo.obba.org', 'dsuser', 'P@ssw0rd').add('server1', 'https://demo.datashield.org', token = '1234abcd') - assert False - except ValueError as e: - assert True - # either user and token is missing - try: - DSLoginBuilder().add('server1', 'https://opal-demo.obba.org').add('server2', 'https://demo.datashield.org') - assert False - except ValueError as e: - assert True \ No newline at end of file + with pytest.raises(ValueError, match="Server name must be unique"): + DSLoginBuilder().add("server1", "https://opal-demo.obba.org", "dsuser", "P@ssw0rd").add( + "server1", "https://demo.datashield.org", token="1234abcd" + ) + + # either user and token is missing + with pytest.raises(ValueError, match="Either user or token must be provided"): + DSLoginBuilder().add("server1", "https://opal-demo.obba.org").add("server2", "https://demo.datashield.org") diff --git a/uv.lock b/uv.lock index b4fde61..cfbd5c4 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,185 @@ version = 1 revision = 3 requires-python = ">=3.10" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "datashield" -version = "0.2.0" +version = "1.0.0" source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "ruff" }, +] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'test'", specifier = ">=7.2.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.10.0" }, +] +provides-extras = ["test", "dev"] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +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 = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, + { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, + { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, + { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, + { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +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" }, +]