Files
markitect-main/markitect/associated_files.py
tegwick 3168de49ac
Some checks failed
Test Suite / code-quality (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
feat: Complete Issue #40 - Associated Files Management with Interactive vs Automation Mode System
This commit implements comprehensive associated files management and introduces
a mode-based architecture that resolves conflicting requirements between
interactive user workflows and automation/testing scenarios.

## Key Features

### Associated Files Management
- Convention-based file pairing (document.md ↔ document.json)
- Automatic path resolution and file discovery
- Complete CLI command suite for managing file pairs
- Performance optimizations with caching

### Interactive vs Automation Mode System
- Automatic mode detection via TTY, CI environment, and pipes
- Environment variable override (MARKITECT_MODE)
- Interactive mode: Uses associated file paths by default
- Automation mode: Optimizes for speed, memory, and stdout output

### Enhanced CLI Commands
- schema-generate: Auto-places output next to source in interactive mode
- generate-stub: Auto-places output next to schema in interactive mode
- validate: Auto-discovers associated schema files
- New associated-files command group with list, info, status, create subcommands

### Bug Fixes
- Fixed isinstance() errors caused by function shadowing built-in types
- Resolved test failures with new mode system integration
- Ensured backward compatibility for all existing functionality

## Technical Implementation
- Added AssociatedFilesManager class with comprehensive file operations
- Implemented mode detection using environment analysis
- Enhanced format_output function with proper type checking
- Added pytest configuration for automation mode during testing
- Complete test coverage for all new functionality

All 448 tests passing. Maintains full backward compatibility while adding
powerful new interactive features for improved developer experience.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 13:09:37 +02:00

355 lines
12 KiB
Python

"""
Associated Files Manager for Issue #40: Associated Files Management.
This module provides functionality to manage associated markdown and schema files
with convention-based naming and automatic file placement.
"""
import os
from pathlib import Path
from typing import Dict, List, Optional, Any
class AssociatedFilesError(Exception):
"""Base exception for associated files operations."""
pass
class InvalidFileTypeError(AssociatedFilesError):
"""Raised when file has unexpected extension."""
pass
class DirectoryAccessError(AssociatedFilesError):
"""Raised when directory cannot be accessed."""
pass
class AssociatedFilesManager:
"""
Manages associated markdown and schema files with convention-based naming.
Provides functionality to find, create, and manage pairs of markdown and JSON schema
files that follow the convention of having identical basenames with different extensions.
"""
def __init__(self, markdown_extension: str = '.md', schema_extension: str = '.json'):
"""
Initialize the associated files manager.
Args:
markdown_extension: File extension for markdown files (default: '.md')
schema_extension: File extension for schema files (default: '.json')
Raises:
ValueError: If extensions are invalid or identical
"""
# Validate extensions
if not markdown_extension.startswith('.'):
raise ValueError("Markdown extension must start with '.'")
if not schema_extension.startswith('.'):
raise ValueError("Schema extension must start with '.'")
if markdown_extension.lower() == schema_extension.lower():
raise ValueError("Markdown and schema extensions must be different")
self.markdown_extension = markdown_extension.lower()
self.schema_extension = schema_extension.lower()
def _validate_file_extension(self, file_path: Path, expected_extension: str, file_type: str) -> None:
"""
Validate file has expected extension.
Args:
file_path: Path to validate
expected_extension: Expected extension (e.g., '.md')
file_type: Description of file type for error messages
Raises:
InvalidFileTypeError: If file doesn't have expected extension
"""
if file_path.suffix.lower() != expected_extension:
raise InvalidFileTypeError(
f"Expected {file_type} file with {expected_extension} extension, got {file_path.suffix}"
)
def _validate_markdown_file(self, file_path: Path) -> None:
"""Validate file is a markdown file."""
self._validate_file_extension(file_path, self.markdown_extension, "markdown")
def _validate_schema_file(self, file_path: Path) -> None:
"""Validate file is a schema file."""
self._validate_file_extension(file_path, self.schema_extension, "schema")
def _validate_directory(self, directory: Path) -> None:
"""
Validate directory access and permissions.
Args:
directory: Directory path to validate
Raises:
DirectoryAccessError: If directory cannot be accessed
"""
if not directory.exists():
raise DirectoryAccessError(f"Directory does not exist: {directory}")
if not directory.is_dir():
raise DirectoryAccessError(f"Path is not a directory: {directory}")
if not os.access(directory, os.R_OK):
raise DirectoryAccessError(f"No read permission for directory: {directory}")
def get_associated_schema_path(self, markdown_file: Path) -> Path:
"""
Get the path for the associated schema file of a markdown file.
Args:
markdown_file: Path to the markdown file
Returns:
Path where the associated schema should be located
Raises:
InvalidFileTypeError: If the file doesn't have .md extension
"""
self._validate_markdown_file(markdown_file)
return markdown_file.with_suffix(self.schema_extension)
def get_associated_markdown_path(self, schema_file: Path) -> Path:
"""
Get the path for the associated markdown file of a schema file.
Args:
schema_file: Path to the schema file
Returns:
Path where the associated markdown should be located
Raises:
InvalidFileTypeError: If the file doesn't have .json extension
"""
self._validate_schema_file(schema_file)
return schema_file.with_suffix(self.markdown_extension)
def find_associated_schema(self, markdown_file: Path) -> Optional[Path]:
"""
Find the associated schema file for a markdown file.
Args:
markdown_file: Path to the markdown file
Returns:
Path to associated schema file if it exists, None otherwise
"""
schema_path = self.get_associated_schema_path(markdown_file)
return schema_path if schema_path.exists() else None
def find_associated_markdown(self, schema_file: Path) -> Optional[Path]:
"""
Find the associated markdown file for a schema file.
Args:
schema_file: Path to the schema file
Returns:
Path to associated markdown file if it exists, None otherwise
"""
markdown_path = self.get_associated_markdown_path(schema_file)
return markdown_path if markdown_path.exists() else None
def has_associated_schema(self, markdown_file: Path) -> bool:
"""
Check if a markdown file has an associated schema file.
Args:
markdown_file: Path to the markdown file
Returns:
True if associated schema exists, False otherwise
"""
return self.find_associated_schema(markdown_file) is not None
def has_associated_markdown(self, schema_file: Path) -> bool:
"""
Check if a schema file has an associated markdown file.
Args:
schema_file: Path to the schema file
Returns:
True if associated markdown exists, False otherwise
"""
return self.find_associated_markdown(schema_file) is not None
def list_file_pairs(self, directory: Path) -> List[Dict[str, Any]]:
"""
List all associated file pairs in a directory.
Optimized version that reduces filesystem calls by collecting all files
at once and finding pairs through basename intersection.
Args:
directory: Directory to search for file pairs
Returns:
List of dictionaries containing information about each file pair
"""
pairs = []
# Get all files at once and group by extension (more efficient)
try:
all_files = [f for f in directory.iterdir() if f.is_file()]
except (OSError, PermissionError):
# Return empty list if directory cannot be read
return pairs
md_files = {f.stem: f for f in all_files if f.suffix.lower() == self.markdown_extension}
json_files = {f.stem: f for f in all_files if f.suffix.lower() == self.schema_extension}
# Find pairs by checking intersection of basenames (no additional filesystem calls)
paired_basenames = set(md_files.keys()) & set(json_files.keys())
for basename in sorted(paired_basenames): # Sort for consistent output
pairs.append({
'basename': basename,
'markdown_file': md_files[basename],
'schema_file': json_files[basename],
'both_exist': True
})
return pairs
def get_file_pair_info(self, file_path: Path) -> Dict[str, Any]:
"""
Get detailed information about a file pair.
Args:
file_path: Path to either markdown or schema file
Returns:
Dictionary with detailed information about the file pair
"""
if file_path.suffix.lower() == self.markdown_extension:
md_file = file_path
schema_file = self.get_associated_schema_path(md_file)
elif file_path.suffix.lower() == self.schema_extension:
schema_file = file_path
md_file = self.get_associated_markdown_path(schema_file)
else:
raise ValueError(f"Unsupported file type: {file_path.suffix}")
info = {
'basename': file_path.stem,
'markdown_file': md_file,
'schema_file': schema_file,
'both_exist': md_file.exists() and schema_file.exists()
}
# Add file sizes if files exist
if md_file.exists():
info['markdown_size'] = md_file.stat().st_size
info['markdown_modified'] = md_file.stat().st_mtime
if schema_file.exists():
info['schema_size'] = schema_file.stat().st_size
info['schema_modified'] = schema_file.stat().st_mtime
return info
def list_orphaned_files(self, directory: Path) -> Dict[str, List[Path]]:
"""
List orphaned files (files without their associated counterpart).
Optimized version that reuses file discovery from list_file_pairs logic.
Args:
directory: Directory to search
Returns:
Dictionary with 'orphaned_markdown' and 'orphaned_schemas' lists
"""
orphaned_markdown = []
orphaned_schemas = []
# Get all files at once (reusing optimization pattern)
try:
all_files = [f for f in directory.iterdir() if f.is_file()]
except (OSError, PermissionError):
return {
'orphaned_markdown': orphaned_markdown,
'orphaned_schemas': orphaned_schemas
}
md_files = {f.stem: f for f in all_files if f.suffix.lower() == self.markdown_extension}
json_files = {f.stem: f for f in all_files if f.suffix.lower() == self.schema_extension}
# Find orphaned files by checking set differences
orphaned_md_basenames = set(md_files.keys()) - set(json_files.keys())
orphaned_json_basenames = set(json_files.keys()) - set(md_files.keys())
# Collect orphaned files
for basename in sorted(orphaned_md_basenames):
orphaned_markdown.append(md_files[basename])
for basename in sorted(orphaned_json_basenames):
orphaned_schemas.append(json_files[basename])
return {
'orphaned_markdown': orphaned_markdown,
'orphaned_schemas': orphaned_schemas
}
def get_directory_status(self, directory: Path) -> Dict[str, Any]:
"""
Get comprehensive status of associated files in a directory.
Args:
directory: Directory to analyze
Returns:
Dictionary with status information
"""
pairs = self.list_file_pairs(directory)
orphaned = self.list_orphaned_files(directory)
return {
'directory': directory,
'paired_files': len(pairs),
'orphaned_markdown': len(orphaned['orphaned_markdown']),
'orphaned_schemas': len(orphaned['orphaned_schemas']),
'pairs': pairs,
'orphaned': orphaned
}
def suggest_output_path(self, input_file: Path, target_extension: str) -> Path:
"""
Suggest an output path for generating an associated file.
Args:
input_file: Source file path
target_extension: Desired extension for output file (e.g., '.json', '.md')
Returns:
Suggested path for the output file
"""
return input_file.with_suffix(target_extension)
def validate_file_pair_naming(self, markdown_file: Path, schema_file: Path) -> bool:
"""
Validate that two files follow the associated files naming convention.
Args:
markdown_file: Path to markdown file
schema_file: Path to schema file
Returns:
True if files follow naming convention, False otherwise
"""
return (
markdown_file.stem == schema_file.stem and
markdown_file.suffix.lower() == self.markdown_extension and
schema_file.suffix.lower() == self.schema_extension and
markdown_file.parent == schema_file.parent
)