Skip to content

Commit 3365e45

Browse files
authored
feat(Internal): Apply SOLID principles to project_io.py and forge.py (#65)
1 parent 15ef14a commit 3365e45

52 files changed

Lines changed: 1007 additions & 653 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

fastapi_forge/__main__.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import click
44

55
from fastapi_forge.frontend.main import init
6-
from fastapi_forge.project_io import ProjectLoader
6+
from fastapi_forge.io import (
7+
YamlProjectLoader,
8+
create_postgres_project_loader,
9+
)
710

811

912
@click.group()
@@ -38,7 +41,6 @@ def start(
3841
from_yaml: str | None = None,
3942
conn_string: str | None = None,
4043
) -> None:
41-
"""Start the FastAPI Forge server and generate a new project."""
4244
option_count = sum([use_example, bool(from_yaml), bool(conn_string)])
4345
if option_count > 1:
4446
msg = "Only one of '--use-example', '--from-yaml', or '--conn-string' can be used."
@@ -54,16 +56,14 @@ def start(
5456
yaml_path = Path(from_yaml).expanduser().resolve()
5557
if not yaml_path.is_file():
5658
raise click.FileError(f"YAML file not found: {yaml_path}")
57-
project_spec = ProjectLoader(project_path=yaml_path).load()
59+
project_spec = YamlProjectLoader(project_path=yaml_path).load()
5860
elif conn_string:
59-
project_spec = ProjectLoader.load_from_conn_string(
60-
conn_string=conn_string,
61-
)
61+
project_spec = create_postgres_project_loader(conn_string).load()
6262
elif use_example:
6363
base_path = Path(__file__).parent / "example-projects"
6464
path = base_path / "game_zone.yaml"
6565

66-
project_spec = ProjectLoader(project_path=path).load()
66+
project_spec = YamlProjectLoader(project_path=path).load()
6767

6868
init(project_spec=project_spec, no_ui=no_ui)
6969

fastapi_forge/core/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
__all__ = [
2+
"CookiecutterAdapter",
3+
"OverwriteCookiecutterAdapter",
4+
"ProjectBuildDirector",
5+
"build_fastapi_project",
6+
]
7+
8+
from .build import ProjectBuildDirector, build_fastapi_project
9+
from .cookiecutter_adapter import CookiecutterAdapter, OverwriteCookiecutterAdapter

fastapi_forge/core/build.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from collections.abc import Callable
2+
from pathlib import Path
3+
from time import perf_counter
4+
5+
from fastapi_forge.io import ArtifactBuilder, create_fastapi_project_builder
6+
from fastapi_forge.logger import logger
7+
from fastapi_forge.schemas import ProjectSpec
8+
9+
from .cookiecutter_adapter import CookiecutterAdapter, OverwriteCookiecutterAdapter
10+
from .project_validators import ProjectNameValidator, ProjectValidator
11+
from .template_processors import DefaultTemplateProcessor, TemplateProcessor
12+
13+
14+
class ProjectBuildDirector:
15+
def __init__(
16+
self,
17+
builder: ArtifactBuilder,
18+
template_processor: TemplateProcessor,
19+
template_generator: CookiecutterAdapter,
20+
template_resolver: Callable,
21+
project_validator: ProjectValidator | None = None,
22+
):
23+
self.builder = builder
24+
self.validator = project_validator
25+
self.template_processor = template_processor
26+
self.template_generator = template_generator
27+
self.template_resolver = template_resolver
28+
29+
async def build(self, spec: ProjectSpec) -> None:
30+
if self.validator:
31+
self.validator.validate(spec)
32+
await self.builder.build_artifacts()
33+
34+
context = self.template_processor.process(spec)
35+
template_path = self.template_resolver()
36+
37+
self.template_generator.generate(
38+
template_path=template_path,
39+
output_dir=Path.cwd().resolve(),
40+
extra_context=context,
41+
)
42+
43+
44+
def _get_template_path() -> Path:
45+
template_path = Path(__file__).resolve().parent.parent / "template"
46+
if not template_path.exists():
47+
raise RuntimeError(f"Template directory not found: {template_path}")
48+
if not template_path.is_dir():
49+
raise RuntimeError(f"Template path is not a directory: {template_path}")
50+
return template_path
51+
52+
53+
async def build_fastapi_project(spec: ProjectSpec) -> None:
54+
start_time = perf_counter()
55+
56+
try:
57+
director = ProjectBuildDirector(
58+
builder=create_fastapi_project_builder(spec),
59+
project_validator=ProjectNameValidator(),
60+
template_processor=DefaultTemplateProcessor(),
61+
template_generator=OverwriteCookiecutterAdapter(),
62+
template_resolver=_get_template_path,
63+
)
64+
65+
await director.build(spec)
66+
67+
build_time = perf_counter() - start_time
68+
logger.info(f"Project built successfully in {build_time:.2f} seconds.")
69+
70+
except Exception as error:
71+
logger.error(f"Project build failed: {error}")
72+
raise
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
__all__ = ["CookiecutterAdapter", "OverwriteCookiecutterAdapter"]
2+
3+
from .adapters import OverwriteCookiecutterAdapter
4+
from .protocols import CookiecutterAdapter
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from pathlib import Path
2+
from typing import Any
3+
4+
from cookiecutter.main import cookiecutter
5+
6+
from .protocols import CookiecutterAdapter
7+
8+
9+
class OverwriteCookiecutterAdapter(CookiecutterAdapter):
10+
def generate(
11+
self,
12+
template_path: Path,
13+
output_dir: Path,
14+
extra_context: dict[str, Any] | None = None,
15+
) -> None:
16+
cookiecutter(
17+
template=str(template_path),
18+
output_dir=str(output_dir),
19+
no_input=True,
20+
overwrite_if_exists=True,
21+
extra_context=extra_context,
22+
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from abc import abstractmethod
2+
from pathlib import Path
3+
from typing import Any, Protocol
4+
5+
6+
class CookiecutterAdapter(Protocol):
7+
@abstractmethod
8+
def generate(
9+
self,
10+
template_path: Path,
11+
output_dir: Path,
12+
extra_context: dict[str, Any] | None = None,
13+
) -> None:
14+
raise NotImplementedError
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
__all__ = ["ProjectNameValidator", "ProjectValidator"]
2+
3+
from .protocols import ProjectValidator
4+
from .validators import ProjectNameValidator
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from abc import abstractmethod
2+
from typing import Protocol
3+
4+
from fastapi_forge.schemas import ProjectSpec
5+
6+
7+
class ProjectValidator(Protocol):
8+
@abstractmethod
9+
def validate(self, project_spec: ProjectSpec) -> None:
10+
raise NotImplementedError
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from pathlib import PurePath
2+
3+
from fastapi_forge.schemas import ProjectSpec
4+
5+
from .protocols import ProjectValidator
6+
7+
8+
class ProjectNameValidator(ProjectValidator):
9+
def validate(self, project_spec: ProjectSpec) -> None:
10+
project_name = project_spec.project_name
11+
12+
if not project_name:
13+
msg = "Project name cannot be empty"
14+
raise ValueError(msg)
15+
if not project_name.isidentifier():
16+
raise ValueError(
17+
f"Invalid project name: {project_name}. Must be a valid identifier."
18+
)
19+
20+
if PurePath(project_name).is_absolute():
21+
raise ValueError(
22+
f"Project name cannot be an absolute path: {project_name}."
23+
)
24+
25+
if not project_name.isascii():
26+
raise ValueError(f"Project name must be ASCII: {project_name}.")
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
__all__ = [
2+
"DefaultTemplateProcessor",
3+
"TemplateProcessor",
4+
]
5+
6+
from .processors import DefaultTemplateProcessor
7+
from .protocols import TemplateProcessor

0 commit comments

Comments
 (0)