Skip to content

Commit d64187a

Browse files
authored
Optimize init script and update dependencies (#139)
1 parent a1f6ccf commit d64187a

9 files changed

Lines changed: 389 additions & 143 deletions

File tree

‎.github/workflows/check.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ on:
44
workflow_call:
55
push:
66

7+
permissions:
8+
contents: read
9+
710
jobs:
811
check:
912
runs-on: ubuntu-latest

‎.jules/bolt.md‎

Lines changed: 0 additions & 11 deletions
This file was deleted.

‎.jules/sentinel.md‎

Lines changed: 0 additions & 3 deletions
This file was deleted.

‎.pre-commit-config.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ repos:
1515
args: [--branch, main]
1616

1717
- repo: https://github.com/astral-sh/ruff-pre-commit
18-
rev: v0.16.2
18+
rev: v0.16.4
1919
hooks:
2020
- id: ruff
2121
args: [--fix]

‎AGENTS.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,9 @@ Every field in a Pydantic model or pydantic-settings class must be documented us
153153
from uuid import uuid4
154154
from pydantic import BaseModel, Field
155155

156+
156157
class Item(BaseModel, populate_by_name=True, alias_generator=to_camel):
157-
id: str = Field(description="Unique item identifier.", default_factory=lambda:str(uuid4()))
158+
id: str = Field(description="Unique item identifier.", default_factory=lambda: str(uuid4()))
158159
name: str = Field(description="Human-readable item name.")
159160
```
160161

@@ -167,6 +168,7 @@ from uuid import uuid4
167168
from pydantic import BaseModel, Field
168169
from pydantic.alias_generators import to_camel
169170

171+
170172
class Item(BaseModel, populate_by_name=True, alias_generator=to_camel):
171173
item_id: str = Field(description="Unique item identifier.", default_factory=str(uuid4()))
172174
# Accepts {"itemId": "..."} from JSON; attribute is item.item_id
@@ -181,8 +183,11 @@ Do not use `model_config = ConfigDict(...)` or `model_config = SettingsConfigDic
181183
```python
182184
# Good
183185
class Item(BaseModel, extra="allow", populate_by_name=True, alias_generator=to_camel): ...
186+
187+
184188
class Settings(BaseSettings, case_sensitive=False): ...
185189

190+
186191
# Bad
187192
class Item(BaseModel):
188193
model_config = ConfigDict(extra="allow")

‎scripts/init.py‎

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,28 @@
66

77
from click import ClickException, UsageError, command, confirm, echo, option, secho
88

9+
# --- PERFORMANCE OPTIMIZATION: Pre-compiled Regular Expressions ---
10+
# Pre-compiling regular expressions at module scope avoids repeated compilation overhead
11+
# during input validation and file replacements, yielding an O(1) matching performance boost.
12+
13+
# Validation regular expressions
14+
RE_VALID_PROJECT_NAME = re.compile(r"^[a-zA-Z0-9_-]+$")
15+
RE_VALID_GITHUB_USERNAME = re.compile(r"^[a-zA-Z0-9-]+$")
16+
RE_VALID_EMAIL = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
17+
18+
# Replacement regular expressions (compiled with re.MULTILINE as they match lines inside files)
19+
RE_APP_REF = re.compile(r"^::: project\.app", flags=re.MULTILINE)
20+
RE_REPO_NAME = re.compile(r"^repo_name: .*", flags=re.MULTILINE)
21+
RE_REPO_URL = re.compile(r"^repo_url: .*", flags=re.MULTILINE)
22+
RE_PYPROJECT_SOURCE = re.compile(r"^source = \[.*\]", flags=re.MULTILINE)
23+
RE_PYPROJECT_APP = re.compile(r'^app = "project\.app:main"', flags=re.MULTILINE)
24+
RE_PYPROJECT_NAME = re.compile(r'^name = ".*"', flags=re.MULTILINE)
25+
RE_PYPROJECT_DESC = re.compile(r'^description = ".*"', flags=re.MULTILINE)
26+
RE_PYPROJECT_AUTHORS = re.compile(r"^authors = \[.*\]", flags=re.MULTILINE)
27+
RE_README_HEADER = re.compile(r"^# .*", flags=re.MULTILINE)
28+
RE_CODEOWNERS = re.compile(r"@.*", flags=re.MULTILINE)
29+
RE_FUNDING_GITHUB = re.compile(r"^github: \[.*\]", flags=re.MULTILINE)
30+
931
_git_config_cache: dict[str, str] = {}
1032
_git_config_loaded = False
1133
GIT_BIN = "/usr/bin/git"
@@ -45,7 +67,7 @@ def _get_git_config(key: str) -> str:
4567
def _get_default_github() -> str:
4668
# Try git config first
4769
username = _get_git_config("github.user") or _get_git_config("user.name")
48-
if username and re.match(r"^[a-zA-Z0-9-]+$", username):
70+
if username and RE_VALID_GITHUB_USERNAME.match(username):
4971
return username
5072

5173
# Try to extract from remote URL
@@ -80,15 +102,15 @@ def _validate_inputs(name: str, description: str, author: str, email: str, githu
80102
if label != "description" and '"' in value:
81103
raise UsageError(f"Invalid {label}: double quotes are not allowed.")
82104

83-
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
105+
if not RE_VALID_PROJECT_NAME.match(name):
84106
raise UsageError(
85107
f"Invalid project name '{name}'. Only alphanumeric characters, dashes, and underscores are allowed."
86108
)
87109

88-
if not re.match(r"^[a-zA-Z0-9-]+$", github):
110+
if not RE_VALID_GITHUB_USERNAME.match(github):
89111
raise UsageError(f"Invalid GitHub username '{github}'. Only alphanumeric characters and dashes are allowed.")
90112

91-
if not re.match(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", email):
113+
if not RE_VALID_EMAIL.match(email):
92114
raise UsageError(f"Invalid email address '{email}'.")
93115

94116

@@ -101,7 +123,7 @@ def toml_escape(s: str) -> str:
101123
escaped_author = toml_escape(author)
102124
escaped_email = toml_escape(email)
103125

104-
def update_file(filepath: str, file_replacements: list[tuple[str, str]]):
126+
def update_file(filepath: str, file_replacements: list[tuple[re.Pattern, str]]):
105127
path = Path(filepath)
106128
if not path.exists():
107129
secho(f" Warning: File {filepath} not found, skipping. ⚠️", fg="yellow")
@@ -111,33 +133,33 @@ def update_file(filepath: str, file_replacements: list[tuple[str, str]]):
111133
new_content = content
112134
for pattern, replacement in file_replacements:
113135
# Use a lambda for replacement to avoid regex backreference injection
114-
new_content = re.sub(pattern, lambda _, r=replacement: r, new_content, flags=re.MULTILINE)
136+
new_content = pattern.sub(lambda _, r=replacement: r, new_content)
115137

116138
if new_content != content:
117139
path.write_text(new_content)
118140
secho(f" Updated {filepath} ✅", fg="blue")
119141

120-
update_file("docs/reference/app.md", [(r"^::: project\.app", f"::: {source}.app")])
142+
update_file("docs/reference/app.md", [(RE_APP_REF, f"::: {source}.app")])
121143
update_file(
122144
"mkdocs.yml",
123145
[
124-
(r"^repo_name: .*", f"repo_name: {github}/{name}"),
125-
(r"^repo_url: .*", f"repo_url: https://github.com/{github}/{name}"),
146+
(RE_REPO_NAME, f"repo_name: {github}/{name}"),
147+
(RE_REPO_URL, f"repo_url: https://github.com/{github}/{name}"),
126148
],
127149
)
128150
update_file(
129151
"pyproject.toml",
130152
[
131-
(r"^source = \[.*\]", f'source = ["{source}"]'),
132-
(r'^app = "project\.app:main"', f'app = "{source}.app:main"'),
133-
(r'^name = ".*"', f'name = "{source}"'),
134-
(r'^description = ".*"', f'description = "{escaped_description}"'),
135-
(r"^authors = \[.*\]", f'authors = ["{escaped_author} <{escaped_email}>"]'),
153+
(RE_PYPROJECT_SOURCE, f'source = ["{source}"]'),
154+
(RE_PYPROJECT_APP, f'app = "{source}.app:main"'),
155+
(RE_PYPROJECT_NAME, f'name = "{source}"'),
156+
(RE_PYPROJECT_DESC, f'description = "{escaped_description}"'),
157+
(RE_PYPROJECT_AUTHORS, f'authors = [{{name = "{escaped_author}", email = "{escaped_email}"}}]'),
136158
],
137159
)
138-
update_file("docs/README.md", [(r"^# .*", f"# {description}")])
139-
update_file(".github/CODEOWNERS", [(r"@.*", f"@{github}")])
140-
update_file(".github/FUNDING.yml", [(r"^github: \[.*\]", f"github: [{github}]")])
160+
update_file("docs/README.md", [(RE_README_HEADER, f"# {description}")])
161+
update_file(".github/CODEOWNERS", [(RE_CODEOWNERS, f"@{github}")])
162+
update_file(".github/FUNDING.yml", [(RE_FUNDING_GITHUB, f"github: [{github}]")])
141163

142164

143165
@command(context_settings={"help_option_names": ["-h", "--help"]})

‎tests/test_app.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,6 @@ def test_greet_empty_fallback():
6363

6464

6565
if __name__ == "__main__":
66-
from pytest import main
66+
import pytest
6767

68-
main()
68+
pytest.main()

‎tests/test_init.py‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from click import UsageError
5+
6+
import scripts.init as init
7+
8+
9+
def test_validate_inputs():
10+
# Valid inputs should pass without exception
11+
init._validate_inputs(
12+
name="my-project",
13+
description="A cool project",
14+
author="Alice Smith",
15+
email="alice@example.com",
16+
github="alice-smith",
17+
)
18+
19+
# Test length validation (>100 characters)
20+
with pytest.raises(UsageError, match="Invalid name: maximum length is 100 characters"):
21+
init._validate_inputs("a" * 101, "desc", "author", "email@test.com", "gh")
22+
23+
# Test non-printable characters validation
24+
with pytest.raises(UsageError, match="Invalid description: control characters are not allowed"):
25+
init._validate_inputs("name", "desc\x01", "author", "email@test.com", "gh")
26+
27+
# Test double quote validation (not allowed in name/author/email/github)
28+
with pytest.raises(UsageError, match="Invalid author: double quotes are not allowed"):
29+
init._validate_inputs("name", "desc", 'author"with"quotes', "email@test.com", "gh")
30+
31+
# Double quotes are allowed in description
32+
init._validate_inputs("name", 'description with "quotes"', "author", "email@test.com", "gh")
33+
34+
# Test project name format
35+
with pytest.raises(UsageError, match="Invalid project name"):
36+
init._validate_inputs("Invalid_Project_Name!", "desc", "author", "email@test.com", "gh")
37+
38+
# Test github username format
39+
with pytest.raises(UsageError, match="Invalid GitHub username"):
40+
init._validate_inputs("name", "desc", "author", "email@test.com", "invalid_username")
41+
42+
# Test email format
43+
with pytest.raises(UsageError, match="Invalid email address"):
44+
init._validate_inputs("name", "desc", "author", "not-an-email", "gh")
45+
46+
47+
def test_get_git_config(monkeypatch):
48+
# Clear cache first to ensure a clean state
49+
monkeypatch.setattr(init, "_git_config_loaded", False)
50+
monkeypatch.setattr(init, "_git_config_cache", {})
51+
52+
# Mock subprocess.check_output
53+
def mock_check_output(args, **kwargs):
54+
if "config" in args and "--get-regexp" in args:
55+
return "user.name Bob Jones\nuser.email bob@example.com\ngithub.user bobjones\n"
56+
raise ValueError("Unexpected command")
57+
58+
monkeypatch.setattr(init, "check_output", mock_check_output)
59+
60+
assert init._get_git_config("user.name") == "Bob Jones"
61+
assert init._get_git_config("user.email") == "bob@example.com"
62+
assert init._get_git_config("github.user") == "bobjones"
63+
64+
# Uncached custom key should invoke check_output directly
65+
monkeypatch.setattr(init, "check_output", lambda args, **kwargs: "custom-value" if "custom.key" in args else "")
66+
assert init._get_git_config("custom.key") == "custom-value"
67+
68+
69+
def test_get_default_github(monkeypatch):
70+
# Case 1: valid username in git config
71+
monkeypatch.setattr(init, "_git_config_loaded", True)
72+
monkeypatch.setattr(init, "_git_config_cache", {"github.user": "jane-doe"})
73+
assert init._get_default_github() == "jane-doe"
74+
75+
# Case 2: invalid username in git config, fallback to remote URL (HTTPS format)
76+
monkeypatch.setattr(init, "_git_config_cache", {})
77+
78+
def mock_check_output(args, **kwargs):
79+
if "remote" in args and "get-url" in args:
80+
return "https://github.com/some-org/some-repo.git\n"
81+
raise ValueError("Unexpected command")
82+
83+
monkeypatch.setattr(init, "check_output", mock_check_output)
84+
assert init._get_default_github() == "some-org"
85+
86+
# Case 3: invalid username in git config, fallback to remote URL (SSH format)
87+
def mock_check_output_ssh(args, **kwargs):
88+
if "remote" in args and "get-url" in args:
89+
return "git@github.com:ssh-user/some-repo.git\n"
90+
raise ValueError("Unexpected")
91+
92+
monkeypatch.setattr(init, "check_output", mock_check_output_ssh)
93+
assert init._get_default_github() == "ssh-user"
94+
95+
96+
def test_perform_replacements(tmp_path, monkeypatch):
97+
# Change current working directory to tmp_path so the script modifies files there
98+
monkeypatch.chdir(tmp_path)
99+
100+
# Set up mocked files that would be modified by the script
101+
docs_ref = tmp_path / "docs" / "reference"
102+
docs_ref.mkdir(parents=True)
103+
app_md = docs_ref / "app.md"
104+
app_md.write_text("::: project.app\nsome other content")
105+
106+
mkdocs_yml = tmp_path / "mkdocs.yml"
107+
mkdocs_yml.write_text("repo_name: original/repo\nrepo_url: https://github.com/original/repo\n")
108+
109+
pyproject_toml = tmp_path / "pyproject.toml"
110+
pyproject_toml.write_text(
111+
'source = ["project"]\n'
112+
'app = "project.app:main"\n'
113+
'name = "project"\n'
114+
'description = "original description"\n'
115+
'authors = ["Original Author <email>"]\n'
116+
)
117+
118+
docs_readme = tmp_path / "docs" / "README.md"
119+
docs_readme.parent.mkdir(parents=True, exist_ok=True)
120+
docs_readme.write_text("# Old Description\nsome doc content")
121+
122+
github_dir = tmp_path / ".github"
123+
github_dir.mkdir()
124+
codeowners = github_dir / "CODEOWNERS"
125+
codeowners.write_text("@original_owner")
126+
127+
funding_yml = github_dir / "FUNDING.yml"
128+
funding_yml.write_text("github: [original_owner]")
129+
130+
# Run perform replacements
131+
init._perform_replacements(
132+
source="my_new_source",
133+
github="new-github",
134+
name="my-new-name",
135+
description='New "escaped" Description',
136+
author="New Author",
137+
email="new@example.com",
138+
)
139+
140+
# Assertions to verify correct updates
141+
assert "::: my_new_source.app" in app_md.read_text()
142+
assert "repo_name: new-github/my-new-name" in mkdocs_yml.read_text()
143+
assert "repo_url: https://github.com/new-github/my-new-name" in mkdocs_yml.read_text()
144+
145+
pyproject_content = pyproject_toml.read_text()
146+
assert 'source = ["my_new_source"]' in pyproject_content
147+
assert 'app = "my_new_source.app:main"' in pyproject_content
148+
assert 'name = "my_new_source"' in pyproject_content
149+
assert 'description = "New \\"escaped\\" Description"' in pyproject_content
150+
assert 'authors = [{name = "New Author", email = "new@example.com"}]' in pyproject_content
151+
152+
assert '# New "escaped" Description' in docs_readme.read_text()
153+
assert "@new-github" in codeowners.read_text()
154+
assert "github: [new-github]" in funding_yml.read_text()
155+
156+
157+
if __name__ == "__main__":
158+
import pytest
159+
160+
pytest.main()

0 commit comments

Comments
 (0)