From 10365d6a1d8c6b1571357acae0a0b540d2dea4d7 Mon Sep 17 00:00:00 2001 From: Mattia Date: Fri, 22 May 2026 16:15:08 +0200 Subject: [PATCH 1/3] Support historical asset payloads --- deadlock_assets_api/deploy.py | 3 + deadlock_assets_api/historical.py | 68 ++++++++ deadlock_assets_api/models/v2/generic_data.py | 48 ++++-- deadlock_assets_api/routes/v1.py | 3 +- deadlock_assets_api/utils.py | 20 ++- tests/test_historical_assets.py | 155 ++++++++++++++++++ 6 files changed, 276 insertions(+), 21 deletions(-) create mode 100644 deadlock_assets_api/historical.py create mode 100644 tests/test_historical_assets.py diff --git a/deadlock_assets_api/deploy.py b/deadlock_assets_api/deploy.py index 9c956731..27bde985 100644 --- a/deadlock_assets_api/deploy.py +++ b/deadlock_assets_api/deploy.py @@ -9,6 +9,7 @@ from pydantic import TypeAdapter from deadlock_assets_api.glob import FONTS_BASE_URL, SOUNDS_BASE_URL, SVGS_BASE_URL, IMAGE_BASE_URL +from deadlock_assets_api.historical import backfill_historical_version_files from deadlock_assets_api.main import app from deadlock_assets_api.models.languages import Language from deadlock_assets_api.models.v1.colors import ColorV1 @@ -316,6 +317,8 @@ def item_from_raw_item(raw_item: RawUpgradeV2 | RawAbilityV2 | RawWeaponV2) -> I with open(f"{out_folder}/versions/{version_id}/images_data.json", "w") as f: json.dump(images_data, f) + backfill_historical_version_files(out_folder, client_versions, images_data) + with open(f"{out_folder}/versions/{version_id}/raw_heroes.json", "w") as f: json.dump([h.model_dump(exclude_none=True) for h in raw_heroes], f) diff --git a/deadlock_assets_api/historical.py b/deadlock_assets_api/historical.py new file mode 100644 index 00000000..1d06757d --- /dev/null +++ b/deadlock_assets_api/historical.py @@ -0,0 +1,68 @@ +import json +import os + +from deadlock_assets_api.models.v2.generic_data import GenericDataV2 + + +def closest_version_file( + client_version: int, filename: str, versions_folder: str = "deploy/versions" +) -> str: + exact_path = f"{versions_folder}/{client_version}/{filename}" + if os.path.exists(exact_path): + return exact_path + + candidates = [] + if os.path.isdir(versions_folder): + for version in os.listdir(versions_folder): + if not version.isdigit(): + continue + filepath = f"{versions_folder}/{version}/{filename}" + if os.path.exists(filepath): + candidates.append(int(version)) + + if not candidates: + return exact_path + + closest_version = min(candidates, key=lambda version: (abs(version - client_version), -version)) + return f"{versions_folder}/{closest_version}/{filename}" + + +def backfill_historical_version_files( + out_folder: str, client_versions: list[int], default_images_data: dict +) -> None: + available_image_versions: list[int] = [] + + for version_id in client_versions: + version_folder = f"{out_folder}/versions/{version_id}" + os.makedirs(version_folder, exist_ok=True) + + generic_source = f"res/builds/{version_id}/v2/generic_data.json" + generic_target = f"{version_folder}/generic_data.json" + if os.path.exists(generic_source) and not os.path.exists(generic_target): + with open(generic_source) as f: + generic_data = GenericDataV2.model_validate_json(f.read()) + with open(generic_target, "w") as f: + f.write(generic_data.model_dump_json(exclude_none=True)) + + if os.path.exists(f"{version_folder}/images_data.json"): + available_image_versions.append(version_id) + + for version_id in client_versions: + version_folder = f"{out_folder}/versions/{version_id}" + images_target = f"{version_folder}/images_data.json" + if os.path.exists(images_target): + continue + + closest_version = min( + available_image_versions, + key=lambda candidate: (abs(candidate - version_id), -candidate), + default=None, + ) + if closest_version is not None: + with open(f"{out_folder}/versions/{closest_version}/images_data.json") as f: + images_data = json.load(f) + else: + images_data = default_images_data + + with open(images_target, "w") as f: + json.dump(images_data, f) diff --git a/deadlock_assets_api/models/v2/generic_data.py b/deadlock_assets_api/models/v2/generic_data.py index fcf1ad73..63c03753 100644 --- a/deadlock_assets_api/models/v2/generic_data.py +++ b/deadlock_assets_api/models/v2/generic_data.py @@ -1,7 +1,7 @@ import os from functools import lru_cache -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator from deadlock_assets_api.models.v1.colors import ColorV1 from deadlock_assets_api.models.v2.enums import ItemTierV2 @@ -154,8 +154,16 @@ class OutcomeToWeights(BaseModel): class ItemDraftRound(BaseModel): model_config = ConfigDict(populate_by_name=True) - normal_mod_tier: ItemTierV2 = Field(..., validation_alias="m_eNormalModTier") - rare_mod_tier: ItemTierV2 = Field(..., validation_alias="m_eRareModTier") + normal_mod_tier: ItemTierV2 = Field( + ..., + validation_alias=AliasChoices( + "m_eNormalModTier", "normal_mod_tier", "chance_enhanced" + ), + ) + rare_mod_tier: ItemTierV2 = Field( + ..., + validation_alias=AliasChoices("m_eRareModTier", "rare_mod_tier", "chance_rare"), + ) class ItemDraftRoundPerGameRound(BaseModel): @@ -234,7 +242,7 @@ class StreetBrawl(BaseModel): outline_color_team2: list[int] | None = Field(None, validation_alias="m_OutlineColorTeam2") outline_color_neutral: list[int] | None = Field(None, validation_alias="m_OutlineColorNeutral") item_drafts: dict[ItemTierV2, DraftBuckets | None] = Field( - ..., validation_alias="m_mapItemTierToItemDraftBuckets" + default_factory=dict, validation_alias="m_mapItemTierToItemDraftBuckets" ) @@ -243,7 +251,7 @@ class GenericDataV2(BaseModel): damage_flash: DamageFlashV2 = Field(..., validation_alias="m_mapDamageFlash") glitch_settings: GlitchSettingsV2 = Field(..., validation_alias="m_GlitchSettings") - lane_info: list[LaneInfoV2] = Field(..., validation_alias="m_LaneInfo") + lane_info: list[LaneInfoV2] = Field(default_factory=list, validation_alias="m_LaneInfo") new_player_metrics: list[NewPlayerMetricsV2] = Field(..., validation_alias="m_NewPlayerMetrics") minimap_team_rebels_color: ColorV1 | None = Field( None, validation_alias="m_MinimapTeamRebelsColor" @@ -258,21 +266,31 @@ class GenericDataV2(BaseModel): enemy_zipline_color: ColorV1 | None = Field(None, validation_alias="m_enemyZiplineColor") item_price_per_tier: list[int] = Field(..., validation_alias="m_nItemPricePerTier") trooper_kill_gold_share_frac: list[float] = Field( - ..., validation_alias="m_flTrooperKillGoldShareFrac" + default_factory=list, validation_alias="m_flTrooperKillGoldShareFrac" ) hero_kill_gold_share_frac: list[float] = Field( - ..., validation_alias="m_flHeroKillGoldShareFrac" + default_factory=list, validation_alias="m_flHeroKillGoldShareFrac" + ) + aim_spring_strength: list[float] = Field( + default_factory=list, validation_alias="m_AimSpringStrength" ) - aim_spring_strength: list[float] = Field(..., validation_alias="m_AimSpringStrength") targeting_spring_strength: list[float] = Field( - ..., validation_alias="m_TargetingSpringStrength" + default_factory=list, validation_alias="m_TargetingSpringStrength" + ) + objective_params: ObjectiveParams | None = Field(None, validation_alias="m_ObjectiveParams") + rejuv_params: RejuvParams | None = Field(None, validation_alias="m_RejuvParams") + mini_map_offsets: list[MiniMapOffsets] = Field( + default_factory=list, validation_alias="m_MiniMapOffsets" + ) + weapon_groups: list[ItemGroup] = Field( + default_factory=list, validation_alias="m_vecWeaponGroups" + ) + armor_groups: list[ItemGroup] = Field( + default_factory=list, validation_alias="m_vecArmorGroups" + ) + spirit_groups: list[ItemGroup] = Field( + default_factory=list, validation_alias="m_vecSpiritGroups" ) - objective_params: ObjectiveParams = Field(..., validation_alias="m_ObjectiveParams") - rejuv_params: RejuvParams = Field(..., validation_alias="m_RejuvParams") - mini_map_offsets: list[MiniMapOffsets] = Field(..., validation_alias="m_MiniMapOffsets") - weapon_groups: list[ItemGroup] = Field(..., validation_alias="m_vecWeaponGroups") - armor_groups: list[ItemGroup] = Field(..., validation_alias="m_vecArmorGroups") - spirit_groups: list[ItemGroup] = Field(..., validation_alias="m_vecSpiritGroups") street_brawl: StreetBrawl | None = Field(None, validation_alias="m_StreetBrawl") @field_validator( diff --git a/deadlock_assets_api/routes/v1.py b/deadlock_assets_api/routes/v1.py index 0040f8af..3aa8be58 100644 --- a/deadlock_assets_api/routes/v1.py +++ b/deadlock_assets_api/routes/v1.py @@ -3,6 +3,7 @@ from starlette.responses import FileResponse from deadlock_assets_api import utils +from deadlock_assets_api.historical import closest_version_file from deadlock_assets_api.models.enums import LATEST_VERSION, ValidClientVersions from deadlock_assets_api.models.v1.colors import ColorV1 from deadlock_assets_api.models.v1.map import MapV1 @@ -62,7 +63,7 @@ def get_images(client_version: ValidClientVersions | None = None) -> dict[str, s if client_version is None: client_version = ValidClientVersions(LATEST_VERSION) return utils.read_parse_data_ta( - f"deploy/versions/{client_version.value}/images_data.json", _TA_IMAGES + closest_version_file(client_version.value, "images_data.json"), _TA_IMAGES ) diff --git a/deadlock_assets_api/utils.py b/deadlock_assets_api/utils.py index 08bf7b2a..3a2fcbb2 100644 --- a/deadlock_assets_api/utils.py +++ b/deadlock_assets_api/utils.py @@ -7,7 +7,7 @@ import css_parser from css_parser.css import CSSRuleList, CSSStyleRule from fastapi import HTTPException -from pydantic import TypeAdapter, BaseModel +from pydantic import TypeAdapter, BaseModel, ValidationError from deadlock_assets_api.models.enums import ValidClientVersions, ALL_CLIENT_VERSIONS from deadlock_assets_api.models.languages import Language @@ -83,15 +83,25 @@ def strip_prefix(string: str, prefix: str) -> str: @lru_cache(maxsize=DATA_CACHE_MAXSIZE) def read_parse_data_ta[T](filepath: str, type_adapter: TypeAdapter[T]) -> T: LOGGER.debug(f"Reading {filepath}") - with open(filepath) as f: - return type_adapter.validate_json(f.read()) + try: + with open(filepath) as f: + return type_adapter.validate_json(f.read()) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=f"Data file not found: {filepath}") from exc + except ValidationError as exc: + raise HTTPException(status_code=422, detail=f"Data validation failed: {filepath}") from exc @lru_cache(maxsize=DATA_CACHE_MAXSIZE) def read_parse_data_model[T: BaseModel](filepath: str, model: type[T]) -> T: LOGGER.debug(f"Reading {filepath}") - with open(filepath) as f: - return model.model_validate_json(f.read()) + try: + with open(filepath) as f: + return model.model_validate_json(f.read()) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=f"Data file not found: {filepath}") from exc + except ValidationError as exc: + raise HTTPException(status_code=422, detail=f"Data validation failed: {filepath}") from exc def validate_client_version(client_version: ValidClientVersions | None = None) -> int: diff --git a/tests/test_historical_assets.py b/tests/test_historical_assets.py new file mode 100644 index 00000000..b379d6ac --- /dev/null +++ b/tests/test_historical_assets.py @@ -0,0 +1,155 @@ +import importlib +import json +import sys +import types +from pathlib import Path + +import pytest +from fastapi import HTTPException +from pydantic import TypeAdapter + +from deadlock_assets_api.models.v2.generic_data import GenericDataV2 + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def prepare_runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + deploy = tmp_path / "deploy" + deploy.mkdir() + (deploy / "client_versions.json").write_text(json.dumps([6016, 5959])) + + css_parser = types.ModuleType("css_parser") + css_parser.parseFile = lambda *_args, **_kwargs: types.SimpleNamespace(cssRules=[]) + css_parser.parseString = lambda *_args, **_kwargs: types.SimpleNamespace(cssRules=[]) + css_module = types.ModuleType("css_parser.css") + css_module.CSSRuleList = list + css_module.CSSStyleRule = type("CSSStyleRule", (), {}) + css_module.ColorValue = type("ColorValue", (), {}) + css_module.CSSUnknownRule = type("CSSUnknownRule", (), {}) + monkeypatch.setitem(sys.modules, "css_parser", css_parser) + monkeypatch.setitem(sys.modules, "css_parser.css", css_module) + stringcase = types.ModuleType("stringcase") + stringcase.snakecase = lambda value: value + monkeypatch.setitem(sys.modules, "stringcase", stringcase) + + map_module = types.ModuleType("deadlock_assets_api.models.v1.map") + map_module.MapV1 = type("MapV1", (), {}) + monkeypatch.setitem(sys.modules, "deadlock_assets_api.models.v1.map", map_module) + steam_info_module = types.ModuleType("deadlock_assets_api.models.v1.steam_info") + steam_info_module.SteamInfoV1 = type("SteamInfoV1", (), {}) + monkeypatch.setitem(sys.modules, "deadlock_assets_api.models.v1.steam_info", steam_info_module) + main_module = types.ModuleType("deadlock_assets_api.main") + main_module.app = object() + monkeypatch.setitem(sys.modules, "deadlock_assets_api.main", main_module) + + for module_name in [ + "deadlock_assets_api.models.enums", + "deadlock_assets_api.utils", + "deadlock_assets_api.routes.v1", + "deadlock_assets_api.deploy", + ]: + sys.modules.pop(module_name, None) + + +def test_generic_data_model_accepts_old_minimal_snapshots() -> None: + payload = json.loads((REPO_ROOT / "res/builds/5959/v2/generic_data.json").read_text()) + + generic_data = GenericDataV2.model_validate(payload) + + assert generic_data.item_price_per_tier == [0, 800, 1600, 3200, 6400, 9999] + assert generic_data.lane_info == [] + assert generic_data.weapon_groups == [] + + +def test_generic_data_model_accepts_legacy_street_brawl_draft_shape() -> None: + payload = json.loads((REPO_ROOT / "res/builds/6351/v2/generic_data.json").read_text()) + + generic_data = GenericDataV2.model_validate(payload) + + first_round = ( + generic_data.street_brawl.item_draft_rounds_per_game_round[0].item_draft_rounds[0] + ) + assert first_round.normal_mod_tier.value == 2 + assert first_round.rare_mod_tier.value == 1 + + +def test_missing_generated_file_returns_404_instead_of_500( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + prepare_runtime(tmp_path, monkeypatch) + utils = importlib.import_module("deadlock_assets_api.utils") + + with pytest.raises(HTTPException) as exc_info: + utils.read_parse_data_ta( + "deploy/versions/5959/images_data.json", TypeAdapter(dict[str, str]) + ) + + assert exc_info.value.status_code == 404 + + +def test_images_fall_back_to_closest_available_manifest( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + prepare_runtime(tmp_path, monkeypatch) + images_folder = tmp_path / "deploy/versions/6016" + images_folder.mkdir(parents=True) + (images_folder / "images_data.json").write_text(json.dumps({"closest_image": "url"})) + (tmp_path / "deploy/versions/5959").mkdir(parents=True) + + historical = importlib.import_module("deadlock_assets_api.historical") + utils = importlib.import_module("deadlock_assets_api.utils") + + images = utils.read_parse_data_ta( + historical.closest_version_file(5959, "images_data.json"), + TypeAdapter(dict[str, str]), + ) + + assert images == {"closest_image": "url"} + + +def test_backfills_historical_generic_and_images( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + prepare_runtime(tmp_path, monkeypatch) + source_folder = tmp_path / "res/builds/5959/v2" + source_folder.mkdir(parents=True) + source_folder.joinpath("generic_data.json").write_text( + (REPO_ROOT / "res/builds/5959/v2/generic_data.json").read_text() + ) + existing_images_folder = tmp_path / "deploy/versions/6016" + existing_images_folder.mkdir(parents=True) + existing_images_folder.joinpath("images_data.json").write_text( + json.dumps({"existing": "image"}) + ) + + historical = importlib.import_module("deadlock_assets_api.historical") + + historical.backfill_historical_version_files("deploy", [6016, 5959], {"default": "image"}) + + generic_data = json.loads((tmp_path / "deploy/versions/5959/generic_data.json").read_text()) + image_data = json.loads((tmp_path / "deploy/versions/5959/images_data.json").read_text()) + assert generic_data["item_price_per_tier"] == [0, 800, 1600, 3200, 6400, 9999] + assert image_data == {"existing": "image"} + + +def test_image_backfill_uses_nearest_original_manifest( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + prepare_runtime(tmp_path, monkeypatch) + newer_images_folder = tmp_path / "deploy/versions/200" + older_images_folder = tmp_path / "deploy/versions/100" + newer_images_folder.mkdir(parents=True) + older_images_folder.mkdir(parents=True) + newer_images_folder.joinpath("images_data.json").write_text(json.dumps({"source": "200"})) + older_images_folder.joinpath("images_data.json").write_text(json.dumps({"source": "100"})) + + historical = importlib.import_module("deadlock_assets_api.historical") + + historical.backfill_historical_version_files("deploy", [200, 160, 140, 100], {}) + + image_data_160 = json.loads((tmp_path / "deploy/versions/160/images_data.json").read_text()) + image_data_140 = json.loads((tmp_path / "deploy/versions/140/images_data.json").read_text()) + assert image_data_160 == {"source": "200"} + assert image_data_140 == {"source": "100"} From 345af51a8641488b71d5a4b38d39a0036f732404 Mon Sep 17 00:00:00 2001 From: Mattia Date: Fri, 22 May 2026 16:35:57 +0200 Subject: [PATCH 2/3] Format historical asset compatibility changes --- deadlock_assets_api/models/v2/generic_data.py | 8 ++------ tests/test_historical_assets.py | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/deadlock_assets_api/models/v2/generic_data.py b/deadlock_assets_api/models/v2/generic_data.py index 63c03753..98534f97 100644 --- a/deadlock_assets_api/models/v2/generic_data.py +++ b/deadlock_assets_api/models/v2/generic_data.py @@ -156,9 +156,7 @@ class ItemDraftRound(BaseModel): normal_mod_tier: ItemTierV2 = Field( ..., - validation_alias=AliasChoices( - "m_eNormalModTier", "normal_mod_tier", "chance_enhanced" - ), + validation_alias=AliasChoices("m_eNormalModTier", "normal_mod_tier", "chance_enhanced"), ) rare_mod_tier: ItemTierV2 = Field( ..., @@ -285,9 +283,7 @@ class GenericDataV2(BaseModel): weapon_groups: list[ItemGroup] = Field( default_factory=list, validation_alias="m_vecWeaponGroups" ) - armor_groups: list[ItemGroup] = Field( - default_factory=list, validation_alias="m_vecArmorGroups" - ) + armor_groups: list[ItemGroup] = Field(default_factory=list, validation_alias="m_vecArmorGroups") spirit_groups: list[ItemGroup] = Field( default_factory=list, validation_alias="m_vecSpiritGroups" ) diff --git a/tests/test_historical_assets.py b/tests/test_historical_assets.py index b379d6ac..037ccd96 100644 --- a/tests/test_historical_assets.py +++ b/tests/test_historical_assets.py @@ -68,9 +68,7 @@ def test_generic_data_model_accepts_legacy_street_brawl_draft_shape() -> None: generic_data = GenericDataV2.model_validate(payload) - first_round = ( - generic_data.street_brawl.item_draft_rounds_per_game_round[0].item_draft_rounds[0] - ) + first_round = generic_data.street_brawl.item_draft_rounds_per_game_round[0].item_draft_rounds[0] assert first_round.normal_mod_tier.value == 2 assert first_round.rare_mod_tier.value == 1 From 2178b85f63926cadcd6560a05591e9a92101dfa6 Mon Sep 17 00:00:00 2001 From: Mattia Date: Sat, 23 May 2026 13:47:17 +0200 Subject: [PATCH 3/3] Address historical assets review feedback --- deadlock_assets_api/deploy.py | 4 +- deadlock_assets_api/historical.py | 61 ++-------------- deadlock_assets_api/models/v2/generic_data.py | 36 ++++------ deadlock_assets_api/routes/v1.py | 3 +- deadlock_assets_api/utils.py | 6 +- tests/test_historical_assets.py | 72 ++----------------- 6 files changed, 30 insertions(+), 152 deletions(-) diff --git a/deadlock_assets_api/deploy.py b/deadlock_assets_api/deploy.py index 27bde985..be9f09e9 100644 --- a/deadlock_assets_api/deploy.py +++ b/deadlock_assets_api/deploy.py @@ -9,7 +9,7 @@ from pydantic import TypeAdapter from deadlock_assets_api.glob import FONTS_BASE_URL, SOUNDS_BASE_URL, SVGS_BASE_URL, IMAGE_BASE_URL -from deadlock_assets_api.historical import backfill_historical_version_files +from deadlock_assets_api.historical import backfill_historical_generic_data from deadlock_assets_api.main import app from deadlock_assets_api.models.languages import Language from deadlock_assets_api.models.v1.colors import ColorV1 @@ -317,7 +317,7 @@ def item_from_raw_item(raw_item: RawUpgradeV2 | RawAbilityV2 | RawWeaponV2) -> I with open(f"{out_folder}/versions/{version_id}/images_data.json", "w") as f: json.dump(images_data, f) - backfill_historical_version_files(out_folder, client_versions, images_data) + backfill_historical_generic_data(out_folder, client_versions) with open(f"{out_folder}/versions/{version_id}/raw_heroes.json", "w") as f: json.dump([h.model_dump(exclude_none=True) for h in raw_heroes], f) diff --git a/deadlock_assets_api/historical.py b/deadlock_assets_api/historical.py index 1d06757d..3bf99bbe 100644 --- a/deadlock_assets_api/historical.py +++ b/deadlock_assets_api/historical.py @@ -1,68 +1,19 @@ -import json import os from deadlock_assets_api.models.v2.generic_data import GenericDataV2 -def closest_version_file( - client_version: int, filename: str, versions_folder: str = "deploy/versions" -) -> str: - exact_path = f"{versions_folder}/{client_version}/{filename}" - if os.path.exists(exact_path): - return exact_path - - candidates = [] - if os.path.isdir(versions_folder): - for version in os.listdir(versions_folder): - if not version.isdigit(): - continue - filepath = f"{versions_folder}/{version}/{filename}" - if os.path.exists(filepath): - candidates.append(int(version)) - - if not candidates: - return exact_path - - closest_version = min(candidates, key=lambda version: (abs(version - client_version), -version)) - return f"{versions_folder}/{closest_version}/{filename}" - - -def backfill_historical_version_files( - out_folder: str, client_versions: list[int], default_images_data: dict -) -> None: - available_image_versions: list[int] = [] - +def backfill_historical_generic_data(out_folder: str, client_versions: list[int]) -> None: for version_id in client_versions: version_folder = f"{out_folder}/versions/{version_id}" os.makedirs(version_folder, exist_ok=True) generic_source = f"res/builds/{version_id}/v2/generic_data.json" generic_target = f"{version_folder}/generic_data.json" - if os.path.exists(generic_source) and not os.path.exists(generic_target): - with open(generic_source) as f: - generic_data = GenericDataV2.model_validate_json(f.read()) - with open(generic_target, "w") as f: - f.write(generic_data.model_dump_json(exclude_none=True)) - - if os.path.exists(f"{version_folder}/images_data.json"): - available_image_versions.append(version_id) - - for version_id in client_versions: - version_folder = f"{out_folder}/versions/{version_id}" - images_target = f"{version_folder}/images_data.json" - if os.path.exists(images_target): + if not os.path.exists(generic_source) or os.path.exists(generic_target): continue - closest_version = min( - available_image_versions, - key=lambda candidate: (abs(candidate - version_id), -candidate), - default=None, - ) - if closest_version is not None: - with open(f"{out_folder}/versions/{closest_version}/images_data.json") as f: - images_data = json.load(f) - else: - images_data = default_images_data - - with open(images_target, "w") as f: - json.dump(images_data, f) + with open(generic_source) as f: + generic_data = GenericDataV2.model_validate_json(f.read()) + with open(generic_target, "w") as f: + f.write(generic_data.model_dump_json(exclude_none=True)) diff --git a/deadlock_assets_api/models/v2/generic_data.py b/deadlock_assets_api/models/v2/generic_data.py index 98534f97..3203b820 100644 --- a/deadlock_assets_api/models/v2/generic_data.py +++ b/deadlock_assets_api/models/v2/generic_data.py @@ -239,8 +239,8 @@ class StreetBrawl(BaseModel): outline_color_team1: list[int] | None = Field(None, validation_alias="m_OutlineColorTeam1") outline_color_team2: list[int] | None = Field(None, validation_alias="m_OutlineColorTeam2") outline_color_neutral: list[int] | None = Field(None, validation_alias="m_OutlineColorNeutral") - item_drafts: dict[ItemTierV2, DraftBuckets | None] = Field( - default_factory=dict, validation_alias="m_mapItemTierToItemDraftBuckets" + item_drafts: dict[ItemTierV2, DraftBuckets | None] | None = Field( + None, validation_alias="m_mapItemTierToItemDraftBuckets" ) @@ -249,7 +249,7 @@ class GenericDataV2(BaseModel): damage_flash: DamageFlashV2 = Field(..., validation_alias="m_mapDamageFlash") glitch_settings: GlitchSettingsV2 = Field(..., validation_alias="m_GlitchSettings") - lane_info: list[LaneInfoV2] = Field(default_factory=list, validation_alias="m_LaneInfo") + lane_info: list[LaneInfoV2] | None = Field(None, validation_alias="m_LaneInfo") new_player_metrics: list[NewPlayerMetricsV2] = Field(..., validation_alias="m_NewPlayerMetrics") minimap_team_rebels_color: ColorV1 | None = Field( None, validation_alias="m_MinimapTeamRebelsColor" @@ -263,30 +263,22 @@ class GenericDataV2(BaseModel): enemy_objectives_color: ColorV1 | None = Field(None, validation_alias="m_enemyObjectivesColor") enemy_zipline_color: ColorV1 | None = Field(None, validation_alias="m_enemyZiplineColor") item_price_per_tier: list[int] = Field(..., validation_alias="m_nItemPricePerTier") - trooper_kill_gold_share_frac: list[float] = Field( - default_factory=list, validation_alias="m_flTrooperKillGoldShareFrac" + trooper_kill_gold_share_frac: list[float] | None = Field( + None, validation_alias="m_flTrooperKillGoldShareFrac" ) - hero_kill_gold_share_frac: list[float] = Field( - default_factory=list, validation_alias="m_flHeroKillGoldShareFrac" + hero_kill_gold_share_frac: list[float] | None = Field( + None, validation_alias="m_flHeroKillGoldShareFrac" ) - aim_spring_strength: list[float] = Field( - default_factory=list, validation_alias="m_AimSpringStrength" - ) - targeting_spring_strength: list[float] = Field( - default_factory=list, validation_alias="m_TargetingSpringStrength" + aim_spring_strength: list[float] | None = Field(None, validation_alias="m_AimSpringStrength") + targeting_spring_strength: list[float] | None = Field( + None, validation_alias="m_TargetingSpringStrength" ) objective_params: ObjectiveParams | None = Field(None, validation_alias="m_ObjectiveParams") rejuv_params: RejuvParams | None = Field(None, validation_alias="m_RejuvParams") - mini_map_offsets: list[MiniMapOffsets] = Field( - default_factory=list, validation_alias="m_MiniMapOffsets" - ) - weapon_groups: list[ItemGroup] = Field( - default_factory=list, validation_alias="m_vecWeaponGroups" - ) - armor_groups: list[ItemGroup] = Field(default_factory=list, validation_alias="m_vecArmorGroups") - spirit_groups: list[ItemGroup] = Field( - default_factory=list, validation_alias="m_vecSpiritGroups" - ) + mini_map_offsets: list[MiniMapOffsets] | None = Field(None, validation_alias="m_MiniMapOffsets") + weapon_groups: list[ItemGroup] | None = Field(None, validation_alias="m_vecWeaponGroups") + armor_groups: list[ItemGroup] | None = Field(None, validation_alias="m_vecArmorGroups") + spirit_groups: list[ItemGroup] | None = Field(None, validation_alias="m_vecSpiritGroups") street_brawl: StreetBrawl | None = Field(None, validation_alias="m_StreetBrawl") @field_validator( diff --git a/deadlock_assets_api/routes/v1.py b/deadlock_assets_api/routes/v1.py index 3aa8be58..0040f8af 100644 --- a/deadlock_assets_api/routes/v1.py +++ b/deadlock_assets_api/routes/v1.py @@ -3,7 +3,6 @@ from starlette.responses import FileResponse from deadlock_assets_api import utils -from deadlock_assets_api.historical import closest_version_file from deadlock_assets_api.models.enums import LATEST_VERSION, ValidClientVersions from deadlock_assets_api.models.v1.colors import ColorV1 from deadlock_assets_api.models.v1.map import MapV1 @@ -63,7 +62,7 @@ def get_images(client_version: ValidClientVersions | None = None) -> dict[str, s if client_version is None: client_version = ValidClientVersions(LATEST_VERSION) return utils.read_parse_data_ta( - closest_version_file(client_version.value, "images_data.json"), _TA_IMAGES + f"deploy/versions/{client_version.value}/images_data.json", _TA_IMAGES ) diff --git a/deadlock_assets_api/utils.py b/deadlock_assets_api/utils.py index 3a2fcbb2..02e9d818 100644 --- a/deadlock_assets_api/utils.py +++ b/deadlock_assets_api/utils.py @@ -7,7 +7,7 @@ import css_parser from css_parser.css import CSSRuleList, CSSStyleRule from fastapi import HTTPException -from pydantic import TypeAdapter, BaseModel, ValidationError +from pydantic import TypeAdapter, BaseModel from deadlock_assets_api.models.enums import ValidClientVersions, ALL_CLIENT_VERSIONS from deadlock_assets_api.models.languages import Language @@ -88,8 +88,6 @@ def read_parse_data_ta[T](filepath: str, type_adapter: TypeAdapter[T]) -> T: return type_adapter.validate_json(f.read()) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=f"Data file not found: {filepath}") from exc - except ValidationError as exc: - raise HTTPException(status_code=422, detail=f"Data validation failed: {filepath}") from exc @lru_cache(maxsize=DATA_CACHE_MAXSIZE) @@ -100,8 +98,6 @@ def read_parse_data_model[T: BaseModel](filepath: str, model: type[T]) -> T: return model.model_validate_json(f.read()) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=f"Data file not found: {filepath}") from exc - except ValidationError as exc: - raise HTTPException(status_code=422, detail=f"Data validation failed: {filepath}") from exc def validate_client_version(client_version: ValidClientVersions | None = None) -> int: diff --git a/tests/test_historical_assets.py b/tests/test_historical_assets.py index 037ccd96..25e4954b 100644 --- a/tests/test_historical_assets.py +++ b/tests/test_historical_assets.py @@ -34,21 +34,9 @@ def prepare_runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: stringcase.snakecase = lambda value: value monkeypatch.setitem(sys.modules, "stringcase", stringcase) - map_module = types.ModuleType("deadlock_assets_api.models.v1.map") - map_module.MapV1 = type("MapV1", (), {}) - monkeypatch.setitem(sys.modules, "deadlock_assets_api.models.v1.map", map_module) - steam_info_module = types.ModuleType("deadlock_assets_api.models.v1.steam_info") - steam_info_module.SteamInfoV1 = type("SteamInfoV1", (), {}) - monkeypatch.setitem(sys.modules, "deadlock_assets_api.models.v1.steam_info", steam_info_module) - main_module = types.ModuleType("deadlock_assets_api.main") - main_module.app = object() - monkeypatch.setitem(sys.modules, "deadlock_assets_api.main", main_module) - for module_name in [ "deadlock_assets_api.models.enums", "deadlock_assets_api.utils", - "deadlock_assets_api.routes.v1", - "deadlock_assets_api.deploy", ]: sys.modules.pop(module_name, None) @@ -59,8 +47,8 @@ def test_generic_data_model_accepts_old_minimal_snapshots() -> None: generic_data = GenericDataV2.model_validate(payload) assert generic_data.item_price_per_tier == [0, 800, 1600, 3200, 6400, 9999] - assert generic_data.lane_info == [] - assert generic_data.weapon_groups == [] + assert generic_data.lane_info is None + assert generic_data.weapon_groups is None def test_generic_data_model_accepts_legacy_street_brawl_draft_shape() -> None: @@ -71,6 +59,7 @@ def test_generic_data_model_accepts_legacy_street_brawl_draft_shape() -> None: first_round = generic_data.street_brawl.item_draft_rounds_per_game_round[0].item_draft_rounds[0] assert first_round.normal_mod_tier.value == 2 assert first_round.rare_mod_tier.value == 1 + assert generic_data.street_brawl.item_drafts is None def test_missing_generated_file_returns_404_instead_of_500( @@ -87,67 +76,18 @@ def test_missing_generated_file_returns_404_instead_of_500( assert exc_info.value.status_code == 404 -def test_images_fall_back_to_closest_available_manifest( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - prepare_runtime(tmp_path, monkeypatch) - images_folder = tmp_path / "deploy/versions/6016" - images_folder.mkdir(parents=True) - (images_folder / "images_data.json").write_text(json.dumps({"closest_image": "url"})) - (tmp_path / "deploy/versions/5959").mkdir(parents=True) - - historical = importlib.import_module("deadlock_assets_api.historical") - utils = importlib.import_module("deadlock_assets_api.utils") - - images = utils.read_parse_data_ta( - historical.closest_version_file(5959, "images_data.json"), - TypeAdapter(dict[str, str]), - ) - - assert images == {"closest_image": "url"} - - -def test_backfills_historical_generic_and_images( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_backfills_historical_generic_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: prepare_runtime(tmp_path, monkeypatch) source_folder = tmp_path / "res/builds/5959/v2" source_folder.mkdir(parents=True) source_folder.joinpath("generic_data.json").write_text( (REPO_ROOT / "res/builds/5959/v2/generic_data.json").read_text() ) - existing_images_folder = tmp_path / "deploy/versions/6016" - existing_images_folder.mkdir(parents=True) - existing_images_folder.joinpath("images_data.json").write_text( - json.dumps({"existing": "image"}) - ) historical = importlib.import_module("deadlock_assets_api.historical") - historical.backfill_historical_version_files("deploy", [6016, 5959], {"default": "image"}) + historical.backfill_historical_generic_data("deploy", [6016, 5959]) generic_data = json.loads((tmp_path / "deploy/versions/5959/generic_data.json").read_text()) - image_data = json.loads((tmp_path / "deploy/versions/5959/images_data.json").read_text()) assert generic_data["item_price_per_tier"] == [0, 800, 1600, 3200, 6400, 9999] - assert image_data == {"existing": "image"} - - -def test_image_backfill_uses_nearest_original_manifest( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - prepare_runtime(tmp_path, monkeypatch) - newer_images_folder = tmp_path / "deploy/versions/200" - older_images_folder = tmp_path / "deploy/versions/100" - newer_images_folder.mkdir(parents=True) - older_images_folder.mkdir(parents=True) - newer_images_folder.joinpath("images_data.json").write_text(json.dumps({"source": "200"})) - older_images_folder.joinpath("images_data.json").write_text(json.dumps({"source": "100"})) - - historical = importlib.import_module("deadlock_assets_api.historical") - - historical.backfill_historical_version_files("deploy", [200, 160, 140, 100], {}) - - image_data_160 = json.loads((tmp_path / "deploy/versions/160/images_data.json").read_text()) - image_data_140 = json.loads((tmp_path / "deploy/versions/140/images_data.json").read_text()) - assert image_data_160 == {"source": "200"} - assert image_data_140 == {"source": "100"} + assert "lane_info" not in generic_data