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.
Files changed (25) hide show
  1. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/PKG-INFO +1 -1
  2. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/task_utils.py +38 -12
  3. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/tasks.py +103 -17
  4. itw_python_builder-0.2.12/itw_python_builder/templates/task_template.md +27 -0
  5. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/PKG-INFO +1 -1
  6. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/SOURCES.txt +2 -1
  7. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/pyproject.toml +2 -2
  8. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/LICENSE +0 -0
  9. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/README.md +0 -0
  10. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/.pylintrc +0 -0
  11. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/__init__.py +0 -0
  12. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/_pyruntime/sitecustomize.py +0 -0
  13. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/cli.py +0 -0
  14. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/notify.py +0 -0
  15. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/ssr_tasks.py +0 -0
  16. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/templates/new_version_email.html +0 -0
  17. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
  18. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/templates/sitemap.routes.ts +0 -0
  19. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/utils.py +0 -0
  20. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder/version.py +0 -0
  21. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/dependency_links.txt +0 -0
  22. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/entry_points.txt +0 -0
  23. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/requires.txt +0 -0
  24. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/itw_python_builder.egg-info/top_level.txt +0 -0
  25. {itw_python_builder-0.2.9 → itw_python_builder-0.2.12}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.2.9
3
+ Version: 0.2.12
4
4
  Summary: Standardized Django deployment pipeline with Docker, testing, and SonarQube integration
5
5
  Author-email: IT-Works <contact@it-works.io>
6
6
  License: MIT
@@ -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
- lines = block.splitlines()
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
- if not os.path.isdir(os.path.join(os.getcwd(), '.git')):
135
- raise RuntimeError('Not a git repository (no .git folder here).')
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
- raise
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 if it's an RC, or abort if it's a release."""
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
- raise RuntimeError(f"Tag {version} already exists! Aborting before pipeline to prevent overwrite.")
440
- print(f"[itw] Tag {version} already exists, auto-incrementing release candidate...")
441
- version.increment_release_candidate()
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
- ctx.run('npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox', warn=warn)
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
- ctx.run('python -m coverage report -m')
552
- ctx.run('python -m coverage xml -o coverage.xml')
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.2.9
3
+ Version: 0.2.12
4
4
  Summary: Standardized Django deployment pipeline with Docker, testing, and SonarQube integration
5
5
  Author-email: IT-Works <contact@it-works.io>
6
6
  License: MIT
@@ -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.9"
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"