Build Typed Python SDK Endpoints

Add typed Pydantic models and read-only endpoints to a Python SDK.

Introduction

30 Second Summary

A label turns a box of loose parts into something you can trust at a glance. Without clear labels, every item has to be inspected before you know how to use it.

In this project, you will extend the existing Simpro SDK so twelve resources arrive as validated Python objects through Pydantic models. You will expose those models through read-only endpoints without changing the raw request behavior.

What You'll Build

You will run the offline test suite and watch exact nested routes, filters, typed responses, plus page metadata pass without a live mock service.

By the end of this project, you'll have:

  • Request typed resources by ID and receive the correct model for companies, customers, jobs, quotes, contacts, sites, assets, employees, projects, job notes, attachments, or statuses.
  • Fetch one typed page through the correct company, customer, site, or job scope while preserving filters plus pagination metadata.
  • Prove offline compatibility with RESPX tests that check exact requests while confirming the original raw client still behaves the same.
  • Secret Mission: Add a route-template inventory test that detects contract drift before an HTTP request is sent.

Are there any prerequisites?

You need access to the existing CLIVE-I checkout. The preflight checks your Git state plus Python 3.12 or newer, uv, Docker, and the locked environment before any source edits.

Before We Start

Your existing CLIVE-I checkout in VS Code is the foundation for an additive typed Simpro SDK layer. The Sprint 1 raw API stays compatible while write methods, lazy pagination, retry changes, rate limiting, schema work, mock routes, and a final Sprint 4 commit stay outside this project.

Protect the Baseline

A typed SDK layer can only be trusted when it is built against the checkout you actually have. Git history forms the safety boundary for every later edit.

This preflight records that baseline before any source file changes. It also verifies that the toolchain can collect the existing tests without a running mock service.

In this step, get ready to:
  • Compare the current commit with the approved baseline.
  • Verify the Python and uv toolchain.
  • Confirm pytest collection works with Docker Compose stopped.
Check the Git baseline

The approved commit gives you a known reference point for the typed SDK work. The working-tree checks expose local differences without altering them.

  • Capture the current commit plus the working-tree state by running these commands in the VS Code terminal from earlier:
git rev-parse --verify HEAD
git status --short
git diff --stat

What do these checks prove?

  • The first line identifies the exact commit at HEAD.
  • The short status exposes staged files plus unstaged files plus untracked files.
  • The diffstat summarizes working-tree changes without modifying them.
  • Compare the printed commit hash with c2c37a184b7eb8b403bfbeb2257b69291ba772b3.
  • Record the current commit hash in preflight notes outside the repository.
  • Record the short status output in the same notes.
  • Record the diffstat output in the same notes.

✔️ The baseline matches

Your checkout matches the reviewed commit. A blank status plus a blank diffstat confirms that the working tree is clean.

ⓧ The checkout differs

A different commit or unexpected working-tree entry needs review before implementation begins.

  • Stop the preflight here.
  • Document the printed commit hash in notes outside the repository.
  • Document every reported path in the same notes.
  • Keep all local work untouched.

Protect the Existing Checkout

Do not reset the repository. Do not clean the working tree.

Do not stash local work. Do not switch commits or create a commit during this preflight.

Verify Python and uv

The project requires Python 3.12 or newer. The uv command synchronizes the repository's locked environment.

  • Check both tool versions by running these commands:
python --version
uv --version

What do these version checks prove?

The Python output confirms which interpreter the terminal resolves. It must report version 3.12 or newer.

The uv output confirms that the project environment manager is available from the same terminal.

✔️ I see the required Python version

Python meets the repository requirement.

  • Continue with the uv result below.

ⓧ I see an older Python version

The resolved interpreter is below the repository minimum.

  • Follow the official Python Unix installation guide to install Python 3.12 or newer.
  • Return to the CLIVE-I repository root in the VS Code terminal from earlier.
  • Repeat the Python version check shown above.

ⓧ The Python command is missing

The terminal cannot currently resolve the required interpreter.

  • Follow the official Python Unix installation guide to install Python 3.12 or newer.
  • Return to the CLIVE-I repository root in the VS Code terminal from earlier.
  • Repeat the Python version check shown above.

✔️ I see a uv version

The uv command is available for the dependency sync.

  • Continue to the environment sync below.

ⓧ The uv command is missing

The terminal cannot currently resolve uv.

  • Follow the official uv installation guide for Linux.
  • Return to the CLIVE-I repository root in the VS Code terminal from earlier.
  • Repeat the uv version check shown above.
  • Synchronize the locked project environment by running this command:
uv sync

What does this command do?

The command synchronizes the project environment from the repository configuration. It installs the project in editable form.

A normal sync already includes the default dev dependency group. This prepares the pinned testing plus linting tools used later.

You should see the environment resolve successfully. The sync finishes without requiring source-code edits.

Environment Sync Failing?

  • Confirm the Python result above reports 3.12 or newer.
  • Confirm the uv version check succeeds in the same terminal.
  • Still stuck? Help me diagnose my uv sync failure.
Prove collection stays offline

Offline collection proves that pytest can discover the suite without contacting the live mock during import. Stopping Docker Compose makes that boundary visible.

  • Stop the local mock stack by running this command:
docker compose down

What does this shutdown preserve?

Docker Compose stops the stack before the collection check. The command omits -v, so existing volumes remain intact.

The command should finish with the mock services stopped. An already-stopped stack can also complete without removing project files.

Docker Compose Shutdown Failing?

  • Confirm you are still at the CLIVE-I repository root in the terminal from earlier.
  • Confirm Docker is available in your Ubuntu environment.
  • Still stuck? Help me stop the CLIVE-I Docker Compose stack safely.

Before you collect the tests, do you expect pytest to contact the stopped mock service?

  • Collect the test suite without running it by using this command:
uv run pytest --collect-only -q

What does this collection check prove?

The --collect-only flag discovers the tests without executing them. The -q flag keeps the report concise.

Successful collection proves that importing the suite does not depend on a running mock service.

You'll see pytest list the collected tests before finishing successfully. No live mock connection is required during collection.

Collection Not Completing Offline?

  • Confirm the environment sync completed successfully.
  • Confirm the command is running from the CLIVE-I repository root.
  • Still stuck? Help me diagnose offline pytest collection.
  • Reconfirm that future model work stays under src/simpro_client/models/.
  • Reconfirm that future endpoint work stays under src/simpro_client/endpoints/.
  • Keep the planned SimproClient integration additive.
  • Limit supporting changes to focused tests plus pytest configuration in pyproject.toml.
  • Keep write methods plus lazy pagination plus retries plus rate limiting outside this project.
  • Keep schema files plus mock-route files unchanged.
  • Leave the final Sprint 4 commit for later.

Before the final snapshot, do you expect the commit or working tree to differ from the baseline you recorded?

  • Confirm the repository still matches the recorded preflight state by running these commands:
git rev-parse --verify HEAD
git status --short
git diff --stat

What confirms a protected baseline?

The commit hash should still match c2c37a184b7eb8b403bfbeb2257b69291ba772b3.

The short status plus diffstat should match the clean outputs recorded at the start. This confirms the preflight changed no project files.

That's the safety check complete. Your approved checkout is intact while the locked environment can collect tests with the mock stopped.

Your baseline is protected. Next, you'll add a focused test that makes the missing typed model contract visible.

Expose the Typed Model Contract

Typed SDK behavior needs a written contract before implementation begins. Without that contract, a model can appear correct while mishandling API aliases, optional fields, or temporal values.

The current client returns raw dictionaries. In this step, you will add a focused test contract that defines how twelve Pydantic models must validate committed Simpro payloads.

In this step, get ready to:
  • Add one model contract test covering all twelve resources.
  • Trace the assertions for aliases, field rules, unknown data, and temporal parsing.
  • Run the focused suite with Docker Compose stopped to expose the missing typed package.
Add the model contract test

A representative contract test turns the committed mock payloads into executable expectations. Each resource gets one realistic PascalCase payload plus checks for the field behavior shared across the model layer.

  • In the VS Code file tree from earlier, select the tests folder.
  • Use the new-file control beside the folder to create tests/test_models.py.
  • Add the complete committed contract shown in the full-code tab below.

✔️ Awesome, I've got everything!

  • Save tests/test_models.py in the CLIVE-I folder.

Your repository now contains the executable contract for the typed model layer.

ⓧ I'd like to double check the full code

Compare tests/test_models.py with this complete committed contract.

"""Contract tests for typed Simpro resources."""

from datetime import date, datetime

import pytest
from pydantic import ValidationError

from simpro_client.models import (
    Asset,
    Attachment,
    Company,
    Contact,
    Customer,
    Employee,
    Job,
    JobNote,
    Project,
    Quote,
    Site,
    Status,
)

MODEL_CASES = [
    (Company, {"ID": 1, "Name": "CVC"}, "Name", []),
    (
        Customer,
        {
            "ID": 2,
            "CompanyID": 1,
            "GivenName": "Ada",
            "FamilyName": "Lovelace",
            "Email": "ada@example.test",
            "Phone": "555-0100",
        },
        "GivenName",
        ["Email", "Phone"],
    ),
    (
        Job,
        {
            "ID": 3,
            "CompanyID": 1,
            "Name": "Service",
            "Status": "Open",
            "DateIssued": "2026-09-07",
            "Total": 120.5,
        },
        "Status",
        ["DateIssued"],
    ),
    (
        Quote,
        {
            "ID": 4,
            "CompanyID": 1,
            "CustomerID": 2,
            "Name": "Quote",
            "Status": "Draft",
            "Total": 99.0,
        },
        "Name",
        ["CustomerID"],
    ),
    (
        Contact,
        {
            "ID": 5,
            "CompanyID": 1,
            "CustomerID": 2,
            "GivenName": "Grace",
            "FamilyName": "Hopper",
            "Position": "Engineer",
            "Email": "grace@example.test",
            "Phone": "555-0101",
        },
        "FamilyName",
        ["Position", "Email", "Phone"],
    ),
    (
        Site,
        {
            "ID": 6,
            "CompanyID": 1,
            "CustomerID": 2,
            "Name": "Plant",
            "Address": "1 Main St",
            "City": "Melbourne",
            "Postcode": "3000",
            "State": "VIC",
            "Country": "AU",
        },
        "Name",
        ["Address", "City", "Postcode", "State", "Country"],
    ),
    (
        Asset,
        {
            "ID": 7,
            "CompanyID": 1,
            "SiteID": 6,
            "AssetNo": "A-7",
            "Name": "Pump",
            "SerialNo": "SN-7",
            "Model": "M1",
            "Manufacturer": "Maker",
            "InstalledDate": "2026-01-02",
        },
        "AssetNo",
        ["SerialNo", "Model", "Manufacturer", "InstalledDate"],
    ),
    (
        Employee,
        {
            "ID": 8,
            "CompanyID": 1,
            "GivenName": "Linus",
            "FamilyName": "Torvalds",
            "Position": "Engineer",
            "Email": "linus@example.test",
            "Phone": "555-0102",
        },
        "GivenName",
        ["Position", "Email", "Phone"],
    ),
    (
        Project,
        {
            "ID": 9,
            "CompanyID": 2,
            "CustomerID": 2,
            "SiteID": 6,
            "Name": "Upgrade",
            "Status": "Active",
            "Total": 1000.0,
        },
        "CustomerID",
        ["SiteID"],
    ),
    (
        JobNote,
        {
            "ID": 10,
            "JobID": 3,
            "Subject": "Visit",
            "Note": "Completed",
            "CreatedBy": 8,
            "CreatedAt": "2026-09-07T10:30:00",
        },
        "JobID",
        ["Subject", "Note", "CreatedBy", "CreatedAt"],
    ),
    (
        Attachment,
        {
            "ID": 11,
            "JobID": 3,
            "Filename": "photo.jpg",
            "MimeType": "image/jpeg",
            "FileSize": 2048,
            "UploadedAt": "2026-09-07T10:31:00",
        },
        "Filename",
        ["MimeType", "FileSize", "UploadedAt"],
    ),
    (
        Status,
        {
            "ID": 12,
            "CompanyID": 1,
            "Name": "Open",
            "Category": "Job",
            "IsDefault": True,
        },
        "IsDefault",
        ["Category"],
    ),
]


@pytest.mark.parametrize(
    ("model_type", "payload", "required_alias", "optional_aliases"), MODEL_CASES
)
def test_models_accept_aliases_and_ignore_unknown_fields(
    model_type, payload, required_alias, optional_aliases
):
    with_extra = {**payload, "FutureAPIField": "ignored"}
    model = model_type.model_validate(with_extra)

    assert model.id == payload["ID"]
    assert not hasattr(model, "FutureAPIField")
    assert model_type.model_validate(model.model_dump()).id == payload["ID"]

    missing_required = payload.copy()
    missing_required.pop(required_alias)
    with pytest.raises(ValidationError):
        model_type.model_validate(missing_required)

    without_optional = payload.copy()
    for alias in optional_aliases:
        without_optional.pop(alias)
    model_type.model_validate(without_optional)


def test_temporal_fields_use_python_types():
    assert isinstance(Job.model_validate(MODEL_CASES[2][1]).date_issued, date)
    assert isinstance(Asset.model_validate(MODEL_CASES[6][1]).installed_date, date)
    assert isinstance(JobNote.model_validate(MODEL_CASES[9][1]).created_at, datetime)
    assert isinstance(
        Attachment.model_validate(MODEL_CASES[10][1]).uploaded_at, datetime
    )

What does this contract test?

  • The MODEL_CASES matrix provides one committed payload for every resource class.
  • The parameterized test validates API aliases. It rebuilds each model from snake_case output.
  • The required-field check expects a ValidationError when a mandatory API key is absent.
  • The optional-field check removes every optional API key. Model validation must still succeed.
  • The temporal test requires four API strings to become Python date or datetime values.
  • Confirm Git sees the new contract file by running this command in the CLIVE-I folder:
git status --short

What should the status show?

The short status output should identify the new model test file as untracked. This confirms the contract exists without changing any source package.

Test file missing from the status?

  • Check that the file is saved at tests/test_models.py inside the CLIVE-I folder.
  • Check that the filename uses the exact .py extension.
  • Still stuck? Help me locate my new model contract test in Git status.
Trace the protected behavior

The payload matrix protects the exact wire contract while the assertions protect Python-facing behavior. Reviewing both parts makes the eventual implementation target explicit before any model code exists.

  • Review MODEL_CASES to confirm that all twelve imported resource classes appear once.
  • Trace test_models_accept_aliases_and_ignore_unknown_fields() to identify the alias, snake_case, required-field, optional-field, and unknown-field checks.
  • Inspect test_temporal_fields_use_python_types() to identify the four fields that must become Python temporal values.

Why use representative payloads?

The Simpro payload keys include acronym-heavy names such as ID, CompanyID, and AssetNo. Representative payloads make each spelling visible in the test contract.

The shared assertions then apply the same behavioral rules across all twelve resources. One failed case points directly to the model whose contract drifted.

Run the expected red test

A red test proves that the new contract can detect the missing typed layer. The focused command also keeps the check offline while Docker Compose remains stopped.

Before you run this, make a prediction about whether Python can import the model package that has not been added to src/simpro_client.

  • Run the focused model contract suite by running this command:
uv run pytest -q tests/test_models.py

Why this failure is useful

Pytest should stop during collection while importing simpro_client.models. The missing package is the exact gap this test was designed to expose.

No HTTP request is needed to reach this result. The test contract has isolated the absent typed layer before implementation begins.

Seeing a different failure?

  • Compare tests/test_models.py with the full committed contract if collection reports a syntax problem.
  • Confirm the terminal remains in the CLIVE-I folder if the test path cannot be found.
  • Still stuck? Help me diagnose my focused model contract test failure.

That red test is doing its job. Your repository now has a precise typed-model contract that the next step can satisfy with explicit aliases and Python temporal values.

Build Typed Resource Models

Your focused contract test now captures the typed behavior the SDK must support. Its collection failure proves the resource package is still missing.

In this step, you will build the shared Pydantic configuration plus all twelve resource models. The model suite runs offline, so Docker Compose stays stopped.

In this step, get ready to:
  • Configure shared validation behavior for every resource model.
  • Make all twelve resource models importable from the package.
  • Prove the focused model contract passes offline.
Define shared validation behavior

Every resource needs the same rules for handling API payloads. A shared base model keeps those rules consistent across all twelve classes.

Why use explicit aliases?

The API uses keys such as ID, CompanyID, AssetNo, and MimeType. Explicit Field(alias="...") declarations preserve those exact spellings while exposing snake_case attributes in Python.

The shared configuration also ignores future API fields. This lets existing models continue validating when the service adds unrelated response data.

  • In the VS Code file sidebar from earlier, select the src/simpro_client folder.
  • Create a models folder inside src/simpro_client using the new-folder icon.

You should see models nested directly beneath src/simpro_client in the file sidebar.

  • Select the new models folder.
  • Create base.py using the new-file icon.
  • Define SimproBaseModel with the shared configuration shown in the full-code check later in this step.
  • Save src/simpro_client/models/base.py.

The saved file now provides one source of truth for alias validation plus unknown-field handling.

What belongs in the base model?

  • The extra="ignore" setting accepts payloads containing fields the model does not declare.
  • The validate_by_alias=True setting accepts committed PascalCase API keys.
  • The validate_by_name=True setting accepts snake_case Python field names.
Add the customer and work models

The first resource group covers companies plus the customer-facing work hierarchy. Each class inherits the shared behavior while declaring its own exact wire aliases.

What does this model group cover?

  • The Company model represents the top-level company identity.
  • The Customer model carries a company scope plus contact details.
  • The Job model converts DateIssued into a Python date value.
  • The Quote model allows CustomerID to be absent.
  • The Contact model belongs to both a company and a customer.
  • The Site model keeps address fields optional.
  • Create company.py inside src/simpro_client/models.
  • Define Company with explicit aliases for ID and Name.
  • Save company.py.
  • Create customer.py inside src/simpro_client/models.
  • Define Customer with the required identity fields plus optional email and phone fields.
  • Save customer.py.

The file sidebar should now show both modules. Their tabs should no longer display unsaved-change markers.

  • Create job.py inside src/simpro_client/models.
  • Define Job with an optional date_issued field typed as date | None.
  • Save job.py.
  • Create quote.py inside src/simpro_client/models.
  • Define Quote with an optional customer_id field.
  • Save quote.py.

The resource package now contains the two work-value models. Their date and optional relationship types match the committed test payloads.

  • Create contact.py inside src/simpro_client/models.
  • Define Contact with required customer identity fields plus optional contact details.
  • Save contact.py.
  • Create site.py inside src/simpro_client/models.
  • Define Site with required scope fields plus optional address fields.
  • Save site.py.

You should now see six saved resource modules beside base.py.

Complete and export the model package

The remaining resources add site-scoped assets plus job-scoped records. This group also includes the package export file that makes every model available through simpro_client.models.

Watch the temporal and schema types

The committed mock schema defines JobNote.created_by as int | None. The executable schema is the contract used here.

The installed_date field uses date | None. The created_at and uploaded_at fields use datetime | None.

  • Create asset.py inside src/simpro_client/models.
  • Define Asset with its site scope plus optional equipment details.
  • Save asset.py.
  • Create employee.py inside src/simpro_client/models.
  • Define Employee with required identity fields plus optional contact details.
  • Save employee.py.

Both saved modules should now appear in the model package.

  • Create project.py inside src/simpro_client/models.
  • Define Project with an optional site relationship.
  • Save project.py.
  • Create job_note.py inside src/simpro_client/models.
  • Define JobNote with optional note metadata plus the committed creator type.
  • Save job_note.py.

The project and job-note modules should now be visible without unsaved-change markers.

  • Create attachment.py inside src/simpro_client/models.
  • Define Attachment with required file identity plus optional metadata.
  • Save attachment.py.
  • Create status.py inside src/simpro_client/models.
  • Define Status with an optional category plus required default-state field.
  • Save status.py.

All twelve resource modules now exist in the file sidebar.

  • Create __init__.py inside src/simpro_client/models.
  • Import all twelve model classes into __init__.py.
  • Declare all twelve class names in __all__.
  • Save src/simpro_client/models/__init__.py.

The package structure is complete. Use the comparison below to catch any alias or type drift before running the contract suite.

✔️ Awesome, I've got everything

Strong work. Your shared configuration plus twelve resource modules are saved and exported from simpro_client.models.

ⓧ I'd like to double check the full code

  • Compare src/simpro_client/models/base.py with this complete file:
"""Shared configuration for typed Simpro resources."""

from pydantic import BaseModel, ConfigDict


class SimproBaseModel(BaseModel):
    """Accept API aliases while exposing Python field names."""

    model_config = ConfigDict(
        extra="ignore",
        validate_by_alias=True,
        validate_by_name=True,
    )

How does the base model help?

Every resource inherits the same rules for aliases plus unknown fields. This prevents configuration drift between model modules.

  • Compare src/simpro_client/models/company.py with this complete file:
from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class Company(SimproBaseModel):
    id: int = Field(alias="ID")
    name: str = Field(alias="Name")

How does Company map its payload?

The explicit aliases convert ID and Name into the Python attributes id and name.

  • Compare src/simpro_client/models/customer.py with this complete file:
from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class Customer(SimproBaseModel):
    id: int = Field(alias="ID")
    company_id: int = Field(alias="CompanyID")
    given_name: str = Field(alias="GivenName")
    family_name: str = Field(alias="FamilyName")
    email: str | None = Field(default=None, alias="Email")
    phone: str | None = Field(default=None, alias="Phone")

How does Customer handle optional data?

The identity fields remain required. Email plus phone default to None when the API omits them.

  • Compare src/simpro_client/models/job.py with this complete file:
from datetime import date

from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class Job(SimproBaseModel):
    id: int = Field(alias="ID")
    company_id: int = Field(alias="CompanyID")
    name: str = Field(alias="Name")
    status: str = Field(alias="Status")
    date_issued: date | None = Field(default=None, alias="DateIssued")
    total: float = Field(alias="Total")

How does Job parse dates?

Pydantic converts a valid DateIssued string into a Python date value. Missing dates remain None.

  • Compare src/simpro_client/models/quote.py with this complete file:
from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class Quote(SimproBaseModel):
    id: int = Field(alias="ID")
    company_id: int = Field(alias="CompanyID")
    customer_id: int | None = Field(default=None, alias="CustomerID")
    name: str = Field(alias="Name")
    status: str = Field(alias="Status")
    total: float = Field(alias="Total")

How does Quote handle customer scope?

The quote always belongs to a company. Its customer relationship can be absent from a valid payload.

  • Compare src/simpro_client/models/contact.py with this complete file:
from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class Contact(SimproBaseModel):
    id: int = Field(alias="ID")
    company_id: int = Field(alias="CompanyID")
    customer_id: int = Field(alias="CustomerID")
    given_name: str = Field(alias="GivenName")
    family_name: str = Field(alias="FamilyName")
    position: str | None = Field(default=None, alias="Position")
    email: str | None = Field(default=None, alias="Email")
    phone: str | None = Field(default=None, alias="Phone")

How does Contact preserve its scope?

The required company plus customer identifiers preserve the nested relationship. Personal contact fields can remain absent.

  • Compare src/simpro_client/models/site.py with this complete file:
from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class Site(SimproBaseModel):
    id: int = Field(alias="ID")
    company_id: int = Field(alias="CompanyID")
    customer_id: int = Field(alias="CustomerID")
    name: str = Field(alias="Name")
    address: str | None = Field(default=None, alias="Address")
    city: str | None = Field(default=None, alias="City")
    postcode: str | None = Field(default=None, alias="Postcode")
    state: str | None = Field(default=None, alias="State")
    country: str | None = Field(default=None, alias="Country")

How does Site handle partial addresses?

The site identity plus ownership fields stay required. Every address component can be omitted independently.

  • Compare src/simpro_client/models/asset.py with this complete file:
from datetime import date

from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class Asset(SimproBaseModel):
    id: int = Field(alias="ID")
    company_id: int = Field(alias="CompanyID")
    site_id: int = Field(alias="SiteID")
    asset_no: str = Field(alias="AssetNo")
    name: str = Field(alias="Name")
    serial_no: str | None = Field(default=None, alias="SerialNo")
    model: str | None = Field(default=None, alias="Model")
    manufacturer: str | None = Field(default=None, alias="Manufacturer")
    installed_date: date | None = Field(default=None, alias="InstalledDate")

How does Asset preserve API spellings?

Explicit aliases preserve spellings such as AssetNo and SerialNo. The installation date becomes a Python date value.

  • Compare src/simpro_client/models/employee.py with this complete file:
from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class Employee(SimproBaseModel):
    id: int = Field(alias="ID")
    company_id: int = Field(alias="CompanyID")
    given_name: str = Field(alias="GivenName")
    family_name: str = Field(alias="FamilyName")
    position: str | None = Field(default=None, alias="Position")
    email: str | None = Field(default=None, alias="Email")
    phone: str | None = Field(default=None, alias="Phone")

How does Employee handle contact details?

Employee identity fields remain required. Position plus contact values default to None when absent.

  • Compare src/simpro_client/models/project.py with this complete file:
from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class Project(SimproBaseModel):
    id: int = Field(alias="ID")
    company_id: int = Field(alias="CompanyID")
    customer_id: int = Field(alias="CustomerID")
    site_id: int | None = Field(default=None, alias="SiteID")
    name: str = Field(alias="Name")
    status: str = Field(alias="Status")
    total: float = Field(alias="Total")

How does Project represent its location?

A project always carries company plus customer scope. Its site relationship remains optional.

  • Compare src/simpro_client/models/job_note.py with this complete file:
from datetime import datetime

from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class JobNote(SimproBaseModel):
    id: int = Field(alias="ID")
    job_id: int = Field(alias="JobID")
    subject: str | None = Field(default=None, alias="Subject")
    note: str | None = Field(default=None, alias="Note")
    created_by: int | None = Field(default=None, alias="CreatedBy")
    created_at: datetime | None = Field(default=None, alias="CreatedAt")

How does JobNote match the mock schema?

The creator identifier uses int | None exactly as the committed mock schema declares. The creation timestamp becomes a Python datetime value.

  • Compare src/simpro_client/models/attachment.py with this complete file:
from datetime import datetime

from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class Attachment(SimproBaseModel):
    id: int = Field(alias="ID")
    job_id: int = Field(alias="JobID")
    filename: str = Field(alias="Filename")
    mime_type: str | None = Field(default=None, alias="MimeType")
    file_size: int | None = Field(default=None, alias="FileSize")
    uploaded_at: datetime | None = Field(default=None, alias="UploadedAt")

How does Attachment parse metadata?

The filename remains required. MIME type plus file size plus upload time can be absent from the payload.

  • Compare src/simpro_client/models/status.py with this complete file:
from pydantic import Field

from simpro_client.models.base import SimproBaseModel


class Status(SimproBaseModel):
    id: int = Field(alias="ID")
    company_id: int = Field(alias="CompanyID")
    name: str = Field(alias="Name")
    category: str | None = Field(default=None, alias="Category")
    is_default: bool = Field(alias="IsDefault")

How does Status preserve its default flag?

The IsDefault alias maps to the snake_case is_default attribute. Category remains optional.

  • Compare src/simpro_client/models/__init__.py with this complete file:
from simpro_client.models.asset import Asset
from simpro_client.models.attachment import Attachment
from simpro_client.models.company import Company
from simpro_client.models.contact import Contact
from simpro_client.models.customer import Customer
from simpro_client.models.employee import Employee
from simpro_client.models.job import Job
from simpro_client.models.job_note import JobNote
from simpro_client.models.project import Project
from simpro_client.models.quote import Quote
from simpro_client.models.site import Site
from simpro_client.models.status import Status

__all__ = [
    "Asset",
    "Attachment",
    "Company",
    "Contact",
    "Customer",
    "Employee",
    "Job",
    "JobNote",
    "Project",
    "Quote",
    "Site",
    "Status",
]

How does the package export work?

The imports expose every model through simpro_client.models. The __all__ list documents the package's public model surface.

Before you run the focused suite, do you expect the earlier collection failure to remain or disappear?

  • Run the focused model contract from the repository terminal:
uv run pytest -q tests/test_models.py

What does this check prove?

The focused suite imports every exported model. It checks aliases plus required fields plus optional fields plus unknown-field handling plus temporal conversion.

You should see every model case pass. The earlier collection failure is gone because simpro_client.models now exists and exports all twelve classes.

Still seeing a model test failure?

  • Check that every module filename matches the import in src/simpro_client/models/__init__.py.
  • Compare the failing field against its exact PascalCase alias in the full-code tab.
  • Confirm every optional field uses default=None.
  • Still stuck? Help me diagnose my failing typed model contract test.

That is the typed contract locked in. Your SDK can now convert committed PascalCase payloads into validated Python objects without changing the existing raw client.

Next, you will connect these models to reusable read-only endpoint surfaces while preserving raw request behavior.

Add Typed Read-Only Endpoints

Your twelve Pydantic models now turn committed API payloads into validated Python objects. The client still returns decoded dictionaries because no endpoint layer connects those models to requests.

This step adds typed read-only endpoints while preserving the raw client contract. A private HTTPX response path will retain pagination headers without changing what existing callers receive.

In this step, get ready to:
  • Build a reusable endpoint and page foundation.
  • Expose twelve typed resources without changing raw request results.
  • Prove routes and pagination behavior with offline tests.
Build the generic endpoint foundation

Each resource needs the same operations for retrieving one item or one page. A generic ResourceEndpoint centralizes that behavior while thin resource classes supply their model and route templates.

  • In the Explorer sidebar in VS Code, create the src/simpro_client/endpoints folder with the New Folder control.
  • Create each endpoint file shown in the reference below with the New File control.

✔️ I created the endpoint package

Your endpoints package now contains the generic behavior and twelve route-specific endpoint classes.

ⓧ I'd like to double check the full code

Compare every file in src/simpro_client/endpoints with these committed versions.

"""Reusable read-only endpoint behavior."""

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Generic, Mapping, TypeVar

from pydantic import BaseModel

if TYPE_CHECKING:
    from httpx import Headers

    from simpro_client.client import SimproClient

ModelT = TypeVar("ModelT", bound=BaseModel)
FilterValue = str | int | float | bool


@dataclass(frozen=True)
class Page(Generic[ModelT]):
    items: list[ModelT]
    page: int
    page_size: int
    total: int
    count: int
    pages: int


class ResourceEndpoint(Generic[ModelT]):
    model: type[ModelT]
    collection_path: str
    detail_path: str
    item_key: str

    def __init__(self, client: "SimproClient") -> None:
        self._client = client

    def get(
        self,
        item_id: int,
        *,
        company_id: int | None = None,
        **scope_ids: int,
    ) -> ModelT:
        values = self._route_values(company_id, scope_ids)
        values[self.item_key] = item_id
        payload = self._client.get(self._render(self.detail_path, values))
        return self.model.model_validate(payload)

    def fetch_page(
        self,
        *,
        company_id: int | None = None,
        page: int = 1,
        page_size: int = 30,
        filters: Mapping[str, FilterValue] | None = None,
        **scope_ids: int,
    ) -> Page[ModelT]:
        values = self._route_values(company_id, scope_ids)
        params: dict[str, Any] = dict(filters or {})
        params["page"] = page
        params["pageSize"] = page_size
        response = self._client._get_response(
            self._render(self.collection_path, values), params=params
        )
        items = [self.model.model_validate(item) for item in response.json()]
        return Page(
            items=items,
            page=page,
            page_size=page_size,
            total=self._header_int(response.headers, "Result-Total"),
            count=self._header_int(response.headers, "Result-Count"),
            pages=self._header_int(response.headers, "Result-Pages"),
        )

    @staticmethod
    def _route_values(
        company_id: int | None, scope_ids: Mapping[str, int]
    ) -> dict[str, int]:
        values = dict(scope_ids)
        if company_id is not None:
            values["company_id"] = company_id
        return values

    @staticmethod
    def _render(template: str, values: Mapping[str, int]) -> str:
        try:
            return template.format(**values)
        except KeyError as exc:
            raise ValueError(f"Missing route parameter: {exc.args[0]}") from exc

    @staticmethod
    def _header_int(headers: "Headers", name: str) -> int:
        try:
            return int(headers[name])
        except KeyError as exc:
            raise ValueError(f"Missing pagination header: {name}") from exc
        except ValueError as exc:
            raise ValueError(f"Invalid pagination header: {name}") from exc
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import Company


class CompaniesEndpoint(ResourceEndpoint[Company]):
    model = Company
    collection_path = "/companies/"
    detail_path = "/companies/{company_id}"
    item_key = "company_id"
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import Customer


class CustomersEndpoint(ResourceEndpoint[Customer]):
    model = Customer
    collection_path = "/companies/{company_id}/customers/"
    detail_path = "/companies/{company_id}/customers/{customer_id}"
    item_key = "customer_id"
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import Job


class JobsEndpoint(ResourceEndpoint[Job]):
    model = Job
    collection_path = "/companies/{company_id}/jobs/"
    detail_path = "/companies/{company_id}/jobs/{job_id}"
    item_key = "job_id"
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import Quote


class QuotesEndpoint(ResourceEndpoint[Quote]):
    model = Quote
    collection_path = "/companies/{company_id}/quotes/"
    detail_path = "/companies/{company_id}/quotes/{quote_id}"
    item_key = "quote_id"
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import Contact


class ContactsEndpoint(ResourceEndpoint[Contact]):
    model = Contact
    collection_path = "/companies/{company_id}/customers/{customer_id}/contacts/"
    detail_path = (
        "/companies/{company_id}/customers/{customer_id}/contacts/{contact_id}"
    )
    item_key = "contact_id"
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import Site


class SitesEndpoint(ResourceEndpoint[Site]):
    model = Site
    collection_path = "/companies/{company_id}/sites/"
    detail_path = "/companies/{company_id}/sites/{site_id}"
    item_key = "site_id"
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import Asset


class AssetsEndpoint(ResourceEndpoint[Asset]):
    model = Asset
    collection_path = "/companies/{company_id}/sites/{site_id}/assets/"
    detail_path = "/companies/{company_id}/sites/{site_id}/assets/{asset_id}"
    item_key = "asset_id"
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import Employee


class EmployeesEndpoint(ResourceEndpoint[Employee]):
    model = Employee
    collection_path = "/companies/{company_id}/employees/"
    detail_path = "/companies/{company_id}/employees/{employee_id}"
    item_key = "employee_id"
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import Project


class ProjectsEndpoint(ResourceEndpoint[Project]):
    model = Project
    collection_path = "/companies/{company_id}/projects/"
    detail_path = "/companies/{company_id}/projects/{project_id}"
    item_key = "project_id"
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import JobNote


class JobNotesEndpoint(ResourceEndpoint[JobNote]):
    model = JobNote
    collection_path = "/companies/{company_id}/jobs/{job_id}/notes/"
    detail_path = "/companies/{company_id}/jobs/{job_id}/notes/{note_id}"
    item_key = "note_id"
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import Attachment


class AttachmentsEndpoint(ResourceEndpoint[Attachment]):
    model = Attachment
    collection_path = "/companies/{company_id}/jobs/{job_id}/attachments/"
    detail_path = (
        "/companies/{company_id}/jobs/{job_id}/attachments/{attachment_id}"
    )
    item_key = "attachment_id"
from simpro_client.endpoints.base import ResourceEndpoint
from simpro_client.models import Status


class StatusesEndpoint(ResourceEndpoint[Status]):
    model = Status
    collection_path = "/companies/{company_id}/statuses/"
    detail_path = "/companies/{company_id}/statuses/{status_id}"
    item_key = "status_id"
from simpro_client.endpoints.asset import AssetsEndpoint
from simpro_client.endpoints.attachment import AttachmentsEndpoint
from simpro_client.endpoints.base import Page, ResourceEndpoint
from simpro_client.endpoints.company import CompaniesEndpoint
from simpro_client.endpoints.contact import ContactsEndpoint
from simpro_client.endpoints.customer import CustomersEndpoint
from simpro_client.endpoints.employee import EmployeesEndpoint
from simpro_client.endpoints.job import JobsEndpoint
from simpro_client.endpoints.job_note import JobNotesEndpoint
from simpro_client.endpoints.project import ProjectsEndpoint
from simpro_client.endpoints.quote import QuotesEndpoint
from simpro_client.endpoints.site import SitesEndpoint
from simpro_client.endpoints.status import StatusesEndpoint

__all__ = [
    "AssetsEndpoint",
    "AttachmentsEndpoint",
    "CompaniesEndpoint",
    "ContactsEndpoint",
    "CustomersEndpoint",
    "EmployeesEndpoint",
    "JobsEndpoint",
    "JobNotesEndpoint",
    "Page",
    "ProjectsEndpoint",
    "QuotesEndpoint",
    "ResourceEndpoint",
    "SitesEndpoint",
    "StatusesEndpoint",
]

How does the endpoint foundation work?

  • The generic Page stores typed items with the requested page values and three response-header totals.
  • The get() method renders a detail route before validating the decoded payload with its resource model.
  • The fetch_page() method forwards filters with page and pageSize before converting every returned item.
  • The route renderer raises a value error when a required company or parent-resource identifier is missing.

Seeing an endpoint import error?

  • Check that every endpoint file is inside src/simpro_client/endpoints.
  • Compare each class name with the exports in src/simpro_client/endpoints/__init__.py.
  • Still stuck? Help me trace an endpoint package import error.
Preserve raw requests while exposing typed resources

Existing callers depend on get(), post(), patch(), and delete() returning decoded JSON. The typed page path needs the full response because pagination values live in headers.

Why preserve a private response path?

Changing the public raw methods to return responses would break earlier callers. The private _request_response() method keeps transport behavior in one place while _request() preserves the established decoded result.

Typed page requests use _get_response() only when they need headers. This makes the endpoint layer additive and backward compatible.

  • Replace src/simpro_client/client.py with the committed client shown in the reference below.
  • Replace src/simpro_client/__init__.py with the committed package exports shown in the reference below.

✔️ I updated the client

The client now owns twelve typed endpoint instances while its public raw methods keep their original return behavior.

ⓧ I'd like to double check the full code

Compare the client and top-level package exports with these committed files.

"""Base HTTP client for the Simpro REST API."""

import logging
from typing import Any

import httpx

from simpro_client.auth import AuthManager
from simpro_client.config import SimproSettings, get_settings
from simpro_client.endpoints import (
    AssetsEndpoint,
    AttachmentsEndpoint,
    CompaniesEndpoint,
    ContactsEndpoint,
    CustomersEndpoint,
    EmployeesEndpoint,
    JobNotesEndpoint,
    JobsEndpoint,
    ProjectsEndpoint,
    QuotesEndpoint,
    SitesEndpoint,
    StatusesEndpoint,
)
from simpro_client.exceptions import (
    SimproAPIError,
    SimproNotFoundError,
    SimproRateLimitError,
)
from simpro_client.logging import RequestTimer, configure_logging, get_correlation_id


class SimproClient:
    """Main entry point for the Simpro API integration."""

    def __init__(self, settings: SimproSettings | None = None) -> None:
        self._settings = settings or get_settings()
        self._auth = AuthManager(self._settings)
        self._logger = configure_logging()
        self._http = httpx.Client(
            base_url=self._settings.base_url,
            timeout=self._settings.timeout,
            headers={"Content-Type": "application/json", "Accept": "application/json"},
        )
        self.companies = CompaniesEndpoint(self)
        self.customers = CustomersEndpoint(self)
        self.jobs = JobsEndpoint(self)
        self.quotes = QuotesEndpoint(self)
        self.contacts = ContactsEndpoint(self)
        self.sites = SitesEndpoint(self)
        self.assets = AssetsEndpoint(self)
        self.employees = EmployeesEndpoint(self)
        self.projects = ProjectsEndpoint(self)
        self.job_notes = JobNotesEndpoint(self)
        self.attachments = AttachmentsEndpoint(self)
        self.statuses = StatusesEndpoint(self)

    def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
        """Send a GET request and return decoded JSON."""
        return self._request("GET", path, params=params)

    def post(self, path: str, json: dict[str, Any] | None = None) -> Any:
        """Send a POST request and return decoded JSON."""
        return self._request("POST", path, json=json)

    def patch(self, path: str, json: dict[str, Any] | None = None) -> Any:
        """Send a PATCH request and return decoded JSON."""
        return self._request("PATCH", path, json=json)

    def delete(self, path: str) -> Any:
        """Send a DELETE request and return decoded JSON."""
        return self._request("DELETE", path)

    def _get_response(
        self, path: str, params: dict[str, Any] | None = None
    ) -> httpx.Response:
        return self._request_response("GET", path, params=params)

    def _request(
        self,
        method: str,
        path: str,
        params: dict[str, Any] | None = None,
        json: dict[str, Any] | None = None,
        _retry_on_401: bool = True,
    ) -> Any:
        response = self._request_response(
            method,
            path,
            params=params,
            json=json,
            _retry_on_401=_retry_on_401,
        )
        if response.status_code == 204:
            return None
        return response.json()

    def _request_response(
        self,
        method: str,
        path: str,
        params: dict[str, Any] | None = None,
        json: dict[str, Any] | None = None,
        _retry_on_401: bool = True,
    ) -> httpx.Response:
        cid = get_correlation_id()
        token = self._auth.get_token()
        headers = {"Authorization": f"Bearer {token}", "X-Correlation-ID": cid}
        with RequestTimer() as timer:
            try:
                response = self._http.request(
                    method=method,
                    url=path,
                    params=params,
                    json=json,
                    headers=headers,
                )
            except httpx.HTTPError as exc:
                self._logger.error(
                    f"Request failed: {method} {path}: {exc}",
                    extra={"method": method, "url": path},
                )
                raise SimproAPIError(
                    message=f"Request failed: {exc}", status_code=0
                ) from exc

        self._log_request(method, path, response.status_code, timer.duration_ms)
        return self._handle_response(
            response, method, path, params, json, _retry_on_401
        )

    def _handle_response(
        self,
        response: httpx.Response,
        method: str,
        path: str,
        params: dict[str, Any] | None,
        json: dict[str, Any] | None,
        retry_on_401: bool,
    ) -> httpx.Response:
        if response.status_code == 401 and retry_on_401:
            self._logger.info("Received 401, refreshing token and retrying")
            self._auth.invalidate()
            return self._request_response(
                method,
                path,
                params=params,
                json=json,
                _retry_on_401=False,
            )
        if response.status_code == 404:
            raise SimproNotFoundError(f"Not found: {method} {path}")
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            raise SimproRateLimitError(
                retry_after=float(retry_after) if retry_after else None
            )
        if response.status_code >= 400:
            raise SimproAPIError(
                message=f"API error: {response.status_code} on {method} {path}",
                status_code=response.status_code,
                response_body=response.text,
            )
        return response

    def _log_request(
        self, method: str, url: str, status_code: int, duration_ms: float
    ) -> None:
        log_record = logging.LogRecord(
            name="simpro_client",
            level=logging.INFO,
            pathname="",
            lineno=0,
            msg=f"{method} {url} -> {status_code} ({duration_ms:.1f}ms)",
            args=None,
            exc_info=None,
        )
        log_record.method = method
        log_record.url = url
        log_record.status_code = status_code
        log_record.duration_ms = round(duration_ms, 1)
        self._logger.handle(log_record)

    def close(self) -> None:
        self._http.close()
        self._auth.close()

    def __enter__(self) -> "SimproClient":
        return self

    def __exit__(self, *args: Any) -> None:
        self.close()
"""Simpro API Client: reusable integration layer for the Simpro REST API."""

from simpro_client.client import SimproClient
from simpro_client.config import SimproSettings, get_settings
from simpro_client.endpoints import Page
from simpro_client.models import (
    Asset,
    Attachment,
    Company,
    Contact,
    Customer,
    Employee,
    Job,
    JobNote,
    Project,
    Quote,
    Site,
    Status,
)

__all__ = [
    "Asset",
    "Attachment",
    "Company",
    "Contact",
    "Customer",
    "Employee",
    "Job",
    "JobNote",
    "Page",
    "Project",
    "Quote",
    "SimproClient",
    "SimproSettings",
    "Site",
    "Status",
    "get_settings",
]
__version__ = "0.1.0"

What changed inside the client?

  • The constructor creates one endpoint property for each of the twelve typed resources.
  • The public raw methods still delegate to _request() and receive decoded JSON or None.
  • The _request_response() method now owns sending requests and retrying one unauthorized response.
  • The _get_response() method gives typed page calls access to response headers.

Before you run the raw client tests, do you expect the internal response refactor to preserve the existing decoded results?

  • Verify raw client compatibility by running this command:
uv run pytest -q tests/test_client.py

You should see the existing client tests pass. This confirms the private response path has not changed the public raw behavior.

Did a raw client test fail?

  • Check that get(), post(), patch(), and delete() still call _request().
  • Check that _request() returns None for a successful empty response.
  • Still stuck? Help me preserve the raw SimproClient contract.
Prove routes and metadata offline

Typed endpoints need evidence for exact nested paths and query parameters. RESPX intercepts the HTTP calls so the suite can verify that contract while Docker Compose remains stopped.

The live smoke test also needs an integration marker and a runtime fixture. That change prevents pytest collection from contacting the mock service.

  • Create tests/test_endpoints.py with the committed offline endpoint tests shown below.
  • Replace tests/test_simpro_mock_v2.py with the runtime-fixture version shown below.
  • Update pyproject.toml with the committed integration marker configuration shown below.

✔️ My verification files are ready

Your offline suite now checks every typed detail route plus one filtered page with pagination metadata.

ⓧ I'd like to double check the full code

Compare the new endpoint tests and integration configuration with these committed files.

"""Offline tests for typed read-only endpoints."""

import pytest
import respx
from httpx import Response

from simpro_client import (
    Asset,
    Attachment,
    Company,
    Contact,
    Customer,
    Employee,
    Job,
    JobNote,
    Project,
    Quote,
    Site,
    Status,
)
from simpro_client.client import SimproClient

GET_CASES = [
    ("companies", 1, {}, "/companies/1", {"ID": 1, "Name": "CVC"}, Company),
    (
        "customers",
        2,
        {"company_id": 1},
        "/companies/1/customers/2",
        {
            "ID": 2,
            "CompanyID": 1,
            "GivenName": "Ada",
            "FamilyName": "Lovelace",
        },
        Customer,
    ),
    (
        "jobs",
        3,
        {"company_id": 1},
        "/companies/1/jobs/3",
        {
            "ID": 3,
            "CompanyID": 1,
            "Name": "Service",
            "Status": "Open",
            "Total": 10.0,
        },
        Job,
    ),
    (
        "quotes",
        4,
        {"company_id": 1},
        "/companies/1/quotes/4",
        {
            "ID": 4,
            "CompanyID": 1,
            "Name": "Quote",
            "Status": "Draft",
            "Total": 20.0,
        },
        Quote,
    ),
    (
        "contacts",
        5,
        {"company_id": 1, "customer_id": 2},
        "/companies/1/customers/2/contacts/5",
        {
            "ID": 5,
            "CompanyID": 1,
            "CustomerID": 2,
            "GivenName": "Grace",
            "FamilyName": "Hopper",
        },
        Contact,
    ),
    (
        "sites",
        6,
        {"company_id": 1},
        "/companies/1/sites/6",
        {"ID": 6, "CompanyID": 1, "CustomerID": 2, "Name": "Plant"},
        Site,
    ),
    (
        "assets",
        7,
        {"company_id": 1, "site_id": 6},
        "/companies/1/sites/6/assets/7",
        {
            "ID": 7,
            "CompanyID": 1,
            "SiteID": 6,
            "AssetNo": "A-7",
            "Name": "Pump",
        },
        Asset,
    ),
    (
        "employees",
        8,
        {"company_id": 1},
        "/companies/1/employees/8",
        {
            "ID": 8,
            "CompanyID": 1,
            "GivenName": "Linus",
            "FamilyName": "Torvalds",
        },
        Employee,
    ),
    (
        "projects",
        9,
        {"company_id": 2},
        "/companies/2/projects/9",
        {
            "ID": 9,
            "CompanyID": 2,
            "CustomerID": 2,
            "Name": "Upgrade",
            "Status": "Active",
            "Total": 1000.0,
        },
        Project,
    ),
    (
        "job_notes",
        10,
        {"company_id": 1, "job_id": 3},
        "/companies/1/jobs/3/notes/10",
        {"ID": 10, "JobID": 3},
        JobNote,
    ),
    (
        "attachments",
        11,
        {"company_id": 1, "job_id": 3},
        "/companies/1/jobs/3/attachments/11",
        {"ID": 11, "JobID": 3, "Filename": "photo.jpg"},
        Attachment,
    ),
    (
        "statuses",
        12,
        {"company_id": 1},
        "/companies/1/statuses/12",
        {
            "ID": 12,
            "CompanyID": 1,
            "Name": "Open",
            "IsDefault": True,
        },
        Status,
    ),
]


def mock_token(mock_settings):
    respx.post(mock_settings.token_url).mock(
        return_value=Response(200, json={"access_token": "tok", "expires_in": 3600})
    )


@pytest.mark.parametrize(
    ("resource", "item_id", "scope", "path", "payload", "model_type"), GET_CASES
)
@respx.mock
def test_get_by_id_uses_exact_route_and_model(
    mock_settings, resource, item_id, scope, path, payload, model_type
):
    mock_token(mock_settings)
    route = respx.get(f"{mock_settings.base_url}{path}").mock(
        return_value=Response(200, json=payload)
    )

    with SimproClient(settings=mock_settings) as client:
        result = getattr(client, resource).get(item_id, **scope)

    assert route.called
    assert isinstance(result, model_type)


@respx.mock
def test_page_preserves_headers_filters_and_company_selection(mock_settings):
    mock_token(mock_settings)
    route = respx.get(f"{mock_settings.base_url}/companies/2/jobs/").mock(
        return_value=Response(
            200,
            json=[
                {
                    "ID": 3,
                    "CompanyID": 2,
                    "Name": "Service",
                    "Status": "Open",
                    "DateIssued": "2026-09-07",
                    "Total": 10.0,
                }
            ],
            headers={
                "Result-Total": "5",
                "Result-Count": "1",
                "Result-Pages": "3",
            },
        )
    )

    with SimproClient(settings=mock_settings) as client:
        result = client.jobs.fetch_page(
            company_id=2,
            page=2,
            page_size=2,
            filters={"Status": "Open"},
        )

    request = route.calls.last.request
    assert request.url.params["Status"] == "Open"
    assert request.url.params["page"] == "2"
    assert request.url.params["pageSize"] == "2"
    assert isinstance(result.items[0], Job)
    assert (result.total, result.count, result.pages) == (5, 1, 3)


def test_missing_nested_scope_fails_before_request(mock_settings):
    with SimproClient(settings=mock_settings) as client:
        with pytest.raises(ValueError, match="site_id"):
            client.assets.get(7, company_id=1)
"""Live smoke tests against a running simpro-mock service."""

import httpx
import pytest

BASE = "http://localhost:8100"
pytestmark = pytest.mark.integration


@pytest.fixture(scope="module", autouse=True)
def require_mock() -> None:
    """Skip at test runtime, never while pytest imports or collects the module."""
    try:
        response = httpx.get(f"{BASE}/health", timeout=1.0)
        response.raise_for_status()
    except httpx.HTTPError:
        pytest.skip(
            "simpro-mock is not running on localhost:8100 "
            "(start it with: docker compose up -d simpro-mock)"
        )


@pytest.fixture(scope="module")
def token() -> str:
    response = httpx.post(
        f"{BASE}/oauth2/token",
        data={
            "grant_type": "client_credentials",
            "client_id": "test",
            "client_secret": "test",
        },
    )
    assert response.status_code == 200
    return response.json()["access_token"]


def test_companies_list_uses_pascal_case_and_pagination_headers(token: str):
    headers = {"Authorization": f"Bearer {token}"}
    response = httpx.get(f"{BASE}/api/v1.0/companies/", headers=headers)

    assert response.status_code == 200
    data = response.json()
    assert data
    assert "ID" in data[0]
    assert "Result-Total" in response.headers
    assert "Result-Pages" in response.headers
    assert "Result-Count" in response.headers


def test_unauthenticated_request_returns_401():
    response = httpx.get(f"{BASE}/api/v1.0/companies/")
    assert response.status_code == 401
[build-system]
requires = ["setuptools>=75.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "simpro-client"
version = "0.1.0"
description = "Reusable integration layer for the Simpro REST API"
requires-python = ">=3.12"
license = {text = "MIT"}
dependencies = [
    "httpx==0.28.1",
    "pydantic==2.13.4",
    "pydantic-settings==2.14.2",
    "python-dotenv==1.1.0",
]

[tool.setuptools.packages.find]
where = ["src"]

[dependency-groups]
dev = [
    "pytest==9.1.1",
    "respx==0.23.1",
    "ruff==0.16.1",
    "pytest-cov==7.0.0",
]

[tool.ruff]
target-version = "py312"
line-length = 88

[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B", "A", "SIM"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
markers = [
    "integration: requires the running simpro-mock service",
]

What do these tests prove?

  • The parameterized test checks every endpoint property against its exact detail route and expected model type.
  • The page test checks explicit company selection and forwarded filters.
  • The page test also checks Result-Total, Result-Count, and Result-Pages metadata.
  • The nested-scope test proves a missing site_id fails before any HTTP request is sent.

Before you run the focused suite, do you expect every mocked request to pass while the live integration module stays offline?

  • Run the raw and typed endpoint tests together with this command:
uv run pytest -q tests/test_client.py tests/test_endpoints.py

You should see the existing raw tests and the new typed endpoint tests pass. No Docker service is needed because RESPX handles every HTTP exchange in memory.

Is the focused endpoint suite failing?

  • Check that every endpoint route starts after the configured API prefix and retains its required trailing collection slash.
  • Check that fetch_page() reads pagination values from response headers before constructing Page.
  • Still stuck? Help me debug the offline endpoint suite.

That is the endpoint checkpoint complete. Your SDK now returns validated resources and one-page metadata without changing the original raw request API. Next, you will run the complete offline quality gates and prepare the implementation handoff.

Verify the Offline Handoff

Your typed endpoint layer now passes its focused RESPX suite while Docker Compose stays stopped. The full repository still needs to prove that the handoff is safe.

A focused green test can still miss repository-wide failures. This step runs every offline gate before you document the final boundary.

In this step, get ready to:
  • Pass every offline quality gate.
  • Confirm the diff stays inside the approved boundary.
  • Record the implementation handoff without creating a final Sprint 4 commit.
Run the offline quality gates

Ruff checks the complete repository for lint or formatting drift. pytest proves the unit suite stays independent from the live mock service.

  • Leave Docker Compose stopped throughout this verification.

Before you run the gates, do you expect every check to succeed without the integration service?

  • Run the offline quality gates from the repository root by running these commands:
uv run ruff check .
uv run ruff format --check .
uv run pytest -q -m "not integration"

What Do These Gates Prove?

  • The first command checks the repository against the configured lint rules.
  • The second command confirms that every checked file already follows the configured format.
  • The final command runs the offline suite while excluding tests marked with integration.

You should see all three commands finish successfully. The offline test run excludes the integration marker while Docker Compose remains stopped.

That clears the full offline gate. Repository-wide quality now supports the focused endpoint result.

Did an Offline Gate Fail?

  • Fix only lint or formatting issues introduced by this project.
  • Check that tests marked for integration are excluded from the offline result.
  • Keep Docker Compose stopped while you investigate any unit-test failure.
  • Still stuck? Help me diagnose the failing offline quality gate.
Review the approved change boundary

Git's diff summary shows where the implementation changed the repository. Its short status report exposes every changed path that belongs in the handoff.

Before you inspect the output, do you expect every path to belong to the approved client or test boundary?

  • Display the current change summary by running these commands:
git diff --stat
git status --short

What Does This Check Show?

  • The diff summary groups changed files with their line counts.
  • The short status report identifies every staged path or unstaged path.
  • Switch back to the existing VS Code window.
  • Review the diff for every changed file shown by Git.
  • Confirm source changes stay inside src/simpro_client.
  • Confirm test changes stay inside tests.
  • Confirm project-configuration changes stay inside pyproject.toml.
  • Confirm no Phase 2 schema files changed.
  • Confirm no database files changed.
  • Confirm no migration files changed.
  • Confirm no seed files changed.
  • Confirm no mock-route files changed.

The changed source paths should sit inside src/simpro_client. The changed test paths should sit inside tests.

The pyproject.toml file should be the only changed project-configuration file.

Found an Out-of-Scope Change?

  • Leave the unexpected file untouched.
  • Record its path beside your handoff notes.
  • Stop the review until the file owner confirms its origin.
  • Avoid using Git to alter or hide the unexpected change.
  • Need help assessing the diff? Help me classify the unexpected repository change.
Write the implementation report

An implementation report turns passing commands into a usable handoff. It also records the boundary that the next project can safely extend.

  • Write the report in your project's existing handoff location.
  • List every changed file covered by this project.
  • Record the successful quality-gate results.
  • Record that Docker Compose stayed stopped during the offline suite.
  • State that raw get(), post(), patch(), and delete() retain decoded-JSON behavior.
  • State that fetch_page() returns exactly one typed page.
  • List lazy pagination, resilience, and release work as the next-project boundary.
  • Confirm that no final Sprint 4 commit was created.

Before you run the final repository check, do you expect HEAD to match the value recorded during preflight?

  • Verify the final repository state by running these commands:
git rev-parse --verify HEAD
git status --short
git diff --stat

What Does the Final Check Prove?

  • The first command confirms the current HEAD value for comparison with the preflight record.
  • The second command provides the final changed-path inventory.
  • The third command summarizes the approved implementation footprint.

You should see the same HEAD value recorded during preflight. This confirms that no final Sprint 4 commit was created.

The remaining diff should stay limited to the approved client source, tests, and project configuration.

Does the Final State Differ?

  • Compare the current HEAD value with the preflight record.
  • Preserve every unexpected path for review.
  • Avoid changing repository history to make the output match.
  • Need help reconciling the final state? Help me review the handoff without changing local work.

Your offline handoff is ready. The typed SDK foundation is verified without a live mock or a final Sprint 4 commit.

Secret mission

Add a Route Contract Guard

Protect all twelve typed endpoints with a declarative route inventory. The focused test catches changes to collection or detail templates before an HTTP request is sent.

Clean Up Your Resources

Clean Up Your Resources

With Docker Compose already stopped, this local project has no ongoing cloud costs. Choose whether to keep the reviewed no-commit checkout, pause your work, or clear the remaining teardownable resources.

Resources you used:

  • The local Docker Compose service stack used by simpro-mock. It is already stopped.
  • No cloud infrastructure or paid external service was created.

Keep everything running

No action is needed. Choose this if you want to continue developing the typed SDK from the reviewed handoff.

  • Keep the existing CLIVE-I checkout in its current location.
  • Continue from the reviewed no-commit handoff when you are ready.
  • Leave Docker Compose stopped until you choose to run the integration suite.

Pause - I'll come back to this later

Keep the source files while freeing local application memory. The mock service is already stopped.

  • Save any open file in the existing CLIVE-I checkout.
  • Close VS Code to free its local memory.
  • Leave Docker Compose stopped.

Delete - I don't want to use this again

Removing the existing checkout could erase local work that predates this project. This option clears teardownable Docker resources while preserving the checkout.

  • Return to the VS Code terminal from earlier at the repository root.
  • Remove any remaining Docker Compose resources by running:
docker compose down

This is the repository's documented volume-preserving cleanup path. You should return to the prompt with the mock service still stopped.

  • Confirm the reviewed source changes remain intact by running:
git status --short

You should still see the no-commit handoff paths from your final review. This confirms that cleanup did not erase the existing checkout.

  • Keep the existing CLIVE-I checkout unless you have separately confirmed that it contains no work from before this project.

Nice Work!

Nice Work!

You made it through a careful SDK refactor. Your Simpro SDK now returns validated Python resources from the committed API contract.

You've learned how to:

  • Build twelve explicit-alias Pydantic resource models for committed PascalCase payloads. Expose snake_case attributes with the committed field requirements. Convert date and datetime strings into Python values.
  • Add a reusable typed endpoint foundation for exact nested routes. Forward list filters. Preserve one-page pagination metadata. Keep the Sprint 1 raw request API backward compatible.
  • Prove the complete SDK behavior with an offline RESPX suite that runs while Docker Compose is stopped. Keep live mock reachability out of test collection through a runtime fixture. Prepare a reviewed no-commit handoff with the approved project boundary intact.
  • Complete the optional Secret Mission by adding a declarative route contract guard. The guard checks all twelve collection and detail template pairs before an HTTP request is sent.

Ready to quiz yourself?