diff --git a/ansible-runner/project b/ansible-runner/project index 9e3cd00..5f3c7c6 160000 --- a/ansible-runner/project +++ b/ansible-runner/project @@ -1 +1 @@ -Subproject commit 9e3cd0040a6a03eec7a7648281b818d00058115a +Subproject commit 5f3c7c6741626a859ca509af8be55dc0ca340c07 diff --git a/coact-facility-overage-daemon.sh b/coact-facility-overage-daemon.sh index 7124ea1..8386a30 100755 --- a/coact-facility-overage-daemon.sh +++ b/coact-facility-overage-daemon.sh @@ -5,6 +5,6 @@ export SDF_COACT_URI=coact.slac.stanford.edu:443/graphql-service while [ 1 ]; do date - ./venv/bin/python3 ./sdf_click.py coact overage --password-file ./etc/.secrets/password --windows 5 --windows 15 --windows 60 --windows 180 --windows 1440 --verbose --influxdb-url=https://influxdb.slac.stanford.edu:443 + ./venv/bin/python3 ./sdf_click.py coact overage --password-file ./etc/.secrets/password --grouper-password-file ./etc/.secrets/grouper_password --windows 5 --windows 15 --windows 60 --windows 180 --windows 1440 --verbose --influxdb-url=https://influxdb.slac.stanford.edu:443 sleep 300 done diff --git a/coact-reporegistration-daemon.sh b/coact-reporegistration-daemon.sh index 3dfaa86..0204091 100755 --- a/coact-reporegistration-daemon.sh +++ b/coact-reporegistration-daemon.sh @@ -1,6 +1,6 @@ #!/bin/sh while [ 1 ]; do - SDF_COACT_URI=coact.slac.stanford.edu/graphql-service ./venv/bin/python3 ./sdf_click.py coactd reporegistration --username=sdf-bot --password-file=etc/.secrets/password -vv + SDF_COACT_URI=coact.slac.stanford.edu/graphql-service ./venv/bin/python3 ./sdf_click.py coactd reporegistration --username=sdf-bot --password-file=etc/.secrets/password --grouper-password-file ./etc/.secrets/grouper_password -vv sleep 1 done diff --git a/coact-userregistration-daemon.sh b/coact-userregistration-daemon.sh index 6e29712..4e4f3af 100755 --- a/coact-userregistration-daemon.sh +++ b/coact-userregistration-daemon.sh @@ -1,6 +1,6 @@ #!/bin/sh while [ 1 ]; do - SDF_COACT_URI=coact.slac.stanford.edu:443/graphql-service ./venv/bin/python3 ./sdf_click.py coactd userregistration --username sdf-bot --password-file ./etc/.secrets/password -vv + SDF_COACT_URI=coact.slac.stanford.edu:443/graphql-service ./venv/bin/python3 ./sdf_click.py coactd userregistration --username sdf-bot --password-file ./etc/.secrets/password --grouper-password-file ./etc/.secrets/grouper_password -vv sleep 5 done diff --git a/modules/coactd.py b/modules/coactd.py index 94f226c..de7bad4 100644 --- a/modules/coactd.py +++ b/modules/coactd.py @@ -154,12 +154,13 @@ class Registration(GraphQlSubscriber, AnsibleRunner): } """ - def __init__(self, username: str, password_file: str, client_name: str, dry_run: bool = False): + def __init__(self, username: str, password_file: str, client_name: str, dry_run: bool = False, grouper_password_file: str = None): self.logger = logger self.username = username self.password_file = password_file self.client_name = client_name self.dry_run = dry_run + self.grouper_password_file = grouper_password_file def run(self): """Main entry point - connect and process subscription requests.""" @@ -531,8 +532,50 @@ def do_new_repo( if repo_allocation_end_delta is None: repo_allocation_end_delta = pdl.duration(years=5) + # For CryoEM repos (ct* / ce*), create a POSIX group via Grouper + repo_gid = None + grouper_name = "" + uses_grouper = ( + facility.lower() == 'cryoem' + and (repo.lower().startswith('ct') or repo.lower().startswith('ce')) + ) + if uses_grouper: + grouper_name = f"sdf-{facility.lower()}-{repo.lower()}" + try: + if not self.grouper_password_file: + raise ValueError("Grouper password file must be provided for CryoEM ct/ce repos") + grouper_kwargs = dict( + grouper_name=grouper_name, + state="present", + grouper_description=f"POSIX group for {facility} {repo} repository access", + grouper_password_file=self.grouper_password_file + ) + grouper_runner = self.run_playbook("coact/grouper.yml", **grouper_kwargs) + repo_gid, __dict__ = self.extract_grouper_values( + grouper_runner, + default_group_name=grouper_name + ) + self.logger.info( + f"Retrieved grouper values for {facility}:{repo}: gid={repo_gid}" + ) + if repo_gid is not None: + self.logger.info(f"Retrieved repo GID for {facility}:{repo}: {repo_gid}") + else: + self.logger.warning(f"No GID found in grouper playbook results for {facility}:{repo}") + raise RuntimeError("Unable to fetch gid from grouper.") + except Exception as e: + self.logger.warning(f"Failed to create grouper POSIX group for {facility}:{repo}: {e}") + raise + # run the facility tasks for this repo - runner = self.run_playbook("coact/add_repo.yaml", facility=facility, repo=repo) + self.run_playbook( + "coact/add_repo.yaml", + facility=facility, + repo=repo, + repo_principal=principal, + gidNumber=repo_gid, + groupName=grouper_name + ) leaders = [principal] users = [principal] @@ -558,18 +601,105 @@ def do_new_repo( repo_upserted = self.back_channel.execute(REPO_UPSERT_GQL, repo_create_req) repo_id = repo_upserted['repoUpsert']['Id'] - feature_req = {'repo': {'Id': repo_id}} + # Create a parameterized feature upsert mutation FEATURE_UPSERT_GQL = gql(""" - mutation repoUpsert($repo: RepoInput! ) { - repoUpsertFeature(repo: $repo, feature: { name: "slurm", state: true, options: [] }) { + mutation repoUpsert($repo: RepoInput!, $feature: RepoFeatureInput!) { + repoUpsertFeature(repo: $repo, feature: $feature) { Id } } """) - feature_upserted = self.back_channel.execute(FEATURE_UPSERT_GQL, feature_req) + + # Create slurm feature + slurm_feature_req = { + 'repo': {'Id': repo_id}, + 'feature': {'name': 'slurm', 'state': True, 'options': []} + } + self.back_channel.execute(FEATURE_UPSERT_GQL, slurm_feature_req) + + # Create posixgroup feature if GID was obtained from grouper + if repo_gid is not None: + posixgroup_options = [json.dumps({ + "name": grouper_name, + "gidNumber": int(repo_gid) + })] + + posixgroup_feature_req = { + 'repo': {'Id': repo_id}, + 'feature': { + 'name': 'posixgroup', + 'state': True, + 'options': posixgroup_options + } + } + + try: + self.back_channel.execute(FEATURE_UPSERT_GQL, posixgroup_feature_req) + self.logger.info(f"Created posixgroup feature for {facility}:{repo} with GID {repo_gid}") + except Exception as e: + self.logger.warning(f"Failed to create posixgroup feature for {facility}:{repo}: {e}") return True + def extract_grouper_values(self, runner: ansible_runner.runner.Runner, default_group_name: str = ""): + """Extract gid and group name from grouper playbook events. + + Ansible callbacks can emit task results in different shapes depending on + task type (`s3df_grouper`, `debug`, `set_fact`). This method scans all + events and picks the first non-empty gid and group name found. + """ + + def _clean(v): + if v is None: + return None + if isinstance(v, str): + v = v.strip() + return v if v else None + return str(v) + + gid = None + group_name = _clean(default_group_name) + + for event_data in self.playbook_events(runner): + res = event_data.get('res', None) + if not isinstance(res, dict): + continue + + facts = res.get('ansible_facts', {}) + facts = facts if isinstance(facts, dict) else {} + msg = res.get('msg', None) + msg = msg if isinstance(msg, dict) else {} + + candidates_gid = [ + res.get('gid', None), + facts.get('gid', None), + msg.get('gid', None), + (res.get('group', {}) if isinstance(res.get('group', {}), dict) else {}).get('idIndex', None), + ] + candidates_group_name = [ + res.get('group_name', None), + facts.get('group_name', None), + msg.get('group_name', None), + (res.get('group', {}) if isinstance(res.get('group', {}), dict) else {}).get('name', None), + ] + + for candidate in candidates_gid: + cleaned = _clean(candidate) + if cleaned is not None: + gid = cleaned + break + + for candidate in candidates_group_name: + cleaned = _clean(candidate) + if cleaned is not None: + group_name = cleaned + break + + if gid is not None and group_name is not None: + break + + return gid, group_name or "" + def upsert_repo_compute_allocation( self, repo_id: str, @@ -906,8 +1036,14 @@ def do_feature(self, repo, facility, dry_run: bool = False) -> bool: @coactd.command(name='reporegistration') @common_options @registration_options +@click.option( + '--grouper-password-file', + default=None, + type=click.Path(exists=True), + help='Path to file containing the Grouper service account password' +) @click.pass_context -def repo_registration(ctx, verbose, username, password_file, client_name, dry_run): +def repo_registration(ctx, verbose, username, password_file, client_name, dry_run, grouper_password_file): """Workflow for repository maintenance. Handles NewRepo, RepoMembership, RepoRemoveUser, RepoChangeComputeRequirement, @@ -920,7 +1056,8 @@ def repo_registration(ctx, verbose, username, password_file, client_name, dry_ru username=username, password_file=password_file, client_name=client_name or 'sdf-bot-RepoRegistration', - dry_run=dry_run + dry_run=dry_run, + grouper_password_file=grouper_password_file ) handler.run() diff --git a/pyproject.toml b/pyproject.toml index 29f26e6..42c3731 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.12,<3.13" dependencies = [ "cliff==3.10.1", "click>=8.0.0", - "gql[all]>=3.4.1", + "gql[all]>=3.4.1,<4", "ansible-runner==2.3.1", "Jinja2>=3.1.2", "pendulum>=3.2.0", diff --git a/tests/test_repo_registration_gid.py b/tests/test_repo_registration_gid.py new file mode 100644 index 0000000..659d07d --- /dev/null +++ b/tests/test_repo_registration_gid.py @@ -0,0 +1,168 @@ +""" +Unit tests for repository registration GID/group name handling. + +Tests the new functionality in RepoRegistration.do_new_repo() that extracts +GID information from Ansible playbook results and creates posixgroup features +for facilities that use grouper (currently only CryoEM). +""" + +import sys +from unittest.mock import Mock, patch +import pytest + +# Mock ansible_runner module to avoid all sdf-ansible dependencies +sys.modules['ansible_runner'] = Mock() + +from modules.coactd import RepoRegistration + + +class TestRepoRegistrationGID: + """Test GID/group name handling in repository registration.""" + + @pytest.fixture + def repo_registration(self) -> RepoRegistration: + """Create a RepoRegistration instance with mocked dependencies.""" + with patch('modules.coactd.GraphQlSubscriber.__init__'), \ + patch('modules.coactd.AnsibleRunner.__init__'): + reg = RepoRegistration( + username='test-user', + password_file='/tmp/test-password', + client_name='test-client', + grouper_password_file='/tmp/test-grouper-password' + ) + reg.logger = Mock() + reg.back_channel = Mock() + reg.run_playbook = Mock() + reg.playbook_task_res = Mock() + return reg + + @pytest.fixture + def mock_ansible_runner(self) -> Mock: + """Mock ansible runner with realistic structure.""" + runner = Mock() + runner.events = [] + return runner + + @pytest.fixture + def sample_gid_facts(self) -> dict: + """Sample ansible facts returned by grouper.yml 'Export grouper params' task.""" + return { + 'ansible_facts': { + 'gid': '12345', + } + } + + def test_gid_extraction_success_cryoem(self, repo_registration: RepoRegistration, mock_ansible_runner: Mock, sample_gid_facts: dict): + """Test successful GID extraction for CryoEM facility (ct* repo triggers grouper).""" + # Setup — run_playbook called twice: add_repo.yaml then grouper.yml + repo_registration.run_playbook.return_value = mock_ansible_runner + # Mock extract_grouper_values to return the GID + repo_registration.extract_grouper_values = Mock(return_value=('12345', 'sdf-cryoem-ct-test')) + repo_registration.back_channel.execute.side_effect = [ + {'repoUpsert': {'Id': 'repo-456'}}, + {'repoUpsertFeature': {'Id': 'feature-slurm'}}, + {'repoUpsertFeature': {'Id': 'feature-posix'}} + ] + + # Execute — repo starts with 'ct' to satisfy grouper condition + result = repo_registration.do_new_repo( + repo='ct-test', + facility='cryoem', + principal='cryo-user' + ) + + # Verify + assert result is True + # grouper.yml should have been invoked + repo_registration.run_playbook.assert_any_call( + 'coact/grouper.yml', + grouper_name='sdf-cryoem-ct-test', + state='present', + grouper_description='POSIX group for cryoem ct-test repository access', + grouper_password_file='/tmp/test-grouper-password', + ) + repo_registration.logger.info.assert_any_call( + "Retrieved repo GID for cryoem:ct-test: 12345" + ) + + def test_gid_extraction_empty_facts(self, repo_registration: RepoRegistration, mock_ansible_runner: Mock): + """Test handling when grouper playbook returns empty ansible_facts (no GID found).""" + # Setup + repo_registration.run_playbook.return_value = mock_ansible_runner + # Mock extract_grouper_values to return None for GID (empty facts scenario) + repo_registration.extract_grouper_values = Mock(return_value=(None, 'sdf-cryoem-ct-repo')) + repo_registration.back_channel.execute.side_effect = [ + {'repoUpsert': {'Id': 'repo-123'}}, + {'repoUpsertFeature': {'Id': 'feature-slurm'}} + ] + + # Execute — repo starts with 'ct' to trigger grouper + # This should now raise a RuntimeError when GID is None + with pytest.raises(RuntimeError, match="Unable to fetch gid from grouper"): + repo_registration.do_new_repo( + repo='ct-repo', + facility='cryoem', + principal='test-user' + ) + + # Verify the exception was logged by the outer exception handler + repo_registration.logger.warning.assert_called_with( + "Failed to create grouper POSIX group for cryoem:ct-repo: Unable to fetch gid from grouper." + ) + + def test_non_grouper_facility_skips_gid(self, repo_registration, mock_ansible_runner): + """Test that non-cryoem facilities skip grouper entirely.""" + # Setup + repo_registration.run_playbook.return_value = mock_ansible_runner + repo_registration.back_channel.execute.side_effect = [ + {'repoUpsert': {'Id': 'repo-123'}}, + {'repoUpsertFeature': {'Id': 'feature-slurm'}} + ] + + # Execute + result = repo_registration.do_new_repo( + repo='test-repo', + facility='OTHER', # Not CryoEM + principal='test-user' + ) + + # Verify + assert result is True + + # Only add_repo.yaml should run — grouper.yml should NOT be called + # The new implementation passes repo_principal, gidNumber=None, and groupName='' + repo_registration.run_playbook.assert_called_once_with( + 'coact/add_repo.yaml', facility='OTHER', repo='test-repo', repo_principal='test-user', gidNumber=None, groupName='' + ) + # extract_grouper_values should not be called for non-grouper facilities + if hasattr(repo_registration, 'extract_grouper_values') and isinstance(repo_registration.extract_grouper_values, Mock): + repo_registration.extract_grouper_values.assert_not_called() + + # Should only create slurm feature (2 back_channel calls: repoUpsert + feature) + assert repo_registration.back_channel.execute.call_count == 2 + + def test_cryoem_non_ct_ce_repo_skips_grouper(self, repo_registration, mock_ansible_runner): + """Test that cryoem repos not starting with ct/ce skip grouper.""" + # Setup + repo_registration.run_playbook.return_value = mock_ansible_runner + repo_registration.back_channel.execute.side_effect = [ + {'repoUpsert': {'Id': 'repo-123'}}, + {'repoUpsertFeature': {'Id': 'feature-slurm'}} + ] + + # Execute — repo does not start with ct or ce + result = repo_registration.do_new_repo( + repo='other-repo', + facility='cryoem', + principal='test-user' + ) + + # Verify + assert result is True + # The new implementation passes repo_principal, gidNumber=None, and groupName='' + repo_registration.run_playbook.assert_called_once_with( + 'coact/add_repo.yaml', facility='cryoem', repo='other-repo', repo_principal='test-user', gidNumber=None, groupName='' + ) + # extract_grouper_values should not be called since this repo doesn't match the pattern + if hasattr(repo_registration, 'extract_grouper_values') and isinstance(repo_registration.extract_grouper_values, Mock): + repo_registration.extract_grouper_values.assert_not_called() diff --git a/uv.lock b/uv.lock index f24215a..c1c5e12 100644 --- a/uv.lock +++ b/uv.lock @@ -2,15 +2,6 @@ version = 1 revision = 3 requires-python = "==3.12.*" -[[package]] -name = "aiofiles" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, -] - [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -263,7 +254,7 @@ wheels = [ [[package]] name = "gql" -version = "4.0.0" +version = "3.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -271,14 +262,13 @@ dependencies = [ { name = "graphql-core" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/9f/cf224a88ed71eb223b7aa0b9ff0aa10d7ecc9a4acdca2279eb046c26d5dc/gql-4.0.0.tar.gz", hash = "sha256:f22980844eb6a7c0266ffc70f111b9c7e7c7c13da38c3b439afc7eab3d7c9c8e", size = 215644, upload-time = "2025-08-17T14:32:35.397Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/ed/44ffd30b06b3afc8274ee2f38c3c1b61fe4740bf03d92083e43d2c17ac77/gql-3.5.3.tar.gz", hash = "sha256:393b8c049d58e0d2f5461b9d738a2b5f904186a40395500b4a84dd092d56e42b", size = 180504, upload-time = "2025-05-20T12:34:08.954Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/94/30bbd09e8d45339fa77a48f5778d74d47e9242c11b3cd1093b3d994770a5/gql-4.0.0-py3-none-any.whl", hash = "sha256:f3beed7c531218eb24d97cb7df031b4a84fdb462f4a2beb86e2633d395937479", size = 89900, upload-time = "2025-08-17T14:32:34.029Z" }, + { url = "https://files.pythonhosted.org/packages/cb/50/2f4e99b216821ac921dbebf91c644ba95818f5d07857acadee17220221f3/gql-3.5.3-py2.py3-none-any.whl", hash = "sha256:e1fcbde2893fcafdd28114ece87ff47f1cc339a31db271fc4e1d528f5a1d4fbc", size = 74348, upload-time = "2025-05-20T12:34:07.687Z" }, ] [package.optional-dependencies] all = [ - { name = "aiofiles" }, { name = "aiohttp" }, { name = "botocore" }, { name = "httpx" }, @@ -289,11 +279,11 @@ all = [ [[package]] name = "graphql-core" -version = "3.2.8" +version = "3.2.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/c5/36aa96205c3ecbb3d34c7c24189e4553c7ca2ebc7e1dd07432339b980272/graphql_core-3.2.8.tar.gz", hash = "sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3", size = 513181, upload-time = "2026-03-05T19:55:37.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/16/7574029da84834349b60ed71614d66ca3afe46e9bf9c7b9562102acb7d4f/graphql_core-3.2.6.tar.gz", hash = "sha256:c08eec22f9e40f0bd61d805907e3b3b1b9a320bc606e23dc145eebca07c8fbab", size = 505353, upload-time = "2025-01-26T16:36:27.374Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/41/cb887d9afc5dabd78feefe6ccbaf83ff423c206a7a1b7aeeac05120b2125/graphql_core-3.2.8-py3-none-any.whl", hash = "sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c", size = 207349, upload-time = "2026-03-05T19:55:35.911Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4f/7297663840621022bc73c22d7d9d80dbc78b4db6297f764b545cd5dd462d/graphql_core-3.2.6-py3-none-any.whl", hash = "sha256:78b016718c161a6fb20a7d97bbf107f331cd1afe53e45566c59f776ed7f0b45f", size = 203416, upload-time = "2025-01-26T16:36:24.868Z" }, ] [[package]] @@ -756,7 +746,7 @@ requires-dist = [ { name = "ansible-runner", specifier = "==2.3.1" }, { name = "click", specifier = ">=8.0.0" }, { name = "cliff", specifier = "==3.10.1" }, - { name = "gql", extras = ["all"], specifier = ">=3.4.1" }, + { name = "gql", extras = ["all"], specifier = ">=3.4.1,<4" }, { name = "jinja2", specifier = ">=3.1.2" }, { name = "loguru", specifier = ">=0.7.0" }, { name = "pendulum", specifier = ">=3.2.0" }, @@ -843,22 +833,11 @@ wheels = [ [[package]] name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +version = "11.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/3b/2ed38e52eed4cf277f9df5f0463a99199a04d9e29c9e227cfafa57bd3993/websockets-11.0.3.tar.gz", hash = "sha256:88fc51d9a26b10fc331be344f1781224a375b78488fc343620184e95a4b27016", size = 104235, upload-time = "2023-05-07T14:25:20.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/96/9d5749106ff57629b54360664ae7eb9afd8302fad1680ead385383e33746/websockets-11.0.3-py3-none-any.whl", hash = "sha256:6681ba9e7f8f3b19440921e99efbb40fc89f26cd71bf539e45d8c8a25c976dc6", size = 118056, upload-time = "2023-05-07T14:25:18.508Z" }, ] [[package]]