itw-python-builder 0.1.26__tar.gz → 0.1.28__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 (20) hide show
  1. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/PKG-INFO +1 -1
  2. itw_python_builder-0.1.28/itw_python_builder/ssr_tasks.py +161 -0
  3. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/itw_python_builder/tasks.py +53 -227
  4. itw_python_builder-0.1.28/itw_python_builder/templates/server.sitemap.snippet.ts +12 -0
  5. itw_python_builder-0.1.28/itw_python_builder/templates/sitemap.routes.ts +35 -0
  6. itw_python_builder-0.1.28/itw_python_builder/utils.py +225 -0
  7. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/itw_python_builder.egg-info/PKG-INFO +1 -1
  8. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/itw_python_builder.egg-info/SOURCES.txt +5 -1
  9. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/pyproject.toml +2 -2
  10. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/LICENSE +0 -0
  11. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/README.md +0 -0
  12. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/itw_python_builder/.pylintrc +0 -0
  13. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/itw_python_builder/__init__.py +0 -0
  14. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/itw_python_builder/cli.py +0 -0
  15. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/itw_python_builder/version.py +0 -0
  16. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/itw_python_builder.egg-info/dependency_links.txt +0 -0
  17. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/itw_python_builder.egg-info/entry_points.txt +0 -0
  18. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/itw_python_builder.egg-info/requires.txt +0 -0
  19. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/itw_python_builder.egg-info/top_level.txt +0 -0
  20. {itw_python_builder-0.1.26 → itw_python_builder-0.1.28}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.1.26
3
+ Version: 0.1.28
4
4
  Summary: Standardized Django deployment pipeline with Docker, testing, and SonarQube integration
5
5
  Author-email: IT-Works <contact@it-works.io>
6
6
  License: MIT
@@ -0,0 +1,161 @@
1
+ """SSR scaffolding tasks. One-time `itw ssr-init` for Angular SSR + ngx-seo-helper.
2
+
3
+ The build/version tasks (`incrementrc`, `release`, etc.) live in tasks.py and
4
+ take an `ssr=False` kwarg. With --ssr they invoke:
5
+ - `npm run build:ssr` on master
6
+ - `npm run build:ssr:staging` on any other branch
7
+
8
+ These two scripts are a contract the project owner defines in package.json.
9
+ ssr-init intentionally does NOT write them, because the right command depends
10
+ on which Angular SSR setup the project ended up with:
11
+
12
+ legacy browser builder → ng build --configuration=X && ng run <name>:server:X
13
+ modern application builder (Angular 17+, fresh `ng add @angular/ssr`)
14
+ → ng build --configuration=X
15
+ """
16
+ import os
17
+ import re
18
+ from pathlib import Path
19
+
20
+ from invoke import task, Context
21
+
22
+ from itw_python_builder.utils import detect_project_type, _read_package_name, is_ssr_project
23
+
24
+
25
+ TEMPLATES_DIR = Path(__file__).parent / 'templates'
26
+
27
+ SITEMAP_IMPORT_LINE = "import {sitemapEntries} from './src/sitemap.routes';"
28
+
29
+
30
+ def _load_template(name: str) -> str:
31
+ """Read a template file, stripping the leading `// @ts-nocheck` editor hint."""
32
+ content = (TEMPLATES_DIR / name).read_text(encoding='utf-8')
33
+ lines = content.splitlines(keepends=True)
34
+ if lines and lines[0].lstrip().startswith('// @ts-nocheck'):
35
+ lines = lines[1:]
36
+ if lines and lines[0].strip() == '':
37
+ lines = lines[1:]
38
+ return ''.join(lines)
39
+
40
+
41
+ def _run_ng_add_ssr(ctx: Context) -> None:
42
+ print('[itw] Running `ng add @angular/ssr` (this may take a minute)...')
43
+ ctx.run('npx ng add @angular/ssr --skip-confirmation --server-routing=false')
44
+
45
+
46
+ def _install_ngx_seo_helper(ctx: Context) -> None:
47
+ print('[itw] Installing ngx-seo-helper...')
48
+ ctx.run('npm install ngx-seo-helper')
49
+
50
+
51
+ def _write_sitemap_template() -> None:
52
+ path = os.path.join(os.getcwd(), 'src', 'sitemap.routes.ts')
53
+ if os.path.exists(path):
54
+ print(f'[itw] Skipping sitemap template — {path} already exists.')
55
+ return
56
+ with open(path, 'w', encoding='utf-8') as fp:
57
+ fp.write(_load_template('sitemap.routes.ts'))
58
+ print('[itw] Wrote src/sitemap.routes.ts (empty tree — fill in your routes).')
59
+
60
+
61
+ def _patch_server_ts() -> None:
62
+ """Inject sitemap import + /sitemap.xml handler into server.ts (idempotent)."""
63
+ path = os.path.join(os.getcwd(), 'server.ts')
64
+ if not os.path.exists(path):
65
+ raise RuntimeError('server.ts missing after `ng add @angular/ssr` — cannot patch.')
66
+
67
+ with open(path, 'r', encoding='utf-8') as fp:
68
+ content = fp.read()
69
+
70
+ if 'sitemap.routes' in content:
71
+ print('[itw] server.ts already references sitemap.routes — skipping patch.')
72
+ return
73
+
74
+ lines = content.splitlines(keepends=True)
75
+ last_import_idx = -1
76
+ for i, line in enumerate(lines):
77
+ if line.startswith('import '):
78
+ last_import_idx = i
79
+ if last_import_idx == -1:
80
+ raise RuntimeError('server.ts has no imports — unexpected shape, aborting patch.')
81
+ lines.insert(last_import_idx + 1, SITEMAP_IMPORT_LINE + '\n')
82
+ content = ''.join(lines)
83
+
84
+ match = re.search(r"(server\.set\('views',\s*distFolder\);\s*\n)", content)
85
+ if not match:
86
+ match = re.search(r"(const\s+commonEngine\s*=\s*new\s+CommonEngine\(\);\s*\n)", content)
87
+ if not match:
88
+ raise RuntimeError(
89
+ 'Could not find an injection point in server.ts (looked for `server.set(\'views\', ...)` '
90
+ 'or `new CommonEngine()`). Patch sitemap handler manually.'
91
+ )
92
+ insert_at = match.end()
93
+ content = content[:insert_at] + _load_template('server.sitemap.snippet.ts') + content[insert_at:]
94
+
95
+ with open(path, 'w', encoding='utf-8') as fp:
96
+ fp.write(content)
97
+ print('[itw] Patched server.ts with /sitemap.xml handler.')
98
+
99
+
100
+ def _print_next_steps(project_name: str) -> None:
101
+ print()
102
+ print('=' * 70)
103
+ print('[itw] SSR scaffolding complete.')
104
+ print('=' * 70)
105
+ print('Next steps you must do manually:')
106
+ print()
107
+ print(' 1. Add `NgxSeoModule` to your AppModule imports.')
108
+ print(' 2. Fill in src/sitemap.routes.ts with your route tree.')
109
+ print(' 3. Add `staging` (and optionally `development`) configurations to')
110
+ print(' angular.json under projects.' + project_name + '.architect.build.configurations.')
111
+ print(' ng add @angular/ssr only creates the `production` configuration.')
112
+ print()
113
+ print(' 4. Add these two npm scripts to package.json — the deploy pipeline')
114
+ print(' calls them by name:')
115
+ print()
116
+ print(' "build:ssr": "<build command for production>"')
117
+ print(' "build:ssr:staging": "<build command for staging>"')
118
+ print()
119
+ print(' The exact command depends on which builder angular.json uses:')
120
+ print()
121
+ print(' • Modern application builder (default for fresh ng add @angular/ssr):')
122
+ print(' "build:ssr": "ng build --configuration=production"')
123
+ print(' "build:ssr:staging": "ng build --configuration=staging"')
124
+ print()
125
+ print(' • Legacy browser builder (older projects with a separate server target):')
126
+ print(f' "build:ssr": "ng build --configuration=production && ng run {project_name}:server:production"')
127
+ print(f' "build:ssr:staging": "ng build --configuration=staging && ng run {project_name}:server:staging"')
128
+ print()
129
+ print(' 5. Verify locally: npm run build:ssr && npm run serve:ssr')
130
+ print(' 6. Commit the new files.')
131
+ print(' 7. Deploy with: itw incrementrc --ssr (or incrementpatch/release --ssr)')
132
+ print('=' * 70)
133
+
134
+
135
+ @task(name='ssr-init')
136
+ def ssr_init(ctx: Context, force: bool = False) -> None:
137
+ """One-time SSR scaffold: ng add @angular/ssr, ngx-seo-helper, sitemap stub, server.ts patch.
138
+
139
+ Does NOT write package.json scripts — those depend on which builder Angular
140
+ chose. The post-run message tells you the two scripts the pipeline expects.
141
+ """
142
+ if detect_project_type() != 'frontend':
143
+ raise RuntimeError('ssr-init only runs on frontend (Angular) projects.')
144
+
145
+ if is_ssr_project() and not force:
146
+ raise RuntimeError(
147
+ 'SSR scaffolding already present (server.ts + tsconfig.server.json exist). '
148
+ 'Re-run with --force to re-apply the sitemap stub and server.ts patch.'
149
+ )
150
+
151
+ name = _read_package_name()
152
+ print(f'[itw] Scaffolding SSR for project: {name}')
153
+
154
+ if not is_ssr_project():
155
+ _run_ng_add_ssr(ctx)
156
+
157
+ _install_ngx_seo_helper(ctx)
158
+ _write_sitemap_template()
159
+ _patch_server_ts()
160
+
161
+ _print_next_steps(name)
@@ -1,193 +1,27 @@
1
1
  from invoke import task, Context
2
2
  from itw_python_builder.version import Version
3
+ from itw_python_builder.utils import (
4
+ detect_project_type,
5
+ detect_and_activate_venv,
6
+ load_env,
7
+ get_current_branch,
8
+ check_branch,
9
+ read_version,
10
+ save_version,
11
+ generate_image_path,
12
+ get_gitlab_username,
13
+ _parse_gitlab_remote,
14
+ is_ssr_project,
15
+ )
16
+ from itw_python_builder.ssr_tasks import * # noqa: F401,F403 — exposes ssr-init
17
+
3
18
  import getpass
4
19
  import io
5
20
  import os
6
- import re
7
21
  from datetime import datetime
8
22
  from pathlib import Path
9
23
 
10
24
  PYLINTRC = Path(__file__).parent / ".pylintrc"
11
- _venv_activated = False
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
-
49
- def detect_and_activate_venv():
50
- """Detect venv in current directory and prepend to PATH. Runs only once."""
51
- global _venv_activated
52
- if _venv_activated:
53
- return
54
-
55
- cwd = os.getcwd()
56
- found = []
57
- for venv_name in ['.venv', 'venv']:
58
- venv_path = os.path.join(cwd, venv_name)
59
- if os.path.isdir(venv_path):
60
- bin_dir = os.path.join(venv_path, 'bin')
61
- if os.path.isdir(bin_dir):
62
- found.append(venv_path)
63
-
64
- if len(found) == 0:
65
- raise RuntimeError(
66
- 'No virtual environment found in current directory.\n'
67
- 'Create one with: python -m venv .venv'
68
- )
69
- if len(found) > 1:
70
- raise RuntimeError(
71
- f'Multiple virtual environments found: {", ".join(found)}\n'
72
- 'Remove one and keep only .venv or venv.'
73
- )
74
-
75
- venv_path = found[0]
76
- bin_dir = os.path.join(venv_path, 'bin')
77
- os.environ['PATH'] = bin_dir + os.pathsep + os.environ.get('PATH', '')
78
- os.environ['VIRTUAL_ENV'] = venv_path
79
- print(f"[itw] Using venv: {venv_path}")
80
- _venv_activated = True
81
-
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
- if detect_project_type() == 'frontend':
92
- env_dir = os.path.join(os.getcwd(), 'src', 'environments')
93
- candidates = []
94
- if ctx is not None:
95
- branch = get_current_branch(ctx)
96
- candidates.append(os.path.join(env_dir, f'environment.{branch}.ts'))
97
- candidates.append(os.path.join(env_dir, 'environment.ts'))
98
- for path in candidates:
99
- if os.path.exists(path):
100
- for key, value in _parse_angular_env_file(path).items():
101
- os.environ.setdefault(key, value)
102
- return
103
- from decouple import Config, RepositoryEnv
104
- env_path = os.path.join(os.getcwd(), '.env')
105
- if not os.path.exists(env_path):
106
- return
107
- config = Config(RepositoryEnv(env_path))
108
- for key, value in config.repository.data.items():
109
- os.environ.setdefault(key, value)
110
-
111
- def get_current_branch(ctx: Context) -> str:
112
- branch_result = ctx.run('git rev-parse --abbrev-ref HEAD')
113
- current_branch = branch_result.stdout.splitlines()[0]
114
- return current_branch
115
-
116
-
117
- def is_branch_production(branch: str) -> bool:
118
- if branch == 'master':
119
- return True
120
- else:
121
- return False
122
-
123
-
124
- def check_branch(ctx: Context) -> bool:
125
- current_branch = get_current_branch(ctx)
126
- production = is_branch_production(current_branch)
127
- changed_result = ctx.run('git diff --quiet HEAD $REF -- $DIR || echo changed')
128
- changed = changed_result.stdout.splitlines() # empty list if not changed
129
- if len(changed) > 0:
130
- raise RuntimeError(f'You have uncommitted changes in branch {current_branch}.')
131
- if production and current_branch != 'master':
132
- raise RuntimeError('You must be in master branch to release in production.')
133
- elif not production and current_branch != 'staging':
134
- raise RuntimeError(f'Cannot tag in branch {current_branch}.')
135
- else:
136
- return production
137
-
138
-
139
- def get_latest_tag(ctx: Context) -> Version:
140
- result = ctx.run('git describe --tags $(git rev-list --tags --max-count=1)')
141
- latest_tag = result.stdout.splitlines()[0]
142
- return Version.parse(latest_tag)
143
-
144
-
145
- def _update_package_json_version(version: Version) -> None:
146
- """Update the top-level `version` in package.json (preserves key order)."""
147
- import json
148
- pkg_path = os.path.join(os.getcwd(), 'package.json')
149
- with open(pkg_path, 'r', encoding='utf-8') as fp:
150
- data = json.load(fp)
151
- data['version'] = version.bare_semver()
152
- with open(pkg_path, 'w', encoding='utf-8') as fp:
153
- json.dump(data, fp, indent=2)
154
- fp.write('\n')
155
-
156
-
157
- def read_version(ctx: Context) -> Version:
158
- """Read current version. Backend → VERSION file. Frontend → latest git tag."""
159
- if detect_project_type() == 'frontend':
160
- return get_latest_tag(ctx)
161
- return Version.read_from_file()
162
-
163
-
164
- def save_version(version: Version) -> None:
165
- """Persist version. Backend → VERSION file. Frontend → package.json (bare semver)."""
166
- if detect_project_type() == 'frontend':
167
- _update_package_json_version(version)
168
- return
169
- version.save_to_file()
170
-
171
-
172
- def generate_image_path(ctx: Context, branch: str) -> str:
173
- result = ctx.run('git remote get-url origin')
174
- result = result.stdout.splitlines()[0]
175
- container_registry = result.split(':')[-1].replace('.git', '')
176
- container_registry_path = f'gitreg.it-works.io:443/{container_registry}:{branch}-latest'
177
- return container_registry_path
178
-
179
-
180
- def get_gitlab_username(ctx: Context) -> str:
181
- """Resolve the GitLab username from git config user.email (local part before '@')."""
182
- result = ctx.run('git config user.email', hide=True, warn=True)
183
- email = result.stdout.strip() if result.ok else ''
184
- if not email or '@' not in email:
185
- raise RuntimeError(
186
- 'Could not determine GitLab username from git config user.email. '
187
- 'Set it with: git config --global user.email "you@email.com" '
188
- 'or pass --username=... explicitly.'
189
- )
190
- return email.split('@')[0]
191
25
 
192
26
 
193
27
  @task
@@ -210,31 +44,17 @@ def login(ctx: Context, username=None):
210
44
  print(f'[itw] GitLab token captured for {username} (will be used for package upload).')
211
45
 
212
46
 
213
- def _read_package_name() -> str:
214
- """Read 'name' field from package.json in cwd."""
215
- import json
216
- pkg_path = os.path.join(os.getcwd(), 'package.json')
217
- with open(pkg_path, 'r', encoding='utf-8') as fp:
218
- return json.load(fp)['name']
219
-
220
-
221
- def _parse_gitlab_remote(ctx: Context) -> tuple:
222
- """Return (host, project_path) parsed from `git remote get-url origin`."""
223
- result = ctx.run('git remote get-url origin', hide=True)
224
- url = result.stdout.strip()
225
- if url.endswith('.git'):
226
- url = url[:-4]
227
- if url.startswith('git@'):
228
- host, path = url.split('@', 1)[1].split(':', 1)
229
- elif '://' in url:
230
- host, path = url.split('://', 1)[1].split('/', 1)
231
- else:
232
- raise RuntimeError(f'Cannot parse GitLab remote URL: {url}')
233
- return host, path
234
-
235
-
236
- def build_frontend(ctx: Context, branch: str) -> None:
237
- """Build the Angular app. Master branch → production config; otherwise staging."""
47
+ def build_frontend(ctx: Context, branch: str, ssr: bool = False) -> None:
48
+ """Build the Angular app. With ssr=True uses the SSR npm scripts."""
49
+ if ssr:
50
+ if not is_ssr_project():
51
+ raise RuntimeError(
52
+ 'Passed --ssr but project is not SSR-scaffolded '
53
+ '(missing server.ts / tsconfig.server.json). Run `itw ssr-init` first.'
54
+ )
55
+ script = 'build:ssr' if branch == 'master' else 'build:ssr:staging'
56
+ ctx.run(f'npm run {script}')
57
+ return
238
58
  config = 'production' if branch == 'master' else 'staging'
239
59
  ctx.run(f'npx ng build --configuration={config}')
240
60
 
@@ -246,17 +66,23 @@ def package_dist(ctx: Context) -> None:
246
66
  ctx.run('tar -czf dist.tar.gz -C dist .')
247
67
 
248
68
 
69
+ _GITLAB_API_HOST_MAP = {
70
+ 'git.it-works.io': 'gitlab.it-works.io',
71
+ }
72
+
73
+
249
74
  def upload_dist(ctx: Context, version) -> None:
250
75
  """Upload dist.tar.gz to this project's GitLab Generic Package Registry under <version>/."""
251
76
  from urllib.parse import quote
252
77
  if not os.environ.get('GITLAB_TOKEN'):
253
78
  raise RuntimeError('GITLAB_TOKEN not set; run `itw login` first.')
254
79
  host, path = _parse_gitlab_remote(ctx)
80
+ host = _GITLAB_API_HOST_MAP.get(host, host)
255
81
  encoded = quote(path, safe='')
256
82
  url = f'https://{host}/api/v4/projects/{encoded}/packages/generic/frontend/{version}/dist.tar.gz'
257
83
  print(f'[itw] Uploading dist.tar.gz → {url}')
258
84
  ctx.run(
259
- 'curl --fail --silent --show-error '
85
+ 'curl --fail --silent --show-error --insecure '
260
86
  '--header "PRIVATE-TOKEN: $GITLAB_TOKEN" '
261
87
  '--upload-file dist.tar.gz '
262
88
  f'"{url}"'
@@ -297,7 +123,7 @@ def pushimage(ctx: Context):
297
123
  ctx.run(f'podman push {container_registry_path} --tls-verify=false --compress --compression-level=9')
298
124
 
299
125
 
300
- def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False) -> None:
126
+ def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False, ssr: bool = False) -> None:
301
127
  project_type = detect_project_type()
302
128
  if not skip_pipeline:
303
129
  test(ctx)
@@ -308,7 +134,7 @@ def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False)
308
134
  pushimage(ctx)
309
135
  else:
310
136
  branch = get_current_branch(ctx)
311
- build_frontend(ctx, branch)
137
+ build_frontend(ctx, branch, ssr=ssr)
312
138
  package_dist(ctx)
313
139
  upload_dist(ctx, version)
314
140
  changelog(ctx, version)
@@ -325,8 +151,8 @@ def taginit(ctx: Context) -> None:
325
151
 
326
152
 
327
153
  @task
328
- def incrementrc(ctx: Context, skip_pipeline=False, pylintrc=None) -> None:
329
- """Increment release candidate version and deploy"""
154
+ def incrementrc(ctx: Context, skip_pipeline=False, pylintrc=None, ssr=False) -> None:
155
+ """Increment release candidate version and deploy. Use --ssr for Angular SSR builds."""
330
156
  check_branch(ctx)
331
157
  login(ctx)
332
158
  project_type = detect_project_type()
@@ -334,56 +160,56 @@ def incrementrc(ctx: Context, skip_pipeline=False, pylintrc=None) -> None:
334
160
  version.increment_release_candidate()
335
161
  if project_type == 'backend':
336
162
  lint(ctx, pylintrc=pylintrc)
337
- tag_build_push(ctx, version, skip_pipeline)
163
+ tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
338
164
  save_version(version)
339
165
  commit_version(ctx, version)
340
166
  push_version(ctx)
341
167
 
342
168
 
343
169
  @task
344
- def incrementpatch(ctx: Context, skip_pipeline=False) -> None:
345
- """Increment patch version, build, and deploy"""
170
+ def incrementpatch(ctx: Context, skip_pipeline=False, ssr=False) -> None:
171
+ """Increment patch version, build, and deploy. Use --ssr for Angular SSR builds."""
346
172
  production = check_branch(ctx)
347
173
  version = read_version(ctx)
348
174
  version.increment_patch(release=production)
349
- tag_build_push(ctx, version, skip_pipeline)
175
+ tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
350
176
  save_version(version)
351
177
  commit_version(ctx, version)
352
178
  push_version(ctx)
353
179
 
354
180
 
355
181
  @task
356
- def incrementminor(ctx: Context, skip_pipeline=False) -> None:
357
- """Increment minor version, build, and deploy"""
182
+ def incrementminor(ctx: Context, skip_pipeline=False, ssr=False) -> None:
183
+ """Increment minor version, build, and deploy. Use --ssr for Angular SSR builds."""
358
184
  production = check_branch(ctx)
359
185
  version = read_version(ctx)
360
186
  version.increment_minor(release=production)
361
- tag_build_push(ctx, version, skip_pipeline)
187
+ tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
362
188
  save_version(version)
363
189
  commit_version(ctx, version)
364
190
  push_version(ctx)
365
191
 
366
192
 
367
193
  @task
368
- def incrementmajor(ctx: Context, skip_pipeline=False) -> None:
369
- """Increment major version, build, and deploy"""
194
+ def incrementmajor(ctx: Context, skip_pipeline=False, ssr=False) -> None:
195
+ """Increment major version, build, and deploy. Use --ssr for Angular SSR builds."""
370
196
  production = check_branch(ctx)
371
197
  version = read_version(ctx)
372
198
  version.increment_major(release=production)
373
- tag_build_push(ctx, version, skip_pipeline)
199
+ tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
374
200
  save_version(version)
375
201
  commit_version(ctx, version)
376
202
  push_version(ctx)
377
203
 
378
204
 
379
205
  @task
380
- def release(ctx: Context, skip_pipeline=False) -> None:
381
- """Promote release candidate to stable release and deploy"""
206
+ def release(ctx: Context, skip_pipeline=False, ssr=False) -> None:
207
+ """Promote release candidate to stable release and deploy. Use --ssr for Angular SSR builds."""
382
208
  _ = check_branch(ctx)
383
209
  login(ctx)
384
210
  version = read_version(ctx)
385
211
  version.reset_release_candidate(release=True)
386
- tag_build_push(ctx, version, skip_pipeline)
212
+ tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
387
213
  save_version(version)
388
214
  commit_version(ctx, version)
389
215
  push_version(ctx)
@@ -460,11 +286,11 @@ def analyze(ctx: Context):
460
286
 
461
287
 
462
288
  @task
463
- def buildlocal(ctx: Context):
464
- """Build locally (Docker image for backend, ng build for frontend)"""
289
+ def buildlocal(ctx: Context, ssr=False):
290
+ """Build locally (Docker image for backend, ng build for frontend). Use --ssr for SSR build."""
465
291
  if detect_project_type() == 'frontend':
466
292
  branch = get_current_branch(ctx)
467
- build_frontend(ctx, branch)
293
+ build_frontend(ctx, branch, ssr=ssr)
468
294
  print('✓ Built frontend (dist/)')
469
295
  return
470
296
  current_branch = get_current_branch(ctx)
@@ -0,0 +1,12 @@
1
+ // @ts-nocheck — template fragment, injected into a file where express is imported
2
+
3
+ const sitemapHandler = (req: express.Request, res: express.Response) => {
4
+ const origin = `https://${req.get('host')}`;
5
+ const now = new Date().toISOString();
6
+ const urls = sitemapEntries
7
+ .map(e => `<url><loc>${origin}${e.path}</loc><lastmod>${now}</lastmod><changefreq>${e.changefreq}</changefreq><priority>${e.priority}</priority></url>`)
8
+ .join('');
9
+ res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`);
10
+ };
11
+
12
+ server.get('/sitemap.xml', sitemapHandler);
@@ -0,0 +1,35 @@
1
+ // @ts-nocheck — template file, types resolved in the target project
2
+ import {Routes} from '@angular/router';
3
+
4
+ // Add your real routes here. Each entry can carry sitemap metadata via
5
+ // data: { sitemap: { priority: 1, changefreq: 'daily' } }
6
+ // Children are walked recursively. Routes with ':' params are skipped.
7
+ type SitemapMeta = { priority?: number; changefreq?: string };
8
+ type SitemapEntry = { path: string; priority: number; changefreq: string };
9
+
10
+ const join = (a: string, b: string) => `/${[a,b].filter(Boolean).join('/')}`.replaceAll(/\/+/g,'/');
11
+
12
+ const flatten = (
13
+ base: string,
14
+ routes: Routes,
15
+ inherited: SitemapMeta = {}
16
+ ): SitemapEntry[] =>
17
+ routes.flatMap(r => {
18
+ const merged: SitemapMeta = { changefreq: 'daily', priority: 0.8, ...inherited, ...r.data?.['sitemap'] };
19
+ const full = join(base, r.path || '');
20
+ if (r.children?.length) return flatten(full, r.children, merged);
21
+ return [{ path: full || '/', priority: merged.priority!, changefreq: merged.changefreq! }];
22
+ });
23
+
24
+ const tree: Routes = [
25
+ // { path: '', data: { sitemap: { priority: 1 } } },
26
+ ];
27
+
28
+ export const sitemapEntries: SitemapEntry[] = Array.from(
29
+ new Map(
30
+ flatten('', tree)
31
+ .filter(e => !e.path.includes(':'))
32
+ .sort((a,b) => a.path.localeCompare(b.path))
33
+ .map(e => [e.path, e])
34
+ ).values()
35
+ );
@@ -0,0 +1,225 @@
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
+ import getpass
6
+ import io
7
+ import json
8
+ import os
9
+ import re
10
+ from pathlib import Path
11
+
12
+ from invoke import Context
13
+
14
+ from itw_python_builder.version import Version
15
+
16
+ _venv_activated = False
17
+
18
+
19
+ def detect_project_type() -> str:
20
+ """Detect whether the current directory is a 'frontend' or 'backend' project."""
21
+ cwd = os.getcwd()
22
+ has_package_json = os.path.isfile(os.path.join(cwd, 'package.json'))
23
+ has_manage_py = os.path.isfile(os.path.join(cwd, 'manage.py'))
24
+
25
+ if has_package_json and has_manage_py:
26
+ raise RuntimeError(
27
+ 'Ambiguous project: both package.json and manage.py found in current directory.'
28
+ )
29
+
30
+ if has_package_json:
31
+ if not os.path.isdir(os.path.join(cwd, 'node_modules')):
32
+ raise RuntimeError(
33
+ 'Found package.json but no node_modules/ directory.\n'
34
+ 'Install dependencies with: npm install'
35
+ )
36
+ return 'frontend'
37
+
38
+ if has_manage_py:
39
+ has_venv = (
40
+ os.path.isdir(os.path.join(cwd, '.venv'))
41
+ or os.path.isdir(os.path.join(cwd, 'venv'))
42
+ )
43
+ if not has_venv:
44
+ raise RuntimeError(
45
+ 'Found manage.py but no virtual environment (venv or .venv) in current directory.\n'
46
+ 'Create one with: python -m venv .venv'
47
+ )
48
+ return 'backend'
49
+
50
+ raise RuntimeError(
51
+ 'Could not detect project type: no package.json or manage.py in current directory.'
52
+ )
53
+
54
+
55
+ def detect_and_activate_venv():
56
+ """Detect venv in current directory and prepend to PATH. Runs only once."""
57
+ global _venv_activated
58
+ if _venv_activated:
59
+ return
60
+
61
+ cwd = os.getcwd()
62
+ found = []
63
+ for venv_name in ['.venv', 'venv']:
64
+ venv_path = os.path.join(cwd, venv_name)
65
+ if os.path.isdir(venv_path):
66
+ bin_dir = os.path.join(venv_path, 'bin')
67
+ if os.path.isdir(bin_dir):
68
+ found.append(venv_path)
69
+
70
+ if len(found) == 0:
71
+ raise RuntimeError(
72
+ 'No virtual environment found in current directory.\n'
73
+ 'Create one with: python -m venv .venv'
74
+ )
75
+ if len(found) > 1:
76
+ raise RuntimeError(
77
+ f'Multiple virtual environments found: {", ".join(found)}\n'
78
+ 'Remove one and keep only .venv or venv.'
79
+ )
80
+
81
+ venv_path = found[0]
82
+ bin_dir = os.path.join(venv_path, 'bin')
83
+ os.environ['PATH'] = bin_dir + os.pathsep + os.environ.get('PATH', '')
84
+ os.environ['VIRTUAL_ENV'] = venv_path
85
+ print(f"[itw] Using venv: {venv_path}")
86
+ _venv_activated = True
87
+
88
+
89
+ def _parse_angular_env_file(path: str) -> dict:
90
+ """Extract string-valued keys from an Angular environment.ts object literal."""
91
+ with open(path, 'r', encoding='utf-8') as fp:
92
+ content = fp.read()
93
+ pattern = re.compile(r"""^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*['"]([^'"]*)['"]""", re.MULTILINE)
94
+ return dict(pattern.findall(content))
95
+
96
+
97
+ def load_env(ctx: Context = None):
98
+ if detect_project_type() == 'frontend':
99
+ env_dir = os.path.join(os.getcwd(), 'src', 'environments')
100
+ candidates = []
101
+ if ctx is not None:
102
+ branch = get_current_branch(ctx)
103
+ candidates.append(os.path.join(env_dir, f'environment.{branch}.ts'))
104
+ candidates.append(os.path.join(env_dir, 'environment.ts'))
105
+ for path in candidates:
106
+ if os.path.exists(path):
107
+ for key, value in _parse_angular_env_file(path).items():
108
+ os.environ.setdefault(key, value)
109
+ return
110
+ from decouple import Config, RepositoryEnv
111
+ env_path = os.path.join(os.getcwd(), '.env')
112
+ if not os.path.exists(env_path):
113
+ return
114
+ config = Config(RepositoryEnv(env_path))
115
+ for key, value in config.repository.data.items():
116
+ os.environ.setdefault(key, value)
117
+
118
+
119
+ def get_current_branch(ctx: Context) -> str:
120
+ branch_result = ctx.run('git rev-parse --abbrev-ref HEAD')
121
+ current_branch = branch_result.stdout.splitlines()[0]
122
+ return current_branch
123
+
124
+
125
+ def is_branch_production(branch: str) -> bool:
126
+ return branch == 'master'
127
+
128
+
129
+ def check_branch(ctx: Context) -> bool:
130
+ current_branch = get_current_branch(ctx)
131
+ production = is_branch_production(current_branch)
132
+ changed_result = ctx.run('git diff --quiet HEAD $REF -- $DIR || echo changed')
133
+ changed = changed_result.stdout.splitlines()
134
+ if len(changed) > 0:
135
+ raise RuntimeError(f'You have uncommitted changes in branch {current_branch}.')
136
+ if production and current_branch != 'master':
137
+ raise RuntimeError('You must be in master branch to release in production.')
138
+ elif not production and current_branch != 'staging':
139
+ raise RuntimeError(f'Cannot tag in branch {current_branch}.')
140
+ else:
141
+ return production
142
+
143
+
144
+ def get_latest_tag(ctx: Context) -> Version:
145
+ result = ctx.run('git describe --tags $(git rev-list --tags --max-count=1)')
146
+ latest_tag = result.stdout.splitlines()[0]
147
+ return Version.parse(latest_tag)
148
+
149
+
150
+ def _update_package_json_version(version: Version) -> None:
151
+ """Update the top-level `version` in package.json (preserves key order)."""
152
+ pkg_path = os.path.join(os.getcwd(), 'package.json')
153
+ with open(pkg_path, 'r', encoding='utf-8') as fp:
154
+ data = json.load(fp)
155
+ data['version'] = version.bare_semver()
156
+ with open(pkg_path, 'w', encoding='utf-8') as fp:
157
+ json.dump(data, fp, indent=2)
158
+ fp.write('\n')
159
+
160
+
161
+ def read_version(ctx: Context) -> Version:
162
+ """Read current version. Backend → VERSION file. Frontend → latest git tag."""
163
+ if detect_project_type() == 'frontend':
164
+ return get_latest_tag(ctx)
165
+ return Version.read_from_file()
166
+
167
+
168
+ def save_version(version: Version) -> None:
169
+ """Persist version. Backend → VERSION file. Frontend → package.json (bare semver)."""
170
+ if detect_project_type() == 'frontend':
171
+ _update_package_json_version(version)
172
+ return
173
+ version.save_to_file()
174
+
175
+
176
+ def generate_image_path(ctx: Context, branch: str) -> str:
177
+ result = ctx.run('git remote get-url origin')
178
+ result = result.stdout.splitlines()[0]
179
+ container_registry = result.split(':')[-1].replace('.git', '')
180
+ container_registry_path = f'gitreg.it-works.io:443/{container_registry}:{branch}-latest'
181
+ return container_registry_path
182
+
183
+
184
+ def get_gitlab_username(ctx: Context) -> str:
185
+ """Resolve the GitLab username from git config user.email (local part before '@')."""
186
+ result = ctx.run('git config user.email', hide=True, warn=True)
187
+ email = result.stdout.strip() if result.ok else ''
188
+ if not email or '@' not in email:
189
+ raise RuntimeError(
190
+ 'Could not determine GitLab username from git config user.email. '
191
+ 'Set it with: git config --global user.email "you@email.com" '
192
+ 'or pass --username=... explicitly.'
193
+ )
194
+ return email.split('@')[0]
195
+
196
+
197
+ def _read_package_name() -> str:
198
+ """Read 'name' field from package.json in cwd."""
199
+ pkg_path = os.path.join(os.getcwd(), 'package.json')
200
+ with open(pkg_path, 'r', encoding='utf-8') as fp:
201
+ return json.load(fp)['name']
202
+
203
+
204
+ def _parse_gitlab_remote(ctx: Context) -> tuple:
205
+ """Return (host, project_path) parsed from `git remote get-url origin`."""
206
+ result = ctx.run('git remote get-url origin', hide=True)
207
+ url = result.stdout.strip()
208
+ if url.endswith('.git'):
209
+ url = url[:-4]
210
+ if url.startswith('git@'):
211
+ host, path = url.split('@', 1)[1].split(':', 1)
212
+ elif '://' in url:
213
+ host, path = url.split('://', 1)[1].split('/', 1)
214
+ else:
215
+ raise RuntimeError(f'Cannot parse GitLab remote URL: {url}')
216
+ return host, path
217
+
218
+
219
+ def is_ssr_project() -> bool:
220
+ """Return True if the current frontend project already has SSR scaffolding."""
221
+ cwd = os.getcwd()
222
+ return (
223
+ os.path.isfile(os.path.join(cwd, 'server.ts'))
224
+ and os.path.isfile(os.path.join(cwd, 'tsconfig.server.json'))
225
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.1.26
3
+ Version: 0.1.28
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,11 +4,15 @@ 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/ssr_tasks.py
7
8
  itw_python_builder/tasks.py
9
+ itw_python_builder/utils.py
8
10
  itw_python_builder/version.py
9
11
  itw_python_builder.egg-info/PKG-INFO
10
12
  itw_python_builder.egg-info/SOURCES.txt
11
13
  itw_python_builder.egg-info/dependency_links.txt
12
14
  itw_python_builder.egg-info/entry_points.txt
13
15
  itw_python_builder.egg-info/requires.txt
14
- itw_python_builder.egg-info/top_level.txt
16
+ itw_python_builder.egg-info/top_level.txt
17
+ itw_python_builder/templates/server.sitemap.snippet.ts
18
+ 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.26"
7
+ version = "0.1.28"
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"]
43
+ itw_python_builder = [".pylintrc", "templates/*.ts"]
44
44
 
45
45
  [project.scripts]
46
46
  itw = "itw_python_builder.cli:main"