diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..5a90bb0 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + lint-format: + name: 🧹 Lint and Format + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: "Set up Python" + uses: actions/setup-python@v5 + with: + python-version-file: "pyproject.toml" + + - name: Lint with Ruff + run: uv run ruff check . + + - name: Format with Ruff + run: uv run ruff format --check . + + test: + name: 🧪 Test and Coverage + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: "Set up Python" + uses: actions/setup-python@v5 + with: + python-version-file: "pyproject.toml" + + - name: Run tests + run: | + uv run pytest --cov --cov-branch --cov-report=xml --junitxml=junit.xml -o junit_family=legacy + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a630fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +coverage.xml +.coverage +htmlcov/ +junit.xml diff --git a/README.md b/README.md index 5b29ed6..5f18771 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +![CI](https://github.com/mikaeld/secrets-manager/workflows/CI/badge.svg) +[![codecov](https://codecov.io/gh/mikaeld/secrets-manager/branch/main/graph/badge.svg)](https://codecov.io/gh//secrets-manager) + ![tui.png](imgs/tui.png) # Secrets Manager diff --git a/app.py b/app.py index d154a53..a5bff28 100644 --- a/app.py +++ b/app.py @@ -1,16 +1,15 @@ -from google.cloud import secretmanager from google.api_core.exceptions import GoogleAPICallError +from google.cloud import secretmanager from textual import work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.reactive import reactive -from textual.widgets import Header, Footer, Tree, DataTable, Input -from textual.markup import escape +from textual.widgets import DataTable, Footer, Header, Input, Tree -from secrets_manager.utils.gcp import search_gcp_projects, list_secrets, get_secret_versions -from secrets_manager.utils.helpers import sanitize_project_id_search, format_error_message from secrets_manager.models.gcp_projects import GCPProject +from secrets_manager.utils.gcp import get_secret_versions, list_secrets, search_gcp_projects +from secrets_manager.utils.helpers import format_error_message, sanitize_project_id_search class SecretsManager(App): @@ -88,7 +87,8 @@ def _do_search(self, search_term: str) -> None: except Exception as e: self.notify( f"Failed to search projects: {format_error_message(str(e), 200)}", - severity="error", markup=False, + severity="error", + markup=False, ) return None @@ -114,7 +114,9 @@ def _load_secrets(self) -> None: # First secret in list is always the latest secret latest_version = secret_versions[0] latest_version_number = latest_version.name.split("/")[-1] - table.add_row(secret_name, latest_version_number, latest_version.state.name, create_time) + table.add_row( + secret_name, latest_version_number, latest_version.state.name, create_time + ) except GoogleAPICallError as e: self.notify( @@ -124,7 +126,8 @@ def _load_secrets(self) -> None: except Exception as e: self.notify( f"Failed to load secrets: {format_error_message(str(e), 200)}", - severity="error", markup=False, + severity="error", + markup=False, ) diff --git a/imgs/tui.png b/imgs/tui.png index 105fb24..cd17f07 100644 Binary files a/imgs/tui.png and b/imgs/tui.png differ diff --git a/pyproject.toml b/pyproject.toml index 33d8079..45892af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=8.3.5", + "pytest-cov>=6.1.1", "ruff>=0.11.7", ] @@ -24,3 +25,41 @@ pythonpath = ["."] [tool.pytest.ini_options] addopts = "-v --tb=short" testpaths = ["tests"] + +[tool.ruff] +target-version = "py313" +line-length = 100 +fix = true + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "C", # flake8-comprehensions + "B", # flake8-bugbear + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long, handled by formatter +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.coverage.run] +source = ["secrets_manager"] +omit = ["tests/*", "setup.py"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if __name__ == .__main__.:", + "raise NotImplementedError", + "pass", +] diff --git a/secrets_manager/models/gcp_projects.py b/secrets_manager/models/gcp_projects.py index 4362851..c366c83 100644 --- a/secrets_manager/models/gcp_projects.py +++ b/secrets_manager/models/gcp_projects.py @@ -1,6 +1,5 @@ from google.cloud.resourcemanager_v3.types import Project from pydantic import BaseModel, Field, field_validator -from typing import Dict, Optional class GCPProject(BaseModel): @@ -11,7 +10,7 @@ class GCPProject(BaseModel): name: str = Field( description="The unique resource name of the project (e.g., projects/415104041262)" ) - parent: Optional[str] = Field( + parent: str | None = Field( default=None, description="Reference to parent resource (e.g., organizations/123 or folders/876)", ) @@ -25,7 +24,7 @@ class GCPProject(BaseModel): max_length=30, description="User-assigned display name of the project", ) - labels: Dict[str, str] = Field( + labels: dict[str, str] = Field( default_factory=dict, description="Key-value pairs for project labels" ) @@ -53,7 +52,7 @@ def __str__(self): return f"GCPProject(display_name={self.display_name}, name={self.name}, project_id={self.project_id})" @field_validator("labels") - def validate_labels(cls, v: Dict[str, str]) -> Dict[str, str]: + def validate_labels(cls, v: dict[str, str]) -> dict[str, str]: """Validate label keys and values according to GCP requirements.""" for key, value in v.items(): # Validate key format @@ -63,9 +62,7 @@ def validate_labels(cls, v: Dict[str, str]) -> Dict[str, str]: raise ValueError(f"Label key too long: {key}") # Validate value format - if not value.isalnum() and not all( - c in "-_" for c in value if not c.isalnum() - ): + if not value.isalnum() and not all(c in "-_" for c in value if not c.isalnum()): raise ValueError(f"Invalid label value format: {value}") if len(value) > 63: raise ValueError(f"Label value too long: {value}") diff --git a/secrets_manager/utils/gcp.py b/secrets_manager/utils/gcp.py index 3f6bea4..5fee29c 100644 --- a/secrets_manager/utils/gcp.py +++ b/secrets_manager/utils/gcp.py @@ -1,21 +1,22 @@ import json -from google.cloud import resourcemanager_v3 -from google.cloud import secretmanager +from google.cloud import resourcemanager_v3, secretmanager from google.cloud.secretmanager_v1.services.secret_manager_service.pagers import ( ListSecretsPager, ) from secrets_manager.models.gcp_projects import GCPProject + def list_secrets(gcp_project: GCPProject) -> ListSecretsPager: client = secretmanager.SecretManagerServiceClient() parent = f"projects/{gcp_project.project_id}" return client.list_secrets(request={"parent": parent}) -def get_secret_versions(secret: secretmanager.Secret, show_deleted: bool = False) -> list[ - secretmanager.SecretVersion]: +def get_secret_versions( + secret: secretmanager.Secret, show_deleted: bool = False +) -> list[secretmanager.SecretVersion]: """ Get all versions of a secret from GCP Secret Manager. @@ -31,8 +32,7 @@ def get_secret_versions(secret: secretmanager.Secret, show_deleted: bool = False # List all versions of the secret request = secretmanager.ListSecretVersionsRequest( - parent=parent, - filter="state!=DESTROYED" if not show_deleted else None + parent=parent, filter="state!=DESTROYED" if not show_deleted else None ) versions = [] @@ -41,6 +41,7 @@ def get_secret_versions(secret: secretmanager.Secret, show_deleted: bool = False return versions + def get_secret_version_value(secret_version: secretmanager.SecretVersion) -> dict: """ Get the value of a specific secret version. diff --git a/secrets_manager/utils/helpers.py b/secrets_manager/utils/helpers.py index 047d5d8..9863fc4 100644 --- a/secrets_manager/utils/helpers.py +++ b/secrets_manager/utils/helpers.py @@ -1,5 +1,3 @@ -from textual.markup import escape - def format_error_message(error_message: str, max_length: int | None = None) -> str: """ Format the error message for display. @@ -31,9 +29,7 @@ def sanitize_project_id_search(search_term: str) -> str: # Replace spaces and invalid characters with hyphens # Keep only allowed characters: lowercase letters, numbers, and hyphens sanitized = "".join( - c if c.isalnum() or c == "-" else "-" - for c in sanitized - if c.isalnum() or c in "-_ " + c if c.isalnum() or c == "-" else "-" for c in sanitized if c.isalnum() or c in "-_ " ) # Remove consecutive hyphens @@ -42,8 +38,9 @@ def sanitize_project_id_search(search_term: str) -> str: return sanitized -if __name__ == '__main__': + +if __name__ == "__main__": print(sanitize_project_id_search("")) print(sanitize_project_id_search("-")) print(sanitize_project_id_search("--")) - print(sanitize_project_id_search(" ")) \ No newline at end of file + print(sanitize_project_id_search(" ")) diff --git a/tests/conftest.py b/tests/conftest.py index 2400d56..507e99b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import pytest -from google.cloud.resourcemanager_v3.types import Project from google.cloud import secretmanager +from google.cloud.resourcemanager_v3.types import Project + @pytest.fixture def mock_project_data(): @@ -9,9 +10,10 @@ def mock_project_data(): "project_id": "test-project-123", "display_name": "Test Project", "parent": "organizations/456", - "labels": {"environment": "test", "team": "platform"} + "labels": {"environment": "test", "team": "platform"}, } + @pytest.fixture def mock_gcp_project(mock_project_data): return Project( @@ -19,17 +21,15 @@ def mock_gcp_project(mock_project_data): project_id=mock_project_data["project_id"], display_name=mock_project_data["display_name"], parent=mock_project_data["parent"], - labels=mock_project_data["labels"] + labels=mock_project_data["labels"], ) + @pytest.fixture def mock_secret(): - return secretmanager.Secret( - name="projects/123456789/secrets/test-secret" - ) + return secretmanager.Secret(name="projects/123456789/secrets/test-secret") + @pytest.fixture def mock_secret_version(): - return secretmanager.SecretVersion( - name="projects/123456789/secrets/test-secret/versions/1" - ) \ No newline at end of file + return secretmanager.SecretVersion(name="projects/123456789/secrets/test-secret/versions/1") diff --git a/tests/test_models/test_gcp_projects.py b/tests/test_models/test_gcp_projects.py index a219556..1821132 100644 --- a/tests/test_models/test_gcp_projects.py +++ b/tests/test_models/test_gcp_projects.py @@ -1,6 +1,8 @@ import pytest + from secrets_manager.models.gcp_projects import GCPProject + def test_gcp_project_creation(mock_project_data): project = GCPProject(**mock_project_data) assert project.name == mock_project_data["name"] @@ -8,35 +10,39 @@ def test_gcp_project_creation(mock_project_data): assert project.display_name == mock_project_data["display_name"] assert project.labels == mock_project_data["labels"] + def test_gcp_project_from_api_response(mock_gcp_project): project = GCPProject.from_project_api_response(mock_gcp_project) assert project.name == mock_gcp_project.name assert project.project_id == mock_gcp_project.project_id assert project.display_name == mock_gcp_project.display_name + def test_project_id_validation(): with pytest.raises(ValueError): GCPProject( name="projects/123", project_id="123invalid", # Must start with letter - display_name="Test Project" + display_name="Test Project", ) + def test_display_name_validation(): with pytest.raises(ValueError): GCPProject( name="projects/123", project_id="valid-project", - display_name="abc" # Too short + display_name="abc", # Too short ) + def test_labels_validation(): # Test valid labels project = GCPProject( name="projects/123", project_id="valid-project", display_name="Valid Project", - labels={"env": "test", "team-name": "platform"} + labels={"env": "test", "team-name": "platform"}, ) assert project.labels == {"env": "test", "team-name": "platform"} @@ -46,7 +52,7 @@ def test_labels_validation(): name="projects/123", project_id="valid-project", display_name="Valid Project", - labels={"invalid@key": "value"} + labels={"invalid@key": "value"}, ) # Test invalid label value @@ -55,9 +61,10 @@ def test_labels_validation(): name="projects/123", project_id="valid-project", display_name="Valid Project", - labels={"key": "invalid@value"} + labels={"key": "invalid@value"}, ) + def test_string_representation(mock_project_data): project = GCPProject(**mock_project_data) str_repr = str(project) diff --git a/tests/test_utils/test_gcp.py b/tests/test_utils/test_gcp.py index ea4649b..4f97975 100644 --- a/tests/test_utils/test_gcp.py +++ b/tests/test_utils/test_gcp.py @@ -1,39 +1,39 @@ import json -import pytest from unittest.mock import Mock, patch + +import pytest + +from secrets_manager.models.gcp_projects import GCPProject from secrets_manager.utils.gcp import ( - list_secrets, - get_secret_versions, get_secret_version_value, - search_gcp_projects + get_secret_versions, + list_secrets, + search_gcp_projects, ) -from secrets_manager.models.gcp_projects import GCPProject @pytest.fixture def mock_secret_manager_client(): - with patch('google.cloud.secretmanager.SecretManagerServiceClient') as mock: + with patch("google.cloud.secretmanager.SecretManagerServiceClient") as mock: yield mock @pytest.fixture def mock_projects_client(): - with patch('google.cloud.resourcemanager_v3.ProjectsClient') as mock: + with patch("google.cloud.resourcemanager_v3.ProjectsClient") as mock: yield mock def test_list_secrets(mock_secret_manager_client): mock_project = GCPProject( - name="projects/123", - project_id="test-project", - display_name="Test Project" + name="projects/123", project_id="test-project", display_name="Test Project" ) mock_client = Mock() mock_secret_manager_client.return_value = mock_client mock_client.list_secrets.return_value = [] - result = list_secrets(mock_project) + _ = list_secrets(mock_project) mock_client.list_secrets.assert_called_once_with( request={"parent": f"projects/{mock_project.project_id}"} @@ -46,23 +46,23 @@ def test_get_secret_versions(mock_secret_manager_client, mock_secret): mock_client.list_secret_versions.return_value = [] # Test without deleted versions - result = get_secret_versions(mock_secret) + _ = get_secret_versions(mock_secret) # Verify that list_secret_versions was called once assert mock_client.list_secret_versions.called # Get the actual call arguments - call_args = mock_client.list_secret_versions.call_args[1]['request'] + call_args = mock_client.list_secret_versions.call_args[1]["request"] # Verify the request parameters individually assert call_args.parent == mock_secret.name assert call_args.filter == "state!=DESTROYED" # Test with deleted versions mock_client.list_secret_versions.reset_mock() - result = get_secret_versions(mock_secret, show_deleted=True) + _ = get_secret_versions(mock_secret, show_deleted=True) # Verify the call for show_deleted=True assert mock_client.list_secret_versions.called - call_args = mock_client.list_secret_versions.call_args[1]['request'] + call_args = mock_client.list_secret_versions.call_args[1]["request"] assert call_args.parent == mock_secret.name diff --git a/tests/test_utils/test_helpers.py b/tests/test_utils/test_helpers.py index 6575ef3..e3e03b9 100644 --- a/tests/test_utils/test_helpers.py +++ b/tests/test_utils/test_helpers.py @@ -1,4 +1,3 @@ -import pytest from secrets_manager.utils.helpers import format_error_message, sanitize_project_id_search @@ -107,8 +106,7 @@ def test_preserves_valid_characters(self): ("abc123", "abc123"), ("dev-prod", "dev-prod"), ("test-123-xyz", "test-123-xyz"), - ("abcdefghijklmnopqrstuvwxyz-0123456789", - "abcdefghijklmnopqrstuvwxyz-0123456789"), + ("abcdefghijklmnopqrstuvwxyz-0123456789", "abcdefghijklmnopqrstuvwxyz-0123456789"), ] for input_str, expected in test_cases: assert sanitize_project_id_search(input_str) == expected diff --git a/uv.lock b/uv.lock index 8b54024..2ef03b1 100644 --- a/uv.lock +++ b/uv.lock @@ -60,6 +60,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "coverage" +version = "7.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708 }, + { url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981 }, + { url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495 }, + { url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538 }, + { url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561 }, + { url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633 }, + { url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712 }, + { url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000 }, + { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195 }, + { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998 }, + { url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541 }, + { url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767 }, + { url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997 }, + { url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708 }, + { url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046 }, + { url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139 }, + { url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307 }, + { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116 }, + { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909 }, + { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068 }, + { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435 }, +] + [[package]] name = "google-api-core" version = "2.24.2" @@ -403,6 +432,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, ] +[[package]] +name = "pytest-cov" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841 }, +] + [[package]] name = "requests" version = "2.32.3" @@ -482,6 +524,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -496,6 +539,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "ruff", specifier = ">=0.11.7" }, ]