Skip to content

Commit 7d48f79

Browse files
authored
Fix cross-platform path validation and add discriminated unions (#5)
This commit fixes validation failures in GitHub Actions on Linux when JSON files contain Windows-style path separators (backslashes). Changes: - Add discriminator fields to all record entry models (entry_type) - Use Pydantic's discriminated unions for better error messages - Preprocess JSON data on load to normalize backslashes to forward slashes - Add entry_type fields automatically based on RecordType.type - Normalize paths to Unix-style (forward slashes) when saving JSON - Handle None values properly in preprocessing Fixes validation errors where FilePath validator failed on Linux CI when paths used Windows-style separators. Now works across platforms.
1 parent 8bc034c commit 7d48f79

1 file changed

Lines changed: 87 additions & 9 deletions

File tree

src/miscore/record_data.py

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
import re
44
from datetime import datetime, timedelta, date
55
from enum import Enum
6-
from typing import List, Optional, Union
6+
from typing import List, Optional, Union, Literal, Annotated
77

8-
from pydantic import model_validator, BaseModel, FilePath
8+
from pydantic import model_validator, BaseModel, FilePath, Field, Discriminator
99

1010

1111
class CompletedRecordEntry(BaseModel, extra="forbid"):
1212
"""
1313
Model for tracking when you completed the game (without anything more)
1414
"""
1515

16+
entry_type: Literal["completed"] = Field(default="completed", frozen=True)
1617
date: Union[date, datetime]
1718
description: Optional[str] = None
1819
screenshot: Optional[FilePath] = None
@@ -23,6 +24,9 @@ class CompletedAtDifficultyRecordEntry(CompletedRecordEntry):
2324
Model for tracking when you completed the game at a specific difficulty
2425
"""
2526

27+
entry_type: Literal["completed_at_difficulty"] = Field(
28+
default="completed_at_difficulty", frozen=True
29+
)
2630
difficulty: str
2731

2832

@@ -31,6 +35,7 @@ class TimeRecordEntry(CompletedRecordEntry):
3135
Model for tracking when you completed the game in a record time
3236
"""
3337

38+
entry_type: Literal["time"] = Field(default="time", frozen=True)
3439
time: timedelta
3540

3641

@@ -39,6 +44,7 @@ class ScoreRecordEntry(CompletedRecordEntry):
3944
Model for tracking when you completed the game with a record score
4045
"""
4146

47+
entry_type: Literal["score"] = Field(default="score", frozen=True)
4248
score: float
4349

4450

@@ -62,11 +68,14 @@ class RecordType(BaseModel):
6268
type: RecordTypeOptions
6369
records: Optional[
6470
List[
65-
Union[
66-
CompletedRecordEntry,
67-
CompletedAtDifficultyRecordEntry,
68-
TimeRecordEntry,
69-
ScoreRecordEntry,
71+
Annotated[
72+
Union[
73+
CompletedAtDifficultyRecordEntry,
74+
TimeRecordEntry,
75+
ScoreRecordEntry,
76+
CompletedRecordEntry,
77+
],
78+
Discriminator("entry_type"),
7079
]
7180
]
7281
] = None
@@ -129,6 +138,49 @@ class RecordData(BaseModel):
129138

130139
games: List[Game]
131140

141+
@classmethod
142+
def _preprocess_json_data(cls, json_data):
143+
"""
144+
Preprocess JSON data to:
145+
1. Normalize path separators (backslash to forward slash) for cross-platform compatibility
146+
2. Add entry_type field to records based on RecordType.type for discriminated unions
147+
"""
148+
if "games" not in json_data:
149+
return json_data
150+
151+
for game in json_data["games"]:
152+
if "record_types" not in game or game["record_types"] is None:
153+
continue
154+
155+
for record_type in game["record_types"]:
156+
if "records" not in record_type or not record_type["records"]:
157+
continue
158+
159+
# Determine the entry_type based on record_type.type
160+
record_type_value = record_type.get("type")
161+
if record_type_value == "completed":
162+
entry_type = "completed"
163+
elif record_type_value == "completed_at_difficulty":
164+
entry_type = "completed_at_difficulty"
165+
elif record_type_value in ["fastest_time", "longest_time"]:
166+
entry_type = "time"
167+
elif record_type_value in ["high_score", "low_score"]:
168+
entry_type = "score"
169+
else:
170+
continue
171+
172+
# Process each record
173+
for record in record_type["records"]:
174+
# Add entry_type if not present
175+
if "entry_type" not in record:
176+
record["entry_type"] = entry_type
177+
178+
# Normalize screenshot path separators
179+
if "screenshot" in record and record["screenshot"]:
180+
record["screenshot"] = record["screenshot"].replace("\\", "/")
181+
182+
return json_data
183+
132184
@classmethod
133185
def load(cls, filename):
134186
"""
@@ -149,19 +201,45 @@ def load(cls, filename):
149201
with open(current_file) as fin:
150202
json_data = json.load(fin)
151203

204+
# Preprocess JSON data for cross-platform compatibility and discriminated unions
205+
json_data = cls._preprocess_json_data(json_data)
206+
152207
record_data = RecordData(**json_data)
153208

154209
# Restore original directory
155210
os.chdir(old_wd)
156211

157212
return record_data
158213

214+
@classmethod
215+
def _normalize_paths_for_save(cls, data):
216+
"""
217+
Normalize all path separators to forward slashes (Unix-style) for cross-platform compatibility.
218+
This ensures that even on Windows, paths are saved with forward slashes.
219+
"""
220+
if isinstance(data, dict):
221+
result = {}
222+
for key, value in data.items():
223+
if key == "screenshot" and isinstance(value, str):
224+
# Normalize screenshot paths to Unix-style
225+
result[key] = value.replace("\\", "/")
226+
else:
227+
result[key] = cls._normalize_paths_for_save(value)
228+
return result
229+
elif isinstance(data, list):
230+
return [cls._normalize_paths_for_save(item) for item in data]
231+
else:
232+
return data
233+
159234
def save(self, filename):
160235
"""
161-
Save records to a JSON file.
236+
Save records to a JSON file with Unix-style path separators.
162237
"""
238+
data = self.model_dump()
239+
# Normalize all paths to Unix-style (forward slashes)
240+
data = self._normalize_paths_for_save(data)
163241
with open(filename, "w") as fout:
164-
json.dump(self.model_dump(), fout, indent=2, default=str)
242+
json.dump(data, fout, indent=2, default=str)
165243

166244
@classmethod
167245
def add_game_to_file(cls, game_name, filename, interactive=True):

0 commit comments

Comments
 (0)