diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml new file mode 100644 index 0000000..5e2b328 --- /dev/null +++ b/.github/workflows/backend-tests.yaml @@ -0,0 +1,51 @@ +name: Backend Tests + +on: + pull_request: + +jobs: + check_paths: + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.filter.outputs.backend }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3.0.2 + id: filter + with: + filters: | + backend: + - 'src/backend/**' + - '.github/workflows/backend-tests.yaml' + + test: + name: Test Backend + needs: check_paths + if: ${{ needs.check_paths.outputs.should_run == 'true' }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./src/backend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install UV + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install + + - name: Install dependencies + run: | + uv sync --locked --dev + + - name: Lint with Ruff + run: | + uv run ruff check --output-format=github --target-version=py313 src tests + uv run ruff format --diff --check --target-version=py313 src tests + + - name: Run tests with coverage + run: | + uv run pytest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 768985f..c8bdd19 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,3 +4,12 @@ repos: hooks: - id: commitizen stages: [commit-msg] + + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.11.6 + hooks: + # Run the linter. + - id: ruff + # Run the formatter. + - id: ruff-format diff --git a/mise.toml b/mise.toml index 77d7645..a92fdf7 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,7 @@ [tools] pre-commit = "latest" python = "3.13" +ruff = "latest" uv = "latest" [settings] diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 49dd107..1823918 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -7,12 +7,19 @@ authors = [{ name = "aadil96", email = "agwan96@gmail.com" }] requires-python = ">=3.13" dependencies = [ "fastapi>=0.121.2", + "httpx>=0.28.1", + "pytest>=9.0.1", + "pytest-asyncio>=1.3.0", + "ruff>=0.14.5", "uvicorn>=0.38.0", ] [project.scripts] study-tracker-api = "backend.main:main" +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" + [tool.hatch.build.targets.wheel] packages = ["src/backend"] diff --git a/src/backend/tests/__init__.py b/src/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/tests/test_config.py b/src/backend/tests/test_config.py new file mode 100644 index 0000000..eeb6a1c --- /dev/null +++ b/src/backend/tests/test_config.py @@ -0,0 +1,69 @@ +import os +from unittest import mock + +from backend.config import ( + APP_NAME, + API_HOST, + API_PORT, + DATA_DIR, + CORS_ALLOW_ORIGINS, + CORS_ALLOW_METHODS, + CORS_ALLOW_HEADERS, + CORS_ALLOW_CREDENTIALS, +) + + +def test_settings_default_values(): + """Test that Settings has the expected default values.""" + assert APP_NAME == "DevOps Study Tracker" + assert API_HOST == "0.0.0.0" + assert API_PORT == 22112 + assert DATA_DIR.endswith("data") + + # Test CORS default values + assert CORS_ALLOW_ORIGINS == ["*"] + assert CORS_ALLOW_METHODS == ["*"] + assert CORS_ALLOW_HEADERS == ["*"] + assert CORS_ALLOW_CREDENTIALS is True + + +@mock.patch.dict( + os.environ, + { + "API_HOST": "127.0.0.1", + "API_PORT": "9000", + "CORS_ALLOW_ORIGINS": "https://example.com,https://api.example.com", + "CORS_ALLOW_METHODS": "GET,POST,PUT", + "CORS_ALLOW_HEADERS": "Content-Type,Authorization", + "CORS_ALLOW_CREDENTIALS": "false", + }, +) +def test_settings_from_env(): + """Test that Settings loads values from environment variables.""" + # Force reload of the config module to get updated environment variables + import importlib + import backend.config + + importlib.reload(backend.config) + + # Import the settings again after reload + from backend.config import ( + API_HOST, + API_PORT, + CORS_ALLOW_ORIGINS, + CORS_ALLOW_METHODS, + CORS_ALLOW_HEADERS, + CORS_ALLOW_CREDENTIALS, + ) + + assert API_HOST == "127.0.0.1" + assert API_PORT == 9000 + + # Test CORS values from environment + assert CORS_ALLOW_ORIGINS == [ + "https://example.com", + "https://api.example.com", + ] + assert CORS_ALLOW_METHODS == ["GET", "POST", "PUT"] + assert CORS_ALLOW_HEADERS == ["Content-Type", "Authorization"] + assert CORS_ALLOW_CREDENTIALS is False diff --git a/src/backend/tests/test_main.py b/src/backend/tests/test_main.py new file mode 100644 index 0000000..1a969aa --- /dev/null +++ b/src/backend/tests/test_main.py @@ -0,0 +1,250 @@ +import pytest +import pytest_asyncio +from httpx import AsyncClient +import os +import csv +from unittest import mock + +# Import the FastAPI app and models from the proper package path +from backend.main import app +from backend.models import StudySessionCreate +from backend.storage import save_session + +# Import the storage module itself +import backend.storage as storage_module + +# Define the path for a temporary test data file using relative paths +TEST_DATA_FILE = os.path.join(os.path.dirname(__file__), "test_sessions.csv") + + +@pytest.fixture(scope="session", autouse=True) +def setup_test_environment(): + """Fixture to set up the test environment before any tests run.""" + # Ensure the test data directory exists + os.makedirs(os.path.dirname(TEST_DATA_FILE), exist_ok=True) + + # Ensure the test data file does not exist before tests + if os.path.exists(TEST_DATA_FILE): + os.remove(TEST_DATA_FILE) + # Override the SESSIONS_FILE path in the storage module for testing + storage_module.SESSIONS_FILE = TEST_DATA_FILE # Use the imported module alias + yield + # Clean up the test data file after all tests run + if os.path.exists(TEST_DATA_FILE): + os.remove(TEST_DATA_FILE) + + +@pytest_asyncio.fixture(scope="function") +async def client(): + """Provides an async test client for the FastAPI app.""" + # Use ASGITransport instead of direct app parameter - newer httpx client syntax + from httpx import ASGITransport + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +@pytest.fixture(scope="function", autouse=True) +def reset_test_data(): + """Fixture to reset the test data file before each test function.""" + if os.path.exists(TEST_DATA_FILE): + os.remove(TEST_DATA_FILE) + + # Create an empty file with headers using CSV, similar to how the main app does it + with open(TEST_DATA_FILE, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["id", "timestamp", "minutes", "tag"]) + writer.writeheader() + + +@pytest.mark.asyncio +async def test_root(client: AsyncClient): + """Test the root endpoint.""" + response = await client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "DevOps Study Tracker API"} + + +@pytest.mark.asyncio +async def test_create_session(client: AsyncClient): + """Test creating a new study session.""" + session_data = {"minutes": 30, "tag": "AWS"} + response = await client.post("/sessions", json=session_data) + assert response.status_code == 200 + data = response.json() + assert data["minutes"] == 30 + assert data["tag"] == "AWS" + assert "id" in data + assert "timestamp" in data + + # Verify data was saved using CSV + with open(TEST_DATA_FILE, "r", newline="") as f: + reader = list(csv.DictReader(f)) + assert len(reader) == 1 + assert int(reader[0]["minutes"]) == 30 + assert reader[0]["tag"] == "AWS" + + +@pytest.mark.asyncio +async def test_read_sessions_empty(client: AsyncClient): + """Test reading sessions when none exist.""" + response = await client.get("/sessions") + assert response.status_code == 200 + assert response.json() == [] + + +@pytest.mark.asyncio +async def test_read_sessions_all(client: AsyncClient): + """Test reading all sessions.""" + # Create some sessions first using the storage function directly for setup + save_session( + StudySessionCreate(minutes=25, tag="Kubernetes") + ) # Use imported save_session + save_session(StudySessionCreate(minutes=50, tag="AWS")) # Use imported save_session + + response = await client.get("/sessions") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["tag"] == "Kubernetes" + assert data[1]["tag"] == "AWS" + + +@pytest.mark.asyncio +async def test_read_sessions_by_tag(client: AsyncClient): + """Test reading sessions filtered by tag.""" + save_session( + StudySessionCreate(minutes=25, tag="Kubernetes") + ) # Use imported save_session + save_session(StudySessionCreate(minutes=50, tag="AWS")) # Use imported save_session + save_session( + StudySessionCreate(minutes=15, tag="Kubernetes") + ) # Use imported save_session + + response = await client.get("/sessions?tag=Kubernetes") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert all(item["tag"] == "Kubernetes" for item in data) + + response = await client.get("/sessions?tag=AWS") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["tag"] == "AWS" + + response = await client.get("/sessions?tag=NonExistent") + assert response.status_code == 200 + assert response.json() == [] + + +@pytest.mark.asyncio +async def test_read_stats(client: AsyncClient): + """Test reading statistics.""" + save_session( + StudySessionCreate(minutes=25, tag="Kubernetes") + ) # Use imported save_session + save_session(StudySessionCreate(minutes=50, tag="AWS")) # Use imported save_session + save_session( + StudySessionCreate(minutes=15, tag="Kubernetes") + ) # Use imported save_session + + response = await client.get("/stats") + assert response.status_code == 200 + data = response.json() + assert data["total_sessions"] == 3 + assert data["total_time"] == 90 # 25 + 50 + 15 + assert "time_by_tag" in data + assert data["sessions_by_tag"]["Kubernetes"] == 2 + assert data["sessions_by_tag"]["AWS"] == 1 + + +@pytest.mark.asyncio +async def test_read_stats_empty(client: AsyncClient): + """Test reading statistics when no sessions exist.""" + response = await client.get("/stats") + assert response.status_code == 200 + data = response.json() + assert data["total_sessions"] == 0 + assert data["total_time"] == 0 + assert data["time_by_tag"] == {} + assert data["sessions_by_tag"] == {} + + +@pytest.mark.asyncio +async def test_error_handling_create_session(client: AsyncClient, monkeypatch): + """Test error handling in create_session endpoint.""" + + # Mock save_session to raise an exception + def mock_save_session(*args, **kwargs): + raise Exception("Test error") + + monkeypatch.setattr("backend.main.save_session", mock_save_session) + + session_data = {"minutes": 30, "tag": "AWS"} + response = await client.post("/sessions", json=session_data) + assert response.status_code == 500 + assert "Error creating session" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_error_handling_read_sessions(client: AsyncClient, monkeypatch): + """Test error handling in read_sessions endpoint.""" + + # Mock get_all_sessions to raise an exception + def mock_get_all_sessions(*args, **kwargs): + raise Exception("Test error") + + monkeypatch.setattr("backend.main.get_all_sessions", mock_get_all_sessions) + + response = await client.get("/sessions") + assert response.status_code == 500 + assert "Error fetching sessions" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_error_handling_read_sessions_by_tag(client: AsyncClient, monkeypatch): + """Test error handling in read_sessions endpoint with tag filter.""" + + # Mock get_sessions_by_tag to raise an exception + def mock_get_sessions_by_tag(*args, **kwargs): + raise Exception("Test error") + + monkeypatch.setattr("backend.main.get_sessions_by_tag", mock_get_sessions_by_tag) + + response = await client.get("/sessions?tag=AWS") + assert response.status_code == 500 + assert "Error fetching sessions" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_error_handling_read_stats(client: AsyncClient, monkeypatch): + """Test error handling in read_stats endpoint.""" + + # Mock get_statistics to raise an exception + def mock_get_statistics(*args, **kwargs): + raise Exception("Test error") + + monkeypatch.setattr("backend.main.get_statistics", mock_get_statistics) + + response = await client.get("/stats") + assert response.status_code == 500 + assert "Error fetching statistics" in response.json()["detail"] + + +# Test for the main function +def test_main_function(monkeypatch): + """Test the main() function that starts the uvicorn server.""" + # Mock uvicorn.run to prevent actually starting the server + mock_run = mock.Mock() + monkeypatch.setattr("uvicorn.run", mock_run) + + # Call the main function + from backend.main import main + + main() + + # Check that uvicorn.run was called with the expected arguments + mock_run.assert_called_once_with( + "backend.main:app", host="0.0.0.0", port=22112, reload=True + ) diff --git a/src/backend/tests/test_storage.py b/src/backend/tests/test_storage.py new file mode 100644 index 0000000..3dbf23b --- /dev/null +++ b/src/backend/tests/test_storage.py @@ -0,0 +1,195 @@ +import pytest +from datetime import datetime +import os +import uuid +import csv + +# Import functions and variables from storage.py using new package structure +from backend.storage import ( + save_session, + get_all_sessions, + get_sessions_by_tag, + get_statistics, +) +from backend.models import StudySessionCreate, StudySession + +# Import the storage module itself +import backend.storage as storage_module + + +# Define the path for a temporary test data file using relative paths +TEST_DATA_FILE = os.path.join(os.path.dirname(__file__), "test_storage_sessions.csv") + + +@pytest.fixture(scope="module", autouse=True) +def setup_test_data_file(): + """Fixture to set up the test data file path for the storage module tests.""" + # Ensure the test data directory exists + os.makedirs(os.path.dirname(TEST_DATA_FILE), exist_ok=True) + + original_data_file = storage_module.SESSIONS_FILE # Use alias + # Override the SESSIONS_FILE path in the storage module for testing + storage_module.SESSIONS_FILE = TEST_DATA_FILE # Use alias + yield + # Restore the original data file path and clean up + storage_module.SESSIONS_FILE = original_data_file # Use alias + if os.path.exists(TEST_DATA_FILE): + os.remove(TEST_DATA_FILE) + + +@pytest.fixture(scope="function", autouse=True) +def reset_test_data(): + """Fixture to reset the test data file before each test function.""" + if os.path.exists(TEST_DATA_FILE): + os.remove(TEST_DATA_FILE) + # Create an empty file with headers using CSV + with open(TEST_DATA_FILE, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["id", "timestamp", "minutes", "tag"]) + writer.writeheader() + + +def test_save_session(): + """Test saving a single session.""" + session_create = StudySessionCreate(minutes=45, tag="Terraform") + saved_session = save_session(session_create) + + assert isinstance(saved_session, StudySession) + assert saved_session.minutes == 45 + assert saved_session.tag == "Terraform" + assert isinstance(saved_session.id, str) # ID is stored as string in the model + assert uuid.UUID(saved_session.id) # Verify it's a valid UUID string + assert isinstance(saved_session.timestamp, datetime) + + # Verify with CSV reader + with open(TEST_DATA_FILE, "r", newline="") as f: + reader = list(csv.DictReader(f)) + assert len(reader) == 1 + assert int(reader[0]["minutes"]) == 45 + assert reader[0]["tag"] == "Terraform" + assert saved_session.id == reader[0]["id"] # Compare as strings + + +def test_save_multiple_sessions(): + """Test saving multiple sessions sequentially.""" + save_session(StudySessionCreate(minutes=30, tag="Docker")) + save_session(StudySessionCreate(minutes=60, tag="Python")) + + with open(TEST_DATA_FILE, "r", newline="") as f: + reader = list(csv.DictReader(f)) + assert len(reader) == 2 + assert reader[0]["tag"] == "Docker" + assert reader[1]["tag"] == "Python" + + +def test_get_all_sessions(): + """Test retrieving all sessions.""" + save_session(StudySessionCreate(minutes=20, tag="Git")) + save_session(StudySessionCreate(minutes=40, tag="CI/CD")) + + sessions = get_all_sessions() + assert len(sessions) == 2 + assert isinstance(sessions[0], StudySession) + assert sessions[0].tag == "Git" + assert sessions[1].tag == "CI/CD" + + +def test_get_all_sessions_empty(): + """Test retrieving all sessions when none exist.""" + sessions = get_all_sessions() + assert sessions == [] + + +def test_get_sessions_by_tag(): + """Test retrieving sessions filtered by tag.""" + save_session(StudySessionCreate(minutes=25, tag="AWS")) + save_session(StudySessionCreate(minutes=55, tag="Azure")) + save_session(StudySessionCreate(minutes=35, tag="AWS")) + + aws_sessions = get_sessions_by_tag("AWS") + assert len(aws_sessions) == 2 + assert all(s.tag == "AWS" for s in aws_sessions) + + azure_sessions = get_sessions_by_tag("Azure") + assert len(azure_sessions) == 1 + assert azure_sessions[0].tag == "Azure" + + gcp_sessions = get_sessions_by_tag("GCP") + assert gcp_sessions == [] + + +def test_get_statistics(): + """Test calculating statistics.""" + save_session(StudySessionCreate(minutes=10, tag="Linux")) + save_session(StudySessionCreate(minutes=20, tag="Networking")) + save_session(StudySessionCreate(minutes=30, tag="Linux")) + + stats = get_statistics() + assert stats.total_sessions == 3 + assert stats.total_time == 60 # 10 + 20 + 30 + # Calculate average manually if needed + average_minutes = stats.total_time / stats.total_sessions + assert average_minutes == 20.0 + assert stats.sessions_by_tag == {"Linux": 2, "Networking": 1} + + +def test_get_statistics_empty(): + """Test calculating statistics when no sessions exist.""" + stats = get_statistics() + assert stats.total_sessions == 0 + assert stats.total_time == 0 + # Can't calculate average with zero sessions + assert stats.time_by_tag == {} + assert stats.sessions_by_tag == {} + + +def test_create_and_read_sessions(): + """Test creating sessions and then reading them back.""" + # Create test CSV file with two sessions + with open(TEST_DATA_FILE, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["id", "timestamp", "minutes", "tag"]) + writer.writeheader() + writer.writerow( + { + "id": str(uuid.uuid4()), + "timestamp": datetime.now().isoformat(), + "minutes": 15, + "tag": "TestTag1", + } + ) + writer.writerow( + { + "id": str(uuid.uuid4()), + "timestamp": datetime.now().isoformat(), + "minutes": 45, + "tag": "TestTag2", + } + ) + + # Read sessions + sessions = get_all_sessions() + assert len(sessions) == 2 + assert sessions[0].tag == "TestTag1" + assert sessions[1].minutes == 45 + + +def test_file_not_exists_handling(): + """Test handling when the data file doesn't exist.""" + # Rename the test file temporarily + if os.path.exists(TEST_DATA_FILE): + os.rename(TEST_DATA_FILE, f"{TEST_DATA_FILE}.bak") + + try: + # This should create the data file + sessions = get_all_sessions() + assert sessions == [] + + # Verify the file was created with headers + with open(TEST_DATA_FILE, "r", newline="") as f: + content = f.read() + assert "id,timestamp,minutes,tag" in content + finally: + # Restore the original file if it existed + if os.path.exists(f"{TEST_DATA_FILE}.bak"): + if os.path.exists(TEST_DATA_FILE): + os.remove(TEST_DATA_FILE) + os.rename(f"{TEST_DATA_FILE}.bak", TEST_DATA_FILE) diff --git a/src/backend/uv.lock b/src/backend/uv.lock index a6ed303..ddfcf5b 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -33,6 +33,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -78,6 +87,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -87,6 +124,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.12.4" @@ -155,6 +219,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -182,12 +309,20 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, { name = "uvicorn" }, ] [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.121.2" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.1" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "ruff", specifier = ">=0.14.5" }, { name = "uvicorn", specifier = ">=0.38.0" }, ]