itw-python-builder 0.1.23__tar.gz → 0.1.25__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 (16) hide show
  1. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/PKG-INFO +1 -1
  2. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/itw_python_builder/tasks.py +160 -39
  3. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/itw_python_builder/version.py +4 -0
  4. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/itw_python_builder.egg-info/PKG-INFO +1 -1
  5. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/pyproject.toml +1 -1
  6. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/LICENSE +0 -0
  7. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/README.md +0 -0
  8. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/itw_python_builder/.pylintrc +0 -0
  9. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/itw_python_builder/__init__.py +0 -0
  10. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/itw_python_builder/cli.py +0 -0
  11. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/itw_python_builder.egg-info/SOURCES.txt +0 -0
  12. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/itw_python_builder.egg-info/dependency_links.txt +0 -0
  13. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/itw_python_builder.egg-info/entry_points.txt +0 -0
  14. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/itw_python_builder.egg-info/requires.txt +0 -0
  15. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/itw_python_builder.egg-info/top_level.txt +0 -0
  16. {itw_python_builder-0.1.23 → itw_python_builder-0.1.25}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.1.23
3
+ Version: 0.1.25
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
@@ -3,12 +3,49 @@ from itw_python_builder.version import Version
3
3
  import getpass
4
4
  import io
5
5
  import os
6
+ import re
6
7
  from datetime import datetime
7
8
  from pathlib import Path
8
9
 
9
10
  PYLINTRC = Path(__file__).parent / ".pylintrc"
10
11
  _venv_activated = False
11
12
 
13
+ def detect_project_type() -> str:
14
+ """Detect whether the current directory is a 'frontend' or 'backend' project."""
15
+ cwd = os.getcwd()
16
+ has_package_json = os.path.isfile(os.path.join(cwd, 'package.json'))
17
+ has_manage_py = os.path.isfile(os.path.join(cwd, 'manage.py'))
18
+
19
+ if has_package_json and has_manage_py:
20
+ raise RuntimeError(
21
+ 'Ambiguous project: both package.json and manage.py found in current directory.'
22
+ )
23
+
24
+ if has_package_json:
25
+ if not os.path.isdir(os.path.join(cwd, 'node_modules')):
26
+ raise RuntimeError(
27
+ 'Found package.json but no node_modules/ directory.\n'
28
+ 'Install dependencies with: npm install'
29
+ )
30
+ return 'frontend'
31
+
32
+ if has_manage_py:
33
+ has_venv = (
34
+ os.path.isdir(os.path.join(cwd, '.venv'))
35
+ or os.path.isdir(os.path.join(cwd, 'venv'))
36
+ )
37
+ if not has_venv:
38
+ raise RuntimeError(
39
+ 'Found manage.py but no virtual environment (venv or .venv) in current directory.\n'
40
+ 'Create one with: python -m venv .venv'
41
+ )
42
+ return 'backend'
43
+
44
+ raise RuntimeError(
45
+ 'Could not detect project type: no package.json or manage.py in current directory.'
46
+ )
47
+
48
+
12
49
  def detect_and_activate_venv():
13
50
  """Detect venv in current directory and prepend to PATH. Runs only once."""
14
51
  global _venv_activated
@@ -42,8 +79,29 @@ def detect_and_activate_venv():
42
79
  print(f"[itw] Using venv: {venv_path}")
43
80
  _venv_activated = True
44
81
 
45
- def load_env():
46
- """Load .env file from current directory into os.environ."""
82
+ def _parse_angular_env_file(path: str) -> dict:
83
+ """Extract string-valued keys from an Angular environment.ts object literal."""
84
+ with open(path, 'r', encoding='utf-8') as fp:
85
+ content = fp.read()
86
+ pattern = re.compile(r"""^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*['"]([^'"]*)['"]""", re.MULTILINE)
87
+ return dict(pattern.findall(content))
88
+
89
+
90
+ def load_env(ctx: Context = None):
91
+ """Load env vars. Backend → .env in cwd. Frontend → src/environments/environment[.<branch>].ts."""
92
+ if detect_project_type() == 'frontend':
93
+ env_dir = os.path.join(os.getcwd(), 'src', 'environments')
94
+ candidates = []
95
+ if ctx is not None:
96
+ branch = get_current_branch(ctx)
97
+ candidates.append(os.path.join(env_dir, f'environment.{branch}.ts'))
98
+ candidates.append(os.path.join(env_dir, 'environment.ts'))
99
+ env_path = next((p for p in candidates if os.path.exists(p)), None)
100
+ if not env_path:
101
+ return
102
+ for key, value in _parse_angular_env_file(env_path).items():
103
+ os.environ.setdefault(key, value)
104
+ return
47
105
  from decouple import Config, RepositoryEnv
48
106
  env_path = os.path.join(os.getcwd(), '.env')
49
107
  if not os.path.exists(env_path):
@@ -86,6 +144,39 @@ def get_latest_tag(ctx: Context) -> Version:
86
144
  return Version.parse(latest_tag)
87
145
 
88
146
 
147
+ def _update_package_json_version(version: Version) -> None:
148
+ """Rewrite only the `"version": "..."` line in package.json, preserving formatting."""
149
+ pkg_path = os.path.join(os.getcwd(), 'package.json')
150
+ with open(pkg_path, 'r', encoding='utf-8') as fp:
151
+ content = fp.read()
152
+ new_content, n = re.subn(
153
+ r'(^\s*"version"\s*:\s*")[^"]*(")',
154
+ lambda m: f'{m.group(1)}{version.bare_semver()}{m.group(2)}',
155
+ content,
156
+ count=1,
157
+ flags=re.MULTILINE,
158
+ )
159
+ if n == 0:
160
+ raise RuntimeError(f'Could not find "version" field in {pkg_path}')
161
+ with open(pkg_path, 'w', encoding='utf-8') as fp:
162
+ fp.write(new_content)
163
+
164
+
165
+ def read_version(ctx: Context) -> Version:
166
+ """Read current version. Backend → VERSION file. Frontend → latest git tag."""
167
+ if detect_project_type() == 'frontend':
168
+ return get_latest_tag(ctx)
169
+ return Version.read_from_file()
170
+
171
+
172
+ def save_version(version: Version) -> None:
173
+ """Persist version. Backend → VERSION file. Frontend → package.json (bare semver)."""
174
+ if detect_project_type() == 'frontend':
175
+ _update_package_json_version(version)
176
+ return
177
+ version.save_to_file()
178
+
179
+
89
180
  def generate_image_path(ctx: Context, branch: str) -> str:
90
181
  result = ctx.run('git remote get-url origin')
91
182
  result = result.stdout.splitlines()[0]
@@ -123,7 +214,10 @@ def login(ctx: Context, username=None):
123
214
 
124
215
 
125
216
  def commit_version(ctx: Context, version: Version) -> None:
126
- ctx.run(f'git add {Version.VERSION_FILE_NAME}')
217
+ if detect_project_type() == 'frontend':
218
+ ctx.run('git add package.json')
219
+ else:
220
+ ctx.run(f'git add {Version.VERSION_FILE_NAME}')
127
221
  ctx.run('git add CHANGELOG.md')
128
222
  ctx.run(f'git commit -m \'version update to {version}\'')
129
223
 
@@ -153,12 +247,14 @@ def pushimage(ctx: Context):
153
247
 
154
248
 
155
249
  def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False) -> None:
250
+ project_type = detect_project_type()
156
251
  if not skip_pipeline:
157
252
  test(ctx)
158
253
  analyze(ctx)
159
254
  tag(ctx, version)
160
- buildimage(ctx)
161
- pushimage(ctx)
255
+ if project_type == 'backend':
256
+ buildimage(ctx)
257
+ pushimage(ctx)
162
258
  changelog(ctx, version)
163
259
  push(ctx)
164
260
 
@@ -169,19 +265,22 @@ def taginit(ctx: Context) -> None:
169
265
  check_branch(ctx)
170
266
  version = Version(0, 1, 0, 1)
171
267
  tag(ctx, version)
172
- version.save_to_file()
268
+ save_version(version)
173
269
 
174
270
 
175
271
  @task
176
272
  def incrementrc(ctx: Context, skip_pipeline=False, pylintrc=None) -> None:
177
273
  """Increment release candidate version and deploy"""
178
274
  check_branch(ctx)
179
- login(ctx)
180
- version = Version.read_from_file()
275
+ project_type = detect_project_type()
276
+ if project_type == 'backend':
277
+ login(ctx)
278
+ version = read_version(ctx)
181
279
  version.increment_release_candidate()
182
- lint(ctx, pylintrc=pylintrc)
280
+ if project_type == 'backend':
281
+ lint(ctx, pylintrc=pylintrc)
183
282
  tag_build_push(ctx, version, skip_pipeline)
184
- version.save_to_file()
283
+ save_version(version)
185
284
  commit_version(ctx, version)
186
285
  push_version(ctx)
187
286
 
@@ -190,10 +289,10 @@ def incrementrc(ctx: Context, skip_pipeline=False, pylintrc=None) -> None:
190
289
  def incrementpatch(ctx: Context, skip_pipeline=False) -> None:
191
290
  """Increment patch version, build, and deploy"""
192
291
  production = check_branch(ctx)
193
- version = Version.read_from_file()
292
+ version = read_version(ctx)
194
293
  version.increment_patch(release=production)
195
294
  tag_build_push(ctx, version, skip_pipeline)
196
- version.save_to_file()
295
+ save_version(version)
197
296
  commit_version(ctx, version)
198
297
  push_version(ctx)
199
298
 
@@ -202,10 +301,10 @@ def incrementpatch(ctx: Context, skip_pipeline=False) -> None:
202
301
  def incrementminor(ctx: Context, skip_pipeline=False) -> None:
203
302
  """Increment minor version, build, and deploy"""
204
303
  production = check_branch(ctx)
205
- version = Version.read_from_file()
304
+ version = read_version(ctx)
206
305
  version.increment_minor(release=production)
207
306
  tag_build_push(ctx, version, skip_pipeline)
208
- version.save_to_file()
307
+ save_version(version)
209
308
  commit_version(ctx, version)
210
309
  push_version(ctx)
211
310
 
@@ -214,10 +313,10 @@ def incrementminor(ctx: Context, skip_pipeline=False) -> None:
214
313
  def incrementmajor(ctx: Context, skip_pipeline=False) -> None:
215
314
  """Increment major version, build, and deploy"""
216
315
  production = check_branch(ctx)
217
- version = Version.read_from_file()
316
+ version = read_version(ctx)
218
317
  version.increment_major(release=production)
219
318
  tag_build_push(ctx, version, skip_pipeline)
220
- version.save_to_file()
319
+ save_version(version)
221
320
  commit_version(ctx, version)
222
321
  push_version(ctx)
223
322
 
@@ -226,18 +325,22 @@ def incrementmajor(ctx: Context, skip_pipeline=False) -> None:
226
325
  def release(ctx: Context, skip_pipeline=False) -> None:
227
326
  """Promote release candidate to stable release and deploy"""
228
327
  _ = check_branch(ctx)
229
- login(ctx)
230
- version = Version.read_from_file()
328
+ if detect_project_type() == 'backend':
329
+ login(ctx)
330
+ version = read_version(ctx)
231
331
  version.reset_release_candidate(release=True)
232
332
  tag_build_push(ctx, version, skip_pipeline)
233
- version.save_to_file()
333
+ save_version(version)
234
334
  commit_version(ctx, version)
235
335
  push_version(ctx)
236
336
 
237
337
 
238
338
  @task
239
339
  def test(ctx: Context, settings='backend.test_settings'):
240
- """Run Django tests with coverage"""
340
+ """Run tests with coverage (Django or Angular/karma)"""
341
+ if detect_project_type() == 'frontend':
342
+ ctx.run('npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox')
343
+ return
241
344
  detect_and_activate_venv()
242
345
  ctx.run(f'python -m coverage run manage.py test --settings={settings} --noinput -v 2')
243
346
  ctx.run('python -m coverage report -m')
@@ -246,7 +349,10 @@ def test(ctx: Context, settings='backend.test_settings'):
246
349
 
247
350
  @task
248
351
  def lint(ctx: Context, pylintrc=None):
249
- """Run pylint, generate reports for SonarQube"""
352
+ """Run pylint, generate reports for SonarQube (backend only)"""
353
+ if detect_project_type() == 'frontend':
354
+ print("[itw] Skipping lint for frontend (SonarQube handles code quality)")
355
+ return
250
356
  detect_and_activate_venv()
251
357
  rcfile = pylintrc if pylintrc else PYLINTRC
252
358
  print("\n" + "=" * 60)
@@ -265,7 +371,10 @@ def lint(ctx: Context, pylintrc=None):
265
371
 
266
372
  @task
267
373
  def lintlocal(ctx: Context, pylintrc=None):
268
- """Run pylint locally with human-readable output (no report files)"""
374
+ """Run pylint locally with human-readable output (backend only)"""
375
+ if detect_project_type() == 'frontend':
376
+ print("[itw] Skipping lint for frontend (SonarQube handles code quality)")
377
+ return
269
378
  detect_and_activate_venv()
270
379
  rcfile = pylintrc if pylintrc else PYLINTRC
271
380
  print("\n" + "=" * 60)
@@ -277,10 +386,11 @@ def lintlocal(ctx: Context, pylintrc=None):
277
386
  @task
278
387
  def analyze(ctx: Context):
279
388
  """Run SonarQube analysis"""
280
- detect_and_activate_venv()
281
- load_env()
389
+ if detect_project_type() == 'backend':
390
+ detect_and_activate_venv()
391
+ load_env(ctx)
282
392
  try:
283
- version = Version.read_from_file()
393
+ version = read_version(ctx)
284
394
  sonar_version = str(version).lstrip('v.')
285
395
  except:
286
396
  sonar_version = "0.0.0"
@@ -297,7 +407,11 @@ def analyze(ctx: Context):
297
407
 
298
408
  @task
299
409
  def buildlocal(ctx: Context):
300
- """Build Docker image locally without pushing to registry"""
410
+ """Build locally (Docker image for backend, npm build for frontend)"""
411
+ if detect_project_type() == 'frontend':
412
+ ctx.run('npm run build')
413
+ print('✓ Built frontend (dist/)')
414
+ return
301
415
  current_branch = get_current_branch(ctx)
302
416
  local_tag = f'myapp:{current_branch}-local'
303
417
  ctx.run(f'podman build . --tag={local_tag}')
@@ -306,7 +420,11 @@ def buildlocal(ctx: Context):
306
420
 
307
421
  @task
308
422
  def testlocal(ctx: Context, settings='backend.test_settings'):
309
- """Run tests locally (not in Docker)"""
423
+ """Run tests locally (Django or Angular/karma)"""
424
+ if detect_project_type() == 'frontend':
425
+ ctx.run('npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox')
426
+ print("✓ Tests completed")
427
+ return
310
428
  detect_and_activate_venv()
311
429
  ctx.run(f'python -m coverage run manage.py test --settings={settings} --noinput -v 2')
312
430
  ctx.run('python -m coverage report -m')
@@ -317,11 +435,12 @@ def testlocal(ctx: Context, settings='backend.test_settings'):
317
435
  @task
318
436
  def analyzelocal(ctx: Context):
319
437
  """Run SonarQube static analysis locally"""
320
- detect_and_activate_venv()
321
- load_env()
438
+ if detect_project_type() == 'backend':
439
+ detect_and_activate_venv()
440
+ load_env(ctx)
322
441
  print("Running SonarQube analysis...")
323
442
  try:
324
- version = Version.read_from_file()
443
+ version = read_version(ctx)
325
444
  sonar_version = str(version).lstrip('v.')
326
445
  except:
327
446
  sonar_version = "0.0.0"
@@ -340,20 +459,22 @@ def analyzelocal(ctx: Context):
340
459
 
341
460
  @task
342
461
  def pipelinelocal(ctx: Context, settings='backend.test_settings', pylintrc=None):
343
- """Local pipeline: lint → test → analyze → build"""
344
- detect_and_activate_venv()
345
- print("=" * 60)
346
- print("Step 1: Running lint...")
347
- print("=" * 60)
348
- lint(ctx, pylintrc=pylintrc)
462
+ """Local pipeline: (lint →) test → analyze"""
463
+ project_type = detect_project_type()
464
+ if project_type == 'backend':
465
+ detect_and_activate_venv()
466
+ print("=" * 60)
467
+ print("Step 1: Running lint...")
468
+ print("=" * 60)
469
+ lint(ctx, pylintrc=pylintrc)
349
470
 
350
471
  print("\n" + "=" * 60)
351
- print("Step 2: Running tests...")
472
+ print(f"Step {'2' if project_type == 'backend' else '1'}: Running tests...")
352
473
  print("=" * 60)
353
474
  testlocal(ctx, settings)
354
475
 
355
476
  print("\n" + "=" * 60)
356
- print("Step 3: Running SonarQube analysis...")
477
+ print(f"Step {'3' if project_type == 'backend' else '2'}: Running SonarQube analysis...")
357
478
  print("=" * 60)
358
479
  analyzelocal(ctx)
359
480
 
@@ -431,7 +552,7 @@ def changelog(ctx: Context, version: Version = None):
431
552
  version_str = str(version)
432
553
  else:
433
554
  try:
434
- version_str = str(Version.read_from_file())
555
+ version_str = str(read_version(ctx))
435
556
  except:
436
557
  version_str = "Unreleased"
437
558
 
@@ -76,6 +76,10 @@ class Version:
76
76
  else:
77
77
  raise ValueError(f'Invalid version: {version}')
78
78
 
79
+ def bare_semver(self) -> str:
80
+ """Return just major.minor.patch (e.g. '2.0.4') for package.json."""
81
+ return f'{self.major}.{self.minor}.{self.patch}'
82
+
79
83
  def __str__(self):
80
84
  version = f'v.{self.major}.{self.minor}.{self.patch}'
81
85
  if self.release_candidate == 0:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.1.23
3
+ Version: 0.1.25
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,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "itw_python_builder"
7
- version = "0.1.23"
7
+ version = "0.1.25"
8
8
  description = "Standardized Django deployment pipeline with Docker, testing, and SonarQube integration"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"