feat: Complete Issue #40 - Associated Files Management with Interactive vs Automation Mode System
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
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
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>
This commit is contained in:
355
markitect/associated_files.py
Normal file
355
markitect/associated_files.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
Reference in New Issue
Block a user