arkparser 0.7.4__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {arkparser-0.7.4 → arkparser-0.8.0}/PKG-INFO +1 -1
  2. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/__init__.py +1 -1
  3. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/export.py +99 -35
  4. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser.egg-info/PKG-INFO +1 -1
  5. {arkparser-0.7.4 → arkparser-0.8.0}/pyproject.toml +1 -1
  6. arkparser-0.8.0/tests/test_export.py +367 -0
  7. arkparser-0.7.4/tests/test_export.py +0 -211
  8. {arkparser-0.7.4 → arkparser-0.8.0}/LICENSE +0 -0
  9. {arkparser-0.7.4 → arkparser-0.8.0}/README.md +0 -0
  10. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/__init__.py +0 -0
  11. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/binary_reader.py +0 -0
  12. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/exceptions.py +0 -0
  13. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/map_config.py +0 -0
  14. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/normalization.py +0 -0
  15. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/types.py +0 -0
  16. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/version_detection.py +0 -0
  17. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/data_models.py +0 -0
  18. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/__init__.py +0 -0
  19. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/base.py +0 -0
  20. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/cloud_inventory.py +0 -0
  21. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/profile.py +0 -0
  22. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/tribe.py +0 -0
  23. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/world_save.py +0 -0
  24. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/game_objects/__init__.py +0 -0
  25. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/game_objects/container.py +0 -0
  26. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/game_objects/game_object.py +0 -0
  27. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/game_objects/location.py +0 -0
  28. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/__init__.py +0 -0
  29. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/base.py +0 -0
  30. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/byte_property.py +0 -0
  31. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/compound.py +0 -0
  32. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/primitives.py +0 -0
  33. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/registry.py +0 -0
  34. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/__init__.py +0 -0
  35. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/base.py +0 -0
  36. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/colors.py +0 -0
  37. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/misc.py +0 -0
  38. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/property_list.py +0 -0
  39. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/registry.py +0 -0
  40. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/vectors.py +0 -0
  41. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser.egg-info/SOURCES.txt +0 -0
  42. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser.egg-info/dependency_links.txt +0 -0
  43. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser.egg-info/requires.txt +0 -0
  44. {arkparser-0.7.4 → arkparser-0.8.0}/arkparser.egg-info/top_level.txt +0 -0
  45. {arkparser-0.7.4 → arkparser-0.8.0}/setup.cfg +0 -0
  46. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_asa_header_position.py +0 -0
  47. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_asa_name_table.py +0 -0
  48. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_asa_profile_drop.py +0 -0
  49. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_ase_cluster_drift.py +0 -0
  50. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_binary_reader.py +0 -0
  51. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_binary_reader_layouts.py +0 -0
  52. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_cloud_export.py +0 -0
  53. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_cloud_inventory.py +0 -0
  54. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_cryopod_export.py +0 -0
  55. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_current_stats.py +0 -0
  56. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_data_models.py +0 -0
  57. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_game_objects.py +0 -0
  58. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_golden_exports.py +0 -0
  59. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_lazy_properties.py +0 -0
  60. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_profile.py +0 -0
  61. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_review_fixes.py +0 -0
  62. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_tribe.py +0 -0
  63. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_v13_property_layouts.py +0 -0
  64. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_version_detection.py +0 -0
  65. {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_world_save.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.7.4
3
+ Version: 0.8.0
4
4
  Summary: Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files
5
5
  Author: Vertyco
6
6
  License-Expression: MIT
@@ -95,4 +95,4 @@ __all__ = [
95
95
  "ArkParseError",
96
96
  ]
97
97
 
98
- __version__ = "0.7.3"
98
+ __version__ = "0.8.0"
@@ -326,6 +326,18 @@ LEGACY_TRIBE_KEYS: frozenset[str] = frozenset(
326
326
  "dataFile",
327
327
  }
328
328
  )
329
+ # Tribe-record keys that must always emit, even when null/zero. The three
330
+ # last_member_login fields have "null means cannot tell" semantics and a real
331
+ # zero value (login within the final in-game second before the snapshot), so
332
+ # _compact must never prune them. Not part of LEGACY_TRIBE_KEYS: that set
333
+ # names the ASVExport parity contract and stays legacy-only.
334
+ TRIBE_KEEP_KEYS: frozenset[str] = LEGACY_TRIBE_KEYS | frozenset(
335
+ {
336
+ "last_member_login",
337
+ "last_member_login_player_id",
338
+ "last_member_login_game_seconds",
339
+ }
340
+ )
329
341
  LEGACY_STRUCT_KEYS: frozenset[str] = frozenset(
330
342
  {
331
343
  "id",
@@ -2436,50 +2448,89 @@ def _tribe_file_date(tribe: t.Any, save: t.Any) -> dt.datetime | None:
2436
2448
  return getattr(save, "file_mtime", None) if save is not None else None
2437
2449
 
2438
2450
 
2439
- def _tribe_active_iso(
2440
- file_date: t.Any,
2451
+ def _tribe_last_login(
2441
2452
  member_ids: t.Iterable[int],
2442
2453
  profile_active: dict[int, dt.datetime],
2443
- ) -> str | None:
2444
- """Legacy ContentTribe.LastActive (ContentTribe.cs:35-49): the max of the
2445
- tribe file date and the members' last-active datetimes, discarding values
2446
- in the future. Tribe-log "Day N" stamps are game-calendar time, not real
2447
- seconds, so they cannot be converted with the save anchor and are unused.
2448
-
2449
- Returns ``None`` when no past candidate exists.
2454
+ profile_login_raw: dict[int, float],
2455
+ game_time: float,
2456
+ ) -> tuple[str | None, int | None, int | None]:
2457
+ """Game-domain last-login rollup over a tribe's allocated members.
2458
+
2459
+ Winner is the argmax of raw ``LastLoginTime`` restricted to
2460
+ ``login <= game_time``: profiles keep saving after the world snapshot, so
2461
+ logins can exceed the world's game clock; those are dropped, never
2462
+ clamped. Selection and filtering both run in the game-time domain, so the
2463
+ result never depends on any file mtime (the defect that ruins the legacy
2464
+ ``active`` as an inactivity signal).
2465
+
2466
+ Returns ``(anchored_iso, player_id, game_seconds)``, all ``None`` when no
2467
+ member passes. ``anchored_iso`` alone may be ``None`` with the other two
2468
+ set, when the save lacks the mtime anchor. ``game_seconds`` is
2469
+ ``int(game_time - login)``: a floored, non-negative JSON int.
2470
+
2471
+ Cluster profiles are out of scope by construction: nothing puts a
2472
+ foreign-map Profile into ``save.profiles``, so every candidate here lives
2473
+ on this map's game clock. No defensive logic needed.
2450
2474
  """
2451
- assert isinstance(profile_active, dict), "profile_active must be a dict"
2452
- candidates: list[dt.datetime] = []
2453
- if isinstance(file_date, dt.datetime):
2454
- candidates.append(file_date if file_date.tzinfo else file_date.astimezone())
2475
+ assert isinstance(profile_login_raw, dict), "profile_login_raw must be a dict"
2476
+ if game_time <= 0:
2477
+ return None, None, None
2478
+ best_pid: int | None = None
2479
+ best_raw = float("-inf")
2455
2480
  for i, pid in enumerate(member_ids):
2456
2481
  assert i < _MAX_TRIBE_MEMBERS, "tribe member list exceeded bound"
2457
- active = profile_active.get(_int(pid))
2458
- if active is not None:
2459
- candidates.append(active if active.tzinfo else active.astimezone())
2460
- now = dt.datetime.now(dt.timezone.utc)
2461
- past = [c for c in candidates if c <= now]
2462
- if not past:
2482
+ raw = profile_login_raw.get(_int(pid))
2483
+ if raw is None or raw > game_time or raw <= best_raw:
2484
+ continue
2485
+ best_pid = _int(pid)
2486
+ best_raw = raw
2487
+ if best_pid is None:
2488
+ return None, None, None
2489
+ seconds = int(game_time - best_raw)
2490
+ assert seconds >= 0, "game_seconds must be non-negative after the future filter"
2491
+ anchored = profile_active.get(best_pid)
2492
+ iso = anchored.isoformat() if anchored is not None else None
2493
+ return iso, best_pid, seconds
2494
+
2495
+
2496
+ def _file_date_iso(value: t.Any) -> str | None:
2497
+ """ISO 8601 for a tribe file date, dropping future values (clock skew,
2498
+ bad rsync). Non-datetime input yields ``None``."""
2499
+ if not isinstance(value, dt.datetime):
2463
2500
  return None
2464
- return max(past).isoformat()
2501
+ aware = value if value.tzinfo else value.astimezone()
2502
+ assert aware.tzinfo is not None, "file date must be timezone-aware here"
2503
+ now = dt.datetime.now(dt.timezone.utc)
2504
+ assert now.tzinfo is not None, "now must be timezone-aware"
2505
+ return aware.isoformat() if aware <= now else None
2465
2506
 
2466
2507
 
2467
- def _profile_actives(profiles: list[Profile], save: t.Any) -> dict[int, dt.datetime]:
2468
- """``player_id -> last-active datetime`` for tribe activity rollups.
2508
+ def _profile_actives(
2509
+ profiles: list[Profile], save: t.Any
2510
+ ) -> tuple[dict[int, dt.datetime], dict[int, float]]:
2511
+ """Per-player last-login maps for tribe activity rollups.
2469
2512
 
2470
- Mirrors legacy ContentPlayer.LastActiveDateTime: the profile's
2471
- LastLoginTime converted through the save anchor.
2513
+ Returns ``(anchored, raw)``: ``anchored`` is ``player_id -> LastLoginTime``
2514
+ converted through the save anchor (legacy ContentPlayer.LastActiveDateTime,
2515
+ inherits .ark mtime error); ``raw`` is ``player_id -> LastLoginTime`` as
2516
+ in-game seconds, untouched by any filesystem state. One pass over
2517
+ ``profiles`` fills both.
2472
2518
  """
2473
2519
  assert isinstance(profiles, list), "profiles must be a list"
2474
- out: dict[int, dt.datetime] = {}
2520
+ anchored: dict[int, dt.datetime] = {}
2521
+ raw: dict[int, float] = {}
2475
2522
  for prof in profiles:
2476
2523
  pid = _int(prof.player_id)
2477
2524
  if not pid:
2478
2525
  continue
2526
+ login = _float(prof.last_login_time)
2527
+ if login > 0:
2528
+ raw[pid] = login
2479
2529
  active = _approx_real_datetime(prof.last_login_time, save)
2480
2530
  if active is not None:
2481
- out[pid] = active
2482
- return out
2531
+ anchored[pid] = active
2532
+ assert len(anchored) <= len(profiles) and len(raw) <= len(profiles), "rollup maps exceed input"
2533
+ return anchored, raw
2483
2534
 
2484
2535
 
2485
2536
  def _build_profile_index(save: t.Any) -> dict[int, Profile]:
@@ -2785,7 +2836,7 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
2785
2836
  member_name.setdefault(pid, mname)
2786
2837
  file_tribe_ids = set(rich) | {_UNCLAIMED_TRIBE_ID, _ABANDONED_TRIBE_ID}
2787
2838
  profiles = _profile_entries(save)
2788
- profile_active = _profile_actives(profiles, save)
2839
+ profile_active, profile_login_raw = _profile_actives(profiles, save)
2789
2840
  profile_tribeid, players_by_tribe = _allocate_profiles(
2790
2841
  profiles, file_tribe_ids, member_index, tribe_members
2791
2842
  )
@@ -2814,6 +2865,8 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
2814
2865
  "counts": counts,
2815
2866
  "profile_index": profile_index,
2816
2867
  "profile_active": profile_active,
2868
+ "profile_login_raw": profile_login_raw,
2869
+ "game_time": _float(getattr(save, "game_time", 0.0)),
2817
2870
  "players_by_tribe": players_by_tribe,
2818
2871
  "profile_tribeid": profile_tribeid,
2819
2872
  "member_stubs": member_stubs,
@@ -2862,18 +2915,26 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
2862
2915
 
2863
2916
  File-backed tribes reuse the rich record (dataFile/owner/alliances) but
2864
2917
  have ``players``/``members`` overridden to the allocated-player set so
2865
- they match the legacy writer (which counts allocated profiles, not the raw
2866
- member list). Synthesized stubs carry id + name + world-derived counts.
2867
- ``active`` finalizes here for both shapes: max of the tribe file date and
2868
- the allocated members' last-active times (legacy ContentTribe.LastActive).
2918
+ they match the legacy writer. ``active`` is the anchored last member
2919
+ login when one exists, else the tribe file date (spec D1-b; deliberate
2920
+ deviation from legacy ContentTribe.LastActive, which was the file mtime
2921
+ on every file-backed tribe). The three ``last_member_login*`` fields are
2922
+ the mtime-immune signal and always emit, null meaning "cannot tell".
2869
2923
  """
2924
+ assert isinstance(a, dict), "assembled tribes must be a dict"
2870
2925
  players = a["players_by_tribe"].get(tid, [])
2926
+ iso, login_pid, game_seconds = _tribe_last_login(
2927
+ players, a["profile_active"], a["profile_login_raw"], a["game_time"]
2928
+ )
2871
2929
  base = a["rich"].get(tid)
2872
2930
  if base is not None:
2873
2931
  rec = dict(base)
2874
2932
  rec["players"] = len(players)
2875
2933
  rec["members"] = _members_for(tid, a)
2876
- rec["active"] = _tribe_active_iso(rec.get("active"), players, a["profile_active"])
2934
+ rec["active"] = iso if iso is not None else _file_date_iso(rec.get("active"))
2935
+ rec["last_member_login"] = iso
2936
+ rec["last_member_login_player_id"] = login_pid
2937
+ rec["last_member_login_game_seconds"] = game_seconds
2877
2938
  return rec
2878
2939
  c = a["counts"].get(tid, {})
2879
2940
  data: dict[str, t.Any] = {
@@ -2884,10 +2945,13 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
2884
2945
  "tames": c.get("tames", 0),
2885
2946
  "uploadedTames": 0,
2886
2947
  "structures": c.get("structures", 0),
2887
- "active": _tribe_active_iso(None, players, a["profile_active"]),
2948
+ "active": iso,
2888
2949
  "dataFile": "",
2950
+ "last_member_login": iso,
2951
+ "last_member_login_player_id": login_pid,
2952
+ "last_member_login_game_seconds": game_seconds,
2889
2953
  }
2890
- return _compact(data, LEGACY_TRIBE_KEYS)
2954
+ return _compact(data, TRIBE_KEEP_KEYS)
2891
2955
 
2892
2956
 
2893
2957
  def export_tribes(save: t.Any) -> list[dict[str, t.Any]]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.7.4
3
+ Version: 0.8.0
4
4
  Summary: Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files
5
5
  Author: Vertyco
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arkparser"
7
- version = "0.7.4"
7
+ version = "0.8.0"
8
8
  description = "Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,367 @@
1
+ """Regression tests for export helpers against real parser objects."""
2
+
3
+ import datetime as dt
4
+ import os
5
+ import shutil
6
+ import typing as t
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from arkparser import Profile, Tribe, WorldSave
12
+ from arkparser.export import (
13
+ export_all,
14
+ export_players,
15
+ export_structures,
16
+ export_tamed,
17
+ export_to_files,
18
+ export_tribe_logs,
19
+ export_tribes,
20
+ export_wild,
21
+ )
22
+
23
+ _EXAMPLES = Path(__file__).parent.parent / "references" / "examples"
24
+ _ASE_SCORCHED_EARTH = _EXAMPLES / "ase" / "maps" / "scorchedearth" / "ScorchedEarth_P.ark"
25
+ _MAP_DUMP_EXTINCTION = Path(__file__).parent.parent / "references" / "map_dumps" / "evolved" / "extinction"
26
+
27
+
28
+ def _first_dump_file(pattern: str) -> Path:
29
+ """First map-dump fixture matching ``pattern``, else skip the test."""
30
+ if _MAP_DUMP_EXTINCTION.is_dir():
31
+ files = sorted(_MAP_DUMP_EXTINCTION.glob(pattern))
32
+ if files:
33
+ return files[0]
34
+ pytest.skip(f"no {pattern} fixture under {_MAP_DUMP_EXTINCTION}")
35
+
36
+
37
+ def _profile_with_last_login() -> Profile:
38
+ """First map-dump profile carrying LastLoginTime, else skip the test."""
39
+ if _MAP_DUMP_EXTINCTION.is_dir():
40
+ for path in sorted(_MAP_DUMP_EXTINCTION.glob("*.arkprofile"))[:50]:
41
+ try:
42
+ profile = Profile.load(path)
43
+ except Exception: # noqa: BLE001 - corrupt fixture must not abort
44
+ continue
45
+ if profile.last_login_time and profile.player_id:
46
+ return profile
47
+ pytest.skip(f"no profile with LastLoginTime under {_MAP_DUMP_EXTINCTION}")
48
+
49
+ _ASV_NAMES = {
50
+ "ASV_Tamed",
51
+ "ASV_Wild",
52
+ "ASV_Players",
53
+ "ASV_Tribes",
54
+ "ASV_Structures",
55
+ "ASV_TribeLogs",
56
+ "ASV_MapStructures",
57
+ }
58
+
59
+
60
+ @pytest.fixture(scope="session")
61
+ def ase_export_world_save() -> WorldSave:
62
+ if not _ASE_SCORCHED_EARTH.exists():
63
+ pytest.skip(f"Fixture not available: {_ASE_SCORCHED_EARTH}")
64
+ return WorldSave.load(_ASE_SCORCHED_EARTH)
65
+
66
+
67
+ def test_export_tamed_matches_world_save(ase_export_world_save: WorldSave) -> None:
68
+ exported = export_tamed(ase_export_world_save)
69
+ assert len(exported) == len(ase_export_world_save.get_tamed_creatures())
70
+ assert any(creature.get("name") == "escobar" for creature in exported)
71
+
72
+
73
+ def test_export_wild_matches_world_save(ase_export_world_save: WorldSave) -> None:
74
+ exported = export_wild(ase_export_world_save)
75
+ assert len(exported) == len(ase_export_world_save.get_wild_creatures())
76
+ assert all(creature.get("tamer", "") == "" for creature in exported[:100])
77
+
78
+
79
+ def test_export_structures_matches_world_save(ase_export_world_save: WorldSave) -> None:
80
+ exported = export_structures(ase_export_world_save)
81
+ assert len(exported) == len(ase_export_world_save.get_structures())
82
+
83
+
84
+ def test_export_players_uses_profile_parser(ase_profile_path: Path) -> None:
85
+ profile = Profile.load(ase_profile_path)
86
+ exported = export_players(type("Holder", (), {"profiles": [profile]})())
87
+ assert exported[0]["playerid"] == profile.player_id
88
+ assert exported[0]["steam"] == profile.player_name
89
+ assert exported[0]["name"] == profile.character_name
90
+ assert exported[0]["sex"] == ("Female" if profile.is_female else "Male")
91
+ assert exported[0]["tribeid"] == profile.tribe_id
92
+
93
+
94
+ def test_export_tribes_uses_tribe_parser(ase_tribe_path: Path) -> None:
95
+ tribe = Tribe.load(ase_tribe_path)
96
+ exported = export_tribes(type("Holder", (), {"tribes": [tribe]})())
97
+ assert exported[0]["tribeid"] == tribe.tribe_id
98
+ assert exported[0]["tribe"] == tribe.name
99
+ assert exported[0]["players"] == tribe.member_count
100
+
101
+
102
+ def test_export_tribes_active_falls_back_to_file_mtime(tmp_path: Path) -> None:
103
+ """With no member login data, ``active`` falls back to the .arktribe
104
+ file's write time (D1-b: login wins when present, file date otherwise).
105
+ The last_member_login fields stay null and are still emitted."""
106
+ tribe_path = _first_dump_file("*.arktribe")
107
+ copy = tmp_path / tribe_path.name
108
+ shutil.copy2(tribe_path, copy)
109
+ local_tz = dt.datetime.now().astimezone().tzinfo
110
+ stamp = dt.datetime(2024, 5, 1, 12, 0, 0, tzinfo=local_tz)
111
+ os.utime(copy, (stamp.timestamp(), stamp.timestamp()))
112
+ tribe = Tribe.load(copy)
113
+ now = dt.datetime.now(tz=local_tz)
114
+ holder = type("Holder", (), {"tribes": [tribe], "file_mtime": now, "game_time": 5_000.0})()
115
+ exported = export_tribes(holder)
116
+ rec = next(r for r in exported if r["tribeid"] == tribe.tribe_id)
117
+ assert rec["active"] is not None
118
+ active = dt.datetime.fromisoformat(rec["active"])
119
+ assert active.timestamp() == pytest.approx(stamp.timestamp(), abs=2)
120
+ assert rec["last_member_login"] is None
121
+ assert rec["last_member_login_player_id"] is None
122
+ assert rec["last_member_login_game_seconds"] is None
123
+
124
+
125
+ def test_export_tribes_solo_active_from_profile_last_login() -> None:
126
+ """A profile-only (solo) tribe gets ``active`` from the member's
127
+ LastLoginTime converted through the save anchor, matching legacy
128
+ ContentTribe.LastActive over allocated players."""
129
+ profile = _profile_with_last_login()
130
+ local_tz = dt.datetime.now().astimezone().tzinfo
131
+ now = dt.datetime.now(tz=local_tz)
132
+ game_time = float(profile.last_login_time) + 3_600.0
133
+ holder = type(
134
+ "Holder",
135
+ (),
136
+ {"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
137
+ )()
138
+ exported = export_tribes(holder)
139
+ rec = next(r for r in exported if r["tribeid"] == profile.player_id)
140
+ assert rec["active"] is not None
141
+ active = dt.datetime.fromisoformat(rec["active"])
142
+ expected = now - dt.timedelta(seconds=3_600)
143
+ assert active.timestamp() == pytest.approx(expected.timestamp(), abs=2)
144
+
145
+
146
+ def test_export_tribes_active_never_future() -> None:
147
+ """A LastLoginTime past the world's game clock is dropped on the
148
+ game_time rule: even with file_mtime pushed weeks into the past (so the
149
+ anchored candidate lands before now and the old real-domain filter would
150
+ keep it), the member is dropped and game_seconds never goes negative."""
151
+ profile = _profile_with_last_login()
152
+ local_tz = dt.datetime.now().astimezone().tzinfo
153
+ stale_mtime = dt.datetime.now(tz=local_tz) - dt.timedelta(weeks=6)
154
+ game_time = max(float(profile.last_login_time) - 100_000_000.0, 1.0)
155
+ holder = type(
156
+ "Holder",
157
+ (),
158
+ {"tribes": [], "profiles": [profile], "file_mtime": stale_mtime, "game_time": game_time},
159
+ )()
160
+ exported = export_tribes(holder)
161
+ rec = next(r for r in exported if r["tribeid"] == profile.player_id)
162
+ assert rec.get("active") is None
163
+ assert rec["last_member_login"] is None
164
+ assert rec["last_member_login_game_seconds"] is None
165
+
166
+
167
+ def test_allocate_profiles_ignores_stale_profile_tribe_id() -> None:
168
+ """A profile whose TribeID names a tribe that no longer lists the player
169
+ in its member roster must NOT be allocated to that tribe (the game leaves
170
+ TribeID stale in profiles of members removed while offline). It falls
171
+ through to a solo tribe keyed on the player id."""
172
+ from arkparser.export import _allocate_profiles
173
+
174
+ current = t.cast(Profile, type("P", (), {"player_id": 1, "raw_tribe_id": 555})())
175
+ stale = t.cast(Profile, type("P", (), {"player_id": 987, "raw_tribe_id": 555})())
176
+ member_index = {1: 555, 2: 555}
177
+ tribe_members = {555: {1, 2}}
178
+ profile_tribeid, players_by_tribe = _allocate_profiles(
179
+ [current, stale], {555}, member_index, tribe_members
180
+ )
181
+ assert profile_tribeid[1] == 555
182
+ assert profile_tribeid[987] == 987
183
+ assert players_by_tribe[555] == [1]
184
+ assert players_by_tribe[987] == [987]
185
+
186
+
187
+ def test_allocate_profiles_member_list_still_wins_without_explicit_id() -> None:
188
+ """A profile with no usable TribeID but present in a tribe's roster is
189
+ still allocated to that tribe via the member-list fallback."""
190
+ from arkparser.export import _allocate_profiles
191
+
192
+ prof = t.cast(Profile, type("P", (), {"player_id": 2, "raw_tribe_id": 0})())
193
+ profile_tribeid, players_by_tribe = _allocate_profiles(
194
+ [prof], {555}, {2: 555}, {555: {2}}
195
+ )
196
+ assert profile_tribeid[2] == 555
197
+ assert players_by_tribe[555] == [2]
198
+
199
+
200
+ def test_export_tribe_logs_uses_tribe_parser(ase_tribe_path: Path) -> None:
201
+ tribe = Tribe.load(ase_tribe_path)
202
+ exported = export_tribe_logs(type("Holder", (), {"tribes": [tribe]})())
203
+ assert exported[0]["tribeid"] == tribe.tribe_id
204
+ assert exported[0]["tribe"] == tribe.name
205
+ assert exported[0]["logs"] == tribe.log_entries
206
+
207
+
208
+ def test_export_all_uses_asv_names(ase_export_world_save: WorldSave) -> None:
209
+ exported = export_all(ase_export_world_save)
210
+ assert set(exported) == _ASV_NAMES
211
+
212
+
213
+ def test_export_to_files_uses_asv_names(
214
+ ase_export_world_save: WorldSave, tmp_path: Path
215
+ ) -> None:
216
+ created = export_to_files(ase_export_world_save, tmp_path)
217
+ assert {path.name for path in created} == {f"{name}.json" for name in _ASV_NAMES}
218
+
219
+
220
+ def test_profile_actives_returns_anchored_and_raw_maps() -> None:
221
+ """_profile_actives yields both the anchored datetime map (legacy) and a
222
+ raw LastLoginTime seconds map keyed by player id."""
223
+ from arkparser.export import _profile_actives
224
+
225
+ profile = _profile_with_last_login()
226
+ local_tz = dt.datetime.now().astimezone().tzinfo
227
+ now = dt.datetime.now(tz=local_tz)
228
+ game_time = float(profile.last_login_time) + 3_600.0
229
+ holder = type("Holder", (), {"file_mtime": now, "game_time": game_time})()
230
+ anchored, raw = _profile_actives([profile], holder)
231
+ pid = int(profile.player_id)
232
+ assert pid in anchored and pid in raw
233
+ assert raw[pid] == pytest.approx(float(profile.last_login_time))
234
+ assert isinstance(anchored[pid], dt.datetime)
235
+
236
+
237
+ def test_tribe_last_login_game_domain_argmax() -> None:
238
+ """Winner is the max raw LastLoginTime <= game_time; all three outputs
239
+ describe that one member; game_seconds is a non-negative int."""
240
+ from arkparser.export import _tribe_last_login
241
+
242
+ local_tz = dt.datetime.now().astimezone().tzinfo
243
+ t1 = dt.datetime(2024, 1, 1, tzinfo=local_tz)
244
+ t2 = dt.datetime(2024, 6, 1, tzinfo=local_tz)
245
+ anchored = {1: t1, 2: t2}
246
+ raw = {1: 1_000.0, 2: 5_000.5}
247
+ iso, pid, secs = _tribe_last_login([1, 2], anchored, raw, 9_000.0)
248
+ assert pid == 2
249
+ assert iso == t2.isoformat()
250
+ assert secs == 3_999 # int(9000.0 - 5000.5) floors, stays int
251
+ assert isinstance(secs, int) and not isinstance(secs, bool)
252
+
253
+
254
+ def test_tribe_last_login_drops_future_on_game_time_not_wall_clock() -> None:
255
+ """A login past the world's game clock is dropped, not clamped, even when
256
+ its anchored datetime lands safely in the past (stale-mtime case the
257
+ real-domain filter gets wrong). Never emits negative game_seconds."""
258
+ from arkparser.export import _tribe_last_login
259
+
260
+ local_tz = dt.datetime.now().astimezone().tzinfo
261
+ long_ago = dt.datetime(2020, 1, 1, tzinfo=local_tz)
262
+ iso, pid, secs = _tribe_last_login([7], {7: long_ago}, {7: 10_000.0}, 9_000.0)
263
+ assert (iso, pid, secs) == (None, None, None)
264
+
265
+
266
+ def test_tribe_last_login_zero_and_no_data() -> None:
267
+ """Login exactly at the snapshot gives game_seconds == 0 (a real value,
268
+ distinct from null); empty inputs and game_time <= 0 give all-None."""
269
+ from arkparser.export import _tribe_last_login
270
+
271
+ local_tz = dt.datetime.now().astimezone().tzinfo
272
+ stamp = dt.datetime(2024, 3, 1, tzinfo=local_tz)
273
+ iso, pid, secs = _tribe_last_login([3], {3: stamp}, {3: 9_000.0}, 9_000.0)
274
+ assert (pid, secs) == (3, 0)
275
+ assert iso == stamp.isoformat()
276
+ assert _tribe_last_login([], {}, {}, 9_000.0) == (None, None, None)
277
+ assert _tribe_last_login([3], {3: stamp}, {3: 9_000.0}, 0.0) == (None, None, None)
278
+
279
+
280
+ def test_tribe_last_login_iso_none_when_anchor_missing() -> None:
281
+ """No anchored datetime (save had no usable mtime) still yields player_id
282
+ and game_seconds; only the ISO is None."""
283
+ from arkparser.export import _tribe_last_login
284
+
285
+ iso, pid, secs = _tribe_last_login([4], {}, {4: 2_500.0}, 9_000.0)
286
+ assert (iso, pid, secs) == (None, 4, 6_500)
287
+
288
+
289
+ def test_file_date_iso_past_only() -> None:
290
+ """_file_date_iso emits past datetimes as ISO and drops future or
291
+ non-datetime values."""
292
+ from arkparser.export import _file_date_iso
293
+
294
+ local_tz = dt.datetime.now().astimezone().tzinfo
295
+ past = dt.datetime.now(tz=local_tz) - dt.timedelta(days=1)
296
+ future = dt.datetime.now(tz=local_tz) + dt.timedelta(days=1)
297
+ assert _file_date_iso(past) == past.isoformat()
298
+ assert _file_date_iso(future) is None
299
+ assert _file_date_iso(None) is None
300
+ assert _file_date_iso("2024-01-01") is None
301
+
302
+
303
+ def test_export_tribes_last_login_ignores_world_save_mtime() -> None:
304
+ """The claim the feature rests on: vary save.file_mtime across two
305
+ exports of the same fixture; game_seconds and player_id are identical,
306
+ only the anchored ISO fields (last_member_login, active) move."""
307
+ profile = _profile_with_last_login()
308
+ local_tz = dt.datetime.now().astimezone().tzinfo
309
+ now = dt.datetime.now(tz=local_tz)
310
+ game_time = float(profile.last_login_time) + 3_600.0
311
+
312
+ def _export_with_mtime(mtime: dt.datetime) -> dict[str, t.Any]:
313
+ holder = type(
314
+ "Holder",
315
+ (),
316
+ {"tribes": [], "profiles": [profile], "file_mtime": mtime, "game_time": game_time},
317
+ )()
318
+ recs = export_tribes(holder)
319
+ return next(r for r in recs if r["tribeid"] == profile.player_id)
320
+
321
+ a = _export_with_mtime(now)
322
+ b = _export_with_mtime(now - dt.timedelta(weeks=3))
323
+ assert a["last_member_login_game_seconds"] == b["last_member_login_game_seconds"] == 3_600
324
+ assert a["last_member_login_player_id"] == b["last_member_login_player_id"] == profile.player_id
325
+ assert a["last_member_login"] != b["last_member_login"]
326
+ assert a["active"] != b["active"]
327
+ assert isinstance(a["last_member_login_game_seconds"], int)
328
+ assert not isinstance(a["last_member_login_game_seconds"], bool)
329
+
330
+
331
+ def test_export_tribes_solo_login_fields_match_active() -> None:
332
+ """Solo/profile-only tribe: active == last_member_login (D1-b, login
333
+ wins), player_id names the member, game_seconds matches the offset."""
334
+ profile = _profile_with_last_login()
335
+ local_tz = dt.datetime.now().astimezone().tzinfo
336
+ now = dt.datetime.now(tz=local_tz)
337
+ game_time = float(profile.last_login_time) + 3_600.0
338
+ holder = type(
339
+ "Holder",
340
+ (),
341
+ {"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
342
+ )()
343
+ rec = next(r for r in export_tribes(holder) if r["tribeid"] == profile.player_id)
344
+ assert rec["active"] == rec["last_member_login"]
345
+ assert rec["last_member_login_player_id"] == profile.player_id
346
+ assert rec["last_member_login_game_seconds"] == 3_600
347
+
348
+
349
+ def test_export_tribes_all_stub_tribe_login_fields_null() -> None:
350
+ """A tribe whose allocated players are all profile-less stubs yields null
351
+ login fields (never inherits a file date), and the keys still emit."""
352
+ tribe_path = _first_dump_file("*.arktribe")
353
+ tribe = Tribe.load(tribe_path)
354
+ if not tribe.get_members():
355
+ pytest.skip("fixture tribe has no members to stub")
356
+ local_tz = dt.datetime.now().astimezone().tzinfo
357
+ now = dt.datetime.now(tz=local_tz)
358
+ holder = type(
359
+ "Holder",
360
+ (),
361
+ {"tribes": [tribe], "profiles": [], "file_mtime": now, "game_time": 5_000.0},
362
+ )()
363
+ rec = next(r for r in export_tribes(holder) if r["tribeid"] == tribe.tribe_id)
364
+ assert "last_member_login" in rec and rec["last_member_login"] is None
365
+ assert "last_member_login_player_id" in rec
366
+ assert "last_member_login_game_seconds" in rec
367
+ assert rec["last_member_login_game_seconds"] is None
@@ -1,211 +0,0 @@
1
- """Regression tests for export helpers against real parser objects."""
2
-
3
- import datetime as dt
4
- import os
5
- import shutil
6
- import typing as t
7
- from pathlib import Path
8
-
9
- import pytest
10
-
11
- from arkparser import Profile, Tribe, WorldSave
12
- from arkparser.export import (
13
- export_all,
14
- export_players,
15
- export_structures,
16
- export_tamed,
17
- export_to_files,
18
- export_tribe_logs,
19
- export_tribes,
20
- export_wild,
21
- )
22
-
23
- _EXAMPLES = Path(__file__).parent.parent / "references" / "examples"
24
- _ASE_SCORCHED_EARTH = _EXAMPLES / "ase" / "maps" / "scorchedearth" / "ScorchedEarth_P.ark"
25
- _MAP_DUMP_EXTINCTION = Path(__file__).parent.parent / "references" / "map_dumps" / "evolved" / "extinction"
26
-
27
-
28
- def _first_dump_file(pattern: str) -> Path:
29
- """First map-dump fixture matching ``pattern``, else skip the test."""
30
- if _MAP_DUMP_EXTINCTION.is_dir():
31
- files = sorted(_MAP_DUMP_EXTINCTION.glob(pattern))
32
- if files:
33
- return files[0]
34
- pytest.skip(f"no {pattern} fixture under {_MAP_DUMP_EXTINCTION}")
35
-
36
-
37
- def _profile_with_last_login() -> Profile:
38
- """First map-dump profile carrying LastLoginTime, else skip the test."""
39
- if _MAP_DUMP_EXTINCTION.is_dir():
40
- for path in sorted(_MAP_DUMP_EXTINCTION.glob("*.arkprofile"))[:50]:
41
- try:
42
- profile = Profile.load(path)
43
- except Exception: # noqa: BLE001 - corrupt fixture must not abort
44
- continue
45
- if profile.last_login_time and profile.player_id:
46
- return profile
47
- pytest.skip(f"no profile with LastLoginTime under {_MAP_DUMP_EXTINCTION}")
48
-
49
- _ASV_NAMES = {
50
- "ASV_Tamed",
51
- "ASV_Wild",
52
- "ASV_Players",
53
- "ASV_Tribes",
54
- "ASV_Structures",
55
- "ASV_TribeLogs",
56
- "ASV_MapStructures",
57
- }
58
-
59
-
60
- @pytest.fixture(scope="session")
61
- def ase_export_world_save() -> WorldSave:
62
- if not _ASE_SCORCHED_EARTH.exists():
63
- pytest.skip(f"Fixture not available: {_ASE_SCORCHED_EARTH}")
64
- return WorldSave.load(_ASE_SCORCHED_EARTH)
65
-
66
-
67
- def test_export_tamed_matches_world_save(ase_export_world_save: WorldSave) -> None:
68
- exported = export_tamed(ase_export_world_save)
69
- assert len(exported) == len(ase_export_world_save.get_tamed_creatures())
70
- assert any(creature.get("name") == "escobar" for creature in exported)
71
-
72
-
73
- def test_export_wild_matches_world_save(ase_export_world_save: WorldSave) -> None:
74
- exported = export_wild(ase_export_world_save)
75
- assert len(exported) == len(ase_export_world_save.get_wild_creatures())
76
- assert all(creature.get("tamer", "") == "" for creature in exported[:100])
77
-
78
-
79
- def test_export_structures_matches_world_save(ase_export_world_save: WorldSave) -> None:
80
- exported = export_structures(ase_export_world_save)
81
- assert len(exported) == len(ase_export_world_save.get_structures())
82
-
83
-
84
- def test_export_players_uses_profile_parser(ase_profile_path: Path) -> None:
85
- profile = Profile.load(ase_profile_path)
86
- exported = export_players(type("Holder", (), {"profiles": [profile]})())
87
- assert exported[0]["playerid"] == profile.player_id
88
- assert exported[0]["steam"] == profile.player_name
89
- assert exported[0]["name"] == profile.character_name
90
- assert exported[0]["sex"] == ("Female" if profile.is_female else "Male")
91
- assert exported[0]["tribeid"] == profile.tribe_id
92
-
93
-
94
- def test_export_tribes_uses_tribe_parser(ase_tribe_path: Path) -> None:
95
- tribe = Tribe.load(ase_tribe_path)
96
- exported = export_tribes(type("Holder", (), {"tribes": [tribe]})())
97
- assert exported[0]["tribeid"] == tribe.tribe_id
98
- assert exported[0]["tribe"] == tribe.name
99
- assert exported[0]["players"] == tribe.member_count
100
-
101
-
102
- def test_export_tribes_active_is_file_mtime(tmp_path: Path) -> None:
103
- """Tribe ``active`` mirrors legacy ContentTribe.LastActive: the .arktribe
104
- file's write time, never a value derived from in-game log day numbers
105
- (those use the game calendar, not real seconds, and produced far-future
106
- dates)."""
107
- tribe_path = _first_dump_file("*.arktribe")
108
- copy = tmp_path / tribe_path.name
109
- shutil.copy2(tribe_path, copy)
110
- local_tz = dt.datetime.now().astimezone().tzinfo
111
- stamp = dt.datetime(2024, 5, 1, 12, 0, 0, tzinfo=local_tz)
112
- os.utime(copy, (stamp.timestamp(), stamp.timestamp()))
113
- tribe = Tribe.load(copy)
114
- now = dt.datetime.now(tz=local_tz)
115
- holder = type("Holder", (), {"tribes": [tribe], "file_mtime": now, "game_time": 5_000.0})()
116
- exported = export_tribes(holder)
117
- rec = next(r for r in exported if r["tribeid"] == tribe.tribe_id)
118
- assert rec["active"] is not None
119
- active = dt.datetime.fromisoformat(rec["active"])
120
- assert active.timestamp() == pytest.approx(stamp.timestamp(), abs=2)
121
- assert active <= now
122
-
123
-
124
- def test_export_tribes_solo_active_from_profile_last_login() -> None:
125
- """A profile-only (solo) tribe gets ``active`` from the member's
126
- LastLoginTime converted through the save anchor, matching legacy
127
- ContentTribe.LastActive over allocated players."""
128
- profile = _profile_with_last_login()
129
- local_tz = dt.datetime.now().astimezone().tzinfo
130
- now = dt.datetime.now(tz=local_tz)
131
- game_time = float(profile.last_login_time) + 3_600.0
132
- holder = type(
133
- "Holder",
134
- (),
135
- {"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
136
- )()
137
- exported = export_tribes(holder)
138
- rec = next(r for r in exported if r["tribeid"] == profile.player_id)
139
- assert rec["active"] is not None
140
- active = dt.datetime.fromisoformat(rec["active"])
141
- expected = now - dt.timedelta(seconds=3_600)
142
- assert active.timestamp() == pytest.approx(expected.timestamp(), abs=2)
143
-
144
-
145
- def test_export_tribes_active_never_future() -> None:
146
- """Future candidates are discarded (legacy filters d <= DateTime.Now)."""
147
- profile = _profile_with_last_login()
148
- local_tz = dt.datetime.now().astimezone().tzinfo
149
- now = dt.datetime.now(tz=local_tz)
150
- game_time = max(float(profile.last_login_time) - 100_000_000.0, 1.0)
151
- holder = type(
152
- "Holder",
153
- (),
154
- {"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
155
- )()
156
- exported = export_tribes(holder)
157
- rec = next(r for r in exported if r["tribeid"] == profile.player_id)
158
- assert rec["active"] is None
159
-
160
-
161
- def test_allocate_profiles_ignores_stale_profile_tribe_id() -> None:
162
- """A profile whose TribeID names a tribe that no longer lists the player
163
- in its member roster must NOT be allocated to that tribe (the game leaves
164
- TribeID stale in profiles of members removed while offline). It falls
165
- through to a solo tribe keyed on the player id."""
166
- from arkparser.export import _allocate_profiles
167
-
168
- current = t.cast(Profile, type("P", (), {"player_id": 1, "raw_tribe_id": 555})())
169
- stale = t.cast(Profile, type("P", (), {"player_id": 987, "raw_tribe_id": 555})())
170
- member_index = {1: 555, 2: 555}
171
- tribe_members = {555: {1, 2}}
172
- profile_tribeid, players_by_tribe = _allocate_profiles(
173
- [current, stale], {555}, member_index, tribe_members
174
- )
175
- assert profile_tribeid[1] == 555
176
- assert profile_tribeid[987] == 987
177
- assert players_by_tribe[555] == [1]
178
- assert players_by_tribe[987] == [987]
179
-
180
-
181
- def test_allocate_profiles_member_list_still_wins_without_explicit_id() -> None:
182
- """A profile with no usable TribeID but present in a tribe's roster is
183
- still allocated to that tribe via the member-list fallback."""
184
- from arkparser.export import _allocate_profiles
185
-
186
- prof = t.cast(Profile, type("P", (), {"player_id": 2, "raw_tribe_id": 0})())
187
- profile_tribeid, players_by_tribe = _allocate_profiles(
188
- [prof], {555}, {2: 555}, {555: {2}}
189
- )
190
- assert profile_tribeid[2] == 555
191
- assert players_by_tribe[555] == [2]
192
-
193
-
194
- def test_export_tribe_logs_uses_tribe_parser(ase_tribe_path: Path) -> None:
195
- tribe = Tribe.load(ase_tribe_path)
196
- exported = export_tribe_logs(type("Holder", (), {"tribes": [tribe]})())
197
- assert exported[0]["tribeid"] == tribe.tribe_id
198
- assert exported[0]["tribe"] == tribe.name
199
- assert exported[0]["logs"] == tribe.log_entries
200
-
201
-
202
- def test_export_all_uses_asv_names(ase_export_world_save: WorldSave) -> None:
203
- exported = export_all(ase_export_world_save)
204
- assert set(exported) == _ASV_NAMES
205
-
206
-
207
- def test_export_to_files_uses_asv_names(
208
- ase_export_world_save: WorldSave, tmp_path: Path
209
- ) -> None:
210
- created = export_to_files(ase_export_world_save, tmp_path)
211
- assert {path.name for path in created} == {f"{name}.json" for name in _ASV_NAMES}
File without changes
File without changes
File without changes
File without changes