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.
@@ -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)
@@ -0,0 +1,6 @@
1
+ """
2
+ CodeTour schema and manipulation module.
3
+
4
+ Handles parsing, validation, and updating of .tour files according to the
5
+ CodeTour specification.
6
+ """