codetour-cli 0.1.0__py3-none-any.whl
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.
- codetour_cli/__init__.py +9 -0
- codetour_cli/cli.py +2087 -0
- codetour_cli/config.py +144 -0
- codetour_cli/description_validator.py +197 -0
- codetour_cli/detection.py +91 -0
- codetour_cli/discovery.py +226 -0
- codetour_cli/lint.py +334 -0
- codetour_cli/logging/__init__.py +6 -0
- codetour_cli/logging/setup.py +63 -0
- codetour_cli/migration/__init__.py +6 -0
- codetour_cli/migration/direct.py +385 -0
- codetour_cli/migration/git_diff.py +418 -0
- codetour_cli/migration/stepwise.py +400 -0
- codetour_cli/review_parser.py +348 -0
- codetour_cli/schema_models.py +203 -0
- codetour_cli/tour/__init__.py +6 -0
- codetour_cli/tour/schema.py +255 -0
- codetour_cli/tour/updater.py +428 -0
- codetour_cli-0.1.0.dist-info/METADATA +47 -0
- codetour_cli-0.1.0.dist-info/RECORD +24 -0
- codetour_cli-0.1.0.dist-info/WHEEL +5 -0
- codetour_cli-0.1.0.dist-info/entry_points.txt +2 -0
- codetour_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- codetour_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Review report parser using schema-driven parsing.
|
|
3
|
+
|
|
4
|
+
Parses migration review reports to extract user corrections and decisions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import structlog
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List, Optional, Any
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from codetour_cli.schema_models import ParsingSchema, FieldSchema, FieldType, ValidationRule
|
|
14
|
+
|
|
15
|
+
log = structlog.get_logger()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class StepReview:
|
|
20
|
+
"""Parsed review data for a single step."""
|
|
21
|
+
step_number: int
|
|
22
|
+
status_checked: bool
|
|
23
|
+
tentative_file: str
|
|
24
|
+
tentative_line: Optional[int] # None if "-" (deletion marker)
|
|
25
|
+
confidence: Optional[int]
|
|
26
|
+
description_edited: bool = False
|
|
27
|
+
edited_description: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
def is_marked_for_deletion(self) -> bool:
|
|
30
|
+
"""Check if step is marked for deletion (file and line both = '-')."""
|
|
31
|
+
return self.tentative_line is None and self.tentative_file == "-"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ReviewReport:
|
|
36
|
+
"""Parsed review report with all step corrections."""
|
|
37
|
+
tour_name: str
|
|
38
|
+
source_commit: str
|
|
39
|
+
target_commit: str
|
|
40
|
+
mode: str
|
|
41
|
+
step_reviews: List[StepReview]
|
|
42
|
+
|
|
43
|
+
def get_review(self, step_number: int) -> Optional[StepReview]:
|
|
44
|
+
"""Get review for specific step number."""
|
|
45
|
+
for review in self.step_reviews:
|
|
46
|
+
if review.step_number == step_number:
|
|
47
|
+
return review
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def get_corrected_steps(self) -> List[StepReview]:
|
|
51
|
+
"""Get only steps that have been reviewed (checkbox checked)."""
|
|
52
|
+
return [r for r in self.step_reviews if r.status_checked]
|
|
53
|
+
|
|
54
|
+
def get_deletion_steps(self) -> List[StepReview]:
|
|
55
|
+
"""Get steps marked for deletion."""
|
|
56
|
+
return [r for r in self.step_reviews if r.is_marked_for_deletion()]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_frontmatter(content: str) -> Dict[str, Any]:
|
|
60
|
+
"""
|
|
61
|
+
Extract YAML frontmatter from markdown content.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Dictionary of frontmatter values (empty if no frontmatter)
|
|
65
|
+
"""
|
|
66
|
+
import yaml
|
|
67
|
+
|
|
68
|
+
# Look for YAML frontmatter delimited by ---
|
|
69
|
+
match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
|
|
70
|
+
if not match:
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
frontmatter = yaml.safe_load(match.group(1))
|
|
75
|
+
return frontmatter or {}
|
|
76
|
+
except yaml.YAMLError as e:
|
|
77
|
+
log.warning("failed_to_parse_frontmatter", error=str(e))
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_parsing_schema(report_content: str) -> ParsingSchema:
|
|
82
|
+
"""
|
|
83
|
+
Load parsing schema using smart defaults.
|
|
84
|
+
|
|
85
|
+
Implements the refined Iteration 2 approach:
|
|
86
|
+
1. Extract frontmatter to get schema version/language
|
|
87
|
+
2. Load default schema for that version
|
|
88
|
+
3. Merge with custom end-schema if present
|
|
89
|
+
"""
|
|
90
|
+
# 1. Extract frontmatter
|
|
91
|
+
frontmatter = extract_frontmatter(report_content)
|
|
92
|
+
schema_version = frontmatter.get('schema_version', '1.0')
|
|
93
|
+
language = frontmatter.get('language', 'en')
|
|
94
|
+
|
|
95
|
+
# 2. Load default schema for version
|
|
96
|
+
schemas_dir = Path(__file__).parent / 'schemas'
|
|
97
|
+
|
|
98
|
+
# Try language-specific schema first
|
|
99
|
+
if language != 'en':
|
|
100
|
+
lang_schema_path = schemas_dir / f"review_report_v{schema_version}_{language}.yaml"
|
|
101
|
+
if lang_schema_path.exists():
|
|
102
|
+
return ParsingSchema.from_yaml(lang_schema_path)
|
|
103
|
+
|
|
104
|
+
# Fall back to default English schema
|
|
105
|
+
default_schema_path = schemas_dir / f"review_report_v{schema_version}.yaml"
|
|
106
|
+
|
|
107
|
+
if not default_schema_path.exists():
|
|
108
|
+
log.warning(
|
|
109
|
+
"schema_version_not_found",
|
|
110
|
+
requested=schema_version,
|
|
111
|
+
fallback="1.0"
|
|
112
|
+
)
|
|
113
|
+
default_schema_path = schemas_dir / "review_report_v1.0.yaml"
|
|
114
|
+
|
|
115
|
+
schema = ParsingSchema.from_yaml(default_schema_path)
|
|
116
|
+
|
|
117
|
+
# 3. Check for custom schema override (future: internationalization)
|
|
118
|
+
# For now, we only use built-in schemas
|
|
119
|
+
|
|
120
|
+
return schema
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def parse_checkbox(value: str) -> bool:
|
|
124
|
+
"""Parse checkbox value: [x] = True, [ ] = False."""
|
|
125
|
+
return value.strip().lower() == 'x'
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_integer_or_dash(value: str) -> Optional[int]:
|
|
129
|
+
"""Parse integer or dash. Returns None if dash."""
|
|
130
|
+
value = value.strip()
|
|
131
|
+
if value == '-':
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
return int(value)
|
|
135
|
+
except ValueError:
|
|
136
|
+
raise ValueError(f"Expected integer or '-', got: {value}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def validate_field_value(value: Any, field: FieldSchema) -> Any:
|
|
140
|
+
"""
|
|
141
|
+
Validate and convert field value according to schema rules.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
value: Raw extracted value
|
|
145
|
+
field: Field schema with validation rules
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Validated and converted value
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ValueError: If validation fails
|
|
152
|
+
"""
|
|
153
|
+
# Apply type conversions
|
|
154
|
+
if field.type == FieldType.CHECKBOX:
|
|
155
|
+
value = parse_checkbox(value)
|
|
156
|
+
elif field.type == FieldType.INTEGER:
|
|
157
|
+
value = int(value)
|
|
158
|
+
elif field.type == FieldType.STRING_OR_DASH:
|
|
159
|
+
# Keep as string initially, will validate below
|
|
160
|
+
pass
|
|
161
|
+
elif field.type == FieldType.MULTILINE:
|
|
162
|
+
# Extract quoted lines
|
|
163
|
+
lines = value.strip().split('\n')
|
|
164
|
+
cleaned_lines = []
|
|
165
|
+
for line in lines:
|
|
166
|
+
line = line.strip()
|
|
167
|
+
if line.startswith('>'):
|
|
168
|
+
cleaned_lines.append(line[1:].strip())
|
|
169
|
+
value = '\n'.join(cleaned_lines)
|
|
170
|
+
|
|
171
|
+
# Apply validation rules
|
|
172
|
+
for rule in field.validation:
|
|
173
|
+
if rule == ValidationRule.INTEGER_OR_DASH:
|
|
174
|
+
value = parse_integer_or_dash(value)
|
|
175
|
+
elif rule == ValidationRule.POSITIVE_IF_INTEGER:
|
|
176
|
+
if isinstance(value, int) and value <= 0:
|
|
177
|
+
raise ValueError(f"Value must be positive, got: {value}")
|
|
178
|
+
elif rule == ValidationRule.NOT_EMPTY:
|
|
179
|
+
if not value or (isinstance(value, str) and not value.strip()):
|
|
180
|
+
raise ValueError("Value cannot be empty")
|
|
181
|
+
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_step_section(step_content: str, step_number: int, schema: ParsingSchema) -> StepReview:
|
|
186
|
+
"""
|
|
187
|
+
Parse a single step section from the review report.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
step_content: Markdown content of the step section
|
|
191
|
+
step_number: Step number (1-indexed)
|
|
192
|
+
schema: Parsing schema to use
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
StepReview with extracted values
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ValueError: If required fields are missing or invalid
|
|
199
|
+
"""
|
|
200
|
+
extracted = {}
|
|
201
|
+
|
|
202
|
+
# Extract each field according to schema
|
|
203
|
+
for field in schema.fields:
|
|
204
|
+
# Skip optional fields based on version
|
|
205
|
+
if field.since_version:
|
|
206
|
+
# For now, we parse all fields; version filtering can be added later
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
# Try to extract field value
|
|
210
|
+
pattern = re.compile(field.pattern, re.MULTILINE | re.DOTALL)
|
|
211
|
+
match = pattern.search(step_content)
|
|
212
|
+
|
|
213
|
+
if match:
|
|
214
|
+
raw_value = match.group(1)
|
|
215
|
+
|
|
216
|
+
# Check conditional fields
|
|
217
|
+
if field.conditional:
|
|
218
|
+
# Only parse if the conditional field is checked
|
|
219
|
+
conditional_field = schema.get_field(field.conditional)
|
|
220
|
+
if conditional_field and conditional_field.logical_name in extracted:
|
|
221
|
+
if not extracted[conditional_field.logical_name]:
|
|
222
|
+
# Conditional not met, skip this field
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
# Validate and convert value
|
|
226
|
+
try:
|
|
227
|
+
validated_value = validate_field_value(raw_value, field)
|
|
228
|
+
extracted[field.logical_name] = validated_value
|
|
229
|
+
except ValueError as e:
|
|
230
|
+
log.warning(
|
|
231
|
+
"field_validation_failed",
|
|
232
|
+
step=step_number,
|
|
233
|
+
field=field.logical_name,
|
|
234
|
+
error=str(e)
|
|
235
|
+
)
|
|
236
|
+
if field.required:
|
|
237
|
+
raise
|
|
238
|
+
else:
|
|
239
|
+
# Field not found
|
|
240
|
+
if field.required:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"Required field '{field.logical_name}' not found in step {step_number}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Build StepReview from extracted values
|
|
246
|
+
return StepReview(
|
|
247
|
+
step_number=step_number,
|
|
248
|
+
status_checked=extracted.get('status', False),
|
|
249
|
+
tentative_file=extracted.get('tentative_file', ''),
|
|
250
|
+
tentative_line=extracted.get('tentative_line'),
|
|
251
|
+
confidence=extracted.get('confidence'),
|
|
252
|
+
description_edited=extracted.get('description_edited', False),
|
|
253
|
+
edited_description=extracted.get('edited_description')
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def parse_review_report(report_path: Path) -> ReviewReport:
|
|
258
|
+
"""
|
|
259
|
+
Parse a review report markdown file.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
report_path: Path to the review report markdown file
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
ReviewReport with all parsed step reviews
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
FileNotFoundError: If report file doesn't exist
|
|
269
|
+
ValueError: If report format is invalid
|
|
270
|
+
"""
|
|
271
|
+
if not report_path.exists():
|
|
272
|
+
raise FileNotFoundError(f"Review report not found: {report_path}")
|
|
273
|
+
|
|
274
|
+
content = report_path.read_text()
|
|
275
|
+
|
|
276
|
+
# Load schema
|
|
277
|
+
schema = load_parsing_schema(content)
|
|
278
|
+
|
|
279
|
+
log.info(
|
|
280
|
+
"parsing_review_report",
|
|
281
|
+
path=str(report_path),
|
|
282
|
+
schema_version=schema.version
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Extract metadata from header (simple regex for now)
|
|
286
|
+
tour_name_match = re.search(r'\*\*Tour\*\*:\s*(.+)', content)
|
|
287
|
+
source_match = re.search(r'\*\*Source commit\*\*:\s*(\w+)', content)
|
|
288
|
+
target_match = re.search(r'\*\*Target commit\*\*:\s*(?:HEAD,\s*)?(\w+)', content)
|
|
289
|
+
mode_match = re.search(r'\*\*Mode\*\*:\s*(.+)', content)
|
|
290
|
+
|
|
291
|
+
tour_name = tour_name_match.group(1) if tour_name_match else "unknown"
|
|
292
|
+
source_commit = source_match.group(1) if source_match else "unknown"
|
|
293
|
+
target_commit = target_match.group(1) if target_match else "unknown"
|
|
294
|
+
mode = mode_match.group(1) if mode_match else "unknown"
|
|
295
|
+
|
|
296
|
+
# Split into step sections
|
|
297
|
+
step_pattern = re.compile(schema.step_marker_pattern)
|
|
298
|
+
step_sections = []
|
|
299
|
+
|
|
300
|
+
# Find all step headers
|
|
301
|
+
for match in step_pattern.finditer(content):
|
|
302
|
+
step_num = int(match.group(1))
|
|
303
|
+
start = match.start()
|
|
304
|
+
step_sections.append((step_num, start))
|
|
305
|
+
|
|
306
|
+
# Extract content for each step
|
|
307
|
+
step_reviews = []
|
|
308
|
+
for i, (step_num, start) in enumerate(step_sections):
|
|
309
|
+
# Get content until next step or end
|
|
310
|
+
if i < len(step_sections) - 1:
|
|
311
|
+
end = step_sections[i + 1][1]
|
|
312
|
+
else:
|
|
313
|
+
# Last step - go until appendix or end
|
|
314
|
+
appendix_match = re.search(r'^## Appendix:', content[start:], re.MULTILINE)
|
|
315
|
+
if appendix_match:
|
|
316
|
+
end = start + appendix_match.start()
|
|
317
|
+
else:
|
|
318
|
+
end = len(content)
|
|
319
|
+
|
|
320
|
+
step_content = content[start:end]
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
review = parse_step_section(step_content, step_num, schema)
|
|
324
|
+
step_reviews.append(review)
|
|
325
|
+
log.debug(
|
|
326
|
+
"parsed_step",
|
|
327
|
+
step=step_num,
|
|
328
|
+
checked=review.status_checked,
|
|
329
|
+
file=review.tentative_file,
|
|
330
|
+
line=review.tentative_line
|
|
331
|
+
)
|
|
332
|
+
except ValueError as e:
|
|
333
|
+
log.error("failed_to_parse_step", step=step_num, error=str(e))
|
|
334
|
+
# Continue parsing other steps
|
|
335
|
+
|
|
336
|
+
log.info(
|
|
337
|
+
"parsed_review_report",
|
|
338
|
+
total_steps=len(step_reviews),
|
|
339
|
+
corrected=len([r for r in step_reviews if r.status_checked])
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
return ReviewReport(
|
|
343
|
+
tour_name=tour_name,
|
|
344
|
+
source_commit=source_commit,
|
|
345
|
+
target_commit=target_commit,
|
|
346
|
+
mode=mode,
|
|
347
|
+
step_reviews=step_reviews
|
|
348
|
+
)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic models for parsing schema structure.
|
|
3
|
+
|
|
4
|
+
Provides type-safe, validated schema definitions for review report parsing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional, Dict, Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FieldType(str, Enum):
|
|
16
|
+
"""Supported field types for parseable fields."""
|
|
17
|
+
CHECKBOX = "checkbox"
|
|
18
|
+
STRING = "string"
|
|
19
|
+
INTEGER = "integer"
|
|
20
|
+
STRING_OR_DASH = "string_or_dash"
|
|
21
|
+
MULTILINE = "multiline"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidationRule(str, Enum):
|
|
25
|
+
"""Supported validation rules for field values."""
|
|
26
|
+
INTEGER_OR_DASH = "integer_or_dash"
|
|
27
|
+
POSITIVE_IF_INTEGER = "positive_if_integer"
|
|
28
|
+
NOT_EMPTY = "not_empty"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FieldSchema(BaseModel):
|
|
32
|
+
"""Schema definition for a single parseable field in review reports."""
|
|
33
|
+
|
|
34
|
+
logical_name: str = Field(
|
|
35
|
+
description="Internal field name used for parsing (constant across languages)"
|
|
36
|
+
)
|
|
37
|
+
display_label: str = Field(
|
|
38
|
+
description="Human-readable label shown in report (can be translated)"
|
|
39
|
+
)
|
|
40
|
+
pattern: str = Field(
|
|
41
|
+
description="Regex pattern to extract field value from markdown"
|
|
42
|
+
)
|
|
43
|
+
type: FieldType = Field(
|
|
44
|
+
description="Data type of the field value"
|
|
45
|
+
)
|
|
46
|
+
required: bool = Field(
|
|
47
|
+
default=True,
|
|
48
|
+
description="Whether this field must be present in all reports"
|
|
49
|
+
)
|
|
50
|
+
since_version: Optional[str] = Field(
|
|
51
|
+
default=None,
|
|
52
|
+
description="Schema version that introduced this field (for backwards compatibility)"
|
|
53
|
+
)
|
|
54
|
+
conditional: Optional[str] = Field(
|
|
55
|
+
default=None,
|
|
56
|
+
description="Logical name of field that must be checked for this field to be parsed"
|
|
57
|
+
)
|
|
58
|
+
validation: List[ValidationRule] = Field(
|
|
59
|
+
default_factory=list,
|
|
60
|
+
description="Validation rules to apply to extracted values"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
64
|
+
|
|
65
|
+
@field_validator('pattern')
|
|
66
|
+
@classmethod
|
|
67
|
+
def validate_pattern(cls, v: str) -> str:
|
|
68
|
+
"""Ensure regex pattern is valid and compiles."""
|
|
69
|
+
try:
|
|
70
|
+
re.compile(v)
|
|
71
|
+
except re.error as e:
|
|
72
|
+
raise ValueError(f"Invalid regex pattern: {e}")
|
|
73
|
+
return v
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ParsingSchema(BaseModel):
|
|
77
|
+
"""Complete parsing schema for review reports."""
|
|
78
|
+
|
|
79
|
+
version: str = Field(
|
|
80
|
+
pattern=r'^\d+\.\d+$',
|
|
81
|
+
description="Schema version in X.Y format"
|
|
82
|
+
)
|
|
83
|
+
language: str = Field(
|
|
84
|
+
default="en",
|
|
85
|
+
description="Language code (en, fr, es, etc.)"
|
|
86
|
+
)
|
|
87
|
+
step_marker_pattern: str = Field(
|
|
88
|
+
default=r'### Step (\d+):',
|
|
89
|
+
description="Regex pattern to identify step sections"
|
|
90
|
+
)
|
|
91
|
+
fields: List[FieldSchema] = Field(
|
|
92
|
+
description="List of parseable field definitions"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
96
|
+
|
|
97
|
+
@field_validator('version')
|
|
98
|
+
@classmethod
|
|
99
|
+
def validate_version(cls, v: str) -> str:
|
|
100
|
+
"""Ensure version follows semantic versioning format."""
|
|
101
|
+
if not re.match(r'^\d+\.\d+$', v):
|
|
102
|
+
raise ValueError('Version must be in format X.Y (e.g., 1.0, 1.1)')
|
|
103
|
+
return v
|
|
104
|
+
|
|
105
|
+
def get_field(self, logical_name: str) -> Optional[FieldSchema]:
|
|
106
|
+
"""
|
|
107
|
+
Get field schema by logical name.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
logical_name: Internal field name to look up
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
FieldSchema if found, None otherwise
|
|
114
|
+
"""
|
|
115
|
+
for field in self.fields:
|
|
116
|
+
if field.logical_name == logical_name:
|
|
117
|
+
return field
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def get_required_fields(self) -> List[FieldSchema]:
|
|
121
|
+
"""Get list of required fields."""
|
|
122
|
+
return [f for f in self.fields if f.required]
|
|
123
|
+
|
|
124
|
+
def get_optional_fields(self) -> List[FieldSchema]:
|
|
125
|
+
"""Get list of optional fields."""
|
|
126
|
+
return [f for f in self.fields if not f.required]
|
|
127
|
+
|
|
128
|
+
def merge(self, override: 'ParsingSchema') -> 'ParsingSchema':
|
|
129
|
+
"""
|
|
130
|
+
Merge with override schema (override takes precedence).
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
override: Schema to merge in (takes precedence over self)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
New ParsingSchema with merged field definitions
|
|
137
|
+
"""
|
|
138
|
+
# Start with base schema fields
|
|
139
|
+
merged_fields: Dict[str, FieldSchema] = {
|
|
140
|
+
f.logical_name: f for f in self.fields
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# Override with custom schema fields
|
|
144
|
+
for field in override.fields:
|
|
145
|
+
merged_fields[field.logical_name] = field
|
|
146
|
+
|
|
147
|
+
return ParsingSchema(
|
|
148
|
+
version=override.version,
|
|
149
|
+
language=override.language or self.language,
|
|
150
|
+
step_marker_pattern=override.step_marker_pattern or self.step_marker_pattern,
|
|
151
|
+
fields=list(merged_fields.values())
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_yaml(cls, path: Path) -> 'ParsingSchema':
|
|
156
|
+
"""
|
|
157
|
+
Load schema from YAML file.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
path: Path to YAML file containing schema definition
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
ParsingSchema instance
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
FileNotFoundError: If schema file doesn't exist
|
|
167
|
+
ValidationError: If schema YAML is invalid
|
|
168
|
+
"""
|
|
169
|
+
import yaml
|
|
170
|
+
|
|
171
|
+
if not path.exists():
|
|
172
|
+
raise FileNotFoundError(f"Schema file not found: {path}")
|
|
173
|
+
|
|
174
|
+
data = yaml.safe_load(path.read_text())
|
|
175
|
+
return cls(**data)
|
|
176
|
+
|
|
177
|
+
def to_yaml(self, path: Path) -> None:
|
|
178
|
+
"""
|
|
179
|
+
Save schema to YAML file.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
path: Path where to save the schema
|
|
183
|
+
"""
|
|
184
|
+
import yaml
|
|
185
|
+
|
|
186
|
+
# Convert to dict for YAML serialization
|
|
187
|
+
data = self.dict(exclude_none=True)
|
|
188
|
+
|
|
189
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
path.write_text(yaml.dump(data, sort_keys=False, allow_unicode=True))
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'ParsingSchema':
|
|
194
|
+
"""
|
|
195
|
+
Create schema from dictionary (useful for parsing embedded schemas).
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
data: Dictionary containing schema definition
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
ParsingSchema instance
|
|
202
|
+
"""
|
|
203
|
+
return cls(**data)
|