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.
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/PKG-INFO +1 -1
- itw_python_builder-0.1.40/itw_python_builder/notify.py +97 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/tasks.py +74 -58
- itw_python_builder-0.1.40/itw_python_builder/templates/new_version_email.html +38 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/utils.py +124 -4
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/PKG-INFO +1 -1
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/SOURCES.txt +2 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/pyproject.toml +2 -2
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/LICENSE +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/README.md +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/.pylintrc +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/__init__.py +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/cli.py +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/ssr_tasks.py +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/templates/sitemap.routes.ts +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder/version.py +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/dependency_links.txt +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/entry_points.txt +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/requires.txt +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/top_level.txt +0 -0
- {itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/setup.cfg +0 -0
|
@@ -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('&', '&')
|
|
46
|
+
.replace('<', '<')
|
|
47
|
+
.replace('>', '>')
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
130
|
-
analyze
|
|
131
|
-
tag
|
|
176
|
+
step('tests', test, ctx)
|
|
177
|
+
step('analyze', analyze, ctx)
|
|
178
|
+
step('tag', tag, ctx, version)
|
|
132
179
|
if project_type == 'backend':
|
|
133
|
-
buildimage
|
|
134
|
-
pushimage
|
|
180
|
+
step('build image', buildimage, ctx)
|
|
181
|
+
step('push image', pushimage, ctx)
|
|
135
182
|
else:
|
|
136
183
|
branch = get_current_branch(ctx)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
{itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/SOURCES.txt
RENAMED
|
@@ -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.
|
|
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"
|
|
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.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/requires.txt
RENAMED
|
File without changes
|
{itw_python_builder-0.1.38 → itw_python_builder-0.1.40}/itw_python_builder.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|