itw-python-builder 0.1.39__tar.gz → 0.1.41__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.39 → itw_python_builder-0.1.41}/PKG-INFO +1 -1
  2. itw_python_builder-0.1.41/itw_python_builder/notify.py +97 -0
  3. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder/tasks.py +61 -47
  4. itw_python_builder-0.1.41/itw_python_builder/templates/new_version_email.html +38 -0
  5. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder/utils.py +97 -4
  6. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder.egg-info/PKG-INFO +1 -1
  7. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder.egg-info/SOURCES.txt +2 -0
  8. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/pyproject.toml +2 -2
  9. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/LICENSE +0 -0
  10. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/README.md +0 -0
  11. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder/.pylintrc +0 -0
  12. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder/__init__.py +0 -0
  13. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder/cli.py +0 -0
  14. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder/ssr_tasks.py +0 -0
  15. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
  16. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder/templates/sitemap.routes.ts +0 -0
  17. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder/version.py +0 -0
  18. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder.egg-info/dependency_links.txt +0 -0
  19. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder.egg-info/entry_points.txt +0 -0
  20. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder.egg-info/requires.txt +0 -0
  21. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/itw_python_builder.egg-info/top_level.txt +0 -0
  22. {itw_python_builder-0.1.39 → itw_python_builder-0.1.41}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.1.39
3
+ Version: 0.1.41
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())
@@ -15,6 +15,12 @@ from itw_python_builder.utils import (
15
15
  install_sigint_guard,
16
16
  abort_if_interrupted,
17
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,
18
24
  )
19
25
  from itw_python_builder.ssr_tasks import * # noqa: F401,F403 — exposes ssr-init
20
26
 
@@ -27,9 +33,8 @@ from pathlib import Path
27
33
  PYLINTRC = Path(__file__).parent / ".pylintrc"
28
34
 
29
35
 
30
- @task
31
- def login(ctx: Context, username=None):
32
- """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."""
33
38
  if not username:
34
39
  username = get_gitlab_username(ctx)
35
40
  token = getpass.getpass(f'Enter GitLab token for {username}: ')
@@ -37,6 +42,7 @@ def login(ctx: Context, username=None):
37
42
  raise RuntimeError('No token entered; aborting login.')
38
43
  os.environ['GITLAB_TOKEN'] = token
39
44
  os.environ['GITLAB_USERNAME'] = username
45
+ save_token_to_cache(token)
40
46
  if detect_project_type() == 'backend':
41
47
  print(f'Logging in to gitreg.it-works.io:443 as {username}')
42
48
  ctx.run(
@@ -45,6 +51,44 @@ def login(ctx: Context, username=None):
45
51
  )
46
52
  else:
47
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
+ project_type = detect_project_type()
70
+ api_host = _gitlab_api_host(ctx)
71
+ username = get_gitlab_username(ctx)
72
+ status = probe_gitlab_token(ctx, project_type, api_host, username, cached)
73
+ if status == 'ok':
74
+ os.environ['GITLAB_TOKEN'] = cached
75
+ os.environ.setdefault('GITLAB_USERNAME', username)
76
+ print('[itw] Using cached GitLab token.')
77
+ return
78
+ if status == 'unreachable':
79
+ print('[itw] Could not reach GitLab to verify cached token; using it anyway.')
80
+ os.environ['GITLAB_TOKEN'] = cached
81
+ os.environ.setdefault('GITLAB_USERNAME', username)
82
+ return
83
+ print('[itw] Cached GitLab token was rejected (401/403). Please log in again.')
84
+
85
+ _prompt_and_store_token(ctx)
86
+
87
+
88
+ @task
89
+ def login(ctx: Context, username=None):
90
+ """Capture GitLab token. Backend also logs into the container registry."""
91
+ _prompt_and_store_token(ctx, username)
48
92
 
49
93
 
50
94
  def build_frontend(ctx: Context, branch: str, ssr: bool = False) -> None:
@@ -99,6 +143,8 @@ def commit_version(ctx: Context, version: Version) -> None:
99
143
  else:
100
144
  ctx.run(f'git add {Version.VERSION_FILE_NAME}')
101
145
  ctx.run('git add CHANGELOG.md')
146
+ if gitignore_touched_this_run():
147
+ ctx.run('git add .gitignore')
102
148
  ctx.run(f'git commit -m \'version update to {version}\'')
103
149
 
104
150
 
@@ -138,9 +184,13 @@ def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False,
138
184
  step('push image', pushimage, ctx)
139
185
  else:
140
186
  branch = get_current_branch(ctx)
187
+ if ensure_gitignored('dist.tar.gz'):
188
+ print('[itw] Added "dist.tar.gz" to .gitignore.')
141
189
  step('build', build_frontend, ctx, branch, ssr=ssr)
142
190
  step('package', package_dist, ctx)
143
191
  step('upload', upload_dist, ctx, version)
192
+ if safe_remove('dist.tar.gz'):
193
+ print('[itw] Removed local artifact dist.tar.gz after upload.')
144
194
  step('changelog', changelog, ctx, version)
145
195
  step('push', push, ctx)
146
196
 
@@ -167,12 +217,12 @@ def _ensure_unique_tag(ctx: Context, version: Version) -> None:
167
217
  def incrementrc(ctx: Context, skip_pipeline=False, pylintrc=None, ssr=False) -> None:
168
218
  """Increment release candidate version and deploy. Use --ssr for Angular SSR builds."""
169
219
  check_branch(ctx)
170
- login(ctx)
220
+ ensure_gitlab_auth(ctx)
171
221
  project_type = detect_project_type()
172
222
  version = read_version(ctx)
173
223
  version.increment_release_candidate()
174
224
  _ensure_unique_tag(ctx, version)
175
- if project_type == 'backend':
225
+ if project_type == 'backend' and not skip_pipeline:
176
226
  lint(ctx, pylintrc=pylintrc)
177
227
  tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
178
228
  save_version(version)
@@ -223,7 +273,7 @@ def incrementmajor(ctx: Context, skip_pipeline=False, ssr=False) -> None:
223
273
  def release(ctx: Context, skip_pipeline=False, ssr=False) -> None:
224
274
  """Promote release candidate to stable release and deploy. Use --ssr for Angular SSR builds."""
225
275
  _ = check_branch(ctx)
226
- login(ctx)
276
+ ensure_gitlab_auth(ctx)
227
277
  version = read_version(ctx)
228
278
  version.reset_release_candidate(release=True)
229
279
  _ensure_unique_tag(ctx, version)
@@ -238,11 +288,13 @@ def test(ctx: Context, settings='backend.test_settings'):
238
288
  """Run tests with coverage (Django or Angular/karma)"""
239
289
  if detect_project_type() == 'frontend':
240
290
  ctx.run('npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox')
291
+ print("✓ Tests completed")
241
292
  return
242
293
  detect_and_activate_venv()
243
294
  ctx.run(f'python -m coverage run manage.py test --settings={settings} --noinput -v 2')
244
295
  ctx.run('python -m coverage report -m')
245
296
  ctx.run('python -m coverage xml -o coverage.xml')
297
+ print("✓ Tests completed")
246
298
 
247
299
 
248
300
  @task
@@ -287,6 +339,7 @@ def analyze(ctx: Context):
287
339
  if detect_project_type() == 'backend':
288
340
  detect_and_activate_venv()
289
341
  load_env(ctx)
342
+ print("Running SonarQube analysis...")
290
343
  try:
291
344
  version = read_version(ctx)
292
345
  sonar_version = str(version).lstrip('v.')
@@ -317,45 +370,6 @@ def buildlocal(ctx: Context, ssr=False):
317
370
  print(f'✓ Built local image: {local_tag}')
318
371
 
319
372
 
320
- @task
321
- def testlocal(ctx: Context, settings='backend.test_settings'):
322
- """Run tests locally (Django or Angular/karma)"""
323
- if detect_project_type() == 'frontend':
324
- ctx.run('npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox')
325
- print("✓ Tests completed")
326
- return
327
- detect_and_activate_venv()
328
- ctx.run(f'python -m coverage run manage.py test --settings={settings} --noinput -v 2')
329
- ctx.run('python -m coverage report -m')
330
- ctx.run('python -m coverage xml -o coverage.xml')
331
- print("✓ Tests completed")
332
-
333
-
334
- @task
335
- def analyzelocal(ctx: Context):
336
- """Run SonarQube static analysis locally"""
337
- if detect_project_type() == 'backend':
338
- detect_and_activate_venv()
339
- load_env(ctx)
340
- print("Running SonarQube analysis...")
341
- try:
342
- version = read_version(ctx)
343
- sonar_version = str(version).lstrip('v.')
344
- except:
345
- sonar_version = "0.0.0"
346
-
347
- sonar_host = os.environ.get('SONAR_HOST_URL', '')
348
- sonar_token = os.environ.get('SONAR_TOKEN', '')
349
- git_depth = os.environ.get('GIT_DEPTH', '0')
350
-
351
- if not sonar_host or not sonar_token:
352
- raise RuntimeError('SONAR_HOST_URL and SONAR_TOKEN environment variables must be set.')
353
- ctx.run(
354
- 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}',
355
- pty=True)
356
- print("✓ SonarQube analysis completed")
357
-
358
-
359
373
  @task
360
374
  def pipelinelocal(ctx: Context, settings='backend.test_settings', pylintrc=None):
361
375
  """Local pipeline: (lint →) test → analyze"""
@@ -371,12 +385,12 @@ def pipelinelocal(ctx: Context, settings='backend.test_settings', pylintrc=None)
371
385
  print("\n" + "=" * 60)
372
386
  print(f"Step {'2' if project_type == 'backend' else '1'}: Running tests...")
373
387
  print("=" * 60)
374
- step('tests', testlocal, ctx, settings)
388
+ step('tests', test, ctx, settings)
375
389
 
376
390
  print("\n" + "=" * 60)
377
391
  print(f"Step {'3' if project_type == 'backend' else '2'}: Running SonarQube analysis...")
378
392
  print("=" * 60)
379
- step('analyze', analyzelocal, ctx)
393
+ step('analyze', analyze, ctx)
380
394
 
381
395
  print("\n" + "=" * 60)
382
396
  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,7 +1,3 @@
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
@@ -255,6 +251,103 @@ def _parse_gitlab_remote(ctx: Context) -> tuple:
255
251
  return host, path
256
252
 
257
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(ctx: Context, project_type: str, api_host: str, username: str, token: str) -> str:
318
+ if project_type == 'backend':
319
+ result = ctx.run(
320
+ f'podman login -u {username} --password-stdin gitreg.it-works.io:443 --tls-verify=false',
321
+ in_stream=io.StringIO(token),
322
+ hide=True,
323
+ warn=True,
324
+ )
325
+ if result.ok:
326
+ return 'ok'
327
+ err = (result.stderr or result.stdout or '').lower()
328
+ if 'unauthorized' in err or '401' in err or 'authentication' in err:
329
+ return 'unauthorized'
330
+ return 'unreachable'
331
+
332
+ import ssl
333
+ import urllib.error
334
+ import urllib.request
335
+ req = urllib.request.Request(
336
+ f'https://{api_host}/api/v4/user',
337
+ headers={'PRIVATE-TOKEN': token},
338
+ )
339
+ ssl_ctx = ssl.create_default_context()
340
+ ssl_ctx.check_hostname = False
341
+ ssl_ctx.verify_mode = ssl.CERT_NONE
342
+ try:
343
+ with urllib.request.urlopen(req, context=ssl_ctx, timeout=5) as resp:
344
+ return 'ok' if resp.status == 200 else 'unreachable'
345
+ except urllib.error.HTTPError as exc:
346
+ return 'unauthorized' if exc.code in (401, 403) else 'unreachable'
347
+ except (urllib.error.URLError, TimeoutError, OSError):
348
+ return 'unreachable'
349
+
350
+
258
351
  def is_ssr_project() -> bool:
259
352
  """Return True if the current frontend project already has SSR scaffolding."""
260
353
  cwd = os.getcwd()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.1.39
3
+ Version: 0.1.41
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.39"
7
+ version = "0.1.41"
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"