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.
@@ -0,0 +1,6 @@
1
+ """
2
+ ITW Python Builder - Standardized Django deployment pipeline
3
+ """
4
+
5
+ from .tasks import *
6
+ from .version import Version
@@ -0,0 +1,9 @@
1
+ from invoke import Program, Collection
2
+
3
+ from itw_python_builder import tasks
4
+
5
+
6
+ def main():
7
+ namespace = Collection.from_module(tasks)
8
+ program = Program(namespace=namespace, name='itw')
9
+ program.run()
@@ -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