itw-python-builder 0.2.12__tar.gz → 0.2.13__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 (25) hide show
  1. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/PKG-INFO +1 -1
  2. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/task_utils.py +154 -44
  3. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/tasks.py +54 -8
  4. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/templates/task_template.md +9 -8
  5. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/PKG-INFO +1 -1
  6. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/pyproject.toml +1 -1
  7. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/LICENSE +0 -0
  8. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/README.md +0 -0
  9. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/.pylintrc +0 -0
  10. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/__init__.py +0 -0
  11. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/_pyruntime/sitecustomize.py +0 -0
  12. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/cli.py +0 -0
  13. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/notify.py +0 -0
  14. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/ssr_tasks.py +0 -0
  15. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/templates/new_version_email.html +0 -0
  16. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
  17. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/templates/sitemap.routes.ts +0 -0
  18. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/utils.py +0 -0
  19. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder/version.py +0 -0
  20. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/SOURCES.txt +0 -0
  21. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/dependency_links.txt +0 -0
  22. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/entry_points.txt +0 -0
  23. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/requires.txt +0 -0
  24. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/top_level.txt +0 -0
  25. {itw_python_builder-0.2.12 → itw_python_builder-0.2.13}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.2.12
3
+ Version: 0.2.13
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
@@ -1,7 +1,13 @@
1
1
  import re
2
+ from difflib import SequenceMatcher
2
3
 
3
4
  from itw_python_builder.utils import _gitlab_api_call
4
5
 
6
+ SIMILARITY_THRESHOLD = 0.95
7
+
8
+ DATE_PLACEHOLDER = 'YYYY-MM-DD'
9
+
10
+ DEFAULT_LABEL_COLOR = '#428BCA'
5
11
 
6
12
  DEFAULT_ACCEPTANCE_CRITERIA = (
7
13
  '- [ ] U kryen testet manuale në ndërfaqe\n'
@@ -10,7 +16,6 @@ DEFAULT_ACCEPTANCE_CRITERIA = (
10
16
  '- [ ] U kontrollua cilësia e kodit'
11
17
  )
12
18
 
13
-
14
19
  _RE_AC_HEADER = re.compile(r'(?im)^##\s*acceptance\s+criteria\s*$')
15
20
  _RE_COMMENT_HEADER = re.compile(r'(?im)^##\s*comment\s*$')
16
21
  _RE_AC_BLOCK = re.compile(
@@ -20,9 +25,9 @@ _RE_COMMENT_BLOCK = re.compile(
20
25
  r'(?ims)^##\s*comment\s*$(.*?)^##\s*comment\s*$'
21
26
  )
22
27
 
23
-
24
28
  _COLOR_GREEN = '\033[92m'
25
29
  _COLOR_RED = '\033[91m'
30
+ _COLOR_WHITE = '\033[97m'
26
31
  _COLOR_RESET = '\033[0m'
27
32
 
28
33
 
@@ -34,10 +39,37 @@ def log_red(msg: str) -> None:
34
39
  print(f'{_COLOR_RED}{msg}{_COLOR_RESET}')
35
40
 
36
41
 
42
+ def log_white(msg: str) -> None:
43
+ print(f'{_COLOR_WHITE}{msg}{_COLOR_RESET}')
44
+
45
+
37
46
  def log_info(msg: str) -> None:
38
47
  print(msg)
39
48
 
40
49
 
50
+ def _normalize_title(value: str) -> str:
51
+ return ' '.join((value or '').lower().split())
52
+
53
+
54
+ def is_similar(left: str, right: str, threshold: float = SIMILARITY_THRESHOLD) -> bool:
55
+ """True when two titles are at least `threshold` similar (default 95%)."""
56
+ a = _normalize_title(left)
57
+ b = _normalize_title(right)
58
+ if not a or not b:
59
+ return False
60
+ if a == b:
61
+ return True
62
+ return SequenceMatcher(None, a, b).ratio() >= threshold
63
+
64
+
65
+ def find_similar(value: str, items: list, key: str = 'title', threshold: float = SIMILARITY_THRESHOLD):
66
+ """Return the first item whose `key` is ~threshold similar to `value`, else None."""
67
+ for item in items:
68
+ if is_similar(value, item.get(key, ''), threshold):
69
+ return item
70
+ return None
71
+
72
+
41
73
  def _list_group_projects(api_host: str, group_path: str, token: str) -> list:
42
74
  from urllib.parse import quote
43
75
  encoded = quote(group_path, safe='')
@@ -87,26 +119,30 @@ def get_project_by_path(api_host: str, project_path: str, token: str) -> dict:
87
119
 
88
120
 
89
121
  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
122
  results = _gitlab_api_call(
93
123
  api_host,
94
- f'/projects/{project_id}/milestones?search={encoded}',
124
+ f'/projects/{project_id}/milestones?per_page=100',
95
125
  token,
96
126
  )
97
- for m in results:
98
- if m.get('title') == title:
99
- return m
100
- return None
127
+ return find_similar(title, results)
128
+
129
+
130
+ def find_project_issue(api_host: str, project_id: int, title: str, token: str, milestone: str = None):
131
+ """Look for a ~matching title within this milestone ('None' = the no-milestone bucket)."""
132
+ from urllib.parse import quote
133
+ endpoint = f'/projects/{project_id}/issues?per_page=100'
134
+ endpoint += f'&milestone={quote(milestone, safe="") if milestone else "None"}'
135
+ results = _gitlab_api_call(api_host, endpoint, token)
136
+ return find_similar(title, results)
101
137
 
102
138
 
103
139
  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,
140
+ api_host: str,
141
+ project_id: int,
142
+ title: str,
143
+ start_date: str = None,
144
+ due_date: str = None,
145
+ token: str = None,
110
146
  ) -> dict:
111
147
  payload = {'title': title}
112
148
  if start_date:
@@ -122,6 +158,20 @@ def create_project_milestone(
122
158
  )
123
159
 
124
160
 
161
+ def list_project_labels(api_host: str, project_id: int, token: str) -> list:
162
+ return _gitlab_api_call(api_host, f'/projects/{project_id}/labels?per_page=100', token)
163
+
164
+
165
+ def create_project_label(api_host: str, project_id: int, name: str, token: str) -> dict:
166
+ return _gitlab_api_call(
167
+ api_host,
168
+ f'/projects/{project_id}/labels',
169
+ token,
170
+ method='POST',
171
+ payload={'name': name, 'color': DEFAULT_LABEL_COLOR},
172
+ )
173
+
174
+
125
175
  def create_issue_note(api_host: str, project_id: int, issue_iid: int, body: str, token: str) -> dict:
126
176
  return _gitlab_api_call(
127
177
  api_host,
@@ -132,6 +182,21 @@ def create_issue_note(api_host: str, project_id: int, issue_iid: int, body: str,
132
182
  )
133
183
 
134
184
 
185
+ def _normalize_repo(raw: str) -> str:
186
+ value = (raw or '').strip()
187
+ if not value:
188
+ return ''
189
+ m = re.match(r'^[a-z][a-z0-9+.-]*://[^/]+/(.*)$', value, re.IGNORECASE)
190
+ if m:
191
+ value = m.group(1)
192
+ else:
193
+ m = re.match(r'^[^/\s]+@[^:/\s]+:(.*)$', value)
194
+ if m:
195
+ value = m.group(1)
196
+ value = re.sub(r'\.git$', '', value.strip(), flags=re.IGNORECASE)
197
+ return value.strip('/')
198
+
199
+
135
200
  def _strip_milestone_wrap(raw: str) -> str:
136
201
  raw = raw.strip()
137
202
  if raw.startswith('%"') and raw.endswith('"'):
@@ -143,6 +208,30 @@ def _strip_milestone_wrap(raw: str) -> str:
143
208
 
144
209
  _CRED_KEYS = ('glab_host', 'glab_username', 'glab_token')
145
210
 
211
+ _RE_MARKER_COMMENT = re.compile(r'^<!--.*-->$')
212
+ _RE_EMPTY_CHECKBOX = re.compile(r'^[-*]\s*\[[ xX]?\]\s*$')
213
+
214
+
215
+ def _block_has_content(text: str) -> bool:
216
+ """False when a section holds only blank lines or empty checklist placeholders."""
217
+ for line in (text or '').splitlines():
218
+ stripped = line.strip()
219
+ if not stripped or _RE_EMPTY_CHECKBOX.match(stripped):
220
+ continue
221
+ return True
222
+ return False
223
+
224
+
225
+ def _match_directive(line: str, name: str):
226
+ """None when the line is not this directive, '' when it carries no value."""
227
+ m = re.match(rf'^/{name}(?![\w-])\s*(.*)$', line, re.IGNORECASE)
228
+ if not m:
229
+ return None
230
+ value = m.group(1).strip()
231
+ if value.upper() == DATE_PLACEHOLDER:
232
+ return ''
233
+ return value
234
+
146
235
 
147
236
  def extract_glab_credentials(file_path: str) -> dict:
148
237
  """Scan md file for /glab_host, /glab_username, /glab_token top-level directives."""
@@ -152,9 +241,9 @@ def extract_glab_credentials(file_path: str) -> dict:
152
241
  for line in content.splitlines():
153
242
  stripped = line.strip()
154
243
  for key in _CRED_KEYS:
155
- m = re.match(rf'^/{key}\s+(\S+)\s*$', stripped, re.IGNORECASE)
156
- if m:
157
- creds[key] = m.group(1)
244
+ value = _match_directive(stripped, key)
245
+ if value is not None:
246
+ creds[key] = value or None
158
247
  break
159
248
  return creds
160
249
 
@@ -162,7 +251,7 @@ def extract_glab_credentials(file_path: str) -> dict:
162
251
  def strip_glab_credentials_lines(content: str) -> str:
163
252
  """Remove /glab_* lines so they don't leak into issue bodies."""
164
253
  pattern = re.compile(
165
- rf'(?im)^\s*/(?:{"|".join(_CRED_KEYS)})\s+\S+\s*$\n?'
254
+ rf'(?im)^[ \t]*/(?:{"|".join(_CRED_KEYS)})(?![\w-])[^\n]*(?:\n|$)'
166
255
  )
167
256
  return pattern.sub('', content)
168
257
 
@@ -204,14 +293,16 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
204
293
  if len(cm_headers) >= 2:
205
294
  m = _RE_COMMENT_BLOCK.search(remaining)
206
295
  if m:
207
- comment_text = m.group(1).strip()
296
+ if _block_has_content(m.group(1)):
297
+ comment_text = m.group(1).strip()
208
298
  remaining = _RE_COMMENT_BLOCK.sub('', remaining, count=1)
209
299
 
210
300
  acceptance_text = None
211
301
  if len(ac_headers) >= 2:
212
302
  m = _RE_AC_BLOCK.search(remaining)
213
303
  if m:
214
- acceptance_text = m.group(1).strip()
304
+ if _block_has_content(m.group(1)):
305
+ acceptance_text = m.group(1).strip()
215
306
  remaining = _RE_AC_BLOCK.sub('', remaining, count=1)
216
307
 
217
308
  title = None
@@ -220,6 +311,7 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
220
311
  milestone_start = None
221
312
  milestone_end = None
222
313
  assignee = None
314
+ labels = []
223
315
  has_estimate = False
224
316
  has_due = False
225
317
  body_lines = []
@@ -227,35 +319,52 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
227
319
  for line in remaining.splitlines():
228
320
  stripped = line.strip()
229
321
 
230
- m = re.match(r'^/title\s+(.+)$', stripped, re.IGNORECASE)
231
- if m:
232
- title = m.group(1).strip()
322
+ if _RE_MARKER_COMMENT.match(stripped):
233
323
  continue
234
- m = re.match(r'^/repo\s+(\S+)\s*$', stripped, re.IGNORECASE)
235
- if m:
236
- repo = m.group(1)
324
+
325
+ value = _match_directive(stripped, 'title')
326
+ if value is not None:
327
+ title = value or None
237
328
  continue
238
- m = re.match(r'^/milestone-start\s+(\S+)\s*$', stripped, re.IGNORECASE)
239
- if m:
240
- milestone_start = m.group(1)
329
+ value = _match_directive(stripped, 'repo')
330
+ if value is not None:
331
+ repo = _normalize_repo(value) or None
241
332
  continue
242
- m = re.match(r'^/milestone-end\s+(\S+)\s*$', stripped, re.IGNORECASE)
243
- if m:
244
- milestone_end = m.group(1)
333
+ value = _match_directive(stripped, 'milestone-start')
334
+ if value is not None:
335
+ milestone_start = value or None
245
336
  continue
246
- m = re.match(r'^/milestone\s+(.+)$', stripped, re.IGNORECASE)
247
- if m:
248
- milestone = _strip_milestone_wrap(m.group(1))
337
+ value = _match_directive(stripped, 'milestone-end')
338
+ if value is not None:
339
+ milestone_end = value or None
249
340
  continue
250
- m = re.match(r'^/assignee\s+(.+)$', stripped, re.IGNORECASE)
251
- if m:
252
- assignee = m.group(1).strip()
253
- body_lines.append(f'/assign {assignee}')
341
+ value = _match_directive(stripped, 'milestone')
342
+ if value is not None:
343
+ milestone = _strip_milestone_wrap(value) or None
344
+ continue
345
+ value = _match_directive(stripped, 'label')
346
+ if value is not None:
347
+ labels = [p.strip().lstrip('~').strip('"') for p in value.split(',') if p.strip()]
348
+ labels = [p for p in labels if p]
349
+ continue
350
+ value = _match_directive(stripped, 'assignee')
351
+ if value is not None:
352
+ if value:
353
+ assignee = value
354
+ body_lines.append(f'/assign {assignee}')
355
+ continue
356
+ value = _match_directive(stripped, 'estimate')
357
+ if value is not None:
358
+ if value:
359
+ has_estimate = True
360
+ body_lines.append(f'/estimate {value}')
361
+ continue
362
+ value = _match_directive(stripped, 'due')
363
+ if value is not None:
364
+ if value:
365
+ has_due = True
366
+ body_lines.append(f'/due {value}')
254
367
  continue
255
- if re.match(r'^/estimate\s+\S+', stripped, re.IGNORECASE):
256
- has_estimate = True
257
- if re.match(r'^/due\s+\S+', stripped, re.IGNORECASE):
258
- has_due = True
259
368
 
260
369
  body_lines.append(line)
261
370
 
@@ -273,6 +382,7 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
273
382
  'milestone_start': milestone_start,
274
383
  'milestone_end': milestone_end,
275
384
  'assignee': assignee,
385
+ 'labels': labels,
276
386
  'has_estimate': has_estimate,
277
387
  'has_due': has_due,
278
388
  'has_acceptance_criteria': acceptance_text is not None,
@@ -38,11 +38,16 @@ from itw_python_builder.task_utils import (
38
38
  build_issue_description,
39
39
  get_project_by_path,
40
40
  find_project_milestone,
41
+ find_project_issue,
41
42
  create_project_milestone,
43
+ list_project_labels,
44
+ create_project_label,
42
45
  create_issue_note,
43
46
  log_green,
44
47
  log_red,
45
48
  log_info,
49
+ log_white,
50
+ find_similar,
46
51
  )
47
52
  from itw_python_builder.version import Version
48
53
 
@@ -201,12 +206,27 @@ def create_task(ctx: Context, file=None):
201
206
  if not issue['has_due']:
202
207
  log_red('No due date is defined for this issue. Use /due in you md file to define a due date')
203
208
 
204
- milestone_id = _resolve_milestone(api_host, project['id'], issue, token)
209
+ milestone_id, milestone_title = _resolve_milestone(api_host, project['id'], issue, token)
210
+
211
+ duplicate = find_project_issue(
212
+ api_host, project['id'], issue['title'], token, milestone_title
213
+ )
214
+ if duplicate:
215
+ scope = f'milestone {milestone_title}' if milestone_title else 'issues without a milestone'
216
+ log_white(
217
+ f'Issue "{issue["title"]}" was not created because it already exists '
218
+ f'in this project for {scope}: {duplicate["web_url"]}'
219
+ )
220
+ continue
221
+
222
+ labels = _resolve_labels(api_host, project['id'], issue, token)
205
223
 
206
224
  description = build_issue_description(issue)
207
225
  payload = {'title': issue['title'], 'description': description}
208
226
  if milestone_id is not None:
209
227
  payload['milestone_id'] = milestone_id
228
+ if labels:
229
+ payload['labels'] = ','.join(labels)
210
230
 
211
231
  result = create_gitlab_issue(api_host, project['id'], payload, token)
212
232
  print(f'[itw] Created: {result["web_url"]}')
@@ -264,6 +284,24 @@ def _resolve_target_project(api_host: str, project_path: str, issue: dict, token
264
284
  print('Invalid choice, try again.')
265
285
 
266
286
 
287
+ def _resolve_labels(api_host: str, project_id: int, issue: dict, token: str) -> list:
288
+ if not issue['labels']:
289
+ return []
290
+ existing = list_project_labels(api_host, project_id, token)
291
+ resolved = []
292
+ for name in issue['labels']:
293
+ match = find_similar(name, existing, key='name')
294
+ if match:
295
+ log_white(f'Using existing label {match["name"]}')
296
+ resolved.append(match['name'])
297
+ continue
298
+ created = create_project_label(api_host, project_id, name, token)
299
+ log_green(f'Created new label {created["name"]}')
300
+ resolved.append(created['name'])
301
+ existing.append(created)
302
+ return resolved
303
+
304
+
267
305
  def _resolve_milestone(api_host: str, project_id: int, issue: dict, token: str):
268
306
  title = issue['milestone']
269
307
  if not title:
@@ -271,12 +309,12 @@ def _resolve_milestone(api_host: str, project_id: int, issue: dict, token: str):
271
309
  'There is no milestone defined for this issue. '
272
310
  'Use /milestone in your md file to define a milestone'
273
311
  )
274
- return None
312
+ return None, None
275
313
 
276
314
  existing = find_project_milestone(api_host, project_id, title, token)
277
315
  if existing:
278
- log_info(f'Using existing milestone {title}')
279
- return existing['id']
316
+ log_info(f'Using existing milestone {existing["title"]}')
317
+ return existing['id'], existing['title']
280
318
 
281
319
  start = issue['milestone_start']
282
320
  end = issue['milestone_end']
@@ -289,7 +327,7 @@ def _resolve_milestone(api_host: str, project_id: int, issue: dict, token: str):
289
327
  f'Created new milestone {title} | No start date or end date is defined for this '
290
328
  'new milestone. Use /milestone-start and /milestone-end in your md file to define them '
291
329
  )
292
- return created['id']
330
+ return created['id'], created['title']
293
331
 
294
332
 
295
333
  def build_frontend(ctx: Context, branch: str, ssr: bool = False) -> None:
@@ -743,10 +781,20 @@ def pipelinelocal(ctx: Context, settings='backend.test_settings', pylintrc=None)
743
781
  print("=" * 60)
744
782
 
745
783
 
784
+ def _ensure_changelog() -> None:
785
+ if os.path.exists('CHANGELOG.md'):
786
+ return
787
+ with open('CHANGELOG.md', 'w') as f:
788
+ f.write('# Changelog\n')
789
+ log_green('Created CHANGELOG.md')
790
+
791
+
746
792
  @task
747
793
  def changelog(ctx: Context, version: Version = None):
748
794
  """Generate changelog from commits with Changelog trailer"""
749
795
 
796
+ _ensure_changelog()
797
+
750
798
  exclude = f'--exclude={version}' if version else ''
751
799
  last_tag_result = ctx.run(f'git describe --tags --abbrev=0 {exclude}', hide=True, warn=True)
752
800
  if last_tag_result.ok and last_tag_result.stdout.strip():
@@ -831,9 +879,7 @@ def changelog(ctx: Context, version: Version = None):
831
879
  changelog_entry += f"{commit}\n"
832
880
  changelog_entry += "\n"
833
881
 
834
- if not os.path.exists('CHANGELOG.md'):
835
- with open('CHANGELOG.md', 'w') as f:
836
- f.write('# Changelog\n')
882
+ _ensure_changelog()
837
883
 
838
884
  with open('CHANGELOG.md', 'r') as f:
839
885
  existing = f.read()
@@ -1,18 +1,19 @@
1
1
  <!-- OPTIONAL -->
2
- /glab_host <gitlab-host>
3
- /glab_username <gitlab-username>
4
- /glab_token <personal-access-token>
2
+ /glab_host
3
+ /glab_username
4
+ /glab_token
5
5
 
6
6
  <!-- REQUIRED -->
7
- /title <issue title>
7
+ /title
8
8
 
9
9
  <!-- OPTIONAL -->
10
- /repo namespace/project
11
- /milestone %"Milestone name"
10
+ /repo
11
+ /milestone
12
12
  /milestone-start YYYY-MM-DD
13
13
  /milestone-end YYYY-MM-DD
14
- /assignee @username
15
- /estimate 2h
14
+ /assignee
15
+ /label
16
+ /estimate
16
17
  /due YYYY-MM-DD
17
18
 
18
19
  <!-- OPTIONAL -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.2.12
3
+ Version: 0.2.13
4
4
  Summary: Standardized Django deployment pipeline with Docker, testing, and SonarQube integration
5
5
  Author-email: IT-Works <contact@it-works.io>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "itw_python_builder"
7
- version = "0.2.12"
7
+ version = "0.2.13"
8
8
  description = "Standardized Django deployment pipeline with Docker, testing, and SonarQube integration"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"