diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 01a0ecc..abba74d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,26 +15,26 @@ jobs: pull-requests: write steps: - - name: Check out code - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.12' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - - name: Build package - run: python setup.py sdist bdist_wheel - - - name: Publish package - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: twine upload dist/* + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + + - name: Build package + run: python setup.py sdist bdist_wheel + + - name: Publish package + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: twine upload dist/* diff --git a/.github/workflows/update-rpm-and-sync.yml b/.github/workflows/update-rpm-and-sync.yml new file mode 100644 index 0000000..c4286d1 --- /dev/null +++ b/.github/workflows/update-rpm-and-sync.yml @@ -0,0 +1,56 @@ +name: Update RPM Spec and Sync + +on: + workflow_run: + workflows: ["Publish Python Package"] # Listens for your existing publish action + types: + - completed + +jobs: + update-and-sync: + runs-on: ubuntu-latest + # Only run if the PyPI publish actually succeeded + if: ${{ github.event.workflow_run.conclusion == 'success' }} + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git fetch origin + + - name: Update RPM spec version on latest + run: | + git checkout latest + + # Extract version from your __init__.py file + VERSION=$(grep -oP "^__version__ = ['\"]([^'\"]+)['\"]" src/quads_lib/__init__.py | grep -oP "[0-9]+\.[0-9]+\.[0-9]+") + echo "Extracted version: $VERSION" + + # Update the spec file + sed -i "s/^%define version.*/%define version $VERSION/" rpm/quads-lib.spec + + # Commit and push if there are changes + git add rpm/quads-lib.spec + if ! git diff --staged --quiet; then + # Added [skip ci] so this commit doesn't accidentally trigger other tests + git commit -m "chore: update RPM spec version to $VERSION [skip ci]" + git push origin latest + echo "Updated RPM spec to version $VERSION on latest" + else + echo "No version changes to commit" + fi + + - name: Sync back to development + run: | + git checkout development + # Merge the updated latest branch into development + git merge origin/latest --no-edit + git push origin development diff --git a/README.rst b/README.rst index 334d729..03fe882 100644 --- a/README.rst +++ b/README.rst @@ -58,6 +58,14 @@ You can also install the in-development version with:: pip install https://github.com/quadsproject/python-quads-lib/archive/development.zip +RPM Installation +---------------- + +For Fedora Linux:: + + dnf copr enable quadsdev/python3-quads -y + dnf install quads-lib + Documentation ============= diff --git a/ci/bootstrap.py b/ci/bootstrap.py index 6b72c2f..1136b38 100755 --- a/ci/bootstrap.py +++ b/ci/bootstrap.py @@ -59,7 +59,9 @@ def main(): # This uses sys.executable the same way that the call in # cookiecutter-pylibrary/hooks/post_gen_project.py # invokes this bootstrap.py itself. - for line in subprocess.check_output([sys.executable, "-m", "tox", "--listenvs"], universal_newlines=True).splitlines() + for line in subprocess.check_output( + [sys.executable, "-m", "tox", "--listenvs"], universal_newlines=True + ).splitlines() ] tox_environments = [line for line in tox_environments if line.startswith("py")] for template in templates_path.rglob("*"): @@ -67,7 +69,11 @@ def main(): template_path = template.relative_to(templates_path).as_posix() destination = base_path / template_path destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_text(jinja.get_template(template_path).render(tox_environments=tox_environments)) + destination.write_text( + jinja.get_template(template_path).render( + tox_environments=tox_environments + ) + ) print(f"Wrote {template_path}") print("DONE.") diff --git a/pyproject.toml b/pyproject.toml index 1426e1e..f2a6d7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ ignore = [ "E501", # pycodestyle line-too-long "S105", # Possible hardcoded password "S106", # Hardcoded password + "COM812", # conflicts with black + "ISC001", # conflicts with black ] select = [ "B", # flake8-bugbear diff --git a/src/quads_lib/base.py b/src/quads_lib/base.py index 790b973..06a5ea6 100644 --- a/src/quads_lib/base.py +++ b/src/quads_lib/base.py @@ -57,9 +57,10 @@ def __exit__(self, exc_type, exc_value, traceback): self.session.close() def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None) -> dict: + full_url = urljoin(self.base_url, endpoint) _response = self.session.request( method, - urljoin(self.base_url, endpoint), + full_url, json=data, verify=self.verify, ) diff --git a/src/quads_lib/quads.py b/src/quads_lib/quads.py index e0179f8..4e2501e 100644 --- a/src/quads_lib/quads.py +++ b/src/quads_lib/quads.py @@ -90,7 +90,8 @@ def remove_host(self, hostname: str) -> dict: def is_available(self, hostname: str, data: dict) -> bool: url_params = urlencode(data) endpoint = Path("available") / hostname - json_response = self.get(f"{endpoint}?{url_params}") + full_url = f"{endpoint}?{url_params}" + json_response = self.get(full_url) # Server returns {hostname: "True"} or {hostname: "False"} return json_response.get(hostname) == "True" diff --git a/tests/test_quads.py b/tests/test_quads.py index 4f16403..66ca2a2 100644 --- a/tests/test_quads.py +++ b/tests/test_quads.py @@ -21,7 +21,12 @@ def setup(self): @patch("requests.Session.request") def test_get_hosts(self, mock_get): - expected_response = {"hosts": [{"name": "host1", "model": "model1"}, {"name": "host2", "model": "model2"}]} + expected_response = { + "hosts": [ + {"name": "host1", "model": "model1"}, + {"name": "host2", "model": "model2"}, + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -57,7 +62,10 @@ def test_get_hosts_error(self, mock_get): @patch("requests.Session.request") def test_get_host_models(self, mock_get): expected_response = { - "model1": [{"name": "host1", "model": "model1"}, {"name": "host2", "model": "model1"}], + "model1": [ + {"name": "host1", "model": "model1"}, + {"name": "host2", "model": "model1"}, + ], "model2": [{"name": "host3", "model": "model2"}], } mock_response = Mock() @@ -124,7 +132,12 @@ def test_get_hosts_bad_request_no_json(self, mock_get): @patch("requests.Session.request") def test_filter_hosts(self, mock_get): - expected_response = {"hosts": [{"name": "host1", "model": "model1"}, {"name": "host2", "model": "model1"}]} + expected_response = { + "hosts": [ + {"name": "host1", "model": "model1"}, + {"name": "host2", "model": "model1"}, + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -182,7 +195,12 @@ def test_filter_hosts_bad_request(self, mock_get): @patch("requests.Session.request") def test_filter_clouds(self, mock_get): - expected_response = {"clouds": [{"name": "cloud1", "owner": "user1"}, {"name": "cloud2", "owner": "user1"}]} + expected_response = { + "clouds": [ + {"name": "cloud1", "owner": "user1"}, + {"name": "cloud2", "owner": "user1"}, + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -240,7 +258,12 @@ def test_filter_clouds_bad_request(self, mock_get): @patch("requests.Session.request") def test_filter_assignments(self, mock_get): - expected_response = {"assignments": [{"id": 1, "cloud": "cloud1", "host": "host1"}, {"id": 2, "cloud": "cloud1", "host": "host2"}]} + expected_response = { + "assignments": [ + {"id": 1, "cloud": "cloud1", "host": "host1"}, + {"id": 2, "cloud": "cloud1", "host": "host2"}, + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -298,7 +321,12 @@ def test_filter_assignments_bad_request(self, mock_get): @patch("requests.Session.request") def test_get_host(self, mock_get): - expected_response = {"name": "host1", "model": "model1", "cloud": "cloud1", "interfaces": []} + expected_response = { + "name": "host1", + "model": "model1", + "cloud": "cloud1", + "interfaces": [], + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -343,7 +371,12 @@ def test_get_host_special_chars(self, mock_get): @patch("requests.Session.request") def test_create_host(self, mock_post): - host_data = {"name": "new-host", "model": "model1", "cloud": "cloud1", "interfaces": []} + host_data = { + "name": "new-host", + "model": "model1", + "cloud": "cloud1", + "interfaces": [], + } mock_response = Mock() mock_response.json.return_value = host_data mock_post.return_value = mock_response @@ -556,7 +589,10 @@ def test_is_available_bad_request(self, mock_get): @patch("requests.Session.request") def test_get_clouds(self, mock_get): expected_response = { - "clouds": [{"name": "cloud1", "owner": "user1", "ticket": "123"}, {"name": "cloud2", "owner": "user2", "ticket": "456"}] + "clouds": [ + {"name": "cloud1", "owner": "user1", "ticket": "123"}, + {"name": "cloud2", "owner": "user2", "ticket": "456"}, + ] } mock_response = Mock() mock_response.json.return_value = expected_response @@ -603,7 +639,10 @@ def test_get_clouds_bad_request(self, mock_get): @patch("requests.Session.request") def test_get_free_clouds(self, mock_get): expected_response = { - "clouds": [{"name": "cloud1", "owner": "user1", "status": "free"}, {"name": "cloud2", "owner": "user2", "status": "free"}] + "clouds": [ + {"name": "cloud1", "owner": "user1", "status": "free"}, + {"name": "cloud2", "owner": "user2", "status": "free"}, + ] } mock_response = Mock() mock_response.json.return_value = expected_response @@ -650,7 +689,12 @@ def test_get_free_clouds_bad_request(self, mock_get): @patch("requests.Session.request") def test_get_cloud(self, mock_get): cloud_name = "test-cloud" - expected_response = {"name": "test-cloud", "owner": "user1", "ticket": "123", "description": "Test cloud environment"} + expected_response = { + "name": "test-cloud", + "owner": "user1", + "ticket": "123", + "description": "Test cloud environment", + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -693,7 +737,10 @@ def test_get_summary(self, mock_get): mock_get.assert_called_once() called_url = str(mock_get.call_args[0][1]) assert called_url.endswith( - ("/clouds/summary?start_date=2024-03-20&end_date=2024-03-21", "/clouds/summary?end_date=2024-03-21&start_date=2024-03-20") + ( + "/clouds/summary?start_date=2024-03-20&end_date=2024-03-21", + "/clouds/summary?end_date=2024-03-21&start_date=2024-03-20", + ) ) assert result == expected_response @@ -731,7 +778,12 @@ def test_get_summary_bad_request(self, mock_get): @patch("requests.Session.request") def test_create_cloud(self, mock_post): - cloud_data = {"name": "new-cloud", "owner": "user1", "ticket": "123", "description": "New test cloud"} + cloud_data = { + "name": "new-cloud", + "owner": "user1", + "ticket": "123", + "description": "New test cloud", + } mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = cloud_data @@ -781,7 +833,11 @@ def test_create_cloud_bad_request(self, mock_post): @patch("requests.Session.request") def test_update_cloud(self, mock_patch): cloud_name = "existing-cloud" - update_data = {"owner": "new-owner", "ticket": "456", "description": "Updated description"} + update_data = { + "owner": "new-owner", + "ticket": "456", + "description": "Updated description", + } mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = update_data @@ -850,8 +906,18 @@ def test_remove_cloud_bad_request(self, mock_delete): def test_get_schedules(self, mock_get): expected_response = { "schedules": [ - {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}, - {"id": 2, "cloud": "cloud2", "start": "2024-03-22", "end": "2024-03-23"}, + { + "id": 1, + "cloud": "cloud1", + "start": "2024-03-20", + "end": "2024-03-21", + }, + { + "id": 2, + "cloud": "cloud2", + "start": "2024-03-22", + "end": "2024-03-23", + }, ] } mock_response = Mock() @@ -928,7 +994,12 @@ def test_get_current_schedules_error(self, mock_get): @patch("requests.Session.request") def test_get_schedule(self, mock_get): schedule_id = 123 - expected_response = {"id": 123, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} + expected_response = { + "id": 123, + "cloud": "cloud1", + "start": "2024-03-20", + "end": "2024-03-21", + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -943,8 +1014,18 @@ def test_get_schedule(self, mock_get): def test_get_future_schedules(self, mock_get): expected_response = { "schedules": [ - {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}, - {"id": 2, "cloud": "cloud2", "start": "2024-03-22", "end": "2024-03-23"}, + { + "id": 1, + "cloud": "cloud1", + "start": "2024-03-20", + "end": "2024-03-21", + }, + { + "id": 2, + "cloud": "cloud2", + "start": "2024-03-22", + "end": "2024-03-23", + }, ] } mock_response = Mock() @@ -1054,7 +1135,12 @@ def test_create_schedule(self, mock_post): @patch("requests.Session.request") def test_get_available(self, mock_get): - expected_response = {"hosts": [{"name": "host1", "model": "model1"}, {"name": "host2", "model": "model2"}]} + expected_response = { + "hosts": [ + {"name": "host1", "model": "model1"}, + {"name": "host2", "model": "model2"}, + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1067,7 +1153,11 @@ def test_get_available(self, mock_get): @patch("requests.Session.request") def test_filter_available(self, mock_get): - filter_data = {"start_date": "2024-03-20", "end_date": "2024-03-21", "model": "model1"} + filter_data = { + "start_date": "2024-03-20", + "end_date": "2024-03-21", + "model": "model1", + } expected_response = {"hosts": [{"name": "host1", "model": "model1"}]} mock_response = Mock() mock_response.json.return_value = expected_response @@ -1084,7 +1174,12 @@ def test_filter_available(self, mock_get): @patch("requests.Session.request") def test_create_assignment(self, mock_post): - assignment_data = {"cloud": "cloud1", "host": "host1", "start": "2024-03-20", "end": "2024-03-21"} + assignment_data = { + "cloud": "cloud1", + "host": "host1", + "start": "2024-03-20", + "end": "2024-03-21", + } mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = assignment_data @@ -1177,7 +1272,12 @@ def test_update_notification_error(self, mock_patch): @patch("requests.Session.request") def test_get_active_cloud_assignment(self, mock_get): cloud_name = "cloud1" - expected_response = {"id": 123, "cloud": "cloud1", "host": "host1", "status": "active"} + expected_response = { + "id": 123, + "cloud": "cloud1", + "host": "host1", + "status": "active", + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1210,7 +1310,10 @@ def test_get_active_assignments(self, mock_get): def test_get_host_interface(self, mock_get): hostname = "host1" expected_response = { - "interfaces": [{"name": "eth0", "mac_address": "00:11:22:33:44:55"}, {"name": "eth1", "mac_address": "00:11:22:33:44:66"}] + "interfaces": [ + {"name": "eth0", "mac_address": "00:11:22:33:44:55"}, + {"name": "eth1", "mac_address": "00:11:22:33:44:66"}, + ] } mock_response = Mock() mock_response.json.return_value = expected_response @@ -1274,7 +1377,11 @@ def test_remove_interface(self, mock_delete): @patch("requests.Session.request") def test_create_interface(self, mock_post): hostname = "host1" - interface_data = {"name": "eth0", "mac_address": "00:11:22:33:44:55", "switch_port": "Gi1/0/1"} + interface_data = { + "name": "eth0", + "mac_address": "00:11:22:33:44:55", + "switch_port": "Gi1/0/1", + } mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = interface_data @@ -1451,7 +1558,11 @@ def test_get_vlans(self, mock_get): @patch("requests.Session.request") def test_get_vlan(self, mock_get): vlan_id = 100 - expected_response = {"id": 100, "name": "prod", "description": "Production network"} + expected_response = { + "id": 100, + "name": "prod", + "description": "Production network", + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1464,7 +1575,11 @@ def test_get_vlan(self, mock_get): @patch("requests.Session.request") def test_get_free_vlan(self, mock_get): - expected_response = {"id": 100, "name": "prod", "description": "Production network"} + expected_response = { + "id": 100, + "name": "prod", + "description": "Production network", + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1510,8 +1625,18 @@ def test_create_vlan(self, mock_post): def test_get_moves(self, mock_get): expected_response = { "moves": [ - {"id": 1, "host": "host1", "from_cloud": "cloud1", "to_cloud": "cloud2"}, - {"id": 2, "host": "host2", "from_cloud": "cloud2", "to_cloud": "cloud3"}, + { + "id": 1, + "host": "host1", + "from_cloud": "cloud1", + "to_cloud": "cloud2", + }, + { + "id": 2, + "host": "host2", + "from_cloud": "cloud2", + "to_cloud": "cloud3", + }, ] } mock_response = Mock() @@ -1614,7 +1739,10 @@ def test_create_self_assignment(self, mock_post): @patch("requests.Session.request") def test_register_success(self, mock_post): - expected_response = {"status_code": 201, "message": "User registered successfully"} + expected_response = { + "status_code": 201, + "message": "User registered successfully", + } mock_response = Mock() mock_response.status_code = 201 mock_response.json.return_value = expected_response @@ -1627,7 +1755,11 @@ def test_register_success(self, mock_post): @patch("requests.Session.request") def test_login_success(self, mock_post): - expected_response = {"status_code": 201, "auth_token": "fake-token-123", "message": "Login successful"} + expected_response = { + "status_code": 201, + "auth_token": "fake-token-123", + "message": "Login successful", + } mock_response = Mock() mock_response.status_code = 201 mock_response.json.return_value = expected_response @@ -1681,7 +1813,12 @@ def test_logout_failure(self, mock_post): @patch("builtins.print") @patch("requests.Session.request") def test_create_assignment_logging(self, mock_request, mock_print): - assignment_data = {"cloud": "cloud1", "host": "host1", "start": "2025-06-20", "end": "2025-06-21"} + assignment_data = { + "cloud": "cloud1", + "host": "host1", + "start": "2025-06-20", + "end": "2025-06-21", + } response_data = { "id": 42, "cloud": {"name": "cloud1", "owner": "user1"}, @@ -1702,9 +1839,19 @@ def test_create_assignment_logging(self, mock_request, mock_print): @patch("builtins.print") @patch("requests.Session.request") def test_create_assignment_no_logging(self, mock_request, mock_print): - assignment_data = {"cloud": "cloud1", "host": "host1", "start": "2025-06-20", "end": "2025-06-21"} + assignment_data = { + "cloud": "cloud1", + "host": "host1", + "start": "2025-06-20", + "end": "2025-06-21", + } # Missing 'id' field in response - should not trigger logging - response_data = {"cloud": {"name": "cloud1", "owner": "user1"}, "host": "host1", "start": "2025-06-20", "end": "2025-06-21"} + response_data = { + "cloud": {"name": "cloud1", "owner": "user1"}, + "host": "host1", + "start": "2025-06-20", + "end": "2025-06-21", + } mock_response = Mock() mock_response.status_code = 200 @@ -1718,9 +1865,18 @@ def test_create_assignment_no_logging(self, mock_request, mock_print): @patch("builtins.print") @patch("requests.Session.request") def test_create_assignment_limit_reached(self, mock_request, mock_print): - assignment_data = {"cloud": "cloud1", "host": "host1", "start": "2025-06-20", "end": "2025-06-21"} + assignment_data = { + "cloud": "cloud1", + "host": "host1", + "start": "2025-06-20", + "end": "2025-06-21", + } # Error response for scheduling limit reached - error_response = {"error": "Forbidden", "message": "Self scheduling limit reached", "status_code": 403} + error_response = { + "error": "Forbidden", + "message": "Self scheduling limit reached", + "status_code": 403, + } mock_response = Mock() mock_response.status_code = 403 @@ -1735,8 +1891,17 @@ def test_create_assignment_limit_reached(self, mock_request, mock_print): @patch("builtins.print") @patch("requests.Session.request") def test_create_self_assignment_logging(self, mock_request, mock_print): - assignment_data = {"cloud": "cloud1", "start": "2025-06-20", "end": "2025-06-21"} - response_data = {"id": 123, "cloud": {"name": "cloud1", "owner": "user1"}, "start": "2025-06-20", "end": "2025-06-21"} + assignment_data = { + "cloud": "cloud1", + "start": "2025-06-20", + "end": "2025-06-21", + } + response_data = { + "id": 123, + "cloud": {"name": "cloud1", "owner": "user1"}, + "start": "2025-06-20", + "end": "2025-06-21", + } mock_response = Mock() mock_response.status_code = 200 @@ -1750,7 +1915,11 @@ def test_create_self_assignment_logging(self, mock_request, mock_print): @patch("builtins.print") @patch("requests.Session.request") def test_create_self_assignment_no_logging(self, mock_request, mock_print): - assignment_data = {"cloud": "cloud1", "start": "2025-06-20", "end": "2025-06-21"} + assignment_data = { + "cloud": "cloud1", + "start": "2025-06-20", + "end": "2025-06-21", + } # Missing 'cloud' field in response - should not trigger logging response_data = {"id": 123, "start": "2025-06-20", "end": "2025-06-21"} @@ -1766,9 +1935,17 @@ def test_create_self_assignment_no_logging(self, mock_request, mock_print): @patch("builtins.print") @patch("requests.Session.request") def test_create_self_assignment_limit_reached(self, mock_request, mock_print): - assignment_data = {"cloud": "cloud1", "start": "2025-06-20", "end": "2025-06-21"} + assignment_data = { + "cloud": "cloud1", + "start": "2025-06-20", + "end": "2025-06-21", + } # Error response for self scheduling limit reached - error_response = {"error": "Forbidden", "message": "Self scheduling limit reached", "status_code": 403} + error_response = { + "error": "Forbidden", + "message": "Self scheduling limit reached", + "status_code": 403, + } mock_response = Mock() mock_response.status_code = 403