diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6f1533..e306180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,4 +31,9 @@ jobs: - run: uv run ruff check . - run: uv run ruff format --check . - run: uv run mypy src + # tests/test_openapi_spec.py already asserts the spec is current. This + # step is here for the other half: nothing else runs the generator + # itself, and a build-time script that only ever runs by hand is one + # that breaks unnoticed and is discovered when someone needs it. + - run: uv run bin/generate-openapi-spec --check - run: uv run pytest --cov=ol_analytics_api --cov-report=term-missing diff --git a/.github/workflows/openapi-diff.yml b/.github/workflows/openapi-diff.yml new file mode 100644 index 0000000..dfb62a3 --- /dev/null +++ b/.github/workflows/openapi-diff.yml @@ -0,0 +1,139 @@ +name: OpenAPI Diff + +# The committed spec is meant to be what a future Concourse client pipeline +# generates a published TypeScript package from (see README.md), so a diff +# here is a preview of a change to somebody else's build. This surfaces that +# change as a comment and fails the PR on a breaking one, rather than leaving +# it to whoever reads 1500 lines of YAML. + +on: + pull_request: + paths: + - "openapi/specs/**" + +permissions: {} + +jobs: + openapi-diff: + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout HEAD + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The exact commit under review, not the branch name: a push while + # this runs would otherwise diff a commit nobody reviewed. + ref: ${{ github.event.pull_request.head.sha }} + path: head + persist-credentials: false + - name: Checkout BASE + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + persist-credentials: false + - name: Generate oasdiff changelog + run: | # Write the comment body to a file rather than a step output. + # A large changelog interpolated into a JS action's `body:` input becomes a + # huge INPUT_BODY env var, which can blow past the OS argv+envp size limit + # and crash the action with "Argument list too long". Writing straight to a + # file and using `body-path` avoids that entirely. + # + # The spec list is the union of base and head filenames, not just base's: + # a base-only loop silently drops both a spec added in this PR (never in + # base, so never iterated) and a spec removed in this PR (caught by the + # -f guard below, so skipped instead of reported as a removal). + specs=$( + { + [ -d base/openapi/specs ] && (cd base/openapi/specs && ls -1 ./*.yaml) + [ -d head/openapi/specs ] && (cd head/openapi/specs && ls -1 ./*.yaml) + } 2>/dev/null | xargs -n1 basename | sort -u + ) + { + echo "## OpenAPI Changes" + echo "" + echo "
" + echo "Show/hide changes" + echo "" + echo '```' + for name in $specs; do + base_spec="base/openapi/specs/$name" + head_spec="head/openapi/specs/$name" + if [ -f "$base_spec" ] && [ -f "$head_spec" ]; then + echo "## Changes for $name:" + docker run --rm \ + --workdir "$GITHUB_WORKSPACE" \ + --volume "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" \ + tufin/oasdiff@sha256:6065c16a4c9ce12504752f444d4981091e58c2a35436fac90b649be47d833db3 \ + changelog "$base_spec" "$head_spec" + echo "" + elif [ -f "$head_spec" ]; then + echo "## $name: added" + echo "" + elif [ -f "$base_spec" ]; then + echo "## $name: removed" + echo "" + fi + done + echo '```' + echo "" + echo "Unexpected changes? Ensure your branch is up-to-date with \`main\` (consider rebasing)." + echo "
" + } > comment_body.md + # A fork's GITHUB_TOKEN is read-only whatever this job asks for, so both + # comment steps would fail and take the breaking-change check below down + # with them. The check is the gate; the comment is a convenience. Skip + # the convenience rather than lose the gate. + - name: Find existing comment + id: find_comment + if: github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + issue-number: ${{ github.event.pull_request.number }} + body-includes: "## OpenAPI Changes" + - name: Post changes as comment + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5 + # Even with no changes, update the old comment if one was found. + if: github.event.pull_request.head.repo.full_name == github.repository + with: + token: ${{ secrets.GITHUB_TOKEN }} + edit-mode: "replace" + repository: ${{ github.repository }} + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find_comment.outputs.comment-id }} + body-path: comment_body.md + - name: Check for breaking changes + run: | + # Breaking here means breaking a client someone else already + # generated and shipped, so this fails the PR rather than warning. + # A spec removed outright is the most breaking change there is — + # deleting the whole published API for a tenant — so it's checked + # explicitly rather than relying on the -f guard to skip it. + specs=$( + { + [ -d base/openapi/specs ] && (cd base/openapi/specs && ls -1 ./*.yaml) + [ -d head/openapi/specs ] && (cd head/openapi/specs && ls -1 ./*.yaml) + } 2>/dev/null | xargs -n1 basename | sort -u + ) + for name in $specs; do + base_spec="base/openapi/specs/$name" + head_spec="head/openapi/specs/$name" + if [ -f "$base_spec" ] && [ -f "$head_spec" ]; then + echo "Checking $name for breaking changes..." + docker run --rm \ + --workdir "$GITHUB_WORKSPACE" \ + --volume "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" \ + tufin/oasdiff@sha256:6065c16a4c9ce12504752f444d4981091e58c2a35436fac90b649be47d833db3 \ + breaking \ + --fail-on ERR \ + --format githubactions \ + "$base_spec" "$head_spec" + elif [ -f "$base_spec" ]; then + echo "::error::$name was removed — deleting a published spec is a breaking change." + exit 1 + fi + done diff --git a/README.md b/README.md index 4603134..b3f8316 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,13 @@ state. Wire it up with one entry in `main.py`'s `TENANTS` list: ```python TENANTS: list[Tenant] = [ - Tenant("/api/v1/analytics", b2b_dashboard.create_app, b2b_dashboard.lifespan), - Tenant("/api/v1/", new_tenant.create_app), + Tenant( + b2b_dashboard.TENANT_NAME, + "/api/v1/analytics", + b2b_dashboard.create_app, + b2b_dashboard.lifespan, + ), + Tenant(new_tenant.TENANT_NAME, "/api/v1/", new_tenant.create_app), ] ``` @@ -107,11 +112,15 @@ A `Tenant` takes a `create_app` *factory* (not a pre-built instance) so the root app constructs every sub-app after OpenTelemetry is configured — a tenant is instrumented regardless of import order. If the tenant owns resources that need startup/shutdown (e.g. an httpx client), it exposes them -as an ordinary `lifespan` context manager and passes it as the third +as an ordinary `lifespan` context manager and passes it as the fourth argument: a mounted sub-app's own `lifespan=` is never invoked by the ASGI server (only the root app's is), so the root lifespan enters each tenant's explicitly. +The leading `name` is the tenant's own `TENANT_NAME`, which already names its +readiness sub-path. It also names the tenant's published OpenAPI document +(`openapi/specs/.yaml`), so it ends up in a consumer-visible filename. + Each tenant gets independent OpenAPI docs at `/docs`. ### Auth @@ -208,3 +217,49 @@ uv run pytest uv run ruff check . uv run mypy src ``` + +## The published API contract + +Each tenant's OpenAPI document is committed under `openapi/specs/.yaml` +and regenerated with: + +```bash +uv run bin/generate-openapi-spec +``` + +Run it whenever a response model, route or query parameter changes. CI fails +otherwise — both as a test (`tests/test_openapi_spec.py`) and as a +`--check` run of the generator itself. + +The spec is committed rather than served-and-forgotten because it is meant to +become a cross-repo interface. The intended pipeline mirrors the one already +running for `mitxonline` and `mit-learn`: a Concourse pipeline in +`ol-infrastructure` (`ol_concourse/pipelines/libraries/api_clients_pipeline.py`) +watching these files on a release branch, running `openapi-generator` over +them, and publishing a TypeScript client the same way +`@mitodl/mitxonline-api-axios` and `@mitodl/mit-learn-api-axios` are today. +None of that is wired up yet — this repo has no entry in `PIPELINE_CONFIGS` +and no `release` branch, and MIT Learn's dashboard still uses its hand-written +client. Until it is, committing the spec still buys the same thing locally: a +column that appears here without appearing in the diff is a column a +consumer would find out about at runtime once the pipeline exists. + +Three details are worth knowing before editing a route: + +- **`operation_id` is named explicitly on every route.** It becomes the + generated client's method name, so FastAPI's path-derived default would both + produce an unreadable name and rename the method whenever the path moves. +- **Published paths carry the tenant's mount prefix.** A mounted sub-app + describes its routes relative to its own root; `openapi.py` re-prefixes them + so a generated client configured with the service host requests the URLs the + service actually serves. +- **A repeatable query parameter is a plain `list[X]`, never `list[X] | None`.** + The optional form renders as `anyOf: [array, null]`, which openapi-generator + cannot reduce; it emits a client that spreads the value with `Object.entries` + and sends `?0=a&1=b` instead of repeating the parameter name. Use + `Query(default_factory=list)` and treat the empty list as "no filter". + +Note that `docs/openapi/b2b-learner-records-v1.yaml` is a different artifact: +a hand-written draft published so partners could review the record shape +before it was built. `openapi/specs/b2b_learner_records.yaml` is generated +from the running code and is the one a client is built from. diff --git a/bin/generate-openapi-spec b/bin/generate-openapi-spec new file mode 100755 index 0000000..41c3782 --- /dev/null +++ b/bin/generate-openapi-spec @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Write each mounted tenant's OpenAPI document to openapi/specs/.yaml. + +Run as `uv run bin/generate-openapi-spec`. + +The output is committed, and that is the point: a materialized view gaining or +renaming a column changes a response model, which changes this file, which +shows up in review as an interface diff instead of silently drifting away from +the clients generated off it. `tests/test_openapi_spec.py` fails when the +committed file no longer matches what the code produces. + +The intended consumer is a Concourse pipeline in ol-infrastructure +(`ol_concourse/pipelines/libraries/api_clients_pipeline.py`), mirroring the one +mitxonline and mit-learn already use: watch `openapi/specs/*.yaml` on a +release branch and regenerate the published TypeScript client from it. That +pipeline isn't wired up for this repo yet (no `PIPELINE_CONFIGS` entry, no +`release` branch) — this file exists so the spec is ready to publish once it +is. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import cyclopts + +from ol_analytics_api.openapi import render, tenant_specs + +DEFAULT_DIRECTORY = Path("openapi/specs") + +app = cyclopts.App(name="generate-openapi-spec", help=__doc__) + + +@app.default +def generate(*, directory: Path = DEFAULT_DIRECTORY, check: bool = False) -> None: + """Write (or, with --check, verify) the per-tenant OpenAPI documents. + + Parameters + ---------- + directory + Where the .yaml files are written. + check + Compare against what is already on disk and exit non-zero on any + difference, without writing anything. + """ + stale = [] + for tenant_name, spec in tenant_specs().items(): + path = directory / f"{tenant_name}.yaml" + rendered = render(spec) + if check: + if not path.exists() or path.read_text() != rendered: + stale.append(path) + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(rendered) + sys.stdout.write(f"wrote {path}\n") + if stale: + names = ", ".join(str(path) for path in stale) + sys.stderr.write( + f"OpenAPI spec is out of date: {names}. " + "Regenerate with `uv run bin/generate-openapi-spec`.\n" + ) + raise SystemExit(1) + + +if __name__ == "__main__": + app() diff --git a/openapi/specs/b2b_dashboard.yaml b/openapi/specs/b2b_dashboard.yaml new file mode 100644 index 0000000..e03fb12 --- /dev/null +++ b/openapi/specs/b2b_dashboard.yaml @@ -0,0 +1,2130 @@ +openapi: 3.1.0 +info: + title: B2B Analytics Dashboard + description: B2B site-license analytics for org managers and MIT contract admins. + Aggregate endpoints apply a k-anonymity floor. The contract-scoped learner-progress + endpoint returns individual learners on the organization's own seats, with outcome + fields consent-gated. + version: 0.0.1 +paths: + /api/v1/analytics/organizations/{organization_id}/contract-utilization: + get: + tags: + - organizations + summary: Contract Utilization + operationId: organizations_contract_utilization_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OrgAnalyticsResponse_ContractUtilization_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/enrollment-funnel: + get: + tags: + - organizations + summary: Enrollment Funnel + operationId: organizations_enrollment_funnel_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OrgAnalyticsResponse_EnrollmentCompletionFunnel_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/engagement-trend: + get: + tags: + - organizations + summary: Engagement Trend + operationId: organizations_engagement_trend_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OrgAnalyticsResponse_MonthlyEngagementTrend_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/program-funnel: + get: + tags: + - organizations + summary: Program Funnel + operationId: organizations_program_funnel_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OrgAnalyticsResponse_ProgramFunnel_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/content-engagement: + get: + tags: + - organizations + summary: Content Engagement + operationId: organizations_content_engagement_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OrgAnalyticsResponse_ContentEngagementDepth_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/contract-utilization: + get: + tags: + - contracts + summary: Contract Utilization + operationId: contracts_contract_utilization_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: contract_id + in: path + required: true + schema: + type: integer + title: Contract Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OrgAnalyticsResponse_ContractUtilization_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/enrollment-funnel: + get: + tags: + - contracts + summary: Enrollment Funnel + operationId: contracts_enrollment_funnel_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: contract_id + in: path + required: true + schema: + type: integer + title: Contract Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OrgAnalyticsResponse_EnrollmentCompletionFunnel_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/engagement-trend: + get: + tags: + - contracts + summary: Engagement Trend + operationId: contracts_engagement_trend_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: contract_id + in: path + required: true + schema: + type: integer + title: Contract Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OrgAnalyticsResponse_ContractMonthlyEngagementTrend_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/program-funnel: + get: + tags: + - contracts + summary: Program Funnel + operationId: contracts_program_funnel_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: contract_id + in: path + required: true + schema: + type: integer + title: Contract Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OrgAnalyticsResponse_ProgramFunnel_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/content-engagement: + get: + tags: + - contracts + summary: Content Engagement + operationId: contracts_content_engagement_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: contract_id + in: path + required: true + schema: + type: integer + title: Contract Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OrgAnalyticsResponse_ContractContentEngagementDepth_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/learner-progress: + get: + tags: + - learners + summary: Each learner's enrollment and completion in each course run under the + contract + operationId: learners_progress_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: contract_id + in: path + required: true + schema: + type: integer + title: Contract Id + - name: search + in: query + required: false + schema: + anyOf: + - type: string + minLength: 1 + maxLength: 254 + - type: 'null' + description: Case-insensitive match on email or name. + title: Search + description: Case-insensitive match on email or name. + - name: completion_status + in: query + required: false + schema: + type: array + items: + $ref: '#/components/schemas/CompletionStatusFilter' + description: Repeat for several. `unknown` selects rows with withheld outcomes. + title: Completion Status + description: Repeat for several. `unknown` selects rows with withheld outcomes. + - name: include_inactive + in: query + required: false + schema: + type: boolean + description: Include deactivated enrollments (unenrolled, refunded). + default: false + title: Include Inactive + description: Include deactivated enrollments (unenrolled, refunded). + - name: sort + in: query + required: false + schema: + $ref: '#/components/schemas/SortKey' + default: full_name + - name: descending + in: query + required: false + schema: + type: boolean + default: false + title: Descending + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/LearnerProgressResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/admin/contract-health: + get: + tags: + - admin + summary: Contract Health + operationId: admin_contract_health_retrieve + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AdminAnalyticsResponse_MitAdminContractHealth_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + AdminAnalyticsResponse_MitAdminContractHealth_: + properties: + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + total_count: + type: integer + title: Total Count + data: + items: + $ref: '#/components/schemas/MitAdminContractHealth' + type: array + title: Data + type: object + required: + - as_of + - total_count + - data + title: AdminAnalyticsResponse[MitAdminContractHealth] + CompletionStatus: + type: string + enum: + - not_started + - in_progress + - passed + - certified + title: CompletionStatus + description: '``passed`` without ``certified`` is normal: certificates are issued + on a + + schedule after grading, and audit-mode enrollments never certify.' + CompletionStatusFilter: + type: string + enum: + - not_started + - in_progress + - passed + - certified + - unknown + title: CompletionStatusFilter + ContentEngagementDepth: + properties: + organization_key: + type: string + title: Organization Key + description: Internal identifier for the organization. + organization_name: + type: string + title: Organization Name + description: The organization's name. + courserun_readable_id: + type: string + title: Courserun Readable Id + description: The course run's ID, e.g. course-v1:MITxT+14.310x+2T2026. + courserun_title: + type: string + title: Courserun Title + description: The course's title. + total_enrolled_learners: + type: integer + title: Total Enrolled Learners + description: Learners who have ever enrolled in this course run. When too + few learners are in the group, the whole row is withheld to avoid identifying + them. + engaged_learners: + anyOf: + - type: integer + - type: 'null' + title: Engaged Learners + description: 'Enrolled learners who did anything in the course: watched + a video, attempted a problem, posted in a discussion, used the chatbot, + moved through course pages or earned a certificate. Withheld when too + few learners are in the group to report without identifying them.' + engagement_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Engagement Rate Pct + description: Percentage of enrolled learners who did anything in the course. + Withheld when the learner count it is based on is withheld. + total_videos_watched: + anyOf: + - type: integer + - type: 'null' + title: Total Videos Watched + description: Videos watched in this course run, counting every view. Withheld + when the learner count it is based on is withheld. + video_watchers: + anyOf: + - type: integer + - type: 'null' + title: Video Watchers + description: Learners who watched at least one video in this course run. + Withheld when too few learners are in the group to report without identifying + them. + avg_videos_per_engaged_learner: + anyOf: + - type: number + - type: 'null' + title: Avg Videos Per Engaged Learner + description: Average videos watched per learner who did anything in the + course. Withheld when the learner count it is based on is withheld. + total_problems_attempted: + anyOf: + - type: integer + - type: 'null' + title: Total Problems Attempted + description: Problem attempts in this course run, counting every attempt. + Withheld when the learner count it is based on is withheld. + problem_attempters: + anyOf: + - type: integer + - type: 'null' + title: Problem Attempters + description: Learners who attempted at least one problem in this course + run. Withheld when too few learners are in the group to report without + identifying them. + avg_problems_per_engaged_learner: + anyOf: + - type: number + - type: 'null' + title: Avg Problems Per Engaged Learner + description: Average problem attempts per learner who did anything in the + course. Withheld when the learner count it is based on is withheld. + total_chatbot_interactions: + anyOf: + - type: integer + - type: 'null' + title: Total Chatbot Interactions + description: Chatbot interactions in this course run. Withheld when the + learner count it is based on is withheld. + chatbot_users: + anyOf: + - type: integer + - type: 'null' + title: Chatbot Users + description: Learners who used the chatbot in this course run. Withheld + when too few learners are in the group to report without identifying them. + chatbot_adoption_pct: + anyOf: + - type: number + - type: 'null' + title: Chatbot Adoption Pct + description: Percentage of enrolled learners who used the chatbot. Withheld + when the learner count it is based on is withheld. + certificates_earned: + anyOf: + - type: integer + - type: 'null' + title: Certificates Earned + description: Certificates earned in this course run. Withheld when too few + learners are in the group to report without identifying them. + type: object + required: + - organization_key + - organization_name + - courserun_readable_id + - courserun_title + - total_enrolled_learners + - engaged_learners + - engagement_rate_pct + - total_videos_watched + - video_watchers + - avg_videos_per_engaged_learner + - total_problems_attempted + - problem_attempters + - avg_problems_per_engaged_learner + - total_chatbot_interactions + - chatbot_users + - chatbot_adoption_pct + - certificates_earned + title: ContentEngagementDepth + description: 'mv_b2b_content_engagement_depth — grain: org x course_run (all-time). + + + The chatbot columns are exact: ``total_chatbot_interactions`` sums over, + + and ``chatbot_adoption_pct`` divides by, ``chatbot_users`` — which this + + view does emit, so both are correctly floored. ``engagement_rate_pct`` is + + ``engaged_learners / total_enrolled_learners``, also correct. + + + The video and problem columns are floored through the cohorts the view now + + publishes (ol-data-platform PR #2520): ``total_videos_watched`` is summed + + over ``video_watchers`` and ``total_problems_attempted`` over + + ``problem_attempters``, each a strict subset of ``engaged_learners`` + + because watching a video or attempting a problem is one of the activities + + that sets ``active_count``. (Every cohort this view emits is such a + + subset. That is a property of these particular cohorts, not a general + + rule — see ``MonthlyEngagementTrend``, where ``enrolling_learners`` is + + not a subset of its primary because enrolling does not set + + ``active_count``.) + + + The ``avg_*_per_engaged_learner`` columns are derived from *two* cohorts, + + which is why each names both. The denominator is ``engaged_learners`` — + + that is what the dbt SQL divides by, so the naming is now accurate — but + + the numerator is the activity SUM, contributed by only the narrower + + cohort. Mapping the average to its denominator alone would leave the + + numerator recoverable: an unsuppressed average multiplied by a published + + ``engaged_learners`` yields the suppressed total exactly, and when the + + contributing cohort is a single learner that total *is* that learner''s + + value. Naming both cohorts nulls the average whenever either is sub-floor. + + + ``certificates_earned`` is the one column still floored as a count of + + itself: it is ``sum(certificate_count)``, an event count, and this view + + emits no certified-learner cohort to attribute it to (unlike + + ``MonthlyEngagementTrend``, which has ``certified_learners``). Flooring an + + event count is weaker than flooring a cohort — several certificates can + + come from one learner — but strictly better than not flooring it. Emitting + + the cohort from dbt would close this the same way #2520 closed the others.' + ContractContentEngagementDepth: + properties: + organization_key: + type: string + title: Organization Key + description: Internal identifier for the organization. + organization_name: + type: string + title: Organization Name + description: The organization's name. + courserun_readable_id: + type: string + title: Courserun Readable Id + description: The course run's ID, e.g. course-v1:MITxT+14.310x+2T2026. + courserun_title: + type: string + title: Courserun Title + description: The course's title. + total_enrolled_learners: + type: integer + title: Total Enrolled Learners + description: Learners who have ever enrolled in this course run. When too + few learners are in the group, the whole row is withheld to avoid identifying + them. + engaged_learners: + anyOf: + - type: integer + - type: 'null' + title: Engaged Learners + description: 'Enrolled learners who did anything in the course: watched + a video, attempted a problem, posted in a discussion, used the chatbot, + moved through course pages or earned a certificate. Withheld when too + few learners are in the group to report without identifying them.' + engagement_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Engagement Rate Pct + description: Percentage of enrolled learners who did anything in the course. + Withheld when the learner count it is based on is withheld. + total_videos_watched: + anyOf: + - type: integer + - type: 'null' + title: Total Videos Watched + description: Videos watched in this course run, counting every view. Withheld + when the learner count it is based on is withheld. + video_watchers: + anyOf: + - type: integer + - type: 'null' + title: Video Watchers + description: Learners who watched at least one video in this course run. + Withheld when too few learners are in the group to report without identifying + them. + avg_videos_per_engaged_learner: + anyOf: + - type: number + - type: 'null' + title: Avg Videos Per Engaged Learner + description: Average videos watched per learner who did anything in the + course. Withheld when the learner count it is based on is withheld. + total_problems_attempted: + anyOf: + - type: integer + - type: 'null' + title: Total Problems Attempted + description: Problem attempts in this course run, counting every attempt. + Withheld when the learner count it is based on is withheld. + problem_attempters: + anyOf: + - type: integer + - type: 'null' + title: Problem Attempters + description: Learners who attempted at least one problem in this course + run. Withheld when too few learners are in the group to report without + identifying them. + avg_problems_per_engaged_learner: + anyOf: + - type: number + - type: 'null' + title: Avg Problems Per Engaged Learner + description: Average problem attempts per learner who did anything in the + course. Withheld when the learner count it is based on is withheld. + total_chatbot_interactions: + anyOf: + - type: integer + - type: 'null' + title: Total Chatbot Interactions + description: Chatbot interactions in this course run. Withheld when the + learner count it is based on is withheld. + chatbot_users: + anyOf: + - type: integer + - type: 'null' + title: Chatbot Users + description: Learners who used the chatbot in this course run. Withheld + when too few learners are in the group to report without identifying them. + chatbot_adoption_pct: + anyOf: + - type: number + - type: 'null' + title: Chatbot Adoption Pct + description: Percentage of enrolled learners who used the chatbot. Withheld + when the learner count it is based on is withheld. + certificates_earned: + anyOf: + - type: integer + - type: 'null' + title: Certificates Earned + description: Certificates earned in this course run. Withheld when too few + learners are in the group to report without identifying them. + contract_pk: + type: string + title: Contract Pk + description: Internal identifier for the contract. + contract_id: + type: integer + title: Contract Id + description: The contract's ID in MITx Online. + b2b_contract_name: + type: string + title: B2B Contract Name + description: The contract's name. + type: object + required: + - organization_key + - organization_name + - courserun_readable_id + - courserun_title + - total_enrolled_learners + - engaged_learners + - engagement_rate_pct + - total_videos_watched + - video_watchers + - avg_videos_per_engaged_learner + - total_problems_attempted + - problem_attempters + - avg_problems_per_engaged_learner + - total_chatbot_interactions + - chatbot_users + - chatbot_adoption_pct + - certificates_earned + - contract_pk + - contract_id + - b2b_contract_name + title: ContractContentEngagementDepth + description: 'mv_b2b_contract_content_engagement_depth — grain: org x contract + x run. + + + The contract-scoped sibling of ``ContentEngagementDepth``, inherited for + + the same reason as ``ContractMonthlyEngagementTrend``. + + + Unlike the trend view, these rows ARE a strict partition of the org-level + + view: a course run belongs to exactly one contract, so naming the contract + + labels a row rather than splitting it, and every count here equals its + + org-level counterpart for the same course run. That equality is exactly + + what makes a suppressed contract recoverable by subtraction once an org + + holds more than one — the k-anonymity floor here is per-row and does not + + defend against differencing across the two grains.' + ContractMonthlyEngagementTrend: + properties: + organization_key: + type: string + title: Organization Key + description: Internal identifier for the organization. + organization_name: + type: string + title: Organization Name + description: The organization's name. + activity_year_and_month: + type: string + title: Activity Year And Month + description: The month, e.g. 2026-08. + monthly_active_learners: + type: integer + title: Monthly Active Learners + description: 'Learners who did anything in a course this month: watched + a video, attempted a problem, posted in a discussion, used the chatbot, + moved through course pages or earned a certificate. Enrolling alone doesn''t + count. If too few learners were active, the whole month is withheld to + avoid identifying them.' + new_enrollments: + anyOf: + - type: integer + - type: 'null' + title: New Enrollments + description: Course enrollments made this month. A learner who enrolled + in six courses counts six times. Withheld when the learner count it is + based on is withheld. + enrolling_learners: + anyOf: + - type: integer + - type: 'null' + title: Enrolling Learners + description: Learners who enrolled in at least one course this month. Withheld + when too few learners are in the group to report without identifying them. + certificates_earned: + anyOf: + - type: integer + - type: 'null' + title: Certificates Earned + description: Certificates earned this month. A learner who earned two counts + twice. Withheld when the learner count it is based on is withheld. + certified_learners: + anyOf: + - type: integer + - type: 'null' + title: Certified Learners + description: Learners who earned at least one certificate this month. Withheld + when too few learners are in the group to report without identifying them. + total_videos_watched: + anyOf: + - type: integer + - type: 'null' + title: Total Videos Watched + description: Videos watched this month, counting every view. Withheld when + the learner count it is based on is withheld. + video_watchers: + anyOf: + - type: integer + - type: 'null' + title: Video Watchers + description: Learners who watched at least one video this month. Withheld + when too few learners are in the group to report without identifying them. + total_problems_attempted: + anyOf: + - type: integer + - type: 'null' + title: Total Problems Attempted + description: Problem attempts this month, counting every attempt. Withheld + when the learner count it is based on is withheld. + problem_attempters: + anyOf: + - type: integer + - type: 'null' + title: Problem Attempters + description: Learners who attempted at least one problem this month. Withheld + when too few learners are in the group to report without identifying them. + total_chatbot_interactions: + anyOf: + - type: integer + - type: 'null' + title: Total Chatbot Interactions + description: Chatbot interactions this month. Withheld when the learner + count it is based on is withheld. + chatbot_users: + anyOf: + - type: integer + - type: 'null' + title: Chatbot Users + description: Learners who used the chatbot this month. Withheld when too + few learners are in the group to report without identifying them. + contract_pk: + type: string + title: Contract Pk + description: Internal identifier for the contract. + contract_id: + type: integer + title: Contract Id + description: The contract's ID in MITx Online. + b2b_contract_name: + type: string + title: B2B Contract Name + description: The contract's name. + type: object + required: + - organization_key + - organization_name + - activity_year_and_month + - monthly_active_learners + - new_enrollments + - enrolling_learners + - certificates_earned + - certified_learners + - total_videos_watched + - video_watchers + - total_problems_attempted + - problem_attempters + - total_chatbot_interactions + - chatbot_users + - contract_pk + - contract_id + - b2b_contract_name + title: ContractMonthlyEngagementTrend + description: 'mv_b2b_contract_monthly_engagement_trend — grain: org x contract + x month. + + + The contract-scoped sibling of ``MonthlyEngagementTrend``, backing the + + endpoints nested under a contract. Subclassed rather than redeclared so the + + two can''t drift: the column set and the ``cohort_policy`` — which is what + + the anonymization floor reads — are inherited verbatim, and only contract + + identity is added. The dbt models are siblings in the same way. + + + The contract columns are not cohorts and take no part in the policy. + + + A learner active under two of an org''s contracts appears in both rows, so + + these rows do not partition the org-level view''s learner counts; summing + + ``monthly_active_learners`` across contracts can exceed the org''s own + + figure. Activity totals, being sums of events, do add up.' + ContractUtilization: + properties: + organization_key: + type: string + title: Organization Key + description: Internal identifier for the organization. + organization_name: + type: string + title: Organization Name + description: The organization's name. + contract_pk: + type: string + title: Contract Pk + description: Internal identifier for the contract. + contract_id: + type: integer + title: Contract Id + description: The contract's ID in MITx Online. + b2b_contract_name: + type: string + title: B2B Contract Name + description: The contract's name. + b2b_contract_is_active: + type: boolean + title: B2B Contract Is Active + description: Whether the contract is currently active. + b2b_contract_start_date: + anyOf: + - type: string + format: date + - type: 'null' + title: B2B Contract Start Date + description: When the contract starts. Empty if no start date is set. + b2b_contract_end_date: + anyOf: + - type: string + format: date + - type: 'null' + title: B2B Contract End Date + description: When the contract ends. Empty if it has no end date. + seat_limit: + anyOf: + - type: integer + - type: 'null' + title: Seat Limit + description: How many seats the contract includes. Empty or zero means unlimited. + b2b_contract_membership_type: + anyOf: + - type: string + - type: 'null' + title: B2B Contract Membership Type + description: The contract's membership type. Empty if not set. + seats_consumed: + type: integer + title: Seats Consumed + description: Learners enrolled in at least one course under the contract. + When too few learners are in the group, the whole row is withheld to avoid + identifying them. + active_learners: + anyOf: + - type: integer + - type: 'null' + title: Active Learners + description: Learners on the contract whose enrollment is still active. + Withheld when too few learners are in the group to report without identifying + them. + learners_certified: + anyOf: + - type: integer + - type: 'null' + title: Learners Certified + description: Learners on the contract who earned a certificate that hasn't + been revoked. Withheld when too few learners are in the group to report + without identifying them. + seat_utilization_pct: + anyOf: + - type: number + - type: 'null' + title: Seat Utilization Pct + description: Percentage of the contract's seats in use. Empty when the contract + has unlimited seats. + completion_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Completion Rate Pct + description: Percentage of enrolled learners who earned a certificate. Withheld + when the learner count it is based on is withheld. + type: object + required: + - organization_key + - organization_name + - contract_pk + - contract_id + - b2b_contract_name + - b2b_contract_is_active + - b2b_contract_start_date + - b2b_contract_end_date + - seat_limit + - b2b_contract_membership_type + - seats_consumed + - active_learners + - learners_certified + - seat_utilization_pct + - completion_rate_pct + title: ContractUtilization + description: 'mv_b2b_contract_utilization — grain: org x contract. + + + ``seats_consumed`` is the primary cohort. ``active_learners`` and + + ``learners_certified`` are secondary counts, nulled when nonzero but below + + the floor. ``completion_rate_pct`` is ``learners_certified`` over + + ``seats_consumed`` and is nulled with it. ``seat_utilization_pct`` is + + ``seats_consumed`` over ``seat_limit``, null when the limit is zero or null + + (the view divides by ``nullif(seat_limit, 0)``).' + EnrollmentCompletionFunnel: + properties: + organization_key: + type: string + title: Organization Key + description: Internal identifier for the organization. + organization_name: + type: string + title: Organization Name + description: The organization's name. + contract_pk: + type: string + title: Contract Pk + description: Internal identifier for the contract. + contract_id: + type: integer + title: Contract Id + description: The contract's ID in MITx Online. + b2b_contract_name: + type: string + title: B2B Contract Name + description: The contract's name. + courserun_pk: + type: string + title: Courserun Pk + description: Internal identifier for the course run. + courserun_readable_id: + type: string + title: Courserun Readable Id + description: The course run's ID, e.g. course-v1:MITxT+14.310x+2T2026. + courserun_title: + type: string + title: Courserun Title + description: The course's title. + enrolled_learners: + type: integer + title: Enrolled Learners + description: Learners enrolled in this course run. When too few learners + are in the group, the whole row is withheld to avoid identifying them. + active_learners: + anyOf: + - type: integer + - type: 'null' + title: Active Learners + description: Enrolled learners whose enrollment is still active. Withheld + when too few learners are in the group to report without identifying them. + passing_learners: + anyOf: + - type: integer + - type: 'null' + title: Passing Learners + description: Enrolled learners with a passing grade. Withheld when too few + learners are in the group to report without identifying them. + certified_learners: + anyOf: + - type: integer + - type: 'null' + title: Certified Learners + description: Enrolled learners who earned a certificate that hasn't been + revoked. Withheld when too few learners are in the group to report without + identifying them. + active_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Active Rate Pct + description: Percentage of enrolled learners whose enrollment is still active. + Withheld when the learner count it is based on is withheld. + completion_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Completion Rate Pct + description: Percentage of enrolled learners who earned a certificate. Withheld + when the learner count it is based on is withheld. + type: object + required: + - organization_key + - organization_name + - contract_pk + - contract_id + - b2b_contract_name + - courserun_pk + - courserun_readable_id + - courserun_title + - enrolled_learners + - active_learners + - passing_learners + - certified_learners + - active_rate_pct + - completion_rate_pct + title: EnrollmentCompletionFunnel + description: 'mv_b2b_enrollment_completion_funnel — grain: org x contract x + course_run. + + + ``enrolled_learners`` is the primary cohort. ``active_rate_pct`` and + + ``completion_rate_pct`` are ``active_learners`` and ``certified_learners`` + + over ``enrolled_learners``, each nulled with its numerator.' + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + LearnerProgress: + properties: + learner_id: + type: string + title: Learner Id + description: The learner's account ID. It stays the same if their email + address changes. + email: + anyOf: + - type: string + - type: 'null' + title: Email + description: The learner's email address. It may differ from the one they + enrolled with. + full_name: + anyOf: + - type: string + - type: 'null' + title: Full Name + description: The learner's name, if they've provided one. + courserun_readable_id: + type: string + title: Courserun Readable Id + description: The course run's ID, e.g. course-v1:MITxT+14.310x+2T2026. + courserun_title: + type: string + title: Courserun Title + description: The course's title. + courserun_start_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun Start On + description: When the course run starts. Empty if no start date is set. + courserun_end_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun End On + description: When the course run ends. Empty for self-paced courses. + enrolled_on: + type: string + format: date-time + title: Enrolled On + description: When the learner enrolled in this course run. + enrollment_is_active: + type: boolean + title: Enrollment Is Active + description: Whether the learner is still enrolled. False if the enrollment + was deactivated, for example after unenrolling. + enrollment_mode: + anyOf: + - type: string + - type: 'null' + title: Enrollment Mode + description: The enrollment track, for example verified or audit. Audit + enrollments don't earn certificates. + outcomes_shared: + type: boolean + title: Outcomes Shared + description: Whether the learner has agreed to share their progress. If + not, their status, grades, certificate and activity are hidden. + completion_status: + anyOf: + - $ref: '#/components/schemas/CompletionStatus' + - type: 'null' + description: 'Where the learner is in the course: not started, in progress, + passed or certified. Hidden if the learner hasn''t agreed to share their + progress.' + is_passing: + anyOf: + - type: boolean + - type: 'null' + title: Is Passing + description: Whether the learner currently has a passing grade. Empty if + no grade has been calculated yet. Hidden if the learner hasn't agreed + to share their progress. + grade: + anyOf: + - type: number + - type: 'null' + title: Grade + description: The learner's current grade, from 0 to 1. Hidden if the learner + hasn't agreed to share their progress. + letter_grade: + anyOf: + - type: string + - type: 'null' + title: Letter Grade + description: The learner's letter grade, when the course assigns one. Hidden + if the learner hasn't agreed to share their progress. + certificate_issued_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Certificate Issued On + description: When the learner's certificate was issued. Empty if they don't + have one. Hidden if the learner hasn't agreed to share their progress. + certificate_is_revoked: + anyOf: + - type: boolean + - type: 'null' + title: Certificate Is Revoked + description: Whether the learner's certificate was revoked. A revoked certificate + doesn't count toward completion. Empty if they don't have one. Hidden + if the learner hasn't agreed to share their progress. + last_active_on: + anyOf: + - type: string + format: date + - type: 'null' + title: Last Active On + description: The last day the learner did anything in the course. Not available + yet, so always empty for now. + type: object + required: + - learner_id + - email + - full_name + - courserun_readable_id + - courserun_title + - courserun_start_on + - courserun_end_on + - enrolled_on + - enrollment_is_active + - enrollment_mode + - outcomes_shared + - completion_status + - is_passing + - grade + - letter_grade + - certificate_issued_on + - certificate_is_revoked + - last_active_on + title: LearnerProgress + description: One learner's enrollment in one course run under the contract. + LearnerProgressResponse: + properties: + organization_id: + type: string + title: Organization Id + description: The organization's ID. + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + description: When the data was last updated. Empty before the first update. + total_count: + type: integer + title: Total Count + description: Matching enrollments across all pages, not just this one. + outcomes_withheld_count: + type: integer + title: Outcomes Withheld Count + description: How many of those enrollments have progress hidden because + the learner hasn't agreed to share it. + data: + items: + $ref: '#/components/schemas/LearnerProgress' + type: array + title: Data + description: This page of enrollments. + type: object + required: + - organization_id + - as_of + - total_count + - outcomes_withheld_count + - data + title: LearnerProgressResponse + description: 'The org envelope (``organization_id``, ``as_of``, ``total_count``, + + ``data``) plus ``outcomes_withheld_count``, so a client can show how many + + rows carry withheld outcomes without paging through all of them.' + MitAdminContractHealth: + properties: + organization_key: + type: string + title: Organization Key + description: Internal identifier for the organization. + organization_name: + type: string + title: Organization Name + description: The organization's name. + contract_pk: + type: string + title: Contract Pk + description: Internal identifier for the contract. + contract_id: + type: integer + title: Contract Id + description: The contract's ID in MITx Online. + b2b_contract_name: + type: string + title: B2B Contract Name + description: The contract's name. + b2b_contract_is_active: + type: boolean + title: B2B Contract Is Active + description: Whether the contract is currently active. + b2b_contract_start_date: + anyOf: + - type: string + format: date + - type: 'null' + title: B2B Contract Start Date + description: When the contract starts. Empty if no start date is set. + b2b_contract_end_date: + anyOf: + - type: string + format: date + - type: 'null' + title: B2B Contract End Date + description: When the contract ends. Empty if it has no end date. + seat_limit: + anyOf: + - type: integer + - type: 'null' + title: Seat Limit + description: How many seats the contract includes. Empty or zero means unlimited. + b2b_contract_membership_type: + anyOf: + - type: string + - type: 'null' + title: B2B Contract Membership Type + description: The contract's membership type. Empty if not set. + seats_consumed: + type: integer + title: Seats Consumed + description: Learners enrolled in at least one course under the contract. + When too few learners are in the group, the whole row is withheld to avoid + identifying them. + active_learners: + anyOf: + - type: integer + - type: 'null' + title: Active Learners + description: Learners on the contract whose enrollment is still active. + Withheld when too few learners are in the group to report without identifying + them. + certified_learners: + anyOf: + - type: integer + - type: 'null' + title: Certified Learners + description: Learners on the contract who earned a certificate that hasn't + been revoked. Withheld when too few learners are in the group to report + without identifying them. + seat_utilization_pct: + anyOf: + - type: number + - type: 'null' + title: Seat Utilization Pct + description: Percentage of the contract's seats in use. Empty when the contract + has unlimited seats. + completion_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Completion Rate Pct + description: Percentage of enrolled learners who earned a certificate. Withheld + when the learner count it is based on is withheld. + health_status: + type: string + title: Health Status + description: 'Overall contract health: inactive (the contract isn''t active), + high_utilization (90% or more of seats in use), at_risk (under 25% of + seats in use and the contract ends within 90 days), healthy (50% or more + of seats in use), or early_stage (anything else).' + type: object + required: + - organization_key + - organization_name + - contract_pk + - contract_id + - b2b_contract_name + - b2b_contract_is_active + - b2b_contract_start_date + - b2b_contract_end_date + - seat_limit + - b2b_contract_membership_type + - seats_consumed + - active_learners + - certified_learners + - seat_utilization_pct + - completion_rate_pct + - health_status + title: MitAdminContractHealth + description: 'mv_b2b_mit_admin_contract_health — grain: org x contract (MIT + admin only). + + + Floored like ``ContractUtilization``. ``health_status`` is computed in the + + view from ``b2b_contract_is_active``, ``seat_utilization_pct`` and + + ``b2b_contract_end_date``.' + MonthlyEngagementTrend: + properties: + organization_key: + type: string + title: Organization Key + description: Internal identifier for the organization. + organization_name: + type: string + title: Organization Name + description: The organization's name. + activity_year_and_month: + type: string + title: Activity Year And Month + description: The month, e.g. 2026-08. + monthly_active_learners: + type: integer + title: Monthly Active Learners + description: 'Learners who did anything in a course this month: watched + a video, attempted a problem, posted in a discussion, used the chatbot, + moved through course pages or earned a certificate. Enrolling alone doesn''t + count. If too few learners were active, the whole month is withheld to + avoid identifying them.' + new_enrollments: + anyOf: + - type: integer + - type: 'null' + title: New Enrollments + description: Course enrollments made this month. A learner who enrolled + in six courses counts six times. Withheld when the learner count it is + based on is withheld. + enrolling_learners: + anyOf: + - type: integer + - type: 'null' + title: Enrolling Learners + description: Learners who enrolled in at least one course this month. Withheld + when too few learners are in the group to report without identifying them. + certificates_earned: + anyOf: + - type: integer + - type: 'null' + title: Certificates Earned + description: Certificates earned this month. A learner who earned two counts + twice. Withheld when the learner count it is based on is withheld. + certified_learners: + anyOf: + - type: integer + - type: 'null' + title: Certified Learners + description: Learners who earned at least one certificate this month. Withheld + when too few learners are in the group to report without identifying them. + total_videos_watched: + anyOf: + - type: integer + - type: 'null' + title: Total Videos Watched + description: Videos watched this month, counting every view. Withheld when + the learner count it is based on is withheld. + video_watchers: + anyOf: + - type: integer + - type: 'null' + title: Video Watchers + description: Learners who watched at least one video this month. Withheld + when too few learners are in the group to report without identifying them. + total_problems_attempted: + anyOf: + - type: integer + - type: 'null' + title: Total Problems Attempted + description: Problem attempts this month, counting every attempt. Withheld + when the learner count it is based on is withheld. + problem_attempters: + anyOf: + - type: integer + - type: 'null' + title: Problem Attempters + description: Learners who attempted at least one problem this month. Withheld + when too few learners are in the group to report without identifying them. + total_chatbot_interactions: + anyOf: + - type: integer + - type: 'null' + title: Total Chatbot Interactions + description: Chatbot interactions this month. Withheld when the learner + count it is based on is withheld. + chatbot_users: + anyOf: + - type: integer + - type: 'null' + title: Chatbot Users + description: Learners who used the chatbot this month. Withheld when too + few learners are in the group to report without identifying them. + type: object + required: + - organization_key + - organization_name + - activity_year_and_month + - monthly_active_learners + - new_enrollments + - enrolling_learners + - certificates_earned + - certified_learners + - total_videos_watched + - video_watchers + - total_problems_attempted + - problem_attempters + - total_chatbot_interactions + - chatbot_users + title: MonthlyEngagementTrend + description: "mv_b2b_monthly_engagement_trend — grain: org x year_month.\n\n\ + Every aggregate here is floored through the cohort that contributes to it,\n\ + which the view publishes alongside it (ol-data-platform PR #2520).\n\nNone\ + \ of them is attributable to ``monthly_active_learners``. Each is a\nplain\ + \ SUM over the source report, so only the learners who did that\nspecific\ + \ thing contribute — and clearing the primary floor says nothing\nabout whether\ + \ that narrower cohort cleared it. A month with 40 active\nlearners can carry\ + \ a chatbot total contributed by exactly one of them,\nwhich is why each total\ + \ is ``derived`` from its own cohort rather than\nfrom the primary.\n\nHow\ + \ each cohort relates to the primary differs, and neither case makes\nmapping\ + \ to the primary safe:\n\n- ``certified_learners``, ``video_watchers``, ``problem_attempters``\ + \ and\n ``chatbot_users`` are strict *subsets*. ``active_count`` is 1 when\ + \ any\n of navigation, discussion, videos, problems, chatbot or certificate\n\ + \ activity is nonzero (organization_administration_report.sql), so each\n\ + \ of those actions sets it.\n- ``enrolling_learners`` is *not* a subset.\ + \ ``enrolled_count`` is absent\n from that expression, so enrolling alone\ + \ never sets ``active_count``\n and a learner who only enrolled is counted\ + \ here but not in the primary.\n The row gate is unaffected — a month whose\ + \ primary is sub-floor is\n dropped whole, which over-suppresses a large\ + \ enrollment cohort rather\n than disclosing one — but the subset reasoning\ + \ does not apply, and\n ``new_enrollments`` is floored through ``enrolling_learners``\ + \ on its\n own terms.\n\n``new_enrollments`` and ``certificates_earned``\ + \ are SUMs of\nper-learner-per-course-run markers, so they count *events*,\ + \ not learners:\none learner enrolling in six runs reads as ``new_enrollments\ + \ == 6`` and\nwould clear a floor of 5 on its own. Flooring them directly\ + \ is therefore\nthe wrong instrument — they are ``derived`` from ``enrolling_learners``\n\ + and ``certified_learners``, the distinct-learner counts they are actually\n\ + attributable to, which do carry the floor." + OrgAnalyticsResponse_ContentEngagementDepth_: + properties: + organization_id: + type: string + title: Organization Id + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + total_count: + type: integer + title: Total Count + data: + items: + $ref: '#/components/schemas/ContentEngagementDepth' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[ContentEngagementDepth] + OrgAnalyticsResponse_ContractContentEngagementDepth_: + properties: + organization_id: + type: string + title: Organization Id + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + total_count: + type: integer + title: Total Count + data: + items: + $ref: '#/components/schemas/ContractContentEngagementDepth' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[ContractContentEngagementDepth] + OrgAnalyticsResponse_ContractMonthlyEngagementTrend_: + properties: + organization_id: + type: string + title: Organization Id + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + total_count: + type: integer + title: Total Count + data: + items: + $ref: '#/components/schemas/ContractMonthlyEngagementTrend' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[ContractMonthlyEngagementTrend] + OrgAnalyticsResponse_ContractUtilization_: + properties: + organization_id: + type: string + title: Organization Id + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + total_count: + type: integer + title: Total Count + data: + items: + $ref: '#/components/schemas/ContractUtilization' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[ContractUtilization] + OrgAnalyticsResponse_EnrollmentCompletionFunnel_: + properties: + organization_id: + type: string + title: Organization Id + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + total_count: + type: integer + title: Total Count + data: + items: + $ref: '#/components/schemas/EnrollmentCompletionFunnel' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[EnrollmentCompletionFunnel] + OrgAnalyticsResponse_MonthlyEngagementTrend_: + properties: + organization_id: + type: string + title: Organization Id + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + total_count: + type: integer + title: Total Count + data: + items: + $ref: '#/components/schemas/MonthlyEngagementTrend' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[MonthlyEngagementTrend] + OrgAnalyticsResponse_ProgramFunnel_: + properties: + organization_id: + type: string + title: Organization Id + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + total_count: + type: integer + title: Total Count + data: + items: + $ref: '#/components/schemas/ProgramFunnel' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[ProgramFunnel] + ProgramFunnel: + properties: + organization_key: + type: string + title: Organization Key + description: Internal identifier for the organization. + organization_name: + type: string + title: Organization Name + description: The organization's name. + contract_pk: + type: string + title: Contract Pk + description: Internal identifier for the contract. + contract_id: + type: integer + title: Contract Id + description: The contract's ID in MITx Online. + b2b_contract_name: + type: string + title: B2B Contract Name + description: The contract's name. + program_pk: + type: string + title: Program Pk + description: Internal identifier for the program. + program_title: + type: string + title: Program Title + description: The program's title. + total_courses: + type: integer + title: Total Courses + description: Courses in the program that the contract covers. + enrolled_in_contract_courses: + type: integer + title: Enrolled In Contract Courses + description: Learners enrolled in at least one of the program's courses + under the contract. When too few learners are in the group, the whole + row is withheld to avoid identifying them. + enrolled_via_program: + anyOf: + - type: integer + - type: 'null' + title: Enrolled Via Program + description: Of those learners, how many enrolled in the program itself + rather than directly in one of its courses. Withheld when too few learners + are in the group to report without identifying them. + program_course_completers: + anyOf: + - type: integer + - type: 'null' + title: Program Course Completers + description: Learners who earned a certificate in at least one of the program's + courses under the contract. This isn't the same as completing the whole + program. Withheld when too few learners are in the group to report without + identifying them. + type: object + required: + - organization_key + - organization_name + - contract_pk + - contract_id + - b2b_contract_name + - program_pk + - program_title + - total_courses + - enrolled_in_contract_courses + - enrolled_via_program + - program_course_completers + title: ProgramFunnel + description: 'mv_b2b_program_funnel — grain: org x contract x program. + + + ``total_courses`` counts courses, not learners, so it is not a cohort. + + + ``program_course_completers`` approximates program completion: it counts a + + non-revoked certificate in any contract-covered course of the program, not + + a program-level certificate, pending a program-certificate fact table.' + SortKey: + type: string + enum: + - full_name + - email + - enrolled_on + - courserun_readable_id + title: SortKey + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + input: + title: Input + ctx: + type: object + title: Context + type: object + required: + - loc + - msg + - type + title: ValidationError diff --git a/openapi/specs/b2b_learner_records.yaml b/openapi/specs/b2b_learner_records.yaml new file mode 100644 index 0000000..b8112f4 --- /dev/null +++ b/openapi/specs/b2b_learner_records.yaml @@ -0,0 +1,856 @@ +openapi: 3.1.0 +info: + title: B2B Learner Records + description: Machine-to-machine, read-only, organization-scoped learner progress + records for B2B site-license partners. Records identify individual learners. + version: 0.0.1 +paths: + /api/v1/learner-records/organizations/{organization_id}/learners: + get: + tags: + - learners + summary: Learner roster with progress rollups + operationId: listLearners + security: + - oauth2ClientCredentials: + - learner-records:read + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + format: uuid + title: Organization Id + - name: contract_id + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Contract Id + - name: learner_id + in: query + required: false + schema: + type: array + items: + type: string + format: uuid + maxItems: 100 + description: Repeat for several. + title: Learner Id + description: Repeat for several. + - name: updated_since + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + description: Return only records changed at or after this instant. + title: Updated Since + description: Return only records changed at or after this instant. + - name: include_inactive + in: query + required: false + schema: + type: boolean + description: Include deactivated enrollments (unenrolled, refunded, transferred). + default: false + title: Include Inactive + description: Include deactivated enrollments (unenrolled, refunded, transferred). + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/LearnerRecordsResponse_Learner_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/learner-records/organizations/{organization_id}/enrollments: + get: + tags: + - enrollments + summary: Learner-by-course-run enrollment and completion records + operationId: listEnrollments + security: + - oauth2ClientCredentials: + - learner-records:read + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + format: uuid + title: Organization Id + - name: contract_id + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Contract Id + - name: courserun_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Courserun Id + - name: learner_id + in: query + required: false + schema: + type: array + items: + type: string + format: uuid + maxItems: 100 + description: Repeat for several. + title: Learner Id + description: Repeat for several. + - name: completion_status + in: query + required: false + schema: + type: array + items: + $ref: '#/components/schemas/CompletionStatusFilter' + title: Completion Status + - name: updated_since + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + description: Return only records changed at or after this instant. + title: Updated Since + description: Return only records changed at or after this instant. + - name: include_inactive + in: query + required: false + schema: + type: boolean + description: Include deactivated enrollments (unenrolled, refunded, transferred). + default: false + title: Include Inactive + description: Include deactivated enrollments (unenrolled, refunded, transferred). + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/LearnerRecordsResponse_Enrollment_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/learner-records/organizations/{organization_id}/courses: + get: + tags: + - catalog + summary: Contracts and course runs covered by the organization's licence + operationId: listCourses + security: + - oauth2ClientCredentials: + - learner-records:read + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + format: uuid + title: Organization Id + - name: contract_id + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Contract Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/LearnerRecordsResponse_CourseRun_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + CompletionStatus: + type: string + enum: + - not_started + - in_progress + - passed + - certified + title: CompletionStatus + description: '``passed`` without ``certified`` is normal — certificates are + issued on + + a schedule after grading, and audit-mode enrollments never certify.' + CompletionStatusFilter: + type: string + enum: + - not_started + - in_progress + - passed + - certified + - unknown + title: CompletionStatusFilter + CourseRun: + properties: + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + organization_name: + type: string + title: Organization Name + description: Display name of the organization. + contract_id: + type: integer + format: int64 + title: Contract Id + description: Numeric identifier of the B2B contract. + contract_name: + type: string + title: Contract Name + description: Name of the B2B contract. + contract_is_active: + type: boolean + title: Contract Is Active + description: Whether the contract is currently active. + contract_start_date: + anyOf: + - type: string + format: date + - type: 'null' + title: Contract Start Date + description: Null where the contract records no start date. + contract_end_date: + anyOf: + - type: string + format: date + - type: 'null' + title: Contract End Date + description: Null where the contract records no end date. + seat_limit: + anyOf: + - type: integer + minimum: 0.0 + - type: 'null' + title: Seat Limit + description: Null means uncapped, not zero. + courserun_id: + type: string + title: Courserun Id + description: Readable course-run identifier, e.g. `course-v1:MITxT+14.310x+2T2026`. + courserun_title: + type: string + title: Courserun Title + description: Mutable display title — key on courserun_id. + courserun_start_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun Start On + description: Start date/time of the course run, or null if unscheduled. + courserun_end_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun End On + description: Null for self-paced runs. + type: object + required: + - organization_id + - organization_name + - contract_id + - contract_name + - contract_is_active + - contract_start_date + - contract_end_date + - seat_limit + - courserun_id + - courserun_title + - courserun_start_on + - courserun_end_on + title: CourseRun + description: 'One course run covered by one of the organization''s contracts. + No personal data, + + so no consent gate.' + Enrollment: + properties: + learner_id: + type: string + format: uuid + title: Learner Id + description: Stable opaque identifier, consistent across endpoints and stable + across email changes. Use as the join key. + email: + anyOf: + - type: string + - type: 'null' + title: Email + description: Not a join key; may differ from the enrolling address. + full_name: + anyOf: + - type: string + - type: 'null' + title: Full Name + description: Often null. + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + contract_id: + type: integer + format: int64 + title: Contract Id + description: Numeric identifier of the B2B contract the enrollment is attributed + to. + contract_name: + type: string + title: Contract Name + description: Name of the B2B contract. + courserun_id: + type: string + title: Courserun Id + description: Readable course-run identifier, e.g. `course-v1:MITxT+14.310x+2T2026`. + courserun_title: + type: string + title: Courserun Title + description: Mutable display title — key on courserun_id. + courserun_start_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun Start On + description: Start date/time of the course run, or null if unscheduled. + courserun_end_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun End On + description: Null for self-paced runs. + enrolled_on: + type: string + format: date-time + title: Enrolled On + description: This run's enrollment, not an earlier run of the same course. + Not consent-gated. + enrollment_is_active: + type: boolean + title: Enrollment Is Active + description: Not consent-gated. + enrollment_mode: + anyOf: + - type: string + - type: 'null' + title: Enrollment Mode + description: e.g. `verified`, `audit`. Determines whether the run is certificate-bearing. + enrollment_status: + anyOf: + - type: string + - type: 'null' + title: Enrollment Status + description: Deactivation reason where one was recorded. + outcomes_shared: + type: boolean + title: Outcomes Shared + description: Whether the learner has opted in to sharing their course status. + False means every field below is null. + completion_status: + anyOf: + - $ref: '#/components/schemas/CompletionStatus' + - type: 'null' + description: 'Single derived answer per row. Null when outcomes are withheld. + Until activity data lands, not_started and in_progress come from the grade + alone: in_progress means a nonzero grade, so a learner active in the run + with no graded work yet reads not_started.' + is_passing: + anyOf: + - type: boolean + - type: 'null' + title: Is Passing + description: Null where no grade has been computed. + grade: + anyOf: + - type: number + - type: 'null' + title: Grade + description: Numeric grade between 0 and 1. + letter_grade: + anyOf: + - type: string + - type: 'null' + title: Letter Grade + description: Frequently null — not every platform records one. + certificate_issued_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Certificate Issued On + description: Timestamp the certificate was issued, or null if none exists. + certificate_is_revoked: + anyOf: + - type: boolean + - type: 'null' + title: Certificate Is Revoked + description: 'Null where no certificate exists. A revoked certificate doesn''t + count as certified: completion_status then follows the grade, so it can + read passed, in_progress or not_started.' + last_active_on: + anyOf: + - type: string + format: date + - type: 'null' + title: Last Active On + description: 'Most recent day with recorded activity in this course run. + Not yet populated upstream: null for every record until activity data + lands, whatever outcomes_shared says.' + days_active: + anyOf: + - type: integer + - type: 'null' + title: Days Active + description: 'Distinct days with recorded activity in this course run. Not + yet populated upstream: null for every record until activity data lands, + whatever outcomes_shared says.' + videos_watched: + anyOf: + - type: integer + - type: 'null' + title: Videos Watched + description: 'Distinct video blocks played. Not yet populated upstream: + null for every record until activity data lands, whatever outcomes_shared + says.' + problems_attempted: + anyOf: + - type: integer + - type: 'null' + title: Problems Attempted + description: 'Distinct problem blocks attempted. Not yet populated upstream: + null for every record until activity data lands, whatever outcomes_shared + says.' + chatbot_interactions: + anyOf: + - type: integer + - type: 'null' + title: Chatbot Interactions + description: 'Chatbot interactions recorded for this course run. Not yet + populated upstream: null for every record until activity data lands, whatever + outcomes_shared says.' + type: object + required: + - learner_id + - email + - full_name + - organization_id + - contract_id + - contract_name + - courserun_id + - courserun_title + - courserun_start_on + - courserun_end_on + - enrolled_on + - enrollment_is_active + - enrollment_mode + - enrollment_status + - outcomes_shared + - completion_status + - is_passing + - grade + - letter_grade + - certificate_issued_on + - certificate_is_revoked + - last_active_on + - days_active + - videos_watched + - problems_attempted + - chatbot_interactions + title: Enrollment + description: One learner's enrollment in one course run under one contract. + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + Learner: + properties: + learner_id: + type: string + format: uuid + title: Learner Id + description: Stable opaque identifier, consistent across endpoints and stable + across email changes. Use as the join key. + email: + anyOf: + - type: string + - type: 'null' + title: Email + description: Not a join key; may differ from the enrolling address. + full_name: + anyOf: + - type: string + - type: 'null' + title: Full Name + description: Often null. + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + organization_name: + type: string + title: Organization Name + description: Display name of the organization. + membership_source: + $ref: '#/components/schemas/MembershipSource' + description: How the learner is associated with the organization; see MembershipSource + for the individual values. + is_organization_manager: + type: boolean + title: Is Organization Manager + description: Administers the organization in MITx Online. + first_enrolled_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: First Enrolled On + description: Null for a roster member with no enrollments. + last_enrolled_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Last Enrolled On + description: Timestamp of the learner's most recent enrollment. + courses_enrolled: + type: integer + title: Courses Enrolled + description: Distinct course runs under the organization's contracts. Not + consent-gated. + outcomes_shared: + type: boolean + title: Outcomes Shared + description: Whether this learner has opted in to sharing their course status. + False means every field below is null. + outcomes_consent_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Outcomes Consent On + description: When consent was recorded. Null when outcomes_shared is false. + last_active_on: + anyOf: + - type: string + format: date + - type: 'null' + title: Last Active On + description: 'Most recent day with recorded course activity. A date, not + a timestamp: activity is aggregated per day. Not yet populated upstream: + null for every record until activity data lands, whatever outcomes_shared + says.' + courses_in_progress: + anyOf: + - type: integer + - type: 'null' + title: Courses In Progress + description: 'Distinct course runs the learner has started but not yet passed + or certified. Not yet populated upstream: null for every record until + activity data lands, whatever outcomes_shared says.' + courses_passed: + anyOf: + - type: integer + - type: 'null' + title: Courses Passed + description: Distinct course runs where the learner has a passing grade. + courses_certified: + anyOf: + - type: integer + - type: 'null' + title: Courses Certified + description: Distinct course runs where the learner holds a non-revoked + certificate. + certificates_earned: + anyOf: + - type: integer + - type: 'null' + title: Certificates Earned + description: Includes program certificates, which have no course run and + so are not in courses_certified. + type: object + required: + - learner_id + - email + - full_name + - organization_id + - organization_name + - membership_source + - is_organization_manager + - first_enrolled_on + - last_enrolled_on + - courses_enrolled + - outcomes_shared + - outcomes_consent_on + - last_active_on + - courses_in_progress + - courses_passed + - courses_certified + - certificates_earned + title: Learner + description: One learner's association with the organization. + LearnerRecordsResponse_CourseRun_: + properties: + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + description: Last refresh of the backing data. Null before an organization's + first refresh. + total_count: + type: integer + title: Total Count + description: Matching records across all pages, not this page. + outcomes_withheld_count: + type: integer + title: Outcomes Withheld Count + description: 'Records in total_count carrying outcomes_shared: false. Always + 0 on endpoints with no consent gate.' + data: + items: + $ref: '#/components/schemas/CourseRun' + type: array + title: Data + description: The requested page of records. + type: object + required: + - organization_id + - as_of + - total_count + - outcomes_withheld_count + - data + title: LearnerRecordsResponse[CourseRun] + LearnerRecordsResponse_Enrollment_: + properties: + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + description: Last refresh of the backing data. Null before an organization's + first refresh. + total_count: + type: integer + title: Total Count + description: Matching records across all pages, not this page. + outcomes_withheld_count: + type: integer + title: Outcomes Withheld Count + description: 'Records in total_count carrying outcomes_shared: false. Always + 0 on endpoints with no consent gate.' + data: + items: + $ref: '#/components/schemas/Enrollment' + type: array + title: Data + description: The requested page of records. + type: object + required: + - organization_id + - as_of + - total_count + - outcomes_withheld_count + - data + title: LearnerRecordsResponse[Enrollment] + LearnerRecordsResponse_Learner_: + properties: + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + description: Last refresh of the backing data. Null before an organization's + first refresh. + total_count: + type: integer + title: Total Count + description: Matching records across all pages, not this page. + outcomes_withheld_count: + type: integer + title: Outcomes Withheld Count + description: 'Records in total_count carrying outcomes_shared: false. Always + 0 on endpoints with no consent gate.' + data: + items: + $ref: '#/components/schemas/Learner' + type: array + title: Data + description: The requested page of records. + type: object + required: + - organization_id + - as_of + - total_count + - outcomes_withheld_count + - data + title: LearnerRecordsResponse[Learner] + MembershipSource: + type: string + enum: + - roster + - enrollment + - both + title: MembershipSource + description: '``roster`` = on the organization''s membership roster with no + enrollments + + (an assigned, unstarted seat). ``enrollment`` = enrolled under a contract + + but absent from the roster (usually a provisioning lag). ``both`` = the + + expected state.' + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + input: + title: Input + ctx: + type: object + title: Context + type: object + required: + - loc + - msg + - type + title: ValidationError + securitySchemes: + oauth2ClientCredentials: + type: oauth2 + flows: + clientCredentials: + scopes: + learner-records:read: Read records, identity fields included. + tokenUrl: https://sso.ol.mit.edu/realms/olapps/protocol/openid-connect/token diff --git a/pyproject.toml b/pyproject.toml index 9f11af1..4537986 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,9 @@ dev = [ "types-hvac>=2.3", "asgi-lifespan>=2.1.0", "pytest-cov>=7.1.0", + "cyclopts>=4.22.5", + "pyyaml>=6.0.3", + "types-pyyaml>=6.0.12.20260724", ] [build-system] @@ -106,6 +109,9 @@ skip_covered = false [tool.ruff] line-length = 100 target-version = "py312" +# bin/ scripts are extensionless with a shebang, matching the bin/starrocks-auth +# convention. Ruff discovers *.py only, so `ruff check .` would skip them. +extend-include = ["bin/*"] [tool.ruff.lint] select = ["ALL"] diff --git a/src/ol_analytics_api/main.py b/src/ol_analytics_api/main.py index b2011e2..7a8c25d 100644 --- a/src/ol_analytics_api/main.py +++ b/src/ol_analytics_api/main.py @@ -98,8 +98,14 @@ class Tenant: idiom a tenant author already writes) makes the lifecycle contract structural: a tenant declares its lifespan in one place and hands it over, instead of remembering to wire a bespoke hook pair into a registry. + + ``name`` is the tenant's stable slug — the tenant's own ``TENANT_NAME``, + which already names its readiness sub-path. It also names the tenant's + published OpenAPI document (openapi/specs/.yaml), so it is part of a + consumer-visible filename and should not be renamed casually. """ + name: str mount_path: str create_app: Callable[[], FastAPI] lifespan: Callable[[FastAPI], AbstractAsyncContextManager[None]] | None = None @@ -107,8 +113,17 @@ class Tenant: # Add a new tenant by appending a Tenant() entry here. TENANTS: list[Tenant] = [ - Tenant("/api/v1/analytics", b2b_dashboard.create_app, b2b_dashboard.lifespan), - Tenant("/api/v1/learner-records", b2b_learner_records.create_app), + Tenant( + b2b_dashboard.TENANT_NAME, + "/api/v1/analytics", + b2b_dashboard.create_app, + b2b_dashboard.lifespan, + ), + Tenant( + b2b_learner_records.TENANT_NAME, + "/api/v1/learner-records", + b2b_learner_records.create_app, + ), ] diff --git a/src/ol_analytics_api/openapi.py b/src/ol_analytics_api/openapi.py new file mode 100644 index 0000000..ca6192e --- /dev/null +++ b/src/ol_analytics_api/openapi.py @@ -0,0 +1,78 @@ +"""Compose one OpenAPI document per mounted tenant. + +Each tenant is an independent ``FastAPI()`` mounted under the root app (see +main.py), so it owns its own ``/openapi.json`` and the root app's schema does +not contain a single tenant path. Dumping ``app.openapi()`` therefore yields +the health endpoints and nothing a client would generate against — the schema +a consumer needs has to come from the sub-app. + +Two things are fixed up on the way out, and both exist because the document is +written for a *client generator* rather than for the sub-app's own ``/docs``: + +- **Paths are re-prefixed with the mount path.** A sub-app describes its routes + relative to its own root ("/organizations/{id}/..."), because Starlette's + Mount strips the prefix before the sub-app ever sees the request. A generated + client configured with the service host as its base URL would then request + the wrong URL. Prefixing here keeps the generated client's paths identical to + the absolute paths the service actually serves, which is also what + mit-learn's hand-written client hardcodes today. + +- **The document version is pinned, not read from the package.** Sourcing it + from the package's CalVer would rewrite every spec on every release, and the + committed spec exists to make *interface* changes visible in review. A + release that changes no route should produce no diff here. + +``tenant_specs()`` builds the documents and ``render()`` serializes one exactly +as the committed file holds it. Both live here rather than in +``bin/generate-openapi-spec`` so the drift test compares against the same +serializer that wrote the file, instead of a second one that can disagree. + +This module is a build-time tool. Nothing the server imports reaches it, which +is why PyYAML and cyclopts are dev dependencies: the running service never +needs either. +""" + +from __future__ import annotations + +from typing import Any + +import yaml + +from ol_analytics_api.main import TENANTS, create_app + +# Pinned rather than derived from the package version — see the module +# docstring. Bump deliberately when a tenant's interface breaks. +SPEC_VERSION = "0.0.1" + + +def _prefix_paths(paths: dict[str, Any], mount_path: str) -> dict[str, Any]: + return {f"{mount_path}{path}": item for path, item in paths.items()} + + +def tenant_specs() -> dict[str, dict[str, Any]]: + """Returns ``{tenant name: OpenAPI document}`` for every mounted tenant. + + Builds the apps through the same ``create_app()`` the server runs, so a + route the registry does not actually mount cannot reach a published spec. + """ + root = create_app() + tenant_apps = root.state.tenant_apps + specs: dict[str, dict[str, Any]] = {} + for tenant in TENANTS: + spec = tenant_apps[tenant.mount_path].openapi() + spec["info"]["version"] = SPEC_VERSION + spec["paths"] = _prefix_paths(spec["paths"], tenant.mount_path) + specs[tenant.name] = spec + return specs + + +def render(spec: dict[str, Any]) -> str: + """Serialize one OpenAPI document exactly as the committed file holds it. + + ``sort_keys=False`` keeps FastAPI's own ordering (paths in registration + order, then components) rather than alphabetising the whole document. That + order is already deterministic across runs, and alphabetising it would put + every path's method, parameters and responses in an order nobody wrote, + making real changes harder to find in a diff. + """ + return yaml.safe_dump(spec, sort_keys=False, default_flow_style=False, allow_unicode=True) diff --git a/src/ol_analytics_api/tenants/b2b_dashboard/routers/admin.py b/src/ol_analytics_api/tenants/b2b_dashboard/routers/admin.py index 9292cc9..5beff3e 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/admin.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/admin.py @@ -38,7 +38,7 @@ _ORDER_BY = ("organization_key", "contract_pk") -@router.get("/contract-health") +@router.get("/contract-health", operation_id="admin_contract_health_retrieve") async def contract_health( page: Annotated[Pagination, Depends(pagination)], ) -> AdminAnalyticsResponse[MitAdminContractHealth]: diff --git a/src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py b/src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py index ac38c28..02ddb64 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py @@ -156,6 +156,11 @@ async def endpoint( # its concrete row model; mypy can't type a value used as a type param. response_model=OrgAnalyticsResponse[spec.model], # type: ignore[name-defined] name=endpoint.__name__, + # See the same call in organizations.py: named explicitly so a + # generated client's method name is ours rather than a derivative of + # the path. The `contracts_` prefix is what separates these from the + # org router's identically-named panels. + operation_id=f"contracts_{endpoint.__name__}_retrieve", ) diff --git a/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py b/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py index 28d4cf3..90ece14 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py @@ -54,6 +54,7 @@ class CompletionStatusFilter(StrEnum): "/learner-progress", response_model=LearnerProgressResponse, name="learner_progress", + operation_id="learners_progress_retrieve", summary="Each learner's enrollment and completion in each course run under the contract", ) async def learner_progress( # noqa: PLR0913 @@ -66,9 +67,12 @@ async def learner_progress( # noqa: PLR0913 Query(min_length=1, max_length=254, description="Case-insensitive match on email or name."), ] = None, completion_status: Annotated[ - list[CompletionStatusFilter] | None, - Query(description="Repeat for several. `unknown` selects rows with withheld outcomes."), - ] = None, + list[CompletionStatusFilter], + Query( + description="Repeat for several. `unknown` selects rows with withheld outcomes.", + default_factory=list, + ), + ], include_inactive: Annotated[ bool, Query(description="Include deactivated enrollments (unenrolled, refunded).") ] = False, diff --git a/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py b/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py index b3cc645..cf2e9b9 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py @@ -157,6 +157,13 @@ async def endpoint( # its concrete row model; mypy can't type a value used as a type param. response_model=OrgAnalyticsResponse[spec.model], # type: ignore[name-defined] name=endpoint.__name__, + # Named explicitly because this is what a generated client's method is + # called. FastAPI's default derives one from the function name *and* + # the whole path, which would make the TS method + # `contractUtilizationOrganizationsOrganizationIdContractUtilizationGet` + # and — worse — churn it whenever the path changes. The tag prefix is + # what keeps this distinct from the contract router's same-named panel. + operation_id=f"organizations_{endpoint.__name__}_retrieve", ) diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py b/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py index 590d18b..5d06ffb 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py @@ -53,8 +53,12 @@ def page( PageParams = Annotated[Page, Depends(page)] LearnerIds = Annotated[ - list[uuid.UUID] | None, - Query(max_length=settings.max_learner_ids, description="Repeat for several."), + list[uuid.UUID], + Query( + max_length=settings.max_learner_ids, + description="Repeat for several.", + default_factory=list, + ), ] UpdatedSince = Annotated[ datetime.datetime | None, @@ -103,7 +107,7 @@ async def list_learners( # noqa: PLR0913 organization_id: uuid.UUID, page: PageParams, contract_id: int | None = None, - learner_id: LearnerIds = None, + learner_id: LearnerIds, updated_since: UpdatedSince = None, include_inactive: IncludeInactive = False, ) -> LearnerRecordsResponse[BaseModel]: @@ -132,8 +136,8 @@ async def list_enrollments( # noqa: PLR0913 page: PageParams, contract_id: int | None = None, courserun_id: str | None = None, - learner_id: LearnerIds = None, - completion_status: Annotated[list[CompletionStatusFilter] | None, Query()] = None, + learner_id: LearnerIds, + completion_status: Annotated[list[CompletionStatusFilter], Query(default_factory=list)], updated_since: UpdatedSince = None, include_inactive: IncludeInactive = False, ) -> LearnerRecordsResponse[BaseModel]: diff --git a/tests/test_learner_records.py b/tests/test_learner_records.py index 6690d33..0b6a67b 100644 --- a/tests/test_learner_records.py +++ b/tests/test_learner_records.py @@ -399,6 +399,29 @@ async def test_enrollment_filters_are_bound_in_order(app, monkeypatch): assert pool.count_call()[1] == params[:-2] +async def test_omitted_list_filters_add_no_predicate(app, monkeypatch): + """`learner_id` and `completion_status` are plain arrays defaulting to [], + not `list | None`: the optional form publishes `anyOf: [array, null]`, which + openapi-generator cannot reduce to a usable client type. An empty array is + not expressible in a query string (a generated client omits the parameter, + and `?learner_id=` is an empty *string*, rejected as a bad UUID), so the + default is only ever reached by omitting the parameter. This pins that + reaching it adds no predicate.""" + pool = _FakePool(rows=[_enrollment_row()], total_count=1, withheld=1) + response = await _get( + app, + f"/organizations/{ORG_ID}/enrollments", + _partner_header(ORG_ID), + pool, + monkeypatch, + ) + assert response.status_code == 200 + query, params = pool.page_call() + assert "learner_id IN" not in query + assert "completion_status IN" not in query + assert params == (ORG_ID, 100, 0) + + @pytest.mark.parametrize( "query", [ diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index 59a4009..9309e74 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -67,8 +67,8 @@ async def failing_lifespan(_app: object): yield # unreachable, but keeps this an async generator fake_tenants = [ - Tenant("/ok", create_app=object, lifespan=ok_lifespan), - Tenant("/broken", create_app=object, lifespan=failing_lifespan), + Tenant("ok", "/ok", create_app=object, lifespan=ok_lifespan), + Tenant("broken", "/broken", create_app=object, lifespan=failing_lifespan), ] root_app = SimpleNamespace( state=SimpleNamespace(tenant_apps={"/ok": object(), "/broken": object()}) diff --git a/tests/test_openapi_spec.py b/tests/test_openapi_spec.py new file mode 100644 index 0000000..2088582 --- /dev/null +++ b/tests/test_openapi_spec.py @@ -0,0 +1,119 @@ +"""The published OpenAPI contract. + +`openapi/specs/.yaml` is what a future Concourse client pipeline is +meant to generate the TypeScript package from (see README.md), so these +assertions are about what consumers would receive, not about FastAPI's +internals. +""" + +from pathlib import Path + +import pytest +from fastapi.routing import APIRoute + +from ol_analytics_api.main import TENANTS, create_app +from ol_analytics_api.openapi import render, tenant_specs + +SPECS_DIR = Path(__file__).resolve().parent.parent / "openapi" / "specs" + + +@pytest.fixture(scope="module") +def specs(): + return tenant_specs() + + +def test_committed_spec_matches_the_code(specs): + """The whole reason the spec is committed: drift is a failing test, not a + consumer discovering a renamed column at runtime.""" + for tenant_name, spec in specs.items(): + path = SPECS_DIR / f"{tenant_name}.yaml" + assert path.exists(), f"{path} is missing. Run `uv run bin/generate-openapi-spec`." + assert path.read_text() == render(spec), ( + f"{path} is out of date. Run `uv run bin/generate-openapi-spec`." + ) + + +def test_every_mounted_tenant_publishes_a_spec(specs): + assert set(specs) == {tenant.name for tenant in TENANTS} + + +def test_paths_carry_the_mount_prefix(specs): + """A sub-app describes its routes relative to its own root, but a generated + client is configured with the service host as its base URL. Publishing the + unprefixed paths would produce a client that requests URLs the service does + not serve.""" + for tenant in TENANTS: + paths = specs[tenant.name]["paths"] + assert paths, f"{tenant.name} published no paths at all" + assert all(path.startswith(f"{tenant.mount_path}/") for path in paths) + + +def test_each_row_model_gets_its_own_response_schema(specs): + """The org and contract endpoints are registered in a loop over a table of + specs, parametrizing one generic envelope at runtime. If that collapsed to + a single `OrgAnalyticsResponse` component, every panel would generate the + same untyped row and the whole point of generating a client would be lost. + """ + schemas = specs["b2b_dashboard"]["components"]["schemas"] + envelopes = {name for name in schemas if name.startswith("OrgAnalyticsResponse")} + row_models = { + "ContractUtilization", + "EnrollmentCompletionFunnel", + "MonthlyEngagementTrend", + "ProgramFunnel", + "ContentEngagementDepth", + "ContractMonthlyEngagementTrend", + "ContractContentEngagementDepth", + } + assert envelopes == {f"OrgAnalyticsResponse_{model}_" for model in row_models} + assert row_models <= set(schemas) + + +def test_operation_ids_are_unique_and_stable(specs): + """openapi-generator names a client method after its operationId, so a + collision silently drops a method and a path-derived default renames every + method whenever a route moves. Both are named explicitly in the routers.""" + operation_ids = [ + operation["operationId"] + for spec in specs.values() + for path_item in spec["paths"].values() + for operation in path_item.values() + ] + assert len(operation_ids) == len(set(operation_ids)) + # The org and contract routers expose identically-named panels; the tag + # prefix is what keeps them apart. + assert "organizations_contract_utilization_retrieve" in operation_ids + assert "contracts_contract_utilization_retrieve" in operation_ids + + +def test_operation_ids_are_explicit(): + """Uniqueness alone doesn't catch a route that never set operation_id: + FastAPI falls back to a path-derived default, which is unique but not + stable, so a route relying on it would pass the test above and still + rename its generated client method whenever the path moves.""" + root = create_app() + for tenant in TENANTS: + tenant_app = root.state.tenant_apps[tenant.mount_path] + for route in tenant_app.routes: + if isinstance(route, APIRoute): + assert route.operation_id is not None, ( + f"{tenant.name} route {route.path} has no explicit operation_id" + ) + + +def test_array_query_params_are_not_nullable(specs): + """A `list[X] | None` parameter publishes `anyOf: [{type: array}, null]`. + openapi-generator's typescript-axios cannot reduce that, so it treats the + parameter as an arbitrary object and spreads it with `Object.entries`, + emitting `?0=a&1=b` instead of repeating the parameter name, and failing + `tsc --strict`. Declare repeatable filters as plain arrays with + `Query(default_factory=list)` instead.""" + offenders = [ + f"{tenant_name} {path} {parameter['name']}" + for tenant_name, spec in specs.items() + for path, path_item in spec["paths"].items() + for operation in path_item.values() + for parameter in operation.get("parameters", []) + if any(option.get("type") == "array" for option in parameter["schema"].get("anyOf", [])) + ] + assert not offenders diff --git a/uv.lock b/uv.lock index 7cace63..00c23b9 100644 --- a/uv.lock +++ b/uv.lock @@ -185,6 +185,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/0b/32f3c8162cb5b33f24bea94503dbdd6b55aee72d367d23a55f1338f3e1b7/ast_serialize-0.11.0-cp39-abi3-win_arm64.whl", hash = "sha256:eb22d9300e7a064fa8c45e2c1e568a4e36c4c91487ea0da5e7f589905365c866", size = 1134422, upload-time = "2026-09-08T14:58:38.267Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "cachetools" version = "7.1.8" @@ -451,6 +460,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, ] +[[package]] +name = "cyclopts" +version = "4.22.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/05/689617b7e86503417c172f577d791524cb13b9697303d5d44409a971ba10/cyclopts-4.22.5.tar.gz", hash = "sha256:94044506317462cad90fb01a917dadce1f48a0915ba3605dc8d178dea1229e24", size = 195144, upload-time = "2026-08-04T13:53:00.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/58/bcab9c33fb7a25a1f5970f357c5b19729bc81d50615d2f737b20c4255909/cyclopts-4.22.5-py3-none-any.whl", hash = "sha256:cf9ce285836053d156730ea4ea0ad0c75cf63beb3f3d8edf222a795bc57666ab", size = 234557, upload-time = "2026-08-04T13:52:58.509Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -781,6 +814,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy" version = "2.3.1" @@ -869,14 +923,17 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "asgi-lifespan" }, + { name = "cyclopts" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-httpx" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "types-cachetools" }, { name = "types-hvac" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -901,14 +958,17 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "asgi-lifespan", specifier = ">=2.1.0" }, + { name = "cyclopts", specifier = ">=4.22.5" }, { name = "mypy", specifier = ">=1.13" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-httpx", specifier = ">=0.35" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "ruff", specifier = ">=0.8" }, { name = "types-cachetools", specifier = ">=5.5" }, { name = "types-hvac", specifier = ">=2.3" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20260724" }, ] [[package]] @@ -1293,6 +1353,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1308,6 +1414,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-rst" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, +] + [[package]] name = "ruff" version = "0.16.6" @@ -1452,6 +1584,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/6d/b2098609cc9b577cd61a1170e724a390de0ae9809338738afd2106e83bbc/types_hvac-2.4.0.20260731-py3-none-any.whl", hash = "sha256:e45270eacbdbdb64756e62b1faa0ea3b48d2ff151b42020d76699d35229eb647", size = 42830, upload-time = "2026-07-31T05:22:12.724Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260724" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"