From b284aa8dc0ba096f82380ba78b235901d14e652d Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 15:34:01 -0500 Subject: [PATCH 01/19] Create .tool-versions --- .tool-versions | 1 + 1 file changed, 1 insertion(+) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..409e34c --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.12.13 From cbe61b8d0d4c40a06a52f011f3b4232be0499db5 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:03:27 -0500 Subject: [PATCH 02/19] fix existing tests --- tests/test_accessgrid.py | 60 +++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index 5f86639..e5e3701 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -56,7 +56,7 @@ def test_provision_card(self, mock_request, client, mock_response, mock_provisio mock_request.assert_called_once() call_args = mock_request.call_args[1] assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/api/v1/nfc_keys/issue" + assert call_args['url'] == f"{client.base_url}/v1/key-cards" assert call_args['json'] == mock_provision_params assert call_args['headers']['X-ACCT-ID'] == MOCK_ACCOUNT_ID assert 'X-PAYLOAD-SIG' in call_args['headers'] @@ -76,20 +76,20 @@ def test_provision_card_error(self, mock_request, client, mock_provision_params) @patch('requests.request') def test_update_card(self, mock_request, client, mock_response): mock_request.return_value = mock_response + card_id = '0xc4rd1d' update_params = { - 'card_id': '0xc4rd1d', 'employee_id': '987654321', 'full_name': 'Updated Employee Name', 'classification': 'contractor', 'expiration_date': '2025-02-22T21:04:03.664Z' } - - card = client.access_cards.update(**update_params) - + + card = client.access_cards.update(card_id, **update_params) + mock_request.assert_called_once() call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/api/v1/nfc_keys/update" + assert call_args['method'] == 'PATCH' + assert call_args['url'] == f"{client.base_url}/v1/key-cards/{card_id}" assert call_args['json'] == update_params @patch('requests.request') @@ -101,23 +101,27 @@ def test_manage_operations(self, mock_request, client, mock_response): client.access_cards.suspend(card_id) call_args = mock_request.call_args[1] assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/api/v1/nfc_keys/manage" - assert call_args['json'] == {'card_id': card_id, 'manage_action': 'suspend'} + assert call_args['url'] == f"{client.base_url}/v1/key-cards/{card_id}/suspend" + assert call_args['json'] is None # Test resume client.access_cards.resume(card_id) call_args = mock_request.call_args[1] - assert call_args['json'] == {'card_id': card_id, 'manage_action': 'resume'} + assert call_args['method'] == 'POST' + assert call_args['url'] == f"{client.base_url}/v1/key-cards/{card_id}/resume" + assert call_args['json'] is None # Test unlink client.access_cards.unlink(card_id) call_args = mock_request.call_args[1] - assert call_args['json'] == {'card_id': card_id, 'manage_action': 'unlink'} + assert call_args['method'] == 'POST' + assert call_args['url'] == f"{client.base_url}/v1/key-cards/{card_id}/unlink" + assert call_args['json'] is None @patch('requests.request') def test_list_keys(self, mock_request, client, mock_response): mock_response.json.return_value = { - 'items': [ + 'keys': [ { 'id': 'key1', 'state': 'active', @@ -136,23 +140,26 @@ def test_list_keys(self, mock_request, client, mock_response): } mock_request.return_value = mock_response template_id = '0xd3adb00b5' - + # Test list with template_id only keys = client.access_cards.list(template_id=template_id) assert len(keys) == 2 assert keys[0].id == 'key1' assert keys[0].state == 'active' assert keys[0].full_name == 'John Doe' - + call_args = mock_request.call_args[1] assert call_args['method'] == 'GET' assert call_args['url'] == f"{client.base_url}/v1/key-cards" - assert call_args['params'] == {'template_id': template_id, 'sig_payload': '{}'} - + assert call_args['params']['template_id'] == template_id + assert 'sig_payload' in call_args['params'] + # Test list with template_id and state keys = client.access_cards.list(template_id=template_id, state='active') call_args = mock_request.call_args[1] - assert call_args['params'] == {'template_id': template_id, 'state': 'active', 'sig_payload': '{}'} + assert call_args['params']['template_id'] == template_id + assert call_args['params']['state'] == 'active' + assert 'sig_payload' in call_args['params'] class TestConsole: @pytest.fixture @@ -187,7 +194,7 @@ def test_create_template(self, mock_request, client, mock_response, mock_templat call_args = mock_request.call_args[1] assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/api/v1/enterprise/create_template" + assert call_args['url'] == f"{client.base_url}/v1/console/card-templates" assert call_args['json'] == mock_template_params @patch('requests.request') @@ -199,10 +206,10 @@ def test_read_template(self, mock_request, client, mock_response): call_args = mock_request.call_args[1] assert call_args['method'] == 'GET' - assert call_args['url'] == f"{client.base_url}/api/v1/enterprise/read_template/{template_id}" + assert call_args['url'] == f"{client.base_url}/v1/console/card-templates/{template_id}" @patch('requests.request') - def test_event_log(self, mock_request, client, mock_response): + def test_get_logs(self, mock_request, client, mock_response): mock_request.return_value = mock_response template_id = '0xd3adb00b5' filters = { @@ -211,10 +218,13 @@ def test_event_log(self, mock_request, client, mock_response): 'end_date': '2025-02-01T00:00:00Z', 'event_type': 'install' } - - events = client.console.event_log(template_id, filters=filters) - + + events = client.console.get_logs(template_id, **filters) + call_args = mock_request.call_args[1] assert call_args['method'] == 'GET' - assert call_args['url'] == f"{client.base_url}/api/v1/enterprise/logs/{template_id}" - assert call_args['params'] == {'filters': filters, 'page': 1, 'per_page': 50} \ No newline at end of file + assert call_args['url'] == f"{client.base_url}/v1/console/card-templates/{template_id}/logs" + assert call_args['params']['device'] == 'mobile' + assert call_args['params']['start_date'] == '2025-01-01T00:00:00Z' + assert call_args['params']['end_date'] == '2025-02-01T00:00:00Z' + assert call_args['params']['event_type'] == 'install' From a023c448a50f3c0b9b8eee1e00097edba25f9b2c Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:06:51 -0500 Subject: [PATCH 03/19] test_get_card --- tests/test_accessgrid.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index e5e3701..ab29c6c 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -73,6 +73,31 @@ def test_provision_card_error(self, mock_request, client, mock_provision_params) with pytest.raises(AccessGridError, match='API request failed: Invalid template ID'): client.access_cards.provision(**mock_provision_params) + @patch('requests.request') + def test_get_card(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + 'id': 'card-123', + 'full_name': 'John Doe', + 'state': 'active', + 'install_url': 'https://example.com/install/card-123', + 'card_template_id': 'tmpl-456', + 'expiration_date': '2025-12-31' + } + mock_request.return_value = mock_resp + + card = client.access_cards.get('card-123') + + call_args = mock_request.call_args[1] + assert call_args['method'] == 'GET' + assert call_args['url'] == f"{client.base_url}/v1/key-cards/card-123" + assert card.id == 'card-123' + assert card.full_name == 'John Doe' + assert card.state == 'active' + assert card.install_url == 'https://example.com/install/card-123' + assert card.card_template_id == 'tmpl-456' + @patch('requests.request') def test_update_card(self, mock_request, client, mock_response): mock_request.return_value = mock_response From be3a86d81478fe88d19fa2c25617f920d82103c5 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:07:18 -0500 Subject: [PATCH 04/19] test access card deletion --- tests/test_accessgrid.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index ab29c6c..b49f911 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -142,6 +142,13 @@ def test_manage_operations(self, mock_request, client, mock_response): assert call_args['method'] == 'POST' assert call_args['url'] == f"{client.base_url}/v1/key-cards/{card_id}/unlink" assert call_args['json'] is None + + # Test delete + client.access_cards.delete(card_id) + call_args = mock_request.call_args[1] + assert call_args['method'] == 'POST' + assert call_args['url'] == f"{client.base_url}/v1/key-cards/{card_id}/delete" + assert call_args['json'] is None @patch('requests.request') def test_list_keys(self, mock_request, client, mock_response): From 902d8077982e7ba2bdede6d4e49ccf96113fa46d Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:07:51 -0500 Subject: [PATCH 05/19] test_list_pass_template_pairs --- tests/test_accessgrid.py | 62 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index b49f911..183503d 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -240,6 +240,68 @@ def test_read_template(self, mock_request, client, mock_response): assert call_args['method'] == 'GET' assert call_args['url'] == f"{client.base_url}/v1/console/card-templates/{template_id}" + @patch('requests.request') + def test_list_pass_template_pairs(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + 'pass_template_pairs': [ + { + 'id': 'pair-1', + 'name': 'Employee Badge', + 'created_at': '2025-01-15T10:00:00Z', + 'android_template': { + 'id': 'tmpl-android-1', + 'name': 'Android Employee Badge', + 'platform': 'google' + }, + 'ios_template': { + 'id': 'tmpl-ios-1', + 'name': 'iOS Employee Badge', + 'platform': 'apple' + } + }, + { + 'id': 'pair-2', + 'name': 'Visitor Pass', + 'created_at': '2025-02-01T12:00:00Z', + 'android_template': None, + 'ios_template': { + 'id': 'tmpl-ios-2', + 'name': 'iOS Visitor Pass', + 'platform': 'apple' + } + } + ], + 'page': 1, + 'per_page': 50 + } + mock_request.return_value = mock_resp + + result = client.console.list_pass_template_pairs(page=1, per_page=50) + + call_args = mock_request.call_args[1] + assert call_args['method'] == 'GET' + assert call_args['url'] == f"{client.base_url}/v1/console/pass-template-pairs" + assert call_args['params']['page'] == 1 + assert call_args['params']['per_page'] == 50 + + pairs = result['pass_template_pairs'] + assert len(pairs) == 2 + + # First pair — both platforms + assert pairs[0].id == 'pair-1' + assert pairs[0].name == 'Employee Badge' + assert pairs[0].android_template.id == 'tmpl-android-1' + assert pairs[0].android_template.platform == 'google' + assert pairs[0].ios_template.id == 'tmpl-ios-1' + assert pairs[0].ios_template.platform == 'apple' + + # Second pair — android_template is None + assert pairs[1].id == 'pair-2' + assert pairs[1].android_template is None + assert pairs[1].ios_template.id == 'tmpl-ios-2' + @patch('requests.request') def test_get_logs(self, mock_request, client, mock_response): mock_request.return_value = mock_response From 60c3dbbe022bb0144332c5b04867e8920d0f999d Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:19:27 -0500 Subject: [PATCH 06/19] test_update_template --- tests/test_accessgrid.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index 183503d..3323cbb 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -229,6 +229,24 @@ def test_create_template(self, mock_request, client, mock_response, mock_templat assert call_args['url'] == f"{client.base_url}/v1/console/card-templates" assert call_args['json'] == mock_template_params + @patch('requests.request') + def test_update_template(self, mock_request, client, mock_response): + mock_request.return_value = mock_response + template_id = '0xd3adb00b5' + update_params = { + 'name': 'Updated Template', + 'allow_on_multiple_devices': False, + 'watch_count': 1, + 'iphone_count': 2 + } + + template = client.console.update_template(template_id, **update_params) + + call_args = mock_request.call_args[1] + assert call_args['method'] == 'PUT' + assert call_args['url'] == f"{client.base_url}/v1/console/card-templates/{template_id}" + assert call_args['json'] == update_params + @patch('requests.request') def test_read_template(self, mock_request, client, mock_response): mock_request.return_value = mock_response From 84e937a31fba0d3b42206bc529ffd4cc0a61b15a Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:23:55 -0500 Subject: [PATCH 07/19] add tests for raising exceptions --- tests/test_accessgrid.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index 3323cbb..cd071ce 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -62,6 +62,26 @@ def test_provision_card(self, mock_request, client, mock_response, mock_provisio assert 'X-PAYLOAD-SIG' in call_args['headers'] assert call_args['headers']['Content-Type'] == 'application/json' + @patch('requests.request') + def test_provision_card_auth_error(self, mock_request, client, mock_provision_params): + error_response = Mock() + error_response.status_code = 401 + error_response.text = 'Unauthorized' + mock_request.return_value = error_response + + with pytest.raises(AuthenticationError, match='Invalid credentials'): + client.access_cards.provision(**mock_provision_params) + + @patch('requests.request') + def test_provision_card_balance_error(self, mock_request, client, mock_provision_params): + error_response = Mock() + error_response.status_code = 402 + error_response.text = 'Payment required' + mock_request.return_value = error_response + + with pytest.raises(AccessGridError, match='Insufficient account balance'): + client.access_cards.provision(**mock_provision_params) + @patch('requests.request') def test_provision_card_error(self, mock_request, client, mock_provision_params): error_response = Mock() From 47d8863df20e03dbb77b30851a7b52443f2ed286 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:26:42 -0500 Subject: [PATCH 08/19] test_issue_returns_unified_access_pass --- tests/test_accessgrid.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index cd071ce..f84204f 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -62,6 +62,42 @@ def test_provision_card(self, mock_request, client, mock_response, mock_provisio assert 'X-PAYLOAD-SIG' in call_args['headers'] assert call_args['headers']['Content-Type'] == 'application/json' + @patch('requests.request') + def test_issue_returns_unified_access_pass(self, mock_request, client, mock_provision_params): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + 'id': 'uap-1', + 'install_url': 'https://example.com/install/uap-1', + 'state': 'active', + 'status': 'issued', + 'details': [ + { + 'id': 'card-ios', + 'state': 'active', + 'full_name': 'John Doe', + 'install_url': 'https://example.com/install/card-ios' + }, + { + 'id': 'card-android', + 'state': 'active', + 'full_name': 'John Doe', + 'install_url': 'https://example.com/install/card-android' + } + ] + } + mock_request.return_value = mock_resp + + result = client.access_cards.issue(**mock_provision_params) + + assert type(result).__name__ == 'UnifiedAccessPass' + assert result.id == 'uap-1' + assert result.state == 'active' + assert result.status == 'issued' + assert len(result.details) == 2 + assert result.details[0].id == 'card-ios' + assert result.details[1].id == 'card-android' + @patch('requests.request') def test_provision_card_auth_error(self, mock_request, client, mock_provision_params): error_response = Mock() From 4b773a8f03799a45fd58190480b9104234d24409 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:27:28 -0500 Subject: [PATCH 09/19] test HID orgs --- tests/test_accessgrid.py | 85 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index f84204f..bb5a73b 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -376,6 +376,91 @@ def test_list_pass_template_pairs(self, mock_request, client): assert pairs[1].android_template is None assert pairs[1].ios_template.id == 'tmpl-ios-2' +class TestHIDOrgs: + @patch('requests.request') + def test_create_org(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + 'id': 'org-1', + 'name': 'Acme Corp', + 'slug': 'acme-corp', + 'status': 'pending', + 'full_address': '123 Main St', + 'phone': '+1-555-0000', + 'first_name': 'Jane', + 'last_name': 'Doe', + 'created_at': '2025-01-15T10:00:00Z' + } + mock_request.return_value = mock_resp + + org = client.console.hid.orgs.create( + name='Acme Corp', + full_address='123 Main St', + phone='+1-555-0000', + first_name='Jane', + last_name='Doe' + ) + + call_args = mock_request.call_args[1] + assert call_args['method'] == 'POST' + assert call_args['url'] == f"{client.base_url}/v1/console/hid/orgs" + assert call_args['json']['name'] == 'Acme Corp' + assert call_args['json']['first_name'] == 'Jane' + assert org.id == 'org-1' + assert org.name == 'Acme Corp' + assert org.slug == 'acme-corp' + assert org.status == 'pending' + + @patch('requests.request') + def test_activate_org(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + 'id': 'org-1', + 'name': 'Acme Corp', + 'slug': 'acme-corp', + 'status': 'active', + 'email': 'admin@acme.com' + } + mock_request.return_value = mock_resp + + org = client.console.hid.orgs.activate( + email='admin@acme.com', + password='hid-registration-pw' + ) + + call_args = mock_request.call_args[1] + assert call_args['method'] == 'POST' + assert call_args['url'] == f"{client.base_url}/v1/console/hid/orgs/activate" + assert call_args['json']['email'] == 'admin@acme.com' + assert call_args['json']['password'] == 'hid-registration-pw' + assert org.id == 'org-1' + assert org.status == 'active' + + @patch('requests.request') + def test_list_orgs(self, mock_request, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + 'orgs': [ + {'id': 'org-1', 'name': 'Acme Corp', 'status': 'active'}, + {'id': 'org-2', 'name': 'Globex', 'status': 'pending'} + ] + } + mock_request.return_value = mock_resp + + orgs = client.console.hid.orgs.list() + + call_args = mock_request.call_args[1] + assert call_args['method'] == 'GET' + assert call_args['url'] == f"{client.base_url}/v1/console/hid/orgs" + assert len(orgs) == 2 + assert orgs[0].id == 'org-1' + assert orgs[0].name == 'Acme Corp' + assert orgs[1].status == 'pending' + +class TestConsoleLogs: @patch('requests.request') def test_get_logs(self, mock_request, client, mock_response): mock_request.return_value = mock_response From f9e0bfdfd182e350aa0cf06a353edd561a1b0c2d Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:50:43 -0500 Subject: [PATCH 10/19] Create ci.yml --- .github/workflows/ci.yml | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c4d4878 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + pull_request: + push: + branches: main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python: ['3.10', '3.12', '3.13'] + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: 'pip' + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Run tests + run: pytest -v + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Lint code + run: flake8 accessgrid/ tests/ + + format: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Check formatting + run: black --check accessgrid/ tests/ + - name: Check import order + run: isort --check-only accessgrid/ tests/ From fda215911a80a826e0fcd1fe19bf394f5c8290c7 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:52:12 -0500 Subject: [PATCH 11/19] [fix] autoformat --- accessgrid/__init__.py | 27 +- accessgrid/client.py | 316 ++++++++++++---------- tests/test_accessgrid.py | 557 ++++++++++++++++++++------------------- 3 files changed, 477 insertions(+), 423 deletions(-) diff --git a/accessgrid/__init__.py b/accessgrid/__init__.py index 681309b..fdd6dc8 100644 --- a/accessgrid/__init__.py +++ b/accessgrid/__init__.py @@ -18,26 +18,19 @@ """ # Import all public components -from .client import ( - AccessGrid, - AccessGridError, - AuthenticationError, - AccessCard, - UnifiedAccessPass, - Template, - Org -) +from .client import (AccessCard, AccessGrid, AccessGridError, + AuthenticationError, Org, Template, UnifiedAccessPass) # Version of the accessgrid package __version__ = "0.2.1" # List of public objects that will be exported with "from accessgrid import *" __all__ = [ - 'AccessGrid', - 'AccessGridError', - 'AuthenticationError', - 'AccessCard', - 'UnifiedAccessPass', - 'Template', - 'Org' -] \ No newline at end of file + "AccessGrid", + "AccessGridError", + "AuthenticationError", + "AccessCard", + "UnifiedAccessPass", + "Template", + "Org", +] diff --git a/accessgrid/client.py b/accessgrid/client.py index 0a6da72..e42c558 100644 --- a/accessgrid/client.py +++ b/accessgrid/client.py @@ -1,59 +1,67 @@ import base64 -import hmac import hashlib +import hmac import json -import requests from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Union from urllib.parse import quote -from typing import Optional, Dict, Any, List, Union + +import requests try: from importlib.metadata import version + __version__ = version("accessgrid") except: __version__ = "unknown" + class AccessGridError(Exception): """Base exception for AccessGrid SDK""" + pass + class AuthenticationError(AccessGridError): """Raised when authentication fails""" + pass + class AccessCard: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.url = data.get('install_url') - self.install_url = data.get('install_url') - self.details = data.get('details') - self.state = data.get('state') - self.full_name = data.get('full_name') - self.expiration_date = data.get('expiration_date') - self.card_template_id = data.get('card_template_id') - self.card_number = data.get('card_number') - self.site_code = data.get('site_code') - self.file_data = data.get('file_data') - self.direct_install_url = data.get('direct_install_url') - self.devices = data.get('devices', []) - self.metadata = data.get('metadata', {}) - + self.id = data.get("id") + self.url = data.get("install_url") + self.install_url = data.get("install_url") + self.details = data.get("details") + self.state = data.get("state") + self.full_name = data.get("full_name") + self.expiration_date = data.get("expiration_date") + self.card_template_id = data.get("card_template_id") + self.card_number = data.get("card_number") + self.site_code = data.get("site_code") + self.file_data = data.get("file_data") + self.direct_install_url = data.get("direct_install_url") + self.devices = data.get("devices", []) + self.metadata = data.get("metadata", {}) + def __str__(self) -> str: return f"AccessCard(name='{self.full_name}', id='{self.id}', state='{self.state}', card_template_id='{self.card_template_id}')" def __repr__(self) -> str: return self.__str__() + class UnifiedAccessPass: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.url = data.get('install_url') - self.install_url = data.get('install_url') - self.state = data.get('state') - self.status = data.get('status') - self.details = [AccessCard(client, item) for item in data.get('details', [])] + self.id = data.get("id") + self.url = data.get("install_url") + self.install_url = data.get("install_url") + self.state = data.get("state") + self.status = data.get("status") + self.details = [AccessCard(client, item) for item in data.get("details", [])] def __str__(self) -> str: return f"UnifiedAccessPass(id='{self.id}', state='{self.state}', cards={len(self.details)})" @@ -61,37 +69,39 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() + class Template: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.name = data.get('name') - self.platform = data.get('platform') - self.use_case = data.get('use_case') - self.protocol = data.get('protocol') - self.created_at = data.get('created_at') - self.last_published_at = data.get('last_published_at') - self.issued_keys_count = data.get('issued_keys_count') - self.active_keys_count = data.get('active_keys_count') - self.allowed_device_counts = data.get('allowed_device_counts') - self.support_settings = data.get('support_settings') - self.terms_settings = data.get('terms_settings') - self.style_settings = data.get('style_settings') + self.id = data.get("id") + self.name = data.get("name") + self.platform = data.get("platform") + self.use_case = data.get("use_case") + self.protocol = data.get("protocol") + self.created_at = data.get("created_at") + self.last_published_at = data.get("last_published_at") + self.issued_keys_count = data.get("issued_keys_count") + self.active_keys_count = data.get("active_keys_count") + self.allowed_device_counts = data.get("allowed_device_counts") + self.support_settings = data.get("support_settings") + self.terms_settings = data.get("terms_settings") + self.style_settings = data.get("style_settings") + class Org: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.name = data.get('name') - self.slug = data.get('slug') - self.status = data.get('status') - self.full_address = data.get('full_address') - self.phone = data.get('phone') - self.first_name = data.get('first_name') - self.last_name = data.get('last_name') - self.email = data.get('email') - self.created_at = data.get('created_at') - self.updated_at = data.get('updated_at') + self.id = data.get("id") + self.name = data.get("name") + self.slug = data.get("slug") + self.status = data.get("status") + self.full_address = data.get("full_address") + self.phone = data.get("phone") + self.first_name = data.get("first_name") + self.last_name = data.get("last_name") + self.email = data.get("email") + self.created_at = data.get("created_at") + self.updated_at = data.get("updated_at") def __str__(self) -> str: return f"Org(name='{self.name}', id='{self.id}', slug='{self.slug}', status='{self.status}')" @@ -99,12 +109,13 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() + class TemplateInfo: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.name = data.get('name') - self.platform = data.get('platform') + self.id = data.get("id") + self.name = data.get("name") + self.platform = data.get("platform") def __str__(self) -> str: return f"TemplateInfo(id='{self.id}', name='{self.name}', platform='{self.platform}')" @@ -112,14 +123,23 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() + class PassTemplatePair: def __init__(self, client, data: Dict[str, Any]): self._client = client - self.id = data.get('id') - self.name = data.get('name') - self.created_at = data.get('created_at') - self.android_template = TemplateInfo(client, data['android_template']) if data.get('android_template') else None - self.ios_template = TemplateInfo(client, data['ios_template']) if data.get('ios_template') else None + self.id = data.get("id") + self.name = data.get("name") + self.created_at = data.get("created_at") + self.android_template = ( + TemplateInfo(client, data["android_template"]) + if data.get("android_template") + else None + ) + self.ios_template = ( + TemplateInfo(client, data["ios_template"]) + if data.get("ios_template") + else None + ) def __str__(self) -> str: return f"PassTemplatePair(id='{self.id}', name='{self.name}')" @@ -127,72 +147,76 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() + class AccessCards: def __init__(self, client): self._client = client def issue(self, **kwargs) -> Union[AccessCard, UnifiedAccessPass]: """Issue a new access card or unified access pass""" - response = self._client._post('/v1/key-cards', kwargs) - if 'details' in response: + response = self._client._post("/v1/key-cards", kwargs) + if "details" in response: return UnifiedAccessPass(self._client, response) return AccessCard(self._client, response) - + def provision(self, **kwargs) -> Union[AccessCard, UnifiedAccessPass]: """Alias for issue() method to maintain backwards compatibility""" return self.issue(**kwargs) def get(self, card_id: str) -> AccessCard: """Get details about a specific issued Access Pass""" - response = self._client._get(f'/v1/key-cards/{card_id}') + response = self._client._get(f"/v1/key-cards/{card_id}") return AccessCard(self._client, response) def update(self, card_id: str, **kwargs) -> AccessCard: """Update an existing access card""" - response = self._client._patch(f'/v1/key-cards/{card_id}', kwargs) + response = self._client._patch(f"/v1/key-cards/{card_id}", kwargs) return AccessCard(self._client, response) - def list(self, template_id: Optional[str] = None, state: Optional[str] = None) -> List[AccessCard]: + def list( + self, template_id: Optional[str] = None, state: Optional[str] = None + ) -> List[AccessCard]: """ List NFC keys provisioned for a particular card template. - + Args: template_id: The card template ID to list keys for (optional) state: Filter keys by state (active, suspended, unlink, deleted) - + Returns: List of AccessCard objects """ params = {} if template_id: - params['template_id'] = template_id + params["template_id"] = template_id if state: - params['state'] = state - - response = self._client._get('/v1/key-cards', params=params) - return [AccessCard(self._client, item) for item in response.get('keys', [])] + params["state"] = state + + response = self._client._get("/v1/key-cards", params=params) + return [AccessCard(self._client, item) for item in response.get("keys", [])] def manage(self, card_id: str, action: str) -> AccessCard: """Manage card state (suspend/resume/unlink)""" - response = self._client._post(f'/v1/key-cards/{card_id}/{action}', {}) + response = self._client._post(f"/v1/key-cards/{card_id}/{action}", {}) return AccessCard(self._client, response) def suspend(self, card_id: str) -> AccessCard: """Suspend an access card""" - return self.manage(card_id, 'suspend') + return self.manage(card_id, "suspend") def resume(self, card_id: str) -> AccessCard: """Resume a suspended access card""" - return self.manage(card_id, 'resume') + return self.manage(card_id, "resume") def unlink(self, card_id: str) -> AccessCard: """Unlink an access card""" - return self.manage(card_id, 'unlink') + return self.manage(card_id, "unlink") def delete(self, card_id: str) -> AccessCard: """Delete an access card""" - return self.manage(card_id, 'delete') + return self.manage(card_id, "delete") + class HIDOrgs: def __init__(self, client): @@ -209,25 +233,23 @@ def activate(self, email: str, password: str) -> Org: Returns: Org object with registration details """ - data = { - 'email': email, - 'password': password - } - response = self._client._post('/v1/console/hid/orgs/activate', data) + data = {"email": email, "password": password} + response = self._client._post("/v1/console/hid/orgs/activate", data) return Org(self._client, response) - def list(self) -> List['Org']: + def list(self) -> List["Org"]: """ List all HID organizations. Returns: List of Org objects """ - response = self._client._get('/v1/console/hid/orgs') - return [Org(self._client, org) for org in response.get('orgs', [])] + response = self._client._get("/v1/console/hid/orgs") + return [Org(self._client, org) for org in response.get("orgs", [])] - def create(self, name: str, full_address: str, phone: str, - first_name: str, last_name: str) -> Org: + def create( + self, name: str, full_address: str, phone: str, first_name: str, last_name: str + ) -> Org: """ Create a new HID organization. @@ -242,20 +264,22 @@ def create(self, name: str, full_address: str, phone: str, Org object with creation details """ data = { - 'name': name, - 'full_address': full_address, - 'phone': phone, - 'first_name': first_name, - 'last_name': last_name + "name": name, + "full_address": full_address, + "phone": phone, + "first_name": first_name, + "last_name": last_name, } - response = self._client._post('/v1/console/hid/orgs', data) + response = self._client._post("/v1/console/hid/orgs", data) return Org(self._client, response) + class HID: def __init__(self, client): self._client = client self.orgs = HIDOrgs(client) + class Console: def __init__(self, client): self._client = client @@ -263,24 +287,28 @@ def __init__(self, client): def create_template(self, **kwargs) -> Template: """Create a new card template""" - response = self._client._post('/v1/console/card-templates', kwargs) + response = self._client._post("/v1/console/card-templates", kwargs) return Template(self._client, response) def update_template(self, template_id: str, **kwargs) -> Template: """Update an existing card template""" - response = self._client._put(f'/v1/console/card-templates/{template_id}', kwargs) + response = self._client._put( + f"/v1/console/card-templates/{template_id}", kwargs + ) return Template(self._client, response) def read_template(self, template_id: str) -> Union[Template, List[Template]]: "Read card template by id or list the card template pairs" - response = self._client._get(f'/v1/console/card-templates/{template_id}') - if 'templates' in response: - return [Template(self._client, item) for item in response['templates']] + response = self._client._get(f"/v1/console/card-templates/{template_id}") + if "templates" in response: + return [Template(self._client, item) for item in response["templates"]] return Template(self._client, response) def get_logs(self, template_id: str, **kwargs) -> Dict[str, Any]: """Get event logs for a card template""" - return self._client._get(f'/v1/console/card-templates/{template_id}/logs', params=kwargs) + return self._client._get( + f"/v1/console/card-templates/{template_id}/logs", params=kwargs + ) def list_pass_template_pairs(self, **kwargs) -> Dict[str, Any]: """ @@ -293,18 +321,24 @@ def list_pass_template_pairs(self, **kwargs) -> Dict[str, Any]: Returns: Dict containing pass_template_pairs list and pagination info """ - response = self._client._get('/v1/console/pass-template-pairs', params=kwargs) + response = self._client._get("/v1/console/pass-template-pairs", params=kwargs) - if 'pass_template_pairs' in response: - response['pass_template_pairs'] = [ + if "pass_template_pairs" in response: + response["pass_template_pairs"] = [ PassTemplatePair(self._client, pair) - for pair in response['pass_template_pairs'] + for pair in response["pass_template_pairs"] ] return response + class AccessGrid: - def __init__(self, account_id: str, secret_key: str, base_url: str = 'https://api.accessgrid.com'): + def __init__( + self, + account_id: str, + secret_key: str, + base_url: str = "https://api.accessgrid.com", + ): if not account_id: raise ValueError("Account ID is required") if not secret_key: @@ -312,8 +346,8 @@ def __init__(self, account_id: str, secret_key: str, base_url: str = 'https://ap self.account_id = account_id self.secret_key = secret_key - self.base_url = base_url.rstrip('/') - + self.base_url = base_url.rstrip("/") + # Initialize API clients self.access_cards = AccessCards(self) self.console = Console(self) @@ -322,46 +356,48 @@ def _generate_signature(self, payload: str) -> str: """ Generate HMAC signature for the payload according to the shared secret scheme: SHA256.update(shared_secret + base64.encode(payload)).hexdigest() - - For requests with no payload (like GET, or actions like suspend/unlink/resume), + + For requests with no payload (like GET, or actions like suspend/unlink/resume), caller should provide a payload with {"id": "{resource_id}"} """ # Base64 encode the payload payload_bytes = payload.encode() encoded_payload = base64.b64encode(payload_bytes) - + # Create HMAC using the shared secret as the key and the base64 encoded payload as the message signature = hmac.new( - self.secret_key.encode(), - encoded_payload, - hashlib.sha256 + self.secret_key.encode(), encoded_payload, hashlib.sha256 ).hexdigest() - + return signature - def _make_request(self, method: str, endpoint: str, - data: Optional[Dict] = None, - params: Optional[Dict] = None) -> Dict[str, Any]: + def _make_request( + self, + method: str, + endpoint: str, + data: Optional[Dict] = None, + params: Optional[Dict] = None, + ) -> Dict[str, Any]: """Make an HTTP request to the API""" url = f"{self.base_url}{endpoint}" - + # Extract resource ID from the endpoint if needed for signature resource_id = None - if method == 'GET' or (method == 'POST' and (not data or data == {})): + if method == "GET" or (method == "POST" and (not data or data == {})): # Extract the ID from the endpoint - patterns like /resource/{id} or /resource/{id}/action - parts = endpoint.strip('/').split('/') + parts = endpoint.strip("/").split("/") if len(parts) >= 2: # For actions like unlink/suspend/resume, get the card ID (second to last part) - if parts[-1] in ['suspend', 'resume', 'unlink', 'delete']: + if parts[-1] in ["suspend", "resume", "unlink", "delete"]: resource_id = parts[-2] else: # Otherwise, the ID is typically the last part of the path resource_id = parts[-1] - + # Special handling for requests with no payload: # 1. POST requests with empty body (like unlink/suspend/resume) # 2. GET requests - if (method == 'POST' and not data) or method == 'GET': + if (method == "POST" and not data) or method == "GET": # For these requests, use {"id": "card_id"} as the payload for signature generation if resource_id: payload = json.dumps({"id": resource_id}) @@ -370,44 +406,40 @@ def _make_request(self, method: str, endpoint: str, else: # For normal POST/PUT/PATCH with body, use the actual payload payload = json.dumps(data) if data else "" - + # Generate signature - we don't need to pass resource_id separately since we've already # incorporated it into the payload when needed signature = self._generate_signature(payload) - + headers = { - 'X-ACCT-ID': self.account_id, - 'X-PAYLOAD-SIG': signature, - 'Content-Type': 'application/json', - 'User-Agent': f'accessgrid.py @ v{__version__}' + "X-ACCT-ID": self.account_id, + "X-PAYLOAD-SIG": signature, + "Content-Type": "application/json", + "User-Agent": f"accessgrid.py @ v{__version__}", } - # For GET requests, we don't need to add sig_payload here anymore + # For GET requests, we don't need to add sig_payload here anymore # as it's handled in the request section below try: # For requests with empty bodies (GET or action endpoints like unlink/suspend/resume), # we need to include the sig_payload parameter - if method == 'GET' or (method == 'POST' and not data): + if method == "GET" or (method == "POST" and not data): if not params: params = {} # Include the ID payload in the query params if resource_id: # The server expects the raw JSON string, not URL-encoded - params['sig_payload'] = json.dumps({"id": resource_id}) - + params["sig_payload"] = json.dumps({"id": resource_id}) + # For POST/PUT/PATCH with empty body, don't include a JSON body # as the server uses request.raw_post which would be empty - json_data = data if data and method != 'GET' else None - + json_data = data if data and method != "GET" else None + response = requests.request( - method=method, - url=url, - headers=headers, - json=json_data, - params=params + method=method, url=url, headers=headers, json=json_data, params=params ) - + if response.status_code == 401: raise AuthenticationError("Invalid credentials") elif response.status_code == 402: @@ -415,7 +447,7 @@ def _make_request(self, method: str, endpoint: str, elif not 200 <= response.status_code < 300: print(f"response.status_code: {response.status_code}") error_data = response.json() if response.text else {} - error_message = error_data.get('message', response.text) + error_message = error_data.get("message", response.text) raise AccessGridError(f"API request failed: {error_message}") return response.json() @@ -425,16 +457,16 @@ def _make_request(self, method: str, endpoint: str, def _get(self, endpoint: str, params: Optional[Dict] = None) -> Dict[str, Any]: """Make a GET request""" - return self._make_request('GET', endpoint, params=params) + return self._make_request("GET", endpoint, params=params) def _post(self, endpoint: str, data: Dict) -> Dict[str, Any]: """Make a POST request""" - return self._make_request('POST', endpoint, data=data) + return self._make_request("POST", endpoint, data=data) def _put(self, endpoint: str, data: Dict) -> Dict[str, Any]: """Make a PUT request""" - return self._make_request('PUT', endpoint, data=data) + return self._make_request("PUT", endpoint, data=data) def _patch(self, endpoint: str, data: Dict) -> Dict[str, Any]: """Make a PATCH request""" - return self._make_request('PATCH', endpoint, data=data) + return self._make_request("PATCH", endpoint, data=data) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index bb5a73b..e0f63fa 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -1,124 +1,138 @@ +from unittest.mock import Mock, patch + import pytest -from unittest.mock import patch, Mock + from accessgrid import AccessGrid, AccessGridError, AuthenticationError -MOCK_ACCOUNT_ID = 'test-account-id' -MOCK_SECRET_KEY = 'test-secret-key' +MOCK_ACCOUNT_ID = "test-account-id" +MOCK_SECRET_KEY = "test-secret-key" + @pytest.fixture def client(): return AccessGrid(MOCK_ACCOUNT_ID, MOCK_SECRET_KEY) + @pytest.fixture def mock_response(): mock = Mock() - mock.json.return_value = {'status': 'success'} + mock.json.return_value = {"status": "success"} mock.status_code = 200 mock.text = '{"status": "success"}' return mock + class TestAccessGrid: def test_constructor_missing_account_id(self): - with pytest.raises(ValueError, match='Account ID is required'): + with pytest.raises(ValueError, match="Account ID is required"): AccessGrid(None, MOCK_SECRET_KEY) def test_constructor_missing_secret_key(self): - with pytest.raises(ValueError, match='Secret Key is required'): + with pytest.raises(ValueError, match="Secret Key is required"): AccessGrid(MOCK_ACCOUNT_ID, None) def test_constructor_with_custom_base_url(self): - custom_url = 'https://custom.api.com' + custom_url = "https://custom.api.com" client = AccessGrid(MOCK_ACCOUNT_ID, MOCK_SECRET_KEY, base_url=custom_url) - assert client.base_url == custom_url.rstrip('/') + assert client.base_url == custom_url.rstrip("/") + class TestAccessCards: @pytest.fixture def mock_provision_params(self): return { - 'card_template_id': '0xd3adb00b5', - 'employee_id': '123456789', - 'tag_id': 'DDEADB33FB00B5', - 'full_name': 'Employee name', - 'email': 'employee@yourwebsite.com', - 'phone_number': '+19547212241', - 'classification': 'full_time', - 'start_date': '2025-01-31T22:46:25.601Z', - 'expiration_date': '2025-04-30T22:46:25.601Z', - 'employee_photo': 'base64photo' + "card_template_id": "0xd3adb00b5", + "employee_id": "123456789", + "tag_id": "DDEADB33FB00B5", + "full_name": "Employee name", + "email": "employee@yourwebsite.com", + "phone_number": "+19547212241", + "classification": "full_time", + "start_date": "2025-01-31T22:46:25.601Z", + "expiration_date": "2025-04-30T22:46:25.601Z", + "employee_photo": "base64photo", } - @patch('requests.request') - def test_provision_card(self, mock_request, client, mock_response, mock_provision_params): + @patch("requests.request") + def test_provision_card( + self, mock_request, client, mock_response, mock_provision_params + ): mock_request.return_value = mock_response card = client.access_cards.provision(**mock_provision_params) mock_request.assert_called_once() call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/v1/key-cards" - assert call_args['json'] == mock_provision_params - assert call_args['headers']['X-ACCT-ID'] == MOCK_ACCOUNT_ID - assert 'X-PAYLOAD-SIG' in call_args['headers'] - assert call_args['headers']['Content-Type'] == 'application/json' - - @patch('requests.request') - def test_issue_returns_unified_access_pass(self, mock_request, client, mock_provision_params): + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/key-cards" + assert call_args["json"] == mock_provision_params + assert call_args["headers"]["X-ACCT-ID"] == MOCK_ACCOUNT_ID + assert "X-PAYLOAD-SIG" in call_args["headers"] + assert call_args["headers"]["Content-Type"] == "application/json" + + @patch("requests.request") + def test_issue_returns_unified_access_pass( + self, mock_request, client, mock_provision_params + ): mock_resp = Mock() mock_resp.status_code = 200 mock_resp.json.return_value = { - 'id': 'uap-1', - 'install_url': 'https://example.com/install/uap-1', - 'state': 'active', - 'status': 'issued', - 'details': [ + "id": "uap-1", + "install_url": "https://example.com/install/uap-1", + "state": "active", + "status": "issued", + "details": [ { - 'id': 'card-ios', - 'state': 'active', - 'full_name': 'John Doe', - 'install_url': 'https://example.com/install/card-ios' + "id": "card-ios", + "state": "active", + "full_name": "John Doe", + "install_url": "https://example.com/install/card-ios", }, { - 'id': 'card-android', - 'state': 'active', - 'full_name': 'John Doe', - 'install_url': 'https://example.com/install/card-android' - } - ] + "id": "card-android", + "state": "active", + "full_name": "John Doe", + "install_url": "https://example.com/install/card-android", + }, + ], } mock_request.return_value = mock_resp result = client.access_cards.issue(**mock_provision_params) - assert type(result).__name__ == 'UnifiedAccessPass' - assert result.id == 'uap-1' - assert result.state == 'active' - assert result.status == 'issued' + assert type(result).__name__ == "UnifiedAccessPass" + assert result.id == "uap-1" + assert result.state == "active" + assert result.status == "issued" assert len(result.details) == 2 - assert result.details[0].id == 'card-ios' - assert result.details[1].id == 'card-android' + assert result.details[0].id == "card-ios" + assert result.details[1].id == "card-android" - @patch('requests.request') - def test_provision_card_auth_error(self, mock_request, client, mock_provision_params): + @patch("requests.request") + def test_provision_card_auth_error( + self, mock_request, client, mock_provision_params + ): error_response = Mock() error_response.status_code = 401 - error_response.text = 'Unauthorized' + error_response.text = "Unauthorized" mock_request.return_value = error_response - with pytest.raises(AuthenticationError, match='Invalid credentials'): + with pytest.raises(AuthenticationError, match="Invalid credentials"): client.access_cards.provision(**mock_provision_params) - @patch('requests.request') - def test_provision_card_balance_error(self, mock_request, client, mock_provision_params): + @patch("requests.request") + def test_provision_card_balance_error( + self, mock_request, client, mock_provision_params + ): error_response = Mock() error_response.status_code = 402 - error_response.text = 'Payment required' + error_response.text = "Payment required" mock_request.return_value = error_response - with pytest.raises(AccessGridError, match='Insufficient account balance'): + with pytest.raises(AccessGridError, match="Insufficient account balance"): client.access_cards.provision(**mock_provision_params) - @patch('requests.request') + @patch("requests.request") def test_provision_card_error(self, mock_request, client, mock_provision_params): error_response = Mock() error_response.status_code = 400 @@ -126,326 +140,337 @@ def test_provision_card_error(self, mock_request, client, mock_provision_params) error_response.json.return_value = {"message": "Invalid template ID"} mock_request.return_value = error_response - with pytest.raises(AccessGridError, match='API request failed: Invalid template ID'): + with pytest.raises( + AccessGridError, match="API request failed: Invalid template ID" + ): client.access_cards.provision(**mock_provision_params) - @patch('requests.request') + @patch("requests.request") def test_get_card(self, mock_request, client): mock_resp = Mock() mock_resp.status_code = 200 mock_resp.json.return_value = { - 'id': 'card-123', - 'full_name': 'John Doe', - 'state': 'active', - 'install_url': 'https://example.com/install/card-123', - 'card_template_id': 'tmpl-456', - 'expiration_date': '2025-12-31' + "id": "card-123", + "full_name": "John Doe", + "state": "active", + "install_url": "https://example.com/install/card-123", + "card_template_id": "tmpl-456", + "expiration_date": "2025-12-31", } mock_request.return_value = mock_resp - card = client.access_cards.get('card-123') + card = client.access_cards.get("card-123") call_args = mock_request.call_args[1] - assert call_args['method'] == 'GET' - assert call_args['url'] == f"{client.base_url}/v1/key-cards/card-123" - assert card.id == 'card-123' - assert card.full_name == 'John Doe' - assert card.state == 'active' - assert card.install_url == 'https://example.com/install/card-123' - assert card.card_template_id == 'tmpl-456' - - @patch('requests.request') + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/card-123" + assert card.id == "card-123" + assert card.full_name == "John Doe" + assert card.state == "active" + assert card.install_url == "https://example.com/install/card-123" + assert card.card_template_id == "tmpl-456" + + @patch("requests.request") def test_update_card(self, mock_request, client, mock_response): mock_request.return_value = mock_response - card_id = '0xc4rd1d' + card_id = "0xc4rd1d" update_params = { - 'employee_id': '987654321', - 'full_name': 'Updated Employee Name', - 'classification': 'contractor', - 'expiration_date': '2025-02-22T21:04:03.664Z' + "employee_id": "987654321", + "full_name": "Updated Employee Name", + "classification": "contractor", + "expiration_date": "2025-02-22T21:04:03.664Z", } card = client.access_cards.update(card_id, **update_params) mock_request.assert_called_once() call_args = mock_request.call_args[1] - assert call_args['method'] == 'PATCH' - assert call_args['url'] == f"{client.base_url}/v1/key-cards/{card_id}" - assert call_args['json'] == update_params + assert call_args["method"] == "PATCH" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/{card_id}" + assert call_args["json"] == update_params - @patch('requests.request') + @patch("requests.request") def test_manage_operations(self, mock_request, client, mock_response): mock_request.return_value = mock_response - card_id = '0xc4rd1d' + card_id = "0xc4rd1d" # Test suspend client.access_cards.suspend(card_id) call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/v1/key-cards/{card_id}/suspend" - assert call_args['json'] is None + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/{card_id}/suspend" + assert call_args["json"] is None # Test resume client.access_cards.resume(card_id) call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/v1/key-cards/{card_id}/resume" - assert call_args['json'] is None + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/{card_id}/resume" + assert call_args["json"] is None # Test unlink client.access_cards.unlink(card_id) call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/v1/key-cards/{card_id}/unlink" - assert call_args['json'] is None + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/{card_id}/unlink" + assert call_args["json"] is None # Test delete client.access_cards.delete(card_id) call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/v1/key-cards/{card_id}/delete" - assert call_args['json'] is None - - @patch('requests.request') + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/key-cards/{card_id}/delete" + assert call_args["json"] is None + + @patch("requests.request") def test_list_keys(self, mock_request, client, mock_response): mock_response.json.return_value = { - 'keys': [ + "keys": [ { - 'id': 'key1', - 'state': 'active', - 'full_name': 'John Doe', - 'install_url': 'https://example.com/install/key1', - 'expiration_date': '2025-12-31' + "id": "key1", + "state": "active", + "full_name": "John Doe", + "install_url": "https://example.com/install/key1", + "expiration_date": "2025-12-31", }, { - 'id': 'key2', - 'state': 'suspended', - 'full_name': 'Jane Smith', - 'install_url': 'https://example.com/install/key2', - 'expiration_date': '2025-12-31' - } + "id": "key2", + "state": "suspended", + "full_name": "Jane Smith", + "install_url": "https://example.com/install/key2", + "expiration_date": "2025-12-31", + }, ] } mock_request.return_value = mock_response - template_id = '0xd3adb00b5' + template_id = "0xd3adb00b5" # Test list with template_id only keys = client.access_cards.list(template_id=template_id) assert len(keys) == 2 - assert keys[0].id == 'key1' - assert keys[0].state == 'active' - assert keys[0].full_name == 'John Doe' + assert keys[0].id == "key1" + assert keys[0].state == "active" + assert keys[0].full_name == "John Doe" call_args = mock_request.call_args[1] - assert call_args['method'] == 'GET' - assert call_args['url'] == f"{client.base_url}/v1/key-cards" - assert call_args['params']['template_id'] == template_id - assert 'sig_payload' in call_args['params'] + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/key-cards" + assert call_args["params"]["template_id"] == template_id + assert "sig_payload" in call_args["params"] # Test list with template_id and state - keys = client.access_cards.list(template_id=template_id, state='active') + keys = client.access_cards.list(template_id=template_id, state="active") call_args = mock_request.call_args[1] - assert call_args['params']['template_id'] == template_id - assert call_args['params']['state'] == 'active' - assert 'sig_payload' in call_args['params'] + assert call_args["params"]["template_id"] == template_id + assert call_args["params"]["state"] == "active" + assert "sig_payload" in call_args["params"] + class TestConsole: @pytest.fixture def mock_template_params(self): return { - 'name': 'Employee NFC key', - 'platform': 'apple', - 'use_case': 'employee_badge', - 'protocol': 'desfire', - 'allow_on_multiple_devices': True, - 'watch_count': 2, - 'iphone_count': 3, - 'design': { - 'background_color': '#FFFFFF', - 'label_color': '#000000', - 'label_secondary_color': '#333333' + "name": "Employee NFC key", + "platform": "apple", + "use_case": "employee_badge", + "protocol": "desfire", + "allow_on_multiple_devices": True, + "watch_count": 2, + "iphone_count": 3, + "design": { + "background_color": "#FFFFFF", + "label_color": "#000000", + "label_secondary_color": "#333333", + }, + "support_info": { + "support_url": "https://help.yourcompany.com", + "support_phone_number": "+1-555-123-4567", + "support_email": "support@yourcompany.com", + "privacy_policy_url": "https://yourcompany.com/privacy", + "terms_and_conditions_url": "https://yourcompany.com/terms", }, - 'support_info': { - 'support_url': 'https://help.yourcompany.com', - 'support_phone_number': '+1-555-123-4567', - 'support_email': 'support@yourcompany.com', - 'privacy_policy_url': 'https://yourcompany.com/privacy', - 'terms_and_conditions_url': 'https://yourcompany.com/terms' - } } - @patch('requests.request') - def test_create_template(self, mock_request, client, mock_response, mock_template_params): + @patch("requests.request") + def test_create_template( + self, mock_request, client, mock_response, mock_template_params + ): mock_request.return_value = mock_response - + template = client.console.create_template(**mock_template_params) - + call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/v1/console/card-templates" - assert call_args['json'] == mock_template_params + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/console/card-templates" + assert call_args["json"] == mock_template_params - @patch('requests.request') + @patch("requests.request") def test_update_template(self, mock_request, client, mock_response): mock_request.return_value = mock_response - template_id = '0xd3adb00b5' + template_id = "0xd3adb00b5" update_params = { - 'name': 'Updated Template', - 'allow_on_multiple_devices': False, - 'watch_count': 1, - 'iphone_count': 2 + "name": "Updated Template", + "allow_on_multiple_devices": False, + "watch_count": 1, + "iphone_count": 2, } template = client.console.update_template(template_id, **update_params) call_args = mock_request.call_args[1] - assert call_args['method'] == 'PUT' - assert call_args['url'] == f"{client.base_url}/v1/console/card-templates/{template_id}" - assert call_args['json'] == update_params + assert call_args["method"] == "PUT" + assert ( + call_args["url"] + == f"{client.base_url}/v1/console/card-templates/{template_id}" + ) + assert call_args["json"] == update_params - @patch('requests.request') + @patch("requests.request") def test_read_template(self, mock_request, client, mock_response): mock_request.return_value = mock_response - template_id = '0xd3adb00b5' - + template_id = "0xd3adb00b5" + template = client.console.read_template(template_id) - + call_args = mock_request.call_args[1] - assert call_args['method'] == 'GET' - assert call_args['url'] == f"{client.base_url}/v1/console/card-templates/{template_id}" + assert call_args["method"] == "GET" + assert ( + call_args["url"] + == f"{client.base_url}/v1/console/card-templates/{template_id}" + ) - @patch('requests.request') + @patch("requests.request") def test_list_pass_template_pairs(self, mock_request, client): mock_resp = Mock() mock_resp.status_code = 200 mock_resp.json.return_value = { - 'pass_template_pairs': [ + "pass_template_pairs": [ { - 'id': 'pair-1', - 'name': 'Employee Badge', - 'created_at': '2025-01-15T10:00:00Z', - 'android_template': { - 'id': 'tmpl-android-1', - 'name': 'Android Employee Badge', - 'platform': 'google' + "id": "pair-1", + "name": "Employee Badge", + "created_at": "2025-01-15T10:00:00Z", + "android_template": { + "id": "tmpl-android-1", + "name": "Android Employee Badge", + "platform": "google", + }, + "ios_template": { + "id": "tmpl-ios-1", + "name": "iOS Employee Badge", + "platform": "apple", }, - 'ios_template': { - 'id': 'tmpl-ios-1', - 'name': 'iOS Employee Badge', - 'platform': 'apple' - } }, { - 'id': 'pair-2', - 'name': 'Visitor Pass', - 'created_at': '2025-02-01T12:00:00Z', - 'android_template': None, - 'ios_template': { - 'id': 'tmpl-ios-2', - 'name': 'iOS Visitor Pass', - 'platform': 'apple' - } - } + "id": "pair-2", + "name": "Visitor Pass", + "created_at": "2025-02-01T12:00:00Z", + "android_template": None, + "ios_template": { + "id": "tmpl-ios-2", + "name": "iOS Visitor Pass", + "platform": "apple", + }, + }, ], - 'page': 1, - 'per_page': 50 + "page": 1, + "per_page": 50, } mock_request.return_value = mock_resp result = client.console.list_pass_template_pairs(page=1, per_page=50) call_args = mock_request.call_args[1] - assert call_args['method'] == 'GET' - assert call_args['url'] == f"{client.base_url}/v1/console/pass-template-pairs" - assert call_args['params']['page'] == 1 - assert call_args['params']['per_page'] == 50 + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/console/pass-template-pairs" + assert call_args["params"]["page"] == 1 + assert call_args["params"]["per_page"] == 50 - pairs = result['pass_template_pairs'] + pairs = result["pass_template_pairs"] assert len(pairs) == 2 # First pair — both platforms - assert pairs[0].id == 'pair-1' - assert pairs[0].name == 'Employee Badge' - assert pairs[0].android_template.id == 'tmpl-android-1' - assert pairs[0].android_template.platform == 'google' - assert pairs[0].ios_template.id == 'tmpl-ios-1' - assert pairs[0].ios_template.platform == 'apple' + assert pairs[0].id == "pair-1" + assert pairs[0].name == "Employee Badge" + assert pairs[0].android_template.id == "tmpl-android-1" + assert pairs[0].android_template.platform == "google" + assert pairs[0].ios_template.id == "tmpl-ios-1" + assert pairs[0].ios_template.platform == "apple" # Second pair — android_template is None - assert pairs[1].id == 'pair-2' + assert pairs[1].id == "pair-2" assert pairs[1].android_template is None - assert pairs[1].ios_template.id == 'tmpl-ios-2' + assert pairs[1].ios_template.id == "tmpl-ios-2" + class TestHIDOrgs: - @patch('requests.request') + @patch("requests.request") def test_create_org(self, mock_request, client): mock_resp = Mock() mock_resp.status_code = 200 mock_resp.json.return_value = { - 'id': 'org-1', - 'name': 'Acme Corp', - 'slug': 'acme-corp', - 'status': 'pending', - 'full_address': '123 Main St', - 'phone': '+1-555-0000', - 'first_name': 'Jane', - 'last_name': 'Doe', - 'created_at': '2025-01-15T10:00:00Z' + "id": "org-1", + "name": "Acme Corp", + "slug": "acme-corp", + "status": "pending", + "full_address": "123 Main St", + "phone": "+1-555-0000", + "first_name": "Jane", + "last_name": "Doe", + "created_at": "2025-01-15T10:00:00Z", } mock_request.return_value = mock_resp org = client.console.hid.orgs.create( - name='Acme Corp', - full_address='123 Main St', - phone='+1-555-0000', - first_name='Jane', - last_name='Doe' + name="Acme Corp", + full_address="123 Main St", + phone="+1-555-0000", + first_name="Jane", + last_name="Doe", ) call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/v1/console/hid/orgs" - assert call_args['json']['name'] == 'Acme Corp' - assert call_args['json']['first_name'] == 'Jane' - assert org.id == 'org-1' - assert org.name == 'Acme Corp' - assert org.slug == 'acme-corp' - assert org.status == 'pending' - - @patch('requests.request') + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/console/hid/orgs" + assert call_args["json"]["name"] == "Acme Corp" + assert call_args["json"]["first_name"] == "Jane" + assert org.id == "org-1" + assert org.name == "Acme Corp" + assert org.slug == "acme-corp" + assert org.status == "pending" + + @patch("requests.request") def test_activate_org(self, mock_request, client): mock_resp = Mock() mock_resp.status_code = 200 mock_resp.json.return_value = { - 'id': 'org-1', - 'name': 'Acme Corp', - 'slug': 'acme-corp', - 'status': 'active', - 'email': 'admin@acme.com' + "id": "org-1", + "name": "Acme Corp", + "slug": "acme-corp", + "status": "active", + "email": "admin@acme.com", } mock_request.return_value = mock_resp org = client.console.hid.orgs.activate( - email='admin@acme.com', - password='hid-registration-pw' + email="admin@acme.com", password="hid-registration-pw" ) call_args = mock_request.call_args[1] - assert call_args['method'] == 'POST' - assert call_args['url'] == f"{client.base_url}/v1/console/hid/orgs/activate" - assert call_args['json']['email'] == 'admin@acme.com' - assert call_args['json']['password'] == 'hid-registration-pw' - assert org.id == 'org-1' - assert org.status == 'active' - - @patch('requests.request') + assert call_args["method"] == "POST" + assert call_args["url"] == f"{client.base_url}/v1/console/hid/orgs/activate" + assert call_args["json"]["email"] == "admin@acme.com" + assert call_args["json"]["password"] == "hid-registration-pw" + assert org.id == "org-1" + assert org.status == "active" + + @patch("requests.request") def test_list_orgs(self, mock_request, client): mock_resp = Mock() mock_resp.status_code = 200 mock_resp.json.return_value = { - 'orgs': [ - {'id': 'org-1', 'name': 'Acme Corp', 'status': 'active'}, - {'id': 'org-2', 'name': 'Globex', 'status': 'pending'} + "orgs": [ + {"id": "org-1", "name": "Acme Corp", "status": "active"}, + {"id": "org-2", "name": "Globex", "status": "pending"}, ] } mock_request.return_value = mock_resp @@ -453,31 +478,35 @@ def test_list_orgs(self, mock_request, client): orgs = client.console.hid.orgs.list() call_args = mock_request.call_args[1] - assert call_args['method'] == 'GET' - assert call_args['url'] == f"{client.base_url}/v1/console/hid/orgs" + assert call_args["method"] == "GET" + assert call_args["url"] == f"{client.base_url}/v1/console/hid/orgs" assert len(orgs) == 2 - assert orgs[0].id == 'org-1' - assert orgs[0].name == 'Acme Corp' - assert orgs[1].status == 'pending' + assert orgs[0].id == "org-1" + assert orgs[0].name == "Acme Corp" + assert orgs[1].status == "pending" + class TestConsoleLogs: - @patch('requests.request') + @patch("requests.request") def test_get_logs(self, mock_request, client, mock_response): mock_request.return_value = mock_response - template_id = '0xd3adb00b5' + template_id = "0xd3adb00b5" filters = { - 'device': 'mobile', - 'start_date': '2025-01-01T00:00:00Z', - 'end_date': '2025-02-01T00:00:00Z', - 'event_type': 'install' + "device": "mobile", + "start_date": "2025-01-01T00:00:00Z", + "end_date": "2025-02-01T00:00:00Z", + "event_type": "install", } events = client.console.get_logs(template_id, **filters) call_args = mock_request.call_args[1] - assert call_args['method'] == 'GET' - assert call_args['url'] == f"{client.base_url}/v1/console/card-templates/{template_id}/logs" - assert call_args['params']['device'] == 'mobile' - assert call_args['params']['start_date'] == '2025-01-01T00:00:00Z' - assert call_args['params']['end_date'] == '2025-02-01T00:00:00Z' - assert call_args['params']['event_type'] == 'install' + assert call_args["method"] == "GET" + assert ( + call_args["url"] + == f"{client.base_url}/v1/console/card-templates/{template_id}/logs" + ) + assert call_args["params"]["device"] == "mobile" + assert call_args["params"]["start_date"] == "2025-01-01T00:00:00Z" + assert call_args["params"]["end_date"] == "2025-02-01T00:00:00Z" + assert call_args["params"]["event_type"] == "install" From 177bfd1cfd5ad87b9ccc54df6e3afcbe3400d0c6 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:58:00 -0500 Subject: [PATCH 12/19] align flake8's line length with black's --- .flake8 | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..2bcd70e --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 88 From a6537d7d7c372aaba7bafaa68dc20231d8384ff6 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 17:58:08 -0500 Subject: [PATCH 13/19] remove unused variable references --- tests/test_accessgrid.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index e0f63fa..cbd9cdb 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -59,7 +59,7 @@ def test_provision_card( ): mock_request.return_value = mock_response - card = client.access_cards.provision(**mock_provision_params) + client.access_cards.provision(**mock_provision_params) mock_request.assert_called_once() call_args = mock_request.call_args[1] @@ -181,7 +181,7 @@ def test_update_card(self, mock_request, client, mock_response): "expiration_date": "2025-02-22T21:04:03.664Z", } - card = client.access_cards.update(card_id, **update_params) + client.access_cards.update(card_id, **update_params) mock_request.assert_called_once() call_args = mock_request.call_args[1] @@ -297,7 +297,7 @@ def test_create_template( ): mock_request.return_value = mock_response - template = client.console.create_template(**mock_template_params) + client.console.create_template(**mock_template_params) call_args = mock_request.call_args[1] assert call_args["method"] == "POST" @@ -315,7 +315,7 @@ def test_update_template(self, mock_request, client, mock_response): "iphone_count": 2, } - template = client.console.update_template(template_id, **update_params) + client.console.update_template(template_id, **update_params) call_args = mock_request.call_args[1] assert call_args["method"] == "PUT" @@ -330,7 +330,7 @@ def test_read_template(self, mock_request, client, mock_response): mock_request.return_value = mock_response template_id = "0xd3adb00b5" - template = client.console.read_template(template_id) + client.console.read_template(template_id) call_args = mock_request.call_args[1] assert call_args["method"] == "GET" @@ -498,7 +498,7 @@ def test_get_logs(self, mock_request, client, mock_response): "event_type": "install", } - events = client.console.get_logs(template_id, **filters) + client.console.get_logs(template_id, **filters) call_args = mock_request.call_args[1] assert call_args["method"] == "GET" From 78416cae75b4ecaff024eaab8de485849755b539 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 18:10:54 -0500 Subject: [PATCH 14/19] remove unused imports --- accessgrid/client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/accessgrid/client.py b/accessgrid/client.py index e42c558..c7ab686 100644 --- a/accessgrid/client.py +++ b/accessgrid/client.py @@ -2,9 +2,7 @@ import hashlib import hmac import json -from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Union -from urllib.parse import quote import requests From 325a033adf893783110b283deae890d7f4da1da4 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 18:11:04 -0500 Subject: [PATCH 15/19] specify except term --- accessgrid/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/accessgrid/client.py b/accessgrid/client.py index c7ab686..077c3b0 100644 --- a/accessgrid/client.py +++ b/accessgrid/client.py @@ -10,7 +10,7 @@ from importlib.metadata import version __version__ = version("accessgrid") -except: +except Exception: __version__ = "unknown" From 5e995da7d3c454cfb58c13a57343ae69121f497d Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 18:15:18 -0500 Subject: [PATCH 16/19] [fix] line lengths --- accessgrid/client.py | 49 ++++++++++++++++++++++++++++------------ tests/test_accessgrid.py | 12 ++++++++++ 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/accessgrid/client.py b/accessgrid/client.py index 077c3b0..daae5d3 100644 --- a/accessgrid/client.py +++ b/accessgrid/client.py @@ -45,7 +45,11 @@ def __init__(self, client, data: Dict[str, Any]): self.metadata = data.get("metadata", {}) def __str__(self) -> str: - return f"AccessCard(name='{self.full_name}', id='{self.id}', state='{self.state}', card_template_id='{self.card_template_id}')" + return ( + f"AccessCard(name='{self.full_name}', id='{self.id}', " + f"state='{self.state}', " + f"card_template_id='{self.card_template_id}')" + ) def __repr__(self) -> str: return self.__str__() @@ -59,10 +63,15 @@ def __init__(self, client, data: Dict[str, Any]): self.install_url = data.get("install_url") self.state = data.get("state") self.status = data.get("status") - self.details = [AccessCard(client, item) for item in data.get("details", [])] + self.details = [ + AccessCard(client, item) for item in data.get("details", []) + ] def __str__(self) -> str: - return f"UnifiedAccessPass(id='{self.id}', state='{self.state}', cards={len(self.details)})" + return ( + f"UnifiedAccessPass(id='{self.id}', " + f"state='{self.state}', cards={len(self.details)})" + ) def __repr__(self) -> str: return self.__str__() @@ -102,7 +111,10 @@ def __init__(self, client, data: Dict[str, Any]): self.updated_at = data.get("updated_at") def __str__(self) -> str: - return f"Org(name='{self.name}', id='{self.id}', slug='{self.slug}', status='{self.status}')" + return ( + f"Org(name='{self.name}', id='{self.id}', " + f"slug='{self.slug}', status='{self.status}')" + ) def __repr__(self) -> str: return self.__str__() @@ -116,7 +128,10 @@ def __init__(self, client, data: Dict[str, Any]): self.platform = data.get("platform") def __str__(self) -> str: - return f"TemplateInfo(id='{self.id}', name='{self.name}', platform='{self.platform}')" + return ( + f"TemplateInfo(id='{self.id}', " + f"name='{self.name}', platform='{self.platform}')" + ) def __repr__(self) -> str: return self.__str__() @@ -355,14 +370,16 @@ def _generate_signature(self, payload: str) -> str: Generate HMAC signature for the payload according to the shared secret scheme: SHA256.update(shared_secret + base64.encode(payload)).hexdigest() - For requests with no payload (like GET, or actions like suspend/unlink/resume), - caller should provide a payload with {"id": "{resource_id}"} + For requests with no payload (like GET, or actions like + suspend/unlink/resume), caller should provide a payload + with {"id": "{resource_id}"} """ # Base64 encode the payload payload_bytes = payload.encode() encoded_payload = base64.b64encode(payload_bytes) - # Create HMAC using the shared secret as the key and the base64 encoded payload as the message + # Create HMAC using the shared secret as the key + # and the base64 encoded payload as the message signature = hmac.new( self.secret_key.encode(), encoded_payload, hashlib.sha256 ).hexdigest() @@ -382,10 +399,12 @@ def _make_request( # Extract resource ID from the endpoint if needed for signature resource_id = None if method == "GET" or (method == "POST" and (not data or data == {})): - # Extract the ID from the endpoint - patterns like /resource/{id} or /resource/{id}/action + # Extract ID from endpoint patterns like + # /resource/{id} or /resource/{id}/action parts = endpoint.strip("/").split("/") if len(parts) >= 2: - # For actions like unlink/suspend/resume, get the card ID (second to last part) + # For actions like unlink/suspend/resume, + # get the card ID (second to last part) if parts[-1] in ["suspend", "resume", "unlink", "delete"]: resource_id = parts[-2] else: @@ -396,7 +415,7 @@ def _make_request( # 1. POST requests with empty body (like unlink/suspend/resume) # 2. GET requests if (method == "POST" and not data) or method == "GET": - # For these requests, use {"id": "card_id"} as the payload for signature generation + # Use {"id": "card_id"} as the signature payload if resource_id: payload = json.dumps({"id": resource_id}) else: @@ -405,8 +424,8 @@ def _make_request( # For normal POST/PUT/PATCH with body, use the actual payload payload = json.dumps(data) if data else "" - # Generate signature - we don't need to pass resource_id separately since we've already - # incorporated it into the payload when needed + # Generate signature — resource_id is already + # incorporated into the payload when needed signature = self._generate_signature(payload) headers = { @@ -420,8 +439,8 @@ def _make_request( # as it's handled in the request section below try: - # For requests with empty bodies (GET or action endpoints like unlink/suspend/resume), - # we need to include the sig_payload parameter + # For empty-body requests (GET or actions), + # include the sig_payload parameter if method == "GET" or (method == "POST" and not data): if not params: params = {} diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index cbd9cdb..aed40cf 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -107,6 +107,9 @@ def test_issue_returns_unified_access_pass( assert len(result.details) == 2 assert result.details[0].id == "card-ios" assert result.details[1].id == "card-android" + expected_str = "UnifiedAccessPass(id='uap-1', state='active', cards=2)" # noqa: E501 + assert str(result) == expected_str + assert repr(result) == expected_str @patch("requests.request") def test_provision_card_auth_error( @@ -169,6 +172,9 @@ def test_get_card(self, mock_request, client): assert card.state == "active" assert card.install_url == "https://example.com/install/card-123" assert card.card_template_id == "tmpl-456" + expected_str = "AccessCard(name='John Doe', id='card-123', state='active', card_template_id='tmpl-456')" # noqa: E501 + assert str(card) == expected_str + assert repr(card) == expected_str @patch("requests.request") def test_update_card(self, mock_request, client, mock_response): @@ -393,6 +399,9 @@ def test_list_pass_template_pairs(self, mock_request, client): assert pairs[0].name == "Employee Badge" assert pairs[0].android_template.id == "tmpl-android-1" assert pairs[0].android_template.platform == "google" + expected_ti = "TemplateInfo(id='tmpl-android-1', name='Android Employee Badge', platform='google')" # noqa: E501 + assert str(pairs[0].android_template) == expected_ti + assert repr(pairs[0].android_template) == expected_ti assert pairs[0].ios_template.id == "tmpl-ios-1" assert pairs[0].ios_template.platform == "apple" @@ -437,6 +446,9 @@ def test_create_org(self, mock_request, client): assert org.name == "Acme Corp" assert org.slug == "acme-corp" assert org.status == "pending" + expected_str = "Org(name='Acme Corp', id='org-1', slug='acme-corp', status='pending')" # noqa: E501 + assert str(org) == expected_str + assert repr(org) == expected_str @patch("requests.request") def test_activate_org(self, mock_request, client): From df481e0c1c9e5d0adb12872190f991d1f22b43bd Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 18:19:00 -0500 Subject: [PATCH 17/19] align isort congif with black --- .isort.cfg | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .isort.cfg diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..c76db01 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[isort] +profile = black From b266b540e513357d095faea88fe168c233d3550f Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 18:19:16 -0500 Subject: [PATCH 18/19] [lint] --- accessgrid/__init__.py | 11 +++++++++-- accessgrid/client.py | 4 +--- tests/test_accessgrid.py | 4 +++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/accessgrid/__init__.py b/accessgrid/__init__.py index fdd6dc8..a2e1040 100644 --- a/accessgrid/__init__.py +++ b/accessgrid/__init__.py @@ -18,8 +18,15 @@ """ # Import all public components -from .client import (AccessCard, AccessGrid, AccessGridError, - AuthenticationError, Org, Template, UnifiedAccessPass) +from .client import ( + AccessCard, + AccessGrid, + AccessGridError, + AuthenticationError, + Org, + Template, + UnifiedAccessPass, +) # Version of the accessgrid package __version__ = "0.2.1" diff --git a/accessgrid/client.py b/accessgrid/client.py index daae5d3..08c37c1 100644 --- a/accessgrid/client.py +++ b/accessgrid/client.py @@ -63,9 +63,7 @@ def __init__(self, client, data: Dict[str, Any]): self.install_url = data.get("install_url") self.state = data.get("state") self.status = data.get("status") - self.details = [ - AccessCard(client, item) for item in data.get("details", []) - ] + self.details = [AccessCard(client, item) for item in data.get("details", [])] def __str__(self) -> str: return ( diff --git a/tests/test_accessgrid.py b/tests/test_accessgrid.py index aed40cf..8449a75 100644 --- a/tests/test_accessgrid.py +++ b/tests/test_accessgrid.py @@ -107,7 +107,9 @@ def test_issue_returns_unified_access_pass( assert len(result.details) == 2 assert result.details[0].id == "card-ios" assert result.details[1].id == "card-android" - expected_str = "UnifiedAccessPass(id='uap-1', state='active', cards=2)" # noqa: E501 + expected_str = ( + "UnifiedAccessPass(id='uap-1', state='active', cards=2)" # noqa: E501 + ) assert str(result) == expected_str assert repr(result) == expected_str From 3a97cdc7033f4ea019613f0585b95fc5107ab750 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Tue, 3 Mar 2026 18:34:36 -0500 Subject: [PATCH 19/19] test to verify all python versions match in dev --- tests/test_python_versions.py | 94 +++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/test_python_versions.py diff --git a/tests/test_python_versions.py b/tests/test_python_versions.py new file mode 100644 index 0000000..005fb41 --- /dev/null +++ b/tests/test_python_versions.py @@ -0,0 +1,94 @@ +import re +from pathlib import Path + +import pytest + +# ══════════════════════════════════════════════════════════════════════════════ +# TARGET VERSION - Update this when upgrading Python +# ══════════════════════════════════════════════════════════════════════════════ +TARGET_PYTHON = "3.12.13" + +ROOT = Path(__file__).resolve().parent.parent + + +def read_tool_versions(): + content = (ROOT / ".tool-versions").read_text() + versions = {} + for line in content.strip().splitlines(): + parts = line.split(None, 1) + if len(parts) == 2: + versions[parts[0]] = parts[1] + return versions + + +def read_ci_jobs(): + ci_path = ROOT / ".github" / "workflows" / "ci.yml" + content = ci_path.read_text() + + jobs = {} + # Split into job blocks by top-level job keys (2-space indent) + job_blocks = re.split(r"\n (?=\w[\w-]*:)", content) + + for block in job_blocks: + name_match = re.match(r"^([\w-]+):", block) + if not name_match: + continue + job_name = name_match.group(1) + + versions = [] + + # Matrix arrays: python: ['3.10', '3.12', '3.13'] + matrix_match = re.search(r"python:\s*\[([^\]]+)\]", block) + if matrix_match: + for v in matrix_match.group(1).split(","): + versions.append(v.strip().strip("'\"")) + + # Standalone: python-version: '3.12' + for m in re.finditer(r"python-version:\s*['\"]?([\d.]+)['\"]?", block): + if m.group(1) not in versions: + versions.append(m.group(1)) + + if versions: + jobs[job_name] = versions + + return jobs + + +def major_minor(version): + parts = version.split(".") + return f"{parts[0]}.{parts[1]}" + + +class TestToolVersions: + def test_python_version_matches_target(self): + tool_versions = read_tool_versions() + assert tool_versions["python"] == TARGET_PYTHON + + +class TestCIWorkflow: + @pytest.fixture(scope="class") + def jobs(self): + return read_ci_jobs() + + def test_every_job_includes_target_minor(self, jobs): + target_minor = major_minor(TARGET_PYTHON) + for job_name, versions in jobs.items(): + assert target_minor in versions, ( + f"Job '{job_name}' is missing target " + f"version {target_minor}: {versions}" + ) + + def test_every_version_is_valid_format(self, jobs): + for job_name, versions in jobs.items(): + for v in versions: + assert re.match(r"^\d+\.\d+$", v), ( + f"Job '{job_name}' has invalid " f"version format: {v}" + ) + + def test_tool_versions_minor_in_every_job(self, jobs): + tool_minor = major_minor(read_tool_versions()["python"]) + for job_name, versions in jobs.items(): + assert tool_minor in versions, ( + f"Job '{job_name}' doesn't test " + f".tool-versions minor {tool_minor}: {versions}" + )