Skip to content

Commit 08078bf

Browse files
Technical-1claudehappy-otter
committed
Fix 9 investigation findings; bump to 0.1.1
Resolves bugs found during repository investigation: - Wire up LICENSE templates for all offered licenses (Apache-2.0, GPL-3.0, BSD-3-Clause, Unlicense, Proprietary); previously only MIT rendered a file - Escape free-text (description, author) in generated TOML/Python via new toml_escape/py_escape/fstring_escape Jinja filters; add github_slug for URLs - Emit valid PyPI trove classifiers and SPDX ids per license (License.classifier property; LicenseRef-Proprietary for the non-SPDX Proprietary case) - Detect .yaml (not just .yml) GitHub Actions workflows in the auditor - Detect API/app project types in load_project_config, not just CLI/library - Build output paths with pathlib joins instead of str.format on Path - Warn instead of silently dropping -r/-e lines in requirements.txt migration - Handle Poetry's table-form python requirement without AttributeError - Respect a configured git identity for the initial commit; only fall back to the placeholder identity when none is set Adds tests/test_fixes.py (25 regression tests). Full suite: 196 passing. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent f6f79b2 commit 08078bf

18 files changed

Lines changed: 1238 additions & 48 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
[project]
1212
name = "quickforge"
13-
version = "0.1.0"
13+
version = "0.1.1"
1414
description = "Modern Python project bootstrapper with 2025's best toolchain"
1515
readme = "README.md"
1616
license = "MIT"

src/quickforge/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
# =============================================================================
5454
# Package Metadata
5555
# =============================================================================
56-
__version__ = "0.1.0"
56+
__version__ = "0.1.1"
5757
__author__ = "Technical-1"
5858
__email__ = "jacobkanfer8@gmail.com"
5959
__license__ = "MIT"

src/quickforge/auditor.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,11 +453,12 @@ def detect_ci(path: Path) -> tuple[str | None, dict[str, str]]:
453453
tuple[str | None, dict[str, str]]
454454
Tuple of (ci_name, extra_info).
455455
"""
456-
# GitHub Actions
456+
# GitHub Actions (workflows may use either .yml or .yaml)
457457
gh_workflows = path / ".github" / "workflows"
458-
if gh_workflows.exists() and any(gh_workflows.glob("*.yml")):
459-
workflows = list(gh_workflows.glob("*.yml"))
460-
return "github-actions", {"workflows": str(len(workflows))}
458+
if gh_workflows.exists():
459+
workflows = list(gh_workflows.glob("*.yml")) + list(gh_workflows.glob("*.yaml"))
460+
if workflows:
461+
return "github-actions", {"workflows": str(len(workflows))}
461462

462463
# GitLab CI
463464
if (path / ".gitlab-ci.yml").exists():

src/quickforge/generator.py

Lines changed: 128 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
from __future__ import annotations
6060

6161
import os
62+
import re
6263
import shutil
6364
import subprocess
6465
from dataclasses import dataclass, field
@@ -132,10 +133,15 @@
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.
136138
LICENSE_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

422481
def 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")

src/quickforge/models.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,47 @@ def spdx_id(self) -> str:
193193
SPDX identifiers are standardized short names for licenses
194194
that tools can parse unambiguously.
195195
196+
For most licenses the enum value already is the SPDX id. The
197+
sole exception is ``Proprietary``, which is not a valid SPDX
198+
expression; per the SPDX spec, custom licenses are referenced
199+
with a ``LicenseRef-`` prefix.
200+
196201
Returns
197202
-------
198203
str
199-
The SPDX identifier (same as enum value for most).
204+
A valid SPDX expression.
200205
"""
206+
if self is License.PROPRIETARY:
207+
return "LicenseRef-Proprietary"
201208
return self.value
202209

210+
@property
211+
def classifier(self) -> str:
212+
"""
213+
The PyPI trove classifier for this license.
214+
215+
PyPI uses its own controlled vocabulary for license classifiers,
216+
which does *not* match SPDX identifiers (e.g. SPDX ``Apache-2.0``
217+
maps to the classifier ``Apache Software License``). Emitting the
218+
SPDX id directly produces classifiers PyPI rejects.
219+
220+
Returns
221+
-------
222+
str
223+
A valid ``License ::`` trove classifier.
224+
"""
225+
classifiers = {
226+
License.MIT: "License :: OSI Approved :: MIT License",
227+
License.APACHE2: "License :: OSI Approved :: Apache Software License",
228+
License.GPL3: (
229+
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)"
230+
),
231+
License.BSD3: "License :: OSI Approved :: BSD License",
232+
License.UNLICENSE: "License :: Public Domain",
233+
License.PROPRIETARY: "License :: Other/Proprietary License",
234+
}
235+
return classifiers[self]
236+
203237

204238
class TypeCheckingMode(str, Enum):
205239
"""
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
BSD 3-Clause License
2+
3+
Copyright (c) {{ year }}, {{ config.author.name }}
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions are met:
7+
8+
1. Redistributions of source code must retain the above copyright notice, this
9+
list of conditions and the following disclaimer.
10+
11+
2. Redistributions in binary form must reproduce the above copyright notice,
12+
this list of conditions and the following disclaimer in the documentation
13+
and/or other materials provided with the distribution.
14+
15+
3. Neither the name of the copyright holder nor the names of its
16+
contributors may be used to endorse or promote products derived from
17+
this software without specific prior written permission.
18+
19+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

0 commit comments

Comments
 (0)