arthexis 0.1.14__py3-none-any.whl → 0.1.15__py3-none-any.whl

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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arthexis
3
- Version: 0.1.14
4
- Summary: Django-based MESH system
3
+ Version: 0.1.15
4
+ Summary: Power & Energy Infrastructure
5
5
  Author-email: "Rafael J. Guillén-Osorio" <tecnologia@gelectriic.com>
6
6
  License-Expression: GPL-3.0-only
7
7
  Project-URL: Repository, https://github.com/arthexis/arthexis
@@ -118,6 +118,8 @@ Dynamic: license-file
118
118
 
119
119
  [![Coverage](https://raw.githubusercontent.com/arthexis/arthexis/main/coverage.svg)](https://github.com/arthexis/arthexis/actions/workflows/coverage.yml) [![OCPP 1.6 Coverage](https://raw.githubusercontent.com/arthexis/arthexis/main/ocpp_coverage.svg)](https://github.com/arthexis/arthexis/blob/main/docs/development/ocpp-user-manual.md)
120
120
 
121
+ For coding guidance, see [AGENTS.md](AGENTS.md).
122
+
121
123
  ## Purpose
122
124
 
123
125
  Arthexis Constellation is a [narrative-driven](https://en.wikipedia.org/wiki/Narrative) [Django](https://www.djangoproject.com/)-based [software suite](https://en.wikipedia.org/wiki/Software_suite) that centralizes tools for managing [electric vehicle charging infrastructure](https://en.wikipedia.org/wiki/Charging_station) and orchestrating [energy](https://en.wikipedia.org/wiki/Energy)-related [products](https://en.wikipedia.org/wiki/Product_(business)) and [services](https://en.wikipedia.org/wiki/Service_(economics)).
@@ -1,4 +1,4 @@
1
- arthexis-0.1.14.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
1
+ arthexis-0.1.15.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
2
2
  config/__init__.py,sha256=AwpOX7il-DAOmkdJ5dVfVJ3CWWebn1lHyQNmkw1EkDw,103
3
3
  config/active_app.py,sha256=KJqYh-o91nPQjVXPEdbiJHzsI6cN9IZsBZ9O3iZ6Hyc,373
4
4
  config/asgi.py,sha256=T-0QSbtieEWKPIDkEcEdd-q6qjK8ZCwwjCaISOBkWdM,1296
@@ -15,14 +15,14 @@ config/settings_helpers.py,sha256=0BdBciUHIkwsWa0vV_RKAd4wDuEzgE7G-42XYiES4YQ,31
15
15
  config/urls.py,sha256=ad9D3kGvv6Fem1ErYo8FtXWKFfjcxVr-6lstKekbO-0,5192
16
16
  config/wsgi.py,sha256=zU_mKlya6hejQ21PxKacTui3dUWd4ca_-YJNSYAoMX0,433
17
17
  core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
- core/admin.py,sha256=K3mgFBcBo32ZAtZxMUVUu9psekBpQ2lDNsNOw3l-BXY,136191
18
+ core/admin.py,sha256=K57fxv6edej4isMM67yStYc9-2Hjm-87FolZkwiTUtM,137295
19
19
  core/admin_history.py,sha256=XZ4b0ryufIka-xcwboK3DzmOL-INSx5Y2fJO-aJdV70,1783
20
20
  core/admindocs.py,sha256=1wJkaVpOklcZnYgeksM14DoISzVpFEigeG5GUGnpji4,5216
21
21
  core/apps.py,sha256=VavdJtQ_JIwyI0pbB8oByQLyzQnKNflH3Fobl6BxA6E,14316
22
22
  core/auto_upgrade.py,sha256=1EffHHFylgydWdZM_id6CppV0QqBtdNw7cwBYVdbNdk,1715
23
23
  core/backends.py,sha256=GLVJpkY6o0V0AyLVCO9BYByU9Logdz4tou6w5n9-Wx0,8838
24
24
  core/changelog.py,sha256=grMvuEektkymwvkC1ubXFZF2JFopPybT82k4rUIlfmo,10840
25
- core/entity.py,sha256=8R9NCZjgrNzsfOmZPAIjGrmxM9iyKHjYhLlNfE96JVI,4372
25
+ core/entity.py,sha256=o4VteOXePGEsIWJFZ3fpq3DZsdWr3hpQ9A6kFbKosSE,4844
26
26
  core/environment.py,sha256=JLcvxAwU3OTL8O6kzwcUCFNZ3T28KanHrU_4mDBFamU,1584
27
27
  core/fields.py,sha256=d-qGahdcv4SRcO4fwCJ6_-NnEAP5xW0k3kODdAAAHSA,5412
28
28
  core/form_fields.py,sha256=h2xT8sO8EWbznsiARkxukFk69yoW6mQwqpgonA-d6aA,2496
@@ -31,27 +31,27 @@ core/github_issues.py,sha256=tkboxXR92_Im2Mac2eU7fHtqcO-MQMdkEmFg4f6PfDc,5006
31
31
  core/github_repos.py,sha256=8KCxcEiO2Ltgde7UDTAFOyHTm_eBeZYUIZegEbrjkWA,1690
32
32
  core/lcd_screen.py,sha256=WtHMlSoZXKOsdM0d-v-f8ul-LSA6FA1bEWFwho1t6s8,2573
33
33
  core/liveupdate.py,sha256=22m0ueQ10-6b-9pQJHY0_5WRYA98fysXKEXOWzIr550,691
34
- core/log_paths.py,sha256=XXi6WMJj5PvrGwcM6vBGlIEKnOAA0KZqL8b_whRQqeo,2945
34
+ core/log_paths.py,sha256=lxvgXPgJtVNZ-kYrqV8VFle4GFQrSxG-yRTglqvclmU,3318
35
35
  core/mailer.py,sha256=ciIZBJuKMJkmo5h8ktJPVlyzghzfNvhC8TGq2CSeGEA,2744
36
36
  core/middleware.py,sha256=j19K9SX-Emkv7BDDtAacR9g6RWsxhKHwCc8w23JFvMM,3388
37
- core/models.py,sha256=q5oXmlbbpb_jvMzP_j5V8LntT5iHo1GONkNwuQZBivo,121943
37
+ core/models.py,sha256=xt6bM3yvDhoIZf9u4GVdgj8HpDJcS-MGDWgd7qdqNdU,122902
38
38
  core/notifications.py,sha256=LYktoKM5k4q7YYWAJuqdeKM-p0Q-3gXgfqdq71qLS68,3916
39
39
  core/public_wifi.py,sha256=yydLgxOo9DmJJbM4X_23wGR3gxL3YzHno54v9GssuFA,7213
40
40
  core/reference_utils.py,sha256=jeox3V4cZNxzM2Jj31g_mdb3O55zy9S2iXAZu70R1Zc,3627
41
- core/release.py,sha256=4S8Eezq-asHEWE6U1SBlitpodiQDtpKcbWmoQtXnxNs,23774
41
+ core/release.py,sha256=ucO4-l09l8RkviHi9DAOKBfCjGnEJDcHLK9-sGcVedw,28043
42
42
  core/rfid_import_export.py,sha256=petyhPvL0WUpehc6uGUDUhjYQ9AVvc6O49zuhDs6YFw,3516
43
43
  core/sigil_builder.py,sha256=VLwbrrD7Zr3SHfIDYV-V7uv7LEGiIelCSkeGswHibuc,4843
44
44
  core/sigil_context.py,sha256=GCzjfM6fcVvBtSbVNfmE6sx3HU8QnxnXrCIytnNpQzM,439
45
45
  core/sigil_resolver.py,sha256=rCsypuX-0oWNfKyM1T9ZLWHY0Ezwhtk4VmI0L3krnsE,11098
46
- core/system.py,sha256=18XJ22ZieB389M4wrUOe6HwfbJLIRA-rrStQr2Dt9Gg,22693
46
+ core/system.py,sha256=5jdzmg236RxE51RKp1pErRJFKOvs42YJPgzVynwJooQ,28973
47
47
  core/tasks.py,sha256=mZtVotqyNPOXvMY1tsQtkblLbvGcIxKUvQfx8nNZkd4,12421
48
48
  core/temp_passwords.py,sha256=FieUnIUeQHmA1DoXvfJ5U6-Ayv3oDz-hSln5s_vNbA4,5271
49
49
  core/test_system_info.py,sha256=V9lzW9fnCFWlOoXZpGJ2aLSqjE6oexQ6lzobbTuGNJE,6371
50
- core/tests.py,sha256=tnv6ayCzWX0ebheC8JXzdcmTxUgCqesaVU01Ywe60rM,82830
50
+ core/tests.py,sha256=2wwwQtpOx45BixBxFI8rD9FNlDOfgdABPvoyYaTxTEk,85475
51
51
  core/tests_liveupdate.py,sha256=IquU8ztk6zbzC1bQu3Nrr3RzGzuujtPwDkANJHbxg98,510
52
52
  core/urls.py,sha256=YPippON1MAP2KeZZ8jHpcLO6mvbnKn1q7fdMv5Vm9dY,425
53
53
  core/user_data.py,sha256=02CfvxayELWSWZJCxWpv1Yz7EGg08yEu5MM31Khsi0U,21083
54
- core/views.py,sha256=Ka91JsMs6uTzStuGSLdAKu0U-Lq2vc_xZ_7BBua8zaw,77158
54
+ core/views.py,sha256=udz_YhyPi3WF3cGcl_9VfliUtHdXaeLNf3HBXJIdhMo,78135
55
55
  core/widgets.py,sha256=vlR9PlFfZGlkHm5X2cqNXuEBZSj8gmWaR6MO1mMy6kg,6904
56
56
  core/workgroup_urls.py,sha256=XR9IqwsSBI8epW7_-hHhWFU9wsyJfZehHwNQBhCgmpM,407
57
57
  core/workgroup_views.py,sha256=vtumF3-8YaTD-K6nSd8eYvUyq3ftpvWSEwtcp5B-P6o,2889
@@ -62,12 +62,12 @@ nodes/backends.py,sha256=dmmbS0X2YIlCDz2KjoDf_L62dy--nuqZF1rEDoi2JHM,5921
62
62
  nodes/dns.py,sha256=D5smXD7Rkh6E4MdL6TBL2WY8GgJg7Rx9z88LZrcMbTw,7048
63
63
  nodes/feature_checks.py,sha256=27e4PCkZ8BGWnJCOwMcY2Bo9z7LoeZWiTZuISWGnrzk,3996
64
64
  nodes/lcd.py,sha256=iKA8Wmq85KZD52aTzAU8ZmS144_gbdGMOXcE8yuECps,5758
65
- nodes/models.py,sha256=4LZOlW6ApAA-MXuJxEFSLz9gvxcF-2B0bJSPa3u2xkc,59450
65
+ nodes/models.py,sha256=1GFx9ZSJqRBecIn0ywkTG5nRQIuIeArJCoA6Q66TtHQ,60349
66
66
  nodes/reports.py,sha256=NRYh3Y0SlZFhx31Zh2K03yO12ZrpxEHEY6T-dODA6WE,12059
67
67
  nodes/rfid_sync.py,sha256=754u-Di1Fzond1LQq4i7mJAcTPRgUwsdKk3Dz5Ba1Dw,6371
68
68
  nodes/signals.py,sha256=PtOKdQfb08mV1LgSZvn7ZAcfOyy2c3Xkq4AOpBQyUdE,622
69
69
  nodes/tasks.py,sha256=ur59ebu9z02idmvy_IvUQt3eu9LWRyyNpkg2szvIHCQ,1522
70
- nodes/tests.py,sha256=Ud7pecqDrCqIOxSoGYWWIiWCbqwIGhc5cTq9dJyyQEE,149770
70
+ nodes/tests.py,sha256=34-56EgVdvelepzChRuhFaGhpQY-a2uH8iwczvXRMrY,150638
71
71
  nodes/urls.py,sha256=HmAxj6sr6nMf0lii_1UX7sNBJUcrkaiKm3R9ofUWhvM,677
72
72
  nodes/utils.py,sha256=3Vtjsi5nPvKqI0bdu6dabGJlOZ6ybkeIyFiJ7JPumdM,4406
73
73
  nodes/views.py,sha256=TyW7exkVaR-o2_XkJXSi9jQ_BygXOE2cQFs4xlI20Xc,22905
@@ -91,19 +91,20 @@ ocpp/transactions_io.py,sha256=YnxI-Tv5UFxv0JuFK3XpoqFYP8eRT8sMuDiqkiMHPtU,7387
91
91
  ocpp/urls.py,sha256=3T5O5DSwVk4PbhPx5p4D3UseCWvC5xV5HwJLSM6AfA8,1700
92
92
  ocpp/views.py,sha256=LE2mqB5FTno4SYzBWabu9g95o77Ojo2uFtTG6K5W9F0,56311
93
93
  pages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
94
- pages/admin.py,sha256=M2jBaGfNd4dzLhYwp7_QJZjo-USepOxBgS3DqfwgoDA,21187
94
+ pages/admin.py,sha256=vbUP8JvjDbVS6OtfSZwYzlwyEy_jYBraG8zwSXpl4Js,23353
95
95
  pages/apps.py,sha256=AzUNXQX0yRUX5jus-5EDReDb0nOEY8DBgYaM970u6Io,288
96
96
  pages/checks.py,sha256=sM8_hUVM_HOIocvtTb2sY3AaSEvbTnOlO46UchGVd-0,1527
97
97
  pages/context_processors.py,sha256=8TmtbbXsX0sbkT-_kOfpINzJhUpZbLy9tMHWLVm7n9s,4277
98
98
  pages/defaults.py,sha256=l36APPAZO4ub2A8Pp-lQGujKeOVYcyzU6t7-kOk8VoA,522
99
99
  pages/forms.py,sha256=T0atqxdNds3IBP8N-9c5-ACf3iR9FzzmhzK4MOa24e8,7058
100
- pages/middleware.py,sha256=KFgACZokxTju3pI_Xjg8EgMH8Pk2RhwyPt0rDMy41ic,6862
101
- pages/models.py,sha256=naJoUvgDhRzRp1GXY220ZRGyrhh3zH8VUAmcYjX7T4I,20020
102
- pages/tests.py,sha256=JwC6SHFh9sWPVIXSyYK4fsrLJUlG3M4y8jOctOsUcOs,102508
103
- pages/urls.py,sha256=5krM6swAR4IrQmRDAKMUQo-iVBRL49LgcdiNG6qwEng,1042
104
- pages/utils.py,sha256=lG1C8BlqR1B2Lxjya2zSGaiiWFKThvKhdLqgbKmm8jQ,299
105
- pages/views.py,sha256=ltYdGZomDhsC_NNe_3G5WBCRGfeLcEtg9rQZR9uMbOU,43394
106
- arthexis-0.1.14.dist-info/METADATA,sha256=ve3L0d9jOfZSNuGtpsevkdlZMJ1u-LXfImqd88pJPdE,9992
107
- arthexis-0.1.14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
108
- arthexis-0.1.14.dist-info/top_level.txt,sha256=J2a2q8_BWrCZ8H2WFUNMBfO2jz8j2gax6zZh-_1QDac,29
109
- arthexis-0.1.14.dist-info/RECORD,,
100
+ pages/middleware.py,sha256=6PMLiyuHAHbfLeHwwQxIVy2fJ32ramEO9SHAN05Set4,6967
101
+ pages/models.py,sha256=YMoKDgVGrEKizSpxtsZLDl7zC9ayVgQM4BtZOTmG6SM,20910
102
+ pages/tasks.py,sha256=ivcba_3wSQ1-cku0oDplzw6vLeQ9hBq3R4TG-LmR5gs,1913
103
+ pages/tests.py,sha256=LJ0uVkQ0v_c2oGBdwEp_CQ8NgcIDrq9fzLSaM8uDB0A,117176
104
+ pages/urls.py,sha256=tnT6h4Zb5whuChvhfpkOF1UDOJu6jCwQk72FguUN9SU,1092
105
+ pages/utils.py,sha256=CR4D1debgJLGgXsw9kap2ggpe7fIpSoWS_ivbgMNp2k,564
106
+ pages/views.py,sha256=JkKy6w-xFySG_T1UCnQk3ooq0cijGHUH5YhSivyGaG4,43616
107
+ arthexis-0.1.15.dist-info/METADATA,sha256=WEu-5YYSs9HX-ZisdIGvIcvj7JNmmWKA3tdpWKCFaMg,10047
108
+ arthexis-0.1.15.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
109
+ arthexis-0.1.15.dist-info/top_level.txt,sha256=J2a2q8_BWrCZ8H2WFUNMBfO2jz8j2gax6zZh-_1QDac,29
110
+ arthexis-0.1.15.dist-info/RECORD,,
core/admin.py CHANGED
@@ -424,6 +424,7 @@ class ReleaseManagerAdminForm(forms.ModelForm):
424
424
  widgets = {
425
425
  "pypi_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
426
426
  "github_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
427
+ "git_password": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
427
428
  }
428
429
 
429
430
  def __init__(self, *args, **kwargs):
@@ -448,6 +449,16 @@ class ReleaseManagerAdminForm(forms.ModelForm):
448
449
  "or an equivalent fine-grained token) and paste it here."
449
450
  ),
450
451
  )
452
+ self.fields["git_username"].help_text = (
453
+ "Username used for HTTPS git pushes (for example, your GitHub username)."
454
+ )
455
+ self.fields["git_password"].help_text = format_html(
456
+ "{} <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\">{}</a>{}",
457
+ "Provide the password or personal access token used for pushing tags. ",
458
+ "https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token",
459
+ "docs.github.com/.../creating-a-personal-access-token",
460
+ " If left blank, the GitHub token will be used instead.",
461
+ )
451
462
 
452
463
 
453
464
  @admin.register(ReleaseManager)
@@ -460,18 +471,27 @@ class ReleaseManagerAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModel
460
471
  fieldsets = (
461
472
  ("Owner", {"fields": ("user", "group")}),
462
473
  (
463
- "Credentials",
474
+ "PyPI",
464
475
  {
465
476
  "fields": (
466
477
  "pypi_username",
467
478
  "pypi_token",
468
479
  "pypi_password",
469
- "github_token",
470
480
  "pypi_url",
471
481
  "secondary_pypi_url",
472
482
  )
473
483
  },
474
484
  ),
485
+ (
486
+ "GitHub",
487
+ {
488
+ "fields": (
489
+ "github_token",
490
+ "git_username",
491
+ "git_password",
492
+ )
493
+ },
494
+ ),
475
495
  )
476
496
 
477
497
  def owner(self, obj):
@@ -1249,12 +1269,16 @@ class ReleaseManagerInlineForm(ProfileFormMixin, forms.ModelForm):
1249
1269
  "pypi_username",
1250
1270
  "pypi_token",
1251
1271
  "github_token",
1272
+ "git_username",
1273
+ "git_password",
1252
1274
  "pypi_password",
1253
1275
  "pypi_url",
1276
+ "secondary_pypi_url",
1254
1277
  )
1255
1278
  widgets = {
1256
1279
  "pypi_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
1257
1280
  "github_token": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
1281
+ "git_password": forms.Textarea(attrs={"rows": 3, "style": "width: 40em;"}),
1258
1282
  }
1259
1283
 
1260
1284
 
core/entity.py CHANGED
@@ -4,10 +4,14 @@ import logging
4
4
  from django.contrib.auth.models import UserManager as DjangoUserManager
5
5
  from django.core.exceptions import FieldDoesNotExist
6
6
  from django.db import models
7
+ from django.dispatch import Signal
7
8
 
8
9
  logger = logging.getLogger(__name__)
9
10
 
10
11
 
12
+ user_data_flag_updated = Signal()
13
+
14
+
11
15
  class EntityQuerySet(models.QuerySet):
12
16
  def delete(self): # pragma: no cover - delegates to instance delete
13
17
  deleted = 0
@@ -16,12 +20,24 @@ class EntityQuerySet(models.QuerySet):
16
20
  deleted += 1
17
21
  return deleted, {}
18
22
 
23
+ def update(self, **kwargs):
24
+ invalidate_user_data_cache = "is_user_data" in kwargs
25
+ updated = super().update(**kwargs)
26
+ if invalidate_user_data_cache and updated:
27
+ user_data_flag_updated.send(sender=self.model)
28
+ return updated
29
+
19
30
 
20
31
  class EntityManager(models.Manager):
21
32
  def get_queryset(self):
22
33
  return EntityQuerySet(self.model, using=self._db).filter(is_deleted=False)
23
34
 
24
35
 
36
+ class EntityAllManager(models.Manager):
37
+ def get_queryset(self):
38
+ return EntityQuerySet(self.model, using=self._db)
39
+
40
+
25
41
  class EntityUserManager(DjangoUserManager):
26
42
  def get_queryset(self):
27
43
  return EntityQuerySet(self.model, using=self._db).filter(is_deleted=False)
@@ -35,7 +51,7 @@ class Entity(models.Model):
35
51
  is_deleted = models.BooleanField(default=False, editable=False)
36
52
 
37
53
  objects = EntityManager()
38
- all_objects = models.Manager()
54
+ all_objects = EntityAllManager()
39
55
 
40
56
  class Meta:
41
57
  abstract = True
core/log_paths.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
  import os
7
7
  import sys
8
+ import tempfile
8
9
 
9
10
 
10
11
  def _is_root() -> bool:
@@ -41,16 +42,29 @@ def select_log_dir(base_dir: Path) -> Path:
41
42
  candidates.append(Path("/var/log/arthexis"))
42
43
  candidates.append(Path("/tmp/arthexis/logs"))
43
44
  else:
44
- home = Path.home()
45
- state_home = _state_home(home)
46
- candidates.extend(
47
- [
48
- default,
49
- state_home / "arthexis" / "logs",
50
- home / ".arthexis" / "logs",
51
- Path("/tmp/arthexis/logs"),
52
- ]
53
- )
45
+ home: Path | None
46
+ try:
47
+ home = Path.home()
48
+ except (RuntimeError, OSError, KeyError):
49
+ home = None
50
+
51
+ candidates.append(default)
52
+
53
+ tmp_logs = Path(tempfile.gettempdir()) / "arthexis" / "logs"
54
+
55
+ if home is not None:
56
+ state_home = _state_home(home)
57
+ candidates.extend(
58
+ [
59
+ state_home / "arthexis" / "logs",
60
+ home / ".arthexis" / "logs",
61
+ ]
62
+ )
63
+ else:
64
+ candidates.append(tmp_logs)
65
+
66
+ candidates.append(Path("/tmp/arthexis/logs"))
67
+ candidates.append(tmp_logs)
54
68
 
55
69
  seen: set[Path] = set()
56
70
  ordered_candidates: list[Path] = []
core/models.py CHANGED
@@ -50,6 +50,7 @@ from .release import (
50
50
  Credentials,
51
51
  DEFAULT_PACKAGE,
52
52
  RepositoryTarget,
53
+ GitCredentials,
53
54
  )
54
55
 
55
56
 
@@ -3024,6 +3025,8 @@ class ReleaseManager(Profile):
3024
3025
  "pypi_username",
3025
3026
  "pypi_token",
3026
3027
  "github_token",
3028
+ "git_username",
3029
+ "git_password",
3027
3030
  "pypi_password",
3028
3031
  "pypi_url",
3029
3032
  "secondary_pypi_url",
@@ -3038,6 +3041,21 @@ class ReleaseManager(Profile):
3038
3041
  "Used before the GITHUB_TOKEN environment variable."
3039
3042
  ),
3040
3043
  )
3044
+ git_username = SigilShortAutoField(
3045
+ "Git username",
3046
+ max_length=100,
3047
+ blank=True,
3048
+ help_text="Username used for Git pushes (for example, your GitHub username).",
3049
+ )
3050
+ git_password = SigilShortAutoField(
3051
+ "Git password/token",
3052
+ max_length=200,
3053
+ blank=True,
3054
+ help_text=(
3055
+ "Password or personal access token for HTTPS Git pushes. "
3056
+ "Leave blank to use the GitHub token instead."
3057
+ ),
3058
+ )
3041
3059
  pypi_password = SigilShortAutoField("PyPI password", max_length=200, blank=True)
3042
3060
  pypi_url = SigilShortAutoField("PyPI URL", max_length=200, blank=True)
3043
3061
  secondary_pypi_url = SigilShortAutoField(
@@ -3079,6 +3097,16 @@ class ReleaseManager(Profile):
3079
3097
  return Credentials(username=self.pypi_username, password=self.pypi_password)
3080
3098
  return None
3081
3099
 
3100
+ def to_git_credentials(self) -> GitCredentials | None:
3101
+ """Return Git credentials for pushing tags."""
3102
+
3103
+ username = (self.git_username or "").strip()
3104
+ password_source = self.git_password or self.github_token or ""
3105
+ password = password_source.strip()
3106
+ if username and password:
3107
+ return GitCredentials(username=username, password=password)
3108
+ return None
3109
+
3082
3110
 
3083
3111
  class Package(Entity):
3084
3112
  """Package details shared across releases."""
core/release.py CHANGED
@@ -10,6 +10,7 @@ import time
10
10
  from dataclasses import dataclass
11
11
  from pathlib import Path
12
12
  from typing import Optional, Sequence
13
+ from urllib.parse import quote, urlsplit, urlunsplit
13
14
 
14
15
  try: # pragma: no cover - optional dependency
15
16
  import toml # type: ignore
@@ -70,6 +71,17 @@ class Credentials:
70
71
  raise ValueError("Missing PyPI credentials")
71
72
 
72
73
 
74
+ @dataclass
75
+ class GitCredentials:
76
+ """Credentials used for Git operations such as pushing tags."""
77
+
78
+ username: Optional[str] = None
79
+ password: Optional[str] = None
80
+
81
+ def has_auth(self) -> bool:
82
+ return bool((self.username or "").strip() and (self.password or "").strip())
83
+
84
+
73
85
  @dataclass
74
86
  class RepositoryTarget:
75
87
  """Configuration for uploading a distribution to a repository."""
@@ -90,7 +102,7 @@ class RepositoryTarget:
90
102
 
91
103
  DEFAULT_PACKAGE = Package(
92
104
  name="arthexis",
93
- description="Django-based MESH system",
105
+ description="Power & Energy Infrastructure",
94
106
  author="Rafael J. Guillén-Osorio",
95
107
  email="tecnologia@gelectriic.com",
96
108
  python_requires=">=3.10",
@@ -243,6 +255,113 @@ def _manager_credentials() -> Optional[Credentials]:
243
255
  return None
244
256
 
245
257
 
258
+ def _manager_git_credentials(package: Optional[Package] = None) -> Optional[GitCredentials]:
259
+ """Return Git credentials from the Package's release manager if available."""
260
+
261
+ try: # pragma: no cover - optional dependency
262
+ from core.models import Package as PackageModel
263
+
264
+ queryset = PackageModel.objects.select_related("release_manager")
265
+ if package is not None:
266
+ queryset = queryset.filter(name=package.name)
267
+ package_obj = queryset.first()
268
+ if package_obj and package_obj.release_manager:
269
+ creds = package_obj.release_manager.to_git_credentials()
270
+ if creds and creds.has_auth():
271
+ return creds
272
+ except Exception:
273
+ return None
274
+ return None
275
+
276
+
277
+ def _git_authentication_missing(exc: subprocess.CalledProcessError) -> bool:
278
+ message = (exc.stderr or exc.stdout or "").strip().lower()
279
+ if not message:
280
+ return False
281
+ auth_markers = [
282
+ "could not read username",
283
+ "authentication failed",
284
+ "fatal: authentication failed",
285
+ "terminal prompts disabled",
286
+ ]
287
+ return any(marker in message for marker in auth_markers)
288
+
289
+
290
+ def _format_subprocess_error(exc: subprocess.CalledProcessError) -> str:
291
+ return (exc.stderr or exc.stdout or str(exc)).strip() or str(exc)
292
+
293
+
294
+ def _git_remote_url(remote: str = "origin") -> Optional[str]:
295
+ proc = subprocess.run(
296
+ ["git", "remote", "get-url", remote],
297
+ capture_output=True,
298
+ text=True,
299
+ check=False,
300
+ )
301
+ if proc.returncode != 0:
302
+ return None
303
+ return (proc.stdout or "").strip() or None
304
+
305
+
306
+ def _remote_with_credentials(url: str, creds: GitCredentials) -> Optional[str]:
307
+ if not creds.has_auth():
308
+ return None
309
+ parsed = urlsplit(url)
310
+ if parsed.scheme not in {"http", "https"}:
311
+ return None
312
+ host = parsed.netloc.split("@", 1)[-1]
313
+ username = quote((creds.username or "").strip(), safe="")
314
+ password = quote((creds.password or "").strip(), safe="")
315
+ netloc = f"{username}:{password}@{host}"
316
+ return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
317
+
318
+
319
+ def _raise_git_authentication_error(tag_name: str, exc: subprocess.CalledProcessError) -> None:
320
+ details = _format_subprocess_error(exc)
321
+ message = (
322
+ "Git authentication failed while pushing tag {tag}. "
323
+ "Configure Git credentials in the release manager profile or authenticate "
324
+ "locally, then rerun the publish step or push the tag manually with `git push "
325
+ "origin {tag}`."
326
+ ).format(tag=tag_name)
327
+ if details:
328
+ message = f"{message} Git reported: {details}"
329
+ raise ReleaseError(message) from exc
330
+
331
+
332
+ def _push_tag(tag_name: str, package: Package) -> None:
333
+ auth_error: subprocess.CalledProcessError | None = None
334
+ try:
335
+ _run(["git", "push", "origin", tag_name])
336
+ return
337
+ except subprocess.CalledProcessError as exc:
338
+ if not _git_authentication_missing(exc):
339
+ raise
340
+ auth_error = exc
341
+
342
+ creds = _manager_git_credentials(package)
343
+ if creds and creds.has_auth():
344
+ remote_url = _git_remote_url("origin")
345
+ if remote_url:
346
+ authed_url = _remote_with_credentials(remote_url, creds)
347
+ if authed_url:
348
+ try:
349
+ _run(["git", "push", authed_url, tag_name])
350
+ return
351
+ except subprocess.CalledProcessError as push_exc:
352
+ if not _git_authentication_missing(push_exc):
353
+ raise
354
+ auth_error = push_exc
355
+ # If we reach this point, the original exception is an auth error
356
+ if auth_error is not None:
357
+ _raise_git_authentication_error(tag_name, auth_error)
358
+ raise ReleaseError(
359
+ "Git authentication failed while pushing tag {tag}. Configure Git credentials and try again.".format(
360
+ tag=tag_name
361
+ )
362
+ )
363
+
364
+
246
365
  def run_tests(
247
366
  log_path: Optional[Path] = None,
248
367
  command: Optional[Sequence[str]] = None,
@@ -541,7 +660,7 @@ def publish(
541
660
 
542
661
  tag_name = f"v{version}"
543
662
  _run(["git", "tag", tag_name])
544
- _run(["git", "push", "origin", tag_name])
663
+ _push_tag(tag_name, package)
545
664
  return uploaded
546
665
 
547
666