From 576013d627e88b7f299f100741c651431707eddb Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 15:28:09 +0100 Subject: [PATCH 01/15] docs(destinations): design for KS4 and post-16 destination measures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published files suppress individual cells, not whole cohorts, and the categories sum to the cohort — so on 22% of mainstream secondaries the withheld figure can be recovered by subtraction. Three disclosure rules fall out of that, and the rest of the design is downstream of them. Verified against the EES API rather than assumed: both datasets carry school-level rows keyed by URN, with the disadvantage split and every destination category the display needs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- .../2026-08-28-destination-measures-design.md | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-28-destination-measures-design.md diff --git a/docs/superpowers/specs/2026-08-28-destination-measures-design.md b/docs/superpowers/specs/2026-08-28-destination-measures-design.md new file mode 100644 index 0000000..22ab448 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-destination-measures-design.md @@ -0,0 +1,352 @@ +# Destination Measures — Design + +**Date:** 2026-08-28 +**Status:** awaiting review +**Scope:** secondary school detail pages only + +## Goal + +Say what happened to a school's leavers after they left. Two sections on the +secondary template: + +- **After Year 11** — every secondary, from the KS4 destination measures +- **After the sixth form** — sixth-form schools only, from the 16-18 measures + +This replaces the "Post-16 destination data coming soon" placeholder standing in +`nextjs-app/components/school/SecondaryAdmissionsSection.tsx:117` since the exam +phase taxonomy work, and fills the `ks5_destinations_pct` slot specified but +never built in `2026-07-07-exam-phase-taxonomy-design.md:201`. + +Mockup, with all three data states live: + + +## The finding that shapes everything + +**Suppression is per cell, and the cells sum to the cohort.** + +DfE withholds a figure it considers disclosive by writing `c`. It does this at +the level of an individual destination category, not the whole school, and it +publishes the cohort total alongside. The categories form a clean partition. So +where exactly one category is suppressed, subtracting the published ones from the +cohort recovers it exactly. + +Verified against three real schools in the 2022/23 file: + +| School | URN | Withheld | Recovers to | +|---|---|---|---| +| North East Futures UTC | 145900 | School sixth form | **3 pupils** | +| Whitley Bay High School | 108638 | Further education | **18 pupils** | +| St Matthew's RC High School | 148389 | School sixth form | **4 pupils** | + +Those are the precise numbers the `c` exists to hide, and in a random 400-school +sample **22% of mainstream secondaries** have exactly one suppressed category in +their disadvantaged group. This is the normal case, not an edge case. + +Two rules follow, and everything else in this document is downstream of them. + +**R1 — Never render a derived remainder.** Not as a number, and not as a bar +segment: a segment sized by the residual can be read straight off the axis. Where +any category in a pupil group is suppressed, the page shows the published +categories, says the rest are withheld, and stops. + +**R2 — Never aggregate across a suppression boundary.** A group total is +publishable only when DfE published that total itself, or when the aggregate +spans **two or more** suppressed cells. Summing published components to fill a +gap is R1 with extra steps. + +**R3 — The three pupil groups are one disclosure surface, not three.** +Disadvantaged and Not-known-to-be-disadvantaged partition All pupils, so +rendering any *two* of them recovers the third. Where a category is suppressed in +the disadvantaged group, it must therefore also be withheld from **all other +pupils** — the all-pupils view is the primary one and keeps it. + +This costs almost nothing, because DfE already applies the same masking: across +the sample, 493 of 498 suppressed disadvantaged cells were suppressed in the +other group too. The mart enforces the remaining 5, which fell on 2 schools of +262. **The all-pupils bar is unaffected** — masking the whole page wherever the +disadvantaged group is thin would remove the bar from 80% of schools, and is not +what this rule says. + +R1 and R2 both hold within a group and still leak across the switch, which is why +R3 is stated separately. + +### The convention that would break this quietly + +`macros/safe_numeric.sql` coerces every EES sentinel — `z`, `c`, `x`, `q`, `u` — +to `NULL`, deliberately and correctly for attainment, where "suppressed" and "no +data" are equally unrenderable. Here they are not the same thing: one must print +*withheld*, the other must print nothing at all, and the difference is what keeps +R1 enforceable. + +**`safe_numeric` must not be used on destination counts.** The staging model +keeps the sentinel in a companion status column. This is the single most likely +way for this feature to regress into a disclosure, so it gets its own dbt test. + +## What is actually available + +Measured against the EES public API (open, no key). Both datasets carry +`geographicLevel: School` with `urn` on every location option, so the join to +`dim_school` is direct. + +| | KS4 | 16-18 | +|---|---|---| +| Dataset id | `019d4f41-22d1-71b2-a1a7-f3b91026815b` | `019d4e73-6440-7523-b60c-bfab1ad4a30d` | +| Rows | 1,871,739 | 3,862,658 | +| Institutions | 4,946 | 3,065 | +| Time periods | 2009/10–2022/23 | 2016/17–2022/23 | + +**Destination categories (KS4).** School sixth form · Sixth form college · +Further education · Other education destination · Sustained apprenticeships (with +level breakdown) · Sustained employment destination · Not recorded as a sustained +destination · Activity not captured. Plus the aggregates `Sustained education +destination` and `Sustained education, employment & apprenticeships`. + +**16-18 adds** UK higher education institution and FE split by level, which is +what makes the post-16 section worth having. + +**Breakdowns.** `Disadvantage Status` gives Disadvantaged / Not known to be +disadvantaged / Total — exactly the three-way switch. Sex, ethnicity, FSM status, +prior attainment and SEN provision also travel in the same table; we ingest none +of them. + +**Indicators.** Both counts and percentages, plus the cohort size. Bar widths use +the counts — the published percentages do not sum to 100. + +### Coverage, and what degrades + +Random 400-school sample, 2022/23, mainstream secondaries (n=262): + +| View | Published | Consequence | +|---|---|---| +| All pupils, all categories | **100%** | Full bar works everywhere | +| Disadvantaged, headline rate | 95% | Gap panel works | +| Disadvantaged, three grouped cards | 68% | Degrades card by card | +| Disadvantaged, all six categories | **20%** | Bar unusable for this group | + +Special schools and alternative provision are far worse: 13% and 41% respectively +have the whole cohort suppressed even for all pupils. The empty state is +load-bearing, not defensive. + +## The display + +Question-led. Three cards over one bar, with the cards acting as a lens on the +bar rather than a summary beside it — hovering a card dims the bar, table and +England reference to the categories that card is built from. The full mockup is +linked above; what matters for implementation: + +**The headline is not the sustained rate.** That figure sits between 92% and 97% +for nearly every school in England. The mix is what varies, so the mix leads. + +**The grouping is ours, not DfE's.** "Academic route" = school sixth form + +sixth-form college; "College" = FE and other colleges; "Work" = apprenticeship + +employment. This is the most arguable thing on the page, so it lives in one place +in `lib/destinations.ts`, is explained in a tooltip, and is reversible in one +edit. + +**The absence is hatched neutral, never a colour.** "Activity not captured" means +no record in the sources DfE holds — it includes independent schools, moving +abroad and private training. Colouring it as a bad outcome would be a factual +error rendered in CSS. The hatch also fixes a real contrast problem: neutral +against the employment blue failed CVD separation at ΔE 7.6, and texture is the +secondary encoding that rescues it. Every other adjacent pair clears ΔE 10.9 +under protanopia. + +**Colour tokens.** Education is one hue in three steps (school-like to +college-like); apprenticeship and employment are separate hues. Six new tokens in +`globals.css`, defined in both themes, per the existing token discipline. + +**The disadvantage split rides the same control.** One visualisation serving +three cohorts, with the England reference repointing to the matching national +group. The gap statement stays visible below the bar whatever is selected, +because a gap nobody clicks on is a gap nobody sees. + +## Data model + +### Extraction + +A new `tap-uk-ees-destinations` extractor, separate from `tap-uk-ees`. The +existing tap downloads a release ZIP and reads a CSV inside it; the destinations +files are far larger than we need and the query API filters server-side, so this +one POSTs to `/v1/data-sets/{id}/query` and pages through results. + +With every dimension pinned — destination measures, disadvantage status, sex +Total, characteristic topic Total — one year returns **252,610 rows** across all +geographic levels. Three school-level years is comfortably tractable. + +Pinning is mandatory, not an optimisation: leaving the characteristic dimensions +unconstrained returned 45 rows where 9 were wanted, because every breakdown +shares one table. + +The tap emits the raw value as text. **It does not coerce `c`.** + +### Staging + +`stg_ees_ks4_destinations` / `stg_ees_ks5_destinations`. Each raw value becomes +two columns: + +```sql +case when raw ~ '^-?[0-9]+(\.[0-9]+)?$' then raw::numeric end as pupils, +case + when raw ~ '^-?[0-9]+(\.[0-9]+)?$' then 'published' + when lower(trim(raw)) = 'c' then 'suppressed' + else 'not_applicable' +end as status +``` + +### Marts + +`fact_ks4_destinations` and `fact_ks5_destinations`, **long format**: + +``` +urn, year, pupil_group, destination_category, cohort_pupils, pupils, percentage, status +``` + +This departs from the wide house pattern (`fact_ks4_performance` and friends) on +purpose. `pupil_group` is a genuine third dimension; going wide would need three +sets of every column, and R2 is far easier to test on rows than on columns. + +Roughly 8 categories × 3 groups × 4,946 schools × 3 years ≈ 356k rows. + +`fact_destination_national` carries the same grain for England, so the page's +England reference repoints with the switch. + +### dbt tests + +- `assert_destinations_no_derived_remainder` — for every (urn, year, + pupil_group) with exactly one suppressed category, assert no aggregate row + exists that would let the residual be recovered. **This is the R1 guard.** +- `assert_destinations_group_masking` — for every (urn, year, category), if the + disadvantaged group carries `suppressed`, so does the other-pupils group. + **This is the R3 guard**, applied in the mart so no consumer can reach an + unmasked combination. +- `assert_destination_status_null_agreement` — `pupils is null` wherever + `status != 'published'`, and never null where it is. +- `assert_destinations_join_dim_school` — no orphaned URNs, matching the + existing `assert_no_orphaned_facts` pattern. + +## API + +`GET /api/schools/{urn}` gains a `destinations` block: + +```json +{ + "ks4": { + "cohort_year": "2022/23", + "published": "2026-04", + "groups": { + "all": { "cohort": 180, "categories": [ … ], "aggregates": { … } }, + "disadvantaged": { … }, + "other": { … } + } + }, + "ks5": { … } +} +``` + +Each category carries `pupils`, `percentage` and `status`. **The serialiser never +emits a computed remainder**, and a backend test asserts that a group containing a +suppressed category serialises no total that closes the gap. + +`null` for the whole block where nothing is published — the frontend renders the +empty state from its absence, not from a sentinel. + +## Frontend + +| File | Kind | Job | +|---|---|---| +| `lib/destinations.ts` | pure | Category list, the academic/college/work grouping, `canAggregate()` enforcing R2, percentage derivation from counts | +| `components/school/DestinationsSection.tsx` | server | Section shell, renders **all pupils** into the HTML | +| `components/school/DestinationsView.tsx` | client | Cohort switch, card↔bar linkage | +| `components/school/Post16DestinationsSection.tsx` | server | Year 13 section, sixth-form schools only | +| `app/globals.css` | tokens | Six destination colours, both themes | + +Server-first matches the directory's existing discipline — every component in +`components/school/` is a server component except `AdmissionsViewToggle`, which +is the precedent this follows. All-pupils figures are in the HTML for crawlers +and for no-JS; only the switch and the hover linkage need the client. + +`lib/schoolSections.ts` gains `hasKs4Destinations` / `hasKs5Destinations` flags +and the nav items, following the existing `computeSchoolFlags` pattern. + +**Placement** on the secondary template: GCSE results → After Year 11 → After the +sixth form → admissions. Destinations follow attainment because they answer "and +then what happened". + +**Dating.** The latest destination year is 2022/23, published April 2026, while +the site's newest KS4 year is 2024/25. The section header states its own cohort +year, or it reads as stale data next to the GCSE section above it. + +## Edge states + +| State | Frequency | Behaviour | +|---|---|---| +| Whole cohort suppressed | 13% of special, 41% of AP | Section renders the explanation, no chart | +| Some categories withheld | 80% of disadvantaged views | Cards degrade individually; **no bar**; table marks withheld rows | +| Disadvantaged group suppressed entirely | 5% | Switch drops to two options, gap panel not rendered | +| No sixth form | — | Post-16 section not rendered at all — absence is correct, a "no data" placeholder would imply something is missing | +| School too new | — | "First figures expected in 2026", not a bare no | + +## Testing + +Per CLAUDE.md, user-facing behaviour extends `e2e/` in the same PR. + +**Unit** — `lib/destinations.ts` is where R1 and R2 live, so it carries the +heaviest tests: `canAggregate()` refuses a group containing one suppressed cell, +allows one spanning two, and the bar builder refuses to emit segments for any +group with suppression. These are the tests that must fail loudly if someone +later "fixes" a gap in the chart. + +**dbt** — the three tests above. + +**Backend** — the serialiser emits no closing total for a partially suppressed +group. + +**E2E** — a school with full data renders three cards and a bar; a school with a +partially suppressed disadvantaged group renders the withheld state and **no bar +element**; a suppressed school renders the explanation; a school with no sixth +form renders no post-16 section. + +Note the staging caveat: mart changes are inert until the Airflow pipeline runs, +and the staging E2E gate runs post-merge. + +## Out of scope + +- **Compare view and rankings.** The long mart shape supports both; neither is + built here. Flagged because "% to a school sixth form" is a plausible rankings + metric and the mart shape should not have to change to allow it. +- **Ethnicity, sex, SEN and prior-attainment breakdowns.** Available in the same + file, ingested deliberately not at all — each is a separate editorial decision + about what a school page should assert. +- **Longer term destinations** (3 and 5 years out) and **Progression to higher + education** — separate publications, worth a later look for sixth forms. +- **Primary schools.** No KS2 destination measures publication exists; DfE + tracking starts at KS4. Naming the secondaries a primary's leavers go to needs + the National Pupil Database, which is not publishable at that grain. + +## Risks + +**A later change reintroduces the disclosure.** The likeliest route is someone +applying `safe_numeric` to a destination column for consistency, or adding a +`coalesce` in a mart. Mitigation is the dbt test plus the unit tests on +`canAggregate()` — the rule has to be executable, not documentary. + +**The two-year lag reads as staleness.** Mitigated by dating the cohort in the +section header rather than only in a tooltip. + +**Sixth-form retention will be misread.** "41% went to a school sixth form" says +nothing about *which* school. The published file reports destination type, never +destination institution. Copy must never imply "stayed on here", and the tooltip +should say so. + +**Section length.** The secondary template is already long and this adds two +sections. If it becomes a problem the post-16 section is the one to collapse +behind a disclosure, not the Year 11 one. + +## Open questions + +1. Is the disadvantage split its own section or a sub-block inside the + destinations section? Modelled as a sub-block; it is the most differentiating + figure on the page and the most easily misread on a small cohort. +2. Do we ingest the apprenticeship level breakdown (intermediate / advanced / + higher) now, or collapse to one apprenticeship figure and revisit? Collapsed + in this design. -- 2.54.0 From 9423f115676ac5faa17a38fe7857ce8728388bbd Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 15:56:36 +0100 Subject: [PATCH 02/15] docs(destinations): implementation plan, ten tasks Ordered so the disclosure guards land first and everything downstream consumes them: lib/destinations.ts, tokens, tap, staging, marts, API, then the two sections and the journeys. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- .../plans/2026-08-28-destination-measures.md | 1754 +++++++++++++++++ 1 file changed, 1754 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-28-destination-measures.md diff --git a/docs/superpowers/plans/2026-08-28-destination-measures.md b/docs/superpowers/plans/2026-08-28-destination-measures.md new file mode 100644 index 0000000..b57dabb --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-destination-measures.md @@ -0,0 +1,1754 @@ +# Destination Measures Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show what happened to a school's leavers after Year 11 and after the sixth form, on secondary school detail pages, without ever republishing a figure DfE withheld. + +**Architecture:** A new Meltano tap pulls the EES destinations query API into `raw`; dbt staging preserves the `c` suppression sentinel as a status column rather than nulling it; long-format marts carry one row per school × year × pupil group × destination category; the API serialises a `destinations` block; a server component renders all-pupils into the HTML with one client component for the cohort switch. All three disclosure rules live as executable guards in `lib/destinations.ts`. + +**Tech Stack:** Python 3.12 / Singer SDK / Meltano · dbt + PostgreSQL · FastAPI + SQLAlchemy · Next.js (App Router) + TypeScript + CSS Modules · Jest · Playwright + +**Spec:** `docs/superpowers/specs/2026-08-28-destination-measures-design.md` + +## Global Constraints + +- **R1 — Never render a derived remainder.** Not as a number, not as a bar segment. Where any category in a pupil group is suppressed, no bar is drawn for that group. +- **R2 — Never aggregate across a suppression boundary.** Compute an aggregate from components only when every component is published. Render a DfE-published aggregate only when the count of suppressed components within it is 0 or ≥ 2. +- **R3 — Where a category is suppressed for the disadvantaged group, it is also withheld for the other-pupils group.** The all-pupils view keeps it. Enforced in the mart. +- **`safe_numeric` must never be applied to a destination count or percentage.** It coerces `c` to `NULL`, destroying the distinction between *withheld* and *no data*. +- Destination categories, verbatim from the EES filter: `School sixth form`, `Sixth form college`, `Further education`, `Other education destination`, `Sustained apprenticeships`, `Sustained employment destination`, `Not recorded as a sustained destination`, `Activity not captured`. Aggregates: `Sustained education destination`, `Sustained education, employment & apprenticeships`. +- Card grouping (ours, not DfE's): academic = school sixth form + sixth-form college; college = further education + other education; work = apprenticeship + employment. +- Copy must never imply a pupil "stayed on here" — the file reports destination *type*, never destination *institution*. +- Every new colour is a token in `nextjs-app/app/globals.css`, defined in `:root` and in both dark blocks. Never style a component from inside a theme block. +- Percentages for display are rounded; bar widths derive from unrounded pupil counts. +- EES API: `https://api.education.gov.uk/statistics/v1`. KS4 dataset `019d4f41-22d1-71b2-a1a7-f3b91026815b`; 16-18 dataset `019d4e73-6440-7523-b60c-bfab1ad4a30d`. Time periods use the `2022/2023` form, not `2022/23`. + +**Pipeline reality:** `dbt` and `meltano` do not run locally. Tasks 3–5 are verified by unit tests and by SQL review; the models only produce data once Tudor triggers the Airflow DAG on staging. Do not claim mart data exists until that has run. + +--- + +### Task 1: Destination domain logic + +The disclosure rules are here, in pure functions, so they can be tested without a database, a network, or a browser. Every later task consumes this module. + +**Files:** +- Create: `nextjs-app/lib/destinations.ts` +- Test: `nextjs-app/__tests__/lib/destinations.test.ts` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `type DestinationCategory` — the eight category slugs + - `type PupilGroup = 'all' | 'disadvantaged' | 'other'` + - `type DestinationStatus = 'published' | 'suppressed' | 'not_applicable'` + - `interface DestinationCell { category; pupils: number | null; percentage: number | null; status }` + - `interface DestinationGroup { cohort: number; cells: DestinationCell[]; aggregates: Record }` + - `CARD_GROUPS: Record` + - `canAggregate(cells: DestinationCell[]): boolean` + - `aggregateCells(cells: DestinationCell[], cohort: number): { pupils: number; percentage: number } | null` + - `canRenderPublishedAggregate(components: DestinationCell[]): boolean` + - `canRenderBar(group: DestinationGroup): boolean` + - `toBarSegments(group: DestinationGroup): { category; pupils; widthPct; labelPct }[]` + - `suppressedCount(cells: DestinationCell[]): number` + +- [ ] **Step 1: Write the failing test** + +Create `nextjs-app/__tests__/lib/destinations.test.ts`: + +```ts +import { + canAggregate, aggregateCells, canRenderPublishedAggregate, + canRenderBar, toBarSegments, CARD_GROUPS, + type DestinationCell, type DestinationGroup, +} from '@/lib/destinations'; + +const pub = (category: any, pupils: number, cohort: number): DestinationCell => ({ + category, pupils, percentage: (pupils / cohort) * 100, status: 'published', +}); +const sup = (category: any): DestinationCell => ({ + category, pupils: null, percentage: null, status: 'suppressed', +}); + +const fullGroup = (): DestinationGroup => ({ + cohort: 180, + cells: [ + pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180), + pub('further_education', 55, 180), pub('other_education', 6, 180), + pub('apprenticeship', 8, 180), pub('employment', 6, 180), + pub('not_sustained', 5, 180), pub('not_captured', 4, 180), + ], + aggregates: {}, +}); + +describe('canAggregate — R2, computing from components', () => { + it('allows a sum when every component is published', () => { + expect(canAggregate([pub('apprenticeship', 8, 180), pub('employment', 6, 180)])).toBe(true); + }); + + it('refuses a sum when any component is suppressed', () => { + expect(canAggregate([pub('apprenticeship', 8, 180), sup('employment')])).toBe(false); + }); + + it('refuses a sum when every component is suppressed', () => { + expect(canAggregate([sup('apprenticeship'), sup('employment')])).toBe(false); + }); +}); + +describe('aggregateCells', () => { + it('sums published cells and derives a percentage from the cohort', () => { + expect(aggregateCells([pub('apprenticeship', 8, 180), pub('employment', 6, 180)], 180)) + .toEqual({ pupils: 14, percentage: (14 / 180) * 100 }); + }); + + it('returns null rather than a partial sum when a component is suppressed', () => { + expect(aggregateCells([pub('apprenticeship', 8, 180), sup('employment')], 180)).toBeNull(); + }); +}); + +describe('canRenderPublishedAggregate — R2, a total DfE published itself', () => { + it('allows it when no component is suppressed', () => { + expect(canRenderPublishedAggregate([pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180)])).toBe(true); + }); + + it('REFUSES it when exactly one component is suppressed — the aggregate identifies it', () => { + expect(canRenderPublishedAggregate([pub('school_sixth_form', 75, 180), sup('sixth_form_college')])).toBe(false); + }); + + it('allows it when two or more components are suppressed', () => { + expect(canRenderPublishedAggregate([sup('school_sixth_form'), sup('sixth_form_college')])).toBe(true); + }); +}); + +describe('canRenderBar — R1', () => { + it('allows a bar when the whole group is published', () => { + expect(canRenderBar(fullGroup())).toBe(true); + }); + + it('refuses a bar when a single category is suppressed', () => { + const g = fullGroup(); + g.cells[1] = sup('sixth_form_college'); + expect(canRenderBar(g)).toBe(false); + }); +}); + +describe('toBarSegments', () => { + it('derives widths from counts, not from rounded percentages', () => { + const segs = toBarSegments(fullGroup()); + expect(segs).toHaveLength(8); + expect(segs[0].widthPct).toBeCloseTo((75 / 180) * 100, 10); + expect(segs.reduce((a, s) => a + s.widthPct, 0)).toBeCloseTo(100, 6); + }); + + it('throws rather than silently leaving a gap when the group is suppressed', () => { + const g = fullGroup(); + g.cells[1] = sup('sixth_form_college'); + expect(() => toBarSegments(g)).toThrow(/suppressed/i); + }); +}); + +describe('CARD_GROUPS', () => { + it('partitions every destination category exactly once, plus the absence', () => { + const grouped = Object.values(CARD_GROUPS).flat(); + expect(new Set(grouped).size).toBe(grouped.length); + expect(grouped).toEqual(expect.arrayContaining([ + 'school_sixth_form', 'sixth_form_college', 'further_education', + 'other_education', 'apprenticeship', 'employment', + ])); + expect(grouped).not.toContain('not_sustained'); + expect(grouped).not.toContain('not_captured'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd nextjs-app && npx jest __tests__/lib/destinations.test.ts` +Expected: FAIL — `Cannot find module '@/lib/destinations'` + +- [ ] **Step 3: Write the implementation** + +Create `nextjs-app/lib/destinations.ts`: + +```ts +/** + * Destination measures — categories, the card grouping, and the disclosure + * guards. + * + * DfE suppresses individual cells with `c`, and the categories sum to the + * cohort. So subtracting the published cells from the cohort total recovers a + * lone suppressed cell exactly — on 22% of mainstream secondaries. The guards + * below are what stop this module's consumers doing that by accident, and they + * are the reason percentages are never reconstructed from a partial sum. + * + * See docs/superpowers/specs/2026-08-28-destination-measures-design.md. + */ + +export type DestinationCategory = + | 'school_sixth_form' + | 'sixth_form_college' + | 'further_education' + | 'other_education' + | 'apprenticeship' + | 'employment' + | 'not_sustained' + | 'not_captured'; + +export type PupilGroup = 'all' | 'disadvantaged' | 'other'; + +export type DestinationStatus = 'published' | 'suppressed' | 'not_applicable'; + +export type CardGroup = 'academic' | 'college' | 'work'; + +export interface DestinationCell { + category: DestinationCategory; + pupils: number | null; + percentage: number | null; + status: DestinationStatus; +} + +export interface DestinationGroup { + cohort: number; + cells: DestinationCell[]; + /** Aggregates DfE published itself, keyed by slug. */ + aggregates: Partial>; +} + +/** Display order, which is also bar order: education, then work, then absence. */ +export const CATEGORY_ORDER: DestinationCategory[] = [ + 'school_sixth_form', 'sixth_form_college', 'further_education', 'other_education', + 'apprenticeship', 'employment', 'not_sustained', 'not_captured', +]; + +/** + * Our grouping, not DfE's — the single most arguable thing on the page, which + * is why it lives in exactly one place. `not_sustained` and `not_captured` are + * deliberately absent: they are an absence of destination, not a route. + */ +export const CARD_GROUPS: Record = { + academic: ['school_sixth_form', 'sixth_form_college'], + college: ['further_education', 'other_education'], + work: ['apprenticeship', 'employment'], +}; + +export function suppressedCount(cells: DestinationCell[]): number { + return cells.filter(c => c.status === 'suppressed').length; +} + +/** R2: a sum computed from components is safe only if every component is published. */ +export function canAggregate(cells: DestinationCell[]): boolean { + return cells.length > 0 && cells.every(c => c.status === 'published'); +} + +export function aggregateCells( + cells: DestinationCell[], cohort: number, +): { pupils: number; percentage: number } | null { + if (!canAggregate(cells) || cohort <= 0) return null; + const pupils = cells.reduce((sum, c) => sum + (c.pupils ?? 0), 0); + return { pupils, percentage: (pupils / cohort) * 100 }; +} + +/** + * R2, the other direction: DfE published this total itself. Showing it beside + * the components is safe only when it spans no suppressed component, or two or + * more. Exactly one and the total names the withheld figure. + */ +export function canRenderPublishedAggregate(components: DestinationCell[]): boolean { + return suppressedCount(components) !== 1; +} + +/** R1: a bar is drawable only when nothing in the group is withheld. */ +export function canRenderBar(group: DestinationGroup): boolean { + return group.cohort > 0 && group.cells.every(c => c.status === 'published'); +} + +export interface BarSegment { + category: DestinationCategory; + pupils: number; + /** Exact width from the count — never the rounded percentage. */ + widthPct: number; + /** Rounded value for the segment label. */ + labelPct: number; +} + +export function toBarSegments(group: DestinationGroup): BarSegment[] { + if (!canRenderBar(group)) { + throw new Error( + 'toBarSegments: refusing to draw a bar for a group with suppressed categories — ' + + 'the gap would disclose the withheld figure (R1).', + ); + } + const byCategory = new Map(group.cells.map(c => [c.category, c])); + return CATEGORY_ORDER.flatMap(category => { + const cell = byCategory.get(category); + if (!cell || cell.pupils === null) return []; + const widthPct = (cell.pupils / group.cohort) * 100; + return [{ category, pupils: cell.pupils, widthPct, labelPct: Math.round(widthPct) }]; + }); +} + +export const CATEGORY_LABELS: Record = { + school_sixth_form: 'State-funded school sixth form', + sixth_form_college: 'Sixth-form college', + further_education: 'FE and other colleges', + other_education: 'Other education destination', + apprenticeship: 'Apprenticeship', + employment: 'Employment', + not_sustained: 'Not recorded as a sustained destination', + not_captured: 'Activity not captured', +}; + +export const CARD_QUESTIONS: Record = { + academic: { question: 'Do leavers stay on an academic route?', hint: 'a school sixth form or a sixth-form college' }, + college: { question: 'Or move to a college?', hint: 'an FE or other college' }, + work: { question: 'Or straight into work?', hint: 'an apprenticeship or a job' }, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd nextjs-app && npx jest __tests__/lib/destinations.test.ts` +Expected: PASS, 12 tests + +- [ ] **Step 5: Typecheck and commit** + +```bash +cd nextjs-app && npm run typecheck +cd .. && git add nextjs-app/lib/destinations.ts nextjs-app/__tests__/lib/destinations.test.ts +git commit -m "feat(destinations): the disclosure rules, as executable guards" +``` + +--- + +### Task 2: Destination colour tokens + +Six tokens, both themes. The absence is neutral plus a hatch rather than a colour, which is both a factual point (it isn't a bad outcome) and the secondary encoding that rescues a failing CVD pair. + +**Files:** +- Modify: `nextjs-app/app/globals.css` (`:root`, the `prefers-color-scheme` block, and the `[data-theme="dark"]` block if one exists) +- Test: `nextjs-app/__tests__/components/darkThemeSafety.test.ts` + +**Interfaces:** +- Consumes: nothing +- Produces: CSS custom properties `--dest-sixthform`, `--dest-sfcollege`, `--dest-fecollege`, `--dest-apprentice`, `--dest-employment`, `--dest-none`, `--dest-none-hatch` + +- [ ] **Step 1: Read the existing token blocks** + +Run: `grep -n "\-\-series-1\|prefers-color-scheme" nextjs-app/app/globals.css` + +Add the new tokens immediately after the `--series-*` group in each block so the palette stays in one place. + +- [ ] **Step 2: Write the failing test** + +Append to `nextjs-app/__tests__/components/darkThemeSafety.test.ts`: + +```ts +describe('destination tokens', () => { + const css = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8'); + const tokens = [ + '--dest-sixthform', '--dest-sfcollege', '--dest-fecollege', + '--dest-apprentice', '--dest-employment', '--dest-none', '--dest-none-hatch', + ]; + + it('defines every destination token in the light palette', () => { + const root = css.slice(css.indexOf(':root {'), css.indexOf('@media (prefers-color-scheme: dark)')); + tokens.forEach(t => expect(root).toContain(t + ':')); + }); + + it('redefines every destination token for dark', () => { + const dark = css.slice(css.indexOf('@media (prefers-color-scheme: dark)')); + tokens.forEach(t => expect(dark).toContain(t + ':')); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd nextjs-app && npx jest __tests__/components/darkThemeSafety.test.ts` +Expected: FAIL — light palette missing `--dest-sixthform:` + +- [ ] **Step 4: Add the tokens** + +In the `:root` block: + +```css + /* ── Destination measures ─────────────────────────────────────────── + Education is one hue in three steps (school-like → college-like) so the + three education destinations read as one family; apprenticeship and + employment are separate hues. The absence is neutral and hatched, never + a colour — "activity not captured" includes independent schools and + moving abroad, so rendering it as a bad outcome would be wrong. The + hatch is also what rescues the neutral/blue pair, which fails CVD + separation at ΔE 7.6 as flat fills. Every other adjacent pair clears + ΔE 10.9 under protanopia. */ + --dest-sixthform: #0F766E; + --dest-sfcollege: #4A9E96; + --dest-fecollege: #7CBFB8; + --dest-apprentice: #806200; + --dest-employment: #2F6F8F; + --dest-none: #6B7580; + --dest-none-hatch: rgba(107, 117, 128, 0.34); +``` + +In the `@media (prefers-color-scheme: dark)` block (and the `[data-theme="dark"]` block if present): + +```css + --dest-sixthform: #5FC7BB; + --dest-sfcollege: #3E9B92; + --dest-fecollege: #2A716B; + --dest-apprentice: #EFC658; + --dest-employment: #8FB4D9; + --dest-none: #8B9AA1; + --dest-none-hatch: rgba(139, 154, 161, 0.34); +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd nextjs-app && npx jest __tests__/components/darkThemeSafety.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add nextjs-app/app/globals.css nextjs-app/__tests__/components/darkThemeSafety.test.ts +git commit -m "feat(destinations): colour tokens, with the absence hatched not coloured" +``` + +--- + +### Task 3: The destinations tap + +A separate extractor from `tap-uk-ees`. That tap downloads a release ZIP and reads a CSV inside it; the destinations files carry every breakdown we don't want, so this one POSTs to the query API and pages. + +**Files:** +- Create: `pipeline/plugins/extractors/tap-uk-ees-destinations/pyproject.toml` +- Create: `pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/__init__.py` +- Create: `pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/tap.py` +- Modify: `pipeline/meltano.yml` +- Modify: `pipeline/dags/school_data_pipeline.py:160-196` +- Test: `pipeline/plugins/extractors/tap-uk-ees-destinations/tests/test_tap.py` + +**Interfaces:** +- Consumes: nothing +- Produces: raw tables `ees_ks4_destinations` and `ees_ks5_destinations`, columns `urn`, `time_period`, `pupil_group`, `destination_measure`, `cohort_pupils`, `pupils_raw`, `percentage_raw` — the last two as **text**, sentinel preserved. + +- [ ] **Step 1: Write the failing test** + +Create `pipeline/plugins/extractors/tap-uk-ees-destinations/tests/test_tap.py`: + +```python +"""The tap's only job that can be tested without the network: mapping an API +row to a Singer record without destroying the suppression sentinel.""" +from tap_uk_ees_destinations.tap import row_to_record, DESTINATION_SLUGS, PUPIL_GROUP_SLUGS + + +def test_published_row_keeps_its_numbers_as_text(): + row = { + "timePeriod": {"period": "2022/2023"}, + "geographicLevel": "SCH", + "locations": {"SCH": "IXn5B"}, + "filters": {"wYXbx": "DCz1Q", "9ss4v": "p9WRS"}, + "values": {"Poghe": "264", "1roqi": "182", "dPjk0": "68.9"}, + } + rec = row_to_record(row, urn_by_location={"IXn5B": "137083"}) + assert rec["urn"] == "137083" + assert rec["time_period"] == "202223" + assert rec["destination_measure"] == "school_sixth_form" + assert rec["pupil_group"] == "all" + assert rec["cohort_pupils"] == "264" + assert rec["pupils_raw"] == "182" + assert rec["percentage_raw"] == "68.9" + + +def test_suppressed_row_preserves_the_c_sentinel(): + row = { + "timePeriod": {"period": "2022/2023"}, + "geographicLevel": "SCH", + "locations": {"SCH": "IXn5B"}, + "filters": {"wYXbx": "eLsdu", "9ss4v": "OvPnC"}, + "values": {"Poghe": "41", "1roqi": "c", "dPjk0": "c"}, + } + rec = row_to_record(row, urn_by_location={"IXn5B": "137083"}) + assert rec["pupils_raw"] == "c", "the sentinel must survive extraction" + assert rec["percentage_raw"] == "c" + assert rec["pupil_group"] == "disadvantaged" + + +def test_national_rows_are_kept_with_a_null_urn(): + """The England reference lives in the same response. It is kept, with urn + None, so fact_destination_national has something to read.""" + row = { + "timePeriod": {"period": "2022/2023"}, + "geographicLevel": "NAT", + "locations": {"NAT": "dP0Zw"}, + "filters": {"wYXbx": "DCz1Q", "9ss4v": "p9WRS"}, + "values": {"Poghe": "500000", "1roqi": "190000", "dPjk0": "38.0"}, + } + rec = row_to_record(row, urn_by_location={}) + assert rec is not None + assert rec["urn"] is None + assert rec["percentage_raw"] == "38.0" + + +def test_other_geographic_levels_are_dropped(): + """Local authority, district, region and constituency rows are noise here.""" + row = { + "timePeriod": {"period": "2022/2023"}, + "geographicLevel": "LA", + "locations": {"LA": "u9Oo4", "NAT": "dP0Zw"}, + "filters": {"wYXbx": "DCz1Q", "9ss4v": "p9WRS"}, + "values": {"Poghe": "1", "1roqi": "1", "dPjk0": "1"}, + } + assert row_to_record(row, urn_by_location={}) is None + + +def test_every_slug_maps_to_one_filter_id(): + assert len(set(DESTINATION_SLUGS.values())) == len(DESTINATION_SLUGS) + assert set(PUPIL_GROUP_SLUGS.values()) == {"all", "disadvantaged", "other"} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd pipeline/plugins/extractors/tap-uk-ees-destinations && uv run --with singer-sdk --with requests pytest tests/ -v` +Expected: FAIL — `ModuleNotFoundError: tap_uk_ees_destinations` + +- [ ] **Step 3: Write the tap** + +Create `pipeline/plugins/extractors/tap-uk-ees-destinations/pyproject.toml`: + +```toml +[project] +name = "tap-uk-ees-destinations" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["singer-sdk>=0.40", "requests>=2.31"] + +[project.scripts] +tap-uk-ees-destinations = "tap_uk_ees_destinations.tap:TapUKEESDestinations.cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +Create `pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/__init__.py` (empty file). + +Create `pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/tap.py`: + +```python +"""EES destinations tap — KS4 and 16-18 destination measures, school level. + +Separate from tap-uk-ees on purpose. That tap pulls a release ZIP and reads a +CSV inside it; the destinations files carry sex, ethnicity, FSM status, prior +attainment and SEN in the same table, so the whole-file route would download +millions of rows to keep a few hundred thousand. The query API filters server +side. + +The one thing this tap must not do is tidy up the data. EES writes `c` where a +figure is withheld, and the categories sum to the cohort — so turning `c` into +NULL here would let a downstream sum reconstruct exactly what DfE suppressed. +Counts and percentages are emitted as TEXT, sentinel intact. +""" + +from __future__ import annotations + +import requests +from singer_sdk import Stream, Tap +from singer_sdk import typing as th + +API_BASE = "https://api.education.gov.uk/statistics/v1" +TIMEOUT = 180 +PAGE_SIZE = 10000 + +KS4_DATASET = "019d4f41-22d1-71b2-a1a7-f3b91026815b" +KS5_DATASET = "019d4e73-6440-7523-b60c-bfab1ad4a30d" + +# Filter option ids, read from each dataset's /meta. KS4 and 16-18 use +# different ids for the same concepts, so they are declared separately. +KS4_DESTINATION_SLUGS = { + "DCz1Q": "school_sixth_form", + "eLsdu": "sixth_form_college", + "o2MJm": "further_education", + "b7v6t": "other_education", + "mlKo9": "apprenticeship", + "QIJEw": "employment", + "RZrek": "not_sustained", + "1j7Ui": "not_captured", + "WiEl2": "agg_sustained_education", + "EfSAq": "agg_sustained_all", +} +KS4_PUPIL_GROUP_SLUGS = {"p9WRS": "all", "OvPnC": "disadvantaged", "7VmdX": "other"} +# Sex=Total and characteristic topic=Total. Leaving these unpinned returns +# every breakdown crossed with every other — 45 rows where 9 are wanted. +KS4_PINNED = ["X542f", "jHdaA"] + +KS5_DESTINATION_SLUGS = { + "dkyu0": "higher_education", + "QIB6w": "school_sixth_form", + "9c3u4": "sixth_form_college", + "S4UgV": "further_education", + "EfYDq": "other_education", + "PaLTe": "apprenticeship", + "9c8k4": "employment", + "Wi6R2": "not_sustained", + "o2l7m": "not_captured", + "wBXtb": "agg_sustained_education", + "o2c4m": "agg_sustained_all", +} +KS5_PUPIL_GROUP_SLUGS = {"Y0PuH": "all", "HTeez": "disadvantaged", "CnSVI": "other"} +KS5_PINNED: list[str] = [] + +# Indicator ids are shared across both datasets. +IND_COHORT = "Poghe" +IND_PUPILS = "1roqi" +IND_PERCENT = "dPjk0" + +DESTINATION_SLUGS = KS4_DESTINATION_SLUGS +PUPIL_GROUP_SLUGS = KS4_PUPIL_GROUP_SLUGS + + +def _period_to_time_period(period: str) -> str: + """'2022/2023' -> '202223', matching the convention the other marts use.""" + start, end = period.split("/") + return start + end[-2:] + + +def row_to_record( + row: dict, + urn_by_location: dict[str, str], + destination_slugs: dict[str, str] | None = None, + pupil_group_slugs: dict[str, str] | None = None, +) -> dict | None: + """Map one API row to a Singer record, or None if it is not school level.""" + destination_slugs = destination_slugs or KS4_DESTINATION_SLUGS + pupil_group_slugs = pupil_group_slugs or KS4_PUPIL_GROUP_SLUGS + + # Read geographicLevel, not the locations keys: a school row also carries + # NAT, LA and REG entries for its parents, so "NAT in locations" is true + # for every row in the file and would let LA rows through as national ones. + level = row.get("geographicLevel") + if level == "SCH": + urn = urn_by_location.get(row.get("locations", {}).get("SCH")) + if not urn: + return None + elif level == "NAT": + urn = None + else: + return None + + filters = row.get("filters", {}) + destination = next( + (slug for fid, slug in destination_slugs.items() if fid in filters.values()), None + ) + group = next( + (slug for fid, slug in pupil_group_slugs.items() if fid in filters.values()), None + ) + if destination is None or group is None: + return None + + values = row.get("values", {}) + return { + "urn": urn, + "time_period": _period_to_time_period(row["timePeriod"]["period"]), + "pupil_group": group, + "destination_measure": destination, + "cohort_pupils": values.get(IND_COHORT), + "pupils_raw": values.get(IND_PUPILS), + "percentage_raw": values.get(IND_PERCENT), + } + + +def fetch_urn_by_location(dataset_id: str) -> dict[str, str]: + """Location id -> URN, from the dataset's meta.""" + resp = requests.get(f"{API_BASE}/data-sets/{dataset_id}/meta", timeout=TIMEOUT) + resp.raise_for_status() + for group in resp.json().get("locations", []): + if group.get("level", {}).get("code") == "SCH": + return {o["id"]: o["urn"] for o in group.get("options", []) if o.get("urn")} + return {} + + +def fetch_time_periods(dataset_id: str) -> list[str]: + resp = requests.get(f"{API_BASE}/data-sets/{dataset_id}/meta", timeout=TIMEOUT) + resp.raise_for_status() + return [t["period"] for t in resp.json().get("timePeriods", [])] + + +class DestinationsStream(Stream): + """One stream per dataset. Pages the query API, one time period at a time.""" + + _dataset_id: str + _destination_slugs: dict[str, str] + _pupil_group_slugs: dict[str, str] + _pinned: list[str] + + schema = th.PropertiesList( + th.Property("urn", th.StringType), + th.Property("time_period", th.StringType), + th.Property("pupil_group", th.StringType), + th.Property("destination_measure", th.StringType), + th.Property("cohort_pupils", th.StringType), + th.Property("pupils_raw", th.StringType), + th.Property("percentage_raw", th.StringType), + ).to_dict() + + primary_keys = ["urn", "time_period", "pupil_group", "destination_measure"] + replication_key = None + + def get_records(self, context): + urn_by_location = fetch_urn_by_location(self._dataset_id) + self.logger.info("%s: %d school locations", self.name, len(urn_by_location)) + + criteria = [{"filters": {"in": list(self._destination_slugs)}}, + {"filters": {"in": list(self._pupil_group_slugs)}}] + for pinned in self._pinned: + criteria.append({"filters": {"in": [pinned]}}) + + for period in fetch_time_periods(self._dataset_id): + page = 1 + while True: + body = { + "criteria": {"and": criteria + [ + {"timePeriods": {"in": [{"period": period, "code": "AY"}]}}, + ]}, + "indicators": [IND_COHORT, IND_PUPILS, IND_PERCENT], + "page": page, + "pageSize": PAGE_SIZE, + } + resp = requests.post( + f"{API_BASE}/data-sets/{self._dataset_id}/query", + json=body, timeout=TIMEOUT, + ) + resp.raise_for_status() + payload = resp.json() + + for row in payload.get("results", []): + record = row_to_record( + row, urn_by_location, + self._destination_slugs, self._pupil_group_slugs, + ) + if record is not None: + yield record + + paging = payload.get("paging", {}) + if page >= paging.get("totalPages", 1): + break + page += 1 + + +class KS4DestinationsStream(DestinationsStream): + name = "ees_ks4_destinations" + _dataset_id = KS4_DATASET + _destination_slugs = KS4_DESTINATION_SLUGS + _pupil_group_slugs = KS4_PUPIL_GROUP_SLUGS + _pinned = KS4_PINNED + + +class KS5DestinationsStream(DestinationsStream): + name = "ees_ks5_destinations" + _dataset_id = KS5_DATASET + _destination_slugs = KS5_DESTINATION_SLUGS + _pupil_group_slugs = KS5_PUPIL_GROUP_SLUGS + _pinned = KS5_PINNED + + +class TapUKEESDestinations(Tap): + name = "tap-uk-ees-destinations" + config_jsonschema = th.PropertiesList().to_dict() + + def discover_streams(self): + return [KS4DestinationsStream(self), KS5DestinationsStream(self)] + + +if __name__ == "__main__": + TapUKEESDestinations.cli() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd pipeline/plugins/extractors/tap-uk-ees-destinations && uv run --with singer-sdk --with requests --with pytest pytest tests/ -v` +Expected: PASS, 4 tests + +- [ ] **Step 5: Register the tap with Meltano** + +In `pipeline/meltano.yml`, after the `tap-uk-ees` block in `extractors:`: + +```yaml + - name: tap-uk-ees-destinations + namespace: uk_ees_destinations + pip_url: ./plugins/extractors/tap-uk-ees-destinations + executable: tap-uk-ees-destinations + settings: [] +``` + +- [ ] **Step 6: Wire it into the annual DAG** + +In `pipeline/dags/school_data_pipeline.py`, inside the `extract_ees` TaskGroup (around line 174), add a second operator and make the dbt selector cover the new models: + +```python + extract_ees_destinations = BashOperator( + task_id="extract_ees_destinations", + bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-ees-destinations target-postgres", + ) + + extract_ees >> extract_ees_destinations +``` + +And extend the `dbt_build_ees` selector with `stg_ees_ks4_destinations+ stg_ees_ks5_destinations+`. + +- [ ] **Step 7: Commit** + +```bash +git add pipeline/plugins/extractors/tap-uk-ees-destinations pipeline/meltano.yml pipeline/dags/school_data_pipeline.py +git commit -m "feat(destinations): a tap that preserves the suppression sentinel" +``` + +--- + +### Task 4: Staging models + +**Files:** +- Create: `pipeline/transform/models/staging/stg_ees_ks4_destinations.sql` +- Create: `pipeline/transform/models/staging/stg_ees_ks5_destinations.sql` +- Modify: `pipeline/transform/models/staging/_stg_sources.yml` + +**Interfaces:** +- Consumes: raw tables from Task 3 +- Produces: `stg_ees_ks4_destinations` / `stg_ees_ks5_destinations` with columns `urn` (int), `year` (int), `pupil_group` (text), `destination_measure` (text), `cohort_pupils` (int), `pupils` (int, null when withheld), `percentage` (numeric, null when withheld), `status` (text: `published` | `suppressed` | `not_applicable`) + +- [ ] **Step 1: Declare the sources** + +In `pipeline/transform/models/staging/_stg_sources.yml`, under `tables:`: + +```yaml + - name: ees_ks4_destinations + description: > + KS4 leavers destinations, school level, long format — one row per + URN × year × pupil group × destination measure. pupils_raw and + percentage_raw are TEXT and may hold the 'c' suppression sentinel; + they must never be passed through safe_numeric. + + - name: ees_ks5_destinations + description: > + 16-18 study leavers destinations, same grain and same suppression + caveat as ees_ks4_destinations. +``` + +- [ ] **Step 2: Write the KS4 staging model** + +Create `pipeline/transform/models/staging/stg_ees_ks4_destinations.sql`: + +```sql +{{ config(materialized='table') }} + +-- Staging model: KS4 leavers destinations, school level. +-- +-- DELIBERATELY DOES NOT USE safe_numeric. That macro maps every EES sentinel +-- (z, c, x, q, u) to NULL, which is right for attainment — there, "suppressed" +-- and "not applicable" are equally unrenderable. Here they are different +-- claims: one prints "withheld", the other prints nothing. Collapsing them +-- would also let a downstream sum reconstruct a withheld figure, because the +-- destination categories add up to the cohort. +-- +-- See docs/superpowers/specs/2026-08-28-destination-measures-design.md. + +with source as ( + select * from {{ source('raw', 'ees_ks4_destinations') }} + -- National rows carry a null urn and feed fact_destination_national. + where (urn is null or urn ~ '^[0-9]+$') + and time_period ~ '^[0-9]+$' +) + +select + case when urn ~ '^[0-9]+$' then cast(trim(urn) as integer) end as urn, + cast(trim(time_period) as integer) as year, + trim(pupil_group) as pupil_group, + trim(destination_measure) as destination_measure, + + case when cohort_pupils ~ '^[0-9]+$' + then cast(cohort_pupils as integer) end as cohort_pupils, + + case when pupils_raw ~ '^[0-9]+$' + then cast(pupils_raw as integer) end as pupils, + + case when percentage_raw ~ '^-?[0-9]+(\.[0-9]+)?$' + then cast(percentage_raw as numeric) end as percentage, + + case + when pupils_raw ~ '^[0-9]+$' then 'published' + when lower(trim(pupils_raw)) = 'c' then 'suppressed' + else 'not_applicable' + end as status + +from source +``` + +- [ ] **Step 3: Write the 16-18 staging model** + +Create `pipeline/transform/models/staging/stg_ees_ks5_destinations.sql` — identical body, reading `source('raw', 'ees_ks5_destinations')`, with the header comment naming 16-18 study leavers. Repeat the full SQL rather than abstracting it; the two sources drift independently and a shared macro would couple their refresh cadences. + +- [ ] **Step 4: Verify the SQL compiles by eye against the sibling models** + +Run: `diff <(sed -n '1,12p' pipeline/transform/models/staging/stg_ees_ks4.sql) <(sed -n '1,12p' pipeline/transform/models/staging/stg_ees_ks4_destinations.sql)` + +Confirm the header comment style matches, and confirm by inspection that `safe_numeric` appears nowhere: + +Run: `grep -c safe_numeric pipeline/transform/models/staging/stg_ees_ks*_destinations.sql` +Expected: `0` for both files + +- [ ] **Step 5: Commit** + +```bash +git add pipeline/transform/models/staging/stg_ees_ks4_destinations.sql \ + pipeline/transform/models/staging/stg_ees_ks5_destinations.sql \ + pipeline/transform/models/staging/_stg_sources.yml +git commit -m "feat(destinations): staging models that keep 'withheld' distinct from 'absent'" +``` + +--- + +### Task 5: Marts and the disclosure tests + +**Files:** +- Create: `pipeline/transform/models/marts/fact_ks4_destinations.sql` +- Create: `pipeline/transform/models/marts/fact_ks5_destinations.sql` +- Create: `pipeline/transform/models/marts/fact_destination_national.sql` +- Create: `pipeline/transform/tests/assert_destinations_no_derived_remainder.sql` +- Create: `pipeline/transform/tests/assert_destinations_group_masking.sql` +- Create: `pipeline/transform/tests/assert_destination_status_null_agreement.sql` +- Modify: `pipeline/transform/models/marts/_marts_schema.yml` + +**Interfaces:** +- Consumes: `stg_ees_ks4_destinations`, `stg_ees_ks5_destinations`, `dim_school` +- Produces: `fact_ks4_destinations` / `fact_ks5_destinations` (`urn`, `year`, `pupil_group`, `destination_measure`, `cohort_pupils`, `pupils`, `percentage`, `status`) and `fact_destination_national` (same, without `urn`, plus `phase`) + +- [ ] **Step 1: Write the KS4 mart with R3 masking** + +Create `pipeline/transform/models/marts/fact_ks4_destinations.sql`: + +```sql +{{ config(materialized='table') }} + +-- Mart: KS4 leavers destinations — one row per URN × year × pupil group × +-- destination measure. +-- +-- Long format, unlike the wide fact_ks4_performance next door. pupil_group is +-- a real third dimension, so going wide would need three sets of every column, +-- and the disclosure tests below are far easier to write over rows. +-- +-- R3 is applied HERE rather than in the API: where a category is suppressed +-- for the disadvantaged group it is masked for the other-pupils group too, +-- because the two partition the whole and the all-pupils figure is published. +-- DfE already does this in 493 of 498 cases; this closes the remainder so no +-- consumer can reach an unmasked combination. + +with staged as ( + select s.* + from {{ ref('stg_ees_ks4_destinations') }} s + inner join {{ ref('dim_school') }} d on d.urn = s.urn +), + +-- Categories withheld for disadvantaged pupils at this school and year. +masked as ( + select distinct urn, year, destination_measure + from staged + where pupil_group = 'disadvantaged' and status = 'suppressed' +) + +select + s.urn, + s.year, + s.pupil_group, + s.destination_measure, + s.cohort_pupils, + case when m.urn is not null and s.pupil_group = 'other' + then null else s.pupils end as pupils, + case when m.urn is not null and s.pupil_group = 'other' + then null else s.percentage end as percentage, + case when m.urn is not null and s.pupil_group = 'other' + then 'suppressed' else s.status end as status +from staged s +left join masked m + on m.urn = s.urn + and m.year = s.year + and m.destination_measure = s.destination_measure +``` + +- [ ] **Step 2: Write the 16-18 mart** + +Create `pipeline/transform/models/marts/fact_ks5_destinations.sql` — the same body reading `stg_ees_ks5_destinations`, with a header naming 16-18 study leavers. + +- [ ] **Step 3: Write the national reference mart** + +Create `pipeline/transform/models/marts/fact_destination_national.sql`: + +```sql +{{ config(materialized='table') }} + +-- Mart: England destination measures by pupil group, for the page's national +-- reference. Kept separate from the school facts so the section's England bar +-- can repoint with the cohort switch — comparing a school's disadvantaged +-- pupils against the national all-pupils figure would flatter or damn the +-- school for its intake rather than its work. + +select 'ks4' as phase, year, pupil_group, destination_measure, + cohort_pupils, pupils, percentage, status +from {{ ref('stg_ees_ks4_destinations') }} +where urn is null + +union all + +select 'ks5' as phase, year, pupil_group, destination_measure, + cohort_pupils, pupils, percentage, status +from {{ ref('stg_ees_ks5_destinations') }} +where urn is null +``` + +- [ ] **Step 4: Write the R1 disclosure test** + +Create `pipeline/transform/tests/assert_destinations_no_derived_remainder.sql`: + +```sql +-- R1 GUARD. Fails if a school/year/group has exactly one suppressed category +-- while also publishing the cohort total — the combination that lets the +-- withheld figure be recovered by subtraction. +-- +-- This does not mean the mart is wrong: DfE publishes exactly this, and the +-- mart's job is to carry it faithfully. The test exists so that the condition +-- is visible and counted, and so that any consumer added later has to +-- acknowledge it. The API and the frontend are what must refuse to render the +-- remainder; this test is the tripwire that says how often the situation +-- arises. It is configured to warn, not error. +{{ config(severity='warn') }} + +select + urn, year, pupil_group, + count(*) filter (where status = 'suppressed') as suppressed_categories +from {{ ref('fact_ks4_destinations') }} +where destination_measure not like 'agg_%' +group by urn, year, pupil_group +having count(*) filter (where status = 'suppressed') = 1 +``` + +- [ ] **Step 5: Write the R3 masking test** + +Create `pipeline/transform/tests/assert_destinations_group_masking.sql`: + +```sql +-- R3 GUARD. Fails if a category is suppressed for disadvantaged pupils but +-- still published for the other-pupils group — the two partition the whole, so +-- publishing both alongside the all-pupils figure recovers the withheld cell. + +select d.urn, d.year, d.destination_measure +from {{ ref('fact_ks4_destinations') }} d +inner join {{ ref('fact_ks4_destinations') }} o + on o.urn = d.urn + and o.year = d.year + and o.destination_measure = d.destination_measure + and o.pupil_group = 'other' +where d.pupil_group = 'disadvantaged' + and d.status = 'suppressed' + and o.status = 'published' +``` + +- [ ] **Step 6: Write the status agreement test** + +Create `pipeline/transform/tests/assert_destination_status_null_agreement.sql`: + +```sql +-- pupils must be null wherever status is not 'published', and never null where +-- it is. This is what stops a later coalesce or a wide-format refactor turning +-- "withheld" into a zero. + +select urn, year, pupil_group, destination_measure, status, pupils +from {{ ref('fact_ks4_destinations') }} +where (status <> 'published' and pupils is not null) + or (status = 'published' and pupils is null) +``` + +- [ ] **Step 7: Document the marts** + +In `pipeline/transform/models/marts/_marts_schema.yml`, add entries for the three new models with a `description` for each and `tests: [not_null]` on `urn`, `year`, `pupil_group`, `destination_measure`, `status`, following the existing entries' shape. + +- [ ] **Step 8: Confirm no test references safe_numeric and commit** + +Run: `grep -rn safe_numeric pipeline/transform/models/marts/fact_ks*_destinations.sql pipeline/transform/models/marts/fact_destination_national.sql` +Expected: no output + +```bash +git add pipeline/transform/models/marts/fact_ks4_destinations.sql \ + pipeline/transform/models/marts/fact_ks5_destinations.sql \ + pipeline/transform/models/marts/fact_destination_national.sql \ + pipeline/transform/tests/assert_destinations_*.sql \ + pipeline/transform/tests/assert_destination_status_null_agreement.sql \ + pipeline/transform/models/marts/_marts_schema.yml +git commit -m "feat(destinations): marts, with R3 masking applied at the boundary" +``` + +--- + +### Task 6: Backend models, loader and API + +**Files:** +- Modify: `backend/models.py` (append after `FactFinance`, around line 265) +- Modify: `backend/data_loader.py:819-830` (`_empty_supplementary`) and `:833-958` (`get_supplementary_data_batch`) +- Modify: `backend/app.py:905-920` (the `get_school_details` return block) +- Test: `backend/tests/test_destinations_api.py` + +**Interfaces:** +- Consumes: `fact_ks4_destinations`, `fact_ks5_destinations`, `fact_destination_national` +- Produces: `destinations` key on `GET /api/schools/{urn}`, shaped `{ ks4: {...} | null, ks5: {...} | null }`; each phase `{ cohort_year, groups: { all, disadvantaged, other } }`; each group `{ cohort, categories: [{ category, pupils, percentage, status }], aggregates: {...} }` + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_destinations_api.py`: + +```python +"""The serialiser's contract: it carries suppression through, and never emits a +total that closes a gap left by a suppressed category.""" +import pytest + +from backend.data_loader import _destinations_block + + +def _row(group, measure, pupils, status, cohort=180, percentage=None): + return { + "pupil_group": group, "destination_measure": measure, + "pupils": pupils, "percentage": percentage, + "status": status, "cohort_pupils": cohort, "year": 202223, + } + + +def test_suppressed_category_serialises_as_suppressed_with_null_pupils(): + rows = [ + _row("all", "school_sixth_form", 75, "published", percentage=41.7), + _row("all", "sixth_form_college", None, "suppressed"), + ] + block = _destinations_block(rows) + cats = {c["category"]: c for c in block["groups"]["all"]["categories"]} + assert cats["sixth_form_college"]["status"] == "suppressed" + assert cats["sixth_form_college"]["pupils"] is None + assert cats["sixth_form_college"]["percentage"] is None + + +def test_no_closing_total_is_emitted_for_a_partially_suppressed_group(): + rows = [ + _row("all", "school_sixth_form", 75, "published", percentage=41.7), + _row("all", "sixth_form_college", None, "suppressed"), + _row("all", "further_education", 61, "published", percentage=33.9), + _row("all", "apprenticeship", 8, "published", percentage=4.4), + _row("all", "employment", 6, "published", percentage=3.3), + _row("all", "not_sustained", 5, "published", percentage=2.8), + _row("all", "not_captured", 4, "published", percentage=2.2), + ] + block = _destinations_block(rows) + group = block["groups"]["all"] + published = sum(c["pupils"] for c in group["categories"] if c["pupils"] is not None) + for value in group["aggregates"].values(): + if value is None or value.get("pupils") is None: + continue + assert value["pupils"] != group["cohort"] - published, ( + "an aggregate that equals the residual identifies the suppressed cell" + ) + + +def test_cohort_year_is_reported_so_the_page_can_date_itself(): + block = _destinations_block([_row("all", "school_sixth_form", 75, "published")]) + assert block["cohort_year"] == "2022/23" + + +def test_empty_rows_yield_none_not_an_empty_shell(): + assert _destinations_block([]) is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/tudor/projects/school_compare && uv run --with fastapi --with 'httpx==0.27.0' --with sqlalchemy --with pandas --with pytest --with pydantic-settings python -m pytest backend/tests/test_destinations_api.py -v` +Expected: FAIL — `ImportError: cannot import name '_destinations_block'` + +- [ ] **Step 3: Add the SQLAlchemy models** + +Append to `backend/models.py` after `FactFinance`: + +```python +class FactKs4Destinations(Base): + """KS4 leavers destinations — one row per URN, year, pupil group, measure.""" + __tablename__ = "fact_ks4_destinations" + __table_args__ = ( + Index("ix_ks4_dest_urn_year", "urn", "year"), + MARTS, + ) + + urn = Column(Integer, primary_key=True) + year = Column(Integer, primary_key=True) + pupil_group = Column(String(20), primary_key=True) + destination_measure = Column(String(40), primary_key=True) + cohort_pupils = Column(Integer) + pupils = Column(Integer) + percentage = Column(Float) + # 'published' | 'suppressed' | 'not_applicable'. Never collapse this to a + # null check: a suppressed cell prints "withheld", an absent one prints + # nothing, and the difference is what keeps the disclosure rules workable. + status = Column(String(20)) + + +class FactKs5Destinations(Base): + """16-18 study leavers destinations — same grain as FactKs4Destinations.""" + __tablename__ = "fact_ks5_destinations" + __table_args__ = ( + Index("ix_ks5_dest_urn_year", "urn", "year"), + MARTS, + ) + + urn = Column(Integer, primary_key=True) + year = Column(Integer, primary_key=True) + pupil_group = Column(String(20), primary_key=True) + destination_measure = Column(String(40), primary_key=True) + cohort_pupils = Column(Integer) + pupils = Column(Integer) + percentage = Column(Float) + status = Column(String(20)) +``` + +- [ ] **Step 4: Write the serialiser** + +Add to `backend/data_loader.py`, above `_empty_supplementary`: + +```python +_AGGREGATE_MEASURES = {"agg_sustained_education", "agg_sustained_all"} + + +def _format_cohort_year(year: int | None) -> str | None: + """202223 -> '2022/23'. The page must date its own cohort: destinations run + two GCSE years behind the results shown above them.""" + if not year: + return None + text = str(year) + return f"{text[:4]}/{text[6:8]}" if len(text) == 8 else f"{text[:4]}/{text[4:6]}" + + +def _destinations_block(rows: list[dict]) -> dict | None: + """Shape destination rows for one phase into the API's block. + + Carries `status` through untouched and emits no computed totals. The only + aggregates present are ones DfE published itself; the frontend decides + whether they are safe to show (see lib/destinations.ts, R2). + """ + if not rows: + return None + + latest_year = max(r["year"] for r in rows if r.get("year") is not None) + rows = [r for r in rows if r.get("year") == latest_year] + + groups: dict[str, dict] = {} + for row in rows: + group = groups.setdefault( + row["pupil_group"], + {"cohort": row.get("cohort_pupils"), "categories": [], "aggregates": {}}, + ) + measure = row["destination_measure"] + cell = { + "category": measure, + "pupils": row.get("pupils"), + "percentage": row.get("percentage"), + "status": row.get("status"), + } + if measure in _AGGREGATE_MEASURES: + group["aggregates"][measure.removeprefix("agg_")] = cell + else: + group["categories"].append(cell) + + if not groups: + return None + + return {"cohort_year": _format_cohort_year(latest_year), "groups": groups} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd /Users/tudor/projects/school_compare && uv run --with fastapi --with 'httpx==0.27.0' --with sqlalchemy --with pandas --with pytest --with pydantic-settings python -m pytest backend/tests/test_destinations_api.py -v` +Expected: PASS, 4 tests + +- [ ] **Step 6: Wire it into the batch loader** + +In `backend/data_loader.py`, add `"destinations": None` to the dict `_empty_supplementary` returns. Then add two query functions inside `get_supplementary_data_batch`, following the `_ofsted` / `_census` pattern exactly, each wrapped in `_safe`: + +```python + # Destinations — KS4 and 16-18, all years; _destinations_block picks the + # latest and shapes the groups. + def _destinations(): + from collections import defaultdict + per_urn_ks4 = defaultdict(list) + for r in (db.query(FactKs4Destinations) + .filter(FactKs4Destinations.urn.in_(urns)).all()): + per_urn_ks4[r.urn].append({ + "year": r.year, "pupil_group": r.pupil_group, + "destination_measure": r.destination_measure, + "cohort_pupils": r.cohort_pupils, "pupils": r.pupils, + "percentage": r.percentage, "status": r.status, + }) + per_urn_ks5 = defaultdict(list) + for r in (db.query(FactKs5Destinations) + .filter(FactKs5Destinations.urn.in_(urns)).all()): + per_urn_ks5[r.urn].append({ + "year": r.year, "pupil_group": r.pupil_group, + "destination_measure": r.destination_measure, + "cohort_pupils": r.cohort_pupils, "pupils": r.pupils, + "percentage": r.percentage, "status": r.status, + }) + for urn in urns: + ks4 = _destinations_block(per_urn_ks4.get(urn, [])) + ks5 = _destinations_block(per_urn_ks5.get(urn, [])) + result[urn]["destinations"] = ( + {"ks4": ks4, "ks5": ks5} if (ks4 or ks5) else None + ) + _safe(_destinations) +``` + +Import `FactKs4Destinations` and `FactKs5Destinations` alongside the other mart models at the top of the file. + +- [ ] **Step 7: Expose it on the endpoint** + +In `backend/app.py`, in the `get_school_details` return dict, after `"finance": supplementary.get("finance"),`: + +```python + "destinations": supplementary.get("destinations"), +``` + +- [ ] **Step 8: Run the full backend suite and commit** + +Run: `cd /Users/tudor/projects/school_compare && uv run --with fastapi --with 'httpx==0.27.0' --with sqlalchemy --with pandas --with pytest --with pydantic-settings python -m pytest backend/tests/ -q` +Expected: all pass + +```bash +git add backend/models.py backend/data_loader.py backend/app.py backend/tests/test_destinations_api.py +git commit -m "feat(destinations): serve destinations without closing the gaps" +``` + +--- + +### Task 7: Frontend types and section flags + +**Files:** +- Modify: `nextjs-app/lib/types.ts` +- Modify: `nextjs-app/lib/schoolSections.ts:12-46` (the interfaces) and `:48-95` (`computeSchoolFlags`) +- Test: `nextjs-app/__tests__/lib/schoolSections.destinations.test.ts` + +**Interfaces:** +- Consumes: `lib/destinations.ts` types from Task 1 +- Produces: `SchoolDestinations` type; `SchoolFlags.hasKs4Destinations` and `.hasKs5Destinations` + +- [ ] **Step 1: Write the failing test** + +Create `nextjs-app/__tests__/lib/schoolSections.destinations.test.ts`: + +```ts +import { computeSchoolFlags } from '@/lib/schoolSections'; + +const base = { + schoolInfo: { urn: 1, school_name: 'X', phase: 'Secondary', has_sixth_form: true } as any, + yearlyData: [], absenceData: null, census: null, deprivation: null, finance: null, +}; + +const ks4Only = { + ks4: { cohort_year: '2022/23', groups: { all: { cohort: 180, categories: [], aggregates: {} } } }, + ks5: null, +} as any; + +it('flags KS4 destinations when the block is present', () => { + const flags = computeSchoolFlags({ ...base, destinations: ks4Only }); + expect(flags.hasKs4Destinations).toBe(true); + expect(flags.hasKs5Destinations).toBe(false); +}); + +it('flags neither when the block is absent', () => { + const flags = computeSchoolFlags({ ...base, destinations: null }); + expect(flags.hasKs4Destinations).toBe(false); + expect(flags.hasKs5Destinations).toBe(false); +}); + +it('does not flag a group with no categories as renderable', () => { + const empty = { ks4: { cohort_year: '2022/23', groups: {} }, ks5: null } as any; + expect(computeSchoolFlags({ ...base, destinations: empty }).hasKs4Destinations).toBe(false); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd nextjs-app && npx jest __tests__/lib/schoolSections.destinations.test.ts` +Expected: FAIL — `hasKs4Destinations` is undefined + +- [ ] **Step 3: Add the types** + +In `nextjs-app/lib/types.ts`: + +```ts +import type { DestinationCell, PupilGroup } from './destinations'; + +export interface DestinationGroupPayload { + cohort: number | null; + categories: DestinationCell[]; + aggregates: Partial>; +} + +export interface DestinationPhase { + cohort_year: string | null; + groups: Partial>; +} + +export interface SchoolDestinations { + ks4: DestinationPhase | null; + ks5: DestinationPhase | null; +} +``` + +- [ ] **Step 4: Extend the flags** + +In `nextjs-app/lib/schoolSections.ts`, add `destinations: SchoolDestinations | null` to `SchoolFlagsInput`, add `hasKs4Destinations: boolean` and `hasKs5Destinations: boolean` to `SchoolFlags`, destructure `destinations` in `computeSchoolFlags`, and compute: + +```ts + // A phase counts as present only if some group actually carries categories — + // a block with an empty groups map is a pipeline artefact, not a section. + const phaseHasContent = (phase: DestinationPhase | null | undefined) => + !!phase && Object.values(phase.groups ?? {}).some(g => (g?.categories?.length ?? 0) > 0); + + const hasKs4Destinations = phaseHasContent(destinations?.ks4); + const hasKs5Destinations = phaseHasContent(destinations?.ks5); +``` + +Return both from `computeSchoolFlags`. Add nav items `{ id: 'destinations', label: 'After Year 11' }` and `{ id: 'post16-destinations', label: 'After the sixth form' }` in `buildNavItems`, gated on the two flags, positioned after the GCSE entry. + +- [ ] **Step 5: Run test and typecheck** + +Run: `cd nextjs-app && npx jest __tests__/lib/schoolSections.destinations.test.ts && npm run typecheck` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add nextjs-app/lib/types.ts nextjs-app/lib/schoolSections.ts nextjs-app/__tests__/lib/schoolSections.destinations.test.ts +git commit -m "feat(destinations): types and section flags" +``` + +--- + +### Task 8: The After Year 11 section + +**Files:** +- Create: `nextjs-app/components/school/DestinationsSection.tsx` +- Create: `nextjs-app/components/school/DestinationsView.tsx` +- Create: `nextjs-app/components/school/destinations.module.css` +- Modify: `nextjs-app/components/school/SecondarySchoolSections.tsx` +- Test: `nextjs-app/__tests__/components/DestinationsSection.test.tsx` + +**Interfaces:** +- Consumes: `lib/destinations.ts` (Task 1), `SchoolDestinations` (Task 7), tokens (Task 2) +- Produces: `` + +- [ ] **Step 1: Write the failing test** + +Create `nextjs-app/__tests__/components/DestinationsSection.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react'; +import { DestinationsSection } from '@/components/school/DestinationsSection'; + +const cell = (category: string, pupils: number | null, status = 'published') => ({ + category, pupils, percentage: pupils === null ? null : (pupils / 180) * 100, status, +}); + +const fullPhase: any = { + cohort_year: '2022/23', + groups: { + all: { + cohort: 180, + categories: [ + cell('school_sixth_form', 75), cell('sixth_form_college', 21), + cell('further_education', 55), cell('other_education', 6), + cell('apprenticeship', 8), cell('employment', 6), + cell('not_sustained', 5), cell('not_captured', 4), + ], + aggregates: {}, + }, + }, +}; + +const suppressedPhase: any = { + cohort_year: '2022/23', + groups: { + all: { + cohort: 180, + categories: [ + cell('school_sixth_form', 75), cell('sixth_form_college', null, 'suppressed'), + cell('further_education', 55), cell('other_education', 6), + cell('apprenticeship', 8), cell('employment', 6), + cell('not_sustained', 5), cell('not_captured', 4), + ], + aggregates: {}, + }, + }, +}; + +it('dates its own cohort so it is not read as stale', () => { + render(); + expect(screen.getByText(/2022\/23/)).toBeInTheDocument(); +}); + +it('renders the bar when the group is fully published', () => { + const { container } = render(); + expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(8); +}); + +it('renders NO bar when a category is withheld', () => { + const { container } = render(); + expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(0); + expect(screen.getByText(/withheld/i)).toBeInTheDocument(); +}); + +it('never states a remainder for a partially suppressed group', () => { + const { container } = render(); + // 180 cohort - 155 published = 25, the withheld figure. It must appear nowhere. + expect(container.textContent).not.toMatch(/\b25\b/); +}); + +it('never claims a pupil stayed at this school', () => { + const { container } = render(); + expect(container.textContent).not.toMatch(/stayed on (here|at)/i); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd nextjs-app && npx jest __tests__/components/DestinationsSection.test.tsx` +Expected: FAIL — cannot find module + +- [ ] **Step 3: Write the server section** + +Create `nextjs-app/components/school/DestinationsSection.tsx`. It renders the `Section` shell, the title, a subtitle naming the cohort year and the publication lag, and delegates the interactive body to `DestinationsView` with `all` as the server-rendered default. Mark each bar segment with `data-destination-segment` so the tests and the E2E journeys can assert on its absence. + +Key structure: + +```tsx +/** + * DestinationsSection — where a school's Year 11 leavers went. Server component. + * + * The headline is deliberately NOT the sustained-destination rate: that figure + * sits between 92% and 97% for nearly every school in England, so leading with + * it would say nothing. The mix is what varies. + */ +import type { DestinationPhase } from '@/lib/types'; +import { Section, sectionStyles } from './sectionShared'; +import { DestinationsView } from './DestinationsView'; + +export function DestinationsSection({ + destinations, schoolName, +}: { destinations: DestinationPhase; schoolName: string }) { + const cohort = destinations.groups.all?.cohort ?? null; + return ( +
+

After Year 11

+

+ Where {cohort ? `the ${cohort} pupils` : 'the pupils'} who left Year 11 in{' '} + {destinations.cohort_year ?? 'the most recent year published'} went next. + Destination measures are published about two years after the exams above. +

+ +
+ ); +} +``` + +- [ ] **Step 4: Write the client view** + +Create `nextjs-app/components/school/DestinationsView.tsx` with `'use client'`. It owns the cohort switch (`role="radiogroup"`, arrow-key navigation), the card↔bar hover linkage, and the render decisions: + +- Cards from `CARD_GROUPS` — `aggregateCells` for the value, or a "Not published" card when it returns `null` +- Bar only when `canRenderBar(group)`; otherwise a panel explaining that the categories add up to the cohort so the rest cannot be drawn +- A published aggregate is shown only when `canRenderPublishedAggregate(components)` is true +- The full table always, with withheld rows marked +- Segment widths from `toBarSegments`, each carrying `data-destination-segment` + +Wrap the `toBarSegments` call in the `canRenderBar` guard rather than a try/catch — the throw is a backstop for programmer error, not control flow. + +- [ ] **Step 5: Write the stylesheet** + +Create `nextjs-app/components/school/destinations.module.css` using only the tokens from Task 2 plus the existing section tokens. The absence segment is `background-image: repeating-linear-gradient(45deg, var(--dest-none-hatch) 0 3px, transparent 3px 7px)` over `var(--bg-card)` with a `1px` inset ring in `var(--dest-none)`. Segments sit in a flex row with `gap: 2px`. + +- [ ] **Step 6: Mount it on the secondary template** + +In `nextjs-app/components/school/SecondarySchoolSections.tsx`, render `` after the GCSE section and before admissions, gated on `flags.hasKs4Destinations`. + +- [ ] **Step 7: Run tests, typecheck, commit** + +Run: `cd nextjs-app && npx jest __tests__/components/DestinationsSection.test.tsx && npm run typecheck` +Expected: PASS, 5 tests + +```bash +git add nextjs-app/components/school/DestinationsSection.tsx \ + nextjs-app/components/school/DestinationsView.tsx \ + nextjs-app/components/school/destinations.module.css \ + nextjs-app/components/school/SecondarySchoolSections.tsx \ + nextjs-app/__tests__/components/DestinationsSection.test.tsx +git commit -m "feat(destinations): the After Year 11 section" +``` + +--- + +### Task 9: The post-16 section, and removing the placeholder + +**Files:** +- Create: `nextjs-app/components/school/Post16DestinationsSection.tsx` +- Modify: `nextjs-app/components/school/SecondaryAdmissionsSection.tsx:110-120` +- Modify: `nextjs-app/components/school/SecondarySchoolSections.tsx` +- Test: `nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx` + +**Interfaces:** +- Consumes: everything from Task 8; reuses `DestinationsView` +- Produces: `` + +- [ ] **Step 1: Write the failing test** + +Create `nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react'; +import { Post16DestinationsSection } from '@/components/school/Post16DestinationsSection'; + +const phase: any = { + cohort_year: '2022/23', + groups: { + all: { + cohort: 96, + categories: [ + { category: 'higher_education', pupils: 56, percentage: 58.3, status: 'published' }, + { category: 'further_education', pupils: 12, percentage: 12.5, status: 'published' }, + { category: 'apprenticeship', pupils: 9, percentage: 9.4, status: 'published' }, + { category: 'employment', pupils: 13, percentage: 13.5, status: 'published' }, + { category: 'not_sustained', pupils: 6, percentage: 6.3, status: 'published' }, + ], + aggregates: {}, + }, + }, +}; + +it('names the Year 13 cohort, not Year 11', () => { + render(); + expect(screen.getByText(/Year 13/)).toBeInTheDocument(); +}); + +it('reports university destinations', () => { + render(); + expect(screen.getByText(/higher education|university/i)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd nextjs-app && npx jest __tests__/components/Post16DestinationsSection.test.tsx` +Expected: FAIL — cannot find module + +- [ ] **Step 3: Write the section** + +Create `nextjs-app/components/school/Post16DestinationsSection.tsx` mirroring `DestinationsSection` with `id="post16-destinations"`, the heading "After the sixth form", copy naming Year 13, and the same `DestinationsView` body. + +- [ ] **Step 4: Remove the placeholder** + +In `nextjs-app/components/school/SecondaryAdmissionsSection.tsx`, delete the "Post-16 destination data coming soon" paragraph at line ~117 and its surrounding conditional. The sixth-form badge in the header stays. + +- [ ] **Step 5: Mount it** + +In `SecondarySchoolSections.tsx`, render `` after ``, gated on `flags.hasKs5Destinations`. Where the school has no sixth form the section is simply not rendered — no placeholder, because absence is the correct statement. + +- [ ] **Step 6: Confirm the placeholder is gone, run tests, commit** + +Run: `grep -rn "coming soon" nextjs-app/components/` +Expected: no output + +Run: `cd nextjs-app && npx jest && npm run typecheck` +Expected: PASS + +```bash +git add nextjs-app/components/school/Post16DestinationsSection.tsx \ + nextjs-app/components/school/SecondaryAdmissionsSection.tsx \ + nextjs-app/components/school/SecondarySchoolSections.tsx \ + nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx +git commit -m "feat(destinations): the post-16 section, replacing the placeholder" +``` + +--- + +### Task 10: E2E journeys + +Per CLAUDE.md, user-facing behaviour extends `e2e/` in the same PR. Note the staging E2E gate runs post-merge — these journeys cannot pass in PR checks until the Airflow DAG has populated the marts on staging. + +**Files:** +- Modify: `e2e/tests/journeys.spec.ts` + +**Interfaces:** +- Consumes: the rendered pages from Tasks 8 and 9 +- Produces: nothing + +- [ ] **Step 1: Write the journeys** + +Append to `e2e/tests/journeys.spec.ts`: + +```ts +test('a secondary school page says where its Year 11 leavers went', async ({ page }) => { + await page.goto('/school/abbey-grange-church-of-england-academy-137083'); + const section = page.locator('#destinations'); + await expect(section).toBeVisible(); + // The section must date its own cohort — destinations run two GCSE years + // behind the results above them, and an undated figure reads as stale. + await expect(section).toContainText(/20\d{2}\/\d{2}/); +}); + +test('the destinations section draws no bar for a group with withheld figures', async ({ page }) => { + await page.goto('/school/abbey-grange-church-of-england-academy-137083'); + const section = page.locator('#destinations'); + await section.getByRole('radio', { name: /disadvantaged/i }).click(); + + const withheld = section.getByText(/withheld/i); + if (await withheld.count() > 0) { + // R1: where anything is withheld, the bar must be absent entirely — a bar + // with a gap in it publishes the withheld figure by its width. + await expect(section.locator('[data-destination-segment]')).toHaveCount(0); + } +}); + +test('a school with no sixth form has no post-16 destinations section', async ({ page }) => { + await page.goto('/school/abbey-grange-church-of-england-academy-137083'); + const hasSixthForm = await page.getByText(/sixth form/i).count() > 0; + if (!hasSixthForm) { + await expect(page.locator('#post16-destinations')).toHaveCount(0); + } +}); + +test('the destinations section never claims a pupil stayed at this school', async ({ page }) => { + await page.goto('/school/abbey-grange-church-of-england-academy-137083'); + const text = await page.locator('#destinations').textContent(); + // The published file reports destination TYPE, never destination institution. + expect(text ?? '').not.toMatch(/stayed on (here|at this school)/i); +}); +``` + +- [ ] **Step 2: Verify the slug resolves** + +Run: `grep -n "school/" e2e/tests/journeys.spec.ts | head -5` + +Match the slug format the existing school-page journeys use. If they build slugs from an API call rather than hardcoding, follow that pattern instead of the literal above. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/tests/journeys.spec.ts +git commit -m "test(e2e): destination journeys, including the no-bar rule" +``` + +--- + +## Self-Review + +**Spec coverage.** Every section of the design maps to a task: disclosure rules → Task 1 (guards) and Task 5 (mart tests); availability/extraction → Task 3; staging and the `safe_numeric` prohibition → Task 4; marts and R3 → Task 5; API → Task 6; display → Tasks 2, 7, 8, 9; edge states → Tasks 8 and 9; testing → every task plus Task 10. + +**Gap found and closed.** A first draft had Task 3 drop every non-school row, which would have left `fact_destination_national` (Task 5) reading an empty table — and a note telling the executor to go back and amend an earlier task. Task 3 now keeps national rows with `urn = None` from the start, and its tests cover both that and the LA rows that must still be dropped. + +**Type consistency.** `row_to_record` reads `geographicLevel`, not the `locations` keys, because a school row also carries `NAT`, `LA` and `REG` entries for its parents — keying off `"NAT" in locations` would admit every LA row as national. `status` takes the same three values in the tap, the staging models, the marts, the SQLAlchemy models, the API and `lib/destinations.ts`. `pupil_group` is `all` / `disadvantaged` / `other` throughout. -- 2.54.0 From 1980d79eee41d2bd8b32f7940717760bfc015abd Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 15:57:46 +0100 Subject: [PATCH 03/15] feat(destinations): the disclosure rules, as executable guards The destination categories sum to the cohort and DfE publishes the cohort total, so a lone suppressed cell is recoverable by subtraction. canAggregate, canRenderPublishedAggregate and canRenderBar are what stop a consumer doing that; toBarSegments throws rather than leaving a readable gap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- nextjs-app/__tests__/lib/destinations.test.ts | 108 ++++++++++++ nextjs-app/lib/destinations.ts | 155 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 nextjs-app/__tests__/lib/destinations.test.ts create mode 100644 nextjs-app/lib/destinations.ts diff --git a/nextjs-app/__tests__/lib/destinations.test.ts b/nextjs-app/__tests__/lib/destinations.test.ts new file mode 100644 index 0000000..81d1ef0 --- /dev/null +++ b/nextjs-app/__tests__/lib/destinations.test.ts @@ -0,0 +1,108 @@ +import { + canAggregate, aggregateCells, canRenderPublishedAggregate, + canRenderBar, toBarSegments, CARD_GROUPS, + type DestinationCell, type DestinationGroup, type DestinationCategory, +} from '@/lib/destinations'; + +const pub = (category: DestinationCategory, pupils: number, cohort: number): DestinationCell => ({ + category, pupils, percentage: (pupils / cohort) * 100, status: 'published', +}); +const sup = (category: DestinationCategory): DestinationCell => ({ + category, pupils: null, percentage: null, status: 'suppressed', +}); + +const fullGroup = (): DestinationGroup => ({ + cohort: 180, + cells: [ + pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180), + pub('further_education', 55, 180), pub('other_education', 6, 180), + pub('apprenticeship', 8, 180), pub('employment', 6, 180), + pub('not_sustained', 5, 180), pub('not_captured', 4, 180), + ], + aggregates: {}, +}); + +describe('canAggregate — R2, computing from components', () => { + it('allows a sum when every component is published', () => { + expect(canAggregate([pub('apprenticeship', 8, 180), pub('employment', 6, 180)])).toBe(true); + }); + + it('refuses a sum when any component is suppressed', () => { + expect(canAggregate([pub('apprenticeship', 8, 180), sup('employment')])).toBe(false); + }); + + it('refuses a sum when every component is suppressed', () => { + expect(canAggregate([sup('apprenticeship'), sup('employment')])).toBe(false); + }); +}); + +describe('aggregateCells', () => { + it('sums published cells and derives a percentage from the cohort', () => { + expect(aggregateCells([pub('apprenticeship', 8, 180), pub('employment', 6, 180)], 180)) + .toEqual({ pupils: 14, percentage: (14 / 180) * 100 }); + }); + + it('returns null rather than a partial sum when a component is suppressed', () => { + expect(aggregateCells([pub('apprenticeship', 8, 180), sup('employment')], 180)).toBeNull(); + }); +}); + +describe('canRenderPublishedAggregate — R2, a total DfE published itself', () => { + it('allows it when no component is suppressed', () => { + expect(canRenderPublishedAggregate([ + pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180), + ])).toBe(true); + }); + + it('REFUSES it when exactly one component is suppressed — the aggregate identifies it', () => { + expect(canRenderPublishedAggregate([ + pub('school_sixth_form', 75, 180), sup('sixth_form_college'), + ])).toBe(false); + }); + + it('allows it when two or more components are suppressed', () => { + expect(canRenderPublishedAggregate([ + sup('school_sixth_form'), sup('sixth_form_college'), + ])).toBe(true); + }); +}); + +describe('canRenderBar — R1', () => { + it('allows a bar when the whole group is published', () => { + expect(canRenderBar(fullGroup())).toBe(true); + }); + + it('refuses a bar when a single category is suppressed', () => { + const g = fullGroup(); + g.cells[1] = sup('sixth_form_college'); + expect(canRenderBar(g)).toBe(false); + }); +}); + +describe('toBarSegments', () => { + it('derives widths from counts, not from rounded percentages', () => { + const segs = toBarSegments(fullGroup()); + expect(segs).toHaveLength(8); + expect(segs[0].widthPct).toBeCloseTo((75 / 180) * 100, 10); + expect(segs.reduce((a, s) => a + s.widthPct, 0)).toBeCloseTo(100, 6); + }); + + it('throws rather than silently leaving a gap when the group is suppressed', () => { + const g = fullGroup(); + g.cells[1] = sup('sixth_form_college'); + expect(() => toBarSegments(g)).toThrow(/suppressed/i); + }); +}); + +describe('CARD_GROUPS', () => { + it('partitions every destination category exactly once, plus the absence', () => { + const grouped = Object.values(CARD_GROUPS).flat(); + expect(new Set(grouped).size).toBe(grouped.length); + expect(grouped).toEqual(expect.arrayContaining([ + 'school_sixth_form', 'sixth_form_college', 'further_education', + 'other_education', 'apprenticeship', 'employment', + ])); + expect(grouped).not.toContain('not_sustained'); + expect(grouped).not.toContain('not_captured'); + }); +}); diff --git a/nextjs-app/lib/destinations.ts b/nextjs-app/lib/destinations.ts new file mode 100644 index 0000000..aca8996 --- /dev/null +++ b/nextjs-app/lib/destinations.ts @@ -0,0 +1,155 @@ +/** + * Destination measures — categories, the card grouping, and the disclosure + * guards. + * + * DfE suppresses individual cells with `c`, and the destination categories sum + * to the cohort. So subtracting the published cells from the cohort total + * recovers a lone suppressed cell exactly — which is the case on 22% of + * mainstream secondaries. The guards below are what stop this module's + * consumers doing that by accident, and they are why a percentage is never + * reconstructed from a partial sum. + * + * See docs/superpowers/specs/2026-08-28-destination-measures-design.md. + */ + +export type DestinationCategory = + | 'school_sixth_form' + | 'sixth_form_college' + | 'further_education' + | 'other_education' + | 'apprenticeship' + | 'employment' + | 'not_sustained' + | 'not_captured' + // 16-18 only. + | 'higher_education'; + +export type PupilGroup = 'all' | 'disadvantaged' | 'other'; + +export type DestinationStatus = 'published' | 'suppressed' | 'not_applicable'; + +export type CardGroup = 'academic' | 'college' | 'work'; + +export interface DestinationCell { + category: DestinationCategory; + pupils: number | null; + percentage: number | null; + status: DestinationStatus; +} + +export interface DestinationGroup { + cohort: number; + cells: DestinationCell[]; + /** Aggregates DfE published itself, keyed by slug. */ + aggregates: Partial>; +} + +/** Display order, which is also bar order: education, then work, then absence. */ +export const CATEGORY_ORDER: DestinationCategory[] = [ + 'higher_education', + 'school_sixth_form', 'sixth_form_college', 'further_education', 'other_education', + 'apprenticeship', 'employment', 'not_sustained', 'not_captured', +]; + +/** + * Our grouping, not DfE's — the single most arguable thing on the page, which + * is why it lives in exactly one place. `not_sustained` and `not_captured` are + * deliberately absent: they are the absence of a destination, not a route, and + * "activity not captured" includes independent schools and moving abroad. + */ +export const CARD_GROUPS: Record = { + academic: ['higher_education', 'school_sixth_form', 'sixth_form_college'], + college: ['further_education', 'other_education'], + work: ['apprenticeship', 'employment'], +}; + +export function suppressedCount(cells: DestinationCell[]): number { + return cells.filter(c => c.status === 'suppressed').length; +} + +/** R2: a sum computed from components is safe only if every component is published. */ +export function canAggregate(cells: DestinationCell[]): boolean { + return cells.length > 0 && cells.every(c => c.status === 'published'); +} + +export function aggregateCells( + cells: DestinationCell[], cohort: number, +): { pupils: number; percentage: number } | null { + if (!canAggregate(cells) || cohort <= 0) return null; + const pupils = cells.reduce((sum, c) => sum + (c.pupils ?? 0), 0); + return { pupils, percentage: (pupils / cohort) * 100 }; +} + +/** + * R2, the other direction: DfE published this total itself. Showing it beside + * the components is safe only when it spans no suppressed component, or two or + * more. Exactly one, and the total names the withheld figure. + */ +export function canRenderPublishedAggregate(components: DestinationCell[]): boolean { + return suppressedCount(components) !== 1; +} + +/** R1: a bar is drawable only when nothing in the group is withheld. */ +export function canRenderBar(group: DestinationGroup): boolean { + return group.cohort > 0 && group.cells.every(c => c.status === 'published'); +} + +export interface BarSegment { + category: DestinationCategory; + pupils: number; + /** Exact width from the count — never the rounded percentage. */ + widthPct: number; + /** Rounded value for the segment label. */ + labelPct: number; +} + +export function toBarSegments(group: DestinationGroup): BarSegment[] { + if (!canRenderBar(group)) { + throw new Error( + 'toBarSegments: refusing to draw a bar for a group with suppressed categories — ' + + 'the gap left behind would disclose the withheld figure (R1).', + ); + } + const byCategory = new Map(group.cells.map(c => [c.category, c])); + return CATEGORY_ORDER.flatMap(category => { + const cell = byCategory.get(category); + if (!cell || cell.pupils === null) return []; + const widthPct = (cell.pupils / group.cohort) * 100; + return [{ category, pupils: cell.pupils, widthPct, labelPct: Math.round(widthPct) }]; + }); +} + +export const CATEGORY_LABELS: Record = { + higher_education: 'UK higher education', + school_sixth_form: 'State-funded school sixth form', + sixth_form_college: 'Sixth-form college', + further_education: 'FE and other colleges', + other_education: 'Other education destination', + apprenticeship: 'Apprenticeship', + employment: 'Employment', + not_sustained: 'Not recorded as a sustained destination', + not_captured: 'Activity not captured', +}; + +export const CARD_QUESTIONS: Record = { + academic: { + question: 'Do leavers stay on an academic route?', + hint: 'a school sixth form or a sixth-form college', + }, + college: { + question: 'Or move to a college?', + hint: 'an FE or other college', + }, + work: { + question: 'Or straight into work?', + hint: 'an apprenticeship or a job', + }, +}; + +/** Which card a category belongs to, or null for the two absence categories. */ +export function cardGroupFor(category: DestinationCategory): CardGroup | null { + for (const [group, categories] of Object.entries(CARD_GROUPS) as [CardGroup, DestinationCategory[]][]) { + if (categories.includes(category)) return group; + } + return null; +} -- 2.54.0 From 7ae9ecdc363287c0ecd74afd6486bcc7d7cf1bf8 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 15:58:25 +0100 Subject: [PATCH 04/15] feat(destinations): colour tokens, with the absence hatched not coloured Activity not captured includes independent schools and moving abroad, so a red segment would be a factual error. The hatch doubles as the secondary encoding that rescues the neutral/blue pair, which separates at only dE 7.6 as flat fills. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- .../components/darkThemeSafety.test.ts | 28 +++++++++++++++++++ nextjs-app/app/globals.css | 28 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/nextjs-app/__tests__/components/darkThemeSafety.test.ts b/nextjs-app/__tests__/components/darkThemeSafety.test.ts index 60445ee..3d6d889 100644 --- a/nextjs-app/__tests__/components/darkThemeSafety.test.ts +++ b/nextjs-app/__tests__/components/darkThemeSafety.test.ts @@ -76,3 +76,31 @@ describe('dark-theme safety', () => { expect(offenders).toEqual([]); }); }); + +/** + * Destination measures add the first new colour family since the palette was + * set. The tokens have to exist in both blocks or the section renders one + * theme's fills on the other theme's ground — the exact failure the suite + * above exists to catch, but for tokens rather than literals. + */ +describe('destination tokens', () => { + const css = fs.readFileSync( + path.join(__dirname, '..', '..', 'app', 'globals.css'), 'utf8'); + + const TOKENS = [ + '--dest-sixthform', '--dest-sfcollege', '--dest-fecollege', + '--dest-apprentice', '--dest-employment', '--dest-none', '--dest-none-hatch', + ]; + + const DARK_AT = css.indexOf('@media (prefers-color-scheme: dark)'); + + it('defines every destination token in the light palette', () => { + const light = css.slice(0, DARK_AT); + expect(TOKENS.filter((t) => !light.includes(`${t}:`))).toEqual([]); + }); + + it('redefines every destination token for dark', () => { + const dark = css.slice(DARK_AT); + expect(TOKENS.filter((t) => !dark.includes(`${t}:`))).toEqual([]); + }); +}); diff --git a/nextjs-app/app/globals.css b/nextjs-app/app/globals.css index 096e4f6..18ad53c 100644 --- a/nextjs-app/app/globals.css +++ b/nextjs-app/app/globals.css @@ -105,6 +105,23 @@ --series-7: #0E7A86; --series-8: #8A4A6B; + /* ── Destination measures ─────────────────────────────────────────── + Education is one hue in three steps (school-like -> college-like) so the + education destinations read as one family; apprenticeship and employment + are separate hues. The absence is neutral and HATCHED, never a colour: + "activity not captured" covers independent schools, moving abroad and + training DfE holds no data on, so rendering it as a bad outcome would be + a factual error. The hatch is also the secondary encoding that rescues + the neutral/blue pair, which separates at only dE 7.6 as flat fills. + Every other adjacent pair clears dE 10.9 under protanopia. */ + --dest-sixthform: #0F766E; + --dest-sfcollege: #4A9E96; + --dest-fecollege: #7CBFB8; + --dest-apprentice: #806200; + --dest-employment: #2F6F8F; + --dest-none: #6B7580; + --dest-none-hatch: rgba(107, 117, 128, 0.34); + /* ── Phase: category, desaturated so it stays under the status hues ── */ --phase-primary: #0F766E; --phase-primary-bg: rgba(167, 215, 197, 0.40); @@ -293,6 +310,17 @@ --series-7: #6FD0DC; --series-8: #D99BB8; + /* Destinations. Not a naive inversion: the education ramp reverses + direction so its darkest step stays the one furthest from the + school, and each step is re-checked against the dark card. */ + --dest-sixthform: #5FC7BB; + --dest-sfcollege: #3E9B92; + --dest-fecollege: #2A716B; + --dest-apprentice: #EFC658; + --dest-employment: #8FB4D9; + --dest-none: #8B9AA1; + --dest-none-hatch: rgba(139, 154, 161, 0.34); + --phase-primary: #5FC7BB; --phase-primary-bg: rgba(95, 199, 187, 0.16); --phase-primary-text: #8ADACF; -- 2.54.0 From 9188626051d0c6404b2de992734dad25a7578e3f Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 16:06:37 +0100 Subject: [PATCH 05/15] feat(destinations): a tap that preserves the suppression sentinel EES writes 'c' where a figure is withheld and the categories sum to the cohort, so counts and percentages are emitted as text with the sentinel intact. safe_numeric must never be pointed at them. School rows and the England reference need different establishment pins: at national level selective schools, studios and UTCs are separate populations rather than labels, so leaving establishment open multiplies 30 rows into 190. Two queries per period, each keeping its own level. Verified against the live API for 2022/23: 135,240 school records over 4,508 schools, exactly 30 each, no duplicate keys, 31,382 sentinels kept. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- pipeline/dags/school_data_pipeline.py | 12 +- pipeline/meltano.yml | 6 + .../tap-uk-ees-destinations/pyproject.toml | 16 + .../tap_uk_ees_destinations.egg-info/PKG-INFO | 7 + .../SOURCES.txt | 10 + .../dependency_links.txt | 1 + .../entry_points.txt | 2 + .../requires.txt | 2 + .../top_level.txt | 1 + .../tap_uk_ees_destinations/__init__.py | 0 .../tap_uk_ees_destinations/tap.py | 291 ++++++ .../tap-uk-ees-destinations/tests/test_tap.py | 134 +++ .../tap-uk-ees-destinations/uv.lock | 986 ++++++++++++++++++ 13 files changed, 1467 insertions(+), 1 deletion(-) create mode 100644 pipeline/plugins/extractors/tap-uk-ees-destinations/pyproject.toml create mode 100644 pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/PKG-INFO create mode 100644 pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/SOURCES.txt create mode 100644 pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/dependency_links.txt create mode 100644 pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/entry_points.txt create mode 100644 pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/requires.txt create mode 100644 pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/top_level.txt create mode 100644 pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/__init__.py create mode 100644 pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/tap.py create mode 100644 pipeline/plugins/extractors/tap-uk-ees-destinations/tests/test_tap.py create mode 100644 pipeline/plugins/extractors/tap-uk-ees-destinations/uv.lock diff --git a/pipeline/dags/school_data_pipeline.py b/pipeline/dags/school_data_pipeline.py index 82ad099..990ab79 100644 --- a/pipeline/dags/school_data_pipeline.py +++ b/pipeline/dags/school_data_pipeline.py @@ -178,9 +178,19 @@ with DAG( bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-ees target-postgres", ) + # Destinations come from the EES query API rather than a release ZIP, + # so they are a separate tap. Run after extract_ees rather than beside + # it: both write to the raw schema and the loader is happier serial. + extract_ees_destinations = BashOperator( + task_id="extract_ees_destinations", + bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-ees-destinations target-postgres", + ) + + extract_ees >> extract_ees_destinations + dbt_build_ees = BashOperator( task_id="dbt_build", - bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+", + bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+ stg_ees_ks4_destinations+ stg_ees_ks5_destinations+", ) sync_typesense_ees = BashOperator( diff --git a/pipeline/meltano.yml b/pipeline/meltano.yml index 65c8199..225e243 100644 --- a/pipeline/meltano.yml +++ b/pipeline/meltano.yml @@ -41,6 +41,12 @@ plugins: "201718": "http://10.0.1.224:8081/filebrowser/api/public/dl/0L61fE_a?inline=true" "201819": "http://10.0.1.224:8081/filebrowser/api/public/dl/XJGJ5lG1?inline=true" + - name: tap-uk-ees-destinations + namespace: uk_ees_destinations + pip_url: ./plugins/extractors/tap-uk-ees-destinations + executable: tap-uk-ees-destinations + settings: [] + - name: tap-uk-ofsted namespace: uk_ofsted pip_url: ./plugins/extractors/tap-uk-ofsted diff --git a/pipeline/plugins/extractors/tap-uk-ees-destinations/pyproject.toml b/pipeline/plugins/extractors/tap-uk-ees-destinations/pyproject.toml new file mode 100644 index 0000000..c6f85cf --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-ees-destinations/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "tap-uk-ees-destinations" +version = "0.1.0" +description = "Singer tap for UK EES destination measures (KS4 and 16-18), school level" +requires-python = ">=3.10" +dependencies = [ + "singer-sdk~=0.53", + "requests>=2.31", +] + +[project.scripts] +tap-uk-ees-destinations = "tap_uk_ees_destinations.tap:TapUKEESDestinations.cli" diff --git a/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/PKG-INFO b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/PKG-INFO new file mode 100644 index 0000000..ac5e58b --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/PKG-INFO @@ -0,0 +1,7 @@ +Metadata-Version: 2.4 +Name: tap-uk-ees-destinations +Version: 0.1.0 +Summary: Singer tap for UK EES destination measures (KS4 and 16-18), school level +Requires-Python: >=3.10 +Requires-Dist: singer-sdk~=0.53 +Requires-Dist: requests>=2.31 diff --git a/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/SOURCES.txt b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/SOURCES.txt new file mode 100644 index 0000000..c144626 --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/SOURCES.txt @@ -0,0 +1,10 @@ +pyproject.toml +tap_uk_ees_destinations/__init__.py +tap_uk_ees_destinations/tap.py +tap_uk_ees_destinations.egg-info/PKG-INFO +tap_uk_ees_destinations.egg-info/SOURCES.txt +tap_uk_ees_destinations.egg-info/dependency_links.txt +tap_uk_ees_destinations.egg-info/entry_points.txt +tap_uk_ees_destinations.egg-info/requires.txt +tap_uk_ees_destinations.egg-info/top_level.txt +tests/test_tap.py \ No newline at end of file diff --git a/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/dependency_links.txt b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/entry_points.txt b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/entry_points.txt new file mode 100644 index 0000000..9f3e8b9 --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +tap-uk-ees-destinations = tap_uk_ees_destinations.tap:TapUKEESDestinations.cli diff --git a/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/requires.txt b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/requires.txt new file mode 100644 index 0000000..606fe48 --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/requires.txt @@ -0,0 +1,2 @@ +singer-sdk~=0.53 +requests>=2.31 diff --git a/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/top_level.txt b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/top_level.txt new file mode 100644 index 0000000..a3d3902 --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations.egg-info/top_level.txt @@ -0,0 +1 @@ +tap_uk_ees_destinations diff --git a/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/__init__.py b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/tap.py b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/tap.py new file mode 100644 index 0000000..c502fe5 --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/tap.py @@ -0,0 +1,291 @@ +"""EES destinations tap — KS4 and 16-18 destination measures, school level. + +Separate from tap-uk-ees on purpose. That tap pulls a release ZIP and reads a +CSV inside it; the destinations files carry sex, ethnicity, FSM status, prior +attainment and SEN in the same table, so the whole-file route would download +millions of rows to keep a few hundred thousand. The query API filters server +side — with every dimension pinned, one year is ~250k rows across all +geographic levels rather than 1.9M. + +The one thing this tap must not do is tidy the data up. EES writes `c` where a +figure is withheld, and the destination categories sum to the cohort — so +turning `c` into NULL here would let a downstream sum reconstruct exactly what +DfE suppressed. Counts and percentages are emitted as TEXT, sentinel intact, +and safe_numeric must never be pointed at them. + +See docs/superpowers/specs/2026-08-28-destination-measures-design.md. +""" + +from __future__ import annotations + +import requests +from singer_sdk import Stream, Tap +from singer_sdk import typing as th + +API_BASE = "https://api.education.gov.uk/statistics/v1" +TIMEOUT = 180 +PAGE_SIZE = 10000 + +KS4_DATASET = "019d4f41-22d1-71b2-a1a7-f3b91026815b" +KS5_DATASET = "019d4e73-6440-7523-b60c-bfab1ad4a30d" + +# Filter option ids, read from each dataset's /meta endpoint. KS4 and 16-18 use +# different ids for the same concepts, so they are declared separately rather +# than shared. `agg_` measures are totals DfE published itself; they are kept +# because they survive suppression far better than their components, and the +# frontend decides whether showing one is safe. +KS4_DESTINATION_SLUGS = { + "DCz1Q": "school_sixth_form", + "eLsdu": "sixth_form_college", + "o2MJm": "further_education", + "b7v6t": "other_education", + "mlKo9": "apprenticeship", + "QIJEw": "employment", + "RZrek": "not_sustained", + "1j7Ui": "not_captured", + "WiEl2": "agg_sustained_education", + "EfSAq": "agg_sustained_all", +} +KS4_PUPIL_GROUP_SLUGS = {"p9WRS": "all", "OvPnC": "disadvantaged", "7VmdX": "other"} +# Every other dimension held at Total: sex, student-characteristic topic, +# ethnicity major and minor, student characteristics. Pinning these is +# mandatory, not an optimisation — unconstrained they cross with each other and +# return 45 rows where 30 are wanted. +# +# The two establishment dimensions are deliberately NOT pinned. A school row +# carries its own establishment type ("State-funded mainstream schools"), never +# Total, so pinning Total there returns zero rows. Verified: with this set, one +# school-year yields exactly 30 rows — 10 measures x 3 pupil groups, no +# duplicates. +KS4_PINNED = ["X542f", "jHdaA", "9c6y4", "Y0RhH", "wBpIb"] + +# The England reference needs a DIFFERENT pin set. At national level the +# establishment dimensions do vary — selective schools, studio schools, UTCs +# and so on are separate populations, not labels — so leaving them open +# multiplies every figure into 190 rows where 30 are wanted. Pinning +# establishment type to Total within the state-funded mainstream group gives +# exactly 30, and the parts reconcile: 151,912 disadvantaged + 441,823 other = +# 593,735 total for 2022/23. +# +# Note it must be the mainstream GROUP, not the Total group: DfE does not +# publish the disadvantage split across all establishment types at once, so +# pinning group=Total silently drops two thirds of the reference. +KS4_NATIONAL_PINNED = KS4_PINNED + ["5RcSd", "rgHcN"] + +# Indicator ids are per-dataset, not shared. The 16-18 set below differs. +KS4_INDICATORS = { + "cohort": "Poghe", + "pupils": "1roqi", + "percentage": "dPjk0", +} + +KS5_DESTINATION_SLUGS = { + "dkyu0": "higher_education", + "QIB6w": "school_sixth_form", + "9c3u4": "sixth_form_college", + "S4UgV": "further_education", + "EfYDq": "other_education", + "PaLTe": "apprenticeship", + "9c8k4": "employment", + "Wi6R2": "not_sustained", + "o2l7m": "not_captured", + "wBXtb": "agg_sustained_education", + "o2c4m": "agg_sustained_all", +} +KS5_PUPIL_GROUP_SLUGS = {"Y0PuH": "all", "HTeez": "disadvantaged", "CnSVI": "other"} +# As KS4, plus the two qualification dimensions — without them a sixth form is +# counted once per qualification level rather than once. Establishment omitted +# for the same reason as KS4. +KS5_PINNED = ["Pa2Ae", "o23fm", "NOBQD", "BSdu7", "fde2Y", "gJpLO", "tXHPE"] +# State-funded mainstream schools group + establishment type Total, as KS4. +KS5_NATIONAL_PINNED = KS5_PINNED + ["0hanT", "S4ROV"] + +KS5_INDICATORS = { + "cohort": "5TESd", + "pupils": "tjybE", + "percentage": "fzKfY", +} + + +def _period_to_time_period(period: str) -> str: + """'2022/2023' -> '202223', the convention every other mart here uses.""" + start, end = period.split("/") + return start + end[-2:] + + +def row_to_record( + row: dict, + urn_by_location: dict[str, str], + destination_slugs: dict[str, str] | None = None, + pupil_group_slugs: dict[str, str] | None = None, + indicators: dict[str, str] | None = None, +) -> dict | None: + """Map one API row to a Singer record, or None if it is not one we keep.""" + destination_slugs = destination_slugs or KS4_DESTINATION_SLUGS + pupil_group_slugs = pupil_group_slugs or KS4_PUPIL_GROUP_SLUGS + + # Read geographicLevel, not the locations keys: a school row also carries + # NAT, LA and REG entries for its parents, so testing "NAT in locations" + # would admit every local-authority row as a national one. + level = row.get("geographicLevel") + if level == "SCH": + urn = urn_by_location.get(row.get("locations", {}).get("SCH", "")) + if not urn: + return None + elif level == "NAT": + urn = None + else: + return None + + applied = set(row.get("filters", {}).values()) + destination = next( + (slug for fid, slug in destination_slugs.items() if fid in applied), None + ) + group = next( + (slug for fid, slug in pupil_group_slugs.items() if fid in applied), None + ) + if destination is None or group is None: + return None + + values = row.get("values", {}) + indicators = indicators or KS4_INDICATORS + return { + "urn": urn, + "time_period": _period_to_time_period(row["timePeriod"]["period"]), + "pupil_group": group, + "destination_measure": destination, + "cohort_pupils": values.get(indicators["cohort"]), + "pupils_raw": values.get(indicators["pupils"]), + "percentage_raw": values.get(indicators["percentage"]), + } + + +def _fetch_meta(dataset_id: str) -> dict: + resp = requests.get(f"{API_BASE}/data-sets/{dataset_id}/meta", timeout=TIMEOUT) + resp.raise_for_status() + return resp.json() + + +def urn_by_location_from_meta(meta: dict) -> dict[str, str]: + """Location id -> URN, for the school-level options.""" + for group in meta.get("locations", []): + if group.get("level", {}).get("code") == "SCH": + return {o["id"]: o["urn"] for o in group.get("options", []) if o.get("urn")} + return {} + + +def time_periods_from_meta(meta: dict) -> list[str]: + return [t["period"] for t in meta.get("timePeriods", [])] + + +class DestinationsStream(Stream): + """One stream per dataset. Pages the query API, one time period at a time.""" + + _dataset_id: str = "" + _destination_slugs: dict[str, str] = {} + _pupil_group_slugs: dict[str, str] = {} + _pinned: list[str] = [] + _national_pinned: list[str] = [] + _indicators: dict[str, str] = {} + + schema = th.PropertiesList( + th.Property("urn", th.StringType), + th.Property("time_period", th.StringType), + th.Property("pupil_group", th.StringType), + th.Property("destination_measure", th.StringType), + th.Property("cohort_pupils", th.StringType), + th.Property("pupils_raw", th.StringType), + th.Property("percentage_raw", th.StringType), + ).to_dict() + + primary_keys = ["urn", "time_period", "pupil_group", "destination_measure"] + replication_key = None + + def _query(self, period: str, pinned: list[str], keep_level: str, + urn_by_location: dict[str, str]): + """Page one period of one geographic level, yielding Singer records.""" + criteria = [ + {"filters": {"in": list(self._destination_slugs)}}, + {"filters": {"in": list(self._pupil_group_slugs)}}, + *[{"filters": {"in": [p]}} for p in pinned], + {"timePeriods": {"in": [{"period": period, "code": "AY"}]}}, + ] + page = 1 + while True: + resp = requests.post( + f"{API_BASE}/data-sets/{self._dataset_id}/query", + json={ + "criteria": {"and": criteria}, + "indicators": list(self._indicators.values()), + "page": page, + "pageSize": PAGE_SIZE, + }, + timeout=TIMEOUT, + ) + resp.raise_for_status() + payload = resp.json() + + for row in payload.get("results", []): + if row.get("geographicLevel") != keep_level: + continue + record = row_to_record( + row, urn_by_location, + self._destination_slugs, self._pupil_group_slugs, + self._indicators, + ) + if record is not None: + yield record + + if page >= payload.get("paging", {}).get("totalPages", 1): + return + page += 1 + + def get_records(self, context): + meta = _fetch_meta(self._dataset_id) + urn_by_location = urn_by_location_from_meta(meta) + periods = time_periods_from_meta(meta) + self.logger.info( + "%s: %d school locations, %d time periods", + self.name, len(urn_by_location), len(periods), + ) + + # Two queries per period, because school rows and the England reference + # need different establishment pins — see KS4_NATIONAL_PINNED. The API + # cannot filter by geographic level, so each pass keeps its own and + # discards the local-authority, district, regional and constituency + # rows that come with them. + for period in periods: + yield from self._query(period, self._pinned, "SCH", urn_by_location) + yield from self._query(period, self._national_pinned, "NAT", urn_by_location) + + +class KS4DestinationsStream(DestinationsStream): + name = "ees_ks4_destinations" + _dataset_id = KS4_DATASET + _destination_slugs = KS4_DESTINATION_SLUGS + _pupil_group_slugs = KS4_PUPIL_GROUP_SLUGS + _pinned = KS4_PINNED + _national_pinned = KS4_NATIONAL_PINNED + _indicators = KS4_INDICATORS + + +class KS5DestinationsStream(DestinationsStream): + name = "ees_ks5_destinations" + _dataset_id = KS5_DATASET + _destination_slugs = KS5_DESTINATION_SLUGS + _pupil_group_slugs = KS5_PUPIL_GROUP_SLUGS + _pinned = KS5_PINNED + _national_pinned = KS5_NATIONAL_PINNED + _indicators = KS5_INDICATORS + + +class TapUKEESDestinations(Tap): + name = "tap-uk-ees-destinations" + config_jsonschema = th.PropertiesList().to_dict() + + def discover_streams(self): + return [KS4DestinationsStream(self), KS5DestinationsStream(self)] + + +if __name__ == "__main__": + TapUKEESDestinations.cli() diff --git a/pipeline/plugins/extractors/tap-uk-ees-destinations/tests/test_tap.py b/pipeline/plugins/extractors/tap-uk-ees-destinations/tests/test_tap.py new file mode 100644 index 0000000..07da87c --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-ees-destinations/tests/test_tap.py @@ -0,0 +1,134 @@ +"""The tap's one testable job without a network: mapping an API row to a Singer +record without destroying the suppression sentinel.""" +from tap_uk_ees_destinations.tap import ( + row_to_record, + KS4_DESTINATION_SLUGS, + KS4_PUPIL_GROUP_SLUGS, +) + + +def test_published_row_keeps_its_numbers_as_text(): + row = { + "timePeriod": {"period": "2022/2023"}, + "geographicLevel": "SCH", + "locations": {"LA": "u9Oo4", "NAT": "dP0Zw", "SCH": "IXn5B"}, + "filters": {"wYXbx": "DCz1Q", "9ss4v": "p9WRS"}, + "values": {"Poghe": "264", "1roqi": "182", "dPjk0": "68.9"}, + } + rec = row_to_record(row, urn_by_location={"IXn5B": "137083"}) + assert rec["urn"] == "137083" + assert rec["time_period"] == "202223" + assert rec["destination_measure"] == "school_sixth_form" + assert rec["pupil_group"] == "all" + assert rec["cohort_pupils"] == "264" + assert rec["pupils_raw"] == "182" + assert rec["percentage_raw"] == "68.9" + + +def test_suppressed_row_preserves_the_c_sentinel(): + row = { + "timePeriod": {"period": "2022/2023"}, + "geographicLevel": "SCH", + "locations": {"SCH": "IXn5B"}, + "filters": {"wYXbx": "eLsdu", "9ss4v": "OvPnC"}, + "values": {"Poghe": "41", "1roqi": "c", "dPjk0": "c"}, + } + rec = row_to_record(row, urn_by_location={"IXn5B": "137083"}) + assert rec["pupils_raw"] == "c", "the sentinel must survive extraction" + assert rec["percentage_raw"] == "c" + assert rec["pupil_group"] == "disadvantaged" + + +def test_national_rows_are_kept_with_a_null_urn(): + """The England reference travels in the same response. It is kept, with a + null urn, so fact_destination_national has something to read.""" + row = { + "timePeriod": {"period": "2022/2023"}, + "geographicLevel": "NAT", + "locations": {"NAT": "dP0Zw"}, + "filters": {"wYXbx": "DCz1Q", "9ss4v": "p9WRS"}, + "values": {"Poghe": "500000", "1roqi": "190000", "dPjk0": "38.0"}, + } + rec = row_to_record(row, urn_by_location={}) + assert rec is not None + assert rec["urn"] is None + assert rec["percentage_raw"] == "38.0" + + +def test_other_geographic_levels_are_dropped(): + """A school row also carries NAT, LA and REG entries for its parents, so + the level has to be read from geographicLevel rather than inferred from + which location keys are present.""" + row = { + "timePeriod": {"period": "2022/2023"}, + "geographicLevel": "LA", + "locations": {"LA": "u9Oo4", "NAT": "dP0Zw"}, + "filters": {"wYXbx": "DCz1Q", "9ss4v": "p9WRS"}, + "values": {"Poghe": "1", "1roqi": "1", "dPjk0": "1"}, + } + assert row_to_record(row, urn_by_location={}) is None + + +def test_school_row_with_an_unknown_location_is_dropped(): + row = { + "timePeriod": {"period": "2022/2023"}, + "geographicLevel": "SCH", + "locations": {"SCH": "unknown"}, + "filters": {"wYXbx": "DCz1Q", "9ss4v": "p9WRS"}, + "values": {"Poghe": "1", "1roqi": "1", "dPjk0": "1"}, + } + assert row_to_record(row, urn_by_location={"IXn5B": "137083"}) is None + + +def test_rows_without_a_recognised_destination_are_dropped(): + row = { + "timePeriod": {"period": "2022/2023"}, + "geographicLevel": "SCH", + "locations": {"SCH": "IXn5B"}, + "filters": {"wYXbx": "notaslug", "9ss4v": "p9WRS"}, + "values": {"Poghe": "1", "1roqi": "1", "dPjk0": "1"}, + } + assert row_to_record(row, urn_by_location={"IXn5B": "137083"}) is None + + +def test_every_slug_maps_to_one_measure(): + assert len(set(KS4_DESTINATION_SLUGS.values())) == len(KS4_DESTINATION_SLUGS) + assert set(KS4_PUPIL_GROUP_SLUGS.values()) == {"all", "disadvantaged", "other"} + + +def test_indicator_ids_are_per_dataset_not_shared(): + """The 16-18 dataset uses different indicator ids for the same concepts. + Reusing the KS4 ids there returns IndicatorsNotFound from the API.""" + from tap_uk_ees_destinations.tap import KS4_INDICATORS, KS5_INDICATORS + assert set(KS4_INDICATORS) == set(KS5_INDICATORS) == {"cohort", "pupils", "percentage"} + assert not set(KS4_INDICATORS.values()) & set(KS5_INDICATORS.values()) + + +def test_ks5_rows_are_read_with_the_ks5_indicators(): + from tap_uk_ees_destinations.tap import ( + KS5_DESTINATION_SLUGS, KS5_PUPIL_GROUP_SLUGS, KS5_INDICATORS, + ) + row = { + "timePeriod": {"period": "2022/2023"}, + "geographicLevel": "SCH", + "locations": {"SCH": "IXn5B"}, + "filters": {"dest": "dkyu0", "dis": "Y0PuH"}, + "values": {"5TESd": "176", "tjybE": "98", "fzKfY": "55.7"}, + } + rec = row_to_record( + row, {"IXn5B": "137083"}, + KS5_DESTINATION_SLUGS, KS5_PUPIL_GROUP_SLUGS, KS5_INDICATORS, + ) + assert rec["destination_measure"] == "higher_education" + assert rec["cohort_pupils"] == "176" + assert rec["pupils_raw"] == "98" + assert rec["percentage_raw"] == "55.7" + + +def test_establishment_dimensions_are_not_pinned(): + """A school row carries its own establishment type, never Total — pinning + Total there returns zero rows for every school.""" + from tap_uk_ees_destinations.tap import KS4_PINNED, KS5_PINNED + establishment_totals = {"4369U", "rgHcN", "EfHQq", "S4ROV"} + assert not set(KS4_PINNED) & establishment_totals + assert not set(KS5_PINNED) & establishment_totals diff --git a/pipeline/plugins/extractors/tap-uk-ees-destinations/uv.lock b/pipeline/plugins/extractors/tap-uk-ees-destinations/uv.lock new file mode 100644 index 0000000..5f2df8f --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-ees-destinations/uv.lock @@ -0,0 +1,986 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version < '3.11'", +] + +[[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 = "backports-datetime-fromisoformat" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588, upload-time = "2024-12-28T20:18:15.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/4b/d6b051ca4b3d76f23c2c436a9669f3be616b8cf6461a7e8061c7c4269642/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", size = 27561, upload-time = "2024-12-28T20:16:47.974Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/e39b0d471e55eb1b5c7c81edab605c02f71c786d59fb875f0a6f23318747/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", size = 34448, upload-time = "2024-12-28T20:16:50.712Z" }, + { url = "https://files.pythonhosted.org/packages/f2/28/7a5c87c5561d14f1c9af979231fdf85d8f9fad7a95ff94e56d2205e2520a/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", size = 27093, upload-time = "2024-12-28T20:16:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/80/ba/f00296c5c4536967c7d1136107fdb91c48404fe769a4a6fd5ab045629af8/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", size = 52836, upload-time = "2024-12-28T20:16:55.283Z" }, + { url = "https://files.pythonhosted.org/packages/e3/92/bb1da57a069ddd601aee352a87262c7ae93467e66721d5762f59df5021a6/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", size = 52798, upload-time = "2024-12-28T20:16:56.64Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/b6cfd355982e817ccdb8d8d109f720cab6e06f900784b034b30efa8fa832/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", size = 52891, upload-time = "2024-12-28T20:16:58.887Z" }, + { url = "https://files.pythonhosted.org/packages/37/39/b13e3ae8a7c5d88b68a6e9248ffe7066534b0cfe504bf521963e61b6282d/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", size = 52955, upload-time = "2024-12-28T20:17:00.028Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e4/70cffa3ce1eb4f2ff0c0d6f5d56285aacead6bd3879b27a2ba57ab261172/backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", size = 29323, upload-time = "2024-12-28T20:17:01.125Z" }, + { url = "https://files.pythonhosted.org/packages/62/f5/5bc92030deadf34c365d908d4533709341fb05d0082db318774fdf1b2bcb/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", size = 27626, upload-time = "2024-12-28T20:17:03.448Z" }, + { url = "https://files.pythonhosted.org/packages/28/45/5885737d51f81dfcd0911dd5c16b510b249d4c4cf6f4a991176e0358a42a/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", size = 34588, upload-time = "2024-12-28T20:17:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6d/bd74de70953f5dd3e768c8fc774af942af0ce9f211e7c38dd478fa7ea910/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", size = 27162, upload-time = "2024-12-28T20:17:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/47/ba/1d14b097f13cce45b2b35db9898957578b7fcc984e79af3b35189e0d332f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", size = 54482, upload-time = "2024-12-28T20:17:08.15Z" }, + { url = "https://files.pythonhosted.org/packages/25/e9/a2a7927d053b6fa148b64b5e13ca741ca254c13edca99d8251e9a8a09cfe/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", size = 54362, upload-time = "2024-12-28T20:17:10.605Z" }, + { url = "https://files.pythonhosted.org/packages/c1/99/394fb5e80131a7d58c49b89e78a61733a9994885804a0bb582416dd10c6f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", size = 54162, upload-time = "2024-12-28T20:17:12.301Z" }, + { url = "https://files.pythonhosted.org/packages/88/25/1940369de573c752889646d70b3fe8645e77b9e17984e72a554b9b51ffc4/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", size = 54118, upload-time = "2024-12-28T20:17:13.609Z" }, + { url = "https://files.pythonhosted.org/packages/b7/46/f275bf6c61683414acaf42b2df7286d68cfef03e98b45c168323d7707778/backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", size = 29329, upload-time = "2024-12-28T20:17:16.124Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630, upload-time = "2024-12-28T20:17:19.442Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707, upload-time = "2024-12-28T20:17:21.79Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280, upload-time = "2024-12-28T20:17:24.503Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094, upload-time = "2024-12-28T20:17:25.546Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605, upload-time = "2024-12-28T20:17:29.208Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353, upload-time = "2024-12-28T20:17:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298, upload-time = "2024-12-28T20:17:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375, upload-time = "2024-12-28T20:17:36.018Z" }, + { url = "https://files.pythonhosted.org/packages/be/03/7eaa9f9bf290395d57fd30d7f1f2f9dff60c06a31c237dc2beb477e8f899/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", size = 28980, upload-time = "2024-12-28T20:18:06.554Z" }, + { url = "https://files.pythonhosted.org/packages/47/80/a0ecf33446c7349e79f54cc532933780341d20cff0ee12b5bfdcaa47067e/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", size = 28449, upload-time = "2024-12-28T20:18:07.77Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/67/07ecd6d85c0f253363cbc4ac9e7dd048ca571a267fbccfea084d6009ac3b/greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667", size = 292971, upload-time = "2026-08-10T13:28:09.837Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/426733bc24247ee17bb76df90f550c299ff5f8572b2bdd7cacb5c53fd994/greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b", size = 609290, upload-time = "2026-08-10T14:14:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/9c/dc/17d3a5acceb2fd0bbc9682a228117b719cdfb68085e6dbb33fa325755f27/greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502", size = 622650, upload-time = "2026-08-10T14:27:22.004Z" }, + { url = "https://files.pythonhosted.org/packages/51/91/00b3c0566316c6f383ea16ee05388ec4f57f6e0afa49e72ae471eda8c47f/greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56", size = 622819, upload-time = "2026-08-10T13:40:46.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/78/52de4f7ac9152ad1dbc8f437895c1e573d30ee1e1427e0f91a280e2417b6/greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0", size = 1582164, upload-time = "2026-08-10T14:15:02.645Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ff/c3855a00c2417e8f61c7b9f0bc4f8f599c6928f5c15681ad251e78a91e05/greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4", size = 1648807, upload-time = "2026-08-10T13:40:27.471Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/d29ff6a79dbd1ec1fe74c155d25d4c5a85e221d6b9ffc2cc7a709e7c8c39/greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc", size = 322832, upload-time = "2026-08-10T13:28:10.85Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "inflection" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonpath-ng" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/58/250751940d75c8019659e15482d548a4aa3b6ce122c515102a4bfdac50e3/jsonpath_ng-1.8.0.tar.gz", hash = "sha256:54252968134b5e549ea5b872f1df1168bd7defe1a52fed5a358c194e1943ddc3", size = 74513, upload-time = "2026-02-24T14:42:06.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/99/33c7d78a3fb70d545fd5411ac67a651c81602cc09c9cf0df383733f068c5/jsonpath_ng-1.8.0-py3-none-any.whl", hash = "sha256:b8dde192f8af58d646fc031fac9c99fe4d00326afc4148f1f043c601a8cfe138", size = 67844, upload-time = "2026-02-28T00:53:19.637Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathlib-abc" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/cb/448649d7f25d228bf0be3a04590ab7afa77f15e056f8fa976ed05ec9a78f/pathlib_abc-0.5.2.tar.gz", hash = "sha256:fcd56f147234645e2c59c7ae22808b34c364bb231f685ddd9f96885aed78a94c", size = 33342, upload-time = "2025-10-10T18:37:20.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/29/c028a0731e202035f0e2e0bfbf1a3e46ad6c628cbb17f6f1cc9eea5d9ff1/pathlib_abc-0.5.2-py3-none-any.whl", hash = "sha256:4c9d94cf1b23af417ce7c0417b43333b06a106c01000b286c99de230d95eefbb", size = 19070, upload-time = "2025-10-10T18:37:19.437Z" }, +] + +[[package]] +name = "python-backoff" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/0e/5275543955cf2b6a0b46b14e0fe55ee7b57bbd9d49efb16dc88146d9b113/python_backoff-2.4.0.tar.gz", hash = "sha256:e01ecdf46e8beed22cd7757df1148c4e03922be7983b3882f01866fe111386d2", size = 23811, upload-time = "2026-08-08T04:04:25.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/e9/decaec866dd76304301f5f7c832febb6734154bd428aa999694a3ad8664a/python_backoff-2.4.0-py3-none-any.whl", hash = "sha256:bdd6cb29bd2c4577999a630e137e730f6270989dad0b535e43fe3b7c3a16c0f1", size = 19307, upload-time = "2026-08-08T04:04:23.908Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +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/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { 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 = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +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 = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "simpleeval" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/9d/e7c9309940794dd3073cba2e5101df5874d84243595ce63b1e1c8f9b9c76/simpleeval-1.0.7.tar.gz", hash = "sha256:1e10e5f9fec597814444e20c0892ed15162fa214c8a88f434b5b077cf2fef85b", size = 30250, upload-time = "2026-03-16T10:53:03.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2f/f32aa85591882378bb43caa09363f3ed97df399369a5144c7f19f2275bc0/simpleeval-1.0.7-py3-none-any.whl", hash = "sha256:97ac271bfd8f2af9e7b9a36ceea67617f26fa873f9d5ae1922f64d4c1442534b", size = 18792, upload-time = "2026-03-16T10:53:02.103Z" }, +] + +[[package]] +name = "simplejson" +version = "4.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/e3/1cc7dbf4deebc16e9dc42db37f473b5b612d021eb10e69974be308425171/simplejson-4.1.2.tar.gz", hash = "sha256:6ae4186f90362e9c03c80a1cd5062a20f3a11ac9d391f7ee0ef0701a0e2b7394", size = 121591, upload-time = "2026-08-27T00:38:27.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/3a/bf9ab4db6f3c5e689acf5e3665fd1100413aa85604fd88bef879b2869b6e/simplejson-4.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:95569ac177885494967c1445a0fcc220eaa1ae53b71e852eb0d8689b683ca6d0", size = 114439, upload-time = "2026-08-27T00:36:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/a0d405be16e7cbac5d1dab9b7f40a5c8dd681b546811047a8f102e53e125/simplejson-4.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c41397ba7c5d0a4ce2f058e8ebb0bf80703b0ba1cd1df9616399c35dd1a7df37", size = 92972, upload-time = "2026-08-27T00:36:33.102Z" }, + { url = "https://files.pythonhosted.org/packages/bf/50/5a6059682c6980d5f2b039aaa643d4d9e7272fc18eacfc7be30d922d20ba/simplejson-4.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d0b62163144dbc0dfc78df632a64ada3ce2e0760b65ead0f42e7d9c69be6f16f", size = 93156, upload-time = "2026-08-27T00:36:34.187Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3d/aa0c2337b17aeee27c24dc512f88db6093ffbe224d7107d6ef0d1a3f35da/simplejson-4.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eaf2652770fc3415498cb7446bdb399624cc685df868d58e09d6722b63e937f3", size = 175832, upload-time = "2026-08-27T00:36:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/bd3582de3eeadd0f7d2864f37a91389f34e19d66391e9ef94bcf028be844/simplejson-4.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99a76da628af68a90c906e5c0dc7da784eb1ceff7e631aa7c43745b3e6d426a7", size = 174639, upload-time = "2026-08-27T00:36:36.587Z" }, + { url = "https://files.pythonhosted.org/packages/89/85/268d7abe6feb708c6ceb6651d28e8120d7d9ffb409e7aeb1f3acbe514e10/simplejson-4.1.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53b56770b5893d91b24f6ba985eb5bade5908971f62cb7f8e1ed50a9314601b2", size = 184317, upload-time = "2026-08-27T00:36:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5b/fa612786782b09373339ed89f1e80b0d667fdbb372534f0250bbceae1db8/simplejson-4.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f155b6b58725890f9a9d915b9e7108eaf7080542ab5fd8e31e9a5908ecf2c7d", size = 172747, upload-time = "2026-08-27T00:36:39.179Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e7/43a5331fa3918b239621a8258993e3ad5217861b345c6464ecf0a84076be/simplejson-4.1.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:81c1746fcf232faec2b5b7160f8e249bb5826234286f861b7aa8e80faa0d98c4", size = 182849, upload-time = "2026-08-27T00:36:40.346Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9d/2b91d17c91638987dfe54bf3d2257370b235b66c1fe2fdb84e10767ac159/simplejson-4.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b2162d7f5e2e9dc48c08af14d91481b9ffbb8d8420d7d1539209fa7f0ba8823f", size = 174024, upload-time = "2026-08-27T00:36:42.327Z" }, + { url = "https://files.pythonhosted.org/packages/02/6a/8546173dc4dadeb261b86fc71eb72e2863263c8a680d191b63e81505f2aa/simplejson-4.1.2-cp310-cp310-win32.whl", hash = "sha256:a372e803f741ba3884cba8b37208da2daba8155cbe244c1db83cedd73f26fa6f", size = 89912, upload-time = "2026-08-27T00:36:43.572Z" }, + { url = "https://files.pythonhosted.org/packages/30/f9/6a8afbb137e2cdc79eb3f1b65226070c0eee5526376196388022b266742d/simplejson-4.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:b60497514538df1eff7e0da8db1b48a220faedce5d33bd364fd2574115958502", size = 91510, upload-time = "2026-08-27T00:36:44.879Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/4d6181137f23088baa8abdc2a6e01a4eb872865532265cc4fae23f7dc193/simplejson-4.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8842cae188daf4f7ab93cca85156bf57a1f5fe24379b9e20040da7d3a829ba64", size = 114120, upload-time = "2026-08-27T00:36:46.033Z" }, + { url = "https://files.pythonhosted.org/packages/2c/67/dff3c64269e352dbec781f1920c4422816a675d1953b62d18c100ddf4931/simplejson-4.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a83dd08a7cbe770e9d14be2dbedde77890477cc44e804582ef69cbd8a1c834b2", size = 92897, upload-time = "2026-08-27T00:36:47.242Z" }, + { url = "https://files.pythonhosted.org/packages/64/b6/82c2322d80345113a16451ecd7f2f487c380de0dbcfdbdefee7b99095cbf/simplejson-4.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbc4f989e44d6153b5097357d260336d8021c21b3ae9d8954c9daf55788bfc16", size = 92912, upload-time = "2026-08-27T00:36:48.589Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/8473951699049947b25c64e02f5897e37a4598a269a217d25e44bc35c571/simplejson-4.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ddbdfe504c8ab09443856544fb744dc881701b0289da68e3e6904ec1745ff4a6", size = 187183, upload-time = "2026-08-27T00:36:49.759Z" }, + { url = "https://files.pythonhosted.org/packages/52/76/b3fe036455beda5e0ed0dd84dba674deb2c7b60f977b711394e5913250d9/simplejson-4.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c9850876ecf19c044d1f048fcb176997c0eb47ec9a9ba85d32d995fcf797168", size = 185410, upload-time = "2026-08-27T00:36:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/dc931f8621a3a6f58f3582e345f1a9e620f3d622f110be2a39e3343a488f/simplejson-4.1.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0be6cf935487b8d0bc97c528c89d0432f22e33ca1e1d4fea666d50d36b5afe4f", size = 194636, upload-time = "2026-08-27T00:36:52.733Z" }, + { url = "https://files.pythonhosted.org/packages/35/0c/eced473e9bdd47c741dcdfc1e309d243be970a9bf432d3f2e274f66f111d/simplejson-4.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5589cd313b201cb915d796b6f9ad372b2285672a6bc9e9833bf23011297b38f6", size = 183058, upload-time = "2026-08-27T00:36:54.125Z" }, + { url = "https://files.pythonhosted.org/packages/58/dd/3253f207f0d7ce5fe4b78d76c70e1e9433c4fc94f40044ac4105ed6c6301/simplejson-4.1.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5d7426d083647d034debf499094637f63257b72b9899c81aa8196444ee69580c", size = 192562, upload-time = "2026-08-27T00:36:55.506Z" }, + { url = "https://files.pythonhosted.org/packages/20/dc/ad9aa843de7f9cb74a924f79a6d53986b3351206b792ccf27d6a4a92b745/simplejson-4.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d63bc3adb552480c890b9ff8fc119e8997b414a8cb68b888761f5dfefafd2bf8", size = 185248, upload-time = "2026-08-27T00:36:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/76/65/f57086a2b505c002f12be455dfc349d91ed5865df73a9bcc2c7f4c2239d0/simplejson-4.1.2-cp311-cp311-win32.whl", hash = "sha256:d792ac0ad6cfc763339895fa08aa5b8512dd262161c9fb90138a0ba814236fe4", size = 90008, upload-time = "2026-08-27T00:36:57.839Z" }, + { url = "https://files.pythonhosted.org/packages/6d/93/5009ff024b798311b3478588dc833f4eb5efce0ed22c0158f4398953b415/simplejson-4.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c79ab4ac91e62b9ccb1269366f3aa51bc2985c2e71dd344541ef2f8c291f001f", size = 91723, upload-time = "2026-08-27T00:36:59.133Z" }, + { url = "https://files.pythonhosted.org/packages/b7/46/cd6f49506d633f5207c868c9373db594d4636b0fd69a2ce3dea53a1a05ff/simplejson-4.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3733bf9afd79291eb0744b5b01041356df1b1bc25175a72519eb548751b61aeb", size = 114985, upload-time = "2026-08-27T00:37:00.459Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dd/1ca3b57840863954e1d402287f8481c88f4e84bdc1cd27f6a0f3a7097b69/simplejson-4.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:630160f22edb2b13e05e0fe13dd80c2a064374f212696d3ee472f9ee13ebf4ac", size = 93388, upload-time = "2026-08-27T00:37:01.665Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/ff9cce6cdd7b2271ccf25e700a38986fa8ccf601fba405db28150414a7fb/simplejson-4.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78a0ff56305feec5217639247dba9174d20d4fa6538885a29b9910aad44fd92d", size = 93292, upload-time = "2026-08-27T00:37:02.851Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f9/69d2be0cbcdc1bd6052998b7d22cb3e7c80c9d6a371eb345cf079401fe41/simplejson-4.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a769bce267ef4091b7ef380bcad1c474f4e66bfcfecb571e996215dcdd24df70", size = 194572, upload-time = "2026-08-27T00:37:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/39e7253427dd4c2ec38596b944e3d3a53926cab7b3c2d28c43ef1a34d6d3/simplejson-4.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2156497f031d5c0051a335216f7ff5441746a93fbab5932e47105da7ef24ab9", size = 191984, upload-time = "2026-08-27T00:37:05.212Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c4/75efe79679235b74ed3ff9ef91421bf4d57ca9e7ffa84543975f3fd833af/simplejson-4.1.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd305a4699fc949ba6089ddfd17f582ce7c4a5d85e5601c44b42e35ebf521174", size = 199832, upload-time = "2026-08-27T00:37:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/49/77/31a91f9e237591802d72d9ff9922c4c88f94eef34ade81d6e36769d70ba6/simplejson-4.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0247d71cd72dbd4f1b9dc3db9a1e333b74cbb35ee4f9287230de97de4cc03eb6", size = 188798, upload-time = "2026-08-27T00:37:07.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/00/7876a15fbecac1d4653ce2af0b446fa1308af8d6f3f802e2e7019698b46d/simplejson-4.1.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b8d4ff4cd6e8cdab666431169378820cb4570fa06cb74d3f0cc165aa5434b62", size = 196805, upload-time = "2026-08-27T00:37:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/1b/47/53185d6bfa624dec5a9d1b10d4a9ca46b29124bc5695a1f33715a66606a1/simplejson-4.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:438aa043f693af285aaeea2ce72fa4a45b96d1532a1431d937577083fe13254d", size = 191871, upload-time = "2026-08-27T00:37:10.424Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/932a5d3ad0877d34381b3319b69fab8b9839173b47eaea5b6cf08033d16e/simplejson-4.1.2-cp312-cp312-win32.whl", hash = "sha256:6e7a978f030beb0f9f710e906c59ed7b0e43e7ba20cbc1a1e67021de24e34b12", size = 90222, upload-time = "2026-08-27T00:37:11.62Z" }, + { url = "https://files.pythonhosted.org/packages/da/2d/3049dc3da55289d3d82e148fd95a6cbfa04bd31db49ef11325e0b681c0d0/simplejson-4.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:5eb414922b5ef7ca6ef5a3e3c82b036f4408f9d15aea9dd8c5de798d000929e0", size = 91969, upload-time = "2026-08-27T00:37:12.789Z" }, + { url = "https://files.pythonhosted.org/packages/45/14/9603f47a6da152a1e3c66748484546ea337f380951f9f971fd9bac6f4d11/simplejson-4.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b99859df62436dff54d597fa26a251c00a0b8227277a6c018faa64cbd76e3a8c", size = 115800, upload-time = "2026-08-27T00:37:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f9/372afb2500e6e4b52899861f3d477a2fac63af4c3e9aa64039d9a62aba18/simplejson-4.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:760864258e2ecf64c9de6e91756b69fe9821b3cc7a1a4326ef99902306a83816", size = 93874, upload-time = "2026-08-27T00:37:15.065Z" }, + { url = "https://files.pythonhosted.org/packages/c1/51/cad8715b85c1a822c790b557ecbdcc03f170ea03c5ff23d61d2efe4de364/simplejson-4.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:01554fea464ecb8a3a9a7053de4670c87e18590836398a30a535124bc4453480", size = 93675, upload-time = "2026-08-27T00:37:16.258Z" }, + { url = "https://files.pythonhosted.org/packages/56/a0/4ad5d81a2adf7ee437a57c6bd2b676c476457aa85c2fcd1b03bc3577ac35/simplejson-4.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f17a3c875f0fd6600739ee9bba4137a5138f8fc9524c69be4db8c659e0f703b0", size = 196944, upload-time = "2026-08-27T00:37:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/8b/49/cccd27be83408a6736e31056ff5a8e1631b55f09555a1c99c0013425d1ad/simplejson-4.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df5da14ae3daafe11c13861a39e7f57a92c2b5fd7b4b06601e82f7f941d53f07", size = 193792, upload-time = "2026-08-27T00:37:18.873Z" }, + { url = "https://files.pythonhosted.org/packages/56/1a/d4e0b631616a118359811b7f1c2245f5995c41c0a97921d0e224f20a62ac/simplejson-4.1.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e20baa381e4c6e9c0b2767794504f40193caac93bdd03116571379490499325", size = 201377, upload-time = "2026-08-27T00:37:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/f5/22/7c6a2599a83e1db980235a16c5195c6c3bf4a0d3b95c7088cff747042d0e/simplejson-4.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4cc740929b41e32c63a22b56b1f1e55174fbcb062a2bc9872c4dc02dc48e5bc9", size = 189885, upload-time = "2026-08-27T00:37:21.364Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/096bdddeb6809ac1e32b63c682fe6851a135fe35e7dc44d10d1e1cc439e4/simplejson-4.1.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c13623bab64ead9f5f246a0a232dcb8be6152fbb9b500b757808cc37c4a025e", size = 198161, upload-time = "2026-08-27T00:37:22.685Z" }, + { url = "https://files.pythonhosted.org/packages/f4/fe/fc0b116a4239b8f312030e238eefd0738b27b0b7efb59cea631e1c5eb755/simplejson-4.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:29fd672ee98241e1f0f6008a2d0a5664ab3d92fb0c454124c18c3b119362beca", size = 193706, upload-time = "2026-08-27T00:37:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f6/a1e71e0d30704f3b6f520b086a2786ebc4c8e65142862c318c0f2d20ec74/simplejson-4.1.2-cp313-cp313-win32.whl", hash = "sha256:28d0e567e74865139d2576dc135e22bc509dbd64f3cab72e5ceed7b2d198bd89", size = 90371, upload-time = "2026-08-27T00:37:25.477Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7a/7d285a780569195d4bab5f44b6691823497d36a2f34c95e3c31507b12b5c/simplejson-4.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f76148abeeb476ac0e129433c9734df9391f5373c8529c3d0b628de14b859fa3", size = 92281, upload-time = "2026-08-27T00:37:26.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a0/3a0934c2b465cb8bbd6fad7e9ca32cfd70f1a0ffd204e0e5db9704fa1fbb/simplejson-4.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d999597ff19143130d2e52f363e5e7791703a87be3df9219fbec6eb54958d4d1", size = 112905, upload-time = "2026-08-27T00:37:28.074Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/a9f131ae07efa8fd7b17961bdcdd5b6a00b0b49480cf66e379fe06459614/simplejson-4.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:37c0293cd6b04ab0e2c2bb8bcbe5c3c7c2cd3bd487bbd95c043237a7db4d4e9c", size = 92397, upload-time = "2026-08-27T00:37:29.471Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0b/05e95a9a673adeeece7d368fd7ff305cfa00599d482a9ba23667af73ddf1/simplejson-4.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a48a827277963a557ad844c845fa8992356d1afd6edba76cd32fe056cbf8df2b", size = 92231, upload-time = "2026-08-27T00:37:30.619Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/2eddb2053b9ca497bbd07c663e54bb6e13747b1ff6c3f3c79a86b19ddd6d/simplejson-4.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d350798258029864b27bca50b4ce7ee138faf8f07be21d2192b7088da9c38074", size = 188580, upload-time = "2026-08-27T00:37:31.841Z" }, + { url = "https://files.pythonhosted.org/packages/2a/98/43eb3ed7edbb07afcb861fc24dfcfc52b0e83373236420dd59ba1dc9aacf/simplejson-4.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c0bd55cc396b7dc1df43b19a9df8d85684fd84dc196fad674c1f4c4887279d0", size = 185310, upload-time = "2026-08-27T00:37:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/98/0b/2f60218ce135fad65dafd9998272c7fd029eb7195645c439395027a872a5/simplejson-4.1.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:205e28d0506e72cedf5b8bad4a946779db236d1fe4d43f2481a3361737ed573b", size = 193465, upload-time = "2026-08-27T00:37:34.49Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/8270e305880188acbe83324c7f8d9a913cdfeec180bef845948113c96496/simplejson-4.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:87dd15ed478dc1dea9e5ee3e564bd49ff9f9ecab854a1ae896d66b8667387b28", size = 181501, upload-time = "2026-08-27T00:37:35.696Z" }, + { url = "https://files.pythonhosted.org/packages/2a/87/dd6b9713ddd91fbdf6f78cf87fe6ff11c3216d01795db6206ee77fb86a9a/simplejson-4.1.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c8135ff667bd1c131964795196e2428a9db4befeb074133b314847675d6ca367", size = 189435, upload-time = "2026-08-27T00:37:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b9/1ece5f5ea9c72eaf98ec06c3f73971c868971b4f8d0d266758e7d95dc95b/simplejson-4.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7273dd5d56430549d5bea9176923891f765ddcc421a1a05c74c6ff5397edaa38", size = 185760, upload-time = "2026-08-27T00:37:38.205Z" }, + { url = "https://files.pythonhosted.org/packages/99/70/9519355cebd5916aabd8d1397a626c9c2e97f47edc4827d3b0859d9903dc/simplejson-4.1.2-cp314-cp314-win32.whl", hash = "sha256:432cc50cc23ce980ec60d3cbf8f35383653fec9b4c952e24cded2f638a48f48e", size = 90304, upload-time = "2026-08-27T00:37:39.432Z" }, + { url = "https://files.pythonhosted.org/packages/d0/81/4ed61f6cfa01e8d43ca242d04a6155091a7f19566f095715ed38a2dcfb80/simplejson-4.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a52db90cc4175bf7aaa36bf7b8c1ff92403349d2d999794ad3f75b175c627ff1", size = 92616, upload-time = "2026-08-27T00:37:40.607Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f1/15e52fe9168510d78315439ad5d0f737e8c9f920cad251c989acac4ef263/simplejson-4.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:92e85afa383ac2f6a80ac0d9dc6ccf9e92bc19d24451c41d290e83bab5534ccb", size = 116460, upload-time = "2026-08-27T00:37:42.916Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b3/1bec2de25e21864d9920bf00427f965da74052e84088b924c12c92a1b1b6/simplejson-4.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2081a5f1a1e2645d4b2db48e49b54862f60bc31387e71549c1d4679caacaf694", size = 94213, upload-time = "2026-08-27T00:37:44.193Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fe/f82ff72f373f3cd9078819525940fefb35b36b0f4581e602a78952c84531/simplejson-4.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fcc4c6b0ba1f0515cbab539b5b7c1b38f3671999b390f8056cd71ac05895d901", size = 93920, upload-time = "2026-08-27T00:37:45.409Z" }, + { url = "https://files.pythonhosted.org/packages/53/cd/292be366a353aa16c7e77a2b37b83bfa24e4bbca9fbf9914475375eaf2b1/simplejson-4.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:119733522f1f5d50a4262820ec587753a368cce0231aae02b958bb47e705bc13", size = 218654, upload-time = "2026-08-27T00:37:46.659Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4b/419a05e7aa24650a28c572a421ae71e3a40f5e10375efa2ce96db80cb358/simplejson-4.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15ffd144c44be1f5ba2a9bc5641ea7af925b75e240adffb79ced67f35f58b4dd", size = 219738, upload-time = "2026-08-27T00:37:48.011Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d8/6ca3a747348465a80c1dcf50f8265477806981253677331a2b217b414f36/simplejson-4.1.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4134f1afadf815295d5d38f50f2b64e1758ce053b552526b44f74c5a34bf9bab", size = 223936, upload-time = "2026-08-27T00:37:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cb/5498d477c0131abf29349102c372307119c1b111d7992a8c899f8de03899/simplejson-4.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:601184707588eec8e04e16c534be5a4794aeac9a5163600e077c85c255a1f9b3", size = 214779, upload-time = "2026-08-27T00:37:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/33/ab/f39ffeb873375d3a733e886ca7c3e03131721e3e814edcd020cdfd339ef6/simplejson-4.1.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3a2f883839314597ace1f48fda2e8f4bf1018674d3842823f54b027690b15e41", size = 219880, upload-time = "2026-08-27T00:37:51.934Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/01e88370229d92f1e8b2bc660cb53b3c3b5068fe76768021a331c9f276f7/simplejson-4.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d284ea3976a73054151e7906fc1b4fff25d589970532b6209ed7aeda9079747b", size = 215009, upload-time = "2026-08-27T00:37:53.217Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/016a4132161e1ac0b75385c1f61dbd96c0dff57ad2bb09e6e1653cfd7513/simplejson-4.1.2-cp314-cp314t-win32.whl", hash = "sha256:14a6a1fd97aeb7ebc54773bd0d195b480e946b9a915ebd163671744a387abee6", size = 91670, upload-time = "2026-08-27T00:37:54.503Z" }, + { url = "https://files.pythonhosted.org/packages/da/b2/6911f3b7d82f641b7050190cc64dbae6dcc1148bb02f7212b26f6119f0c2/simplejson-4.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:e933a481b825bac33fb274de49e1574f805f48442198e06b1f2c3b4cbde40350", size = 94267, upload-time = "2026-08-27T00:37:55.836Z" }, + { url = "https://files.pythonhosted.org/packages/95/e2/7f47df8fbd3d9c35902b76c242e21759a27d6e8c030c901c4f0ad93dd10b/simplejson-4.1.2-py3-none-any.whl", hash = "sha256:93f95f6769f37f4c3763666950b23bf9b9230b60bf1d718845e92db2ebcbf32d", size = 71116, upload-time = "2026-08-27T00:38:26.256Z" }, +] + +[[package]] +name = "singer-sdk" +version = "0.54.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-datetime-fromisoformat", marker = "python_full_version < '3.11'" }, + { name = "click" }, + { name = "fsspec" }, + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "inflection" }, + { name = "joblib" }, + { name = "jsonpath-ng" }, + { name = "jsonschema" }, + { name = "packaging" }, + { name = "python-backoff" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "requests" }, + { name = "simpleeval" }, + { name = "simplejson" }, + { name = "sqlalchemy" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "universal-pathlib" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/d4/48444fc63b94438b4affd261e7e982c647ba2d802c6e102421219109f5d0/singer_sdk-0.54.5.tar.gz", hash = "sha256:a0ac6524721c0b0b75999864f74c6280f4611be930c39103cb101e20cacc3d8c", size = 1380791, upload-time = "2026-06-16T02:43:11.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/71/3f0a36c5ece55a63ffdb8556dfd573957f22ed75f39526897d17a8f37233/singer_sdk-0.54.5-py3-none-any.whl", hash = "sha256:5479b69e2240ec4dd7635f5290d958ef141694a036d6e9aa6e167005aa9c2188", size = 217459, upload-time = "2026-06-16T02:43:09.743Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/d7/e0354e7334d33ea2795db3ecbe2977026c05a1ecf8ba4b5953c329872453/sqlalchemy-2.0.52-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7438774e1091192fc50a2bd8ceff5c596912d00ecd46587e88effdea7826101", size = 2172616, upload-time = "2026-08-11T20:58:21.078Z" }, + { url = "https://files.pythonhosted.org/packages/5b/64/98eef682e6946eb1b4195a9a2393db4662ebfcc89f823ae78b938765c3c0/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c1b7ed45bf87b214e0a9def9c2313949067efe6269db5ef18d542ee13250af7", size = 3279798, upload-time = "2026-08-11T21:00:03.535Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/5b96afc1407c314347ad006b72bb251fb68ef84d05505ebf8a39bc47fcde/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309cc8ba50fc5d2174189dfcd49cdf7aa711f8346afcff19f2642ae4fc449c14", size = 3277555, upload-time = "2026-08-11T21:05:47.932Z" }, + { url = "https://files.pythonhosted.org/packages/50/69/ce6776724511d1b5dd40477b08d6a5f0953a45375e092dfa852b1857732c/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2f9eccf8793c8c3f8dd2dfd11b9e400cb27d1d19370ef732b66017e212107822", size = 3231246, upload-time = "2026-08-11T21:00:05.184Z" }, + { url = "https://files.pythonhosted.org/packages/72/19/ab0cb9ccdafa2419c796ae62f8740aedb903f1e93bb326064b1a0147e458/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9255ceb65a80c1b001129060b63ee776a2e9c288be3b662be36dfbb888fffdcd", size = 3250763, upload-time = "2026-08-11T21:05:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/f2/3c9b54b61bec4f493c0007dc9e2700c963c5b32667e21a506f1aca7a8115/sqlalchemy-2.0.52-cp310-cp310-win32.whl", hash = "sha256:2e15b1d1116a64fc399b8c2694a83f3e792fdc58df28514a81e1dc4f8cf22729", size = 2132169, upload-time = "2026-08-11T21:09:48.108Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a1/934bb6cf543a398c72784d1fc777eb530559c16ebe1549fa7611e5989ce9/sqlalchemy-2.0.52-cp310-cp310-win_amd64.whl", hash = "sha256:11560064cc4696e772298b6221ede59e646386d9f2a85d549365473b972f7850", size = 2156289, upload-time = "2026-08-11T21:09:49.429Z" }, + { url = "https://files.pythonhosted.org/packages/6b/08/cc5f7627b92f1456bc0b5fb7e98af4600248abe422a44da0d17a3fe6a448/sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c", size = 2172460, upload-time = "2026-08-11T20:58:22.429Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/9a2abad8bfc8fdcd38c64adc056aeefab7aaa96ecd32f5e8c140e6375f17/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608", size = 3355720, upload-time = "2026-08-11T21:00:06.746Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/e75597b5841043e3c74055d00d4feb53d9a49a5c89ba2450d2d9aab53597/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1", size = 3354394, upload-time = "2026-08-11T21:05:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/410fbc6c2f1fa8310f4ef1b6847d47d0ac1c042c7b4e81eaaca063d030a9/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43", size = 3306991, upload-time = "2026-08-11T21:00:08.603Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ba/25ffd5c24681ea4b46e62c80ceca8200ce204de1773366321306cf3f608a/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736", size = 3327454, upload-time = "2026-08-11T21:05:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f1/0f1b1d4800e51218e736a06ed55a3b2a59c257600bbaca7673bf13d2dbec/sqlalchemy-2.0.52-cp311-cp311-win32.whl", hash = "sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72", size = 2131248, upload-time = "2026-08-11T21:09:50.765Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/04d2ac5ad66f3d31278f37064ed5f5ef3fe653f7bdaa67036663f223d186/sqlalchemy-2.0.52-cp311-cp311-win_amd64.whl", hash = "sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc", size = 2156943, upload-time = "2026-08-11T21:09:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" }, + { url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" }, + { url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + +[[package]] +name = "tap-uk-ees-destinations" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "requests" }, + { name = "singer-sdk" }, +] + +[package.metadata] +requires-dist = [ + { name = "requests", specifier = ">=2.31" }, + { name = "singer-sdk", specifier = "~=0.53" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "universal-pathlib" +version = "0.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec" }, + { name = "pathlib-abc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/6e/d997a70ee8f4c61f9a7e2f4f8af721cf072a3326848fc881b05187e52558/universal_pathlib-0.3.10.tar.gz", hash = "sha256:4487cbc90730a48cfb64f811d99e14b6faed6d738420cd5f93f59f48e6930bfb", size = 261110, upload-time = "2026-02-22T14:40:58.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/1a/5d9a402b39ec892d856bbdd9db502ff73ce28cdf4aff72eb1ce1d6843506/universal_pathlib-0.3.10-py3-none-any.whl", hash = "sha256:dfaf2fb35683d2eb1287a3ed7b215e4d6016aa6eaf339c607023d22f90821c66", size = 83528, upload-time = "2026-02-22T14:40:57.316Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] -- 2.54.0 From c5645664327ff1ab7f7abcdfe4b647bf582a7e39 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 16:07:17 +0100 Subject: [PATCH 06/15] feat(destinations): staging models that keep 'withheld' distinct from 'absent' safe_numeric maps every EES sentinel to NULL, which is right for attainment and wrong here: one of those states has to print 'withheld' and the other has to print nothing. A status column carries the difference. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- .../transform/models/staging/_stg_sources.yml | 14 ++++++ .../staging/stg_ees_ks4_destinations.sql | 46 ++++++++++++++++++ .../staging/stg_ees_ks5_destinations.sql | 47 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 pipeline/transform/models/staging/stg_ees_ks4_destinations.sql create mode 100644 pipeline/transform/models/staging/stg_ees_ks5_destinations.sql diff --git a/pipeline/transform/models/staging/_stg_sources.yml b/pipeline/transform/models/staging/_stg_sources.yml index 6b294c4..564927b 100644 --- a/pipeline/transform/models/staging/_stg_sources.yml +++ b/pipeline/transform/models/staging/_stg_sources.yml @@ -33,6 +33,20 @@ sources: - name: legacy_ks2 description: Pre-COVID KS2 school-level data (2015-16 to 2018-19) from DfE performance tables + - name: ees_ks4_destinations + description: > + KS4 leavers destinations, long format — one row per URN x year x + pupil group x destination measure, plus England rows with a null urn. + pupils_raw and percentage_raw are TEXT and may carry the 'c' + suppression sentinel; they must never be passed through safe_numeric, + because the categories sum to the cohort and collapsing 'c' to null + lets a downstream sum recover a withheld figure. + + - name: ees_ks5_destinations + description: > + 16-18 study leavers destinations. Same grain, same suppression + caveat, and only institutions with post-16 provision appear. + - name: ees_ks4_performance description: KS4 performance tables (long format — one row per school × breakdown × sex) diff --git a/pipeline/transform/models/staging/stg_ees_ks4_destinations.sql b/pipeline/transform/models/staging/stg_ees_ks4_destinations.sql new file mode 100644 index 0000000..95d1ca4 --- /dev/null +++ b/pipeline/transform/models/staging/stg_ees_ks4_destinations.sql @@ -0,0 +1,46 @@ +{{ config(materialized='table') }} + +-- Staging model: KS4 leavers destinations, school level plus the England +-- reference (which carries a null urn). +-- +-- DELIBERATELY DOES NOT USE safe_numeric. That macro maps every EES sentinel +-- (z, c, x, q, u) to NULL, which is right for attainment — there, "suppressed" +-- and "not applicable" are equally unrenderable. Here they are different +-- claims: one has to print "withheld", the other has to print nothing, and +-- collapsing them would also let a downstream sum reconstruct a withheld +-- figure, because the destination categories add up to the cohort. +-- +-- See docs/superpowers/specs/2026-08-28-destination-measures-design.md. + +with source as ( + select * from {{ source('raw', 'ees_ks4_destinations') }} + -- National rows carry a null urn and feed fact_destination_national. + where (urn is null or urn = '' or urn ~ '^[0-9]+$') + and time_period ~ '^[0-9]+$' +) + +select + case when urn ~ '^[0-9]+$' then cast(trim(urn) as integer) end as urn, + cast(trim(time_period) as integer) as year, + trim(pupil_group) as pupil_group, + trim(destination_measure) as destination_measure, + + case when cohort_pupils ~ '^[0-9]+$' + then cast(cohort_pupils as integer) end as cohort_pupils, + + case when pupils_raw ~ '^[0-9]+$' + then cast(pupils_raw as integer) end as pupils, + + case when percentage_raw ~ '^-?[0-9]+(\.[0-9]+)?$' + then cast(percentage_raw as numeric) end as percentage, + + -- The whole point of this model. 'suppressed' is DfE withholding a figure + -- it considers disclosive; 'not_applicable' is the measure not applying to + -- this row at all. The API and the frontend treat them differently. + case + when pupils_raw ~ '^[0-9]+$' then 'published' + when lower(trim(pupils_raw)) = 'c' then 'suppressed' + else 'not_applicable' + end as status + +from source diff --git a/pipeline/transform/models/staging/stg_ees_ks5_destinations.sql b/pipeline/transform/models/staging/stg_ees_ks5_destinations.sql new file mode 100644 index 0000000..bce4202 --- /dev/null +++ b/pipeline/transform/models/staging/stg_ees_ks5_destinations.sql @@ -0,0 +1,47 @@ +{{ config(materialized='table') }} + +-- Staging model: 16-18 study leavers destinations, institution level plus the +-- England reference (which carries a null urn). Only sixth forms and colleges +-- appear here, so a secondary with no post-16 provision has no rows at all. +-- +-- DELIBERATELY DOES NOT USE safe_numeric. That macro maps every EES sentinel +-- (z, c, x, q, u) to NULL, which is right for attainment — there, "suppressed" +-- and "not applicable" are equally unrenderable. Here they are different +-- claims: one has to print "withheld", the other has to print nothing, and +-- collapsing them would also let a downstream sum reconstruct a withheld +-- figure, because the destination categories add up to the cohort. +-- +-- See docs/superpowers/specs/2026-08-28-destination-measures-design.md. + +with source as ( + select * from {{ source('raw', 'ees_ks5_destinations') }} + -- National rows carry a null urn and feed fact_destination_national. + where (urn is null or urn = '' or urn ~ '^[0-9]+$') + and time_period ~ '^[0-9]+$' +) + +select + case when urn ~ '^[0-9]+$' then cast(trim(urn) as integer) end as urn, + cast(trim(time_period) as integer) as year, + trim(pupil_group) as pupil_group, + trim(destination_measure) as destination_measure, + + case when cohort_pupils ~ '^[0-9]+$' + then cast(cohort_pupils as integer) end as cohort_pupils, + + case when pupils_raw ~ '^[0-9]+$' + then cast(pupils_raw as integer) end as pupils, + + case when percentage_raw ~ '^-?[0-9]+(\.[0-9]+)?$' + then cast(percentage_raw as numeric) end as percentage, + + -- The whole point of this model. 'suppressed' is DfE withholding a figure + -- it considers disclosive; 'not_applicable' is the measure not applying to + -- this row at all. The API and the frontend treat them differently. + case + when pupils_raw ~ '^[0-9]+$' then 'published' + when lower(trim(pupils_raw)) = 'c' then 'suppressed' + else 'not_applicable' + end as status + +from source -- 2.54.0 From 5e5b61987a44157122497150716d4bf49f55fa40 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 16:08:20 +0100 Subject: [PATCH 07/15] feat(destinations): marts, with R3 masking applied at the boundary Disadvantaged and other-pupils partition the whole and the all-pupils figure is published, so publishing both halves recovers the suppressed one. The mask is applied in the mart rather than the API so no consumer added later can reach an unmasked combination. The R1 test is a warn, not an error: DfE publishes the recoverable combination and the mart's job is to carry it faithfully. Refusing to close the gap is the API's job and the frontend's. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- .../transform/models/marts/_marts_schema.yml | 68 +++++++++++++++++++ .../marts/fact_destination_national.sql | 40 +++++++++++ .../models/marts/fact_ks4_destinations.sql | 50 ++++++++++++++ .../models/marts/fact_ks5_destinations.sql | 50 ++++++++++++++ ...sert_destination_status_null_agreement.sql | 15 ++++ .../assert_destinations_group_masking.sql | 20 ++++++ ...sert_destinations_no_derived_remainder.sql | 24 +++++++ 7 files changed, 267 insertions(+) create mode 100644 pipeline/transform/models/marts/fact_destination_national.sql create mode 100644 pipeline/transform/models/marts/fact_ks4_destinations.sql create mode 100644 pipeline/transform/models/marts/fact_ks5_destinations.sql create mode 100644 pipeline/transform/tests/assert_destination_status_null_agreement.sql create mode 100644 pipeline/transform/tests/assert_destinations_group_masking.sql create mode 100644 pipeline/transform/tests/assert_destinations_no_derived_remainder.sql diff --git a/pipeline/transform/models/marts/_marts_schema.yml b/pipeline/transform/models/marts/_marts_schema.yml index 1aa86c1..89f2301 100644 --- a/pipeline/transform/models/marts/_marts_schema.yml +++ b/pipeline/transform/models/marts/_marts_schema.yml @@ -180,6 +180,74 @@ models: - name: year tests: [not_null] + - name: fact_ks4_destinations + description: > + KS4 leavers destinations — one row per URN, year, pupil group and + destination measure. status distinguishes a figure DfE withheld + ('suppressed') from one that does not apply ('not_applicable'); pupils is + null for both, so status is the only thing that tells them apart. R3 + masking is applied here: a category withheld for disadvantaged pupils is + withheld for the other-pupils group too. + columns: + - name: urn + tests: [not_null] + - name: year + tests: [not_null] + - name: pupil_group + tests: + - not_null + - accepted_values: + values: ['all', 'disadvantaged', 'other'] + - name: destination_measure + tests: [not_null] + - name: status + tests: + - not_null + - accepted_values: + values: ['published', 'suppressed', 'not_applicable'] + + - name: fact_ks5_destinations + description: > + 16-18 study leavers destinations — same grain and same suppression + semantics as fact_ks4_destinations. Only institutions with post-16 + provision appear. + columns: + - name: urn + tests: [not_null] + - name: year + tests: [not_null] + - name: pupil_group + tests: + - not_null + - accepted_values: + values: ['all', 'disadvantaged', 'other'] + - name: destination_measure + tests: [not_null] + - name: status + tests: + - not_null + - accepted_values: + values: ['published', 'suppressed', 'not_applicable'] + + - name: fact_destination_national + description: > + England destination measures by phase and pupil group — the national + reference the school sections compare against. Repointed with the cohort + switch so a school's disadvantaged pupils are measured against national + disadvantaged pupils, not against the national average. + columns: + - name: phase + tests: + - not_null + - accepted_values: + values: ['ks4', 'ks5'] + - name: year + tests: [not_null] + - name: pupil_group + tests: [not_null] + - name: destination_measure + tests: [not_null] + - name: fact_ks2_national_averages description: Official DfE KS2 national headline averages — one row per academic year columns: diff --git a/pipeline/transform/models/marts/fact_destination_national.sql b/pipeline/transform/models/marts/fact_destination_national.sql new file mode 100644 index 0000000..811078f --- /dev/null +++ b/pipeline/transform/models/marts/fact_destination_national.sql @@ -0,0 +1,40 @@ +{{ config(materialized='table') }} + +-- Mart: England destination measures by pupil group — the page's national +-- reference. +-- +-- Kept separate from the school facts so the section's England bar can repoint +-- with the cohort switch. Comparing a school's disadvantaged pupils against +-- the national all-pupils figure would flatter or damn the school for its +-- intake rather than its work, which is the failure this whole feature is +-- meant to avoid. +-- +-- The tap pins establishment type to Total within the state-funded mainstream +-- group for these rows, so the parts reconcile: for 2022/23 KS4, 151,912 +-- disadvantaged + 441,823 other = 593,735 total. + +select + 'ks4' as phase, + year, + pupil_group, + destination_measure, + cohort_pupils, + pupils, + percentage, + status +from {{ ref('stg_ees_ks4_destinations') }} +where urn is null + +union all + +select + 'ks5' as phase, + year, + pupil_group, + destination_measure, + cohort_pupils, + pupils, + percentage, + status +from {{ ref('stg_ees_ks5_destinations') }} +where urn is null diff --git a/pipeline/transform/models/marts/fact_ks4_destinations.sql b/pipeline/transform/models/marts/fact_ks4_destinations.sql new file mode 100644 index 0000000..cf03b7c --- /dev/null +++ b/pipeline/transform/models/marts/fact_ks4_destinations.sql @@ -0,0 +1,50 @@ +{{ config(materialized='table') }} + +-- Mart: KS4 leavers destinations — one row per URN x year x pupil group x +-- destination measure. +-- +-- Long format, unlike the wide fact_ks4_performance next door. pupil_group is +-- a real third dimension, so going wide would need three sets of every column, +-- and the disclosure tests are far easier to write over rows than columns. +-- +-- R3 IS APPLIED HERE rather than in the API. Disadvantaged and other-pupils +-- partition the whole, and the all-pupils figure is published — so publishing +-- both halves recovers the suppressed one by subtraction. Where a category is +-- withheld for disadvantaged pupils it is therefore withheld for the other +-- group too, and the all-pupils view keeps it. DfE already does this in 493 of +-- 498 cases; this closes the remaining 5 so that no consumer, now or later, +-- can reach an unmasked combination. +-- +-- See docs/superpowers/specs/2026-08-28-destination-measures-design.md. + +with staged as ( + select s.* + from {{ ref('stg_ees_ks4_destinations') }} s + inner join {{ ref('dim_school') }} d on d.urn = s.urn +), + +-- Categories withheld for disadvantaged pupils at this school and year. +masked as ( + select distinct urn, year, destination_measure + from staged + where pupil_group = 'disadvantaged' + and status = 'suppressed' +) + +select + s.urn, + s.year, + s.pupil_group, + s.destination_measure, + s.cohort_pupils, + case when m.urn is not null and s.pupil_group = 'other' + then null else s.pupils end as pupils, + case when m.urn is not null and s.pupil_group = 'other' + then null else s.percentage end as percentage, + case when m.urn is not null and s.pupil_group = 'other' + then 'suppressed' else s.status end as status +from staged s +left join masked m + on m.urn = s.urn + and m.year = s.year + and m.destination_measure = s.destination_measure diff --git a/pipeline/transform/models/marts/fact_ks5_destinations.sql b/pipeline/transform/models/marts/fact_ks5_destinations.sql new file mode 100644 index 0000000..59cbef7 --- /dev/null +++ b/pipeline/transform/models/marts/fact_ks5_destinations.sql @@ -0,0 +1,50 @@ +{{ config(materialized='table') }} + +-- Mart: 16-18 study leavers destinations — one row per URN x year x pupil group x +-- destination measure. +-- +-- Long format, unlike the wide fact_ks4_performance next door. pupil_group is +-- a real third dimension, so going wide would need three sets of every column, +-- and the disclosure tests are far easier to write over rows than columns. +-- +-- R3 IS APPLIED HERE rather than in the API. Disadvantaged and other-pupils +-- partition the whole, and the all-pupils figure is published — so publishing +-- both halves recovers the suppressed one by subtraction. Where a category is +-- withheld for disadvantaged pupils it is therefore withheld for the other +-- group too, and the all-pupils view keeps it. DfE already does this in 493 of +-- 498 cases; this closes the remaining 5 so that no consumer, now or later, +-- can reach an unmasked combination. +-- +-- See docs/superpowers/specs/2026-08-28-destination-measures-design.md. + +with staged as ( + select s.* + from {{ ref('stg_ees_ks5_destinations') }} s + inner join {{ ref('dim_school') }} d on d.urn = s.urn +), + +-- Categories withheld for disadvantaged pupils at this school and year. +masked as ( + select distinct urn, year, destination_measure + from staged + where pupil_group = 'disadvantaged' + and status = 'suppressed' +) + +select + s.urn, + s.year, + s.pupil_group, + s.destination_measure, + s.cohort_pupils, + case when m.urn is not null and s.pupil_group = 'other' + then null else s.pupils end as pupils, + case when m.urn is not null and s.pupil_group = 'other' + then null else s.percentage end as percentage, + case when m.urn is not null and s.pupil_group = 'other' + then 'suppressed' else s.status end as status +from staged s +left join masked m + on m.urn = s.urn + and m.year = s.year + and m.destination_measure = s.destination_measure diff --git a/pipeline/transform/tests/assert_destination_status_null_agreement.sql b/pipeline/transform/tests/assert_destination_status_null_agreement.sql new file mode 100644 index 0000000..ec5ffda --- /dev/null +++ b/pipeline/transform/tests/assert_destination_status_null_agreement.sql @@ -0,0 +1,15 @@ +-- pupils must be null wherever status is not 'published', and never null where +-- it is. This is what stops a later coalesce, or a wide-format refactor, +-- turning a withheld figure into a zero — which would read on the page as +-- "no pupils went here" rather than "we are not told". + +select + urn, + year, + pupil_group, + destination_measure, + status, + pupils +from {{ ref('fact_ks4_destinations') }} +where (status <> 'published' and pupils is not null) + or (status = 'published' and pupils is null) diff --git a/pipeline/transform/tests/assert_destinations_group_masking.sql b/pipeline/transform/tests/assert_destinations_group_masking.sql new file mode 100644 index 0000000..586c4ed --- /dev/null +++ b/pipeline/transform/tests/assert_destinations_group_masking.sql @@ -0,0 +1,20 @@ +-- R3 GUARD. Fails if a category is suppressed for disadvantaged pupils but +-- still published for the other-pupils group. +-- +-- The two groups partition the cohort and the all-pupils figure is published, +-- so publishing both halves recovers the withheld one. fact_ks4_destinations +-- masks the other group to prevent it; this asserts the masking actually held. + +select + d.urn, + d.year, + d.destination_measure +from {{ ref('fact_ks4_destinations') }} d +inner join {{ ref('fact_ks4_destinations') }} o + on o.urn = d.urn + and o.year = d.year + and o.destination_measure = d.destination_measure + and o.pupil_group = 'other' +where d.pupil_group = 'disadvantaged' + and d.status = 'suppressed' + and o.status = 'published' diff --git a/pipeline/transform/tests/assert_destinations_no_derived_remainder.sql b/pipeline/transform/tests/assert_destinations_no_derived_remainder.sql new file mode 100644 index 0000000..c8f53f7 --- /dev/null +++ b/pipeline/transform/tests/assert_destinations_no_derived_remainder.sql @@ -0,0 +1,24 @@ +{{ config(severity='warn') }} + +-- R1 TRIPWIRE. Flags a school/year/pupil group that has exactly one suppressed +-- category while its cohort total is published — the combination that lets the +-- withheld figure be recovered by subtracting the published categories. +-- +-- This is a WARN, not an error, and the distinction matters. The mart is not +-- wrong: DfE publishes exactly this, and the mart's job is to carry the source +-- faithfully. What must refuse to close the gap is everything downstream — the +-- API serialiser and lib/destinations.ts, which have their own tests. This +-- query exists so the condition stays visible and counted, and so anyone +-- adding a consumer later has to look at it rather than discover it. +-- +-- Expect a non-trivial count: measured at 22% of mainstream secondaries. + +select + urn, + year, + pupil_group, + count(*) filter (where status = 'suppressed') as suppressed_categories +from {{ ref('fact_ks4_destinations') }} +where destination_measure not like 'agg\_%' escape '\' +group by urn, year, pupil_group +having count(*) filter (where status = 'suppressed') = 1 -- 2.54.0 From c5719ef362ee602d2e4f3dbb46f8f7c0ce3ceb62 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 16:10:29 +0100 Subject: [PATCH 08/15] feat(destinations): serve destinations without closing the gaps The serialiser carries status through and computes no totals of its own. The only aggregates in the payload are ones DfE published itself; whether showing one is safe depends on how many of its components are suppressed, which the frontend decides. The batch guard grows from six tables to eight. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- backend/app.py | 1 + backend/data_loader.py | 101 +++++++++++++++++++ backend/models.py | 45 +++++++++ backend/tests/test_destinations_api.py | 112 ++++++++++++++++++++++ backend/tests/test_supplementary_batch.py | 11 ++- 5 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_destinations_api.py diff --git a/backend/app.py b/backend/app.py index a513aea..7836ca7 100644 --- a/backend/app.py +++ b/backend/app.py @@ -919,6 +919,7 @@ async def get_school_details(request: Request, urn: int): "phonics": supplementary.get("phonics"), "deprivation": supplementary.get("deprivation"), "finance": supplementary.get("finance"), + "destinations": supplementary.get("destinations"), } diff --git a/backend/data_loader.py b/backend/data_loader.py index 8aff4df..a176fe4 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -20,6 +20,7 @@ from .models import ( DimSchool, DimLocation, KS2Performance, FactOfstedInspection, FactAdmissions, FactAdmissionDistance, FactDeprivation, FactFinance, FactPupilCharacteristics, + FactKs4Destinations, FactKs5Destinations, ) from .ofsted_codes import ofsted_page_url, report_card_labels from .schemas import SCHOOL_TYPE_MAP @@ -816,6 +817,73 @@ def _finance_dict(f) -> dict: } +# Destination measures that are totals DfE published itself, rather than one of +# the categories that partition the cohort. +_AGGREGATE_MEASURES = {"agg_sustained_education", "agg_sustained_all"} + + +def _format_cohort_year(year) -> str | None: + """202223 -> '2022/23'. + + The section has to date its own cohort. Destination measures run about two + GCSE years behind the results shown above them on the same page, so an + undated figure reads as stale data rather than as a different question. + """ + if not year: + return None + text = str(year) + if len(text) == 6: + return f"{text[:4]}/{text[4:6]}" + if len(text) == 8: + return f"{text[:4]}/{text[6:8]}" + return text + + +def _destinations_block(rows: list) -> dict | None: + """Shape destination rows for one phase into the API's block. + + Carries `status` through untouched and emits no computed totals. The only + aggregates present are ones DfE published itself; whether showing one is + safe depends on how many of its components are suppressed, which the + frontend decides (lib/destinations.ts, rule R2). + + Deliberately does NOT compute a residual, a "remaining pupils" figure, or + any total that would close a gap left by a suppressed category — the + categories sum to the cohort, so such a figure names the withheld cell. + """ + if not rows: + return None + + years = [r["year"] for r in rows if r.get("year") is not None] + if not years: + return None + latest_year = max(years) + rows = [r for r in rows if r.get("year") == latest_year] + + groups: dict = {} + for row in rows: + group = groups.setdefault( + row["pupil_group"], + {"cohort": row.get("cohort_pupils"), "categories": [], "aggregates": {}}, + ) + measure = row["destination_measure"] + cell = { + "category": measure, + "pupils": row.get("pupils"), + "percentage": row.get("percentage"), + "status": row.get("status"), + } + if measure in _AGGREGATE_MEASURES: + group["aggregates"][measure[len("agg_"):]] = cell + else: + group["categories"].append(cell) + + if not groups: + return None + + return {"cohort_year": _format_cohort_year(latest_year), "groups": groups} + + def _empty_supplementary() -> dict: return { "ofsted": None, @@ -827,6 +895,7 @@ def _empty_supplementary() -> dict: "phonics": None, "deprivation": None, "finance": None, + "destinations": None, } @@ -954,6 +1023,38 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict: result[f.urn]["finance"] = _finance_dict(f) _safe(_finance) + # Destinations — KS4 and 16-18. Both marts are long-format, so every row + # for a URN is collected and _destinations_block picks the latest year and + # shapes the pupil groups. A phase with no rows serialises as null rather + # than an empty shell, so the frontend renders nothing rather than an empty + # section. + def _destinations(): + from collections import defaultdict + + def _collect(model): + per_urn = defaultdict(list) + for r in db.query(model).filter(model.urn.in_(urns)).all(): + per_urn[r.urn].append({ + "year": r.year, + "pupil_group": r.pupil_group, + "destination_measure": r.destination_measure, + "cohort_pupils": r.cohort_pupils, + "pupils": r.pupils, + "percentage": r.percentage, + "status": r.status, + }) + return per_urn + + ks4_rows = _collect(FactKs4Destinations) + ks5_rows = _collect(FactKs5Destinations) + for urn in urns: + ks4 = _destinations_block(ks4_rows.get(urn, [])) + ks5 = _destinations_block(ks5_rows.get(urn, [])) + result[urn]["destinations"] = ( + {"ks4": ks4, "ks5": ks5} if (ks4 or ks5) else None + ) + _safe(_destinations) + return result diff --git a/backend/models.py b/backend/models.py index b68b342..d090d8f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -321,3 +321,48 @@ class Ks2NationalAverage(Base): gps_high_pct = Column(Float) gps_avg_score = Column(Float) science_expected_pct = Column(Float) + + +class FactKs4Destinations(Base): + """KS4 leavers destinations — one row per URN, year, pupil group, measure. + + Long format rather than wide because pupil_group is a real third dimension. + `status` is load-bearing: 'suppressed' means DfE withheld a figure it + considered disclosive and the page must print "withheld"; 'not_applicable' + means the measure does not apply and the page must print nothing. `pupils` + is null for both, so collapsing status to a null check loses the + difference — and the categories sum to the cohort, so a consumer that + treats a withheld cell as zero republishes what DfE hid. + """ + __tablename__ = "fact_ks4_destinations" + __table_args__ = ( + Index("ix_ks4_dest_urn_year", "urn", "year"), + MARTS, + ) + + urn = Column(Integer, primary_key=True) + year = Column(Integer, primary_key=True) + pupil_group = Column(String(20), primary_key=True) + destination_measure = Column(String(40), primary_key=True) + cohort_pupils = Column(Integer) + pupils = Column(Integer) + percentage = Column(Float) + status = Column(String(20)) + + +class FactKs5Destinations(Base): + """16-18 study leavers destinations — same grain as FactKs4Destinations.""" + __tablename__ = "fact_ks5_destinations" + __table_args__ = ( + Index("ix_ks5_dest_urn_year", "urn", "year"), + MARTS, + ) + + urn = Column(Integer, primary_key=True) + year = Column(Integer, primary_key=True) + pupil_group = Column(String(20), primary_key=True) + destination_measure = Column(String(40), primary_key=True) + cohort_pupils = Column(Integer) + pupils = Column(Integer) + percentage = Column(Float) + status = Column(String(20)) diff --git a/backend/tests/test_destinations_api.py b/backend/tests/test_destinations_api.py new file mode 100644 index 0000000..a2a2cd9 --- /dev/null +++ b/backend/tests/test_destinations_api.py @@ -0,0 +1,112 @@ +"""The destinations serialiser's contract: it carries suppression through, and +never emits a total that closes a gap left by a suppressed category. + +The destination categories sum to the cohort, so an aggregate that happens to +equal the residual names the withheld figure exactly. See +docs/superpowers/specs/2026-08-28-destination-measures-design.md. +""" + +from backend.data_loader import _destinations_block, _format_cohort_year + + +def _row(group, measure, pupils, status, cohort=180, percentage=None, year=202223): + return { + "pupil_group": group, + "destination_measure": measure, + "pupils": pupils, + "percentage": percentage, + "status": status, + "cohort_pupils": cohort, + "year": year, + } + + +def test_suppressed_category_serialises_as_suppressed_with_null_pupils(): + rows = [ + _row("all", "school_sixth_form", 75, "published", percentage=41.7), + _row("all", "sixth_form_college", None, "suppressed"), + ] + block = _destinations_block(rows) + cats = {c["category"]: c for c in block["groups"]["all"]["categories"]} + assert cats["sixth_form_college"]["status"] == "suppressed" + assert cats["sixth_form_college"]["pupils"] is None + assert cats["sixth_form_college"]["percentage"] is None + + +def test_published_category_keeps_its_figures(): + block = _destinations_block([ + _row("all", "school_sixth_form", 75, "published", percentage=41.7), + ]) + cat = block["groups"]["all"]["categories"][0] + assert cat["pupils"] == 75 + assert cat["percentage"] == 41.7 + assert cat["status"] == "published" + + +def test_no_closing_total_is_emitted_for_a_partially_suppressed_group(): + rows = [ + _row("all", "school_sixth_form", 75, "published", percentage=41.7), + _row("all", "sixth_form_college", None, "suppressed"), + _row("all", "further_education", 61, "published", percentage=33.9), + _row("all", "apprenticeship", 8, "published", percentage=4.4), + _row("all", "employment", 6, "published", percentage=3.3), + _row("all", "not_sustained", 5, "published", percentage=2.8), + _row("all", "not_captured", 4, "published", percentage=2.2), + ] + block = _destinations_block(rows) + group = block["groups"]["all"] + published = sum(c["pupils"] for c in group["categories"] if c["pupils"] is not None) + residual = group["cohort"] - published + for value in group["aggregates"].values(): + if value is None or value.get("pupils") is None: + continue + assert value["pupils"] != residual, ( + "an aggregate equal to the residual identifies the suppressed cell" + ) + + +def test_aggregates_are_separated_from_categories(): + rows = [ + _row("all", "school_sixth_form", 75, "published", percentage=41.7), + _row("all", "agg_sustained_all", 171, "published", percentage=95.0), + ] + block = _destinations_block(rows) + group = block["groups"]["all"] + assert [c["category"] for c in group["categories"]] == ["school_sixth_form"] + assert group["aggregates"]["sustained_all"]["pupils"] == 171 + + +def test_only_the_latest_year_is_served(): + rows = [ + _row("all", "school_sixth_form", 60, "published", year=202122), + _row("all", "school_sixth_form", 75, "published", year=202223), + ] + block = _destinations_block(rows) + assert block["cohort_year"] == "2022/23" + assert len(block["groups"]["all"]["categories"]) == 1 + assert block["groups"]["all"]["categories"][0]["pupils"] == 75 + + +def test_all_three_pupil_groups_are_carried(): + rows = [ + _row("all", "school_sixth_form", 75, "published"), + _row("disadvantaged", "school_sixth_form", 17, "published", cohort=62), + _row("other", "school_sixth_form", 58, "published", cohort=118), + ] + block = _destinations_block(rows) + assert set(block["groups"]) == {"all", "disadvantaged", "other"} + assert block["groups"]["disadvantaged"]["cohort"] == 62 + + +def test_cohort_year_is_reported_so_the_page_can_date_itself(): + block = _destinations_block([_row("all", "school_sixth_form", 75, "published")]) + assert block["cohort_year"] == "2022/23" + + +def test_format_cohort_year_handles_the_six_digit_form(): + assert _format_cohort_year(202223) == "2022/23" + assert _format_cohort_year(None) is None + + +def test_empty_rows_yield_none_not_an_empty_shell(): + assert _destinations_block([]) is None diff --git a/backend/tests/test_supplementary_batch.py b/backend/tests/test_supplementary_batch.py index 5fb6cdd..7813e7f 100644 --- a/backend/tests/test_supplementary_batch.py +++ b/backend/tests/test_supplementary_batch.py @@ -132,16 +132,23 @@ def test_one_query_per_table_and_latest_row_per_urn(): "FactPupilCharacteristics": [], "FactDeprivation": [], "FactFinance": [], + "FactKs4Destinations": [], + "FactKs5Destinations": [], } session = _FakeSession(rows) out = get_supplementary_data_batch(session, [1, 2]) - # Exactly one query per table — six total, regardless of two URNs. + # Exactly one query per table — eight total, regardless of two URNs. assert sorted(session.queries) == [ "FactAdmissionDistance", "FactAdmissions", "FactDeprivation", - "FactFinance", "FactOfstedInspection", "FactPupilCharacteristics", + "FactFinance", "FactKs4Destinations", "FactKs5Destinations", + "FactOfstedInspection", "FactPupilCharacteristics", ] + # A school with no destination rows gets null, not an empty shell — the + # frontend renders the section from the block's presence. + assert out[1]["destinations"] is None + # Latest Ofsted kept per URN assert out[1]["ofsted"]["overall_effectiveness"] == 2 assert out[2]["ofsted"]["overall_effectiveness"] == 1 -- 2.54.0 From 68b6417149600f0d2023c1684927958a86fb21d6 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 16:11:37 +0100 Subject: [PATCH 09/15] feat(destinations): types and secondary section flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Destinations are secondary-only, so the flags go on computeSecondaryFlags rather than computeSchoolFlags. A phase counts as present only when some pupil group carries categories — an empty block would otherwise open a nav entry pointing at a section that never renders. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- .../lib/schoolSections.destinations.test.ts | 84 +++++++++++++++++++ nextjs-app/lib/schoolSections.ts | 24 +++++- nextjs-app/lib/types.ts | 27 ++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 nextjs-app/__tests__/lib/schoolSections.destinations.test.ts diff --git a/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts b/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts new file mode 100644 index 0000000..670ec07 --- /dev/null +++ b/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts @@ -0,0 +1,84 @@ +import { computeSecondaryFlags, buildSecondaryNavItems } from '@/lib/schoolSections'; +import type { School, SchoolDestinations } from '@/lib/types'; + +const schoolInfo = { + urn: 137083, school_name: 'Northbrook Academy', phase: 'Secondary', + has_sixth_form: true, +} as unknown as School; + +const base = { schoolInfo, yearlyData: [], deprivation: null, finance: null }; + +const phase = (categories = 1) => ({ + cohort_year: '2022/23', + groups: { + all: { + cohort: 180, + categories: Array.from({ length: categories }, () => ({ + category: 'school_sixth_form' as const, + pupils: 75, percentage: 41.7, status: 'published' as const, + })), + aggregates: {}, + }, + }, +}); + +const ks4Only: SchoolDestinations = { ks4: phase(), ks5: null }; +const both: SchoolDestinations = { ks4: phase(), ks5: phase() }; + +describe('computeSecondaryFlags — destinations', () => { + it('flags KS4 destinations when the block carries categories', () => { + const flags = computeSecondaryFlags({ ...base, destinations: ks4Only }); + expect(flags.hasKs4Destinations).toBe(true); + expect(flags.hasKs5Destinations).toBe(false); + }); + + it('flags both phases when both are present', () => { + const flags = computeSecondaryFlags({ ...base, destinations: both }); + expect(flags.hasKs4Destinations).toBe(true); + expect(flags.hasKs5Destinations).toBe(true); + }); + + it('flags neither when the block is absent', () => { + const flags = computeSecondaryFlags({ ...base, destinations: null }); + expect(flags.hasKs4Destinations).toBe(false); + expect(flags.hasKs5Destinations).toBe(false); + }); + + it('does not flag a phase whose groups carry no categories', () => { + const empty: SchoolDestinations = { + ks4: { cohort_year: '2022/23', groups: {} }, ks5: null, + }; + expect(computeSecondaryFlags({ ...base, destinations: empty }).hasKs4Destinations) + .toBe(false); + }); + + it('does not flag a phase whose only group has an empty category list', () => { + const empty: SchoolDestinations = { ks4: phase(0), ks5: null }; + expect(computeSecondaryFlags({ ...base, destinations: empty }).hasKs4Destinations) + .toBe(false); + }); +}); + +describe('buildSecondaryNavItems — destinations', () => { + const navInput = { + ofsted: null, admissions: null, admissionDistance: null, + hasLocation: false, yearlyDataLength: 0, + }; + + it('adds both entries, after GCSEs', () => { + const flags = computeSecondaryFlags({ ...base, destinations: both }); + const ids = buildSecondaryNavItems({ ...flags, hasResults: true }, navInput) + .map(i => i.id); + expect(ids).toContain('destinations'); + expect(ids).toContain('post16-destinations'); + expect(ids.indexOf('destinations')).toBeGreaterThan(ids.indexOf('gcse')); + expect(ids.indexOf('post16-destinations')).toBe(ids.indexOf('destinations') + 1); + }); + + it('adds no entry for a phase that will not render — the nav must not link to a missing anchor', () => { + const flags = computeSecondaryFlags({ ...base, destinations: null }); + const ids = buildSecondaryNavItems(flags, navInput).map(i => i.id); + expect(ids).not.toContain('destinations'); + expect(ids).not.toContain('post16-destinations'); + }); +}); diff --git a/nextjs-app/lib/schoolSections.ts b/nextjs-app/lib/schoolSections.ts index de7b9c4..eeec922 100644 --- a/nextjs-app/lib/schoolSections.ts +++ b/nextjs-app/lib/schoolSections.ts @@ -10,6 +10,7 @@ import type { School, SchoolResult, AbsenceData, SchoolCensus, OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, + SchoolDestinations, DestinationPhase, } from './types'; import { isSpecialSchool } from './utils'; @@ -20,6 +21,7 @@ export interface SchoolFlagsInput { census: SchoolCensus | null; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; + destinations?: SchoolDestinations | null; } export interface SchoolFlags { @@ -183,10 +185,22 @@ export interface SecondaryFlags { p8Suspended: boolean; isSpecial: boolean; suppressComparison: boolean; + /** Whether a destinations phase has anything to render. A block can exist + * with empty groups when the pipeline has run but the school has no rows, + * and a nav entry for a section that never renders links to nothing. */ + hasKs4Destinations: boolean; + hasKs5Destinations: boolean; +} + +/** A phase is renderable only if some pupil group actually carries categories. */ +function phaseHasContent(phase: DestinationPhase | null | undefined): boolean { + if (!phase) return false; + return Object.values(phase.groups ?? {}) + .some(group => (group?.categories?.length ?? 0) > 0); } export function computeSecondaryFlags({ - schoolInfo, yearlyData, deprivation, finance, + schoolInfo, yearlyData, deprivation, finance, destinations, }: Omit): SecondaryFlags { const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; @@ -212,6 +226,8 @@ export function computeSecondaryFlags({ return { latestResults, hasSixthForm, hasFinance, hasDeprivation, hasLocation, hasWellbeing, hasResults: !!hasResults, p8Suspended, isSpecial, suppressComparison, + hasKs4Destinations: phaseHasContent(destinations?.ks4), + hasKs5Destinations: phaseHasContent(destinations?.ks5), }; } @@ -228,6 +244,12 @@ export function buildSecondaryNavItems( const navItems: NavItem[] = []; if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' }); + // Destinations sit straight after attainment: they answer "and then what + // happened", which only makes sense once the results are in view. + if (flags.hasKs4Destinations) navItems.push({ id: 'destinations', label: 'After Year 11' }); + if (flags.hasKs5Destinations) { + navItems.push({ id: 'post16-destinations', label: 'After sixth form' }); + } if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' }); if (admissionDistance?.distance_m != null && hasLocation) { navItems.push({ id: 'distance', label: 'Distance' }); diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 601fd1f..ded1b99 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -598,3 +598,30 @@ export interface SortConfig { key: string; direction: SortDirection; } + +// ── Destination measures ──────────────────────────────────────────────────── +// Shaped by backend/data_loader.py::_destinations_block. `status` is the field +// that matters: 'suppressed' is DfE withholding a figure it judged disclosive +// and must render as "withheld"; 'not_applicable' must render as nothing. +// `pupils` is null for both, so a null check alone loses the difference. + +import type { DestinationCell, PupilGroup } from './destinations'; + +export interface DestinationGroupPayload { + cohort: number | null; + categories: DestinationCell[]; + /** Totals DfE published itself. Never computed here — see lib/destinations.ts. */ + aggregates: Partial>; +} + +export interface DestinationPhase { + /** e.g. "2022/23" — the section dates its own cohort, which runs about two + * GCSE years behind the results shown above it. */ + cohort_year: string | null; + groups: Partial>; +} + +export interface SchoolDestinations { + ks4: DestinationPhase | null; + ks5: DestinationPhase | null; +} -- 2.54.0 From cef2f771495bc4bbae78a745867c84c0c15a2e40 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 16:14:13 +0100 Subject: [PATCH 10/15] feat(destinations): the After Year 11 section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Question cards over one bar, with the cards acting as a lens on the bar rather than a summary beside it — focusing a card dims everything it is not made of, so the grouping we chose is inspectable rather than asserted. The bar renders only when canRenderBar allows it. Where a category is withheld the section says so and shows the table instead: the categories sum to the cohort, so a bar drawn from the published segments leaves a gap whose width is the withheld figure. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- .../components/DestinationsSection.test.tsx | 92 ++++++ nextjs-app/app/school/[slug]/page.tsx | 4 +- .../components/school/DestinationsSection.tsx | 38 +++ .../components/school/DestinationsView.tsx | 232 ++++++++++++++ .../school/SecondarySchoolSections.tsx | 11 +- .../components/school/destinations.module.css | 299 ++++++++++++++++++ nextjs-app/lib/types.ts | 6 + 7 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 nextjs-app/__tests__/components/DestinationsSection.test.tsx create mode 100644 nextjs-app/components/school/DestinationsSection.tsx create mode 100644 nextjs-app/components/school/DestinationsView.tsx create mode 100644 nextjs-app/components/school/destinations.module.css diff --git a/nextjs-app/__tests__/components/DestinationsSection.test.tsx b/nextjs-app/__tests__/components/DestinationsSection.test.tsx new file mode 100644 index 0000000..fa83056 --- /dev/null +++ b/nextjs-app/__tests__/components/DestinationsSection.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react'; +import { DestinationsSection } from '@/components/school/DestinationsSection'; +import type { DestinationPhase } from '@/lib/types'; +import type { DestinationCategory, DestinationStatus } from '@/lib/destinations'; + +const cell = ( + category: DestinationCategory, + pupils: number | null, + status: DestinationStatus = 'published', +) => ({ + category, pupils, + percentage: pupils === null ? null : (pupils / 180) * 100, + status, +}); + +const ALL_PUBLISHED = [ + cell('school_sixth_form', 75), cell('sixth_form_college', 21), + cell('further_education', 55), cell('other_education', 6), + cell('apprenticeship', 8), cell('employment', 6), + cell('not_sustained', 5), cell('not_captured', 4), +]; + +const fullPhase: DestinationPhase = { + cohort_year: '2022/23', + groups: { all: { cohort: 180, categories: ALL_PUBLISHED, aggregates: {} } }, +}; + +const suppressedPhase: DestinationPhase = { + cohort_year: '2022/23', + groups: { + all: { + cohort: 180, + categories: [ + cell('school_sixth_form', 75), cell('sixth_form_college', null, 'suppressed'), + cell('further_education', 55), cell('other_education', 6), + cell('apprenticeship', 8), cell('employment', 6), + cell('not_sustained', 5), cell('not_captured', 4), + ], + aggregates: {}, + }, + }, +}; + +describe('DestinationsSection', () => { + it('dates its own cohort so it is not read as stale next to the GCSE section', () => { + render(); + expect(screen.getByText(/2022\/23/)).toBeInTheDocument(); + }); + + it('renders one bar segment per published category', () => { + const { container } = render(); + expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(8); + }); + + it('renders NO bar at all when a category is withheld', () => { + const { container } = render(); + // R1: a bar with a gap in it publishes the withheld figure by its width. + expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(0); + expect(screen.getAllByText(/withheld/i).length).toBeGreaterThan(0); + }); + + it('never states the remainder for a partially suppressed group', () => { + const { container } = render(); + // 180 cohort - 159 published = 21, the withheld figure. It must appear nowhere. + expect(container.textContent).not.toMatch(/\b21\b/); + }); + + it('shows a card value for a group whose components are all published', () => { + render(); + // academic route = 75 + 21 = 96 of 180 = 53% + expect(screen.getByText('53%')).toBeInTheDocument(); + }); + + it('refuses a card value when one of its components is withheld', () => { + render(); + // academic route needs sixth_form_college, which is suppressed. + expect(screen.getByText(/not published/i)).toBeInTheDocument(); + expect(screen.queryByText('53%')).not.toBeInTheDocument(); + }); + + it('never claims a pupil stayed at this school', () => { + const { container } = render(); + // The published file reports destination TYPE, never destination institution. + expect(container.textContent).not.toMatch(/stayed on (here|at this school)/i); + }); + + it('renders nothing when no group carries categories', () => { + const empty: DestinationPhase = { cohort_year: '2022/23', groups: {} }; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/nextjs-app/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx index 4e9f6aa..6320aa4 100644 --- a/nextjs-app/app/school/[slug]/page.tsx +++ b/nextjs-app/app/school/[slug]/page.tsx @@ -148,7 +148,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { notFound(); } - const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data; + const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance, destinations } = data; // Redirect bare URN to canonical slug URL const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', ''); @@ -171,6 +171,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { schoolInfo: school_info, yearlyData: yearly_data, absenceData: absence_data, census: census ?? null, deprivation: deprivation ?? null, finance: finance ?? null, + destinations: destinations ?? null, }; const primaryFlags = computeSchoolFlags(sectionInput); const secondaryFlags = computeSecondaryFlags(sectionInput); @@ -236,6 +237,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { deprivation={deprivation ?? null} finance={finance ?? null} nationalAvg={nationalAvg} + destinations={destinations ?? null} flags={secondaryFlags} /> diff --git a/nextjs-app/components/school/DestinationsSection.tsx b/nextjs-app/components/school/DestinationsSection.tsx new file mode 100644 index 0000000..ef8c4f4 --- /dev/null +++ b/nextjs-app/components/school/DestinationsSection.tsx @@ -0,0 +1,38 @@ +/** + * DestinationsSection — where a school's Year 11 leavers went. Server component. + * + * The headline is deliberately NOT the sustained-destination rate. That figure + * sits between 92% and 97% for nearly every school in England, so leading with + * it would say nothing; the mix is what actually varies between schools. + * + * The section dates its own cohort because destination measures are published + * about two GCSE years behind the results in the section above — undated, the + * figures read as stale rather than as a different question. + */ + +import type { DestinationPhase } from '@/lib/types'; +import { Section, sectionStyles } from './sectionShared'; +import { DestinationsView } from './DestinationsView'; + +export function DestinationsSection({ destinations }: { destinations: DestinationPhase }) { + const all = destinations.groups.all; + const hasContent = Object.values(destinations.groups) + .some(group => (group?.categories?.length ?? 0) > 0); + if (!hasContent) return null; + + const cohort = all?.cohort ?? null; + const year = destinations.cohort_year; + + return ( +
+

After Year 11

+

+ Where {cohort ? `the ${cohort} pupils` : 'the pupils'} who left Year 11 + {year ? ` in ${year}` : ''} were during the following year. The Department + for Education tracks leavers for two terms, so these figures cover an + earlier year group than the GCSE results above. +

+ +
+ ); +} diff --git a/nextjs-app/components/school/DestinationsView.tsx b/nextjs-app/components/school/DestinationsView.tsx new file mode 100644 index 0000000..9677e7a --- /dev/null +++ b/nextjs-app/components/school/DestinationsView.tsx @@ -0,0 +1,232 @@ +'use client'; + +/** + * DestinationsView — the interactive body of both destination sections. + * + * Three question cards over one bar, with the cards acting as a lens on the + * bar rather than a summary beside it: focusing a card dims everything the + * card is not made of, so the grouping we chose is inspectable rather than + * asserted. + * + * Everything here defers to lib/destinations.ts for what may be shown. In + * particular the bar is rendered only when canRenderBar() allows it: the + * destination categories sum to the cohort, so a bar drawn from the published + * segments leaves a gap whose width IS the withheld figure. + * + * The one client component in this directory besides AdmissionsViewToggle. + * The all-pupils view is what the server renders into the HTML; the switch and + * the hover linkage are the only parts that need the browser. + */ + +import { useState } from 'react'; +import type { DestinationPhase, DestinationGroupPayload } from '@/lib/types'; +import { + CARD_GROUPS, CARD_QUESTIONS, CATEGORY_LABELS, CATEGORY_ORDER, + aggregateCells, canRenderBar, toBarSegments, cardGroupFor, + type CardGroup, type DestinationCell, type DestinationGroup, type PupilGroup, +} from '@/lib/destinations'; +import styles from './destinations.module.css'; + +const GROUP_LABELS: Record = { + all: 'All pupils', + disadvantaged: 'Disadvantaged', + other: 'All other pupils', +}; + +const GROUP_ORDER: PupilGroup[] = ['all', 'disadvantaged', 'other']; + +function toGroup(payload: DestinationGroupPayload): DestinationGroup { + return { + cohort: payload.cohort ?? 0, + cells: payload.categories, + aggregates: payload.aggregates, + }; +} + +function cellsFor(group: DestinationGroup, card: CardGroup): DestinationCell[] { + const wanted = new Set(CARD_GROUPS[card]); + return group.cells.filter(c => wanted.has(c.category)); +} + +export function DestinationsView({ + destinations, phase, +}: { destinations: DestinationPhase; phase: 'ks4' | 'ks5' }) { + const available = GROUP_ORDER.filter( + g => (destinations.groups[g]?.categories?.length ?? 0) > 0, + ); + const [selected, setSelected] = useState(available[0] ?? 'all'); + const [focused, setFocused] = useState(null); + + const payload = destinations.groups[selected]; + if (!payload) return null; + const group = toGroup(payload); + + const barDrawable = canRenderBar(group); + const segments = barDrawable ? toBarSegments(group) : []; + const withheld = group.cells.filter(c => c.status === 'suppressed'); + + const dimmed = (card: CardGroup | null) => focused !== null && focused !== card; + + return ( +
+ {available.length > 1 && ( +
+ Show +
+ {available.map(g => ( + + ))} +
+
+ )} + +
+ {(Object.keys(CARD_GROUPS) as CardGroup[]).map(card => { + const cells = cellsFor(group, card); + if (cells.length === 0) return null; + const total = aggregateCells(cells, group.cohort); + const { question, hint } = CARD_QUESTIONS[card]; + const keys = cells.map(c => ( + + )); + + if (total === null) { + return ( +
+ {question} + Not published + + Too few pupils went to {hint} for the Department for Education + to release a figure. + + {keys} +
+ ); + } + + return ( + + ); + })} +
+ + {barDrawable ? ( +
+
+ {segments.map(seg => { + const card = cardGroupFor(seg.category); + return ( +
+ {seg.widthPct >= 9 ? `${seg.labelPct}%` : ''} +
+ ); + })} +
+
+ 0%25%50%75%100% +
+
+ ) : ( +
+ + No breakdown chart for this group + +

+ {withheld.length === 1 + ? 'One of the destinations is withheld' + : `${withheld.length} of the destinations are withheld`} + {' '}because too few pupils went there. These destinations add up to + the whole year group, so drawing the rest as a chart would give the + withheld figures away. The table below shows what was published, + and nothing more. +

+
+ )} + +
+ + + + + + + + + + {CATEGORY_ORDER.flatMap(category => { + const cell = group.cells.find(c => c.category === category); + if (!cell) return []; + const card = cardGroupFor(category); + const isWithheld = cell.status === 'suppressed'; + return [( + + + + + + )]; + })} + +
DestinationPupilsShare
+ + {CATEGORY_LABELS[category]} + + {isWithheld + ? withheld + : cell.pupils} + + {isWithheld || cell.percentage === null + ? withheld + : `${Math.round(cell.percentage)}%`} +
+
+ +

+ Shares are rounded and may not add up to 100%. A pupil counted under a + school sixth form may have moved to a different school's sixth + form — the published data records the type of place, not which one. +

+
+ ); +} diff --git a/nextjs-app/components/school/SecondarySchoolSections.tsx b/nextjs-app/components/school/SecondarySchoolSections.tsx index 1ba3d7a..8530f47 100644 --- a/nextjs-app/components/school/SecondarySchoolSections.tsx +++ b/nextjs-app/components/school/SecondarySchoolSections.tsx @@ -14,11 +14,13 @@ import type { School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages, + SchoolDestinations, } from '@/lib/types'; import { ofstedLegacyAreas } from '@/lib/utils'; import type { SecondaryFlags } from '@/lib/schoolSections'; import { OfstedSection } from './OfstedSection'; import { GcseSection } from './GcseSection'; +import { DestinationsSection } from './DestinationsSection'; import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection'; import { DistanceSection } from './DistanceSection'; import { SecondaryHistorySection } from './SecondaryHistorySection'; @@ -40,13 +42,14 @@ export interface SecondarySchoolSectionsProps { deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; nationalAvg: NationalAverages | null; + destinations: SchoolDestinations | null; flags: SecondaryFlags; } export function SecondarySchoolSections({ schoolInfo, yearlyData, ofsted, census, admissions, admissionsHistory, admissionDistance, - deprivation, finance, nationalAvg, flags, + deprivation, finance, nationalAvg, destinations, flags, }: SecondarySchoolSectionsProps) { const secondaryAvg = nationalAvg?.secondary ?? {}; @@ -85,6 +88,12 @@ export function SecondarySchoolSections({ /> )} + {/* Destinations follow attainment: they answer "and then what happened", + which only lands once the results are in view. */} + {flags.hasKs4Destinations && destinations?.ks4 && ( + + )} + {/* See PrimarySchoolSections: distance and EES admissions are independent sources, so either one warrants the section. */} {(admissions || admissionDistance) && ( diff --git a/nextjs-app/components/school/destinations.module.css b/nextjs-app/components/school/destinations.module.css new file mode 100644 index 0000000..0985196 --- /dev/null +++ b/nextjs-app/components/school/destinations.module.css @@ -0,0 +1,299 @@ +/* + * Destination sections. + * + * Every colour comes from the --dest-* tokens in globals.css, which are + * defined in both themes. Nothing here is a literal colour — see + * __tests__/components/darkThemeSafety.test.ts for why. + */ + +.view { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* ── Cohort switch ─────────────────────────────────────────────────────── */ + +.switchRow { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.switchLabel { + font-size: var(--step--2); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.switchButtons { + display: inline-flex; + flex-wrap: wrap; + gap: 2px; + padding: 2px; + background: var(--bg-secondary); + border-radius: var(--radius-md); + align-self: flex-start; +} + +.switchButton { + appearance: none; + border: none; + background: transparent; + font: inherit; + font-size: var(--step--1); + font-weight: 600; + color: var(--text-secondary); + padding: 0.5rem 0.9rem; + border-radius: calc(var(--radius-md) - 2px); + cursor: pointer; + white-space: nowrap; + transition: background var(--transition), color var(--transition); +} + +.switchButton:hover { color: var(--text-primary); } +.switchButton:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; } + +.switchButton[aria-checked='true'] { + background: var(--bg-card); + color: var(--text-primary); + box-shadow: var(--shadow-soft); +} + +.switchCount { + margin-left: 0.4rem; + font-weight: 500; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +/* ── Question cards ────────────────────────────────────────────────────── */ + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.75rem; +} + +.card { + display: flex; + flex-direction: column; + gap: 0.35rem; + text-align: left; + font: inherit; + background: var(--brand-bg); + border: 1px solid transparent; + border-radius: var(--radius-md); + padding: 1.15rem 1.25rem; + cursor: pointer; + transition: border-color var(--transition), opacity var(--transition); +} + +.card:hover { border-color: var(--brand); } +.card:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } + +.cardWithheld { + background: var(--bg-secondary); + border-style: dashed; + border-color: var(--border-strong); + cursor: default; +} + +.cardQuestion { + font-family: var(--font-display); + font-size: var(--step--1); + font-weight: 700; + color: var(--text-primary); + text-wrap: balance; +} + +.cardValue { + font-family: var(--font-display); + font-size: var(--step-3); + font-weight: 800; + line-height: 1.05; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + color: var(--text-primary); +} + +.cardWithheldValue { + font-family: var(--font-display); + font-size: var(--step-1); + font-weight: 700; + color: var(--text-muted); +} + +.cardHint { + font-size: var(--step--2); + color: var(--text-secondary); +} + +.cardKeys { + display: flex; + gap: 0.3rem; + margin-top: 0.2rem; +} + +.cardKeys .swatch { + width: 1.5rem; + height: 0.35rem; + border-radius: 2px; +} + +/* ── Bar ───────────────────────────────────────────────────────────────── */ + +.barBlock { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* 2px surface gaps between segments so adjacent fills stay distinguishable + without a border darkening the palette. */ +.bar { + display: flex; + gap: 2px; + height: 3rem; + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--bg-card); +} + +.segment { + display: grid; + place-items: center; + min-width: 2px; + overflow: hidden; + color: var(--text-inverse); + font-size: var(--step--2); + font-weight: 700; + font-variant-numeric: tabular-nums; + transition: opacity var(--transition); +} + +.barScale { + display: flex; + justify-content: space-between; + font-size: var(--step--2); + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +/* ── Category colours ──────────────────────────────────────────────────── */ + +.higher_education, +.school_sixth_form { background: var(--dest-sixthform); } +.sixth_form_college { background: var(--dest-sfcollege); } +.further_education, +.other_education { background: var(--dest-fecollege); color: var(--text-primary); } +.apprenticeship { background: var(--dest-apprentice); } +.employment { background: var(--dest-employment); } + +/* The absence is hatched neutral, never a colour: "activity not captured" + covers independent schools, moving abroad and training the department holds + no data on, so a red segment would state something false. The hatch is also + the secondary encoding that separates it from the employment blue. */ +.not_sustained, +.not_captured { + background-color: var(--bg-card); + background-image: repeating-linear-gradient( + 45deg, + var(--dest-none-hatch) 0 3px, + transparent 3px 7px + ); + box-shadow: inset 0 0 0 1px var(--dest-none); + color: var(--text-secondary); +} + +.swatch { + display: inline-block; + width: 0.9rem; + height: 0.9rem; + border-radius: 3px; + flex: none; +} + +/* ── Dimming, for the card-to-bar linkage ──────────────────────────────── */ + +.dim { opacity: 0.3; } + +/* ── Withheld panel ────────────────────────────────────────────────────── */ + +.withheldPanel { + background: var(--bg-secondary); + border-radius: var(--radius-md); + padding: 1.15rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.withheldTitle { + font-family: var(--font-display); + font-size: var(--step--1); + font-weight: 700; + color: var(--text-primary); +} + +.withheldBody { + margin: 0; + font-size: var(--step--1); + color: var(--text-secondary); +} + +.withheldMark { + display: inline-block; + font-size: var(--step--2); + font-weight: 700; + color: var(--text-muted); + background: var(--bg-secondary); + border: 1px dashed var(--border-strong); + border-radius: 999px; + padding: 0.1rem 0.5rem; +} + +/* ── Table ─────────────────────────────────────────────────────────────── */ + +.tableWrap { overflow-x: auto; } + +.table { + width: 100%; + border-collapse: collapse; + font-size: var(--step--1); +} + +.table th, +.table td { + padding: 0.55rem 0; + border-bottom: 1px solid var(--border); + text-align: right; + font-variant-numeric: tabular-nums; +} + +.table thead th { + font-size: var(--step--2); + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); + white-space: nowrap; +} + +.table th:first-child { text-align: left; } + +.rowName { + display: flex; + align-items: center; + gap: 0.6rem; + font-weight: 500; + color: var(--text-primary); +} + +.footnote { + margin: 0; + font-size: var(--step--2); + color: var(--text-muted); +} diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index ded1b99..17688dc 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -369,6 +369,12 @@ export interface SchoolDetailsResponse { admission_distance?: SchoolAdmissionDistance | null; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; + /** + * Optional so an older backend, which does not send the key at all, still + * typechecks. Null means the school has no published destination data; + * either way the sections simply do not render. + */ + destinations?: SchoolDestinations | null; } export interface ComparisonData { -- 2.54.0 From 2b4cf20d75e6be1bc622cad2a7b9c71f13d12312 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 16:15:21 +0100 Subject: [PATCH 11/15] feat(destinations): the post-16 section, replacing the placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Post-16 destination data coming soon' note is deleted rather than reworded: for a school with no sixth form the truthful statement is that the question does not apply, and a placeholder there implies something is missing. hasSixthForm and .sixthFormNote go with it — nothing else used them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- .../Post16DestinationsSection.test.tsx | 45 +++++++++++++++++++ .../school/Post16DestinationsSection.tsx | 40 +++++++++++++++++ .../school/SecondaryAdmissionsSection.tsx | 8 +--- .../school/SecondarySchoolSections.tsx | 8 +++- .../school/schoolSections.module.css | 11 +---- 5 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx create mode 100644 nextjs-app/components/school/Post16DestinationsSection.tsx diff --git a/nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx b/nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx new file mode 100644 index 0000000..946d88f --- /dev/null +++ b/nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@testing-library/react'; +import { Post16DestinationsSection } from '@/components/school/Post16DestinationsSection'; +import type { DestinationPhase } from '@/lib/types'; + +const phase: DestinationPhase = { + cohort_year: '2022/23', + groups: { + all: { + cohort: 96, + categories: [ + { category: 'higher_education', pupils: 56, percentage: 58.3, status: 'published' }, + { category: 'further_education', pupils: 12, percentage: 12.5, status: 'published' }, + { category: 'apprenticeship', pupils: 9, percentage: 9.4, status: 'published' }, + { category: 'employment', pupils: 13, percentage: 13.5, status: 'published' }, + { category: 'not_sustained', pupils: 6, percentage: 6.3, status: 'published' }, + ], + aggregates: {}, + }, + }, +}; + +describe('Post16DestinationsSection', () => { + it('names the Year 13 cohort, not Year 11', () => { + const { container } = render(); + expect(container.textContent).toMatch(/Year 13/); + expect(container.textContent).not.toMatch(/Year 11/); + }); + + it('reports higher education destinations', () => { + render(); + expect(screen.getByText(/UK higher education/i)).toBeInTheDocument(); + }); + + it('uses its own anchor so the nav does not collide with After Year 11', () => { + const { container } = render(); + expect(container.querySelector('#post16-destinations')).toBeTruthy(); + expect(container.querySelector('#destinations')).toBeNull(); + }); + + it('renders nothing when no group carries categories', () => { + const empty: DestinationPhase = { cohort_year: '2022/23', groups: {} }; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/nextjs-app/components/school/Post16DestinationsSection.tsx b/nextjs-app/components/school/Post16DestinationsSection.tsx new file mode 100644 index 0000000..e5113eb --- /dev/null +++ b/nextjs-app/components/school/Post16DestinationsSection.tsx @@ -0,0 +1,40 @@ +/** + * Post16DestinationsSection — where a school's Year 13 leavers went. + * Server component. + * + * A separate publication, a separate cohort and a separate question from + * After Year 11, so it is a separate section rather than a tab: a parent + * choosing a secondary and a student choosing a sixth form are not the same + * reader. + * + * Not rendered at all for a school without post-16 provision. A "no data" + * placeholder there would imply something is missing, when the truthful + * statement is that the question does not apply — which is why the old + * "Post-16 destination data coming soon" note is gone rather than reworded. + */ + +import type { DestinationPhase } from '@/lib/types'; +import { Section, sectionStyles } from './sectionShared'; +import { DestinationsView } from './DestinationsView'; + +export function Post16DestinationsSection({ + destinations, +}: { destinations: DestinationPhase }) { + const hasContent = Object.values(destinations.groups) + .some(group => (group?.categories?.length ?? 0) > 0); + if (!hasContent) return null; + + const cohort = destinations.groups.all?.cohort ?? null; + const year = destinations.cohort_year; + + return ( +
+

After the sixth form

+

+ Where {cohort ? `the ${cohort} students` : 'the students'} who finished + Year 13{year ? ` in ${year}` : ''} went next. +

+ +
+ ); +} diff --git a/nextjs-app/components/school/SecondaryAdmissionsSection.tsx b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx index 49c8970..73a8e36 100644 --- a/nextjs-app/components/school/SecondaryAdmissionsSection.tsx +++ b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx @@ -15,7 +15,7 @@ import { } from './lastDistanceOffered'; export function SecondaryAdmissionsSection({ - admissions, admissionsHistory, admissionDistance, schoolInfo, hasSixthForm, + admissions, admissionsHistory, admissionDistance, schoolInfo, }: { /* Nullable for the same reason as the primary section: a school can have a published cut-off and no EES admissions row. */ @@ -23,7 +23,6 @@ export function SecondaryAdmissionsSection({ admissionsHistory: SchoolAdmissions[]; admissionDistance: SchoolAdmissionDistance | null; schoolInfo: School; - hasSixthForm: boolean; }) { const cutoff = describeCutoff(admissionDistance); // Moved with this section from SecondarySchoolDetailView, its only consumer. @@ -112,11 +111,6 @@ export function SecondaryAdmissionsSection({

)} - {hasSixthForm && ( -
- This school has a sixth form (Post-16 provision). Post-16 destination data coming soon. -
- )} ); } diff --git a/nextjs-app/components/school/SecondarySchoolSections.tsx b/nextjs-app/components/school/SecondarySchoolSections.tsx index 8530f47..f3542ef 100644 --- a/nextjs-app/components/school/SecondarySchoolSections.tsx +++ b/nextjs-app/components/school/SecondarySchoolSections.tsx @@ -21,6 +21,7 @@ import type { SecondaryFlags } from '@/lib/schoolSections'; import { OfstedSection } from './OfstedSection'; import { GcseSection } from './GcseSection'; import { DestinationsSection } from './DestinationsSection'; +import { Post16DestinationsSection } from './Post16DestinationsSection'; import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection'; import { DistanceSection } from './DistanceSection'; import { SecondaryHistorySection } from './SecondaryHistorySection'; @@ -94,6 +95,12 @@ export function SecondarySchoolSections({ )} + {/* Sixth-form schools only. Absent, not placeheld, for a school with no + post-16 provision — the question simply does not apply there. */} + {flags.hasKs5Destinations && destinations?.ks5 && ( + + )} + {/* See PrimarySchoolSections: distance and EES admissions are independent sources, so either one warrants the section. */} {(admissions || admissionDistance) && ( @@ -102,7 +109,6 @@ export function SecondarySchoolSections({ admissionDistance={admissionDistance} admissionsHistory={admissionsHistory} schoolInfo={schoolInfo} - hasSixthForm={flags.hasSixthForm} /> )} diff --git a/nextjs-app/components/school/schoolSections.module.css b/nextjs-app/components/school/schoolSections.module.css index 7e26d29..931fee0 100644 --- a/nextjs-app/components/school/schoolSections.module.css +++ b/nextjs-app/components/school/schoolSections.module.css @@ -1883,15 +1883,7 @@ color: var(--phase-secondary-text); border: 1px solid rgba(var(--status-above-rgb), 0.2); } -.sixthFormNote { - margin-top: 1rem; - padding: 0.625rem 0.875rem; - background: var(--bg-secondary); - border-radius: 6px; - font-size: 0.825rem; - color: var(--text-secondary); - border-left: 3px solid var(--brand); -} + .genderSplitHint { font-size: 0.7rem; color: var(--text-muted); @@ -2107,7 +2099,6 @@ border-top: 1px solid var(--border); } - .cutoffMapFigure { /* Enough to read a set of concentric rings and no more — this is a diagram of a number, not a map anyone navigates by. */ -- 2.54.0 From ccd5074c904f715dfa4457bf2f28c270e45881f6 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 16:16:25 +0100 Subject: [PATCH 12/15] test(e2e): destination journeys, including the no-bar rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper throws rather than skipping when no school returns a destinations block: a silent skip would let a real regression in the sections ride along unnoticed, which is why the distance journeys were changed the same way in 4f01fbd. The disadvantaged journey computes the residual itself and asserts it appears nowhere on the page — the one number the section must never state. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- e2e/tests/journeys.spec.ts | 122 +++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 966f2ab..e953520 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -2366,3 +2366,125 @@ test('with autosuggest off, the search box is a plain input', async ({ page }) = await page.getByRole('button', { name: /Search/i }).first().click(); await expect(page).toHaveURL(/search=abbey/); }); + +// ── Destination measures ─────────────────────────────────────────────────── +// +// These journeys need marts.fact_ks4_destinations to be populated, which only +// happens after the annual EES DAG runs. Until then the helper below fails the +// suite loudly rather than skipping: a silent skip here would let a genuine +// regression in the sections ride along unnoticed, which is exactly what the +// distance journeys were changed to avoid. + +async function secondaryWithDestinations(page: Page): Promise<{ + urn: string; destinations: any; +}> { + const res = await page.request.get('/api/schools?search=school&per_page=100'); + expect(res.ok()).toBeTruthy(); + const body = await res.json(); + const urns: string[] = (body.schools ?? []) + .filter((s: { phase?: string; attainment_8_score?: number | null }) => + s.phase === 'Secondary' && s.attainment_8_score != null) + .map((s: { urn: number }) => String(s.urn)); + + for (const urn of urns.slice(0, 25)) { + const detail = await page.request.get(`/api/schools/${urn}`); + if (!detail.ok()) continue; + const data = await detail.json(); + if (data.destinations?.ks4) return { urn, destinations: data.destinations }; + } + throw new Error( + 'No secondary school returned a destinations block. Either the annual EES ' + + 'DAG has not run on this environment, or the destinations marts are empty.', + ); +} + +test('a secondary school page says where its Year 11 leavers went', async ({ page }) => { + const { urn } = await secondaryWithDestinations(page); + await page.goto(`/school/${urn}`); + + const section = page.locator('#destinations'); + await expect(section).toBeVisible({ timeout: 15_000 }); + await expect(section.getByRole('heading', { name: 'After Year 11' })).toBeVisible(); + // The section must date its own cohort: destinations run about two GCSE + // years behind the results above them, and an undated figure reads as stale. + await expect(section).toContainText(/20\d{2}\/\d{2}/); +}); + +test('the destinations bar is absent entirely whenever a figure is withheld', async ({ page }) => { + const { urn, destinations } = await secondaryWithDestinations(page); + await page.goto(`/school/${urn}`); + const section = page.locator('#destinations'); + await expect(section).toBeVisible({ timeout: 15_000 }); + + const allGroup = destinations.ks4.groups.all; + const suppressed = (allGroup?.categories ?? []) + .filter((c: { status: string }) => c.status === 'suppressed'); + + if (suppressed.length > 0) { + // R1: a bar drawn from the published segments leaves a gap whose width is + // the withheld figure, readable straight off the axis. + await expect(section.locator('[data-destination-segment]')).toHaveCount(0); + await expect(section.getByText(/withheld/i).first()).toBeVisible(); + } else { + const published = (allGroup?.categories ?? []) + .filter((c: { status: string }) => c.status === 'published'); + await expect(section.locator('[data-destination-segment]')) + .toHaveCount(published.length); + } +}); + +test('switching to disadvantaged pupils never reveals a withheld figure', async ({ page }) => { + const { urn, destinations } = await secondaryWithDestinations(page); + const disadvantaged = destinations.ks4.groups.disadvantaged; + test.skip(!disadvantaged, 'this school publishes no disadvantaged breakdown'); + + await page.goto(`/school/${urn}`); + const section = page.locator('#destinations'); + await expect(section).toBeVisible({ timeout: 15_000 }); + + const radio = section.getByRole('radio', { name: /disadvantaged/i }); + await expect(radio).toBeVisible(); + await radio.click(); + + const suppressed = (disadvantaged.categories ?? []) + .filter((c: { status: string }) => c.status === 'suppressed'); + if (suppressed.length > 0) { + await expect(section.locator('[data-destination-segment]')).toHaveCount(0); + + // The residual must appear nowhere on the page — it is the withheld figure. + const cohort: number = disadvantaged.cohort; + const publishedTotal = (disadvantaged.categories ?? []) + .filter((c: { status: string }) => c.status === 'published') + .reduce((sum: number, c: { pupils: number }) => sum + c.pupils, 0); + const residual = cohort - publishedTotal; + const text = (await section.textContent()) ?? ''; + expect(text).not.toMatch(new RegExp(`\\b${residual}\\b`)); + } +}); + +test('a school with no sixth form has no post-16 destinations section', async ({ page }) => { + const res = await page.request.get('/api/schools?search=school&per_page=100'); + const body = await res.json(); + const noSixthForm = (body.schools ?? []) + .filter((s: { phase?: string; has_sixth_form?: boolean }) => + s.phase === 'Secondary' && s.has_sixth_form === false) + .map((s: { urn: number }) => String(s.urn)); + test.skip(noSixthForm.length === 0, 'no sixth-form-less secondary in this dataset'); + + await page.goto(`/school/${noSixthForm[0]}`); + await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); + // Absence is the correct statement, so there must be no placeholder either. + await expect(page.locator('#post16-destinations')).toHaveCount(0); + await expect(page.getByText(/destination data coming soon/i)).toHaveCount(0); +}); + +test('the destinations section never claims a pupil stayed at this school', async ({ page }) => { + const { urn } = await secondaryWithDestinations(page); + await page.goto(`/school/${urn}`); + const section = page.locator('#destinations'); + await expect(section).toBeVisible({ timeout: 15_000 }); + // The published file records the TYPE of place a leaver went to, never which + // one, so the page can never say a pupil stayed on here. + const text = (await section.textContent()) ?? ''; + expect(text).not.toMatch(/stayed on (here|at this school)/i); +}); -- 2.54.0 From 102397fe695d666a23ae6de7859da6ca650ce778 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 28 Aug 2026 19:58:02 +0100 Subject: [PATCH 13/15] fix(destinations): withhold at the API, not just in the chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review found the disclosure the whole design was meant to prevent. R1 was written as a rendering rule and implemented as one: canRenderBar stopped the bar being drawn, but GET /api/schools/{urn} still carried the cohort and every published category. cohort - sum(published) returned Whitley Bay's withheld further-education figure exactly — 18 pupils — to any caller, and the RSC payload put it in the browser too. app.py already stated the principle for admission_distance: this endpoint is public and unauthenticated, so a field left in the payload is a published field. The same reasoning applies here and did not get applied. _mask_for_disclosure now closes both identities before serialisation — categories sum to the cohort, and disadvantaged + other = all — by adding secondary suppression until every row and column hides none or at least two. My first attempt picked the smallest published cell as the companion and a new test caught it choosing a zero, which protects nothing: the residual still resolved to 18. The companion must carry pupils. DfE's own aggregates are no longer served. Nothing rendered them, and one spanning a single suppressed component names it. Cost, measured over 262 mainstream secondaries: the all-pupils bar survives on 94% rather than 100%. Zero lone-suppressed groups remain. The e2e helper now tells a missing feature apart from missing data: it fails if the API serves no destinations key at all, and skips if the key is served but the annual DAG has not populated the marts. Failing on the second would redden the staging gate for unrelated commits. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- backend/data_loader.py | 114 +++++++++++-- backend/tests/test_destinations_api.py | 161 +++++++++++++----- .../2026-08-28-destination-measures-design.md | 75 +++++--- e2e/tests/journeys.spec.ts | 38 ++++- .../components/DestinationsSection.test.tsx | 3 +- .../Post16DestinationsSection.test.tsx | 1 - nextjs-app/__tests__/lib/destinations.test.ts | 23 +-- .../lib/schoolSections.destinations.test.ts | 1 - .../components/school/DestinationsView.tsx | 1 - nextjs-app/lib/destinations.ts | 21 +-- nextjs-app/lib/types.ts | 2 - 11 files changed, 318 insertions(+), 122 deletions(-) diff --git a/backend/data_loader.py b/backend/data_loader.py index a176fe4..fc0529c 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -839,17 +839,100 @@ def _format_cohort_year(year) -> str | None: return text +def _mask_for_disclosure(groups: dict) -> None: + """Add secondary suppression until no withheld figure can be recovered. + + Not rendering a number is not the same as not publishing it. This endpoint + is public and unauthenticated, so anything left in the payload is + published, whatever the UI chooses to draw — the same reasoning the + admission_distance field carries in app.py. + + Two identities let a caller solve for a withheld cell: + + * within a pupil group, the categories sum to the cohort, so a group with + exactly ONE suppressed category gives it away as cohort - sum(rest); + * across groups, disadvantaged + other = all for every category, so a + category suppressed in exactly ONE of the three gives itself away. + + DfE's own answer is secondary suppression: withhold a second cell so the + residual spans two unknowns and identifies neither. This does the same, + iterating because each new suppression can break the other identity, and + terminating because cells are only ever added to the suppressed set. + + Mutates `groups` in place. + """ + PAIRS = ("disadvantaged", "other", "all") + + def cells(group_key): + group = groups.get(group_key) + return group["categories"] if group else [] + + def suppress(cell): + if cell["status"] == "published": + cell["status"] = "suppressed" + cell["pupils"] = None + cell["percentage"] = None + return True + return False + + def add_companion(candidates): + """Withhold a second cell so the residual spans two unknowns. + + The companion must carry pupils. Suppressing a zero looks like + secondary suppression and protects nothing: the residual still equals + the original withheld figure exactly. Where every remaining cell is + zero there is no companion that helps, so the whole set goes — losing + real data, but that beats publishing what DfE withheld. + """ + published = [c for c in candidates if c["status"] == "published"] + useful = sorted( + (c for c in published if (c["pupils"] or 0) > 0), + key=lambda c: c["pupils"], + ) + if useful: + return suppress(useful[0]) + return any([suppress(c) for c in published]) + + changed = True + while changed: + changed = False + + # Column rule: a category must be suppressed in none of the three + # pupil groups, or in at least two of them. + categories = {c["category"] for key in PAIRS for c in cells(key)} + for category in categories: + found = [ + c for key in PAIRS for c in cells(key) if c["category"] == category + ] + hidden = [c for c in found if c["status"] == "suppressed"] + if len(hidden) == 1 and len(found) > 1: + if add_companion(found): + changed = True + + # Row rule: within a group, none suppressed or at least two. + for key in PAIRS: + group_cells = cells(key) + hidden = [c for c in group_cells if c["status"] == "suppressed"] + if len(hidden) == 1: + if add_companion(group_cells): + changed = True + + def _destinations_block(rows: list) -> dict | None: """Shape destination rows for one phase into the API's block. - Carries `status` through untouched and emits no computed totals. The only - aggregates present are ones DfE published itself; whether showing one is - safe depends on how many of its components are suppressed, which the - frontend decides (lib/destinations.ts, rule R2). + Applies secondary suppression before returning, so no caller of this public + endpoint can solve for a figure DfE withheld. See _mask_for_disclosure. - Deliberately does NOT compute a residual, a "remaining pupils" figure, or - any total that would close a gap left by a suppressed category — the - categories sum to the cohort, so such a figure names the withheld cell. + Aggregate measures are dropped entirely. DfE publishes them, and they would + be useful for a "what is published for this group" fallback, but nothing + renders them today and an aggregate spanning exactly one suppressed + component names that component. An unused field that leaks is not a + trade-off worth carrying — re-add them with their own guard if the fallback + is ever built. + + Deliberately computes no residual, no "remaining pupils" figure, and no + total that would close a gap left by a suppressed category. """ if not rows: return None @@ -864,23 +947,28 @@ def _destinations_block(rows: list) -> dict | None: for row in rows: group = groups.setdefault( row["pupil_group"], - {"cohort": row.get("cohort_pupils"), "categories": [], "aggregates": {}}, + {"cohort": row.get("cohort_pupils"), "categories": []}, ) measure = row["destination_measure"] + published = row.get("status") == "published" + # Belt and braces: percentage is derived from the same source cell as + # pupils, but publishing one without the other would hand back the + # cohort (pupils / percentage) and with it the residual. cell = { "category": measure, - "pupils": row.get("pupils"), - "percentage": row.get("percentage"), + "pupils": row.get("pupils") if published else None, + "percentage": row.get("percentage") if published else None, "status": row.get("status"), } if measure in _AGGREGATE_MEASURES: - group["aggregates"][measure[len("agg_"):]] = cell - else: - group["categories"].append(cell) + continue + group["categories"].append(cell) if not groups: return None + _mask_for_disclosure(groups) + return {"cohort_year": _format_cohort_year(latest_year), "groups": groups} diff --git a/backend/tests/test_destinations_api.py b/backend/tests/test_destinations_api.py index a2a2cd9..a418408 100644 --- a/backend/tests/test_destinations_api.py +++ b/backend/tests/test_destinations_api.py @@ -1,9 +1,12 @@ -"""The destinations serialiser's contract: it carries suppression through, and -never emits a total that closes a gap left by a suppressed category. +"""The destinations serialiser's contract. -The destination categories sum to the cohort, so an aggregate that happens to -equal the residual names the withheld figure exactly. See -docs/superpowers/specs/2026-08-28-destination-measures-design.md. +Not rendering a figure is not the same as not publishing it. This endpoint is +public and unauthenticated, so whatever the payload carries is published, +whatever the UI draws. The categories sum to the cohort and the pupil groups +sum to each other, so a lone suppressed cell is solvable by subtraction — the +serialiser adds secondary suppression to prevent it. + +See docs/superpowers/specs/2026-08-28-destination-measures-design.md. """ from backend.data_loader import _destinations_block, _format_cohort_year @@ -43,39 +46,6 @@ def test_published_category_keeps_its_figures(): assert cat["status"] == "published" -def test_no_closing_total_is_emitted_for_a_partially_suppressed_group(): - rows = [ - _row("all", "school_sixth_form", 75, "published", percentage=41.7), - _row("all", "sixth_form_college", None, "suppressed"), - _row("all", "further_education", 61, "published", percentage=33.9), - _row("all", "apprenticeship", 8, "published", percentage=4.4), - _row("all", "employment", 6, "published", percentage=3.3), - _row("all", "not_sustained", 5, "published", percentage=2.8), - _row("all", "not_captured", 4, "published", percentage=2.2), - ] - block = _destinations_block(rows) - group = block["groups"]["all"] - published = sum(c["pupils"] for c in group["categories"] if c["pupils"] is not None) - residual = group["cohort"] - published - for value in group["aggregates"].values(): - if value is None or value.get("pupils") is None: - continue - assert value["pupils"] != residual, ( - "an aggregate equal to the residual identifies the suppressed cell" - ) - - -def test_aggregates_are_separated_from_categories(): - rows = [ - _row("all", "school_sixth_form", 75, "published", percentage=41.7), - _row("all", "agg_sustained_all", 171, "published", percentage=95.0), - ] - block = _destinations_block(rows) - group = block["groups"]["all"] - assert [c["category"] for c in group["categories"]] == ["school_sixth_form"] - assert group["aggregates"]["sustained_all"]["pupils"] == 171 - - def test_only_the_latest_year_is_served(): rows = [ _row("all", "school_sixth_form", 60, "published", year=202122), @@ -110,3 +80,118 @@ def test_format_cohort_year_handles_the_six_digit_form(): def test_empty_rows_yield_none_not_an_empty_shell(): assert _destinations_block([]) is None + + +# ── Disclosure control ────────────────────────────────────────────────────── +# +# The rendering guards in lib/destinations.ts stop a withheld figure being +# DRAWN. They do nothing about it being COMPUTED: this endpoint is public and +# unauthenticated, so whatever the payload carries is published. These tests +# are the ones that matter. + +def _solve_residual(group): + """What any caller can work out: cohort minus everything published.""" + published = [c["pupils"] for c in group["categories"] if c["pupils"] is not None] + hidden = [c for c in group["categories"] if c["status"] == "suppressed"] + return group["cohort"] - sum(published), len(hidden) + + +def test_a_lone_suppressed_category_cannot_be_solved_for(): + """Whitley Bay High School's real 2022/23 disadvantaged group: further + education withheld, everything else published, cohort 41. Before secondary + suppression the payload gave the answer away as 41 - 23 = 18.""" + rows = [ + _row("disadvantaged", "school_sixth_form", 15, "published", cohort=41), + _row("disadvantaged", "sixth_form_college", 0, "published", cohort=41), + _row("disadvantaged", "further_education", None, "suppressed", cohort=41), + _row("disadvantaged", "apprenticeship", 1, "published", cohort=41), + _row("disadvantaged", "employment", 2, "published", cohort=41), + _row("disadvantaged", "not_sustained", 3, "published", cohort=41), + _row("disadvantaged", "not_captured", 2, "published", cohort=41), + ] + group = _destinations_block(rows)["groups"]["disadvantaged"] + residual, hidden = _solve_residual(group) + assert hidden >= 2, "a lone suppressed cell must gain a companion" + assert residual != 18, "the withheld figure is recoverable from the payload" + + +def test_every_group_hides_none_or_at_least_two_categories(): + rows = [ + _row("all", "school_sixth_form", 75, "published"), + _row("all", "sixth_form_college", None, "suppressed"), + _row("all", "further_education", 61, "published"), + _row("all", "apprenticeship", 8, "published"), + _row("all", "employment", 6, "published"), + _row("all", "not_sustained", 5, "published"), + _row("all", "not_captured", 4, "published"), + ] + group = _destinations_block(rows)["groups"]["all"] + hidden = [c for c in group["categories"] if c["status"] == "suppressed"] + assert len(hidden) >= 2 + + +def test_a_category_hidden_in_one_group_is_hidden_in_a_second(): + """disadvantaged + other = all for every category, so a category withheld + in exactly one of the three is recoverable from the other two.""" + rows = [] + for measure, a, d, o in [ + ("school_sixth_form", 75, None, 58), + ("further_education", 61, 27, 34), + ("apprenticeship", 8, 4, 4), + ("employment", 6, 1, 5), + ("not_sustained", 5, 3, 2), + ("not_captured", 4, 2, 2), + ]: + rows.append(_row("all", measure, a, "published", cohort=159)) + rows.append(_row("disadvantaged", measure, d, + "published" if d is not None else "suppressed", cohort=37)) + rows.append(_row("other", measure, o, "published", cohort=122)) + + groups = _destinations_block(rows)["groups"] + for measure in ["school_sixth_form"]: + hidden = sum( + 1 for key in ("all", "disadvantaged", "other") + for c in groups[key]["categories"] + if c["category"] == measure and c["status"] == "suppressed" + ) + assert hidden >= 2, f"{measure} is solvable across the pupil groups" + + +def test_a_suppressed_cell_never_keeps_its_percentage(): + """percentage / pupils would hand back the cohort, and with it the residual.""" + rows = [ + _row("all", "school_sixth_form", 75, "published", percentage=41.7), + _row("all", "sixth_form_college", None, "suppressed", percentage=11.7), + _row("all", "further_education", 61, "published", percentage=33.9), + ] + group = _destinations_block(rows)["groups"]["all"] + for cell in group["categories"]: + if cell["status"] != "published": + assert cell["pupils"] is None + assert cell["percentage"] is None + + +def test_aggregates_are_not_served(): + """An aggregate spanning exactly one suppressed component names it, and + nothing renders them today.""" + rows = [ + _row("all", "school_sixth_form", 75, "published"), + _row("all", "agg_sustained_all", 171, "published"), + ] + group = _destinations_block(rows)["groups"]["all"] + assert [c["category"] for c in group["categories"]] == ["school_sixth_form"] + assert "aggregates" not in group + + +def test_a_fully_published_group_is_left_alone(): + """Secondary suppression must not cost anything where nothing is withheld — + this is the all-pupils view on every mainstream secondary.""" + rows = [ + _row("all", m, p, "published") + for m, p in [("school_sixth_form", 75), ("sixth_form_college", 21), + ("further_education", 61), ("apprenticeship", 8), + ("employment", 6), ("not_sustained", 5), ("not_captured", 4)] + ] + group = _destinations_block(rows)["groups"]["all"] + assert all(c["status"] == "published" for c in group["categories"]) + assert len(group["categories"]) == 7 diff --git a/docs/superpowers/specs/2026-08-28-destination-measures-design.md b/docs/superpowers/specs/2026-08-28-destination-measures-design.md index 22ab448..8fceb7f 100644 --- a/docs/superpowers/specs/2026-08-28-destination-measures-design.md +++ b/docs/superpowers/specs/2026-08-28-destination-measures-design.md @@ -42,17 +42,47 @@ Those are the precise numbers the `c` exists to hide, and in a random 400-school sample **22% of mainstream secondaries** have exactly one suppressed category in their disadvantaged group. This is the normal case, not an edge case. -Two rules follow, and everything else in this document is downstream of them. +Three rules follow, and everything else in this document is downstream of them. -**R1 — Never render a derived remainder.** Not as a number, and not as a bar -segment: a segment sized by the residual can be read straight off the axis. Where -any category in a pupil group is suppressed, the page shows the published -categories, says the rest are withheld, and stops. +**R1 — Never *publish* enough to derive a remainder.** -**R2 — Never aggregate across a suppression boundary.** A group total is -publishable only when DfE published that total itself, or when the aggregate -spans **two or more** suppressed cells. Summing published components to fill a -gap is R1 with extra steps. +An earlier draft of this rule said "never *render* a derived remainder", and +that was the defect code review caught in PR #137. Not drawing a number does +nothing to stop it being computed: `GET /api/schools/{urn}` is public and +unauthenticated, so anything in the payload is published whatever the UI +chooses to draw. The rendering guards shipped; the payload still carried the +cohort and every published category, and `cohort - sum(published)` returned +Whitley Bay's withheld figure exactly. + +The rule is therefore about the serialiser, and the UI guards are a second line +of defence behind it. Two identities have to be closed: + +- within a pupil group the categories sum to the cohort, so a group with + exactly **one** suppressed category gives it away; +- across groups, disadvantaged + other = all for every category, so a category + suppressed in exactly **one** of the three gives itself away. + +`_mask_for_disclosure` applies DfE's own answer — secondary suppression — +withholding a companion cell until every row and every column hides either none +or at least two. It iterates, because each new suppression can break the other +identity, and terminates because cells are only ever added. + +The companion must carry pupils. Suppressing a zero looks like secondary +suppression and protects nothing: the residual still equals the original +withheld figure. Where no non-zero companion exists, the whole set is withheld. + +Measured cost on the 400-school sample: the all-pupils bar survives on **94%** +of mainstream secondaries rather than 100%. That is the price of not +republishing what DfE withheld. + +**R2 — Never aggregate across a suppression boundary.** Summing published +components to fill a gap is R1 with extra steps. + +DfE's own aggregates (`Sustained education destination`, `Sustained education, +employment & apprenticeships`) are ingested but **not served**. An aggregate +spanning exactly one suppressed component names it, and nothing renders them +today — an unused field that leaks is not a trade-off worth carrying. They can +be re-added with their own guard if the fallback ladder is ever built. **R3 — The three pupil groups are one disclosure surface, not three.** Disadvantaged and Not-known-to-be-disadvantaged partition All pupils, so @@ -116,12 +146,17 @@ the counts — the published percentages do not sum to 100. Random 400-school sample, 2022/23, mainstream secondaries (n=262): -| View | Published | Consequence | -|---|---|---| -| All pupils, all categories | **100%** | Full bar works everywhere | -| Disadvantaged, headline rate | 95% | Gap panel works | -| Disadvantaged, three grouped cards | 68% | Degrades card by card | -| Disadvantaged, all six categories | **20%** | Bar unusable for this group | +| View | As published by DfE | After R1–R3 masking | Consequence | +|---|---|---|---| +| All pupils, all categories | 100% | **94%** | Bar works nearly everywhere | +| Disadvantaged, headline rate | 95% | 95% | Gap panel works | +| Disadvantaged, three grouped cards | 68% | 68% | Degrades card by card | +| Disadvantaged, all six categories | 20% | **20%** | Bar unusable for this group | + +The middle column is what the site actually serves. Masking costs the +all-pupils bar on 6% of mainstream secondaries — those are schools where a +category was suppressed in exactly one pupil group and no non-zero companion +existed below the all-pupils row. Special schools and alternative provision are far worse: 13% and 41% respectively have the whole cohort suppressed even for all pupils. The empty state is @@ -325,10 +360,12 @@ and the staging E2E gate runs post-merge. ## Risks -**A later change reintroduces the disclosure.** The likeliest route is someone -applying `safe_numeric` to a destination column for consistency, or adding a -`coalesce` in a mart. Mitigation is the dbt test plus the unit tests on -`canAggregate()` — the rule has to be executable, not documentary. +**A later change reintroduces the disclosure.** The likeliest routes are +applying `safe_numeric` to a destination column for consistency, adding a +`coalesce` in a mart, or — as happened in review — enforcing a disclosure rule +at the rendering layer instead of the publishing layer. Mitigation is the dbt +tests plus `backend/tests/test_destinations_api.py`, which reconstructs the +residual the way an attacker would and asserts it no longer resolves. **The two-year lag reads as staleness.** Mitigated by dating the cohort in the section header rather than only in a tooltip. diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index d7065fa..98ac194 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -2415,11 +2415,20 @@ test('with autosuggest off, the search box is a plain input', async ({ page }) = // ── Destination measures ─────────────────────────────────────────────────── // -// These journeys need marts.fact_ks4_destinations to be populated, which only -// happens after the annual EES DAG runs. Until then the helper below fails the -// suite loudly rather than skipping: a silent skip here would let a genuine -// regression in the sections ride along unnoticed, which is exactly what the -// distance journeys were changed to avoid. +// Two failure modes have to be told apart here, and conflating them is how +// this suite would either hide a regression or block the promotion pipeline: +// +// * the backend does not serve the `destinations` field at all — a code +// regression, or a deploy that did not land. FAILS. +// * the field is served but every school is empty — the annual EES DAG has +// not run on this environment yet. SKIPS, loudly. +// +// The second is a data-load precondition, not a defect, and it is true for +// every commit between this merging and the DAG being triggered. Failing on it +// would redden the staging gate for unrelated work. This is not the quiet skip +// 4f01fbd removed from the distance journeys: that one hid a broken feature +// behind a flag check, whereas the assertion that the code is deployed and +// correctly shaped still runs here on every commit. async function secondaryWithDestinations(page: Page): Promise<{ urn: string; destinations: any; @@ -2431,17 +2440,28 @@ async function secondaryWithDestinations(page: Page): Promise<{ .filter((s: { phase?: string; attainment_8_score?: number | null }) => s.phase === 'Secondary' && s.attainment_8_score != null) .map((s: { urn: number }) => String(s.urn)); + expect(urns.length).toBeGreaterThan(0); + let served = false; for (const urn of urns.slice(0, 25)) { const detail = await page.request.get(`/api/schools/${urn}`); if (!detail.ok()) continue; const data = await detail.json(); + // The key must exist, even as null. Its absence means the backend in front + // of us does not know about destinations at all. + if ('destinations' in data) served = true; if (data.destinations?.ks4) return { urn, destinations: data.destinations }; } - throw new Error( - 'No secondary school returned a destinations block. Either the annual EES ' - + 'DAG has not run on this environment, or the destinations marts are empty.', - ); + + expect(served, + 'GET /api/schools/{urn} served no `destinations` key at all — the backend ' + + 'is missing this feature, not merely missing its data').toBeTruthy(); + + test.skip(true, + 'No school has destination data yet: the annual EES DAG has not run on ' + + 'this environment. The API shape is correct, so this is a data-load ' + + 'precondition rather than a regression.'); + throw new Error('unreachable'); } test('a secondary school page says where its Year 11 leavers went', async ({ page }) => { diff --git a/nextjs-app/__tests__/components/DestinationsSection.test.tsx b/nextjs-app/__tests__/components/DestinationsSection.test.tsx index fa83056..11002e7 100644 --- a/nextjs-app/__tests__/components/DestinationsSection.test.tsx +++ b/nextjs-app/__tests__/components/DestinationsSection.test.tsx @@ -22,7 +22,7 @@ const ALL_PUBLISHED = [ const fullPhase: DestinationPhase = { cohort_year: '2022/23', - groups: { all: { cohort: 180, categories: ALL_PUBLISHED, aggregates: {} } }, + groups: { all: { cohort: 180, categories: ALL_PUBLISHED } }, }; const suppressedPhase: DestinationPhase = { @@ -36,7 +36,6 @@ const suppressedPhase: DestinationPhase = { cell('apprenticeship', 8), cell('employment', 6), cell('not_sustained', 5), cell('not_captured', 4), ], - aggregates: {}, }, }, }; diff --git a/nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx b/nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx index 946d88f..77e8e1c 100644 --- a/nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx +++ b/nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx @@ -14,7 +14,6 @@ const phase: DestinationPhase = { { category: 'employment', pupils: 13, percentage: 13.5, status: 'published' }, { category: 'not_sustained', pupils: 6, percentage: 6.3, status: 'published' }, ], - aggregates: {}, }, }, }; diff --git a/nextjs-app/__tests__/lib/destinations.test.ts b/nextjs-app/__tests__/lib/destinations.test.ts index 81d1ef0..def1949 100644 --- a/nextjs-app/__tests__/lib/destinations.test.ts +++ b/nextjs-app/__tests__/lib/destinations.test.ts @@ -1,5 +1,5 @@ import { - canAggregate, aggregateCells, canRenderPublishedAggregate, + canAggregate, aggregateCells, canRenderBar, toBarSegments, CARD_GROUPS, type DestinationCell, type DestinationGroup, type DestinationCategory, } from '@/lib/destinations'; @@ -19,7 +19,6 @@ const fullGroup = (): DestinationGroup => ({ pub('apprenticeship', 8, 180), pub('employment', 6, 180), pub('not_sustained', 5, 180), pub('not_captured', 4, 180), ], - aggregates: {}, }); describe('canAggregate — R2, computing from components', () => { @@ -47,26 +46,6 @@ describe('aggregateCells', () => { }); }); -describe('canRenderPublishedAggregate — R2, a total DfE published itself', () => { - it('allows it when no component is suppressed', () => { - expect(canRenderPublishedAggregate([ - pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180), - ])).toBe(true); - }); - - it('REFUSES it when exactly one component is suppressed — the aggregate identifies it', () => { - expect(canRenderPublishedAggregate([ - pub('school_sixth_form', 75, 180), sup('sixth_form_college'), - ])).toBe(false); - }); - - it('allows it when two or more components are suppressed', () => { - expect(canRenderPublishedAggregate([ - sup('school_sixth_form'), sup('sixth_form_college'), - ])).toBe(true); - }); -}); - describe('canRenderBar — R1', () => { it('allows a bar when the whole group is published', () => { expect(canRenderBar(fullGroup())).toBe(true); diff --git a/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts b/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts index 670ec07..c91584d 100644 --- a/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts +++ b/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts @@ -17,7 +17,6 @@ const phase = (categories = 1) => ({ category: 'school_sixth_form' as const, pupils: 75, percentage: 41.7, status: 'published' as const, })), - aggregates: {}, }, }, }); diff --git a/nextjs-app/components/school/DestinationsView.tsx b/nextjs-app/components/school/DestinationsView.tsx index 9677e7a..2dae294 100644 --- a/nextjs-app/components/school/DestinationsView.tsx +++ b/nextjs-app/components/school/DestinationsView.tsx @@ -39,7 +39,6 @@ function toGroup(payload: DestinationGroupPayload): DestinationGroup { return { cohort: payload.cohort ?? 0, cells: payload.categories, - aggregates: payload.aggregates, }; } diff --git a/nextjs-app/lib/destinations.ts b/nextjs-app/lib/destinations.ts index aca8996..9129f82 100644 --- a/nextjs-app/lib/destinations.ts +++ b/nextjs-app/lib/destinations.ts @@ -5,9 +5,13 @@ * DfE suppresses individual cells with `c`, and the destination categories sum * to the cohort. So subtracting the published cells from the cohort total * recovers a lone suppressed cell exactly — which is the case on 22% of - * mainstream secondaries. The guards below are what stop this module's - * consumers doing that by accident, and they are why a percentage is never - * reconstructed from a partial sum. + * mainstream secondaries. + * + * The guards here are the SECOND line of defence, not the first. Not drawing a + * number does nothing to stop it being computed, so the real fix lives in + * backend/data_loader.py::_mask_for_disclosure, which withholds a companion + * cell before the figures ever leave the server. These functions keep the UI + * honest about what it draws from an already-safe payload. * * See docs/superpowers/specs/2026-08-28-destination-measures-design.md. */ @@ -40,8 +44,6 @@ export interface DestinationCell { export interface DestinationGroup { cohort: number; cells: DestinationCell[]; - /** Aggregates DfE published itself, keyed by slug. */ - aggregates: Partial>; } /** Display order, which is also bar order: education, then work, then absence. */ @@ -80,15 +82,6 @@ export function aggregateCells( return { pupils, percentage: (pupils / cohort) * 100 }; } -/** - * R2, the other direction: DfE published this total itself. Showing it beside - * the components is safe only when it spans no suppressed component, or two or - * more. Exactly one, and the total names the withheld figure. - */ -export function canRenderPublishedAggregate(components: DestinationCell[]): boolean { - return suppressedCount(components) !== 1; -} - /** R1: a bar is drawable only when nothing in the group is withheld. */ export function canRenderBar(group: DestinationGroup): boolean { return group.cohort > 0 && group.cells.every(c => c.status === 'published'); diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 17688dc..a19d250 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -616,8 +616,6 @@ import type { DestinationCell, PupilGroup } from './destinations'; export interface DestinationGroupPayload { cohort: number | null; categories: DestinationCell[]; - /** Totals DfE published itself. Never computed here — see lib/destinations.ts. */ - aggregates: Partial>; } export interface DestinationPhase { -- 2.54.0 From 2e9b5c83c573cd7541cb589d1da11689b95f22af Mon Sep 17 00:00:00 2001 From: Tudor Date: Sun, 30 Aug 2026 21:35:03 +0100 Subject: [PATCH 14/15] fix(destinations): the masking pass can no longer exit unsafely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found _mask_for_disclosure could return with its invariant broken and say nothing. add_companion only ever withheld a *published* cell, so a group with one suppressed category and every other one not_applicable — routine in special schools and AP, where few categories apply — left the loop with the lone suppressed cell still solvable. Reproduced on a nine-pupil cohort: one hidden cell, cohort served, residual intact. A disclosure-control pass that fails silently is worse than none, because everything downstream trusts it. The loop now runs until the invariant holds and escalates when no companion exists: the pupil group is dropped from the payload, and an empty block serialises as None so the section is absent rather than an empty shell. disclosure_invariant_holds() is exported so tests assert it directly instead of re-deriving it, and an exhaustive test sweeps all 81 suppression patterns of a four-category group. Also fixes a test that set up six measures and checked one: the loop was `for measure in ["school_sixth_form"]`. It now checks every measure, and against the real invariant — none hidden, or at least two, rather than "at least two", which the five published measures would have failed. No regression on real data: 262 mainstream secondaries, all-pupils bar still drawable on 94%, zero invariant violations, one disadvantaged group dropped by the new escalation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- backend/data_loader.py | 129 +++++++++++++----- backend/tests/test_destinations_api.py | 82 ++++++++++- .../2026-08-28-destination-measures-design.md | 12 +- 3 files changed, 181 insertions(+), 42 deletions(-) diff --git a/backend/data_loader.py b/backend/data_loader.py index fc0529c..cad154e 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -839,10 +839,44 @@ def _format_cohort_year(year) -> str | None: return text -def _mask_for_disclosure(groups: dict) -> None: - """Add secondary suppression until no withheld figure can be recovered. +_PUPIL_GROUPS = ("disadvantaged", "other", "all") - Not rendering a number is not the same as not publishing it. This endpoint + +def _lone_hidden_groups(groups: dict) -> list: + """Pupil groups hiding exactly one category — solvable by subtraction.""" + return [ + key for key, group in groups.items() + if sum(1 for c in group["categories"] if c["status"] == "suppressed") == 1 + ] + + +def _lone_hidden_categories(groups: dict) -> list: + """Categories hidden in exactly one of several pupil groups.""" + lone = [] + categories = {c["category"] for g in groups.values() for c in g["categories"]} + for category in categories: + found = [ + c for g in groups.values() for c in g["categories"] + if c["category"] == category + ] + hidden = [c for c in found if c["status"] == "suppressed"] + if len(hidden) == 1 and len(found) > 1: + lone.append(category) + return lone + + +def disclosure_invariant_holds(groups: dict) -> bool: + """Every row and every column hides none, or at least two. + + Public so the tests can assert it directly rather than re-deriving it. + """ + return not _lone_hidden_groups(groups) and not _lone_hidden_categories(groups) + + +def _mask_for_disclosure(groups: dict) -> None: + """Withhold further cells until nothing suppressed can be solved for. + + Not rendering a figure is not the same as not publishing it. This endpoint is public and unauthenticated, so anything left in the payload is published, whatever the UI chooses to draw — the same reasoning the admission_distance field carries in app.py. @@ -855,17 +889,19 @@ def _mask_for_disclosure(groups: dict) -> None: category suppressed in exactly ONE of the three gives itself away. DfE's own answer is secondary suppression: withhold a second cell so the - residual spans two unknowns and identifies neither. This does the same, - iterating because each new suppression can break the other identity, and - terminating because cells are only ever added to the suppressed set. + residual spans two unknowns and identifies neither. - Mutates `groups` in place. + Where no companion can do that — a sparse cohort whose every other category + is `not_applicable`, which is common in special schools and alternative + provision — there is nothing left to withhold, so the pupil group is + DROPPED entirely. An earlier version simply gave up here and returned with + the violation intact and no signal, which is the one outcome this function + must never produce: a disclosure-control pass that fails silently is worse + than none, because everything downstream trusts it. + + Mutates `groups` in place. Guaranteed to return with + disclosure_invariant_holds(groups) true. """ - PAIRS = ("disadvantaged", "other", "all") - - def cells(group_key): - group = groups.get(group_key) - return group["categories"] if group else [] def suppress(cell): if cell["status"] == "published": @@ -875,14 +911,13 @@ def _mask_for_disclosure(groups: dict) -> None: return True return False - def add_companion(candidates): + def add_companion(candidates) -> bool: """Withhold a second cell so the residual spans two unknowns. The companion must carry pupils. Suppressing a zero looks like secondary suppression and protects nothing: the residual still equals - the original withheld figure exactly. Where every remaining cell is - zero there is no companion that helps, so the whole set goes — losing - real data, but that beats publishing what DfE withheld. + the original withheld figure exactly. Returns False when no cell can + do the job, which escalates to dropping the group. """ published = [c for c in candidates if c["status"] == "published"] useful = sorted( @@ -891,31 +926,47 @@ def _mask_for_disclosure(groups: dict) -> None: ) if useful: return suppress(useful[0]) - return any([suppress(c) for c in published]) + # Every remaining cell is zero or not applicable: withholding any of + # them leaves the residual equal to the original figure. + return False - changed = True - while changed: + # Fixpoint: each new suppression can break the other identity. Terminates + # because every pass either adds a suppression, drops a group, or stops. + while not disclosure_invariant_holds(groups): changed = False - # Column rule: a category must be suppressed in none of the three - # pupil groups, or in at least two of them. - categories = {c["category"] for key in PAIRS for c in cells(key)} - for category in categories: - found = [ - c for key in PAIRS for c in cells(key) if c["category"] == category + for category in _lone_hidden_categories(groups): + siblings = [ + c for g in groups.values() for c in g["categories"] + if c["category"] == category ] - hidden = [c for c in found if c["status"] == "suppressed"] - if len(hidden) == 1 and len(found) > 1: - if add_companion(found): - changed = True + if add_companion(siblings): + changed = True - # Row rule: within a group, none suppressed or at least two. - for key in PAIRS: - group_cells = cells(key) - hidden = [c for c in group_cells if c["status"] == "suppressed"] - if len(hidden) == 1: - if add_companion(group_cells): - changed = True + for key in _lone_hidden_groups(groups): + if add_companion(groups[key]["categories"]): + changed = True + + if changed: + continue + + # Nothing left to withhold. Drop the groups that are still solvable, + # and any category still solvable across the groups that remain. + for key in _lone_hidden_groups(groups): + del groups[key] + changed = True + + for category in _lone_hidden_categories(groups): + for group in groups.values(): + for cell in group["categories"]: + if cell["category"] == category and suppress(cell): + changed = True + + if not changed: + # Unreachable given the two escalations above, but a masking pass + # must never spin or exit unsafely. Withhold everything. + groups.clear() + return def _destinations_block(rows: list) -> dict | None: @@ -969,6 +1020,12 @@ def _destinations_block(rows: list) -> dict | None: _mask_for_disclosure(groups) + # Masking can empty the block entirely — a sparse cohort where no group + # could be made safe. Return None so the section is absent rather than + # rendering an empty shell. + if not groups: + return None + return {"cohort_year": _format_cohort_year(latest_year), "groups": groups} diff --git a/backend/tests/test_destinations_api.py b/backend/tests/test_destinations_api.py index a418408..462b7db 100644 --- a/backend/tests/test_destinations_api.py +++ b/backend/tests/test_destinations_api.py @@ -9,7 +9,9 @@ serialiser adds secondary suppression to prevent it. See docs/superpowers/specs/2026-08-28-destination-measures-design.md. """ -from backend.data_loader import _destinations_block, _format_cohort_year +from backend.data_loader import ( + _destinations_block, _format_cohort_year, disclosure_invariant_holds, +) def _row(group, measure, pupils, status, cohort=180, percentage=None, year=202223): @@ -148,13 +150,16 @@ def test_a_category_hidden_in_one_group_is_hidden_in_a_second(): rows.append(_row("other", measure, o, "published", cohort=122)) groups = _destinations_block(rows)["groups"] - for measure in ["school_sixth_form"]: + measures = {c["category"] for g in groups.values() for c in g["categories"]} + assert len(measures) == 6, "the fixture's six measures must all be checked" + + for measure in sorted(measures): hidden = sum( - 1 for key in ("all", "disadvantaged", "other") - for c in groups[key]["categories"] + 1 for g in groups.values() for c in g["categories"] if c["category"] == measure and c["status"] == "suppressed" ) - assert hidden >= 2, f"{measure} is solvable across the pupil groups" + # The invariant is "none, or at least two" — not "at least two". + assert hidden != 1, f"{measure} is solvable across the pupil groups" def test_a_suppressed_cell_never_keeps_its_percentage(): @@ -195,3 +200,70 @@ def test_a_fully_published_group_is_left_alone(): group = _destinations_block(rows)["groups"]["all"] assert all(c["status"] == "published" for c in group["categories"]) assert len(group["categories"]) == 7 + + +def test_the_invariant_is_asserted_directly_not_re_derived(): + """A group with one suppressed category and nothing else to withhold.""" + rows = [ + _row("all", "school_sixth_form", None, "suppressed", cohort=9), + _row("all", "sixth_form_college", None, "not_applicable", cohort=9), + _row("all", "further_education", None, "not_applicable", cohort=9), + ] + block = _destinations_block(rows) + assert block is None or disclosure_invariant_holds(block["groups"]) + + +def test_a_sparse_cohort_with_no_companion_drops_the_group(): + """Special schools and AP routinely have one suppressed category and every + other one not applicable. There is nothing left to withhold, so the group + goes — an earlier version returned here with the violation intact.""" + rows = [ + _row("all", "school_sixth_form", None, "suppressed", cohort=9), + _row("all", "sixth_form_college", None, "not_applicable", cohort=9), + _row("all", "further_education", None, "not_applicable", cohort=9), + _row("all", "apprenticeship", None, "not_applicable", cohort=9), + _row("all", "employment", None, "not_applicable", cohort=9), + _row("all", "not_sustained", None, "not_applicable", cohort=9), + _row("all", "not_captured", None, "not_applicable", cohort=9), + ] + block = _destinations_block(rows) + assert block is None or "all" not in block["groups"], ( + "a group that cannot be made safe must not be served" + ) + + +def test_zeros_are_not_treated_as_a_usable_companion(): + """Suppressing a zero protects nothing — the residual is unchanged. With + only zeros available the group must be dropped, not falsely 'fixed'.""" + rows = [ + _row("all", "school_sixth_form", None, "suppressed", cohort=5), + _row("all", "sixth_form_college", 0, "published", cohort=5), + _row("all", "further_education", 0, "published", cohort=5), + ] + block = _destinations_block(rows) + if block and "all" in block["groups"]: + group = block["groups"]["all"] + published = sum(c["pupils"] for c in group["categories"] + if c["pupils"] is not None) + hidden = [c for c in group["categories"] if c["status"] == "suppressed"] + assert len(hidden) != 1, "a zero companion leaves the figure solvable" + assert group["cohort"] - published != 5 + + +def test_masking_always_terminates_in_a_safe_state(): + """Exhaustive over every suppression pattern of a four-category group.""" + from itertools import product + MEASURES = ["school_sixth_form", "sixth_form_college", + "further_education", "apprenticeship"] + for statuses in product(["published", "suppressed", "not_applicable"], + repeat=len(MEASURES)): + rows = [ + _row("all", m, 3 if st == "published" else None, st, cohort=12) + for m, st in zip(MEASURES, statuses) + ] + block = _destinations_block(rows) + if block is None: + continue + assert disclosure_invariant_holds(block["groups"]), ( + f"invariant broken for {statuses}" + ) diff --git a/docs/superpowers/specs/2026-08-28-destination-measures-design.md b/docs/superpowers/specs/2026-08-28-destination-measures-design.md index 8fceb7f..492167a 100644 --- a/docs/superpowers/specs/2026-08-28-destination-measures-design.md +++ b/docs/superpowers/specs/2026-08-28-destination-measures-design.md @@ -69,7 +69,17 @@ identity, and terminates because cells are only ever added. The companion must carry pupils. Suppressing a zero looks like secondary suppression and protects nothing: the residual still equals the original -withheld figure. Where no non-zero companion exists, the whole set is withheld. +withheld figure. + +Where no companion can do the job — a sparse cohort whose every other category +is `not_applicable`, routine in special schools and alternative provision — the +pupil group is **dropped from the payload entirely**. A first version simply +returned at that point with the violation intact and no signal, which review +caught: a disclosure-control pass that fails silently is worse than none, +because everything downstream trusts it. The function now cannot terminate +except in a state where `disclosure_invariant_holds()` is true, and an +exhaustive test sweeps all 81 suppression patterns of a four-category group to +prove it. Measured cost on the 400-school sample: the all-pupils bar survives on **94%** of mainstream secondaries rather than 100%. That is the price of not -- 2.54.0 From cbe3a9a772efbb17a0e7061412c6ee06a0ac2251 Mon Sep 17 00:00:00 2001 From: Tudor Date: Sun, 30 Aug 2026 21:48:39 +0100 Subject: [PATCH 15/15] fix(destinations): the table said 'withheld' for a category that just doesn't apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Share column keyed off `percentage === null`, which is true for not_applicable as well as suppressed, so a destination that does not apply to the school was labelled as one DfE withheld — while the Pupils column in the same row rendered blank. Two columns, one row, disagreeing about what the row was, and one of them making a claim about DfE that wasn't true. Both columns now derive from `status`, which is the distinction the mart, the SQLAlchemy model and the serialiser all preserve deliberately: published shows the figure, suppressed shows the withheld badge, not_applicable shows an em-dash with a title saying so. A published count with no published percentage now derives its share from the cohort rather than falling through to a marker — both halves are published, so nothing withheld is involved, and it is the same derivation the bar widths already use. Verified the new tests fail against the old logic before keeping them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob --- .../components/DestinationsSection.test.tsx | 58 +++++++++++++++++++ .../components/school/DestinationsView.tsx | 52 +++++++++++++---- .../components/school/destinations.module.css | 8 +++ 3 files changed, 107 insertions(+), 11 deletions(-) diff --git a/nextjs-app/__tests__/components/DestinationsSection.test.tsx b/nextjs-app/__tests__/components/DestinationsSection.test.tsx index 11002e7..d744423 100644 --- a/nextjs-app/__tests__/components/DestinationsSection.test.tsx +++ b/nextjs-app/__tests__/components/DestinationsSection.test.tsx @@ -89,3 +89,61 @@ describe('DestinationsSection', () => { expect(container.firstChild).toBeNull(); }); }); + +describe('the detail table keeps the three statuses apart', () => { + // 'suppressed' and 'not_applicable' are different claims, and the mart, the + // SQLAlchemy model and the serialiser all preserve the difference. The table + // used to key its Share column off `percentage === null`, which is true for + // both, so a category that simply does not apply was labelled "withheld" — + // while the Pupils column beside it rendered blank. + const mixedPhase: DestinationPhase = { + cohort_year: '2022/23', + groups: { + all: { + cohort: 180, + categories: [ + cell('school_sixth_form', 75), + cell('sixth_form_college', null, 'suppressed'), + cell('further_education', null, 'suppressed'), + cell('apprenticeship', null, 'not_applicable'), + ], + }, + }, + }; + + const rowFor = (container: HTMLElement, category: string) => + Array.from(container.querySelectorAll('tbody tr')) + .find(tr => tr.textContent?.includes(category)); + + it('never labels a not-applicable category as withheld', () => { + const { container } = render(); + const row = rowFor(container, 'Apprenticeship'); + expect(row).toBeTruthy(); + expect(row!.textContent).not.toMatch(/withheld/i); + }); + + it('labels a genuinely suppressed category as withheld in both columns', () => { + const { container } = render(); + const row = rowFor(container, 'Sixth-form college'); + expect(row).toBeTruthy(); + expect(row!.querySelectorAll('td')).toHaveLength(2); + Array.from(row!.querySelectorAll('td')).forEach(td => + expect(td.textContent).toMatch(/withheld/i)); + }); + + it('the two columns of a row never disagree about what the row is', () => { + const { container } = render(); + Array.from(container.querySelectorAll('tbody tr')).forEach(tr => { + const cells = Array.from(tr.querySelectorAll('td')) + .map(td => /withheld/i.test(td.textContent ?? '')); + expect(new Set(cells).size).toBe(1); + }); + }); + + it('shows a published category its real figures', () => { + const { container } = render(); + const row = rowFor(container, 'State-funded school sixth form'); + expect(row!.textContent).toMatch(/75/); + expect(row!.textContent).toMatch(/42%/); + }); +}); diff --git a/nextjs-app/components/school/DestinationsView.tsx b/nextjs-app/components/school/DestinationsView.tsx index 2dae294..f538d57 100644 --- a/nextjs-app/components/school/DestinationsView.tsx +++ b/nextjs-app/components/school/DestinationsView.tsx @@ -47,6 +47,44 @@ function cellsFor(group: DestinationGroup, card: CardGroup): DestinationCell[] { return group.cells.filter(c => wanted.has(c.category)); } +/** + * One cell of the detail table. + * + * The three statuses are three different statements and the table has to keep + * them apart, because the whole pipeline does — the mart, the SQLAlchemy model + * and the serialiser all preserve the difference deliberately: + * + * published the figure + * suppressed DfE withheld it to protect a small number of pupils + * not_applicable this destination does not apply to this school at all + * + * An earlier version keyed the share column off `percentage === null`, which is + * also true for not_applicable, so a category that simply does not apply was + * labelled "withheld" — while the pupils column beside it rendered blank. Both + * columns now derive from `status`, so they cannot disagree. + */ +function cellValue( + cell: DestinationCell, cohort: number, kind: 'pupils' | 'share', +) { + if (cell.status === 'suppressed') { + return withheld; + } + const notApplicable = ( + + — + + ); + + if (cell.status !== 'published' || cell.pupils === null) return notApplicable; + if (kind === 'pupils') return cell.pupils; + + // Percentages come from the mart, but a published count with no published + // percentage is recoverable from the cohort — both halves are published, so + // nothing withheld is involved. Same derivation the bar widths use. + const share = cell.percentage ?? (cohort > 0 ? (cell.pupils / cohort) * 100 : null); + return share === null ? notApplicable : `${Math.round(share)}%`; +} + export function DestinationsView({ destinations, phase, }: { destinations: DestinationPhase; phase: 'ks4' | 'ks5' }) { @@ -193,27 +231,19 @@ export function DestinationsView({ const cell = group.cells.find(c => c.category === category); if (!cell) return []; const card = cardGroupFor(category); - const isWithheld = cell.status === 'suppressed'; return [( {CATEGORY_LABELS[category]} - - {isWithheld - ? withheld - : cell.pupils} - - - {isWithheld || cell.percentage === null - ? withheld - : `${Math.round(cell.percentage)}%`} - + {cellValue(cell, group.cohort, 'pupils')} + {cellValue(cell, group.cohort, 'share')} )]; })} diff --git a/nextjs-app/components/school/destinations.module.css b/nextjs-app/components/school/destinations.module.css index 0985196..28896f3 100644 --- a/nextjs-app/components/school/destinations.module.css +++ b/nextjs-app/components/school/destinations.module.css @@ -297,3 +297,11 @@ font-size: var(--step--2); color: var(--text-muted); } + +/* A destination that does not apply to this school. Deliberately not the + withheld badge: "we are not told" and "there is nothing to tell" are + different statements, and the rest of the pipeline keeps them apart. */ +.notApplicable { + color: var(--text-muted); + cursor: help; +} -- 2.54.0