5959from __future__ import annotations
6060
6161import os
62+ import re
6263import shutil
6364import subprocess
6465from dataclasses import dataclass , field
132133 ),
133134}
134135
135- # License template mappings
136+ # License template mappings - every License the CLI offers must map to a
137+ # template so the generated project always ships a real LICENSE file.
136138LICENSE_TEMPLATES : dict [License , str ] = {
137139 License .MIT : "LICENSE_MIT.j2" ,
138- # Add more licenses as templates are created
140+ License .APACHE2 : "LICENSE_APACHE2.j2" ,
141+ License .GPL3 : "LICENSE_GPL3.j2" ,
142+ License .BSD3 : "LICENSE_BSD3.j2" ,
143+ License .UNLICENSE : "LICENSE_UNLICENSE.j2" ,
144+ License .PROPRIETARY : "LICENSE_PROPRIETARY.j2" ,
139145}
140146
141147
@@ -228,9 +234,56 @@ def create_jinja_env() -> Environment:
228234 # Add custom filters
229235 env .filters ["snake_case" ] = lambda s : s .replace ("-" , "_" ).lower ()
230236
237+ # Escaping filters for embedding free-text (description, author name) into
238+ # generated files. Autoescaping is disabled for code generation, so every
239+ # interpolation of user-supplied text into a string literal must be escaped
240+ # explicitly or a stray quote/backslash/brace produces a broken file.
241+ env .filters ["toml_escape" ] = _escape_basic_string
242+ env .filters ["py_escape" ] = _escape_basic_string
243+ env .filters ["fstring_escape" ] = _escape_fstring
244+ env .filters ["github_slug" ] = _github_slug
245+
231246 return env
232247
233248
249+ def _github_slug (value : object ) -> str :
250+ """
251+ Turn an author name into a URL-safe slug for GitHub URLs.
252+
253+ Keeps only lowercase alphanumerics and hyphens so the result is a
254+ valid path segment regardless of punctuation in the author name.
255+ """
256+ return re .sub (r"[^a-z0-9-]" , "" , str (value ).lower ())
257+
258+
259+ def _escape_basic_string (value : object ) -> str :
260+ """
261+ Escape a value for embedding inside a double-quoted string literal.
262+
263+ The result is safe for TOML basic strings and for Python single-,
264+ double-, and triple-quoted string literals (escaping the quote also
265+ neutralises an embedded ``\" \" \" ``).
266+ """
267+ return (
268+ str (value )
269+ .replace ("\\ " , "\\ \\ " )
270+ .replace ('"' , '\\ "' )
271+ .replace ("\n " , "\\ n" )
272+ .replace ("\r " , "\\ r" )
273+ .replace ("\t " , "\\ t" )
274+ )
275+
276+
277+ def _escape_fstring (value : object ) -> str :
278+ """
279+ Escape a value for embedding inside a Python f-string literal.
280+
281+ In addition to the standard string escaping, literal braces must be
282+ doubled so they are not interpreted as f-string replacement fields.
283+ """
284+ return _escape_basic_string (value ).replace ("{" , "{{" ).replace ("}" , "}}" )
285+
286+
234287# =============================================================================
235288# Directory Structure Creation
236289# =============================================================================
@@ -409,14 +462,20 @@ def get_output_path(
409462 >>> get_output_path("{src_path}/__init__.py", config)
410463 PosixPath('src/mylib/__init__.py')
411464 """
412- # Replace placeholders
413- path_str = template_path .format (
414- src_path = config .get_src_path (),
415- package_name = config .package_name ,
416- test_path = config .get_test_path (),
417- )
465+ # Resolve placeholders segment-by-segment using pathlib joins rather than
466+ # string formatting. Formatting a Path via str() can mix separators on
467+ # Windows; joining keeps the result platform-correct.
468+ replacements : dict [str , Path ] = {
469+ "{src_path}" : config .get_src_path (),
470+ "{test_path}" : config .get_test_path (),
471+ "{package_name}" : Path (config .package_name ),
472+ }
418473
419- return Path (path_str )
474+ result = Path ()
475+ for segment in template_path .split ("/" ):
476+ result = result / replacements .get (segment , Path (segment ))
477+
478+ return result
420479
421480
422481def render_all_templates (
@@ -629,19 +688,26 @@ def init_git_repository(project_dir: Path) -> bool:
629688 check = True ,
630689 )
631690
691+ # Only inject a placeholder identity when the user has none configured;
692+ # otherwise the user's real git identity should author their own
693+ # project's initial commit.
694+ commit_env = None
695+ if not _has_git_identity (project_dir ):
696+ commit_env = {
697+ ** os .environ ,
698+ "GIT_AUTHOR_NAME" : "quickforge" ,
699+ "GIT_AUTHOR_EMAIL" : "quickforge@example.com" ,
700+ "GIT_COMMITTER_NAME" : "quickforge" ,
701+ "GIT_COMMITTER_EMAIL" : "quickforge@example.com" ,
702+ }
703+
632704 # Create initial commit
633705 subprocess .run (
634706 ["git" , "commit" , "-m" , "Initial commit (generated by quickforge)" ],
635707 cwd = project_dir ,
636708 capture_output = True ,
637709 check = True ,
638- env = {
639- ** os .environ ,
640- "GIT_AUTHOR_NAME" : "quickforge" ,
641- "GIT_AUTHOR_EMAIL" : "quickforge@example.com" ,
642- "GIT_COMMITTER_NAME" : "quickforge" ,
643- "GIT_COMMITTER_EMAIL" : "quickforge@example.com" ,
644- },
710+ env = commit_env ,
645711 )
646712
647713 return True
@@ -650,6 +716,35 @@ def init_git_repository(project_dir: Path) -> bool:
650716 return False
651717
652718
719+ def _has_git_identity (project_dir : Path ) -> bool :
720+ """
721+ Return True if git has both a user name and email configured.
722+
723+ Checks the effective configuration (which includes global and local
724+ config) so a generated project inherits the developer's real identity
725+ when one exists.
726+ """
727+ try :
728+ name = subprocess .run (
729+ ["git" , "config" , "user.name" ],
730+ cwd = project_dir ,
731+ capture_output = True ,
732+ text = True ,
733+ check = False ,
734+ )
735+ email = subprocess .run (
736+ ["git" , "config" , "user.email" ],
737+ cwd = project_dir ,
738+ capture_output = True ,
739+ text = True ,
740+ check = False ,
741+ )
742+ except FileNotFoundError :
743+ return False
744+
745+ return bool (name .stdout .strip ()) and bool (email .stdout .strip ())
746+
747+
653748# =============================================================================
654749# Post-Creation Validation
655750# =============================================================================
@@ -996,11 +1091,26 @@ def load_project_config(path: Path) -> ProjectConfig:
9961091 # Extract description
9971092 description = project .get ("description" , "A Python project" )
9981093
999- # Detect project type
1000- project_type = ProjectType .LIBRARY
1094+ # Detect project type using the strongest available signal.
10011095 scripts = project .get ("scripts" , {})
1096+ dependencies = project .get ("dependencies" , [])
1097+ dep_names = {
1098+ re .split (r"[<>=!~ \[]" , str (dep ).lower (), maxsplit = 1 )[0 ].strip ()
1099+ for dep in dependencies
1100+ }
1101+ api_frameworks = {"fastapi" , "flask" , "uvicorn" , "starlette" , "django" }
1102+
10021103 if scripts :
10031104 project_type = ProjectType .CLI
1105+ elif dep_names & api_frameworks :
1106+ project_type = ProjectType .API
1107+ elif (path / "src" ).is_dir ():
1108+ project_type = ProjectType .LIBRARY
1109+ elif (path / name .replace ("-" , "_" )).is_dir ():
1110+ # Flat layout: a top-level package directory with no src/.
1111+ project_type = ProjectType .APP
1112+ else :
1113+ project_type = ProjectType .LIBRARY
10041114
10051115 # Extract Python version
10061116 requires_python = project .get ("requires-python" , ">=3.12" )
0 commit comments