itw-python-builder 0.2.4__tar.gz → 0.2.6__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.
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/PKG-INFO +2 -1
- itw_python_builder-0.2.6/itw_python_builder/_pyruntime/sitecustomize.py +27 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/notify.py +26 -26
- itw_python_builder-0.2.6/itw_python_builder/task_utils.py +266 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/tasks.py +140 -38
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/utils.py +56 -69
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/PKG-INFO +2 -1
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/SOURCES.txt +2 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/requires.txt +1 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/pyproject.toml +3 -2
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/LICENSE +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/README.md +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/.pylintrc +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/__init__.py +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/cli.py +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/ssr_tasks.py +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/templates/new_version_email.html +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/templates/sitemap.routes.ts +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/version.py +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/dependency_links.txt +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/entry_points.txt +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/top_level.txt +0 -0
- {itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: itw_python_builder
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
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
|
|
@@ -24,6 +24,7 @@ Requires-Dist: invoke>=2.0.0
|
|
|
24
24
|
Requires-Dist: pylint>=3.0.0
|
|
25
25
|
Requires-Dist: pylint-django>=2.5.0
|
|
26
26
|
Requires-Dist: python-decouple>=3.8
|
|
27
|
+
Requires-Dist: requests>=2.28.0
|
|
27
28
|
Dynamic: license-file
|
|
28
29
|
|
|
29
30
|
# ITW Python Builder
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
def _install():
|
|
2
|
+
try:
|
|
3
|
+
from django.db.backends.postgresql import creation as _c
|
|
4
|
+
except Exception:
|
|
5
|
+
return
|
|
6
|
+
cls = _c.DatabaseCreation
|
|
7
|
+
if getattr(cls, '_itw_terminate_patched', False):
|
|
8
|
+
return
|
|
9
|
+
_orig = cls._destroy_test_db
|
|
10
|
+
|
|
11
|
+
def _patched(self, test_database_name, verbosity):
|
|
12
|
+
try:
|
|
13
|
+
with self._nodb_cursor() as cursor:
|
|
14
|
+
cursor.execute(
|
|
15
|
+
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
|
16
|
+
"WHERE datname = %s AND pid <> pg_backend_pid()",
|
|
17
|
+
[test_database_name],
|
|
18
|
+
)
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
return _orig(self, test_database_name, verbosity)
|
|
22
|
+
|
|
23
|
+
cls._destroy_test_db = _patched
|
|
24
|
+
cls._itw_terminate_patched = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_install()
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import smtplib
|
|
3
|
-
import ssl
|
|
4
2
|
import sys
|
|
5
|
-
from email.mime.multipart import MIMEMultipart
|
|
6
|
-
from email.mime.text import MIMEText
|
|
7
3
|
from pathlib import Path
|
|
8
4
|
|
|
5
|
+
import requests
|
|
6
|
+
|
|
9
7
|
TEMPLATE_PATH = Path(__file__).parent / 'templates' / 'new_version_email.html'
|
|
10
8
|
|
|
11
9
|
|
|
@@ -21,14 +19,12 @@ def _env_getter():
|
|
|
21
19
|
return lambda key, default=None: os.environ[key] if default is None else os.environ.get(key, default)
|
|
22
20
|
|
|
23
21
|
|
|
24
|
-
def
|
|
22
|
+
def _read_mailgun_config() -> dict:
|
|
25
23
|
get = _env_getter()
|
|
26
24
|
return {
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'password': get('EMAIL_HOST_PASSWORD'),
|
|
31
|
-
'use_tls': str(get('EMAIL_USE_TLS', 'True')).lower() == 'true',
|
|
25
|
+
'from_email': get('FROM_EMAIL'),
|
|
26
|
+
'base_url': get('MAILGUN_BASE_URL'),
|
|
27
|
+
'api_key': get('MAILGUN_API_KEY'),
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
|
|
@@ -56,24 +52,28 @@ def send_new_version_email(version: str, commit_message: str = '') -> int:
|
|
|
56
52
|
print('[notify] EMAIL_RECIPIENTS is empty — skipping email notification.')
|
|
57
53
|
return 0
|
|
58
54
|
|
|
59
|
-
cfg =
|
|
55
|
+
cfg = _read_mailgun_config()
|
|
60
56
|
html_body = _render_template(version, commit_message)
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
58
|
+
try:
|
|
59
|
+
data = {
|
|
60
|
+
'from': cfg['from_email'],
|
|
61
|
+
'to': recipients,
|
|
62
|
+
'subject': f'itw-python-builder — Version i ri ({version})',
|
|
63
|
+
'html': html_body,
|
|
64
|
+
}
|
|
65
|
+
response = requests.post(
|
|
66
|
+
cfg['base_url'],
|
|
67
|
+
auth=("api", cfg['api_key']),
|
|
68
|
+
data=data,
|
|
69
|
+
)
|
|
70
|
+
if not response.ok:
|
|
71
|
+
raise RuntimeError(f'Mailgun API error {response.status_code}: {response.text}')
|
|
72
|
+
print(f'[notify] Sent release email for {version} to {len(recipients)} recipient(s).')
|
|
73
|
+
return len(recipients)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
print(f'FAILED TO SEND EMAIL TO {str(recipients)}: {str(e)}')
|
|
76
|
+
return 0
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
def _main() -> int:
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from itw_python_builder.utils import _gitlab_api_call
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
DEFAULT_ACCEPTANCE_CRITERIA = (
|
|
7
|
+
'- [ ] U kryen testet manuale në ndërfaqe\n'
|
|
8
|
+
'- [ ] U kryen testet automatike\n'
|
|
9
|
+
'- [ ] U trajtuan sugjerimet e SonarQube\n'
|
|
10
|
+
'- [ ] U kontrollua cilësia e kodit'
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_RE_AC_HEADER = re.compile(r'(?im)^##\s*acceptance\s+criteria\s*$')
|
|
15
|
+
_RE_COMMENT_HEADER = re.compile(r'(?im)^##\s*comment\s*$')
|
|
16
|
+
_RE_AC_BLOCK = re.compile(
|
|
17
|
+
r'(?ims)^##\s*acceptance\s+criteria\s*$(.*?)^##\s*acceptance\s+criteria\s*$'
|
|
18
|
+
)
|
|
19
|
+
_RE_COMMENT_BLOCK = re.compile(
|
|
20
|
+
r'(?ims)^##\s*comment\s*$(.*?)^##\s*comment\s*$'
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_COLOR_GREEN = '\033[92m'
|
|
25
|
+
_COLOR_RED = '\033[91m'
|
|
26
|
+
_COLOR_RESET = '\033[0m'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def log_green(msg: str) -> None:
|
|
30
|
+
print(f'{_COLOR_GREEN}{msg}{_COLOR_RESET}')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def log_red(msg: str) -> None:
|
|
34
|
+
print(f'{_COLOR_RED}{msg}{_COLOR_RESET}')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def log_info(msg: str) -> None:
|
|
38
|
+
print(msg)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _list_group_projects(api_host: str, group_path: str, token: str) -> list:
|
|
42
|
+
from urllib.parse import quote
|
|
43
|
+
encoded = quote(group_path, safe='')
|
|
44
|
+
return _gitlab_api_call(
|
|
45
|
+
api_host,
|
|
46
|
+
f'/groups/{encoded}/projects?per_page=100&search=_pm',
|
|
47
|
+
token,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_pm_repo(api_host: str, project_path: str, token: str) -> list:
|
|
52
|
+
"""Find _pm repos by walking the namespace upward from project_path."""
|
|
53
|
+
parts = project_path.split('/')
|
|
54
|
+
if len(parts) < 2:
|
|
55
|
+
raise RuntimeError(f'Project path {project_path!r} has no parent group.')
|
|
56
|
+
parts.pop()
|
|
57
|
+
while parts:
|
|
58
|
+
namespace = '/'.join(parts)
|
|
59
|
+
try:
|
|
60
|
+
projects = _list_group_projects(api_host, namespace, token)
|
|
61
|
+
except RuntimeError as exc:
|
|
62
|
+
if '404' in str(exc):
|
|
63
|
+
parts.pop()
|
|
64
|
+
continue
|
|
65
|
+
raise
|
|
66
|
+
matches = [p for p in projects if p.get('path', '').endswith('_pm')]
|
|
67
|
+
if matches:
|
|
68
|
+
return matches
|
|
69
|
+
parts.pop()
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def create_gitlab_issue(api_host: str, project_id: int, payload: dict, token: str) -> dict:
|
|
74
|
+
return _gitlab_api_call(
|
|
75
|
+
api_host,
|
|
76
|
+
f'/projects/{project_id}/issues',
|
|
77
|
+
token,
|
|
78
|
+
method='POST',
|
|
79
|
+
payload=payload,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_project_by_path(api_host: str, project_path: str, token: str) -> dict:
|
|
84
|
+
from urllib.parse import quote
|
|
85
|
+
encoded = quote(project_path, safe='')
|
|
86
|
+
return _gitlab_api_call(api_host, f'/projects/{encoded}', token)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_project_milestone(api_host: str, project_id: int, title: str, token: str):
|
|
90
|
+
from urllib.parse import quote
|
|
91
|
+
encoded = quote(title, safe='')
|
|
92
|
+
results = _gitlab_api_call(
|
|
93
|
+
api_host,
|
|
94
|
+
f'/projects/{project_id}/milestones?search={encoded}',
|
|
95
|
+
token,
|
|
96
|
+
)
|
|
97
|
+
for m in results:
|
|
98
|
+
if m.get('title') == title:
|
|
99
|
+
return m
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def create_project_milestone(
|
|
104
|
+
api_host: str,
|
|
105
|
+
project_id: int,
|
|
106
|
+
title: str,
|
|
107
|
+
start_date: str = None,
|
|
108
|
+
due_date: str = None,
|
|
109
|
+
token: str = None,
|
|
110
|
+
) -> dict:
|
|
111
|
+
payload = {'title': title}
|
|
112
|
+
if start_date:
|
|
113
|
+
payload['start_date'] = start_date
|
|
114
|
+
if due_date:
|
|
115
|
+
payload['due_date'] = due_date
|
|
116
|
+
return _gitlab_api_call(
|
|
117
|
+
api_host,
|
|
118
|
+
f'/projects/{project_id}/milestones',
|
|
119
|
+
token,
|
|
120
|
+
method='POST',
|
|
121
|
+
payload=payload,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def create_issue_note(api_host: str, project_id: int, issue_iid: int, body: str, token: str) -> dict:
|
|
126
|
+
return _gitlab_api_call(
|
|
127
|
+
api_host,
|
|
128
|
+
f'/projects/{project_id}/issues/{issue_iid}/notes',
|
|
129
|
+
token,
|
|
130
|
+
method='POST',
|
|
131
|
+
payload={'body': body},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _strip_milestone_wrap(raw: str) -> str:
|
|
136
|
+
raw = raw.strip()
|
|
137
|
+
if raw.startswith('%"') and raw.endswith('"'):
|
|
138
|
+
return raw[2:-1]
|
|
139
|
+
if raw.startswith('%'):
|
|
140
|
+
return raw[1:].strip().strip('"')
|
|
141
|
+
return raw
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_md_issues(file_path: str) -> list:
|
|
145
|
+
"""Parse a markdown file into structured issue dicts separated by '==='."""
|
|
146
|
+
with open(file_path, 'r', encoding='utf-8') as fp:
|
|
147
|
+
content = fp.read().replace('\r\n', '\n')
|
|
148
|
+
blocks = re.split(r'(?m)^\s*===\s*$', content)
|
|
149
|
+
issues = []
|
|
150
|
+
for idx, block in enumerate(blocks, start=1):
|
|
151
|
+
block = block.strip()
|
|
152
|
+
if not block:
|
|
153
|
+
continue
|
|
154
|
+
issues.append(_parse_single_issue(block, idx, file_path))
|
|
155
|
+
if not issues:
|
|
156
|
+
raise RuntimeError(f'No issues found in {file_path}.')
|
|
157
|
+
return issues
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
|
|
161
|
+
ac_headers = _RE_AC_HEADER.findall(block)
|
|
162
|
+
if len(ac_headers) == 1:
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
f'Block {idx} in {file_path}: found opening "## Acceptance Criteria" '
|
|
165
|
+
'but no closing "## Acceptance Criteria" tag.'
|
|
166
|
+
)
|
|
167
|
+
cm_headers = _RE_COMMENT_HEADER.findall(block)
|
|
168
|
+
if len(cm_headers) == 1:
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
f'Block {idx} in {file_path}: found opening "## Comment" '
|
|
171
|
+
'but no closing "## Comment" tag.'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
lines = block.splitlines()
|
|
175
|
+
title = None
|
|
176
|
+
body_start = 0
|
|
177
|
+
for i, line in enumerate(lines):
|
|
178
|
+
if line.strip():
|
|
179
|
+
title = line.lstrip('#').strip()
|
|
180
|
+
body_start = i + 1
|
|
181
|
+
break
|
|
182
|
+
if not title:
|
|
183
|
+
raise RuntimeError(f'Block {idx} in {file_path}: missing title (first non-empty line).')
|
|
184
|
+
|
|
185
|
+
remaining = '\n'.join(lines[body_start:])
|
|
186
|
+
|
|
187
|
+
comment_text = None
|
|
188
|
+
if len(cm_headers) >= 2:
|
|
189
|
+
m = _RE_COMMENT_BLOCK.search(remaining)
|
|
190
|
+
if m:
|
|
191
|
+
comment_text = m.group(1).strip()
|
|
192
|
+
remaining = _RE_COMMENT_BLOCK.sub('', remaining, count=1)
|
|
193
|
+
|
|
194
|
+
acceptance_text = None
|
|
195
|
+
if len(ac_headers) >= 2:
|
|
196
|
+
m = _RE_AC_BLOCK.search(remaining)
|
|
197
|
+
if m:
|
|
198
|
+
acceptance_text = m.group(1).strip()
|
|
199
|
+
remaining = _RE_AC_BLOCK.sub('', remaining, count=1)
|
|
200
|
+
|
|
201
|
+
repo = None
|
|
202
|
+
milestone = None
|
|
203
|
+
milestone_start = None
|
|
204
|
+
milestone_end = None
|
|
205
|
+
assignee = None
|
|
206
|
+
has_estimate = False
|
|
207
|
+
has_due = False
|
|
208
|
+
body_lines = []
|
|
209
|
+
|
|
210
|
+
for line in remaining.splitlines():
|
|
211
|
+
stripped = line.strip()
|
|
212
|
+
|
|
213
|
+
m = re.match(r'^/repo\s+(\S+)\s*$', stripped, re.IGNORECASE)
|
|
214
|
+
if m:
|
|
215
|
+
repo = m.group(1)
|
|
216
|
+
continue
|
|
217
|
+
m = re.match(r'^/milestone-start\s+(\S+)\s*$', stripped, re.IGNORECASE)
|
|
218
|
+
if m:
|
|
219
|
+
milestone_start = m.group(1)
|
|
220
|
+
continue
|
|
221
|
+
m = re.match(r'^/milestone-end\s+(\S+)\s*$', stripped, re.IGNORECASE)
|
|
222
|
+
if m:
|
|
223
|
+
milestone_end = m.group(1)
|
|
224
|
+
continue
|
|
225
|
+
m = re.match(r'^/milestone\s+(.+)$', stripped, re.IGNORECASE)
|
|
226
|
+
if m:
|
|
227
|
+
milestone = _strip_milestone_wrap(m.group(1))
|
|
228
|
+
continue
|
|
229
|
+
m = re.match(r'^/assignee\s+(.+)$', stripped, re.IGNORECASE)
|
|
230
|
+
if m:
|
|
231
|
+
assignee = m.group(1).strip()
|
|
232
|
+
body_lines.append(f'/assign {assignee}')
|
|
233
|
+
continue
|
|
234
|
+
if re.match(r'^/estimate\s+\S+', stripped, re.IGNORECASE):
|
|
235
|
+
has_estimate = True
|
|
236
|
+
if re.match(r'^/due\s+\S+', stripped, re.IGNORECASE):
|
|
237
|
+
has_due = True
|
|
238
|
+
|
|
239
|
+
body_lines.append(line)
|
|
240
|
+
|
|
241
|
+
body = '\n'.join(body_lines).strip()
|
|
242
|
+
return {
|
|
243
|
+
'title': title,
|
|
244
|
+
'body': body,
|
|
245
|
+
'repo': repo,
|
|
246
|
+
'milestone': milestone,
|
|
247
|
+
'milestone_start': milestone_start,
|
|
248
|
+
'milestone_end': milestone_end,
|
|
249
|
+
'assignee': assignee,
|
|
250
|
+
'has_estimate': has_estimate,
|
|
251
|
+
'has_due': has_due,
|
|
252
|
+
'has_acceptance_criteria': acceptance_text is not None,
|
|
253
|
+
'acceptance_criteria': acceptance_text,
|
|
254
|
+
'has_comment': comment_text is not None,
|
|
255
|
+
'comment': comment_text,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def build_issue_description(issue: dict) -> str:
|
|
260
|
+
"""Compose the final GitLab issue description with acceptance criteria section."""
|
|
261
|
+
parts = []
|
|
262
|
+
if issue['body']:
|
|
263
|
+
parts.append(issue['body'])
|
|
264
|
+
ac = issue['acceptance_criteria'] if issue['has_acceptance_criteria'] else DEFAULT_ACCEPTANCE_CRITERIA
|
|
265
|
+
parts.append(f'## Acceptance Criteria\n{ac}')
|
|
266
|
+
return '\n\n'.join(parts)
|
|
@@ -8,6 +8,7 @@ from datetime import datetime
|
|
|
8
8
|
from itw_python_builder.ssr_tasks import * # noqa: F401,F403 — exposes ssr-init
|
|
9
9
|
from itw_python_builder.utils import (
|
|
10
10
|
detect_and_activate_venv,
|
|
11
|
+
terminate_stale_test_db_sessions,
|
|
11
12
|
load_env,
|
|
12
13
|
get_current_branch,
|
|
13
14
|
check_branch,
|
|
@@ -28,9 +29,19 @@ from itw_python_builder.utils import (
|
|
|
28
29
|
TOKEN_CACHE_PATH,
|
|
29
30
|
maybe_override_release_version,
|
|
30
31
|
ensure_gitlab_token,
|
|
32
|
+
)
|
|
33
|
+
from itw_python_builder.task_utils import (
|
|
31
34
|
resolve_pm_repo,
|
|
32
|
-
|
|
35
|
+
create_gitlab_issue,
|
|
33
36
|
parse_md_issues,
|
|
37
|
+
build_issue_description,
|
|
38
|
+
get_project_by_path,
|
|
39
|
+
find_project_milestone,
|
|
40
|
+
create_project_milestone,
|
|
41
|
+
create_issue_note,
|
|
42
|
+
log_green,
|
|
43
|
+
log_red,
|
|
44
|
+
log_info,
|
|
34
45
|
)
|
|
35
46
|
from itw_python_builder.version import Version
|
|
36
47
|
|
|
@@ -113,7 +124,7 @@ def logout(ctx: Context):
|
|
|
113
124
|
|
|
114
125
|
@task(name='task')
|
|
115
126
|
def create_task(ctx: Context, file=None):
|
|
116
|
-
"""Create GitLab
|
|
127
|
+
"""Create GitLab issues in the group's _pm repo from a markdown file (--file path.md)."""
|
|
117
128
|
if not file:
|
|
118
129
|
raise RuntimeError('Missing --file path to markdown file.')
|
|
119
130
|
if not file.lower().endswith('.md'):
|
|
@@ -123,51 +134,126 @@ def create_task(ctx: Context, file=None):
|
|
|
123
134
|
if not os.path.isdir(os.path.join(os.getcwd(), '.git')):
|
|
124
135
|
raise RuntimeError('Not a git repository (no .git folder here).')
|
|
125
136
|
|
|
126
|
-
|
|
127
|
-
|
|
137
|
+
try:
|
|
138
|
+
issues = parse_md_issues(file)
|
|
139
|
+
except RuntimeError as exc:
|
|
140
|
+
log_red(str(exc))
|
|
141
|
+
raise
|
|
142
|
+
print(f'[itw] Parsed {len(issues)} issue(s) from {file}.')
|
|
128
143
|
|
|
129
144
|
token = ensure_gitlab_token(ctx)
|
|
130
145
|
host, project_path = _parse_gitlab_remote(ctx)
|
|
131
146
|
api_host = _GITLAB_API_HOST_MAP.get(host, host)
|
|
132
147
|
|
|
133
|
-
|
|
148
|
+
for idx, issue in enumerate(issues, start=1):
|
|
149
|
+
print(f'\n[itw] Issue {idx}/{len(issues)}: {issue["title"]}')
|
|
150
|
+
try:
|
|
151
|
+
project = _resolve_target_project(api_host, project_path, issue, token)
|
|
152
|
+
|
|
153
|
+
if issue['has_acceptance_criteria']:
|
|
154
|
+
log_info('Using inputed acceptance criteria')
|
|
155
|
+
else:
|
|
156
|
+
log_green(
|
|
157
|
+
'Default acceptance criteria applied. To define a custom acceptance criteria, '
|
|
158
|
+
'define the section ## Acceptance Criteria in your md file and make sure to end '
|
|
159
|
+
'it with ## Acceptance Criteria'
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if issue['assignee']:
|
|
163
|
+
log_info(f'This issue will be assigned to {issue["assignee"]}')
|
|
164
|
+
else:
|
|
165
|
+
log_red('No asignee is defined for this issue.Use /assignee in you md file to define an asignee')
|
|
166
|
+
|
|
167
|
+
if not issue['has_estimate']:
|
|
168
|
+
log_red('There is no estimation for this issue. Use /estimate in you md file to define an estimation')
|
|
169
|
+
|
|
170
|
+
if not issue['has_due']:
|
|
171
|
+
log_red('No due date is defined for this issue. Use /due in you md file to define a due date')
|
|
172
|
+
|
|
173
|
+
milestone_id = _resolve_milestone(api_host, project['id'], issue, token)
|
|
174
|
+
|
|
175
|
+
description = build_issue_description(issue)
|
|
176
|
+
payload = {'title': issue['title'], 'description': description}
|
|
177
|
+
if milestone_id is not None:
|
|
178
|
+
payload['milestone_id'] = milestone_id
|
|
179
|
+
|
|
180
|
+
result = create_gitlab_issue(api_host, project['id'], payload, token)
|
|
181
|
+
print(f'[itw] Created: {result["web_url"]}')
|
|
182
|
+
|
|
183
|
+
if issue['has_comment']:
|
|
184
|
+
create_issue_note(api_host, project['id'], result['iid'], issue['comment'], token)
|
|
185
|
+
log_info('Comments added sucessfully')
|
|
186
|
+
else:
|
|
187
|
+
log_green(
|
|
188
|
+
'No coments found on this issue. To leave a comment define the section '
|
|
189
|
+
'## Comment section in your md file and make sure to end it with ## Comment'
|
|
190
|
+
)
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
log_red(f'Failed at issue {idx}/{len(issues)} ({issue["title"]!r}): {exc}')
|
|
193
|
+
print(f'[itw] {idx - 1} issue(s) were created successfully before this failure.')
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _resolve_target_project(api_host: str, project_path: str, issue: dict, token: str) -> dict:
|
|
198
|
+
if issue['repo']:
|
|
199
|
+
log_info(f'Using {issue["repo"]} repo')
|
|
200
|
+
return get_project_by_path(api_host, issue['repo'], token)
|
|
201
|
+
|
|
202
|
+
log_green(f'Using current git repo {project_path}')
|
|
134
203
|
candidates = resolve_pm_repo(api_host, project_path, token)
|
|
135
204
|
if not candidates:
|
|
136
205
|
raise RuntimeError(
|
|
137
206
|
f'No _pm repo found anywhere in the namespace of {project_path!r}.'
|
|
138
207
|
)
|
|
139
|
-
|
|
140
208
|
if len(candidates) == 1:
|
|
141
209
|
chosen = candidates[0]
|
|
142
210
|
print(f'[itw] Using _pm repo: {chosen["path_with_namespace"]}')
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
try:
|
|
155
|
-
idx = int(raw)
|
|
156
|
-
if 1 <= idx <= len(candidates):
|
|
157
|
-
chosen = candidates[idx - 1]
|
|
158
|
-
break
|
|
159
|
-
except ValueError:
|
|
160
|
-
pass
|
|
161
|
-
print('Invalid choice, try again.')
|
|
162
|
-
|
|
163
|
-
for idx, (title, body) in enumerate(issues, start=1):
|
|
211
|
+
return chosen
|
|
212
|
+
if not sys.stdin.isatty():
|
|
213
|
+
names = ', '.join(p['path_with_namespace'] for p in candidates)
|
|
214
|
+
raise RuntimeError(
|
|
215
|
+
f'{len(candidates)} _pm repos found ({names}) and stdin is not a TTY — cannot prompt.'
|
|
216
|
+
)
|
|
217
|
+
print(f'[itw] Found {len(candidates)} _pm repos:')
|
|
218
|
+
for i, p in enumerate(candidates, start=1):
|
|
219
|
+
print(f' [{i}] {p["path_with_namespace"]}')
|
|
220
|
+
while True:
|
|
221
|
+
raw = input(f'Pick one (1-{len(candidates)}): ').strip()
|
|
164
222
|
try:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
print(
|
|
223
|
+
idx = int(raw)
|
|
224
|
+
if 1 <= idx <= len(candidates):
|
|
225
|
+
return candidates[idx - 1]
|
|
226
|
+
except ValueError:
|
|
227
|
+
pass
|
|
228
|
+
print('Invalid choice, try again.')
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _resolve_milestone(api_host: str, project_id: int, issue: dict, token: str):
|
|
232
|
+
title = issue['milestone']
|
|
233
|
+
if not title:
|
|
234
|
+
log_red(
|
|
235
|
+
'There is no milestone defined for this issue. '
|
|
236
|
+
'Use /milestone in your md file to define a milestone'
|
|
237
|
+
)
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
existing = find_project_milestone(api_host, project_id, title, token)
|
|
241
|
+
if existing:
|
|
242
|
+
log_info(f'Using existing milestone {title}')
|
|
243
|
+
return existing['id']
|
|
244
|
+
|
|
245
|
+
start = issue['milestone_start']
|
|
246
|
+
end = issue['milestone_end']
|
|
247
|
+
if start and end:
|
|
248
|
+
created = create_project_milestone(api_host, project_id, title, start, end, token)
|
|
249
|
+
log_green(f'Created new milestone {title} | {start}-{end}')
|
|
250
|
+
else:
|
|
251
|
+
created = create_project_milestone(api_host, project_id, title, token=token)
|
|
252
|
+
log_green(
|
|
253
|
+
f'Created new milestone {title} | No start date or end date is defined for this '
|
|
254
|
+
'new milestone. Use /milestone-start and /milestone-end in your md file to define them '
|
|
255
|
+
)
|
|
256
|
+
return created['id']
|
|
171
257
|
|
|
172
258
|
|
|
173
259
|
def build_frontend(ctx: Context, branch: str, ssr: bool = False) -> None:
|
|
@@ -308,7 +394,7 @@ def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False,
|
|
|
308
394
|
install_sigint_guard()
|
|
309
395
|
project_type = detect_project_type()
|
|
310
396
|
if not skip_pipeline:
|
|
311
|
-
step('tests', test, ctx)
|
|
397
|
+
step('tests', test, ctx, warn=False)
|
|
312
398
|
step('analyze', analyze, ctx)
|
|
313
399
|
step('tag', tag, ctx, version)
|
|
314
400
|
if project_type == 'backend':
|
|
@@ -442,14 +528,26 @@ def release(ctx: Context, skip_pipeline=False, ssr=False) -> None:
|
|
|
442
528
|
|
|
443
529
|
|
|
444
530
|
@task
|
|
445
|
-
def test(ctx: Context, settings='backend.test_settings', warn=
|
|
446
|
-
"""Run tests with coverage.
|
|
531
|
+
def test(ctx: Context, settings='backend.test_settings', warn=True):
|
|
532
|
+
"""Run tests with coverage."""
|
|
447
533
|
if detect_project_type() == 'frontend':
|
|
448
534
|
ctx.run('npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox', warn=warn)
|
|
449
535
|
print("✓ Tests completed")
|
|
450
536
|
return
|
|
451
537
|
detect_and_activate_venv()
|
|
452
|
-
ctx
|
|
538
|
+
load_env(ctx)
|
|
539
|
+
terminate_stale_test_db_sessions(ctx, settings)
|
|
540
|
+
patch_dir = str(Path(__file__).parent / '_pyruntime')
|
|
541
|
+
existing_pp = os.environ.get('PYTHONPATH', '')
|
|
542
|
+
injected_pp = f'{patch_dir}:{existing_pp}' if existing_pp else patch_dir
|
|
543
|
+
try:
|
|
544
|
+
ctx.run(
|
|
545
|
+
f'python -m coverage run manage.py test --settings={settings} --noinput -v 2',
|
|
546
|
+
warn=warn,
|
|
547
|
+
env={'PYTHONPATH': injected_pp},
|
|
548
|
+
)
|
|
549
|
+
finally:
|
|
550
|
+
terminate_stale_test_db_sessions(ctx, settings)
|
|
453
551
|
ctx.run('python -m coverage report -m')
|
|
454
552
|
ctx.run('python -m coverage xml -o coverage.xml')
|
|
455
553
|
print("✓ Tests completed")
|
|
@@ -510,6 +608,10 @@ def analyze(ctx: Context):
|
|
|
510
608
|
|
|
511
609
|
if not sonar_host or not sonar_token:
|
|
512
610
|
raise RuntimeError('SONAR_HOST_URL and SONAR_TOKEN environment variables must be set.')
|
|
611
|
+
|
|
612
|
+
sonar_cache_dir = os.path.join(os.path.expanduser('~'), '.sonar', 'cache')
|
|
613
|
+
os.makedirs(sonar_cache_dir, exist_ok=True)
|
|
614
|
+
|
|
513
615
|
ctx.run(
|
|
514
616
|
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 -v $HOME/.sonar/cache:/opt/sonar-scanner/.sonar/cache:Z docker.io/sonarsource/sonar-scanner-cli:latest -Dsonar.projectVersion={sonar_version}')
|
|
515
617
|
|
|
@@ -543,7 +645,7 @@ def pipelinelocal(ctx: Context, settings='backend.test_settings', pylintrc=None)
|
|
|
543
645
|
print("\n" + "=" * 60)
|
|
544
646
|
print(f"Step {'2' if project_type == 'backend' else '1'}: Running tests...")
|
|
545
647
|
print("=" * 60)
|
|
546
|
-
step('tests', test, ctx, settings, warn=
|
|
648
|
+
step('tests', test, ctx, settings, warn=False)
|
|
547
649
|
|
|
548
650
|
print("\n" + "=" * 60)
|
|
549
651
|
print(f"Step {'3' if project_type == 'backend' else '2'}: Running SonarQube analysis...")
|
|
@@ -2,8 +2,10 @@ import io
|
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
|
+
import shlex
|
|
5
6
|
import signal
|
|
6
7
|
import sys
|
|
8
|
+
import textwrap
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
|
|
9
11
|
from invoke import Context
|
|
@@ -120,6 +122,60 @@ def detect_and_activate_venv():
|
|
|
120
122
|
_venv_activated = True
|
|
121
123
|
|
|
122
124
|
|
|
125
|
+
def terminate_stale_test_db_sessions(ctx: Context, settings_module: str) -> None:
|
|
126
|
+
script = textwrap.dedent(f'''
|
|
127
|
+
import os, sys
|
|
128
|
+
os.environ["DJANGO_SETTINGS_MODULE"] = {settings_module!r}
|
|
129
|
+
try:
|
|
130
|
+
import django
|
|
131
|
+
django.setup()
|
|
132
|
+
from django.conf import settings as _s
|
|
133
|
+
except Exception as _e:
|
|
134
|
+
print(f"[itw] session-cleanup skipped: cannot load Django settings ({{_e}})")
|
|
135
|
+
sys.exit(0)
|
|
136
|
+
try:
|
|
137
|
+
import psycopg2 as _pg
|
|
138
|
+
except ImportError:
|
|
139
|
+
try:
|
|
140
|
+
import psycopg as _pg
|
|
141
|
+
except ImportError:
|
|
142
|
+
print("[itw] session-cleanup skipped: no psycopg installed")
|
|
143
|
+
sys.exit(0)
|
|
144
|
+
for _alias, _cfg in _s.DATABASES.items():
|
|
145
|
+
if "postgresql" not in _cfg.get("ENGINE", ""):
|
|
146
|
+
continue
|
|
147
|
+
_test_name = (_cfg.get("TEST") or {{}}).get("NAME") or f"test_{{_cfg.get('NAME', '')}}"
|
|
148
|
+
if not _test_name or _test_name == "test_":
|
|
149
|
+
continue
|
|
150
|
+
try:
|
|
151
|
+
_conn = _pg.connect(
|
|
152
|
+
host=_cfg.get("HOST") or "localhost",
|
|
153
|
+
port=int(_cfg.get("PORT") or 5432),
|
|
154
|
+
user=_cfg.get("USER") or None,
|
|
155
|
+
password=_cfg.get("PASSWORD") or None,
|
|
156
|
+
dbname="postgres",
|
|
157
|
+
connect_timeout=5,
|
|
158
|
+
)
|
|
159
|
+
_conn.autocommit = True
|
|
160
|
+
_cur = _conn.cursor()
|
|
161
|
+
_cur.execute(
|
|
162
|
+
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
|
163
|
+
"WHERE datname = %s AND pid <> pg_backend_pid()",
|
|
164
|
+
[_test_name],
|
|
165
|
+
)
|
|
166
|
+
_rows = _cur.fetchall()
|
|
167
|
+
_cur.close()
|
|
168
|
+
_conn.close()
|
|
169
|
+
_killed = len(_rows)
|
|
170
|
+
if _killed > 0:
|
|
171
|
+
print(f"[itw] Terminated {{_killed}} stale session(s) on {{_test_name}}")
|
|
172
|
+
except Exception as _e:
|
|
173
|
+
print(f"[itw] session-cleanup skipped for {{_test_name}}: {{_e}}")
|
|
174
|
+
continue
|
|
175
|
+
''').strip()
|
|
176
|
+
ctx.run(f'python -c {shlex.quote(script)}', warn=True)
|
|
177
|
+
|
|
178
|
+
|
|
123
179
|
def _parse_angular_env_file(path: str) -> dict:
|
|
124
180
|
"""Extract string-valued keys from an Angular environment.ts object literal."""
|
|
125
181
|
with open(path, 'r', encoding='utf-8') as fp:
|
|
@@ -494,72 +550,3 @@ def ensure_gitlab_token(ctx: Context) -> str:
|
|
|
494
550
|
return token
|
|
495
551
|
|
|
496
552
|
|
|
497
|
-
def _list_group_projects(api_host: str, group_path: str, token: str) -> list:
|
|
498
|
-
"""List up to 100 projects in a group matching '_pm'."""
|
|
499
|
-
from urllib.parse import quote
|
|
500
|
-
encoded = quote(group_path, safe='')
|
|
501
|
-
return _gitlab_api_call(
|
|
502
|
-
api_host,
|
|
503
|
-
f'/groups/{encoded}/projects?per_page=100&search=_pm',
|
|
504
|
-
token,
|
|
505
|
-
)
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
def resolve_pm_repo(api_host: str, project_path: str, token: str) -> list:
|
|
509
|
-
"""Find _pm repos by walking the namespace upward from project_path."""
|
|
510
|
-
parts = project_path.split('/')
|
|
511
|
-
if len(parts) < 2:
|
|
512
|
-
raise RuntimeError(f'Project path {project_path!r} has no parent group.')
|
|
513
|
-
parts.pop()
|
|
514
|
-
while parts:
|
|
515
|
-
namespace = '/'.join(parts)
|
|
516
|
-
try:
|
|
517
|
-
projects = _list_group_projects(api_host, namespace, token)
|
|
518
|
-
except RuntimeError as exc:
|
|
519
|
-
if '404' in str(exc):
|
|
520
|
-
parts.pop()
|
|
521
|
-
continue
|
|
522
|
-
raise
|
|
523
|
-
matches = [p for p in projects if p.get('path', '').endswith('_pm')]
|
|
524
|
-
if matches:
|
|
525
|
-
return matches
|
|
526
|
-
parts.pop()
|
|
527
|
-
return []
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
def create_gitlab_task(api_host: str, project_id: int, title: str, description: str, token: str) -> dict:
|
|
531
|
-
"""Create a GitLab task in the given project."""
|
|
532
|
-
return _gitlab_api_call(
|
|
533
|
-
api_host,
|
|
534
|
-
f'/projects/{project_id}/issues',
|
|
535
|
-
token,
|
|
536
|
-
method='POST',
|
|
537
|
-
payload={'title': title, 'description': description, 'issue_type': 'task'},
|
|
538
|
-
)
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
def parse_md_issues(file_path: str) -> list:
|
|
542
|
-
"""Parse a markdown file into (title, body) blocks separated by '==='."""
|
|
543
|
-
with open(file_path, 'r', encoding='utf-8') as fp:
|
|
544
|
-
content = fp.read().replace('\r\n', '\n')
|
|
545
|
-
blocks = re.split(r'(?m)^\s*===\s*$', content)
|
|
546
|
-
issues = []
|
|
547
|
-
for idx, block in enumerate(blocks, start=1):
|
|
548
|
-
block = block.strip()
|
|
549
|
-
if not block:
|
|
550
|
-
continue
|
|
551
|
-
lines = block.splitlines()
|
|
552
|
-
title = None
|
|
553
|
-
body_start = 0
|
|
554
|
-
for i, line in enumerate(lines):
|
|
555
|
-
if line.strip():
|
|
556
|
-
title = line.lstrip('#').strip()
|
|
557
|
-
body_start = i + 1
|
|
558
|
-
break
|
|
559
|
-
if not title:
|
|
560
|
-
raise RuntimeError(f'Block {idx} in {file_path}: missing title (first non-empty line).')
|
|
561
|
-
body = '\n'.join(lines[body_start:]).strip()
|
|
562
|
-
issues.append((title, body))
|
|
563
|
-
if not issues:
|
|
564
|
-
raise RuntimeError(f'No issues found in {file_path}.')
|
|
565
|
-
return issues
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: itw_python_builder
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
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
|
|
@@ -24,6 +24,7 @@ Requires-Dist: invoke>=2.0.0
|
|
|
24
24
|
Requires-Dist: pylint>=3.0.0
|
|
25
25
|
Requires-Dist: pylint-django>=2.5.0
|
|
26
26
|
Requires-Dist: python-decouple>=3.8
|
|
27
|
+
Requires-Dist: requests>=2.28.0
|
|
27
28
|
Dynamic: license-file
|
|
28
29
|
|
|
29
30
|
# ITW Python Builder
|
{itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/SOURCES.txt
RENAMED
|
@@ -6,6 +6,7 @@ itw_python_builder/__init__.py
|
|
|
6
6
|
itw_python_builder/cli.py
|
|
7
7
|
itw_python_builder/notify.py
|
|
8
8
|
itw_python_builder/ssr_tasks.py
|
|
9
|
+
itw_python_builder/task_utils.py
|
|
9
10
|
itw_python_builder/tasks.py
|
|
10
11
|
itw_python_builder/utils.py
|
|
11
12
|
itw_python_builder/version.py
|
|
@@ -15,6 +16,7 @@ itw_python_builder.egg-info/dependency_links.txt
|
|
|
15
16
|
itw_python_builder.egg-info/entry_points.txt
|
|
16
17
|
itw_python_builder.egg-info/requires.txt
|
|
17
18
|
itw_python_builder.egg-info/top_level.txt
|
|
19
|
+
itw_python_builder/_pyruntime/sitecustomize.py
|
|
18
20
|
itw_python_builder/templates/new_version_email.html
|
|
19
21
|
itw_python_builder/templates/server.sitemap.snippet.ts
|
|
20
22
|
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.2.
|
|
7
|
+
version = "0.2.6"
|
|
8
8
|
description = "Standardized Django deployment pipeline with Docker, testing, and SonarQube integration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -29,6 +29,7 @@ dependencies = [
|
|
|
29
29
|
"pylint>=3.0.0",
|
|
30
30
|
"pylint-django>=2.5.0",
|
|
31
31
|
"python-decouple>=3.8",
|
|
32
|
+
"requests>=2.28.0",
|
|
32
33
|
]
|
|
33
34
|
|
|
34
35
|
[project.urls]
|
|
@@ -40,7 +41,7 @@ Issues = "https://git.it-works.io/"
|
|
|
40
41
|
include-package-data = true
|
|
41
42
|
|
|
42
43
|
[tool.setuptools.package-data]
|
|
43
|
-
itw_python_builder = [".pylintrc", "templates/*.ts", "templates/*.html"]
|
|
44
|
+
itw_python_builder = [".pylintrc", "templates/*.ts", "templates/*.html", "_pyruntime/*.py"]
|
|
44
45
|
|
|
45
46
|
[project.scripts]
|
|
46
47
|
itw = "itw_python_builder.cli:main"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder/templates/sitemap.routes.ts
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{itw_python_builder-0.2.4 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|