itw-python-builder 0.2.9__tar.gz → 0.2.12__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/PKG-INFO +1 -1
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/task_utils.py +38 -12
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/tasks.py +103 -17
- itw_python_builder-0.2.12/itw_python_builder/templates/task_template.md +27 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/PKG-INFO +1 -1
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/SOURCES.txt +2 -1
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/pyproject.toml +2 -2
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/LICENSE +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/README.md +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/.pylintrc +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/__init__.py +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/_pyruntime/sitecustomize.py +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/cli.py +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/notify.py +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/ssr_tasks.py +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/templates/new_version_email.html +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/templates/sitemap.routes.ts +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/utils.py +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/version.py +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/dependency_links.txt +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/entry_points.txt +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/requires.txt +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/top_level.txt +0 -0
- {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/setup.cfg +0 -0
|
@@ -141,10 +141,37 @@ def _strip_milestone_wrap(raw: str) -> str:
|
|
|
141
141
|
return raw
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
_CRED_KEYS = ('glab_host', 'glab_username', 'glab_token')
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def extract_glab_credentials(file_path: str) -> dict:
|
|
148
|
+
"""Scan md file for /glab_host, /glab_username, /glab_token top-level directives."""
|
|
149
|
+
with open(file_path, 'r', encoding='utf-8') as fp:
|
|
150
|
+
content = fp.read().replace('\r\n', '\n')
|
|
151
|
+
creds = {k: None for k in _CRED_KEYS}
|
|
152
|
+
for line in content.splitlines():
|
|
153
|
+
stripped = line.strip()
|
|
154
|
+
for key in _CRED_KEYS:
|
|
155
|
+
m = re.match(rf'^/{key}\s+(\S+)\s*$', stripped, re.IGNORECASE)
|
|
156
|
+
if m:
|
|
157
|
+
creds[key] = m.group(1)
|
|
158
|
+
break
|
|
159
|
+
return creds
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def strip_glab_credentials_lines(content: str) -> str:
|
|
163
|
+
"""Remove /glab_* lines so they don't leak into issue bodies."""
|
|
164
|
+
pattern = re.compile(
|
|
165
|
+
rf'(?im)^\s*/(?:{"|".join(_CRED_KEYS)})\s+\S+\s*$\n?'
|
|
166
|
+
)
|
|
167
|
+
return pattern.sub('', content)
|
|
168
|
+
|
|
169
|
+
|
|
144
170
|
def parse_md_issues(file_path: str) -> list:
|
|
145
171
|
"""Parse a markdown file into structured issue dicts separated by '==='."""
|
|
146
172
|
with open(file_path, 'r', encoding='utf-8') as fp:
|
|
147
173
|
content = fp.read().replace('\r\n', '\n')
|
|
174
|
+
content = strip_glab_credentials_lines(content)
|
|
148
175
|
blocks = re.split(r'(?m)^\s*===\s*$', content)
|
|
149
176
|
issues = []
|
|
150
177
|
for idx, block in enumerate(blocks, start=1):
|
|
@@ -171,18 +198,7 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
|
|
|
171
198
|
'but no closing "## Comment" tag.'
|
|
172
199
|
)
|
|
173
200
|
|
|
174
|
-
|
|
175
|
-
title = None
|
|
176
|
-
body_start = 0
|
|
177
|
-
for i, line in enumerate(lines):
|
|
178
|
-
if line.strip():
|
|
179
|
-
title = line.lstrip('#').strip()
|
|
180
|
-
body_start = i + 1
|
|
181
|
-
break
|
|
182
|
-
if not title:
|
|
183
|
-
raise RuntimeError(f'Block {idx} in {file_path}: missing title (first non-empty line).')
|
|
184
|
-
|
|
185
|
-
remaining = '\n'.join(lines[body_start:])
|
|
201
|
+
remaining = block
|
|
186
202
|
|
|
187
203
|
comment_text = None
|
|
188
204
|
if len(cm_headers) >= 2:
|
|
@@ -198,6 +214,7 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
|
|
|
198
214
|
acceptance_text = m.group(1).strip()
|
|
199
215
|
remaining = _RE_AC_BLOCK.sub('', remaining, count=1)
|
|
200
216
|
|
|
217
|
+
title = None
|
|
201
218
|
repo = None
|
|
202
219
|
milestone = None
|
|
203
220
|
milestone_start = None
|
|
@@ -210,6 +227,10 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
|
|
|
210
227
|
for line in remaining.splitlines():
|
|
211
228
|
stripped = line.strip()
|
|
212
229
|
|
|
230
|
+
m = re.match(r'^/title\s+(.+)$', stripped, re.IGNORECASE)
|
|
231
|
+
if m:
|
|
232
|
+
title = m.group(1).strip()
|
|
233
|
+
continue
|
|
213
234
|
m = re.match(r'^/repo\s+(\S+)\s*$', stripped, re.IGNORECASE)
|
|
214
235
|
if m:
|
|
215
236
|
repo = m.group(1)
|
|
@@ -238,6 +259,11 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
|
|
|
238
259
|
|
|
239
260
|
body_lines.append(line)
|
|
240
261
|
|
|
262
|
+
if not title:
|
|
263
|
+
raise RuntimeError(
|
|
264
|
+
f'Block {idx} in {file_path}: missing title. Add a "/title <text>" line to the block.'
|
|
265
|
+
)
|
|
266
|
+
|
|
241
267
|
body = '\n'.join(body_lines).strip()
|
|
242
268
|
return {
|
|
243
269
|
'title': title,
|
|
@@ -34,6 +34,7 @@ from itw_python_builder.task_utils import (
|
|
|
34
34
|
resolve_pm_repo,
|
|
35
35
|
create_gitlab_issue,
|
|
36
36
|
parse_md_issues,
|
|
37
|
+
extract_glab_credentials,
|
|
37
38
|
build_issue_description,
|
|
38
39
|
get_project_by_path,
|
|
39
40
|
find_project_milestone,
|
|
@@ -46,6 +47,7 @@ from itw_python_builder.task_utils import (
|
|
|
46
47
|
from itw_python_builder.version import Version
|
|
47
48
|
|
|
48
49
|
PYLINTRC = Path(__file__).parent / ".pylintrc"
|
|
50
|
+
TASK_TEMPLATE_PATH = Path(__file__).parent / "templates" / "task_template.md"
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
def _prompt_and_store_token(ctx: Context, username: str = None) -> str:
|
|
@@ -122,6 +124,17 @@ def logout(ctx: Context):
|
|
|
122
124
|
os.environ.pop('GITLAB_USERNAME', None)
|
|
123
125
|
|
|
124
126
|
|
|
127
|
+
@task(name='task-init')
|
|
128
|
+
def task_init(ctx: Context) -> None:
|
|
129
|
+
"""Create a TASK.md template with every /directive and section supported by `itw task`."""
|
|
130
|
+
dest = os.path.join(os.getcwd(), 'TASK.md')
|
|
131
|
+
if os.path.exists(dest):
|
|
132
|
+
log_red('TASK.md already exists in this directory — refusing to overwrite.')
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
shutil.copyfile(TASK_TEMPLATE_PATH, dest)
|
|
135
|
+
log_green(f'Created TASK.md in {os.getcwd()}')
|
|
136
|
+
|
|
137
|
+
|
|
125
138
|
@task(name='task')
|
|
126
139
|
def create_task(ctx: Context, file=None):
|
|
127
140
|
"""Create GitLab issues in the group's _pm repo from a markdown file (--file path.md)."""
|
|
@@ -131,20 +144,38 @@ def create_task(ctx: Context, file=None):
|
|
|
131
144
|
raise RuntimeError(f'File must be .md, got {file!r}.')
|
|
132
145
|
if not os.path.isfile(file):
|
|
133
146
|
raise RuntimeError(f'File not found: {file}')
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
|
|
148
|
+
creds = extract_glab_credentials(file)
|
|
149
|
+
provided = [k for k, v in creds.items() if v]
|
|
150
|
+
if provided and len(provided) != 3:
|
|
151
|
+
missing = [k for k, v in creds.items() if not v]
|
|
152
|
+
log_red(
|
|
153
|
+
f'Partial GitLab credentials in {file}: missing {", ".join("/" + m for m in missing)}. '
|
|
154
|
+
'Provide all of /glab_host, /glab_username, /glab_token or none.'
|
|
155
|
+
)
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
|
|
158
|
+
if provided:
|
|
159
|
+
host = creds['glab_host']
|
|
160
|
+
token = creds['glab_token']
|
|
161
|
+
api_host = _GITLAB_API_HOST_MAP.get(host, host)
|
|
162
|
+
project_path = None
|
|
163
|
+
log_info(f'Using {host} and given credentials to authenticate in GitLab')
|
|
164
|
+
else:
|
|
165
|
+
if not os.path.isdir(os.path.join(os.getcwd(), '.git')):
|
|
166
|
+
raise RuntimeError('Not a git repository (no .git folder here).')
|
|
167
|
+
log_green('Using .git in current directory to authenticate in GitLab')
|
|
168
|
+
token = ensure_gitlab_token(ctx)
|
|
169
|
+
host, project_path = _parse_gitlab_remote(ctx)
|
|
170
|
+
api_host = _GITLAB_API_HOST_MAP.get(host, host)
|
|
136
171
|
|
|
137
172
|
try:
|
|
138
173
|
issues = parse_md_issues(file)
|
|
139
174
|
except RuntimeError as exc:
|
|
140
175
|
log_red(str(exc))
|
|
141
|
-
|
|
176
|
+
sys.exit(1)
|
|
142
177
|
print(f'[itw] Parsed {len(issues)} issue(s) from {file}.')
|
|
143
178
|
|
|
144
|
-
token = ensure_gitlab_token(ctx)
|
|
145
|
-
host, project_path = _parse_gitlab_remote(ctx)
|
|
146
|
-
api_host = _GITLAB_API_HOST_MAP.get(host, host)
|
|
147
|
-
|
|
148
179
|
for idx, issue in enumerate(issues, start=1):
|
|
149
180
|
print(f'\n[itw] Issue {idx}/{len(issues)}: {issue["title"]}')
|
|
150
181
|
try:
|
|
@@ -199,6 +230,11 @@ def _resolve_target_project(api_host: str, project_path: str, issue: dict, token
|
|
|
199
230
|
log_info(f'Using {issue["repo"]} repo')
|
|
200
231
|
return get_project_by_path(api_host, issue['repo'], token)
|
|
201
232
|
|
|
233
|
+
if not project_path:
|
|
234
|
+
raise RuntimeError(
|
|
235
|
+
'No /repo defined and no .git directory to auto-detect from. '
|
|
236
|
+
'Add a /repo directive to the block or run inside a git repo.'
|
|
237
|
+
)
|
|
202
238
|
log_green(f'Using current git repo {project_path}')
|
|
203
239
|
candidates = resolve_pm_repo(api_host, project_path, token)
|
|
204
240
|
if not candidates:
|
|
@@ -433,12 +469,14 @@ def taginit(ctx: Context) -> None:
|
|
|
433
469
|
|
|
434
470
|
|
|
435
471
|
def _ensure_unique_tag(ctx: Context, version: Version) -> None:
|
|
436
|
-
"""If the tag already exists, auto-increment RC
|
|
472
|
+
"""If the tag already exists, auto-increment RC (RC tags) or patch (release tags)."""
|
|
437
473
|
while ctx.run(f'git rev-parse -q --verify "refs/tags/{version}"', warn=True, hide=True).ok:
|
|
438
474
|
if version.release_candidate == 0:
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
475
|
+
print(f"[itw] Tag {version} already exists, auto-incrementing patch...")
|
|
476
|
+
version.increment_patch(release=True)
|
|
477
|
+
else:
|
|
478
|
+
print(f"[itw] Tag {version} already exists, auto-incrementing release candidate...")
|
|
479
|
+
version.increment_release_candidate()
|
|
442
480
|
|
|
443
481
|
|
|
444
482
|
@task
|
|
@@ -527,11 +565,52 @@ def release(ctx: Context, skip_pipeline=False, ssr=False) -> None:
|
|
|
527
565
|
propagate_changelog(ctx)
|
|
528
566
|
|
|
529
567
|
|
|
568
|
+
def _list_spec_files() -> list:
|
|
569
|
+
root = Path(os.getcwd())
|
|
570
|
+
return [
|
|
571
|
+
p.relative_to(root)
|
|
572
|
+
for p in root.glob('**/*.spec.ts')
|
|
573
|
+
if 'node_modules' not in p.parts
|
|
574
|
+
]
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _resolve_spec_include(target: str) -> str:
|
|
578
|
+
if '/' in target or '*' in target:
|
|
579
|
+
return target
|
|
580
|
+
if target.endswith('.spec.ts'):
|
|
581
|
+
return f'**/{target}'
|
|
582
|
+
|
|
583
|
+
name = target.removesuffix('.ts')
|
|
584
|
+
specs = _list_spec_files()
|
|
585
|
+
if any(name in spec.parts[:-1] for spec in specs):
|
|
586
|
+
return f'**/{name}/**/*.spec.ts'
|
|
587
|
+
if any(spec.name == f'{name}.spec.ts' for spec in specs):
|
|
588
|
+
return f'**/{name}.spec.ts'
|
|
589
|
+
if '.' in name:
|
|
590
|
+
head, rest = name.split('.', 1)
|
|
591
|
+
if any(head in spec.parts[:-1] and spec.name == f'{rest}.spec.ts' for spec in specs):
|
|
592
|
+
return f'**/{head}/**/{rest}.spec.ts'
|
|
593
|
+
|
|
594
|
+
raise RuntimeError(
|
|
595
|
+
f'No spec files match --target={target!r}. Expected a directory name '
|
|
596
|
+
f'(e.g. --target=core), a spec name (e.g. --target=core.service), or a path/glob.'
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _resolve_django_label(target: str) -> str:
|
|
601
|
+
return target.strip().removesuffix('.py').replace('/', '.').strip('.')
|
|
602
|
+
|
|
603
|
+
|
|
530
604
|
@task
|
|
531
|
-
def test(ctx: Context, settings='backend.test_settings', warn=True):
|
|
532
|
-
"""Run tests with coverage."""
|
|
605
|
+
def test(ctx: Context, settings='backend.test_settings', target=None, warn=True):
|
|
606
|
+
"""Run tests with coverage. --target=app or --target=app.test_file runs a subset."""
|
|
533
607
|
if detect_project_type() == 'frontend':
|
|
534
|
-
|
|
608
|
+
cmd = 'npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox'
|
|
609
|
+
if target:
|
|
610
|
+
include = _resolve_spec_include(target)
|
|
611
|
+
print(f'[itw] Limiting run to specs matching {include}')
|
|
612
|
+
cmd += f' --include="{include}"'
|
|
613
|
+
ctx.run(cmd, warn=warn)
|
|
535
614
|
print("✓ Tests completed")
|
|
536
615
|
return
|
|
537
616
|
detect_and_activate_venv()
|
|
@@ -540,16 +619,23 @@ def test(ctx: Context, settings='backend.test_settings', warn=True):
|
|
|
540
619
|
patch_dir = str(Path(__file__).parent / '_pyruntime')
|
|
541
620
|
existing_pp = os.environ.get('PYTHONPATH', '')
|
|
542
621
|
injected_pp = f'{patch_dir}:{existing_pp}' if existing_pp else patch_dir
|
|
622
|
+
label = ''
|
|
623
|
+
if target:
|
|
624
|
+
label = f' {_resolve_django_label(target)}'
|
|
625
|
+
print(f'[itw] Limiting run to{label}')
|
|
543
626
|
try:
|
|
544
627
|
ctx.run(
|
|
545
|
-
f'python -m coverage run manage.py test --settings={settings} --noinput -v 2',
|
|
628
|
+
f'python -m coverage run manage.py test{label} --settings={settings} --noinput -v 2',
|
|
546
629
|
warn=warn,
|
|
547
630
|
env={'PYTHONPATH': injected_pp},
|
|
548
631
|
)
|
|
549
632
|
finally:
|
|
550
633
|
terminate_stale_test_db_sessions(ctx, settings)
|
|
551
|
-
|
|
552
|
-
|
|
634
|
+
if target:
|
|
635
|
+
ctx.run('python -m coverage report -m --fail-under=0')
|
|
636
|
+
else:
|
|
637
|
+
ctx.run('python -m coverage report -m')
|
|
638
|
+
ctx.run('python -m coverage xml -o coverage.xml')
|
|
553
639
|
print("✓ Tests completed")
|
|
554
640
|
|
|
555
641
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!-- OPTIONAL -->
|
|
2
|
+
/glab_host <gitlab-host>
|
|
3
|
+
/glab_username <gitlab-username>
|
|
4
|
+
/glab_token <personal-access-token>
|
|
5
|
+
|
|
6
|
+
<!-- REQUIRED -->
|
|
7
|
+
/title <issue title>
|
|
8
|
+
|
|
9
|
+
<!-- OPTIONAL -->
|
|
10
|
+
/repo namespace/project
|
|
11
|
+
/milestone %"Milestone name"
|
|
12
|
+
/milestone-start YYYY-MM-DD
|
|
13
|
+
/milestone-end YYYY-MM-DD
|
|
14
|
+
/assignee @username
|
|
15
|
+
/estimate 2h
|
|
16
|
+
/due YYYY-MM-DD
|
|
17
|
+
|
|
18
|
+
<!-- OPTIONAL -->
|
|
19
|
+
## Acceptance Criteria
|
|
20
|
+
- [ ]
|
|
21
|
+
- [ ]
|
|
22
|
+
## Acceptance Criteria
|
|
23
|
+
|
|
24
|
+
<!-- OPTIONAL -->
|
|
25
|
+
## Comment
|
|
26
|
+
|
|
27
|
+
## Comment
|
{itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/SOURCES.txt
RENAMED
|
@@ -19,4 +19,5 @@ itw_python_builder.egg-info/top_level.txt
|
|
|
19
19
|
itw_python_builder/_pyruntime/sitecustomize.py
|
|
20
20
|
itw_python_builder/templates/new_version_email.html
|
|
21
21
|
itw_python_builder/templates/server.sitemap.snippet.ts
|
|
22
|
-
itw_python_builder/templates/sitemap.routes.ts
|
|
22
|
+
itw_python_builder/templates/sitemap.routes.ts
|
|
23
|
+
itw_python_builder/templates/task_template.md
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "itw_python_builder"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.12"
|
|
8
8
|
description = "Standardized Django deployment pipeline with Docker, testing, and SonarQube integration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -41,7 +41,7 @@ Issues = "https://git.it-works.io/"
|
|
|
41
41
|
include-package-data = true
|
|
42
42
|
|
|
43
43
|
[tool.setuptools.package-data]
|
|
44
|
-
itw_python_builder = [".pylintrc", "templates/*.ts", "templates/*.html", "_pyruntime/*.py"]
|
|
44
|
+
itw_python_builder = [".pylintrc", "templates/*.ts", "templates/*.html", "templates/*.md", "_pyruntime/*.py"]
|
|
45
45
|
|
|
46
46
|
[project.scripts]
|
|
47
47
|
itw = "itw_python_builder.cli:main"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/requires.txt
RENAMED
|
File without changes
|
{itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|