itw-python-builder 0.1.25__tar.gz → 0.1.27__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.25 → itw_python_builder-0.1.27}/PKG-INFO +1 -1
  2. itw_python_builder-0.1.27/itw_python_builder/ssr_tasks.py +161 -0
  3. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/itw_python_builder/tasks.py +92 -217
  4. itw_python_builder-0.1.27/itw_python_builder/templates/server.sitemap.snippet.ts +12 -0
  5. itw_python_builder-0.1.27/itw_python_builder/templates/sitemap.routes.ts +35 -0
  6. itw_python_builder-0.1.27/itw_python_builder/utils.py +225 -0
  7. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/itw_python_builder.egg-info/PKG-INFO +1 -1
  8. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/itw_python_builder.egg-info/SOURCES.txt +5 -1
  9. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/pyproject.toml +2 -2
  10. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/LICENSE +0 -0
  11. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/README.md +0 -0
  12. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/itw_python_builder/.pylintrc +0 -0
  13. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/itw_python_builder/__init__.py +0 -0
  14. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/itw_python_builder/cli.py +0 -0
  15. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/itw_python_builder/version.py +0 -0
  16. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/itw_python_builder.egg-info/dependency_links.txt +0 -0
  17. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/itw_python_builder.egg-info/entry_points.txt +0 -0
  18. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/itw_python_builder.egg-info/requires.txt +0 -0
  19. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/itw_python_builder.egg-info/top_level.txt +0 -0
  20. {itw_python_builder-0.1.25 → itw_python_builder-0.1.27}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.1.25
3
+ Version: 0.1.27
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,216 +1,87 @@
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
- """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
105
- from decouple import Config, RepositoryEnv
106
- env_path = os.path.join(os.getcwd(), '.env')
107
- if not os.path.exists(env_path):
108
- return
109
- config = Config(RepositoryEnv(env_path))
110
- for key, value in config.repository.data.items():
111
- os.environ.setdefault(key, value)
112
-
113
- def get_current_branch(ctx: Context) -> str:
114
- branch_result = ctx.run('git rev-parse --abbrev-ref HEAD')
115
- current_branch = branch_result.stdout.splitlines()[0]
116
- return current_branch
117
-
118
-
119
- def is_branch_production(branch: str) -> bool:
120
- if branch == 'master':
121
- return True
122
- else:
123
- return False
124
-
125
-
126
- def check_branch(ctx: Context) -> bool:
127
- current_branch = get_current_branch(ctx)
128
- production = is_branch_production(current_branch)
129
- changed_result = ctx.run('git diff --quiet HEAD $REF -- $DIR || echo changed')
130
- changed = changed_result.stdout.splitlines() # empty list if not changed
131
- if len(changed) > 0:
132
- raise RuntimeError(f'You have uncommitted changes in branch {current_branch}.')
133
- if production and current_branch != 'master':
134
- raise RuntimeError('You must be in master branch to release in production.')
135
- elif not production and current_branch != 'staging':
136
- raise RuntimeError(f'Cannot tag in branch {current_branch}.')
137
- else:
138
- return production
139
-
140
-
141
- def get_latest_tag(ctx: Context) -> Version:
142
- result = ctx.run('git describe --tags $(git rev-list --tags --max-count=1)')
143
- latest_tag = result.stdout.splitlines()[0]
144
- return Version.parse(latest_tag)
145
-
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
-
180
- def generate_image_path(ctx: Context, branch: str) -> str:
181
- result = ctx.run('git remote get-url origin')
182
- result = result.stdout.splitlines()[0]
183
- container_registry = result.split(':')[-1].replace('.git', '')
184
- container_registry_path = f'gitreg.it-works.io:443/{container_registry}:{branch}-latest'
185
- return container_registry_path
186
-
187
-
188
- def get_gitlab_username(ctx: Context) -> str:
189
- """Resolve the GitLab username from git config user.email (local part before '@')."""
190
- result = ctx.run('git config user.email', hide=True, warn=True)
191
- email = result.stdout.strip() if result.ok else ''
192
- if not email or '@' not in email:
193
- raise RuntimeError(
194
- 'Could not determine GitLab username from git config user.email. '
195
- 'Set it with: git config --global user.email "you@email.com" '
196
- 'or pass --username=... explicitly.'
197
- )
198
- return email.split('@')[0]
199
25
 
200
26
 
201
27
  @task
202
28
  def login(ctx: Context, username=None):
203
- """Log in to the GitLab container registry."""
29
+ """Capture GitLab token. Backend also logs into the container registry."""
204
30
  if not username:
205
31
  username = get_gitlab_username(ctx)
206
- print(f'Logging in to gitreg.it-works.io:443 as {username}')
207
- token = getpass.getpass(f'Enter GitLab token (password) for {username}: ')
32
+ token = getpass.getpass(f'Enter GitLab token for {username}: ')
208
33
  if not token:
209
34
  raise RuntimeError('No token entered; aborting login.')
35
+ os.environ['GITLAB_TOKEN'] = token
36
+ os.environ['GITLAB_USERNAME'] = username
37
+ if detect_project_type() == 'backend':
38
+ print(f'Logging in to gitreg.it-works.io:443 as {username}')
39
+ ctx.run(
40
+ f'podman login -u {username} --password-stdin gitreg.it-works.io:443 --tls-verify=false',
41
+ in_stream=io.StringIO(token)
42
+ )
43
+ else:
44
+ print(f'[itw] GitLab token captured for {username} (will be used for package upload).')
45
+
46
+
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
58
+ config = 'production' if branch == 'master' else 'staging'
59
+ ctx.run(f'npx ng build --configuration={config}')
60
+
61
+
62
+ def package_dist(ctx: Context) -> None:
63
+ """Tar the dist/ output directory into dist.tar.gz."""
64
+ if not os.path.isdir(os.path.join(os.getcwd(), 'dist')):
65
+ raise RuntimeError('No dist/ directory found after build — did `ng build` succeed?')
66
+ ctx.run('tar -czf dist.tar.gz -C dist .')
67
+
68
+
69
+ def upload_dist(ctx: Context, version) -> None:
70
+ """Upload dist.tar.gz to this project's GitLab Generic Package Registry under <version>/."""
71
+ from urllib.parse import quote
72
+ if not os.environ.get('GITLAB_TOKEN'):
73
+ raise RuntimeError('GITLAB_TOKEN not set; run `itw login` first.')
74
+ host, path = _parse_gitlab_remote(ctx)
75
+ encoded = quote(path, safe='')
76
+ url = f'https://{host}/api/v4/projects/{encoded}/packages/generic/frontend/{version}/dist.tar.gz'
77
+ print(f'[itw] Uploading dist.tar.gz → {url}')
210
78
  ctx.run(
211
- f'podman login -u {username} --password-stdin gitreg.it-works.io:443 --tls-verify=false',
212
- in_stream=io.StringIO(token)
79
+ 'curl --fail --silent --show-error --insecure '
80
+ '--header "PRIVATE-TOKEN: $GITLAB_TOKEN" '
81
+ '--upload-file dist.tar.gz '
82
+ f'"{url}"'
213
83
  )
84
+ print('[itw] Upload complete.')
214
85
 
215
86
 
216
87
  def commit_version(ctx: Context, version: Version) -> None:
@@ -246,7 +117,7 @@ def pushimage(ctx: Context):
246
117
  ctx.run(f'podman push {container_registry_path} --tls-verify=false --compress --compression-level=9')
247
118
 
248
119
 
249
- def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False) -> None:
120
+ def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False, ssr: bool = False) -> None:
250
121
  project_type = detect_project_type()
251
122
  if not skip_pipeline:
252
123
  test(ctx)
@@ -255,6 +126,11 @@ def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False)
255
126
  if project_type == 'backend':
256
127
  buildimage(ctx)
257
128
  pushimage(ctx)
129
+ else:
130
+ branch = get_current_branch(ctx)
131
+ build_frontend(ctx, branch, ssr=ssr)
132
+ package_dist(ctx)
133
+ upload_dist(ctx, version)
258
134
  changelog(ctx, version)
259
135
  push(ctx)
260
136
 
@@ -269,67 +145,65 @@ def taginit(ctx: Context) -> None:
269
145
 
270
146
 
271
147
  @task
272
- def incrementrc(ctx: Context, skip_pipeline=False, pylintrc=None) -> None:
273
- """Increment release candidate version and deploy"""
148
+ def incrementrc(ctx: Context, skip_pipeline=False, pylintrc=None, ssr=False) -> None:
149
+ """Increment release candidate version and deploy. Use --ssr for Angular SSR builds."""
274
150
  check_branch(ctx)
151
+ login(ctx)
275
152
  project_type = detect_project_type()
276
- if project_type == 'backend':
277
- login(ctx)
278
153
  version = read_version(ctx)
279
154
  version.increment_release_candidate()
280
155
  if project_type == 'backend':
281
156
  lint(ctx, pylintrc=pylintrc)
282
- tag_build_push(ctx, version, skip_pipeline)
157
+ tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
283
158
  save_version(version)
284
159
  commit_version(ctx, version)
285
160
  push_version(ctx)
286
161
 
287
162
 
288
163
  @task
289
- def incrementpatch(ctx: Context, skip_pipeline=False) -> None:
290
- """Increment patch version, build, and deploy"""
164
+ def incrementpatch(ctx: Context, skip_pipeline=False, ssr=False) -> None:
165
+ """Increment patch version, build, and deploy. Use --ssr for Angular SSR builds."""
291
166
  production = check_branch(ctx)
292
167
  version = read_version(ctx)
293
168
  version.increment_patch(release=production)
294
- tag_build_push(ctx, version, skip_pipeline)
169
+ tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
295
170
  save_version(version)
296
171
  commit_version(ctx, version)
297
172
  push_version(ctx)
298
173
 
299
174
 
300
175
  @task
301
- def incrementminor(ctx: Context, skip_pipeline=False) -> None:
302
- """Increment minor version, build, and deploy"""
176
+ def incrementminor(ctx: Context, skip_pipeline=False, ssr=False) -> None:
177
+ """Increment minor version, build, and deploy. Use --ssr for Angular SSR builds."""
303
178
  production = check_branch(ctx)
304
179
  version = read_version(ctx)
305
180
  version.increment_minor(release=production)
306
- tag_build_push(ctx, version, skip_pipeline)
181
+ tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
307
182
  save_version(version)
308
183
  commit_version(ctx, version)
309
184
  push_version(ctx)
310
185
 
311
186
 
312
187
  @task
313
- def incrementmajor(ctx: Context, skip_pipeline=False) -> None:
314
- """Increment major version, build, and deploy"""
188
+ def incrementmajor(ctx: Context, skip_pipeline=False, ssr=False) -> None:
189
+ """Increment major version, build, and deploy. Use --ssr for Angular SSR builds."""
315
190
  production = check_branch(ctx)
316
191
  version = read_version(ctx)
317
192
  version.increment_major(release=production)
318
- tag_build_push(ctx, version, skip_pipeline)
193
+ tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
319
194
  save_version(version)
320
195
  commit_version(ctx, version)
321
196
  push_version(ctx)
322
197
 
323
198
 
324
199
  @task
325
- def release(ctx: Context, skip_pipeline=False) -> None:
326
- """Promote release candidate to stable release and deploy"""
200
+ def release(ctx: Context, skip_pipeline=False, ssr=False) -> None:
201
+ """Promote release candidate to stable release and deploy. Use --ssr for Angular SSR builds."""
327
202
  _ = check_branch(ctx)
328
- if detect_project_type() == 'backend':
329
- login(ctx)
203
+ login(ctx)
330
204
  version = read_version(ctx)
331
205
  version.reset_release_candidate(release=True)
332
- tag_build_push(ctx, version, skip_pipeline)
206
+ tag_build_push(ctx, version, skip_pipeline, ssr=ssr)
333
207
  save_version(version)
334
208
  commit_version(ctx, version)
335
209
  push_version(ctx)
@@ -406,10 +280,11 @@ def analyze(ctx: Context):
406
280
 
407
281
 
408
282
  @task
409
- def buildlocal(ctx: Context):
410
- """Build locally (Docker image for backend, npm build for frontend)"""
283
+ def buildlocal(ctx: Context, ssr=False):
284
+ """Build locally (Docker image for backend, ng build for frontend). Use --ssr for SSR build."""
411
285
  if detect_project_type() == 'frontend':
412
- ctx.run('npm run build')
286
+ branch = get_current_branch(ctx)
287
+ build_frontend(ctx, branch, ssr=ssr)
413
288
  print('✓ Built frontend (dist/)')
414
289
  return
415
290
  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.25
3
+ Version: 0.1.27
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.25"
7
+ version = "0.1.27"
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"