Skip to content

Commit b2bcecc

Browse files
Merge pull request #74 from Bullish-Design/claude/refactor-devman-architecture-GsTY3
Refactor to layered architecture with Railway-Oriented Programming
2 parents 7e5040b + d67c43c commit b2bcecc

23 files changed

Lines changed: 1201 additions & 113 deletions

MIGRATION.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Migration Guide: v0.1.0 to v0.2.0
2+
3+
## Breaking Changes
4+
5+
### 1. TemplateValidator Returns Structured Results
6+
7+
**Before:**
8+
```python
9+
issues = TemplateValidator.validate(ref)
10+
# issues: dict[str, list[str]]
11+
if issues["errors"]:
12+
for error in issues["errors"]:
13+
print(error)
14+
```
15+
16+
**After:**
17+
```python
18+
result = TemplateValidator.validate_typed(ref)
19+
# result: ValidationResult
20+
if not result.is_valid:
21+
for error in result.errors:
22+
print(f"{error.message} at {error.location}")
23+
```
24+
25+
### 2. TemplateReference.create() Returns Result
26+
27+
**Before:**
28+
```python
29+
try:
30+
ref = TemplateReference(source_type="file", location=path)
31+
except ValueError as e:
32+
handle_error(e)
33+
```
34+
35+
**After:**
36+
```python
37+
result = TemplateReference.create("file", path)
38+
if result.is_err():
39+
handle_error(result.unwrap_err())
40+
else:
41+
ref = result.unwrap()
42+
```
43+
44+
### 3. CopierConfig.questions Is Now Typed
45+
46+
**Before:**
47+
```python
48+
config.questions["name"] # type: Any
49+
```
50+
51+
**After:**
52+
```python
53+
config.questions["name"] # type: Question (typed when loaded via from_yaml_file)
54+
# Can use isinstance() for type checking:
55+
if isinstance(config.questions["name"], StrQuestion):
56+
default = config.questions["name"].default
57+
```
58+
59+
## Backward Compatibility
60+
61+
The following are maintained for backward compatibility but deprecated:
62+
63+
- `TemplateReference.from_string()` - Use `create()` instead
64+
- `TemplateValidator.validate()` - Use `validate_typed()` instead
65+
- `TemplateValidator.validate_structure()` - Use `validate_structure_typed()` instead
66+
- `CopierConfig.validate_questions()` - Use `validate_questions_structured()` instead
67+
- `devman.cli.DevmanFinder` - Use `devman.domain.finder.DevmanFinder` instead
68+
69+
## New Features
70+
71+
- Structured error types in `devman.domain.errors`
72+
- Use cases in `devman.application.use_cases`
73+
- Value objects in `devman.domain.models`
74+
- Domain finder in `devman.domain.finder`
75+
- `parse_question()` function for type-safe question parsing

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,46 @@ See `tests/fixtures/example_copier.yaml` for a complete example.
9090
- `devman version`: Print the current devman version.
9191
- `devman hello NAME`: Print a greeting.
9292

93+
## Architecture
94+
95+
devman follows a layered architecture:
96+
97+
### Domain Layer (`src/devman/domain/`)
98+
Pure business logic with no framework dependencies:
99+
- **Models**: Value objects (`ProjectRoot`, `DevmanDirectory`, `ValidationResult`)
100+
- **Errors**: Structured error types for all failure cases
101+
- **Services**: `DevmanFinder` for .devman directory location
102+
- **Protocols**: Interfaces for dependency inversion
103+
104+
### Application Layer (`src/devman/application/`)
105+
Use cases orchestrating domain objects:
106+
- `FindDevmanUseCase`: Locate .devman directory
107+
- `RunDevenvUseCase`: Execute devenv commands
108+
- `ValidateTemplateUseCase`: Validate template structure
109+
110+
### Infrastructure Layer
111+
- **CLI** (`cli.py`): Typer-based command interface
112+
- **Schemas** (`schemas/`): Pydantic models for copier.yaml
113+
- **Templates** (`templates.py`): Template reference and validation
114+
115+
### Error Handling
116+
Uses Railway-Oriented Programming with `Result` types:
117+
- `Ok(value)` for success
118+
- `Err(error)` for failures
119+
- No exceptions in business logic
120+
- All errors are typed domain objects
121+
122+
Example:
123+
```python
124+
result = ProjectRoot.create(path)
125+
if result.is_ok():
126+
root = result.unwrap()
127+
# ... use root
128+
else:
129+
error = result.unwrap_err()
130+
print(f"Failed: {error}")
131+
```
132+
93133
## Development
94134
```bash
95135
# Install dev dependencies

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies = [
1313
"pydantic-settings>=2.0.0",
1414
"copier>=9.0.0",
1515
"pyyaml>=6.0.0",
16+
"result>=0.17.0",
1617
]
1718

1819
[project.optional-dependencies]

src/devman/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,3 @@
22
"""DevEnv project templating system."""
33

44
__version__ = "0.1.0"
5-

src/devman/application/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# src/devman/application/__init__.py
2+
"""Application layer: use cases orchestrating domain objects."""
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# src/devman/application/use_cases.py
2+
from __future__ import annotations
3+
4+
import subprocess
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
8+
from result import Err, Ok, Result
9+
10+
from devman.domain.errors import DomainError, DevmanNotFoundError
11+
from devman.domain.finder import DevmanFinder
12+
from devman.domain.models import DevmanDirectory, ProjectRoot, ValidationResult
13+
from devman.templates import TemplateReference, TemplateValidator
14+
15+
16+
@dataclass(frozen=True)
17+
class FindDevmanCommand:
18+
"""Command to locate .devman directory."""
19+
20+
start_path: Path | None = None
21+
projects_root: ProjectRoot | None = None
22+
23+
24+
@dataclass(frozen=True)
25+
class FindDevmanResult:
26+
"""Result of finding .devman directory."""
27+
28+
devman_directory: DevmanDirectory
29+
30+
31+
class FindDevmanUseCase:
32+
"""Use case: Find nearest .devman directory."""
33+
34+
def execute(
35+
self, command: FindDevmanCommand
36+
) -> Result[FindDevmanResult, DevmanNotFoundError]:
37+
"""Execute the find operation."""
38+
finder = DevmanFinder(projects_root=command.projects_root)
39+
result = finder.find(start_path=command.start_path)
40+
41+
return result.map(
42+
lambda devman_dir: FindDevmanResult(devman_directory=devman_dir)
43+
)
44+
45+
46+
@dataclass(frozen=True)
47+
class RunDevenvCommand:
48+
"""Command to run devenv with arguments."""
49+
50+
devenv_args: list[str]
51+
devman_directory: DevmanDirectory
52+
53+
54+
@dataclass(frozen=True)
55+
class RunDevenvResult:
56+
"""Result of running devenv command."""
57+
58+
exit_code: int
59+
60+
61+
@dataclass(frozen=True)
62+
class RunDevenvError(DomainError):
63+
"""Error running devenv command."""
64+
65+
exit_code: int
66+
stderr: str | None = None
67+
68+
69+
class RunDevenvUseCase:
70+
"""Use case: Execute devenv command in .devman directory."""
71+
72+
def execute(
73+
self, command: RunDevenvCommand
74+
) -> Result[RunDevenvResult, RunDevenvError]:
75+
"""Execute devenv with provided arguments."""
76+
try:
77+
result = subprocess.run(
78+
["devenv", *command.devenv_args],
79+
cwd=command.devman_directory.path,
80+
check=True,
81+
capture_output=True,
82+
text=True,
83+
)
84+
return Ok(RunDevenvResult(exit_code=result.returncode))
85+
86+
except subprocess.CalledProcessError as e:
87+
return Err(
88+
RunDevenvError(
89+
message=f"devenv failed with exit code {e.returncode}",
90+
exit_code=e.returncode,
91+
stderr=e.stderr if e.stderr else None,
92+
)
93+
)
94+
95+
except FileNotFoundError:
96+
return Err(
97+
RunDevenvError(
98+
message="devenv command not found",
99+
exit_code=127,
100+
)
101+
)
102+
103+
104+
@dataclass(frozen=True)
105+
class ValidateTemplateCommand:
106+
"""Command to validate a template."""
107+
108+
template_reference: TemplateReference
109+
110+
111+
@dataclass(frozen=True)
112+
class ValidateTemplateResult:
113+
"""Result of template validation."""
114+
115+
validation_result: ValidationResult
116+
117+
@property
118+
def is_valid(self) -> bool:
119+
return self.validation_result.is_valid
120+
121+
122+
class ValidateTemplateUseCase:
123+
"""Use case: Validate template structure and schema."""
124+
125+
def execute(self, command: ValidateTemplateCommand) -> ValidateTemplateResult:
126+
"""Execute validation."""
127+
validation_result = TemplateValidator.validate_typed(command.template_reference)
128+
return ValidateTemplateResult(validation_result=validation_result)

0 commit comments

Comments
 (0)