Contributor Guide¶
Thank you for your interest in improving pyado. The project is open-source under the MIT license and welcomes bug reports, feature requests, and pull requests.
Quick links for development: Usage guide · Quick reference · API reference · Architecture · Coding standards · Adding new functionality
pyado uses Python 3.11, uv for dependency management, ruff for linting
and formatting, mypy in strict mode for type checking, and pytest with
100 % branch coverage enforced. All of these are configured in
pyproject.toml; no tool needs a separate config file.
Reporting bugs¶
File a bug report on the Issue Tracker. Include:
Operating system and Python version
pyado version (
pip show pyado)What you did
What you expected
What actually happened (full traceback if applicable)
The fastest path to a fix is a minimal reproducible test case.
Requesting features¶
Open a feature request on the Issue Tracker. Describe the use case and why
pyado is the right place to solve it (rather than, say, calling build_call()
directly). It helps to open a discussion before writing code — this avoids
work that might not align with the project’s design.
Setting up a development environment¶
You need Python 3.11 and uv.
$ git clone https://github.com/fredstober/pyado
$ cd pyado
$ uv sync
uv sync installs the package and all development dependencies into an
isolated virtualenv. Activate it or prefix every command with uv run:
$ uv run python
Running the tests¶
$ uv run pytest
The test suite enforces 100% branch coverage. Every new code path must be covered by a test. The coverage report is printed automatically; failed coverage fails the build.
Tests live in tests/. Naming conventions:
Thing |
Convention |
|---|---|
Test file |
|
Test function |
|
Keep test data realistic but synthetic. No PII. Avoid over-mocking — test observable behaviour, not internal call sequences.
Linting, formatting, and type checking¶
Run all pre-commit hooks in one shot:
$ uv run prek run -a
Or run individual tools:
$ uv run ruff check src/ # lint
$ uv run ruff format --check src/ # format check
$ uv run mypy src/ # type checking (strict mode)
mypy runs in strict mode with warn_unreachable = true. Every new function
and class must be fully annotated. No # type: ignore suppressions.
detect-secrets¶
pyado uses detect-secrets to prevent accidentally committing tokens or keys. If the baseline becomes stale (e.g. after adding a string that looks like a secret), regenerate it:
$ uv run detect-secrets scan > .secrets.baseline
$ git add .secrets.baseline
Coding standards¶
Language and runtime¶
Python 3.11 only —
requires-python = ">=3.11"Double quotes for strings
snake_casefor variables and functions,PascalCasefor classes,UPPER_SNAKE_CASEfor module-level constants
Style tooling¶
Tool |
Role |
Config |
|---|---|---|
Lint + format |
|
|
Type checking |
strict mode |
|
Test runner |
100% branch coverage enforced |
The ruff configuration uses select = ["ALL"] with a small set of explicit
ignores. Do not add new ignores without discussion.
Docstrings¶
Google-style docstrings are required on every public module, class, and
function. Private helpers (_prefixed) do not require docstrings but benefit
from them when the logic is non-obvious.
def my_function(api_call: ApiCall, work_item_id: WorkItemId) -> WorkItemInfo:
"""Return full work item data for a single ID.
Args:
api_call: Work-item-level ADO API call.
work_item_id: Integer ID of the work item to fetch.
Returns:
WorkItemInfo with all fields and relations populated.
Raises:
RuntimeError: If the ADO API returns a non-2xx response.
"""
Design principles (in priority order)¶
KISS — write the simplest thing that works.
SOLID — single responsibility, open/closed, etc.
DRY — don’t repeat yourself.
YAGNI — no code for hypothetical future requirements.
Practical rules that follow from these:
No global state — pass everything explicitly.
No error handling for scenarios that cannot happen.
No abstractions for code that appears only once.
No features beyond what was asked.
Pydantic models¶
All request and response types are Pydantic v2 BaseModel subclasses.
Raw dict is never returned from any public function.
Multi-field request models are public (no
_prefix) — callers may need to construct them directly.Single-field internal wrappers may be private.
Never use
model_config = ConfigDict(populate_by_name=True)as a shortcut to avoid aliasing correctly — keep the alias and expose the Python-friendly name through the field definition.
Package architecture¶
pyado has two layers. All raw functions are re-exported through the top-level
pyado namespace. The OOP layer is a preview API imported from pyado.oop.
src/pyado/
├── __init__.py ← re-exports everything from raw/
├── raw/ ← one function per ADO REST endpoint
│ ├── _core.py ← ApiCall, shared primitive types, HTTP machinery
│ ├── build.py
│ ├── git.py
│ ├── identity.py
│ ├── pipeline.py
│ ├── profile.py
│ ├── project.py
│ ├── pull_request.py
│ ├── variable_group.py
│ └── work_item.py
└── oop/ ← OOP resource objects (preview layer)
├── service.py ← AzureDevOpsService, entry point
├── organization.py
├── project.py
├── repository.py
├── pull_request.py
├── work_item.py
├── build.py
├── pipeline.py
├── variable_group.py
├── team.py
├── iteration.py
├── area.py
├── _build.py ← private helpers: payload construction + multi-step logic
├── _git.py
├── _pull_request.py
├── _variable_group.py
└── _work_item.py
The two layers¶
Layer |
Responsibility |
|---|---|
|
One function per ADO REST endpoint. Accepts fully-built Pydantic request models; returns Pydantic response models. No payload construction, no pagination, no multi-step logic. |
|
OOP resource objects ( |
Rules for raw/¶
One function per endpoint. No multi-step logic.
Accept fully-built Pydantic request models. Do not construct models from primitive arguments inside a
raw/function.Return Pydantic response models. Never a raw
dict.Public request models. Multi-field request models must be public (no
_prefix) so callers can reference them if needed.
Rules for oop/ private helpers¶
The private modules (oop/_build.py, oop/_git.py, oop/_pull_request.py,
oop/_variable_group.py, oop/_work_item.py) bridge the OOP objects and the
raw/ layer.
Accept primitive arguments. Strings, ints, enums — no Pydantic models at the call site unless there is no simpler alternative.
Construct request models internally. Build the Pydantic model, then pass it to the corresponding
raw/function.Own pagination.
iter_*functions yield individual items and page through results internally — callers should never need to manageskip/top.Delegate all HTTP to
raw/. Never callapi_call.get()/.post()/.patch()/.delete()directly fromoop/modules.Intent-expressing names are allowed.
push_commitswrappingpost_push, orstart_buildwrappingpost_build, is fine.
Design decisions¶
This section explains the why behind the less obvious choices in pyado’s implementation.
ApiCall session caching¶
ApiCall._get_session is decorated with @lru_cache(maxsize=8) keyed on the
access token string. The cache is intentional: requests.Session holds an
internal connection pool. Reusing the same session object for every call that
shares a token avoids negotiating a new TLS connection on each request. The
cache is bounded at 8 so applications that use a small number of tokens still
benefit without accumulating sessions indefinitely.
A practical consequence: do not recreate ApiCall on every call. Construct it
once and pass it around — the underlying session and its connection pool are
shared automatically.
Content-type negotiation¶
ADO expects different Content-Type headers depending on the operation:
Payload |
Content-Type |
|---|---|
Regular JSON body |
|
JSON Patch sequence |
|
Raw bytes (file upload) |
|
_get_content_type detects the payload type from the value being sent. A list
of dicts each containing op and path keys is treated as a JSON Patch
document; raw bytes triggers the octet-stream type; everything else uses
application/json. Callers never specify the content-type directly — it
follows from the shape of the request.
Why JSON Patch for work item mutations¶
The ADO Work Item Tracking API uses JSON Patch (RFC 6902) for all
mutations. A field update, a relation addition, and a multiline-format hint are
all expressed as patch operations with a path and a value. This is an API
constraint imposed by Microsoft, not a pyado choice. JsonPatchAdd models the
only operation type pyado requires — op: "add", which ADO uses both for adding
new values and for overwriting existing ones.
The multilineFieldsFormat patch path (/multilineFieldsFormat/<field>) is a
pyado-discovered extension that ADO accepts in the same PATCH request alongside
field values. It tells the ADO UI to render the field as markdown rather than
plain text.
Error extraction¶
When ADO returns a non-2xx response, pyado tries to extract a human-readable message in order:
Parse the body as JSON and return
body["message"].If the body is HTML (e.g. an IIS or gateway error page), strip tags with
HTMLTextFilterand return the visible text.Fall back to
repr(response.content).
This layered approach handles both well-formed ADO error JSON and the raw HTML
pages that ADO gateway proxies sometimes return for infrastructure-level errors.
All three cases surface as RuntimeError with a readable message.
Retry strategy¶
ApiCall._request retries up to 3 times, but only on ConnectionResetError —
a TCP-level reset that occurs when a long-lived pooled connection is silently
closed by the server between requests. HTTP-level errors (4xx, 5xx) are not
retried, because they represent definitive ADO responses that would return the
same error on every attempt.
Optimistic concurrency for git ref mutations¶
ADO’s ref update API requires both an old_object_id (the SHA the caller
expects the branch HEAD to be at) and a new_object_id (the desired new SHA).
If the branch has moved between reading the SHA and submitting the update, ADO
rejects the request. This is optimistic concurrency control: ADO holds no
server-side lock; instead it validates that the caller’s expectation of the
current state still holds at commit time.
ZERO_SHA ("000...0") is git’s conventional null SHA, meaning “this ref does
not exist”. Using it as old_object_id tells ADO to create the branch only if
it does not yet exist, and reject the operation if it already does. Using
ZERO_SHA as new_object_id on an existing branch deletes it.
Pagination via skip and top¶
ADO REST endpoints that return collections accept $skip (offset) and $top
(page size) query parameters. iter_* functions manage these internally.
iter_commit_diff additionally inspects the all_changes_included flag in
each page response — when True, the current page is the last one and iteration
stops without issuing a further request. This is necessary because the diff
endpoint does not return a total count, so the only reliable stop condition is
the flag.
Attachment upload is two steps¶
ADO separates file storage from work item data. An attachment must first be
uploaded to the attachment store (POST .../wit/attachments), which returns a
permanent URL. That URL is then added to the work item as an AttachedFile
relation via a JSON Patch operation. add_work_item_attachment performs both
steps in sequence from the caller’s perspective, but if the second step fails
(network error, permission problem), the file remains uploaded but unlinked.
Re-running the call will upload a second copy — ADO does not de-duplicate by
content.
Branch name normalisation¶
Several functions accept a branch name as either a short name ("main") or a
full ref ("refs/heads/main"). The private _full_ref helper in
oop/_pull_request.py canonicalises both forms to "refs/heads/<name>" because
ADO’s ref mutation APIs require the full path. Functions that only read refs
(e.g. iter_refs) do not apply this normalisation because the nameFilter
query parameter accepts a prefix without the refs/ root.
Adding new functionality¶
Follow these steps whenever adding support for a new ADO endpoint or building a new higher-level helper:
raw/first. Add the HTTP call and any request/response Pydantic models to the appropriate domain module (e.g.raw/git.py). If the endpoint needs a new multi-field request model, make it public.Export from
raw/. Add new public symbols toraw/__init__.pyandpyado/__init__.py.oop/_*.pyif needed. If the new endpoint benefits from payload construction, pagination, or multi-step orchestration, add a helper in the matching private module (e.g.oop/_git.py) and expose a method on the relevant OOP class (e.g.Repository).Tests. Add unit tests. Coverage must remain at 100%.
Docs. Add an example to
docs/usage.mdif the new function is user-facing.
Example: adding a new raw/ function¶
# raw/work_item.py
def get_work_item_revisions(
work_item_api_call: ApiCall,
) -> list[WorkItemInfo]:
"""Return all historical revisions of a work item.
Args:
work_item_api_call: Work-item-level ADO API call (from
get_work_item_api_call).
Returns:
List of WorkItemInfo, one per revision, oldest first.
"""
response = work_item_api_call.get("revisions", version="7.0")
return _WorkItemInfoResults.model_validate(response).value
Building the documentation¶
Documentation is built with Sphinx and MyST Parser — the source files are
Markdown, and the API reference is generated automatically from docstrings via
sphinx.ext.autodoc.
Install the docs dependencies (separate from the default dev group):
$ uv sync --group docs
Build HTML output into docs/_build/html/:
$ uv run --group docs sphinx-build docs docs/_build/html
Open docs/_build/html/index.html in a browser to review the result.
For live-reload during editing, add sphinx-autobuild on the fly:
$ uv run --group docs --with sphinx-autobuild sphinx-autobuild docs docs/_build/html
Docs source layout¶
File |
Purpose |
|---|---|
|
Sphinx configuration (extensions, theme) |
|
Landing page; pulls top section of |
|
Full usage guide with worked examples — see also Usage Guide |
|
API reference — auto-generated from module docstrings |
|
Compact API reference for agent/LLM consumption |
|
One-page signature summary |
|
Comparison with |
|
This contributor guide, rendered on the docs site |
Submitting a pull request¶
Fork the repo and create a branch from
main.Open an issue first if the change is non-trivial — discuss the approach.
Make your changes, add tests, update
docs/usage.mdif adding user-facing functionality.Ensure the full check suite passes locally:
uv run prek run -a.Open a pull request. The PR description should explain why, not just what.
Acceptance criteria:
Test suite passes; coverage stays at 100%.
All linting and type-checking checks pass.
New public functions have Google-style docstrings.
docs/usage.mdis updated for any new user-facing functionality.