itw-python-builder 0.1.0__py3-none-any.whl
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/.pylintrc +743 -0
- itw_python_builder/__init__.py +6 -0
- itw_python_builder/cli.py +9 -0
- itw_python_builder/tasks.py +429 -0
- itw_python_builder/version.py +84 -0
- itw_python_builder-0.1.0.dist-info/METADATA +164 -0
- itw_python_builder-0.1.0.dist-info/RECORD +11 -0
- itw_python_builder-0.1.0.dist-info/WHEEL +5 -0
- itw_python_builder-0.1.0.dist-info/entry_points.txt +2 -0
- itw_python_builder-0.1.0.dist-info/licenses/LICENSE +21 -0
- itw_python_builder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
from invoke import task, Context
|
|
2
|
+
from itw_python_builder.version import Version
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
PYLINTRC = Path(__file__).parent / ".pylintrc"
|
|
8
|
+
_venv_activated = False
|
|
9
|
+
|
|
10
|
+
def detect_and_activate_venv():
|
|
11
|
+
"""Detect venv in current directory and prepend to PATH. Runs only once."""
|
|
12
|
+
global _venv_activated
|
|
13
|
+
if _venv_activated:
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
cwd = os.getcwd()
|
|
17
|
+
found = []
|
|
18
|
+
for venv_name in ['.venv', 'venv']:
|
|
19
|
+
venv_path = os.path.join(cwd, venv_name)
|
|
20
|
+
if os.path.isdir(venv_path):
|
|
21
|
+
bin_dir = os.path.join(venv_path, 'bin')
|
|
22
|
+
if os.path.isdir(bin_dir):
|
|
23
|
+
found.append(venv_path)
|
|
24
|
+
|
|
25
|
+
if len(found) == 0:
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
'No virtual environment found in current directory.\n'
|
|
28
|
+
'Create one with: python -m venv .venv'
|
|
29
|
+
)
|
|
30
|
+
if len(found) > 1:
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
f'Multiple virtual environments found: {", ".join(found)}\n'
|
|
33
|
+
'Remove one and keep only .venv or venv.'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
venv_path = found[0]
|
|
37
|
+
bin_dir = os.path.join(venv_path, 'bin')
|
|
38
|
+
os.environ['PATH'] = bin_dir + os.pathsep + os.environ.get('PATH', '')
|
|
39
|
+
os.environ['VIRTUAL_ENV'] = venv_path
|
|
40
|
+
print(f"[itw] Using venv: {venv_path}")
|
|
41
|
+
_venv_activated = True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_current_branch(ctx: Context) -> str:
|
|
45
|
+
branch_result = ctx.run('git rev-parse --abbrev-ref HEAD')
|
|
46
|
+
current_branch = branch_result.stdout.splitlines()[0]
|
|
47
|
+
return current_branch
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_branch_production(branch: str) -> bool:
|
|
51
|
+
if branch == 'master':
|
|
52
|
+
return True
|
|
53
|
+
else:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_branch(ctx: Context) -> bool:
|
|
58
|
+
current_branch = get_current_branch(ctx)
|
|
59
|
+
production = is_branch_production(current_branch)
|
|
60
|
+
changed_result = ctx.run('git diff --quiet HEAD $REF -- $DIR || echo changed')
|
|
61
|
+
changed = changed_result.stdout.splitlines() # empty list if not changed
|
|
62
|
+
if len(changed) > 0:
|
|
63
|
+
raise RuntimeError(f'You have uncommitted changes in branch {current_branch}.')
|
|
64
|
+
if production and current_branch != 'master':
|
|
65
|
+
raise RuntimeError('You must be in master branch to release in production.')
|
|
66
|
+
elif not production and current_branch != 'staging':
|
|
67
|
+
raise RuntimeError(f'Cannot tag in branch {current_branch}.')
|
|
68
|
+
else:
|
|
69
|
+
return production
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_latest_tag(ctx: Context) -> Version:
|
|
73
|
+
result = ctx.run('git describe --tags $(git rev-list --tags --max-count=1)')
|
|
74
|
+
latest_tag = result.stdout.splitlines()[0]
|
|
75
|
+
return Version.parse(latest_tag)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def generate_image_path(ctx: Context, branch: str) -> str:
|
|
79
|
+
result = ctx.run('git remote get-url origin')
|
|
80
|
+
result = result.stdout.splitlines()[0]
|
|
81
|
+
container_registry = result.split(':')[-1].replace('.git', '')
|
|
82
|
+
container_registry_path = f'gitreg.it-works.io:443/{container_registry}:{branch}-latest'
|
|
83
|
+
return container_registry_path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def commit_version(ctx: Context, version: Version) -> None:
|
|
87
|
+
ctx.run(f'git add {Version.VERSION_FILE_NAME}')
|
|
88
|
+
ctx.run('git add CHANGELOG.md')
|
|
89
|
+
ctx.run(f'git commit -m \'version update to {version}\'')
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def push_version(ctx: Context) -> None:
|
|
93
|
+
ctx.run(f'git push origin {get_current_branch(ctx)}')
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def tag(ctx: Context, version: Version) -> None:
|
|
97
|
+
ctx.run(f'git tag {version}')
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def push(ctx: Context):
|
|
101
|
+
ctx.run('git push origin --tags')
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def buildimage(ctx: Context):
|
|
105
|
+
current_branch = get_current_branch(ctx)
|
|
106
|
+
container_registry_path = generate_image_path(ctx, current_branch)
|
|
107
|
+
ctx.run(f'podman build . --tag={container_registry_path}')
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def pushimage(ctx: Context):
|
|
111
|
+
current_branch = get_current_branch(ctx)
|
|
112
|
+
container_registry_path = generate_image_path(ctx, current_branch)
|
|
113
|
+
ctx.run(f'podman push {container_registry_path} --tls-verify=false --compress --compression-level=9')
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False) -> None:
|
|
117
|
+
changelog(ctx, version)
|
|
118
|
+
if not skip_pipeline:
|
|
119
|
+
test(ctx)
|
|
120
|
+
analyze(ctx)
|
|
121
|
+
tag(ctx, version)
|
|
122
|
+
buildimage(ctx)
|
|
123
|
+
pushimage(ctx)
|
|
124
|
+
push(ctx)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@task(name='tag-init')
|
|
128
|
+
def taginit(ctx: Context) -> None:
|
|
129
|
+
"""Initialize version tagging"""
|
|
130
|
+
check_branch(ctx)
|
|
131
|
+
version = Version(0, 1, 0, 1)
|
|
132
|
+
tag(ctx, version)
|
|
133
|
+
version.save_to_file()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@task
|
|
137
|
+
def incrementrc(ctx: Context, skip_pipeline=False) -> None:
|
|
138
|
+
"""Increment release candidate version and deploy"""
|
|
139
|
+
check_branch(ctx)
|
|
140
|
+
version = Version.read_from_file()
|
|
141
|
+
version.increment_release_candidate()
|
|
142
|
+
lint(ctx)
|
|
143
|
+
tag_build_push(ctx, version, skip_pipeline)
|
|
144
|
+
version.save_to_file()
|
|
145
|
+
commit_version(ctx, version)
|
|
146
|
+
push_version(ctx)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@task
|
|
150
|
+
def incrementpatch(ctx: Context, skip_pipeline=False) -> None:
|
|
151
|
+
"""Increment patch version, build, and deploy"""
|
|
152
|
+
production = check_branch(ctx)
|
|
153
|
+
version = Version.read_from_file()
|
|
154
|
+
version.increment_patch(release=production)
|
|
155
|
+
tag_build_push(ctx, version, skip_pipeline)
|
|
156
|
+
version.save_to_file()
|
|
157
|
+
commit_version(ctx, version)
|
|
158
|
+
push_version(ctx)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@task
|
|
162
|
+
def incrementminor(ctx: Context, skip_pipeline=False) -> None:
|
|
163
|
+
"""Increment minor version, build, and deploy"""
|
|
164
|
+
production = check_branch(ctx)
|
|
165
|
+
version = Version.read_from_file()
|
|
166
|
+
version.increment_minor(release=production)
|
|
167
|
+
tag_build_push(ctx, version, skip_pipeline)
|
|
168
|
+
version.save_to_file()
|
|
169
|
+
commit_version(ctx, version)
|
|
170
|
+
push_version(ctx)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@task
|
|
174
|
+
def incrementmajor(ctx: Context, skip_pipeline=False) -> None:
|
|
175
|
+
"""Increment major version, build, and deploy"""
|
|
176
|
+
production = check_branch(ctx)
|
|
177
|
+
version = Version.read_from_file()
|
|
178
|
+
version.increment_major(release=production)
|
|
179
|
+
tag_build_push(ctx, version, skip_pipeline)
|
|
180
|
+
version.save_to_file()
|
|
181
|
+
commit_version(ctx, version)
|
|
182
|
+
push_version(ctx)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@task
|
|
186
|
+
def release(ctx: Context, skip_pipeline=False) -> None:
|
|
187
|
+
"""Promote release candidate to stable release and deploy"""
|
|
188
|
+
_ = check_branch(ctx)
|
|
189
|
+
version = Version.read_from_file()
|
|
190
|
+
version.reset_release_candidate(release=True)
|
|
191
|
+
tag_build_push(ctx, version, skip_pipeline)
|
|
192
|
+
version.save_to_file()
|
|
193
|
+
commit_version(ctx, version)
|
|
194
|
+
push_version(ctx)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@task
|
|
198
|
+
def test(ctx: Context, settings='backend.test_settings'):
|
|
199
|
+
"""Run Django tests with coverage"""
|
|
200
|
+
ctx.run(f'coverage run manage.py test --settings={settings} --keepdb')
|
|
201
|
+
ctx.run('coverage report -m')
|
|
202
|
+
ctx.run('coverage xml -o coverage.xml')
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@task
|
|
206
|
+
def lint(ctx: Context):
|
|
207
|
+
"""Run pylint, generate reports for SonarQube"""
|
|
208
|
+
print("\n" + "=" * 60)
|
|
209
|
+
print("Running pylint...")
|
|
210
|
+
print("=" * 60)
|
|
211
|
+
ctx.run(
|
|
212
|
+
f'pylint . '
|
|
213
|
+
f'--exit-zero '
|
|
214
|
+
f'--rcfile={PYLINTRC} '
|
|
215
|
+
f'--output-format=parseable '
|
|
216
|
+
f'--msg-template="{{path}}:{{line}}: [{{msg_id}}({{symbol}}), {{obj}}] {{msg}}" '
|
|
217
|
+
f'> pylint-report.txt',
|
|
218
|
+
warn=True
|
|
219
|
+
)
|
|
220
|
+
print("✓ pylint done → pylint-report.txt")
|
|
221
|
+
print("\n✓ Lint reports ready for SonarQube")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@task
|
|
225
|
+
def lintlocal(ctx: Context):
|
|
226
|
+
"""Run pylint locally with human-readable output (no report files)"""
|
|
227
|
+
|
|
228
|
+
print("\n" + "=" * 60)
|
|
229
|
+
print("Running pylint...")
|
|
230
|
+
print("=" * 60)
|
|
231
|
+
ctx.run(f'pylint . --exit-zero --output-format=parseable --rcfile={PYLINTRC}', warn=True)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@task
|
|
235
|
+
def analyze(ctx: Context):
|
|
236
|
+
"""Run SonarQube analysis"""
|
|
237
|
+
try:
|
|
238
|
+
version = Version.read_from_file()
|
|
239
|
+
sonar_version = str(version).lstrip('v.')
|
|
240
|
+
except:
|
|
241
|
+
sonar_version = "0.0.0"
|
|
242
|
+
try:
|
|
243
|
+
from decouple import config as env_config
|
|
244
|
+
sonar_host = env_config('SONAR_HOST_URL', default='')
|
|
245
|
+
sonar_token = env_config('SONAR_TOKEN', default='')
|
|
246
|
+
git_depth = env_config('GIT_DEPTH', default=0, cast=int)
|
|
247
|
+
except ImportError:
|
|
248
|
+
sonar_host = os.environ.get('SONAR_HOST_URL', '')
|
|
249
|
+
sonar_token = os.environ.get('SONAR_TOKEN', '')
|
|
250
|
+
git_depth = os.environ.get('GIT_DEPTH', 0)
|
|
251
|
+
if not sonar_host or not sonar_token:
|
|
252
|
+
raise RuntimeError('SONAR_HOST_URL and SONAR_TOKEN environment variables must be set.')
|
|
253
|
+
ctx.run(
|
|
254
|
+
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}')
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@task
|
|
258
|
+
def buildlocal(ctx: Context):
|
|
259
|
+
"""Build Docker image locally without pushing to registry"""
|
|
260
|
+
current_branch = get_current_branch(ctx)
|
|
261
|
+
local_tag = f'myapp:{current_branch}-local'
|
|
262
|
+
ctx.run(f'podman build . --tag={local_tag}')
|
|
263
|
+
print(f'✓ Built local image: {local_tag}')
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@task
|
|
267
|
+
def testlocal(ctx: Context, settings='backend.test_settings'):
|
|
268
|
+
"""Run tests locally (not in Docker)"""
|
|
269
|
+
ctx.run(f'coverage run manage.py test --settings={settings} --keepdb')
|
|
270
|
+
ctx.run('coverage report -m')
|
|
271
|
+
ctx.run('coverage xml -o coverage.xml')
|
|
272
|
+
print("✓ Tests completed")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@task
|
|
276
|
+
def analyzelocal(ctx: Context):
|
|
277
|
+
"""Run SonarQube static analysis locally"""
|
|
278
|
+
print("Running SonarQube analysis...")
|
|
279
|
+
try:
|
|
280
|
+
version = Version.read_from_file()
|
|
281
|
+
sonar_version = str(version).lstrip('v.')
|
|
282
|
+
except:
|
|
283
|
+
sonar_version = "0.0.0"
|
|
284
|
+
try:
|
|
285
|
+
from decouple import config as env_config
|
|
286
|
+
sonar_host = env_config('SONAR_HOST_URL', default='')
|
|
287
|
+
sonar_token = env_config('SONAR_TOKEN', default='')
|
|
288
|
+
git_depth = env_config('GIT_DEPTH', default=0, cast=int)
|
|
289
|
+
except ImportError:
|
|
290
|
+
sonar_host = os.environ.get('SONAR_HOST_URL', '')
|
|
291
|
+
sonar_token = os.environ.get('SONAR_TOKEN', '')
|
|
292
|
+
git_depth = os.environ.get('GIT_DEPTH', 0)
|
|
293
|
+
if not sonar_host or not sonar_token:
|
|
294
|
+
raise RuntimeError('SONAR_HOST_URL and SONAR_TOKEN environment variables must be set.')
|
|
295
|
+
ctx.run(
|
|
296
|
+
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}',
|
|
297
|
+
pty=True)
|
|
298
|
+
print("✓ SonarQube analysis completed")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@task
|
|
302
|
+
def pipelinelocal(ctx: Context, settings='backend.test_settings'):
|
|
303
|
+
"""Local pipeline: lint → test → analyze → build"""
|
|
304
|
+
changelog(ctx)
|
|
305
|
+
|
|
306
|
+
print("=" * 60)
|
|
307
|
+
print("Step 1: Running lint...")
|
|
308
|
+
print("=" * 60)
|
|
309
|
+
lint(ctx)
|
|
310
|
+
|
|
311
|
+
print("\n" + "=" * 60)
|
|
312
|
+
print("Step 2: Running tests...")
|
|
313
|
+
print("=" * 60)
|
|
314
|
+
testlocal(ctx, settings)
|
|
315
|
+
|
|
316
|
+
print("\n" + "=" * 60)
|
|
317
|
+
print("Step 3: Running SonarQube analysis...")
|
|
318
|
+
print("=" * 60)
|
|
319
|
+
analyzelocal(ctx)
|
|
320
|
+
|
|
321
|
+
print("\n" + "=" * 60)
|
|
322
|
+
print("Step 4: Building Docker image locally...")
|
|
323
|
+
print("=" * 60)
|
|
324
|
+
buildlocal(ctx)
|
|
325
|
+
|
|
326
|
+
print("\n" + "=" * 60)
|
|
327
|
+
print("✓ Local pipeline completed successfully!")
|
|
328
|
+
print("=" * 60)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@task
|
|
332
|
+
def changelog(ctx: Context, version: Version = None):
|
|
333
|
+
"""Generate changelog from commits with Changelog trailer"""
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
last_tag_result = ctx.run('git describe --tags --abbrev=0', hide=True)
|
|
337
|
+
last_tag = last_tag_result.stdout.strip()
|
|
338
|
+
cmd = f'git log {last_tag}..HEAD --pretty=format:"%s|||%h|||%b|||END"'
|
|
339
|
+
except:
|
|
340
|
+
cmd = 'git log --pretty=format:"%s|||%h|||%b|||END"'
|
|
341
|
+
|
|
342
|
+
result = ctx.run(cmd, hide=True, warn=True)
|
|
343
|
+
|
|
344
|
+
if not result.stdout.strip():
|
|
345
|
+
print("No new commits since last tag")
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
commits_by_type = {}
|
|
349
|
+
for entry in result.stdout.strip().split('|||END'):
|
|
350
|
+
if not entry.strip():
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
parts = entry.strip().split('|||')
|
|
354
|
+
if len(parts) < 3:
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
title = parts[0].strip()
|
|
358
|
+
hash = parts[1].strip()
|
|
359
|
+
body = parts[2].strip() if len(parts) > 2 else ""
|
|
360
|
+
|
|
361
|
+
changelog_type = None
|
|
362
|
+
for line in body.split('\n'):
|
|
363
|
+
if line.strip().lower().startswith('changelog:'):
|
|
364
|
+
changelog_type = line.split(':', 1)[1].strip().lower()
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
if not changelog_type:
|
|
368
|
+
changelog_type = 'other'
|
|
369
|
+
|
|
370
|
+
type_map = {
|
|
371
|
+
'added': 'Added',
|
|
372
|
+
'fixed': 'Fixed',
|
|
373
|
+
'changed': 'Changed',
|
|
374
|
+
'deprecated': 'Deprecated',
|
|
375
|
+
'removed': 'Removed',
|
|
376
|
+
'security': 'Security',
|
|
377
|
+
'performance': 'Performance',
|
|
378
|
+
'tests': 'Tests',
|
|
379
|
+
'docs': 'Documentation',
|
|
380
|
+
'refactor': 'Refactoring',
|
|
381
|
+
'other': 'Other Changes'
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
section = type_map.get(changelog_type, 'Other Changes')
|
|
385
|
+
|
|
386
|
+
if section not in commits_by_type:
|
|
387
|
+
commits_by_type[section] = []
|
|
388
|
+
|
|
389
|
+
commits_by_type[section].append(f"- {title} ({hash})")
|
|
390
|
+
|
|
391
|
+
if not commits_by_type:
|
|
392
|
+
print("No commits found")
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
if version is not None:
|
|
396
|
+
version_str = str(version)
|
|
397
|
+
else:
|
|
398
|
+
try:
|
|
399
|
+
version_str = str(Version.read_from_file())
|
|
400
|
+
except:
|
|
401
|
+
version_str = "Unreleased"
|
|
402
|
+
|
|
403
|
+
date = datetime.now().strftime('%Y-%m-%d')
|
|
404
|
+
|
|
405
|
+
changelog_entry = f"\n## {version_str} - {date}\n\n"
|
|
406
|
+
|
|
407
|
+
section_order = ['Added', 'Fixed', 'Changed', 'Deprecated', 'Removed',
|
|
408
|
+
'Security', 'Performance', 'Tests', 'Documentation',
|
|
409
|
+
'Refactoring', 'Other Changes']
|
|
410
|
+
|
|
411
|
+
for section in section_order:
|
|
412
|
+
if section in commits_by_type:
|
|
413
|
+
changelog_entry += f"### {section}\n\n"
|
|
414
|
+
for commit in commits_by_type[section]:
|
|
415
|
+
changelog_entry += f"{commit}\n"
|
|
416
|
+
changelog_entry += "\n"
|
|
417
|
+
|
|
418
|
+
if not os.path.exists('CHANGELOG.md'):
|
|
419
|
+
with open('CHANGELOG.md', 'w') as f:
|
|
420
|
+
f.write('# Changelog\n')
|
|
421
|
+
|
|
422
|
+
with open('CHANGELOG.md', 'r') as f:
|
|
423
|
+
existing = f.read()
|
|
424
|
+
|
|
425
|
+
with open('CHANGELOG.md', 'w') as f:
|
|
426
|
+
f.write('# Changelog' + changelog_entry + existing.replace('# Changelog', ''))
|
|
427
|
+
|
|
428
|
+
total = sum(len(v) for v in commits_by_type.values())
|
|
429
|
+
print(f"✓ Changelog updated with {total} commits")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Self
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Version:
|
|
6
|
+
RGX_RELEASE = re.compile(r'v(\.\d{0,2}){3}-release')
|
|
7
|
+
RGX_RELEASE_CANDIDATE = re.compile(r'v(\.\d{0,2}){3}-rc\d{0,2}')
|
|
8
|
+
VERSION_FILE_NAME = 'VERSION'
|
|
9
|
+
major: int = 0
|
|
10
|
+
minor: int = 0
|
|
11
|
+
patch: int = 0
|
|
12
|
+
release_candidate: int = 0
|
|
13
|
+
|
|
14
|
+
def __init__(self, major: int = 0, minor: int = 1, patch: int = 0, release_candidate: int = 0) -> None:
|
|
15
|
+
self.major = major
|
|
16
|
+
self.minor = minor
|
|
17
|
+
self.patch = patch
|
|
18
|
+
self.release_candidate = release_candidate
|
|
19
|
+
|
|
20
|
+
def reset_release_candidate(self, release: bool = False) -> None:
|
|
21
|
+
if release:
|
|
22
|
+
self.release_candidate = 0
|
|
23
|
+
else:
|
|
24
|
+
self.release_candidate = 1
|
|
25
|
+
|
|
26
|
+
def increment_release_candidate(self) -> None:
|
|
27
|
+
self.release_candidate += 1
|
|
28
|
+
|
|
29
|
+
def increment_patch(self, release: bool = False) -> None:
|
|
30
|
+
self.patch += 1
|
|
31
|
+
self.reset_release_candidate(release)
|
|
32
|
+
|
|
33
|
+
def increment_minor(self, release: bool = False) -> None:
|
|
34
|
+
self.minor += 1
|
|
35
|
+
self.reset_release_candidate(release)
|
|
36
|
+
|
|
37
|
+
def increment_major(self, release: bool = False) -> None:
|
|
38
|
+
self.major += 1
|
|
39
|
+
self.reset_release_candidate(release)
|
|
40
|
+
|
|
41
|
+
def save_to_file(self) -> None:
|
|
42
|
+
with open(self.VERSION_FILE_NAME, 'w', encoding='utf-8') as fp:
|
|
43
|
+
fp.write(self.__str__())
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def read_from_file(cls):
|
|
47
|
+
with open(cls.VERSION_FILE_NAME, 'r', encoding='utf-8') as fp:
|
|
48
|
+
version_line = fp.readline()
|
|
49
|
+
return cls.parse(version_line)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def match_release(cls: Self, version: str) -> bool:
|
|
53
|
+
return bool(Version.RGX_RELEASE.match(version))
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def match_release_candidate(cls: Self, version: str) -> bool:
|
|
57
|
+
return bool(Version.RGX_RELEASE_CANDIDATE.match(version))
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def parse(cls: Self, version: str) -> Self | None:
|
|
61
|
+
if Version.match_release_candidate(version):
|
|
62
|
+
[_, major, minor, patch_release] = version.split('.')
|
|
63
|
+
major = int(major)
|
|
64
|
+
minor = int(minor)
|
|
65
|
+
[patch, release_candidate] = patch_release.split('-')
|
|
66
|
+
patch = int(patch)
|
|
67
|
+
release_candidate = int(release_candidate.replace('rc', ''))
|
|
68
|
+
return Version(major, minor, patch, release_candidate)
|
|
69
|
+
elif Version.match_release(version):
|
|
70
|
+
[_, major, minor, patch] = version.split('.')
|
|
71
|
+
major = int(major)
|
|
72
|
+
minor = int(minor)
|
|
73
|
+
[patch, _] = patch.split('-')
|
|
74
|
+
patch = int(patch)
|
|
75
|
+
return Version(major, minor, patch, 0)
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError(f'Invalid version: {version}')
|
|
78
|
+
|
|
79
|
+
def __str__(self):
|
|
80
|
+
version = f'v.{self.major}.{self.minor}.{self.patch}'
|
|
81
|
+
if self.release_candidate == 0:
|
|
82
|
+
return f'{version}-release'
|
|
83
|
+
else:
|
|
84
|
+
return f'{version}-rc{self.release_candidate}'
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: itw_python_builder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Standardized Django deployment pipeline with Docker, testing, and SonarQube integration
|
|
5
|
+
Author-email: IT-Works <contact@it-works.io>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://git.it-works.io/
|
|
8
|
+
Project-URL: Repository, https://git.it-works.io/
|
|
9
|
+
Project-URL: Issues, https://git.it-works.io/
|
|
10
|
+
Keywords: django,deployment,docker,ci-cd,sonarqube
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: invoke>=2.0.0
|
|
24
|
+
Requires-Dist: pylint>=3.0.0
|
|
25
|
+
Requires-Dist: pylint-django>=2.5.0
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# ITW Python Builder
|
|
29
|
+
|
|
30
|
+
Standardized Django deployment pipeline with Docker, testing, SonarQube integration, automatic changelog generation, and code quality enforcement.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- Automated deployment with semantic versioning
|
|
35
|
+
- Docker/Podman-based build and push pipeline
|
|
36
|
+
- Automated testing with coverage
|
|
37
|
+
- SonarQube static code analysis
|
|
38
|
+
- Pylint linting with SonarQube integration
|
|
39
|
+
- Quality gates - deploy only when tests pass
|
|
40
|
+
- Automatic changelog generation from commit trailers
|
|
41
|
+
- Support for local and production pipelines
|
|
42
|
+
- Global CLI tool
|
|
43
|
+
- Automatic venv detection per project
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
```bash
|
|
47
|
+
pip install itw_python_builder
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Install globally (outside any project venv). The `itw` command becomes available system-wide.
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
1. Navigate to your Django project directory (must have a `.venv` or `venv`):
|
|
55
|
+
|
|
56
|
+
2. Initialize versioning:
|
|
57
|
+
```bash
|
|
58
|
+
itw tag-init
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
3. Run local pipeline:
|
|
62
|
+
```bash
|
|
63
|
+
itw pipelinelocal
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
4. Deploy to staging:
|
|
67
|
+
```bash
|
|
68
|
+
itw incrementrc
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
5. Deploy to production:
|
|
72
|
+
```bash
|
|
73
|
+
itw incrementpatch
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Available Commands
|
|
77
|
+
|
|
78
|
+
### Deployment Commands
|
|
79
|
+
|
|
80
|
+
- `itw incrementpatch` — Increment patch version and deploy
|
|
81
|
+
- `itw incrementminor` — Increment minor version and deploy
|
|
82
|
+
- `itw incrementmajor` — Increment major version and deploy
|
|
83
|
+
- `itw incrementrc` — Increment release candidate (staging)
|
|
84
|
+
- `itw release` — Promote RC to stable release (master)
|
|
85
|
+
|
|
86
|
+
### Local Development Commands
|
|
87
|
+
|
|
88
|
+
- `itw pipelinelocal` — Run full local pipeline (lint → test → analyze → build)
|
|
89
|
+
- `itw lintlocal` — Run pylint with human-readable output
|
|
90
|
+
- `itw lint` — Run pylint and generate SonarQube report files
|
|
91
|
+
- `itw buildlocal` — Build Docker image locally
|
|
92
|
+
- `itw testlocal` — Run tests locally
|
|
93
|
+
- `itw analyzelocal` — Run SonarQube analysis locally
|
|
94
|
+
- `itw changelog` — Generate changelog manually
|
|
95
|
+
- `itw tag-init` — Initialize version tagging
|
|
96
|
+
|
|
97
|
+
### Skip Pipeline
|
|
98
|
+
```bash
|
|
99
|
+
itw incrementpatch --skip-pipeline
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Virtual Environment Detection
|
|
103
|
+
|
|
104
|
+
`itw` is installed globally but automatically detects the project's virtual environment (`.venv` or `venv`) in the current directory. Tasks that need Python/Django dependencies (test, lint, analyze) activate the venv automatically — no need to manually activate it.
|
|
105
|
+
|
|
106
|
+
If no venv is found, those tasks will error with a clear message. Tasks that only use git (like `changelog`, `tag-init`, `buildlocal`) work without a venv.
|
|
107
|
+
|
|
108
|
+
## Linting
|
|
109
|
+
|
|
110
|
+
The pipeline runs pylint automatically on every deployment and generates report files for SonarQube. The `.pylintrc` configuration is shipped with the package — no config files needed in your project.
|
|
111
|
+
```bash
|
|
112
|
+
# Review issues with human-readable output
|
|
113
|
+
itw lintlocal
|
|
114
|
+
|
|
115
|
+
# Generate report files for SonarQube
|
|
116
|
+
itw lint
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Add this to your `sonar-project.properties`:
|
|
120
|
+
```properties
|
|
121
|
+
sonar.python.pylint.reportPaths=pylint-report.txt
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Changelog Generation
|
|
125
|
+
|
|
126
|
+
Changelog entries are generated automatically on every deployment based on commit messages. To categorize a commit, add a `Changelog:` trailer to the commit body:
|
|
127
|
+
```
|
|
128
|
+
add user authentication
|
|
129
|
+
|
|
130
|
+
Changelog: added
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Available categories: `added`, `fixed`, `changed`, `deprecated`, `removed`, `security`, `performance`, `tests`, `docs`, `refactor`
|
|
134
|
+
|
|
135
|
+
Commits without a trailer appear under `Other Changes`. The `CHANGELOG.md` file is committed and pushed automatically with each deployment.
|
|
136
|
+
|
|
137
|
+
## Requirements
|
|
138
|
+
|
|
139
|
+
- Python 3.10+
|
|
140
|
+
- Podman
|
|
141
|
+
- Git
|
|
142
|
+
- SonarQube server
|
|
143
|
+
|
|
144
|
+
## Configuration
|
|
145
|
+
|
|
146
|
+
### Environment Variables
|
|
147
|
+
|
|
148
|
+
Add to your project `.env`:
|
|
149
|
+
```
|
|
150
|
+
SONAR_HOST_URL=https://your-sonar-server
|
|
151
|
+
SONAR_TOKEN=your-token
|
|
152
|
+
GIT_DEPTH=0
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Project Files Required
|
|
156
|
+
|
|
157
|
+
- Git repository with `develop`, `staging`, and `master` branches
|
|
158
|
+
- `VERSION` file in project root
|
|
159
|
+
- `Dockerfile`
|
|
160
|
+
- `sonar-project.properties`
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|