itw-python-builder 0.1.38__tar.gz → 0.1.40__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 (22) hide show
  1. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/PKG-INFO +1 -1
  2. itw_python_builder-0.1.40/itw_python_builder/notify.py +97 -0
  3. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/tasks.py +74 -58
  4. itw_python_builder-0.1.40/itw_python_builder/templates/new_version_email.html +38 -0
  5. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/utils.py +124 -4
  6. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/PKG-INFO +1 -1
  7. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/SOURCES.txt +2 -0
  8. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/pyproject.toml +2 -2
  9. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/LICENSE +0 -0
  10. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/README.md +0 -0
  11. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/.pylintrc +0 -0
  12. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/__init__.py +0 -0
  13. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/cli.py +0 -0
  14. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/ssr_tasks.py +0 -0
  15. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
  16. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/templates/sitemap.routes.ts +0 -0
  17. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/version.py +0 -0
  18. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/dependency_links.txt +0 -0
  19. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/entry_points.txt +0 -0
  20. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/requires.txt +0 -0
  21. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/top_level.txt +0 -0
  22. {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.1.38
3
+ Version: 0.1.40
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
@@ -0,0 +1,97 @@
1
+ import os
2
+ import smtplib
3
+ import ssl
4
+ import sys
5
+ from email.mime.multipart import MIMEMultipart
6
+ from email.mime.text import MIMEText
7
+ from pathlib import Path
8
+
9
+ TEMPLATE_PATH = Path(__file__).parent / 'templates' / 'new_version_email.html'
10
+
11
+
12
+ def _env_getter():
13
+ env_path = Path.cwd() / '.env'
14
+ try:
15
+ from decouple import Config, RepositoryEnv
16
+ if env_path.exists():
17
+ cfg = Config(RepositoryEnv(str(env_path)))
18
+ return lambda key, default=None: cfg(key, default=default) if default is not None else cfg(key, default='')
19
+ except ImportError:
20
+ pass
21
+ return lambda key, default=None: os.environ[key] if default is None else os.environ.get(key, default)
22
+
23
+
24
+ def _read_smtp_config() -> dict:
25
+ get = _env_getter()
26
+ return {
27
+ 'host': get('EMAIL_HOST', 'smtp.gmail.com'),
28
+ 'port': int(get('EMAIL_PORT', '587')),
29
+ 'user': get('EMAIL_HOST_USER'),
30
+ 'password': get('EMAIL_HOST_PASSWORD'),
31
+ 'use_tls': str(get('EMAIL_USE_TLS', 'True')).lower() == 'true',
32
+ }
33
+
34
+
35
+ def _read_team_emails() -> list:
36
+ raw = _env_getter()('EMAIL_RECIPIENTS', '')
37
+ return [addr.strip() for addr in (raw or '').split(',') if addr.strip()]
38
+
39
+
40
+ def _render_template(version: str, commit_message: str) -> str:
41
+ html = TEMPLATE_PATH.read_text(encoding='utf-8')
42
+ safe_commit = (commit_message or '').strip() or '—'
43
+ safe_commit_html = (
44
+ safe_commit
45
+ .replace('&', '&amp;')
46
+ .replace('<', '&lt;')
47
+ .replace('>', '&gt;')
48
+ .replace('\n', '<br>')
49
+ )
50
+ return html.replace('{VERSION}', version).replace('{COMMIT_MESSAGE}', safe_commit_html)
51
+
52
+
53
+ def send_new_version_email(version: str, commit_message: str = '') -> int:
54
+ recipients = _read_team_emails()
55
+ if not recipients:
56
+ print('[notify] EMAIL_RECIPIENTS is empty — skipping email notification.')
57
+ return 0
58
+
59
+ cfg = _read_smtp_config()
60
+ html_body = _render_template(version, commit_message)
61
+
62
+ msg = MIMEMultipart('alternative')
63
+ msg['Subject'] = f'itw-python-builder — Version i ri ({version})'
64
+ msg['From'] = cfg['user']
65
+ msg['To'] = ', '.join(recipients)
66
+ msg.attach(MIMEText(html_body, 'html', 'utf-8'))
67
+
68
+ context = ssl.create_default_context()
69
+ with smtplib.SMTP(cfg['host'], cfg['port']) as smtp:
70
+ if cfg['use_tls']:
71
+ smtp.starttls(context=context)
72
+ smtp.login(cfg['user'], cfg['password'])
73
+ smtp.sendmail(cfg['user'], recipients, msg.as_string())
74
+
75
+ print(f'[notify] Sent release email for {version} to {len(recipients)} recipient(s).')
76
+ return len(recipients)
77
+
78
+
79
+ def _main() -> int:
80
+ if len(sys.argv) < 2:
81
+ print(
82
+ 'Usage: python -m itw_python_builder.notify <version> [<commit_message>]',
83
+ file=sys.stderr,
84
+ )
85
+ return 2
86
+ version = sys.argv[1]
87
+ commit_message = sys.argv[2] if len(sys.argv) >= 3 else ''
88
+ try:
89
+ send_new_version_email(version, commit_message)
90
+ except Exception as exc:
91
+ print(f'[notify] Failed to send release email: {exc}', file=sys.stderr)
92
+ return 1
93
+ return 0
94
+
95
+
96
+ if __name__ == '__main__':
97
+ sys.exit(_main())
@@ -12,6 +12,15 @@ from itw_python_builder.utils import (
12
12
  get_gitlab_username,
13
13
  _parse_gitlab_remote,
14
14
  is_ssr_project,
15
+ install_sigint_guard,
16
+ abort_if_interrupted,
17
+ step,
18
+ safe_remove,
19
+ ensure_gitignored,
20
+ gitignore_touched_this_run,
21
+ load_cached_token,
22
+ save_token_to_cache,
23
+ probe_gitlab_token,
15
24
  )
16
25
  from itw_python_builder.ssr_tasks import * # noqa: F401,F403 — exposes ssr-init
17
26
 
@@ -24,9 +33,8 @@ from pathlib import Path
24
33
  PYLINTRC = Path(__file__).parent / ".pylintrc"
25
34
 
26
35
 
27
- @task
28
- def login(ctx: Context, username=None):
29
- """Capture GitLab token. Backend also logs into the container registry."""
36
+ def _prompt_and_store_token(ctx: Context, username: str = None) -> str:
37
+ """Prompt for the GitLab token, set env vars, save to cache, and do podman login on backend."""
30
38
  if not username:
31
39
  username = get_gitlab_username(ctx)
32
40
  token = getpass.getpass(f'Enter GitLab token for {username}: ')
@@ -34,6 +42,7 @@ def login(ctx: Context, username=None):
34
42
  raise RuntimeError('No token entered; aborting login.')
35
43
  os.environ['GITLAB_TOKEN'] = token
36
44
  os.environ['GITLAB_USERNAME'] = username
45
+ save_token_to_cache(token)
37
46
  if detect_project_type() == 'backend':
38
47
  print(f'Logging in to gitreg.it-works.io:443 as {username}')
39
48
  ctx.run(
@@ -42,6 +51,41 @@ def login(ctx: Context, username=None):
42
51
  )
43
52
  else:
44
53
  print(f'[itw] GitLab token captured for {username} (will be used for package upload).')
54
+ return token
55
+
56
+
57
+ def _gitlab_api_host(ctx: Context) -> str:
58
+ host, _ = _parse_gitlab_remote(ctx)
59
+ return _GITLAB_API_HOST_MAP.get(host, host)
60
+
61
+
62
+ def ensure_gitlab_auth(ctx: Context) -> None:
63
+ """Make sure GITLAB_TOKEN is set and valid."""
64
+ if os.environ.get('GITLAB_TOKEN'):
65
+ return
66
+
67
+ cached = load_cached_token()
68
+ if cached:
69
+ status = probe_gitlab_token(_gitlab_api_host(ctx), cached)
70
+ if status == 'ok':
71
+ os.environ['GITLAB_TOKEN'] = cached
72
+ os.environ.setdefault('GITLAB_USERNAME', get_gitlab_username(ctx))
73
+ print('[itw] Using cached GitLab token.')
74
+ return
75
+ if status == 'unreachable':
76
+ print('[itw] Could not reach GitLab to verify cached token; using it anyway.')
77
+ os.environ['GITLAB_TOKEN'] = cached
78
+ os.environ.setdefault('GITLAB_USERNAME', get_gitlab_username(ctx))
79
+ return
80
+ print('[itw] Cached GitLab token was rejected (401/403). Please log in again.')
81
+
82
+ _prompt_and_store_token(ctx)
83
+
84
+
85
+ @task
86
+ def login(ctx: Context, username=None):
87
+ """Capture GitLab token. Backend also logs into the container registry."""
88
+ _prompt_and_store_token(ctx, username)
45
89
 
46
90
 
47
91
  def build_frontend(ctx: Context, branch: str, ssr: bool = False) -> None:
@@ -96,6 +140,8 @@ def commit_version(ctx: Context, version: Version) -> None:
96
140
  else:
97
141
  ctx.run(f'git add {Version.VERSION_FILE_NAME}')
98
142
  ctx.run('git add CHANGELOG.md')
143
+ if gitignore_touched_this_run():
144
+ ctx.run('git add .gitignore')
99
145
  ctx.run(f'git commit -m \'version update to {version}\'')
100
146
 
101
147
 
@@ -124,21 +170,26 @@ def pushimage(ctx: Context):
124
170
 
125
171
 
126
172
  def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False, ssr: bool = False) -> None:
173
+ install_sigint_guard()
127
174
  project_type = detect_project_type()
128
175
  if not skip_pipeline:
129
- test(ctx)
130
- analyze(ctx)
131
- tag(ctx, version)
176
+ step('tests', test, ctx)
177
+ step('analyze', analyze, ctx)
178
+ step('tag', tag, ctx, version)
132
179
  if project_type == 'backend':
133
- buildimage(ctx)
134
- pushimage(ctx)
180
+ step('build image', buildimage, ctx)
181
+ step('push image', pushimage, ctx)
135
182
  else:
136
183
  branch = get_current_branch(ctx)
137
- build_frontend(ctx, branch, ssr=ssr)
138
- package_dist(ctx)
139
- upload_dist(ctx, version)
140
- changelog(ctx, version)
141
- push(ctx)
184
+ if ensure_gitignored('dist.tar.gz'):
185
+ print('[itw] Added "dist.tar.gz" to .gitignore.')
186
+ step('build', build_frontend, ctx, branch, ssr=ssr)
187
+ step('package', package_dist, ctx)
188
+ step('upload', upload_dist, ctx, version)
189
+ if safe_remove('dist.tar.gz'):
190
+ print('[itw] Removed local artifact dist.tar.gz after upload.')
191
+ step('changelog', changelog, ctx, version)
192
+ step('push', push, ctx)
142
193
 
143
194
 
144
195
  @task(name='tag-init')
@@ -163,12 +214,12 @@ def _ensure_unique_tag(ctx: Context, version: Version) -> None:
163
214
  def incrementrc(ctx: Context, skip_pipeline=False, pylintrc=None, ssr=False) -> None:
164
215
  """Increment release candidate version and deploy. Use --ssr for Angular SSR builds."""
165
216
  check_branch(ctx)
166
- login(ctx)
217
+ ensure_gitlab_auth(ctx)
167
218
  project_type = detect_project_type()
168
219
  version = read_version(ctx)
169
220
  version.increment_release_candidate()
170
221
  _ensure_unique_tag(ctx, version)
171
- if project_type == 'backend':
222
+ if project_type == 'backend' and not skip_pipeline:
172
223
  lint(ctx, pylintrc=pylintrc)
173
224
  tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
174
225
  save_version(version)
@@ -219,7 +270,7 @@ def incrementmajor(ctx: Context, skip_pipeline=False, ssr=False) -> None:
219
270
  def release(ctx: Context, skip_pipeline=False, ssr=False) -> None:
220
271
  """Promote release candidate to stable release and deploy. Use --ssr for Angular SSR builds."""
221
272
  _ = check_branch(ctx)
222
- login(ctx)
273
+ ensure_gitlab_auth(ctx)
223
274
  version = read_version(ctx)
224
275
  version.reset_release_candidate(release=True)
225
276
  _ensure_unique_tag(ctx, version)
@@ -234,11 +285,13 @@ def test(ctx: Context, settings='backend.test_settings'):
234
285
  """Run tests with coverage (Django or Angular/karma)"""
235
286
  if detect_project_type() == 'frontend':
236
287
  ctx.run('npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox')
288
+ print("✓ Tests completed")
237
289
  return
238
290
  detect_and_activate_venv()
239
291
  ctx.run(f'python -m coverage run manage.py test --settings={settings} --noinput -v 2')
240
292
  ctx.run('python -m coverage report -m')
241
293
  ctx.run('python -m coverage xml -o coverage.xml')
294
+ print("✓ Tests completed")
242
295
 
243
296
 
244
297
  @task
@@ -283,6 +336,7 @@ def analyze(ctx: Context):
283
336
  if detect_project_type() == 'backend':
284
337
  detect_and_activate_venv()
285
338
  load_env(ctx)
339
+ print("Running SonarQube analysis...")
286
340
  try:
287
341
  version = read_version(ctx)
288
342
  sonar_version = str(version).lstrip('v.')
@@ -313,65 +367,27 @@ def buildlocal(ctx: Context, ssr=False):
313
367
  print(f'✓ Built local image: {local_tag}')
314
368
 
315
369
 
316
- @task
317
- def testlocal(ctx: Context, settings='backend.test_settings'):
318
- """Run tests locally (Django or Angular/karma)"""
319
- if detect_project_type() == 'frontend':
320
- ctx.run('npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox')
321
- print("✓ Tests completed")
322
- return
323
- detect_and_activate_venv()
324
- ctx.run(f'python -m coverage run manage.py test --settings={settings} --noinput -v 2')
325
- ctx.run('python -m coverage report -m')
326
- ctx.run('python -m coverage xml -o coverage.xml')
327
- print("✓ Tests completed")
328
-
329
-
330
- @task
331
- def analyzelocal(ctx: Context):
332
- """Run SonarQube static analysis locally"""
333
- if detect_project_type() == 'backend':
334
- detect_and_activate_venv()
335
- load_env(ctx)
336
- print("Running SonarQube analysis...")
337
- try:
338
- version = read_version(ctx)
339
- sonar_version = str(version).lstrip('v.')
340
- except:
341
- sonar_version = "0.0.0"
342
-
343
- sonar_host = os.environ.get('SONAR_HOST_URL', '')
344
- sonar_token = os.environ.get('SONAR_TOKEN', '')
345
- git_depth = os.environ.get('GIT_DEPTH', '0')
346
-
347
- if not sonar_host or not sonar_token:
348
- raise RuntimeError('SONAR_HOST_URL and SONAR_TOKEN environment variables must be set.')
349
- ctx.run(
350
- f'podman run --rm -e SONAR_HOST_URL={sonar_host} -e SONAR_TOKEN={sonar_token} -e GIT_DEPTH={git_depth} --user=root -v "$(pwd)":/usr/src:Z docker.io/sonarsource/sonar-scanner-cli:latest -X -Dsonar.projectVersion={sonar_version}',
351
- pty=True)
352
- print("✓ SonarQube analysis completed")
353
-
354
-
355
370
  @task
356
371
  def pipelinelocal(ctx: Context, settings='backend.test_settings', pylintrc=None):
357
372
  """Local pipeline: (lint →) test → analyze"""
373
+ install_sigint_guard()
358
374
  project_type = detect_project_type()
359
375
  if project_type == 'backend':
360
376
  detect_and_activate_venv()
361
377
  print("=" * 60)
362
378
  print("Step 1: Running lint...")
363
379
  print("=" * 60)
364
- lint(ctx, pylintrc=pylintrc)
380
+ step('lint', lint, ctx, pylintrc=pylintrc)
365
381
 
366
382
  print("\n" + "=" * 60)
367
383
  print(f"Step {'2' if project_type == 'backend' else '1'}: Running tests...")
368
384
  print("=" * 60)
369
- testlocal(ctx, settings)
385
+ step('tests', test, ctx, settings)
370
386
 
371
387
  print("\n" + "=" * 60)
372
388
  print(f"Step {'3' if project_type == 'backend' else '2'}: Running SonarQube analysis...")
373
389
  print("=" * 60)
374
- analyzelocal(ctx)
390
+ step('analyze', analyze, ctx)
375
391
 
376
392
  print("\n" + "=" * 60)
377
393
  print("✓ Local pipeline completed successfully!")
@@ -0,0 +1,38 @@
1
+ <!DOCTYPE html>
2
+ <html lang="sq">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>itw-python-builder — Version i ri</title>
6
+ </head>
7
+ <body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;color:#1f2937;">
8
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:24px 0;">
9
+ <tr>
10
+ <td align="center">
11
+ <table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
12
+ <tr>
13
+ <td style="background:#0f172a;color:#ffffff;padding:20px 28px;">
14
+ <h1 style="margin:0;font-size:20px;">itw-python-builder — Version i ri</h1>
15
+ </td>
16
+ </tr>
17
+ <tr>
18
+ <td style="padding:28px;">
19
+ <p style="margin:0 0 16px 0;font-size:15px;">Përshëndetje kolegë,</p>
20
+ <p style="margin:0 0 16px 0;font-size:15px;">
21
+ Një version i ri i paketës <strong>itw-python-builder</strong> është publikuar:
22
+ <strong>{VERSION}</strong>.
23
+ </p>
24
+ <p style="margin:0 0 8px 0;font-size:15px;"><strong>Mesazhi i commit-it:</strong></p>
25
+ <div style="background:#f3f4f6;border-left:4px solid #0f172a;padding:12px 16px;font-size:14px;line-height:1.5;color:#374151;margin:0 0 20px 0;">
26
+ {COMMIT_MESSAGE}
27
+ </div>
28
+ <p style="margin:0 0 8px 0;font-size:15px;"><strong>Komanda për të bërë upgrade:</strong></p>
29
+ <pre style="background:#0f172a;color:#f9fafb;padding:14px 16px;border-radius:6px;font-size:13px;overflow-x:auto;margin:0 0 20px 0;">pip install itw-python-builder=={VERSION} --no-cache-dir</pre>
30
+ <p style="margin:0;font-size:13px;color:#6b7280;">Faleminderit</p>
31
+ </td>
32
+ </tr>
33
+ </table>
34
+ </td>
35
+ </tr>
36
+ </table>
37
+ </body>
38
+ </html>
@@ -1,12 +1,10 @@
1
- """Shared helpers used by tasks.py and ssr_tasks.py.
2
-
3
- Only non-@task functions live here. CLI tasks stay in tasks.py / ssr_tasks.py.
4
- """
5
1
  import getpass
6
2
  import io
7
3
  import json
8
4
  import os
9
5
  import re
6
+ import signal
7
+ import sys
10
8
  from pathlib import Path
11
9
 
12
10
  from invoke import Context
@@ -15,6 +13,43 @@ from itw_python_builder.version import Version
15
13
 
16
14
  _venv_activated = False
17
15
 
16
+ _INTERRUPTED = False
17
+ _SIGINT_INSTALLED = False
18
+
19
+
20
+ def _sigint_handler(signum, frame):
21
+ global _INTERRUPTED
22
+ _INTERRUPTED = True
23
+ raise KeyboardInterrupt
24
+
25
+
26
+ def install_sigint_guard() -> None:
27
+ """Install a process-wide SIGINT trap."""
28
+ global _SIGINT_INSTALLED
29
+ if _SIGINT_INSTALLED:
30
+ return
31
+ signal.signal(signal.SIGINT, _sigint_handler)
32
+ _SIGINT_INSTALLED = True
33
+
34
+
35
+ def abort_if_interrupted(step: str = '') -> None:
36
+ """Exit 130 if a SIGINT was received since the last call."""
37
+ if _INTERRUPTED:
38
+ where = f' during {step}' if step else ''
39
+ print(f'\n[itw] Aborted by user (Ctrl+C){where}.')
40
+ sys.exit(130)
41
+
42
+
43
+ def step(label: str, fn, *args, **kwargs):
44
+ """Run `fn(*args, **kwargs)`, swallow KeyboardInterrupt, then bail if SIGINT was seen."""
45
+ result = None
46
+ try:
47
+ result = fn(*args, **kwargs)
48
+ except KeyboardInterrupt:
49
+ pass
50
+ abort_if_interrupted(label)
51
+ return result
52
+
18
53
 
19
54
  def detect_project_type() -> str:
20
55
  """Detect whether the current directory is a 'frontend' or 'backend' project."""
@@ -216,6 +251,91 @@ def _parse_gitlab_remote(ctx: Context) -> tuple:
216
251
  return host, path
217
252
 
218
253
 
254
+ def safe_remove(name: str) -> bool:
255
+ if '/' in name or name in ('', '.', '..'):
256
+ raise ValueError(f'safe_remove: refusing unsafe name {name!r}')
257
+ target = Path(os.getcwd()) / name
258
+ if not target.exists() or target.is_dir() or target.is_symlink():
259
+ return False
260
+ target.unlink()
261
+ return True
262
+
263
+
264
+ _GITIGNORE_TOUCHED = False
265
+
266
+
267
+ def ensure_gitignored(name: str) -> bool:
268
+ """Append `name` to ./.gitignore if not already present. Returns True if added."""
269
+ global _GITIGNORE_TOUCHED
270
+ gi = Path(os.getcwd()) / '.gitignore'
271
+ if gi.exists():
272
+ content = gi.read_text(encoding='utf-8')
273
+ if name in (line.strip() for line in content.splitlines()):
274
+ return False
275
+ prefix = '' if content.endswith('\n') or content == '' else '\n'
276
+ with gi.open('a', encoding='utf-8') as fp:
277
+ fp.write(f'{prefix}{name}\n')
278
+ else:
279
+ gi.write_text(f'{name}\n', encoding='utf-8')
280
+ _GITIGNORE_TOUCHED = True
281
+ return True
282
+
283
+
284
+ def gitignore_touched_this_run() -> bool:
285
+ """Return True if `ensure_gitignored` modified .gitignore in this process."""
286
+ return _GITIGNORE_TOUCHED
287
+
288
+
289
+ TOKEN_CACHE_PATH = Path.home() / '.config' / 'itw' / 'gitlab_token'
290
+
291
+
292
+ def load_cached_token() -> str:
293
+ """Return cached GitLab token from disk, or '' if missing/unreadable."""
294
+ try:
295
+ if TOKEN_CACHE_PATH.exists():
296
+ return TOKEN_CACHE_PATH.read_text(encoding='utf-8').strip()
297
+ except OSError as exc:
298
+ print(f'[itw] Could not read cached token at {TOKEN_CACHE_PATH}: {exc}')
299
+ return ''
300
+
301
+
302
+ def save_token_to_cache(token: str) -> bool:
303
+ """Best-effort write of token to disk (mode 0600). Warns and returns False on failure."""
304
+ try:
305
+ TOKEN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
306
+ TOKEN_CACHE_PATH.write_text(token, encoding='utf-8')
307
+ os.chmod(TOKEN_CACHE_PATH, 0o600)
308
+ return True
309
+ except OSError as exc:
310
+ print(
311
+ f'[itw] Could not persist token to {TOKEN_CACHE_PATH}: {exc}. '
312
+ 'You will be asked for it again next time.'
313
+ )
314
+ return False
315
+
316
+
317
+ def probe_gitlab_token(api_host: str, token: str) -> str:
318
+ """Probe GET /api/v4/user. Returns 'ok' | 'unauthorized' | 'unreachable'."""
319
+ import ssl
320
+ import urllib.error
321
+ import urllib.request
322
+
323
+ req = urllib.request.Request(
324
+ f'https://{api_host}/api/v4/user',
325
+ headers={'PRIVATE-TOKEN': token},
326
+ )
327
+ ssl_ctx = ssl.create_default_context()
328
+ ssl_ctx.check_hostname = False
329
+ ssl_ctx.verify_mode = ssl.CERT_NONE
330
+ try:
331
+ with urllib.request.urlopen(req, context=ssl_ctx, timeout=5) as resp:
332
+ return 'ok' if resp.status == 200 else 'unreachable'
333
+ except urllib.error.HTTPError as exc:
334
+ return 'unauthorized' if exc.code in (401, 403) else 'unreachable'
335
+ except (urllib.error.URLError, TimeoutError, OSError):
336
+ return 'unreachable'
337
+
338
+
219
339
  def is_ssr_project() -> bool:
220
340
  """Return True if the current frontend project already has SSR scaffolding."""
221
341
  cwd = os.getcwd()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.1.38
3
+ Version: 0.1.40
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
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  itw_python_builder/.pylintrc
5
5
  itw_python_builder/__init__.py
6
6
  itw_python_builder/cli.py
7
+ itw_python_builder/notify.py
7
8
  itw_python_builder/ssr_tasks.py
8
9
  itw_python_builder/tasks.py
9
10
  itw_python_builder/utils.py
@@ -14,5 +15,6 @@ itw_python_builder.egg-info/dependency_links.txt
14
15
  itw_python_builder.egg-info/entry_points.txt
15
16
  itw_python_builder.egg-info/requires.txt
16
17
  itw_python_builder.egg-info/top_level.txt
18
+ itw_python_builder/templates/new_version_email.html
17
19
  itw_python_builder/templates/server.sitemap.snippet.ts
18
20
  itw_python_builder/templates/sitemap.routes.ts
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "itw_python_builder"
7
- version = "0.1.38"
7
+ version = "0.1.40"
8
8
  description = "Standardized Django deployment pipeline with Docker, testing, and SonarQube integration"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -40,7 +40,7 @@ Issues = "https://git.it-works.io/"
40
40
  include-package-data = true
41
41
 
42
42
  [tool.setuptools.package-data]
43
- itw_python_builder = [".pylintrc", "templates/*.ts"]
43
+ itw_python_builder = [".pylintrc", "templates/*.ts", "templates/*.html"]
44
44
 
45
45
  [project.scripts]
46
46
  itw = "itw_python_builder.cli:main"