diff --git a/.gitignore b/.gitignore index 47b99235..be55c689 100644 --- a/.gitignore +++ b/.gitignore @@ -8,12 +8,14 @@ *info_*.png *info_log_parsed.txt .DS_Store +.agents .codex .coverage .idea/ .pytest_cache/ .venv .vs/ +/skills-lock.json /tts/saapi /tts/x64 __pycache__/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0940de61..68f92828 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ default_language_version: minimum_prek_version: '0.3.0' repos: - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.16 + rev: 0.11.21 hooks: - id: uv-lock priority: &group1 4294967294 @@ -32,7 +32,7 @@ repos: priority: *group1 files: \.(cpp|h)$ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files priority: &read-only 4294967295 @@ -62,7 +62,7 @@ repos: priority: *group1 - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.15 + rev: v0.15.17 hooks: - id: ruff-format priority: *group1 diff --git a/.scratch/typed-paragon-payload/PRD.md b/.scratch/typed-paragon-payload/PRD.md new file mode 100644 index 00000000..d78d1770 --- /dev/null +++ b/.scratch/typed-paragon-payload/PRD.md @@ -0,0 +1,124 @@ +# Typed Paragon Payload Profile Schema + +Status: complete + +## Problem Statement + +Paragon payload data in a profile is currently typed as an open-ended mapping or list of mappings. That lets malformed Paragon overlay data pass profile validation and pushes schema assumptions into importer and overlay code. A profile can currently contain arbitrary Paragon data, even though the application expects a specific shape: one Paragon payload with a build name, optional metadata, and one or more Paragon progression steps made of 21x21 board node grids. + +This weak typing makes the Paragon overlay fragile. Importers can write incomplete or malformed payloads, profile loading can accept data the overlay cannot render, and future changes do not have a clear schema contract. + +## Solution + +Make the profile's `Paragon` section a first-class Pydantic model instead of a free-form object. + +The profile schema should represent exactly one stored Paragon payload per profile. That payload can contain multiple Paragon progression steps for the same imported build. Legacy single-payload list shapes should still be accepted as migration tolerance, but profile export should write one payload object, not a list. + +Paragon import builders should return the typed Paragon payload model so malformed importer output fails before profile save. Profile loading should keep typed Paragon payloads through the filter layer. The Paragon overlay should consume typed model attributes instead of raw dictionary keys. + +## User Stories + +1. As a profile author, I want invalid Paragon data to fail validation, so that broken profile files do not silently reach the overlay. +1. As a profile author, I want clear validation errors for malformed Paragon data, so that I can fix profile YAML manually when needed. +1. As a profile author, I want a profile to contain at most one Paragon payload, so that the profile schema matches the intended domain model. +1. As a profile author with older data, I want a legacy single-item `Paragon` list to keep loading, so that existing profiles do not break unnecessarily. +1. As a profile author with bad older data, I want a multi-item `Paragon` list to be rejected, so that ambiguous multiple-payload profiles are not treated as supported. +1. As a profile author, I want profile export to write `Paragon` as one object, so that new files follow the canonical schema. +1. As a profile author, I want `ParagonBoardsList` to support multiple Paragon progression steps, so that leveling or progression states remain available. +1. As a profile author with older data, I want a direct board list in `ParagonBoardsList` to normalize into one progression step, so that simple old payloads still load. +1. As a profile author, I want an empty `ParagonBoardsList` to fail validation, so that a stored Paragon payload always contains renderable board data. +1. As a profile author, I want each Paragon board to require a name, so that the overlay can present readable board choices. +1. As a profile author, I want each Paragon board to require exactly 441 node values, so that every board represents the expected 21x21 grid. +1. As a profile author, I want all-false node grids to be valid, so that a board can exist before selected nodes are present. +1. As a profile author, I want board rotation to accept common input forms and normalize to the current stored string format, so that profile YAML stays compatible. +1. As a profile author, I want only supported rotations to validate, so that the overlay never receives impossible board angles. +1. As a profile author, I want unknown Paragon payload keys to be rejected, so that typos and unsupported metadata do not become accidental schema. +1. As a profile author, I want unknown Paragon board keys to be rejected, so that source-specific data must be deliberately modeled. +1. As a Maxroll importer user, I want imported board IDs and glyph IDs to remain supported, so that useful source metadata is not lost. +1. As a Mobalytics importer user, I want imported Paragon data to validate before saving, so that bad source extraction fails early. +1. As a D4Builds importer user, I want reconstructed Paragon data to validate before saving, so that DOM parsing mistakes are caught. +1. As a Paragon overlay user, I want the overlay to read typed Paragon payloads, so that runtime rendering no longer depends on arbitrary dictionaries. +1. As a maintainer, I want the filter layer to expose typed Paragon payloads, so that downstream code has a clear contract. +1. As a maintainer, I want tests around the profile schema, so that future Paragon changes cannot weaken validation accidentally. +1. As a maintainer, I want tests around serialization aliases, so that generated YAML remains compatible with existing profile conventions. +1. As a maintainer, I want legacy normalization covered by tests, so that compatibility behavior is intentional and documented. +1. As a maintainer, I want importer return types to be precise, so that `dict[str, Any]` does not keep spreading through Paragon code. +1. As a maintainer, I want profile model errors to identify the faulty Paragon field, so that support/debugging is faster. +1. As a maintainer, I want the existing overlay UI behavior preserved, so that this schema fix does not become a UI rewrite. +1. As a maintainer, I want no new concept of alternative Paragon builds inside one profile, so that the domain model stays simple. +1. As a maintainer, I want optional payload metadata to remain optional, so that stripped or manually authored profiles can still be valid. +1. As a maintainer, I want generated payload metadata to continue being written by importers, so that saved profiles still show source and generator context. + +## Implementation Decisions + +- Add a dedicated Paragon board model to the profile schema. It should model board name, glyph, rotation, node grid, and known optional source IDs. +- Add a dedicated Paragon payload model to the profile schema. It should model payload name, optional source metadata, and the payload's Paragon progression steps. +- The profile's `Paragon` field should become `ParagonPayloadModel | None`. +- A profile may include at most one stored Paragon payload. Multiple Paragon payloads in one profile are not part of the supported domain model. +- A Paragon payload may contain multiple Paragon progression steps. Each step is a list of Paragon boards. +- Legacy `Paragon: [payload]` input should normalize to `Paragon: payload`. +- Legacy `Paragon: []` input should normalize to no Paragon payload. +- Legacy `Paragon: [payload1, payload2]` input should fail validation with a clear error. +- Official `ParagonBoardsList` shape is a list of progression steps. +- Legacy direct board-list input in `ParagonBoardsList` should normalize to one progression step. +- Empty `ParagonBoardsList` should fail validation. +- Board `Nodes` must contain exactly 441 boolean-compatible values. +- Board `Nodes` may be all false. +- Board `Rotation` should export as the current string format: `0°`, `90°`, `180°`, or `270°`. +- Rotation input may accept integer or digit-only string forms, but validation should normalize to the canonical stored string. +- Payload metadata fields such as source URL, generated timestamp, and generator should be optional strings. +- Unknown fields should be forbidden on Paragon payloads and boards. +- Known optional Maxroll metadata such as board ID and glyph ID should stay modeled. +- Paragon import builders should return the typed Paragon payload model, not a raw dictionary. +- The filter layer should store and expose typed Paragon payload models. +- The Paragon overlay should consume typed model attributes rather than dictionary key lookups. +- Profile serialization should keep existing public YAML aliases such as `Paragon`, `ParagonBoardsList`, `Name`, `Glyph`, `Rotation`, and `Nodes`. +- Exported profiles should write the canonical one-payload shape, even when input used a legacy tolerated shape. +- No ADR is needed for this change. The decision is a schema tightening with compatibility rules, not a hard-to-reverse architectural trade-off. + +## Testing Decisions + +- Tests should assert external behavior: profile validation, normalized model values, serialization output, importer-builder return behavior, and overlay-facing loaded data shape. +- Add profile model tests following the existing config model test style. +- Cover valid Paragon payload construction with canonical aliases. +- Cover snake-case and alias behavior where it matters for existing model conventions. +- Cover rejection of unknown payload fields. +- Cover rejection of unknown board fields. +- Cover missing required payload name. +- Cover missing required board name. +- Cover missing nodes. +- Cover node count shorter than 441. +- Cover node count longer than 441. +- Cover all-false nodes as valid. +- Cover valid rotations for 0, 90, 180, and 270 degrees. +- Cover invalid rotations. +- Cover rotation normalization from integer and string inputs if implemented. +- Cover legacy `Paragon: []` normalization. +- Cover legacy `Paragon: [payload]` normalization. +- Cover legacy multi-payload list rejection. +- Cover canonical `ParagonBoardsList` list-of-steps input. +- Cover legacy direct board-list normalization to one progression step. +- Cover serialization with aliases so saved YAML keeps existing field names. +- Cover importer builder returning a typed Paragon payload model. +- Cover the filter layer returning typed Paragon payloads after profile load where practical with existing filter tests. +- Keep overlay tests focused at the seam where typed models are converted into overlay build rows; avoid testing tkinter rendering internals. +- Prior art exists in the current profile model tests, importer tests, and filter profile-loading tests. + +## Out of Scope + +- Changing Paragon overlay UI behavior. +- Changing board rendering, node layout, or rotation math. +- Supporting multiple alternative Paragon payloads inside one profile. +- Adding a migration command that rewrites profile files on disk. +- Adding new Paragon importer sources. +- Validating whether a board name, glyph name, board ID, or glyph ID exists in Diablo 4 game data. +- Changing how item, sigil, tribute, or aspect profile sections are modeled. +- Reworking profile editor UI to edit Paragon payloads manually. + +## Further Notes + +The domain glossary defines a profile as a user-defined loot filtering configuration for one Diablo 4 build. A profile may include at most one stored Paragon payload. A Paragon payload represents one imported Paragon build and may contain multiple Paragon progression steps. + +This PRD deliberately preserves current YAML names and the current importer payload concept while replacing the permissive `dict[str, object] | list[dict[str, object]]` schema with a strict model. + +Verified with focused tests: `172 passed, 6 skipped`. diff --git a/.scratch/typed-paragon-payload/issues/01-type-paragon-payload-schema.md b/.scratch/typed-paragon-payload/issues/01-type-paragon-payload-schema.md new file mode 100644 index 00000000..1eb6408d --- /dev/null +++ b/.scratch/typed-paragon-payload/issues/01-type-paragon-payload-schema.md @@ -0,0 +1,40 @@ +# Type the Paragon payload schema + +Status: complete + +## Parent + +.scratch/typed-paragon-payload/PRD.md + +## What to build + +Make the profile's `Paragon` section a first-class typed schema. A profile should contain at most one Paragon payload, and that payload should contain one or more Paragon progression steps made of boards with valid 21x21 node grids. + +The schema should reject arbitrary payload and board keys, preserve current YAML aliases, validate rotations and node counts, and normalize the tolerated legacy shapes into the canonical one-payload format. + +## Acceptance criteria + +- [x] Profile validation accepts one canonical Paragon payload with one or more Paragon progression steps. +- [x] Profile validation rejects multiple Paragon payloads in one profile. +- [x] Empty `Paragon` legacy list input normalizes to no Paragon payload. +- [x] Single-item `Paragon` legacy list input normalizes to one Paragon payload. +- [x] Direct board-list `ParagonBoardsList` input normalizes to one Paragon progression step. +- [x] Empty `ParagonBoardsList` fails validation. +- [x] Boards require a non-empty name. +- [x] Boards require exactly 441 node values. +- [x] All-false node grids are valid. +- [x] Rotation accepts supported values and normalizes to the canonical stored string. +- [x] Unsupported rotation values fail validation. +- [x] Unknown payload fields fail validation. +- [x] Unknown board fields fail validation, except deliberately modeled optional source IDs. +- [x] Serialized profile output keeps existing public aliases such as `Paragon`, `ParagonBoardsList`, `Name`, `Glyph`, `Rotation`, and `Nodes`. +- [x] Focused profile model tests cover valid, invalid, normalization, and serialization behavior. + +## Blocked by + +None - can start immediately. + +## Comments + +- Implemented the typed Paragon board/payload schema, legacy normalization, alias-preserving serialization, and validation coverage. +- Verified with focused tests: `137 passed, 26 skipped`. diff --git a/.scratch/typed-paragon-payload/issues/02-return-typed-paragon-payloads-from-importers.md b/.scratch/typed-paragon-payload/issues/02-return-typed-paragon-payloads-from-importers.md new file mode 100644 index 00000000..2634ff9e --- /dev/null +++ b/.scratch/typed-paragon-payload/issues/02-return-typed-paragon-payloads-from-importers.md @@ -0,0 +1,30 @@ +# Return typed Paragon payloads from importers + +Status: complete + +## Parent + +.scratch/typed-paragon-payload/PRD.md + +## What to build + +Change Paragon importer payload construction so imported Paragon data becomes a typed Paragon payload before it is assigned to a profile. Maxroll, Mobalytics, and D4Builds imports should still save the same user-facing YAML shape, but malformed extracted payloads should fail at the importer/profile boundary instead of reaching the overlay. + +## Acceptance criteria + +- [x] Paragon payload construction returns the typed Paragon payload model instead of a raw mapping. +- [x] Maxroll Paragon import preserves board IDs and glyph IDs where present. +- [x] Mobalytics Paragon import still produces valid payloads from existing extracted board and node data. +- [x] D4Builds Paragon import still produces valid payloads from reconstructed board data. +- [x] Importer-generated profiles still serialize with the existing Paragon YAML aliases. +- [x] Tests cover typed payload construction and at least one importer path using the builder. +- [x] Existing importer behavior unrelated to Paragon remains unchanged. + +## Blocked by + +- .scratch/typed-paragon-payload/issues/01-type-paragon-payload-schema.md + +## Comments + +- Paragon payload construction now returns `ParagonPayloadModel`, and Maxroll/Mobalytics/D4Builds paths are covered by regression tests. +- Verified with focused tests: `137 passed, 26 skipped`. diff --git a/.scratch/typed-paragon-payload/issues/03-carry-typed-paragon-payloads-through-filter-and-overlay.md b/.scratch/typed-paragon-payload/issues/03-carry-typed-paragon-payloads-through-filter-and-overlay.md new file mode 100644 index 00000000..d9988fcf --- /dev/null +++ b/.scratch/typed-paragon-payload/issues/03-carry-typed-paragon-payloads-through-filter-and-overlay.md @@ -0,0 +1,30 @@ +# Carry typed Paragon payloads through filter and overlay + +Status: complete + +## Parent + +.scratch/typed-paragon-payload/PRD.md + +## What to build + +Keep typed Paragon payloads after profile validation and pass them through profile loading into the Paragon overlay. The overlay should build its selectable Paragon build rows from model attributes rather than raw dictionary keys, while preserving current overlay behavior and user-facing labels. + +## Acceptance criteria + +- [x] Profile loading stores typed Paragon payloads for profiles that contain Paragon data. +- [x] The filter layer exposes typed Paragon payloads to Paragon consumers. +- [x] The Paragon overlay reads payload names, progression steps, boards, rotations, glyphs, and nodes from typed model attributes. +- [x] Existing overlay behavior is preserved, including newest progression step first. +- [x] Legacy shapes accepted by the profile model still reach the overlay as canonical typed payloads. +- [x] No tkinter rendering behavior is intentionally changed. +- [x] Tests cover the overlay-facing build-row seam using typed Paragon payloads. + +## Blocked by + +- .scratch/typed-paragon-payload/issues/01-type-paragon-payload-schema.md + +## Comments + +- The filter layer now exposes typed Paragon payloads and the overlay builds rows from model attributes. +- Verified with focused tests: `137 passed, 26 skipped`. diff --git a/.scratch/typed-paragon-payload/issues/04-regression-coverage-for-end-to-end-profile-behavior.md b/.scratch/typed-paragon-payload/issues/04-regression-coverage-for-end-to-end-profile-behavior.md new file mode 100644 index 00000000..8a3b8904 --- /dev/null +++ b/.scratch/typed-paragon-payload/issues/04-regression-coverage-for-end-to-end-profile-behavior.md @@ -0,0 +1,31 @@ +# Regression coverage for end-to-end profile behavior + +Status: complete + +## Parent + +.scratch/typed-paragon-payload/PRD.md + +## What to build + +Add final regression coverage across the profile, importer, filter, and overlay seams so the typed Paragon payload contract stays intact end to end. This issue should remove or update any remaining tests or assumptions that treat Paragon data as arbitrary dictionaries or lists. + +## Acceptance criteria + +- [x] Tests verify a profile with canonical Paragon data can be validated, serialized, loaded, and exposed for overlay use. +- [x] Tests verify legacy tolerated Paragon shapes become canonical typed payloads before overlay use. +- [x] Tests verify invalid Paragon payloads fail before overlay use. +- [x] Tests verify importer-generated Paragon data remains compatible with profile serialization. +- [x] Any stale test expectations around `dict[str, object] | list[dict[str, object]]` are removed or updated. +- [x] Targeted tests for config models, importer payload construction, filter loading, and overlay build-row creation pass. + +## Blocked by + +- .scratch/typed-paragon-payload/issues/01-type-paragon-payload-schema.md +- .scratch/typed-paragon-payload/issues/02-return-typed-paragon-payloads-from-importers.md +- .scratch/typed-paragon-payload/issues/03-carry-typed-paragon-payloads-through-filter-and-overlay.md + +## Comments + +- Added regression coverage across schema, serialization, importer, filter, and overlay seams. +- Verified with focused tests: `137 passed, 26 skipped`. diff --git a/AGENTS.md b/AGENTS.md index 63f0af39..9e578df4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -221,3 +221,17 @@ All UI coordinates in `src/config/data.py` are defined at **3840×2160** (UHD) a - User data directory: `~/.d4lf/` (profiles, params.ini, logs). - Version is defined in `src/__init__.py` as `__version__`. - Existing comments should be kept in place unless the code they relate to is removed + +## Agent skills + +### Issue tracker + +Issues and PRDs are tracked as local markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Triage labels use the default canonical strings. See `docs/agents/triage-labels.md`. + +### Domain docs + +This repo uses a single-context domain docs layout. See `docs/agents/domain.md`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..b03d6ada --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,15 @@ +# Domain Context + +## Glossary + +### Profile + +A user-defined loot filtering configuration for one Diablo 4 build. A profile may include at most one stored Paragon payload for the Paragon overlay. + +### Paragon payload + +The stored Paragon overlay data attached to a profile. It represents one imported Paragon build, not a collection of alternative builds. A payload may contain multiple progression steps for that build. + +### Paragon progression step + +One board-state snapshot within a Paragon payload. Each step contains the boards and active nodes for a point in the imported build's progression. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..5c0bbd51 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,35 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`docs/adr/`** -- read ADRs that touch the area you're about to work in. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +|-- CONTEXT.md +|-- docs/adr/ +| |-- 0001-event-sourced-orders.md +| `-- 0002-postgres-for-write-model.md +`-- src/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal -- either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) -- but worth reopening because..._ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..a2f08fb0 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,19 @@ +# Issue tracker: Local Markdown + +Issues and PRDs for this repo live as markdown files in `.scratch/`. + +## Conventions + +- One feature per directory: `.scratch//` +- The PRD is `.scratch//PRD.md` +- Implementation issues are `.scratch//issues/-.md`, numbered from `01` +- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings) +- Comments and conversation history append to the bottom of the file under a `## Comments` heading + +## When a skill says "publish to the issue tracker" + +Create a new file under `.scratch//` (creating the directory if needed). + +## When a skill says "fetch the relevant ticket" + +Read the file at the referenced path. The user will normally pass the path or the issue number directly. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 00000000..b716855d --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/src/config/profile_models.py b/src/config/profile_models.py index f75b3f31..024f2686 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -2,9 +2,10 @@ import enum import logging +import re import sys -from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, RootModel, field_serializer, field_validator, model_validator from src.config.helper import check_greater_than_zero, validate_percent from src.item.data.item_type import ItemType # noqa: TC001 @@ -315,6 +316,110 @@ def parse_rarities(cls, data: str | list[str]) -> list[str]: return _parse_item_type_or_rarities(data) +class ParagonBoardModel(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + name: str = Field(alias="Name") + glyph: str = Field(default="", alias="Glyph") + rotation: str = Field(default="0°", alias="Rotation") + nodes: list[bool] = Field(alias="Nodes") + board_id: str | None = Field(default=None, alias="BoardId") + glyph_id: str | None = Field(default=None, alias="GlyphId") + + @field_validator("name") + @classmethod + def name_must_not_be_empty(cls, name: str) -> str: + if not name.strip(): + msg = "Name must not be empty" + raise ValueError(msg) + return name + + @field_validator("rotation", mode="before") + @classmethod + def normalize_rotation(cls, rotation: object) -> str: + if isinstance(rotation, int) and not isinstance(rotation, bool): + degrees = rotation + elif isinstance(rotation, str): + match = re.search(r"^\s*(\d+)\s*°?\s*$", rotation) + if not match: + msg = "Rotation must be one of 0, 90, 180, or 270 degrees" + raise ValueError(msg) + degrees = int(match.group(1)) + else: + msg = "Rotation must be an integer or string" + raise ValueError(msg) + + if degrees not in {0, 90, 180, 270}: + msg = "Rotation must be one of 0, 90, 180, or 270 degrees" + raise ValueError(msg) + return f"{degrees}°" + + @field_validator("nodes", mode="before") + @classmethod + def validate_nodes(cls, nodes: object) -> list[object]: + if not isinstance(nodes, list): + msg = "Nodes must be a list of 441 boolean-compatible values" + raise ValueError(msg) + if len(nodes) != 441: + msg = "Nodes must contain exactly 441 values" + raise ValueError(msg) + return nodes + + +class ParagonPayloadModel(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + name: str = Field(alias="Name") + source: str | None = Field(default=None, alias="Source") + generated_at: str | None = Field(default=None, alias="GeneratedAt") + generator: str | None = Field(default=None, alias="Generator") + paragon_boards_list: list[list[ParagonBoardModel]] = Field(default_factory=list, alias="ParagonBoardsList") + + @field_validator("name") + @classmethod + def name_must_not_be_empty(cls, name: str) -> str: + if not name.strip(): + msg = "Name must not be empty" + raise ValueError(msg) + return name + + @model_validator(mode="before") + @classmethod + def normalize_paragon_boards_list(cls, data: object) -> object: + if not isinstance(data, dict): + return data + + key = ( + "ParagonBoardsList" + if "ParagonBoardsList" in data + else "paragon_boards_list" + if "paragon_boards_list" in data + else None + ) + if key is None: + return data + + boards_list = data[key] + if not isinstance(boards_list, list): + return data + if not boards_list: + msg = "ParagonBoardsList must not be empty" + raise ValueError(msg) + if all(not isinstance(step, list) for step in boards_list): + normalized = dict(data) + normalized.pop(key, None) + normalized["ParagonBoardsList"] = [boards_list] + return normalized + return data + + @model_validator(mode="after") + def paragon_boards_list_must_not_be_empty(self) -> ParagonPayloadModel: + if not self.paragon_boards_list or any(not step for step in self.paragon_boards_list): + msg = "ParagonBoardsList must not be empty" + raise ValueError(msg) + return self + + class ProfileModel(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) affixes: list[DynamicItemFilterModel] = Field(default=[], alias="Affixes") @@ -325,7 +430,7 @@ class ProfileModel(BaseModel): default=SigilFilterModel(blacklist=[], whitelist=[], priority=SigilPriority.blacklist), alias="Sigils" ) tributes: list[TributeFilterModel] = Field(default=[], alias="Tributes") - paragon: dict[str, object] | list[dict[str, object]] | None = Field(default=None, alias="Paragon") + paragon: ParagonPayloadModel | None = Field(default=None, alias="Paragon") @model_validator(mode="before") def aspects_must_exist(self) -> ProfileModel: @@ -344,3 +449,34 @@ def aspects_must_exist(self) -> ProfileModel: raise ValueError(msg) return self + + @model_validator(mode="before") + @classmethod + def normalize_paragon(cls, data: object) -> object: + if not isinstance(data, dict): + return data + + key = "Paragon" if "Paragon" in data else "paragon" if "paragon" in data else None + if key is None: + return data + + paragon = data[key] + if paragon is None: + return data + if not isinstance(paragon, list): + return data + if not paragon: + return {**data, key: None} + if len(paragon) > 1: + msg = "Paragon must contain at most one payload" + raise ValueError(msg) + if not isinstance(paragon[0], dict): + msg = "Paragon legacy list entries must be objects" + raise ValueError(msg) + return {**data, key: paragon[0]} + + @field_serializer("paragon", when_used="json-unless-none") + def serialize_paragon(self, paragon: ParagonPayloadModel | None) -> object: + if paragon is None: + return None + return paragon.model_dump(mode="python", by_alias=True, exclude_none=True, exclude_defaults=True) diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index 2835a09e..5ed1eb04 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -298,13 +298,17 @@ def add_to_profiles(build_name): # Built in to_yaml_str does not preserve the order of the attributes of the model, which is important for uniques def _to_yaml_str(profile: ProfileModel, exclude_defaults: bool, exclude: set[str]) -> str: - str_val = profile.model_dump_json(by_alias=False, exclude_defaults=exclude_defaults, exclude=exclude) + str_val = profile.model_dump_json( + by_alias=False, exclude_defaults=exclude_defaults, exclude_none=True, exclude=exclude + ) yaml = YAML() yaml.default_flow_style = None # Back to original dict_val = yaml.load(str_val) + if "paragon" in dict_val: + dict_val["Paragon"] = dict_val.pop("paragon") _sort_profile_sections(dict_val) _rm_style_info(dict_val) - _use_block_style(dict_val.get("aspect_upgrades")) + _use_block_style(dict_val) stream = StringIO() yaml.dump(dict_val, stream) stream.seek(0) @@ -312,13 +316,23 @@ def _to_yaml_str(profile: ProfileModel, exclude_defaults: bool, exclude: set[str def _sort_profile_sections(d): - if isinstance(d, dict) and isinstance(d.get("aspect_upgrades"), list): - d["aspect_upgrades"].sort(key=str.casefold) + if not isinstance(d, dict): + return + + for key in ("aspect_upgrades", "AspectUpgrades"): + if isinstance(d.get(key), list): + d[key].sort(key=str.casefold) + break def _use_block_style(d): - if hasattr(d, "fa"): - d.fa.set_block_style() + if not isinstance(d, dict): + return + + for key in ("aspect_upgrades", "AspectUpgrades"): + if hasattr(d.get(key), "fa"): + d[key].fa.set_block_style() + break def _rm_style_info(d): diff --git a/src/gui/importer/paragon_export.py b/src/gui/importer/paragon_export.py index 56c914cd..f993b046 100644 --- a/src/gui/importer/paragon_export.py +++ b/src/gui/importer/paragon_export.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any from src import __version__ +from src.config.profile_models import ParagonPayloadModel from src.gui.importer.gui_common import PLAYER_CLASSES try: @@ -315,18 +316,18 @@ def _maxroll_glyph_slug(glyph_id: str, board_id: str) -> str: def build_paragon_profile_payload( build_name: str, source_url: str, paragon_boards_list: list[list[dict[str, Any]]] -) -> dict[str, Any]: +) -> ParagonPayloadModel: """Build the Paragon payload intended to be embedded into a profile YAML. The structure matches the existing JSON export payload (without the outer list wrapper). """ - return { - "Name": build_name, - "Source": source_url, - "GeneratedAt": datetime.datetime.now(tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%S UTC"), - "Generator": f"d4lf v{__version__}", - "ParagonBoardsList": paragon_boards_list, - } + return ParagonPayloadModel( + Name=build_name, + Source=source_url, + GeneratedAt=datetime.datetime.now(tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%S UTC"), + Generator=f"d4lf v{__version__}", + ParagonBoardsList=paragon_boards_list, + ) # diff --git a/src/item/filter.py b/src/item/filter.py index f48e7c3f..367c5c70 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -17,6 +17,7 @@ AffixFilterModel, DynamicItemFilterModel, GlobalUniqueModel, + ParagonPayloadModel, ProfileModel, SigilConditionModel, SigilFilterModel, @@ -65,7 +66,7 @@ def construct_mapping(self, node: MappingNode, deep=False): class Filter: affix_filters = {} aspect_upgrade_filters = {} - paragon_filters = {} + paragon_filters: dict[str, ParagonPayloadModel] = {} global_unique_filters = {} sigil_filters = {} tribute_filters = {} @@ -478,7 +479,7 @@ def load_files(self): self.files_loaded = True self.affix_filters: dict[str, list[DynamicItemFilterModel]] = {} self.aspect_upgrade_filters: dict[str, list[str]] = {} - self.paragon_filters: dict[str, object] = {} + self.paragon_filters: dict[str, ParagonPayloadModel] = {} self.sigil_filters: dict[str, SigilFilterModel] = {} self.tribute_filters: dict[str, list[TributeFilterModel]] = {} self.global_unique_filters: dict[str, list[GlobalUniqueModel]] = {} @@ -557,7 +558,7 @@ def load_files(self): self.last_loaded = time.time() self.last_profile_list = IniConfigLoader().general.profiles.copy() - def get_paragon_filters(self) -> dict[str, object]: + def get_paragon_filters(self) -> dict[str, ParagonPayloadModel]: """Return the loaded Paragon payloads, reloading profiles when needed.""" if not self.files_loaded or self._did_files_change(): self.load_files() diff --git a/src/paragon_overlay.py b/src/paragon_overlay.py index f211ec96..efdc094a 100644 --- a/src/paragon_overlay.py +++ b/src/paragon_overlay.py @@ -47,6 +47,8 @@ from collections.abc import Callable from pathlib import Path + from src.config.profile_models import ParagonBoardModel + LOGGER = logging.getLogger(__name__) OverlaySettingT = TypeVar("OverlaySettingT", int, str, bool) @@ -304,15 +306,6 @@ def _clamp_int(v: int | None, lo: int, hi: int, default: int) -> int: return default -def _iter_paragon_payloads(paragon: object) -> list[dict[str, Any]]: - """Normalize Paragon data so the rest of the loader can iterate one shape.""" - if isinstance(paragon, dict): - return [paragon] - if isinstance(paragon, list): - return [payload for payload in paragon if isinstance(payload, dict)] - return [] - - def _format_build_display_name(raw_name: object) -> str: """Convert stored build/profile names into a cleaner title-card label.""" text = str(raw_name or "").strip() @@ -373,26 +366,14 @@ def load_builds_from_path(preset_path: str | None = None) -> list[dict[str, Any] paragon_filters = Filter().get_paragon_filters() builds: list[dict[str, Any]] = [] - for pname, paragon_payload in paragon_filters.items(): - # A profile can contain one or many Paragon payloads, and each payload can - # contain multiple step states. The overlay shows each step as its own - # selectable build entry. - for payload in _iter_paragon_payloads(paragon_payload): - payload_steps = payload.get("ParagonBoardsList", []) - if payload_steps and isinstance(payload_steps, list) and isinstance(payload_steps[0], list): - steps = [step for step in payload_steps if isinstance(step, list) and step] - elif isinstance(payload_steps, list) and payload_steps: - # Older/smaller payloads may store only one board-state list instead - # of a list-of-steps. Wrap that shape so the overlay can iterate one way. - steps = [payload_steps] - else: - steps = [] - bname = payload.get("Name") or payload.get("name") or "Unknown Build" - # Newest step first keeps the build selector aligned with the latest - # imported planner state while still exposing earlier progression steps. - for idx in range(len(steps) - 1, -1, -1): - sname = f"{bname} - Step {idx + 1}" if len(steps) > 1 else bname - builds.append({"name": sname, "boards": steps[idx], "profile": pname}) + for pname, payload in paragon_filters.items(): + steps = payload.paragon_boards_list + bname = payload.name or "Unknown Build" + # Newest step first keeps the build selector aligned with the latest + # imported planner state while still exposing earlier progression steps. + for idx in range(len(steps) - 1, -1, -1): + sname = f"{bname} - Step {idx + 1}" if len(steps) > 1 else bname + builds.append({"name": sname, "boards": steps[idx], "profile": pname}) return builds @@ -413,6 +394,30 @@ def nodes_to_grid(nodes: list[int] | list[bool]) -> list[list[bool]]: return [[bool(nodes[y * GRID + x]) for x in range(GRID)] for y in range(GRID)] +def format_board_display_text(board: ParagonBoardModel) -> str: + """Build the readable label shown for a Paragon board card.""" + raw_name = str(board.name or "?") + name_parts = raw_name.split("-", 1) + class_slug = (name_parts[0] if name_parts else raw_name).strip().lower() + board_slug = (name_parts[1] if len(name_parts) > 1 else raw_name).strip() + class_name = {class_name: class_name.title() for class_name in PLAYER_CLASSES}.get( + class_slug, class_slug.title() if class_slug else "?" + ) + + glyph_name = "No Glyph" + if board.glyph: + glyph_parts = str(board.glyph).strip().split("-", 1) + glyph_slug = ( + glyph_parts[1] + if len(glyph_parts) > 1 and glyph_parts[0].strip().lower() == class_slug + else str(board.glyph).strip() + ) + glyph_name = re.sub(r"[-_]+", " ", glyph_slug).strip().title() if glyph_slug else "No Glyph" + + readable_board = board_slug.replace("-", " ").strip().title() if board_slug else "?" + return f"{class_name} - {readable_board} - {glyph_name} - {parse_rotation(board.rotation)}°" + + # ============================================================================= # DATA CLASSES # ============================================================================= @@ -1429,19 +1434,7 @@ def _refresh_lists(self) -> None: acc = self._accent_frame_color() for idx, bd in enumerate(self.boards): - # Build a readable line that includes class, board name, glyph, and - # rotation so the user can identify each board without opening it. - rn, rg = str(bd.get("Name", "?") or "?"), bd.get("Glyph") - np = rn.split("-", 1) - cs, bs = ((np[0] if np else rn).strip().lower(), (np[1] if len(np) > 1 else rn).strip()) - cn = {c: c.title() for c in PLAYER_CLASSES}.get(cs, cs.title() if cs else "?") - gn = "No Glyph" - if rg: - gp = str(rg).strip().split("-", 1) - g = gp[1] if len(gp) > 1 and gp[0].strip().lower() == cs else str(rg).strip() - gn = g.replace("-", " ").strip().title() if g else "No Glyph" - - txt = f"{cn} - {bs.replace('-', ' ').strip().title() if bs else '?'} - {gn} - {parse_rotation(str(bd.get('Rotation', '0')))}°" + txt = format_board_display_text(bd) sel = idx == self.selected_board_idx bg, fg = (SELECT_BG, GOLD) if sel else (CARD_BG, TEXT) @@ -1690,7 +1683,7 @@ def _apply_geometry(self) -> None: def redraw(self) -> None: """Redraw the entire transparent grid overlay for the selected board.""" self.canvas.delete("all") - if not self.boards or len(n := self.boards[self.selected_board_idx].get("Nodes") or []) != NODES_LEN: + if not self.boards or len(n := self.boards[self.selected_board_idx].nodes) != NODES_LEN: return grid, acc = nodes_to_grid(n), self._accent_frame_color() diff --git a/tests/config/models_test.py b/tests/config/models_test.py index 4d04532c..7c0602bd 100644 --- a/tests/config/models_test.py +++ b/tests/config/models_test.py @@ -11,7 +11,7 @@ import json import re -from typing import TYPE_CHECKING, Any +from typing import Any import pytest from pydantic import ValidationError @@ -23,6 +23,8 @@ GlobalUniqueModel, ItemFilterModel, ItemRarity, + ParagonBoardModel, + ParagonPayloadModel, ProfileModel, SigilConditionModel, SigilFilterModel, @@ -32,15 +34,8 @@ from src.item.data.item_type import ItemType from tests.config.data import sigils, uniques -if TYPE_CHECKING: - from src.config.loader import IniConfigLoader - class TestSigil: - @pytest.fixture(autouse=True) - def _setup(self, mock_ini_loader: IniConfigLoader) -> None: - self.mock_ini_loader = mock_ini_loader - @staticmethod @pytest.mark.parametrize("data", sigils.all_bad_cases) def test_all_bad_cases(data: dict[str, Any]) -> None: @@ -56,10 +51,6 @@ def test_all_good_cases(data: dict[str, Any]) -> None: class TestUnique: - @pytest.fixture(autouse=True) - def _setup(self, mock_ini_loader: IniConfigLoader) -> None: - self.mock_ini_loader = mock_ini_loader - @staticmethod @pytest.mark.parametrize(("data", "expected_msg"), uniques.all_bad_cases) def test_all_bad_cases(data: dict[str, Any], expected_msg: str) -> None: @@ -810,3 +801,148 @@ def test_dict_construction_snake_case(self) -> None: assert model.name == "dict_test" assert len(model.global_uniques) == 1 assert model.global_uniques[0].min_power == 900 + + +class TestParagonModels: + @staticmethod + def _board_data(**overrides: object) -> dict[str, Any]: + board = { + "Name": "Starting Board", + "Glyph": "glyph_name", + "Rotation": 90, + "Nodes": [False] * 441, + "BoardId": "Paragon_Barb_00", + "GlyphId": "glyph_1", + } + board.update(overrides) + return board + + def test_board_accepts_supported_rotations(self) -> None: + board = ParagonBoardModel.model_validate(self._board_data(Rotation="180°")) + assert board.rotation == "180°" + + def test_board_rejects_unsupported_rotation(self) -> None: + with pytest.raises(ValidationError, match="Rotation must be one of 0, 90, 180, or 270 degrees"): + ParagonBoardModel.model_validate(self._board_data(Rotation=45)) + + @pytest.mark.parametrize("rotation", [360, "360°", -90]) + def test_board_rejects_wrapped_rotation_values(self, rotation: object) -> None: + with pytest.raises(ValidationError, match="Rotation must be one of 0, 90, 180, or 270 degrees"): + ParagonBoardModel.model_validate(self._board_data(Rotation=rotation)) + + def test_board_requires_name(self) -> None: + with pytest.raises(ValidationError, match="Name must not be empty"): + ParagonBoardModel.model_validate(self._board_data(Name=" ")) + + def test_board_requires_nodes(self) -> None: + board_data = self._board_data() + board_data.pop("Nodes") + + with pytest.raises(ValidationError): + ParagonBoardModel.model_validate(board_data) + + def test_board_requires_exactly_441_nodes(self) -> None: + with pytest.raises(ValidationError, match="Nodes must contain exactly 441 values"): + ParagonBoardModel.model_validate(self._board_data(Nodes=[False] * 440)) + + with pytest.raises(ValidationError, match="Nodes must contain exactly 441 values"): + ParagonBoardModel.model_validate(self._board_data(Nodes=[False] * 442)) + + def test_all_false_nodes_are_valid(self) -> None: + board = ParagonBoardModel.model_validate(self._board_data(Nodes=[False] * 441)) + assert board.nodes == [False] * 441 + + def test_payload_direct_board_list_normalizes_to_one_step(self) -> None: + payload = ParagonPayloadModel.model_validate({"Name": "Build Name", "ParagonBoardsList": [self._board_data()]}) + assert payload.paragon_boards_list == [[ParagonBoardModel.model_validate(self._board_data())]] + + def test_empty_payload_board_list_rejected(self) -> None: + with pytest.raises(ValidationError, match="ParagonBoardsList must not be empty"): + ParagonPayloadModel.model_validate({"Name": "Build Name", "ParagonBoardsList": []}) + + def test_empty_step_in_payload_board_list_rejected(self) -> None: + with pytest.raises(ValidationError, match="ParagonBoardsList must not be empty"): + ParagonPayloadModel.model_validate({"Name": "Build Name", "ParagonBoardsList": [[]]}) + + def test_payload_requires_name(self) -> None: + with pytest.raises(ValidationError): + ParagonPayloadModel.model_validate({"ParagonBoardsList": [self._board_data()]}) + + def test_payload_rejects_unknown_fields(self) -> None: + with pytest.raises(ValidationError): + ParagonPayloadModel.model_validate({ + "Name": "Build Name", + "ParagonBoardsList": [[self._board_data(UnknownField=True)]], + "UnknownField": True, + }) + + def test_board_rejects_unknown_fields(self) -> None: + with pytest.raises(ValidationError): + ParagonBoardModel.model_validate(self._board_data(UnknownField=True)) + + def test_payload_accepts_board_and_glyph_ids(self) -> None: + payload = ParagonPayloadModel.model_validate({ + "Name": "Build Name", + "Source": "https://example.invalid", + "GeneratedAt": "2026-06-15 00:00:00 UTC", + "Generator": "d4lf v0.0.0", + "ParagonBoardsList": [self._board_data()], + }) + + assert payload.paragon_boards_list[0][0].board_id == "Paragon_Barb_00" + assert payload.paragon_boards_list[0][0].glyph_id == "glyph_1" + + def test_canonical_profile_paragon_payload_is_typed(self) -> None: + profile = ProfileModel(name="test", Paragon={"Name": "Build Name", "ParagonBoardsList": [self._board_data()]}) + + assert isinstance(profile.paragon, ParagonPayloadModel) + assert profile.paragon.name == "Build Name" + + def test_legacy_empty_paragon_list_normalizes_to_none(self) -> None: + profile = ProfileModel(name="test", Paragon=[]) + assert profile.paragon is None + + def test_legacy_single_payload_list_normalizes_to_one_payload(self) -> None: + profile = ProfileModel(name="test", Paragon=[{"Name": "Build Name", "ParagonBoardsList": [self._board_data()]}]) + assert profile.paragon is not None + assert profile.paragon.name == "Build Name" + assert len(profile.paragon.paragon_boards_list) == 1 + + def test_legacy_multi_payload_list_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="Paragon must contain at most one payload"): + ProfileModel( + name="test", + Paragon=[ + {"Name": "Build One", "ParagonBoardsList": [self._board_data()]}, + {"Name": "Build Two", "ParagonBoardsList": [self._board_data()]}, + ], + ) + + def test_profile_serialization_preserves_paragon_aliases(self) -> None: + profile = ProfileModel( + name="test", + Paragon=[ + { + "Name": "Build Name", + "Source": "https://example.invalid", + "Generator": "d4lf v0.0.0", + "GeneratedAt": "2026-06-15 00:00:00 UTC", + "ParagonBoardsList": [self._board_data()], + } + ], + ) + + exported = json.loads(profile.model_dump_json(by_alias=True)) + assert exported["Paragon"]["Name"] == "Build Name" + assert exported["Paragon"]["ParagonBoardsList"][0][0]["Name"] == "Starting Board" + assert exported["Paragon"]["ParagonBoardsList"][0][0]["Rotation"] == "90°" + assert len(exported["Paragon"]["ParagonBoardsList"][0][0]["Nodes"]) == 441 + + def test_serialize_paragon_excludes_null_metadata(self) -> None: + profile = ProfileModel(name="test", Paragon={"Name": "Build Name", "ParagonBoardsList": [self._board_data()]}) + + exported = json.loads(profile.model_dump_json(by_alias=True)) + paragon = exported["Paragon"] + assert "Source" not in paragon + assert "GeneratedAt" not in paragon + assert "Generator" not in paragon diff --git a/tests/conftest.py b/tests/conftest.py index 1cda5d1b..3d258d05 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ "template_finder_test.py", "char_inventory_test.py", "chest_test.py", + "paragon_overlay_test.py", ] diff --git a/tests/gui/importer/test_d4builds.py b/tests/gui/importer/test_d4builds.py index 3f23e057..87dea53a 100644 --- a/tests/gui/importer/test_d4builds.py +++ b/tests/gui/importer/test_d4builds.py @@ -5,8 +5,10 @@ import pytest from src.dataloader import Dataloader -from src.gui.importer.d4builds import _extract_build_metadata, _extract_d4builds_season_number, import_d4builds +from src.gui.importer import d4builds as d4builds_module +from src.gui.importer import paragon_export as paragon_export_module from src.gui.importer.importer_config import ImportConfig +from src.gui.importer.paragon_export import build_paragon_profile_payload if typing.TYPE_CHECKING: from pytest_mock import MockerFixture @@ -51,7 +53,7 @@ def test_extract_build_metadata_from_planner_header() -> None: """) - assert _extract_build_metadata(data) == ( + assert d4builds_module._extract_build_metadata(data) == ( "Necromancer", "Rob's Golem Minion Necro (S4) Pit 142+", "4", @@ -79,7 +81,12 @@ def test_extract_build_metadata_prefers_description_for_guides() -> None: """) - assert _extract_build_metadata(data) == ("Paladin", "Rob's Cpt. America (S12)", "12", "Pit Push (Glasscannon)") + assert d4builds_module._extract_build_metadata(data) == ( + "Paladin", + "Rob's Cpt. America (S12)", + "12", + "Pit Push (Glasscannon)", + ) def test_extract_d4builds_season_number_from_gear_dropdown() -> None: @@ -100,7 +107,62 @@ def test_extract_d4builds_season_number_from_gear_dropdown() -> None: """) - assert _extract_d4builds_season_number(data) == "12" + assert d4builds_module._extract_d4builds_season_number(data) == "12" + + +def test_parse_d4builds_paragon_boards_produces_valid_typed_payload_input() -> None: + class _FakeTextNode: + def __init__(self, text: str): + self._text = text + + def get_attribute(self, name: str) -> str: + return self._text if name == "innerText" else "" + + class _FakeTile: + def __init__(self, class_name: str): + self._class_name = class_name + + def get_attribute(self, name: str) -> str: + return self._class_name if name == "class" else "" + + class _FakeBoardElement: + def __init__(self): + self._attrs = {"data-board-id": "Paragon_Barb_00"} + + def find_element(self, by, value): + if value == "paragon__board__name": + return _FakeTextNode("Starting Board") + msg = f"unexpected selector: {value}" + raise AssertionError(msg) + + def find_elements(self, by, value): + if value == "paragon__board__name__glyph": + return [_FakeTextNode("Glyph Name")] + if value == "paragon__board__tile": + return [_FakeTile("paragon__board__tile r2 c10 active enabled")] + msg = f"unexpected selector: {value}" + raise AssertionError(msg) + + def get_attribute(self, name: str) -> str: + return "transform: rotate(90deg);" if name == "style" else "" + + class _FakeDriver: + def execute_script(self, script, board_elem): + return board_elem._attrs + + def find_elements(self, by, value): + if value == "paragon__board": + return [_FakeBoardElement()] + msg = f"unexpected selector: {value}" + raise AssertionError(msg) + + boards = paragon_export_module._parse_d4builds_paragon_boards(_FakeDriver(), class_slug="barbarian") + payload = build_paragon_profile_payload("Build Name", "https://example.invalid", boards) + + board = payload.paragon_boards_list[0][0] + assert board.name == "barbarian-paragon-barb-00" + assert board.rotation == "90°" + assert board.nodes.count(True) == 1 @pytest.mark.parametrize("url", URLS) @@ -117,4 +179,4 @@ def test_import_d4builds(url: str, mock_ini_loader: MockerFixture, mocker: Mocke require_greater_affixes=True, custom_file_name=None, ) - import_d4builds(config=config) + d4builds_module.import_d4builds(config=config) diff --git a/tests/gui/importer/test_gui_common.py b/tests/gui/importer/test_gui_common.py index e8566d7e..6c8afeed 100644 --- a/tests/gui/importer/test_gui_common.py +++ b/tests/gui/importer/test_gui_common.py @@ -61,3 +61,21 @@ def test_to_yaml_str_sorts_aspect_upgrades_and_uses_block_style(mock_ini_loader) assert "aspect_upgrades:\n- accelerating\n- snowveiled\n" in yaml_str assert "aspect_upgrades: [" not in yaml_str + + +def test_to_yaml_str_preserves_paragon_aliases(mock_ini_loader) -> None: + profile = ProfileModel( + name="test", + Paragon={ + "Name": "Build Name", + "ParagonBoardsList": [ + [{"Name": "Starting Board", "Glyph": "glyph_name", "Rotation": 0, "Nodes": [False] * 441}] + ], + }, + ) + + yaml_str = _to_yaml_str(profile, exclude_defaults=True, exclude={"name", "Sigils"}) + + assert "Paragon:" in yaml_str + assert "ParagonBoardsList:" in yaml_str + assert "Name: Build Name" in yaml_str diff --git a/tests/gui/importer/test_maxroll.py b/tests/gui/importer/test_maxroll.py index 32630dd7..7246df1e 100644 --- a/tests/gui/importer/test_maxroll.py +++ b/tests/gui/importer/test_maxroll.py @@ -6,6 +6,7 @@ from src.dataloader import Dataloader from src.gui.importer.importer_config import ImportConfig from src.gui.importer.maxroll import _find_item_affixes, _find_item_type, _resolve_visible_profile_index, import_maxroll +from src.gui.importer.paragon_export import build_paragon_profile_payload, extract_maxroll_paragon_steps from src.item.data.item_type import ItemType if typing.TYPE_CHECKING: @@ -112,3 +113,17 @@ def test_find_item_affixes_resolves_skill_rank_category_from_related_description affixes = _find_item_affixes(mapping_data=mapping_data, item_affixes=[{"nid": 1}], item_type=ItemType.Amulet) assert [affix.name for affix in affixes] == ["to_ultimate_skills"] + + +def test_extract_maxroll_paragon_steps_preserves_board_and_glyph_ids() -> None: + steps = extract_maxroll_paragon_steps({ + "paragon": { + "steps": [{"data": [{"id": "Paragon_Barb_00", "glyph": "Glyph_01", "rotation": 0, "nodes": {"0": True}}]}] + } + }) + + payload = build_paragon_profile_payload("Build Name", "https://example.invalid", steps) + + board = payload.paragon_boards_list[0][0] + assert board.board_id == "Paragon_Barb_00" + assert board.glyph_id == "Glyph_01" diff --git a/tests/gui/importer/test_mobalytics.py b/tests/gui/importer/test_mobalytics.py index 9247da77..a9e22fd8 100644 --- a/tests/gui/importer/test_mobalytics.py +++ b/tests/gui/importer/test_mobalytics.py @@ -4,6 +4,7 @@ import pytest +from src.config.profile_models import ParagonPayloadModel from src.dataloader import Dataloader from src.gui.importer.importer_config import ImportConfig from src.gui.importer.mobalytics import ( @@ -11,7 +12,7 @@ _log_mobalytics_page_diagnostics, import_mobalytics, ) -from src.gui.importer.paragon_export import extract_mobalytics_paragon_steps +from src.gui.importer.paragon_export import build_paragon_profile_payload, extract_mobalytics_paragon_steps if typing.TYPE_CHECKING: from pytest_mock import MockerFixture @@ -52,6 +53,20 @@ def test_extract_mobalytics_paragon_steps_normalizes_warlock_starting_board(): assert board["Nodes"][node_index] is True +def test_build_paragon_profile_payload_returns_typed_model(): + payload = build_paragon_profile_payload( + build_name="Build Name", + source_url="https://example.invalid", + paragon_boards_list=[ + [{"Name": "Starting Board", "Glyph": "glyph_name", "Rotation": 90, "Nodes": [False] * 441}] + ], + ) + + assert isinstance(payload, ParagonPayloadModel) + assert payload.name == "Build Name" + assert payload.paragon_boards_list[0][0].rotation == "90°" + + @pytest.mark.parametrize( "script_text", [ diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index b1af8d46..6a0bb514 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -1,13 +1,18 @@ from __future__ import annotations +import sys import typing import pytest from natsort import natsorted +if sys.platform == "darwin": + pytest.skip("Windows-only filter test module", allow_module_level=True) + from src.config.loader import IniConfigLoader -from src.config.profile_models import SigilPriority +from src.config.profile_models import ParagonPayloadModel, ProfileModel, SigilPriority from src.config.settings_models import AspectFilterType +from src.gui.importer.gui_common import save_as_profile from src.item.filter import Filter, FilterResult from tests.item.filter.data import filters from tests.item.filter.data.affixes import affixes @@ -121,3 +126,25 @@ def test_mythic_always_kept(_name: str, result: bool, item: Item, mocker: Mocker test_filter = _create_mocked_filter(mocker) test_filter.global_unique_filters = {filters.always_keep_mythics.name: filters.always_keep_mythics.global_uniques} assert test_filter.should_keep(item).keep == result + + +def test_filter_loads_typed_paragon_payload(tmp_path, mock_ini_loader: IniConfigLoader, mocker: MockerFixture) -> None: + mock_ini_loader._user_dir = tmp_path + mock_ini_loader.general.profiles = ["typed_paragon"] + + profile = ProfileModel( + name="typed_paragon", + Paragon={ + "Name": "Build Name", + "ParagonBoardsList": [ + [{"Name": "Starting Board", "Glyph": "glyph_name", "Rotation": 0, "Nodes": [False] * 441}] + ], + }, + ) + save_as_profile(file_name="typed_paragon", profile=profile, url="https://example.invalid") + + test_filter = _create_mocked_filter(mocker) + test_filter.files_loaded = False + test_filter.load_files() + + assert isinstance(test_filter.get_paragon_filters()["typed_paragon"], ParagonPayloadModel) diff --git a/tests/paragon_overlay_test.py b/tests/paragon_overlay_test.py new file mode 100644 index 00000000..258711ee --- /dev/null +++ b/tests/paragon_overlay_test.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import sys + +import pytest + +if sys.platform == "darwin": + pytest.skip("Windows-only overlay test", allow_module_level=True) + +from src.config.profile_models import ParagonPayloadModel +from src.paragon_overlay import format_board_display_text, load_builds_from_path + + +def test_load_builds_from_path_uses_typed_paragon_payloads(monkeypatch): + payload = ParagonPayloadModel.model_validate({ + "Name": "Build Name", + "ParagonBoardsList": [ + [{"Name": "Starting Board", "Glyph": "glyph_name", "Rotation": 0, "Nodes": [False] * 441}], + [{"Name": "Second Step Board", "Glyph": "glyph_name", "Rotation": 90, "Nodes": [False] * 441}], + ], + }) + + monkeypatch.setattr("src.item.filter.Filter.get_paragon_filters", lambda _self: {"profile_name": payload}) + + builds = load_builds_from_path() + + assert [build["name"] for build in builds] == ["Build Name - Step 2", "Build Name - Step 1"] + assert builds[0]["boards"][0].rotation == "90°" + assert builds[1]["boards"][0].rotation == "0°" + assert ( + format_board_display_text(builds[0]["boards"][0]) == "Second Step Board - Second Step Board - Glyph Name - 90°" + ) diff --git a/uv.lock b/uv.lock index 71b6673f..de70d655 100644 --- a/uv.lock +++ b/uv.lock @@ -43,15 +43,15 @@ wheels = [ [[package]] name = "beautifulsoup4" -version = "4.14.3" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] @@ -337,11 +337,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.29.1" +version = "3.29.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" }, + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, ] [[package]] @@ -581,11 +581,11 @@ wheels = [ [[package]] name = "mycdp" -version = "1.3.7" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/cf/8990229f1e51a1531105503254e3f76d6acd77e783ad9f9236c95a1a59d7/mycdp-1.3.7.tar.gz", hash = "sha256:0861fd85949eafd1ec794ebe58b54515c571544c983affbbc74249119ff94d21", size = 224859, upload-time = "2026-03-19T21:22:29.623Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/3e/8ec13aadc92724c20c470ac2c5cf9386ea785daa5681b6e3bf8043c2a7ff/mycdp-1.4.0.tar.gz", hash = "sha256:6a6c18cefbf7a5a4c17be3d00fcd2cac05497b03f995e52f05abfbfbee5ab42e", size = 246353, upload-time = "2026-06-13T05:23:28.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/0e/83918568b03bc2c7326eaf66454c1cb3d8734d34ccb1c4a21454ed9b8230/mycdp-1.3.7-py3-none-any.whl", hash = "sha256:49aa0f8cfbc608b157b3ad35999a74e32cddc674d7f0e0bbe402cdcef7ce69d6", size = 252208, upload-time = "2026-03-19T21:22:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/f3/0a/c72f4357272634bb1db2c94e414fd5ac03304603dc9a51551955e26deee4/mycdp-1.4.0-py3-none-any.whl", hash = "sha256:e4723c4b3d52dc7962ced497beb4a201431bdfc813f8bb653376498b8f217411", size = 275242, upload-time = "2026-06-13T05:23:27.326Z" }, ] [[package]] @@ -932,7 +932,7 @@ wheels = [ [[package]] name = "pyinstaller" -version = "6.20.0" +version = "6.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "altgraph" }, @@ -943,19 +943,19 @@ dependencies = [ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/60/d03d52e6690d4e9caf333dcd14550cde634ce6c118b3bc8fa3112c3186fd/pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a", size = 4048728, upload-time = "2026-04-22T20:59:36.96Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/e4/e228d6d1bbb7fd62dc660a8fb202a583b023d3a3624ca95d1a9290ee4d6a/pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e", size = 1047642, upload-time = "2026-04-22T20:58:32.006Z" }, - { url = "https://files.pythonhosted.org/packages/ce/bd/afb631bcb3f9040efebd4f6d067f0828b51710818f69fb41a2d4b7787f52/pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712", size = 742494, upload-time = "2026-04-22T20:58:36.285Z" }, - { url = "https://files.pythonhosted.org/packages/76/08/0729a5bac14754150e5d83b39d87d842eb42b0bffcaa03dbad6252e23a39/pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941", size = 754191, upload-time = "2026-04-22T20:58:40.603Z" }, - { url = "https://files.pythonhosted.org/packages/e6/82/bc0ee4c7b97db1958eb651e0da9fb1e672e5ae53ca8867fd97701de52906/pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e", size = 751902, upload-time = "2026-04-22T20:58:44.7Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e7/770002d6aaa54173881cb2c49bb195ba67b97bf39bac1cdf320f28401629/pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59", size = 748634, upload-time = "2026-04-22T20:58:48.579Z" }, - { url = "https://files.pythonhosted.org/packages/fe/db/68ba1fccb71278b2124fb90b37b7c8c0bc4c1173fba45b94466df3d9cb7f/pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b", size = 748490, upload-time = "2026-04-22T20:58:52.749Z" }, - { url = "https://files.pythonhosted.org/packages/03/0f/ac77ffa996a56be3d5c8f85734a007f8347240691657f9704e7de2527fa3/pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7", size = 747650, upload-time = "2026-04-22T20:58:57.093Z" }, - { url = "https://files.pythonhosted.org/packages/e0/56/1ee91c3a2bc10ca1f36da10a6fd55ff7efc4dec367171eb25992a827874f/pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9", size = 747413, upload-time = "2026-04-22T20:59:01.174Z" }, - { url = "https://files.pythonhosted.org/packages/d7/55/ae264339996953c4cdf9d89d916a0a8fa26a83cf917a742fff8b9d5f3fe8/pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c", size = 1331584, upload-time = "2026-04-22T20:59:07.201Z" }, - { url = "https://files.pythonhosted.org/packages/76/8c/300f57578882cce259bfb5ae56fda3b69caa3fe9df40a176c719920ea6e2/pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a", size = 1391851, upload-time = "2026-04-22T20:59:14.024Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ea/b2f8e1642aecda78c0b75c7321f708e49e10bb3c00dd4f148c40761a1527/pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2", size = 1332259, upload-time = "2026-04-22T20:59:20.509Z" }, + { url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350, upload-time = "2026-06-13T14:13:55.88Z" }, + { url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477, upload-time = "2026-06-13T14:14:00.327Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877, upload-time = "2026-06-13T14:14:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194, upload-time = "2026-06-13T14:14:10.61Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979, upload-time = "2026-06-13T14:14:15.226Z" }, + { url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812, upload-time = "2026-06-13T14:14:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887, upload-time = "2026-06-13T14:14:25.268Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779, upload-time = "2026-06-13T14:14:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462, upload-time = "2026-06-13T14:14:35.785Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487, upload-time = "2026-06-13T14:14:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094, upload-time = "2026-06-13T14:14:48.914Z" }, ] [[package]] @@ -3587,11 +3587,11 @@ wheels = [ [[package]] name = "pyotp" -version = "2.9.0" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/b2/1d5994ba2acde054a443bd5e2d384175449c7d2b6d1a0614dbca3a63abfc/pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63", size = 17763, upload-time = "2023-07-27T23:41:03.295Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/c6/c5d96a86fd0bf6fa1bbb5c5c341ff3208638b692727a683c8289068d9a11/pyotp-2.10.0.tar.gz", hash = "sha256:d01e9703443616b03c57c700b5cbffd56a1f929c1b0f8f03131bc78c1fca9d3f", size = 18625, upload-time = "2026-06-14T03:48:49.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376, upload-time = "2023-07-27T23:41:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/65/33/7b83bde70eddaaaaef487751a9c3a5cc0c0be54620ded0e120ebdc401ff9/pyotp-2.10.0-py3-none-any.whl", hash = "sha256:1df2f6a1bcc3bb0716172a5215ddc2f8c7c7fd26a13df9927d52e1746934836c", size = 13768, upload-time = "2026-06-14T03:48:47.831Z" }, ] [[package]] @@ -3678,7 +3678,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -3687,9 +3687,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] [[package]] @@ -3984,27 +3984,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.16" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, - { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, - { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, - { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, - { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, - { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, - { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, - { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, - { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] [[package]] @@ -4035,7 +4035,7 @@ wheels = [ [[package]] name = "seleniumbase" -version = "4.49.7" +version = "4.49.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -4099,9 +4099,9 @@ dependencies = [ { name = "wheel" }, { name = "wsproto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/8d/e21249a29f1f8a27b7b9ef48722fd61e64061332395450d7b88af48da68d/seleniumbase-4.49.7.tar.gz", hash = "sha256:79f7f394a475bb211ea9715d2618bbbff684126249ef3fd6606e356e2cd4691c", size = 660715, upload-time = "2026-06-05T15:51:05.564Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/e3/e90c5e963eb47c1ad469b706ae3c3acb353eecaa5d4e4f1d503f7280e1a6/seleniumbase-4.49.13.tar.gz", hash = "sha256:7166f4f93b38ca62b86b01902783f6b591bc6c74ae3d5957963922ae059337a8", size = 662995, upload-time = "2026-06-15T03:25:21.754Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/8d/8e1305cf773e4dd8c5af8aa7a29da31a5839c030cc5823ffc9bb5232e272/seleniumbase-4.49.7-py3-none-any.whl", hash = "sha256:37b261d5ac732952bc2aed9e7db248d34d3d07eb4b5df02dee195f6539d74c40", size = 665981, upload-time = "2026-06-05T15:51:01.893Z" }, + { url = "https://files.pythonhosted.org/packages/38/8f/738165bac260afb9c94cb6ea92150b1b7ce1fecb6a2d9a035a6fc713dd07/seleniumbase-4.49.13-py3-none-any.whl", hash = "sha256:6da12a907bf21a64eaaf9e7e42887902de62103ab257d01c956c0535b633006c", size = 668466, upload-time = "2026-06-15T03:25:18.701Z" }, ] [[package]]