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,255 @@
1
+ """
2
+ CodeTour schema definitions using Pydantic.
3
+
4
+ Based on the CodeTour specification:
5
+ https://github.com/microsoft/codetour
6
+
7
+ A tour is a JSON file (.tour) containing:
8
+ - Metadata (title, description, ref commit)
9
+ - Steps (file, line, description, optional pattern)
10
+ """
11
+
12
+ from typing import Optional, List, Literal
13
+ from pydantic import BaseModel, Field, ConfigDict
14
+ from pathlib import Path
15
+ import json
16
+ import re
17
+
18
+
19
+ class TourStep(BaseModel):
20
+ """
21
+ A single step in a CodeTour.
22
+
23
+ Steps can reference code by:
24
+ - file + line (most common)
25
+ - file + pattern (regex, mutually exclusive with line)
26
+ - Just description (no code reference)
27
+ """
28
+
29
+ model_config = ConfigDict(extra='allow') # Allow extra fields for future compatibility
30
+
31
+ # Core fields
32
+ file: Optional[str] = None # Relative path from workspace root
33
+ line: Optional[int] = None # Line number (1-indexed)
34
+ description: str # Markdown description shown to user
35
+
36
+ # Optional fields
37
+ pattern: Optional[str] = None # Regex pattern (mutually exclusive with line)
38
+ title: Optional[str] = None # Optional step title
39
+ directory: Optional[str] = None # For directory-only steps
40
+ uri: Optional[str] = None # Full URI (for non-file resources)
41
+
42
+ # Selection range (for highlighting multiple lines)
43
+ selection: Optional[dict] = None # {start: {line, character}, end: {line, character}}
44
+
45
+ # Commands to execute
46
+ commands: Optional[List[str]] = None
47
+
48
+ def has_line_reference(self) -> bool:
49
+ """Does this step reference a specific line?"""
50
+ return self.file is not None and self.line is not None
51
+
52
+ def has_pattern_reference(self) -> bool:
53
+ """Does this step use a regex pattern?"""
54
+ return self.file is not None and self.pattern is not None
55
+
56
+ def has_code_reference(self) -> bool:
57
+ """Does this step reference any code location?"""
58
+ return self.has_line_reference() or self.has_pattern_reference()
59
+
60
+ def validate_line_content(self, content: str) -> bool:
61
+ """
62
+ Validate that line content matches the step's pattern.
63
+
64
+ Used for confidence validation - if a step has a pattern,
65
+ we can check that the migrated line still matches it.
66
+
67
+ Args:
68
+ content: The actual line content from the file
69
+
70
+ Returns:
71
+ True if no pattern, or pattern matches content
72
+ """
73
+ if not self.pattern:
74
+ return True # No pattern = always valid
75
+
76
+ try:
77
+ return bool(re.search(self.pattern, content))
78
+ except re.error:
79
+ # Invalid regex pattern - treat as no match
80
+ return False
81
+
82
+
83
+ class Tour(BaseModel):
84
+ """
85
+ A complete CodeTour.
86
+
87
+ Contains metadata and a sequence of steps guiding through code.
88
+ """
89
+
90
+ model_config = ConfigDict(extra='allow')
91
+
92
+ # Schema reference (optional, used by VS Code)
93
+ schema_: Optional[str] = Field(None, alias='$schema')
94
+
95
+ # Required fields
96
+ title: str # Tour title
97
+ steps: List[TourStep] # Ordered list of steps
98
+
99
+ # Optional metadata
100
+ description: Optional[str] = None # Tour description
101
+ ref: Optional[str] = None # Git commit SHA this tour targets
102
+ isPrimary: Optional[bool] = None # Is this the primary tour for the repo?
103
+ nextTour: Optional[str] = None # Path to next tour in sequence
104
+
105
+ # Migration metadata (our additions for tracking tour migrations)
106
+ original_ref: Optional[str] = Field(None, alias='originalRef') # Original commit before migration
107
+ migration_log: Optional[List[dict]] = Field(None, alias='migrationLog') # Migration history
108
+
109
+ @property
110
+ def num_steps(self) -> int:
111
+ """Number of steps in this tour."""
112
+ return len(self.steps)
113
+
114
+ @property
115
+ def has_ref(self) -> bool:
116
+ """Does this tour have a target commit?"""
117
+ return self.ref is not None
118
+
119
+ def steps_with_line_refs(self) -> List[tuple]:
120
+ """
121
+ Get all steps that have line references.
122
+
123
+ Returns:
124
+ List of (index, step) tuples for steps with file+line
125
+ """
126
+ return [
127
+ (idx, step)
128
+ for idx, step in enumerate(self.steps)
129
+ if step.has_line_reference()
130
+ ]
131
+
132
+ def to_json(self, **kwargs) -> str:
133
+ """Serialize tour to JSON string."""
134
+ return self.model_dump_json(
135
+ exclude_none=True,
136
+ by_alias=True,
137
+ indent=2,
138
+ **kwargs
139
+ )
140
+
141
+ def to_dict(self) -> dict:
142
+ """Convert tour to dictionary."""
143
+ return self.model_dump(exclude_none=True, by_alias=True)
144
+
145
+ @classmethod
146
+ def from_json(cls, json_str: str) -> "Tour":
147
+ """Parse tour from JSON string."""
148
+ return cls.model_validate_json(json_str)
149
+
150
+ @classmethod
151
+ def from_file(cls, path: Path) -> "Tour":
152
+ """Load tour from .tour file."""
153
+ return cls.model_validate_json(path.read_text())
154
+
155
+ def save(self, path: Path) -> None:
156
+ """Save tour to .tour file."""
157
+ path.write_text(self.to_json())
158
+
159
+
160
+ class TourCollection(BaseModel):
161
+ """
162
+ Collection of tours in a workspace.
163
+
164
+ The .tours directory contains multiple .tour files.
165
+ """
166
+
167
+ tours: List[tuple] # List of (filename, Tour) pairs
168
+ workspace_path: Path
169
+
170
+ model_config = ConfigDict(arbitrary_types_allowed=True)
171
+
172
+ @classmethod
173
+ def from_directory(cls, tours_dir: Path) -> "TourCollection":
174
+ """
175
+ Load all tours from a .tours directory.
176
+
177
+ Args:
178
+ tours_dir: Path to .tours directory
179
+
180
+ Returns:
181
+ TourCollection with all loaded tours
182
+ """
183
+ tours = []
184
+ for tour_file in sorted(tours_dir.glob("*.tour")):
185
+ try:
186
+ tour = Tour.from_file(tour_file)
187
+ tours.append((tour_file.name, tour))
188
+ except Exception as e:
189
+ # Log error but continue loading other tours
190
+ print(f"Warning: Failed to load {tour_file.name}: {e}")
191
+
192
+ return cls(
193
+ tours=tours,
194
+ workspace_path=tours_dir.parent
195
+ )
196
+
197
+ def save_all(self, tours_dir: Path) -> None:
198
+ """
199
+ Save all tours back to directory.
200
+
201
+ Args:
202
+ tours_dir: Path to .tours directory
203
+ """
204
+ tours_dir.mkdir(exist_ok=True)
205
+
206
+ for filename, tour in self.tours:
207
+ tour_path = tours_dir / filename
208
+ tour.save(tour_path)
209
+
210
+ def get_tour(self, filename: str) -> Optional[Tour]:
211
+ """Get a tour by filename."""
212
+ for name, tour in self.tours:
213
+ if name == filename:
214
+ return tour
215
+ return None
216
+
217
+
218
+ def create_example_tour() -> Tour:
219
+ """
220
+ Create an example tour for testing/documentation.
221
+
222
+ Returns:
223
+ A sample Tour object
224
+ """
225
+ return Tour(
226
+ title="Introduction to the Codebase",
227
+ description="A guided tour through the main components",
228
+ ref="abc123def456", # Git commit SHA
229
+ steps=[
230
+ TourStep(
231
+ file="src/main.py",
232
+ line=1,
233
+ description="# Welcome!\n\nThis is the main entry point."
234
+ ),
235
+ TourStep(
236
+ file="src/core/engine.py",
237
+ line=42,
238
+ description="The core processing engine starts here."
239
+ ),
240
+ TourStep(
241
+ file="src/utils/helpers.py",
242
+ pattern=r"def process_data\(",
243
+ description="Helper functions for data processing"
244
+ ),
245
+ TourStep(
246
+ description="# That's it!\n\nYou've completed the tour."
247
+ )
248
+ ]
249
+ )
250
+
251
+
252
+ if __name__ == "__main__":
253
+ # Example usage
254
+ tour = create_example_tour()
255
+ print(tour.to_json())
@@ -0,0 +1,428 @@
1
+ """
2
+ Tour updater - applies migrations to CodeTour files.
3
+
4
+ This module connects the migration engine to actual .tour files,
5
+ updating step locations when code changes.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import List, Optional
10
+ from pathlib import Path
11
+ from datetime import datetime
12
+
13
+ import git
14
+ import structlog
15
+
16
+ from codetour_cli.tour.schema import Tour, TourStep
17
+ from codetour_cli.migration.direct import DirectMigrator, MigrationResult
18
+
19
+ logger = structlog.get_logger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class StepUpdate:
24
+ """
25
+ Record of a single step update.
26
+
27
+ Tracks what changed for each tour step during migration.
28
+ """
29
+
30
+ step_index: int
31
+ old_location: str # "file.py:42"
32
+ new_location: Optional[str] # "file.py:45" or None if deprecated
33
+
34
+ migration_result: MigrationResult
35
+
36
+ @property
37
+ def was_updated(self) -> bool:
38
+ """Did the location change?"""
39
+ return self.old_location != self.new_location
40
+
41
+ @property
42
+ def needs_review(self) -> bool:
43
+ """Should this update be reviewed by user?"""
44
+ return self.migration_result.needs_review
45
+
46
+ @property
47
+ def deprecated(self) -> bool:
48
+ """Was this step deprecated (file deleted)?"""
49
+ return self.migration_result.deprecated
50
+
51
+
52
+ @dataclass
53
+ class TourUpdateResult:
54
+ """
55
+ Result of updating an entire tour.
56
+
57
+ Contains the updated tour and metadata about changes.
58
+ """
59
+
60
+ tour: Tour
61
+ updates: List[StepUpdate]
62
+
63
+ source_commit: str
64
+ target_commit: str
65
+ timestamp: datetime
66
+
67
+ @property
68
+ def num_updated(self) -> int:
69
+ """Number of steps that changed location."""
70
+ return sum(1 for u in self.updates if u.was_updated)
71
+
72
+ @property
73
+ def num_deprecated(self) -> int:
74
+ """Number of steps marked as deprecated."""
75
+ return sum(1 for u in self.updates if u.deprecated)
76
+
77
+ @property
78
+ def num_needs_review(self) -> int:
79
+ """Number of steps that need manual review."""
80
+ return sum(1 for u in self.updates if u.needs_review)
81
+
82
+ @property
83
+ def all_successful(self) -> bool:
84
+ """Were all migrations successful (no deprecated steps)?"""
85
+ return self.num_deprecated == 0
86
+
87
+
88
+ class TourUpdater:
89
+ """
90
+ Updates CodeTour files by migrating steps through git commits.
91
+
92
+ Uses DirectMigrator to track line movements, then updates
93
+ the tour file with new locations.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ repo: git.Repo,
99
+ diff_algorithm: str = "histogram",
100
+ threshold: float = 0.7
101
+ ):
102
+ """
103
+ Initialize the tour updater.
104
+
105
+ Args:
106
+ repo: GitPython repository object
107
+ diff_algorithm: Git diff algorithm to use
108
+ threshold: confidence cutoff passed through to DirectMigrator
109
+ (ADR-0016 QST-CONF-2 -- previously never reached the scorer)
110
+ """
111
+ self.repo = repo
112
+ self.diff_algorithm = diff_algorithm
113
+ self.threshold = threshold
114
+ self.log = logger.bind(repo=str(repo.working_dir))
115
+
116
+ def update_tour(
117
+ self,
118
+ tour: Tour,
119
+ target_commit: str,
120
+ source_commit: Optional[str] = None
121
+ ) -> TourUpdateResult:
122
+ """
123
+ Update a tour to target commit.
124
+
125
+ Args:
126
+ tour: Tour object to update
127
+ target_commit: Commit SHA to migrate to
128
+ source_commit: Source commit (defaults to tour.ref)
129
+
130
+ Returns:
131
+ TourUpdateResult with updated tour and metadata
132
+ """
133
+ # Use tour.ref if no source specified
134
+ if source_commit is None:
135
+ if tour.ref is None:
136
+ raise ValueError(
137
+ "Tour has no ref commit and no source_commit provided"
138
+ )
139
+ source_commit = tour.ref
140
+
141
+ # Resolve commit refs to actual SHAs (HEAD, branch names, etc.)
142
+ # This ensures we store concrete commit IDs, not moving targets
143
+ source_commit = self.repo.commit(source_commit).hexsha
144
+ target_commit = self.repo.commit(target_commit).hexsha
145
+
146
+ log = self.log.bind(
147
+ source=source_commit[:8],
148
+ target=target_commit[:8],
149
+ tour_title=tour.title
150
+ )
151
+
152
+ log.info("updating_tour")
153
+
154
+ # Create migrator
155
+ migrator = DirectMigrator(
156
+ self.repo,
157
+ source_commit,
158
+ target_commit,
159
+ self.diff_algorithm,
160
+ self.threshold
161
+ )
162
+
163
+ # Migrate each step with line reference
164
+ updates = []
165
+ for idx, step in enumerate(tour.steps):
166
+ if step.has_line_reference():
167
+ update = self._migrate_step(migrator, idx, step, log)
168
+ updates.append(update)
169
+
170
+ # Apply update to step
171
+ if update.migration_result.file_renamed:
172
+ step.file = update.migration_result.new_file
173
+ if update.migration_result.new_line is not None:
174
+ step.line = update.migration_result.new_line
175
+ else:
176
+ log.debug(
177
+ "step_skipped",
178
+ step_index=idx,
179
+ reason="no_line_reference"
180
+ )
181
+
182
+ # Update tour metadata
183
+ self._update_tour_metadata(
184
+ tour,
185
+ source_commit,
186
+ target_commit,
187
+ updates
188
+ )
189
+
190
+ # Create result
191
+ result = TourUpdateResult(
192
+ tour=tour,
193
+ updates=updates,
194
+ source_commit=source_commit,
195
+ target_commit=target_commit,
196
+ timestamp=datetime.now()
197
+ )
198
+
199
+ log.info(
200
+ "tour_updated",
201
+ num_steps=len(tour.steps),
202
+ num_updated=result.num_updated,
203
+ num_deprecated=result.num_deprecated,
204
+ num_needs_review=result.num_needs_review
205
+ )
206
+
207
+ return result
208
+
209
+ def _migrate_step(
210
+ self,
211
+ migrator: DirectMigrator,
212
+ step_index: int,
213
+ step: TourStep,
214
+ log: structlog.BoundLogger
215
+ ) -> StepUpdate:
216
+ """
217
+ Migrate a single tour step.
218
+
219
+ Args:
220
+ migrator: DirectMigrator instance
221
+ step_index: Index of step in tour
222
+ step: TourStep to migrate
223
+ log: Structured logger
224
+
225
+ Returns:
226
+ StepUpdate with migration result
227
+ """
228
+ old_location = f"{step.file}:{step.line}"
229
+
230
+ # Perform migration (pass step for pattern validation)
231
+ result = migrator.migrate_step(
232
+ step.file,
233
+ step.line,
234
+ step_id=step_index,
235
+ step=step # Pass full step for pattern validation
236
+ )
237
+
238
+ # Determine new location (`is not None`, not bare truthiness -- line 0
239
+ # is a real, if invalid, int that Python's truthiness treats as falsy;
240
+ # ADR-0016 QST-CONF-1 found this exact bug caused this display logic
241
+ # to report "deprecated" while the mutation below -- which already
242
+ # correctly used `is not None` -- wrote a real line into the tour)
243
+ if result.new_file and result.new_line is not None:
244
+ new_location = f"{result.new_file}:{result.new_line}"
245
+ else:
246
+ new_location = None # Deprecated
247
+
248
+ log.debug(
249
+ "step_migrated",
250
+ step_index=step_index,
251
+ old=old_location,
252
+ new=new_location,
253
+ method=result.method,
254
+ confidence=result.confidence
255
+ )
256
+
257
+ return StepUpdate(
258
+ step_index=step_index,
259
+ old_location=old_location,
260
+ new_location=new_location,
261
+ migration_result=result
262
+ )
263
+
264
+ def _update_tour_metadata(
265
+ self,
266
+ tour: Tour,
267
+ source_commit: str,
268
+ target_commit: str,
269
+ updates: List[StepUpdate]
270
+ ) -> None:
271
+ """
272
+ Update tour metadata to reflect migration.
273
+
274
+ Args:
275
+ tour: Tour object to update (modified in-place)
276
+ source_commit: Source commit SHA
277
+ target_commit: Target commit SHA
278
+ updates: List of step updates
279
+ """
280
+ # Store original ref if this is first migration
281
+ if tour.original_ref is None:
282
+ tour.original_ref = source_commit
283
+
284
+ # Update current ref to target
285
+ tour.ref = target_commit
286
+
287
+ # Add migration log entry
288
+ if tour.migration_log is None:
289
+ tour.migration_log = []
290
+
291
+ tour.migration_log.append({
292
+ "from": source_commit,
293
+ "to": target_commit,
294
+ "date": datetime.now().isoformat(),
295
+ "num_updated": sum(1 for u in updates if u.was_updated),
296
+ "num_deprecated": sum(1 for u in updates if u.deprecated),
297
+ "num_needs_review": sum(1 for u in updates if u.needs_review)
298
+ })
299
+
300
+
301
+ def update_tour_file(
302
+ repo_path: Path,
303
+ tour_file: Path,
304
+ target_commit: str,
305
+ source_commit: Optional[str] = None,
306
+ diff_algorithm: str = "histogram",
307
+ save_backup: bool = True,
308
+ dry_run: bool = False,
309
+ threshold: float = 0.7
310
+ ) -> TourUpdateResult:
311
+ """
312
+ Convenience function to update a .tour file.
313
+
314
+ Args:
315
+ repo_path: Path to Git repository
316
+ tour_file: Path to .tour file
317
+ target_commit: Commit to migrate to
318
+ source_commit: Source commit (uses tour.ref if None)
319
+ diff_algorithm: Git diff algorithm
320
+ save_backup: Save .tour.backup before updating
321
+ dry_run: Preview changes without modifying files
322
+ threshold: confidence cutoff for needs_review, passed through to
323
+ DirectMigrator (ADR-0016 QST-CONF-2)
324
+
325
+ Returns:
326
+ TourUpdateResult with updated tour
327
+ """
328
+ log = logger.bind(
329
+ repo=str(repo_path),
330
+ tour=tour_file.name
331
+ )
332
+
333
+ if dry_run:
334
+ log.info("updating_tour_file_dry_run")
335
+ else:
336
+ log.info("updating_tour_file")
337
+
338
+ # Load tour
339
+ tour = Tour.from_file(tour_file)
340
+
341
+ # Create updater
342
+ repo = git.Repo(repo_path)
343
+ updater = TourUpdater(repo, diff_algorithm, threshold)
344
+
345
+ # Update tour
346
+ result = updater.update_tour(tour, target_commit, source_commit)
347
+
348
+ # Save files only if not dry-run
349
+ if not dry_run:
350
+ # Save backup if requested (use direct copy to preserve formatting)
351
+ if save_backup:
352
+ import shutil
353
+ backup_path = tour_file.with_suffix('.tour.backup')
354
+ shutil.copy2(tour_file, backup_path)
355
+ log.info("backup_saved", backup_path=str(backup_path))
356
+
357
+ # Save updated tour
358
+ result.tour.save(tour_file)
359
+ log.info("tour_file_updated")
360
+
361
+ return result
362
+
363
+
364
+ def update_all_tours(
365
+ repo_path: Path,
366
+ tours_dir: Path,
367
+ target_commit: str,
368
+ diff_algorithm: str = "histogram",
369
+ save_backups: bool = True,
370
+ threshold: float = 0.7
371
+ ) -> List[TourUpdateResult]:
372
+ """
373
+ Update all .tour files in a directory.
374
+
375
+ Args:
376
+ repo_path: Path to Git repository
377
+ tours_dir: Path to .tours directory
378
+ target_commit: Commit to migrate to
379
+ diff_algorithm: Git diff algorithm
380
+ save_backups: Save .tour.backup files
381
+ threshold: confidence cutoff for needs_review (ADR-0016 QST-CONF-2)
382
+
383
+ Returns:
384
+ List of TourUpdateResult objects
385
+ """
386
+ log = logger.bind(
387
+ repo=str(repo_path),
388
+ tours_dir=str(tours_dir)
389
+ )
390
+
391
+ # Find all .tour files
392
+ tour_files = list(tours_dir.glob("*.tour"))
393
+ log.info("updating_all_tours", num_tours=len(tour_files))
394
+
395
+ # Update each tour
396
+ results = []
397
+ for tour_file in sorted(tour_files):
398
+ try:
399
+ result = update_tour_file(
400
+ repo_path,
401
+ tour_file,
402
+ target_commit,
403
+ diff_algorithm=diff_algorithm,
404
+ save_backup=save_backups,
405
+ threshold=threshold
406
+ )
407
+ results.append(result)
408
+ except Exception as e:
409
+ log.error(
410
+ "tour_update_failed",
411
+ tour=tour_file.name,
412
+ error=str(e)
413
+ )
414
+
415
+ # Summary
416
+ total_updated = sum(r.num_updated for r in results)
417
+ total_deprecated = sum(r.num_deprecated for r in results)
418
+ total_needs_review = sum(r.num_needs_review for r in results)
419
+
420
+ log.info(
421
+ "all_tours_updated",
422
+ num_tours=len(results),
423
+ total_updated=total_updated,
424
+ total_deprecated=total_deprecated,
425
+ total_needs_review=total_needs_review
426
+ )
427
+
428
+ return results