Coverage for src/prosemark/templates/domain/values/directory_path.py: 100%
98 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-30 23:09 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-30 23:09 +0000
1"""DirectoryPath value object for template directory handling and validation."""
3from pathlib import Path
4from typing import Self
6from prosemark.templates.domain.exceptions.template_exceptions import (
7 TemplateDirectoryNotFoundError,
8 TemplateValidationError,
9)
12class DirectoryPath:
13 """Immutable value object representing a validated template directory path.
15 This value object encapsulates a directory path and provides template-specific
16 directory operations and validation.
17 """
19 def __init__(self, path: Path | str) -> None:
20 """Initialize a directory path with validation.
22 Args:
23 path: File system path to a directory
25 Raises:
26 TemplateDirectoryNotFoundError: If the directory does not exist
27 TemplateValidationError: If the path is not a valid directory
29 """
30 if isinstance(path, str):
31 path = Path(path)
33 if not path.exists():
34 raise TemplateDirectoryNotFoundError(str(path))
36 if not path.is_dir():
37 msg = f'Path must point to a directory, not a file: {path}'
38 raise TemplateValidationError(msg, template_path=str(path))
40 self._path = path.resolve()
42 @property
43 def value(self) -> Path:
44 """Get the absolute path to the directory."""
45 return self._path
47 @property
48 def name(self) -> str:
49 """Get the directory name."""
50 return self._path.name
52 @property
53 def exists(self) -> bool:
54 """Check if the directory still exists."""
55 return self._path.exists() and self._path.is_dir()
57 @property
58 def template_count(self) -> int:
59 """Count the number of .md files in this directory (non-recursive)."""
60 if not self.exists:
61 return 0
63 try:
64 return len([f for f in self._path.iterdir() if f.is_file() and f.suffix == '.md'])
65 except (OSError, PermissionError): # pragma: no cover
66 return 0
68 @property
69 def total_template_count(self) -> int:
70 """Count all .md files in this directory recursively."""
71 if not self.exists:
72 return 0
74 try:
75 return len(list(self._path.rglob('*.md')))
76 except (OSError, PermissionError): # pragma: no cover
77 return 0
79 @property
80 def subdirectory_count(self) -> int:
81 """Count the number of subdirectories in this directory."""
82 if not self.exists:
83 return 0
85 try:
86 return len([d for d in self._path.iterdir() if d.is_dir()])
87 except (OSError, PermissionError): # pragma: no cover
88 return 0
90 @property
91 def is_valid_template_directory(self) -> bool:
92 """Check if this directory is a valid template directory.
94 A valid template directory either:
95 1. Contains at least one .md file directly, OR
96 2. Contains subdirectories that contain .md files
97 """
98 if not self.exists:
99 return False
101 # Check for direct .md files
102 if self.template_count > 0:
103 return True
105 # Check for .md files in subdirectories
106 return self.total_template_count > self.template_count
108 def list_template_files(self, *, recursive: bool = False) -> list[Path]:
109 """List all .md files in the directory.
111 Args:
112 recursive: If True, search subdirectories recursively
114 Returns:
115 List of paths to .md files
117 Raises:
118 TemplateDirectoryNotFoundError: If directory no longer exists
120 """
121 if not self.exists:
122 raise TemplateDirectoryNotFoundError(str(self._path))
124 try:
125 if recursive:
126 return sorted(self._path.rglob('*.md'))
127 return sorted([f for f in self._path.iterdir() if f.is_file() and f.suffix == '.md'])
128 except (OSError, PermissionError) as e: # pragma: no cover
129 msg = f'Cannot access directory: {self._path}'
130 raise TemplateDirectoryNotFoundError(msg) from e
132 def list_subdirectories(self) -> list[Path]:
133 """List all subdirectories.
135 Returns:
136 List of paths to subdirectories
138 Raises:
139 TemplateDirectoryNotFoundError: If directory no longer exists
141 """
142 if not self.exists:
143 raise TemplateDirectoryNotFoundError(str(self._path))
145 try:
146 return sorted([d for d in self._path.iterdir() if d.is_dir()])
147 except (OSError, PermissionError) as e: # pragma: no cover
148 msg = f'Cannot access directory: {self._path}'
149 raise TemplateDirectoryNotFoundError(msg) from e
151 def find_template_file(self, template_name: str) -> Path | None:
152 """Find a template file by name in this directory.
154 Args:
155 template_name: Name of template (with or without .md extension)
157 Returns:
158 Path to template file if found, None otherwise
160 """
161 if not self.exists:
162 return None
164 # Ensure .md extension
165 if not template_name.endswith('.md'):
166 template_name = f'{template_name}.md'
168 template_file = self._path / template_name
169 return template_file if template_file.is_file() else None
171 def find_subdirectory(self, directory_name: str) -> Path | None:
172 """Find a subdirectory by name.
174 Args:
175 directory_name: Name of subdirectory
177 Returns:
178 Path to subdirectory if found, None otherwise
180 """
181 if not self.exists:
182 return None
184 subdirectory = self._path / directory_name
185 return subdirectory if subdirectory.is_dir() else None
187 def get_relative_path_to(self, target_path: Path) -> Path | None:
188 """Get relative path from this directory to a target path.
190 Args:
191 target_path: Target path to calculate relative path to
193 Returns:
194 Relative path if possible, None if paths are not related
196 """
197 try:
198 return target_path.relative_to(self._path)
199 except ValueError:
200 return None
202 def contains_path(self, path: Path) -> bool:
203 """Check if a path is contained within this directory.
205 Args:
206 path: Path to check
208 Returns:
209 True if path is within this directory
211 """
212 return self.get_relative_path_to(path) is not None
214 @classmethod
215 def create_if_not_exists(cls, path: Path | str) -> Self:
216 """Create directory if it doesn't exist and return DirectoryPath.
218 Args:
219 path: Path to directory to create
221 Returns:
222 New DirectoryPath instance
224 Raises:
225 TemplateValidationError: If path exists but is not a directory
226 OSError: If directory cannot be created
228 """
229 if isinstance(path, str):
230 path = Path(path)
232 if path.exists() and not path.is_dir():
233 msg = f'Path exists but is not a directory: {path}'
234 raise TemplateValidationError(msg, template_path=str(path))
236 if not path.exists():
237 path.mkdir(parents=True, exist_ok=True)
239 return cls(path)
241 def __str__(self) -> str:
242 """String representation of the directory path."""
243 return str(self._path)
245 def __repr__(self) -> str:
246 """Developer representation of the directory path."""
247 return f'DirectoryPath({self._path!r})'
249 def __eq__(self, other: object) -> bool:
250 """Check equality with another DirectoryPath."""
251 if not isinstance(other, DirectoryPath):
252 return NotImplemented
253 return self._path == other._path
255 def __hash__(self) -> int:
256 """Hash based on the path value."""
257 return hash(self._path)