gitpr-cli 1.4.2__py3-none-any.whl → 1.4.3__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitpr-cli
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: AI-powered PR automation, commit messages, and code review (Gemini, DeepSeek, and Ollama)
5
5
  Author-email: Natan Fiuza <contato@natanfiuza.dev.br>
6
6
  Requires-Python: >=3.10
@@ -1,4 +1,4 @@
1
- gitpr_cli-1.4.2.dist-info/licenses/LICENSE,sha256=UQzLC6mwqb-ZNDkBBAqOxC-SMQcEID0gaM6oFcu6u48,27030
1
+ gitpr_cli-1.4.3.dist-info/licenses/LICENSE,sha256=UQzLC6mwqb-ZNDkBBAqOxC-SMQcEID0gaM6oFcu6u48,27030
2
2
  src/__init__.py,sha256=WZW9x8P5CYnru29UiarFa8q1nQzuwYWyIodSOlruCWI,29
3
3
  src/ai_providers.py,sha256=QyFEOsXyA59lZziO5bl-4nlYFJpxBcxU9aLhcTBnkWE,14633
4
4
  src/blame_engine.py,sha256=Zov1hxrt7Dcmf56ovJKz3QmHIfzDKtSub2iItXeg9-A,17104
@@ -17,7 +17,7 @@ src/issue_engine.py,sha256=RmZsQVg6N1vCcpMdv_5FX-cMGgQ3I0vwMN4parQ4zFg,10974
17
17
  src/ledger.py,sha256=s10Sm0qcA1b916X7m8d2osDThCmSK0UQnmUrkyj8Eao,43663
18
18
  src/linter_engine.py,sha256=hIxht8M09Fnm6NiJZOqjI6dyqnFoHUDVA8Ve4dWZV4s,25214
19
19
  src/linter_wizard.py,sha256=7J_nQFghVwCaKG6QI3K45KcOaxHSH1oTCAvP_6tnCQM,7424
20
- src/main.py,sha256=qWtf9ryGOS_elZgfV1I2rOWUzW9U7HD0XqnOGwwGu4o,214873
20
+ src/main.py,sha256=71K36Q_35gEDU9vfNDCQJSljFSXc9NpOjYMUuWV4BSo,215874
21
21
  src/mcp_server.py,sha256=gi4LmMYVN4CZWHQgb-5aV5UD2O-tRYzc_BqNg7t-EFA,111654
22
22
  src/metrics.py,sha256=M4Yqa__giuhmkElT-XOWrTfHGsjK_URVLhIZvErD2f0,39560
23
23
  src/net.py,sha256=f0MOkXyIxi4HVetsybtWpV_yHjiC17T2JMWySBdoV2I,2774
@@ -29,7 +29,7 @@ src/security_ruleset.py,sha256=L754S4RlVoZGWm0nuCoeli9-49mKydZpwi31V319DQ0,4337
29
29
  src/spinner.py,sha256=63pEotiDa3TVwBr-sn0MJoDPQ_uWJTTwINXieJIDDKc,8964
30
30
  src/suggest_reviewers.py,sha256=-wRe0HYksx4FWQZ-eDvU9Ghdk5gqtEt457KQP7sE31Y,5181
31
31
  src/tui_issue.py,sha256=WEFxT4kzWQ_KbSDsOnS_iuYXasZySB-68jryGNm0peg,10331
32
- src/updater.py,sha256=8AGxl_Lil9K3Laydw3f7yDipuuy6p3XokCOidfUyVR0,4771
32
+ src/updater.py,sha256=WdlaW2QTjSjSj-pXwT7paa7zeL0YZqDHTMTS2fpU-iE,9210
33
33
  src/usage_log.py,sha256=FdfSmZKL_I5BcG750rD4gl3pNHx0jqV8Shij5XkAnzc,4381
34
34
  src/version_bump.py,sha256=OjqJe9OL1-1dT5od7-SgU5ZN_dTtR_aKX_oSjSY64YQ,2954
35
35
  src/application/__init__.py,sha256=0GADzpCB3CdCzw-d3az0DVOtTvXfCppt-0hY-Js5a1U,29
@@ -44,7 +44,7 @@ src/application/use_cases/generate_mentor_explanation.py,sha256=KNj5cDm-i7aPJxxb
44
44
  src/application/use_cases/generate_pr_explanation.py,sha256=Xbn1b9G57O18pT-_FLA_wAEJmyeLCv1n80p49WVxxjo,3336
45
45
  src/application/use_cases/generate_test_file.py,sha256=r8kpNFPD5zU0vsshAzqqspU9stlCnopQzS9cwP5XT-I,9410
46
46
  src/application/use_cases/install_policy_pack.py,sha256=U1XvkHt0elXvyM2VRc6TPNukhUomSI2BSWj09hmLC_8,2745
47
- src/application/use_cases/publish_pull_request.py,sha256=DPK8ERbO2fsjoWUhcLQs10z1WhHVhsfTvtGumn50uIA,9369
47
+ src/application/use_cases/publish_pull_request.py,sha256=QtPx6a4oFYaUsYecVokcMNWWL9VomzmqbnHJNBGWSHA,9490
48
48
  src/application/use_cases/resolve_effective_policy.py,sha256=nTbjRR4di1GnCRV7l3yDNw27CQV3heAd45sftHxNyNc,7563
49
49
  src/application/use_cases/run_check.py,sha256=NEhuOZmdWgPFrOPWNku6MwS1Lp0OArk3OMd78M8_EcE,15246
50
50
  src/application/use_cases/suppress_finding.py,sha256=juj2jjk2BLjrAcx6WTKJNr5VMTJQ4Gb3GaEjDNGjM64,17809
@@ -166,8 +166,8 @@ src/ui/demo/__init__.py,sha256=PGXenIwpS1lVDE164CZ55q8_65XtJq5H300NIpnqOh0,58
166
166
  src/ui/demo/demo_app.py,sha256=EDeuqxh_v9KrPhib49TyirmFlHZN7I5NQkg9AjDGhcs,3074
167
167
  src/ui/demo/demo_help_screen.py,sha256=m8SuvhzGmkMsahUBA6Aq7ull4W65nS0KOX4hLx-GGps,2056
168
168
  src/ui/demo/demo_screens.py,sha256=3Ntp-A7OB43yEtniRqV1UKWc-p5lHRyxeadpe8bxJpM,1937
169
- gitpr_cli-1.4.2.dist-info/METADATA,sha256=rdUlN_BJSULMQ64zu-PNoDrxHaaTlmCoSTxqRdSF_wc,51553
170
- gitpr_cli-1.4.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
171
- gitpr_cli-1.4.2.dist-info/entry_points.txt,sha256=TstGnMUzdOI5qNmpM97EPDBPuMqOZxoo6EoLxHSOMSQ,71
172
- gitpr_cli-1.4.2.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
173
- gitpr_cli-1.4.2.dist-info/RECORD,,
169
+ gitpr_cli-1.4.3.dist-info/METADATA,sha256=65g6lyhixY8BoxcWT43zWKu811J92hec2eKCvmholUM,51553
170
+ gitpr_cli-1.4.3.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
171
+ gitpr_cli-1.4.3.dist-info/entry_points.txt,sha256=TstGnMUzdOI5qNmpM97EPDBPuMqOZxoo6EoLxHSOMSQ,71
172
+ gitpr_cli-1.4.3.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
173
+ gitpr_cli-1.4.3.dist-info/RECORD,,
@@ -129,20 +129,20 @@ def publish_pull_request_use_case(
129
129
  message=__("PR description must not be empty."),
130
130
  )
131
131
 
132
- if not getattr(provider, "token", ""):
133
- return PublishPrOutcome(
134
- status="auth_required",
135
- provider=provider.name,
136
- source_branch=source_branch,
137
- target_branch=target_branch,
138
- http_status=401,
139
- message=__(
140
- "{provider} token is not configured. Run 'gitpr' in a terminal to authenticate.",
141
- provider=label,
142
- ),
143
- )
144
-
145
132
  if check_remote_state:
133
+ if not getattr(provider, "token", ""):
134
+ return PublishPrOutcome(
135
+ status="auth_required",
136
+ provider=provider.name,
137
+ source_branch=source_branch,
138
+ target_branch=target_branch,
139
+ http_status=401,
140
+ message=__(
141
+ "{provider} token is not configured. Run 'gitpr' in a terminal to authenticate.",
142
+ provider=label,
143
+ ),
144
+ )
145
+
146
146
  try:
147
147
  existing: Optional[PullRequestResult] = provider.check_existing_pull_request(
148
148
  repo_ref, source_branch
@@ -254,10 +254,12 @@ def publish_pull_request_use_case(
254
254
  message=mask_sensitive_text(str(exc)),
255
255
  )
256
256
 
257
+ pr_url = getattr(result, "url", "")
258
+ pr_number = getattr(result, "number", 0)
257
259
  return PublishPrOutcome(
258
260
  status="success",
259
- url=result.url,
260
- number=result.number,
261
+ url=pr_url,
262
+ number=pr_number,
261
263
  provider=provider.name,
262
264
  source_branch=source_branch,
263
265
  target_branch=target_branch,
@@ -265,6 +267,6 @@ def publish_pull_request_use_case(
265
267
  message=__(
266
268
  "PR successfully created on {provider}: {pr_url}",
267
269
  provider=label,
268
- pr_url=result.url,
270
+ pr_url=pr_url,
269
271
  ),
270
272
  )
src/main.py CHANGED
@@ -75,6 +75,18 @@ def _may_ask_the_user(quiet=False, hook=False, mcp=False):
75
75
  return False
76
76
 
77
77
 
78
+ def _subcommand_help_in_argv(argv=None):
79
+ """Whether the raw command line asks for help.
80
+
81
+ Click clears the group context arguments before this callback runs, so by
82
+ the time the update gate below executes, ``gitpr release -h`` is
83
+ indistinguishable from ``gitpr release``. The raw tokens are the only
84
+ surviving record. The scan fails open, which costs at most one skipped gate.
85
+ """
86
+ tokens = sys.argv[1:] if argv is None else argv
87
+ return any(token in ("-h", "--help") for token in tokens)
88
+
89
+
78
90
  def print_banner():
79
91
  """Displays the project ASCII Art signature"""
80
92
  banner = r"""
@@ -677,6 +689,13 @@ def cli(
677
689
  # first, so everything below runs only when NO subcommand was requested —
678
690
  # the legacy flag surface stays byte-for-byte intact (ADR-002).
679
691
  if ctx.invoked_subcommand is not None:
692
+ # The mandatory update gate covers subcommands too, but only when the
693
+ # run may stop and ask the user: a release in CI, a pipe or a hook must
694
+ # never be blocked. Help tokens are read from the raw argv because
695
+ # Click clears the context arguments before this callback runs.
696
+ if may_ask and not update and not help_flag and not _subcommand_help_in_argv():
697
+ if enforce_update_required():
698
+ sys.exit(1)
680
699
  return
681
700
 
682
701
  # ============================================================
src/updater.py CHANGED
@@ -1,25 +1,38 @@
1
- import urllib.request
2
1
  import json
3
2
  import os
4
- import click
3
+ import tempfile
5
4
  from datetime import datetime
6
5
 
6
+ import click
7
+ from packaging.version import InvalidVersion, Version
8
+
7
9
  # Current version of your local executable (Update this on every new build!)
8
10
  # ── Must stay ABOVE the i18n import: i18n.py lazily imports __lang_version__
9
11
  # from here, and pyproject.toml reads __version__ via setuptools attr:.
10
- __version__ = "1.4.2" # GitPR current version
12
+ __version__ = "1.4.3" # GitPR current version
11
13
  __lang_version__ = "v0.0.35" # Language dictionary version control
12
14
  __scripts_version__ = (
13
15
  "v0.0.4" # Git Hook scripts version control (independent from __lang_version__)
14
16
  )
15
17
 
16
18
  from src.i18n import __
19
+ from src.net import bounded_urlopen
17
20
 
18
21
  PYPI_API_URL = "https://pypi.org/pypi/gitpr-cli/json"
19
22
 
23
+ # How long a PyPI answer is trusted before the next check goes back online.
24
+ # A rolling window, not the local calendar day: a release published after the
25
+ # first run of the day used to stay invisible until midnight.
26
+ UPDATE_CACHE_TTL_SECONDS = 3600
27
+
28
+ # Wall-clock ceiling for the PyPI fetch, DNS resolution included.
29
+ UPDATE_FETCH_HARD_TIMEOUT = 3.0
30
+
20
31
  # Environment switch used to mute the mandatory update check (test harness and
21
- # offline automation). Read as a plain flag: any non-empty value disables it.
32
+ # offline automation). Parsed as a strict boolean: only _SKIP_TRUTHY_VALUES
33
+ # disable it, so `GITPR_SKIP_UPDATE_CHECK=false` still checks.
22
34
  SKIP_UPDATE_CHECK_ENV = "GITPR_SKIP_UPDATE_CHECK"
35
+ _SKIP_TRUTHY_VALUES = ("1", "true", "yes", "on", "y")
23
36
 
24
37
 
25
38
  def get_gitpr_dir():
@@ -32,72 +45,188 @@ def get_update_cache_file():
32
45
  return os.path.join(get_gitpr_dir(), "update_cache.json")
33
46
 
34
47
 
35
- def parse_version(version_str):
36
- """Converts 'v0.1.0' or '0.1.0' into a tuple (0, 1, 0) for version math."""
37
- clean_version = version_str.lower().replace("v", "")
48
+ def _coerce_version(value):
49
+ """Parses *value* as a PEP 440 version, or returns None when it is not one."""
50
+ if not isinstance(value, str):
51
+ return None
52
+ text = value.strip()
53
+ # packaging rejects a leading "v"; the old helper stripped every "v" in the
54
+ # string, which corrupted suffixes such as 1.2.3-dev.
55
+ if text[:1] in ("v", "V"):
56
+ text = text[1:]
57
+ if not text:
58
+ return None
38
59
  try:
39
- return tuple(map(int, clean_version.split(".")))
40
- except ValueError:
41
- return (0, 0, 0)
60
+ return Version(text)
61
+ except InvalidVersion:
62
+ return None
63
+
64
+
65
+ def parse_version(version_str):
66
+ """Converts 'v0.1.0' or '0.1.0' into a tuple (0, 1, 0) for version math.
67
+
68
+ Kept for compatibility with outside callers. The tuple is lossy for
69
+ pre/post/dev markers, so the update gate itself compares Version objects
70
+ through _is_newer() instead.
71
+ """
72
+ parsed = _coerce_version(version_str)
73
+ return parsed.release if parsed is not None else (0, 0, 0)
42
74
 
43
75
 
44
76
  def is_update_check_disabled():
45
77
  """Returns True when the update check must be muted for this execution."""
46
- return bool(os.environ.get(SKIP_UPDATE_CHECK_ENV, "").strip())
78
+ value = os.environ.get(SKIP_UPDATE_CHECK_ENV, "")
79
+ return value.strip().lower() in _SKIP_TRUTHY_VALUES
47
80
 
48
81
 
49
- def get_latest_remote_version():
50
- """Fetches the latest version published on PyPI, with a daily cache."""
51
- cache_file = get_update_cache_file()
52
- today = datetime.now().strftime("%Y-%m-%d")
82
+ def _cache_is_fresh(cache_data, now=None):
83
+ """Whether *cache_data* carries a timestamp inside the freshness window.
84
+
85
+ A timestamp in the future (the clock moved backwards) counts as stale, so
86
+ a clock jump can never freeze the cache on an old answer indefinitely.
87
+ """
88
+ checked_at = cache_data.get("checked_at")
89
+ if not isinstance(checked_at, str):
90
+ return False
91
+ try:
92
+ written_at = datetime.fromisoformat(checked_at)
93
+ current = datetime.now() if now is None else now
94
+ age = (current - written_at).total_seconds()
95
+ except (TypeError, ValueError):
96
+ # Not a valid timestamp, or a timezone-aware stamp this cache never writes.
97
+ return False
98
+ return 0 <= age < UPDATE_CACHE_TTL_SECONDS
99
+
100
+
101
+ def _read_cached_version(cache_file):
102
+ """Returns the cached PyPI version while it is still fresh, else "".
103
+
104
+ Only the current schema is read: a document from the former calendar-day
105
+ cache has no ``checked_at`` and therefore counts as expired.
106
+ """
107
+ try:
108
+ with open(cache_file, "r", encoding="utf-8", errors="replace") as f:
109
+ cache_data = json.load(f)
110
+ except Exception:
111
+ return ""
112
+
113
+ if not isinstance(cache_data, dict) or not _cache_is_fresh(cache_data):
114
+ return ""
115
+
116
+ version = cache_data.get("version")
117
+ if isinstance(version, str) and version.strip():
118
+ return version.strip()
119
+ return ""
120
+
53
121
 
54
- # Try to read from cache to avoid slowing down the user's terminal
55
- if os.path.exists(cache_file):
122
+ def _write_cached_version(cache_file, version):
123
+ """Writes the cache atomically; the caller treats a failure as non-fatal."""
124
+ folder = os.path.dirname(cache_file)
125
+ if folder:
126
+ os.makedirs(folder, exist_ok=True)
127
+
128
+ payload = {"checked_at": datetime.now().isoformat(), "version": version}
129
+ handle = tempfile.NamedTemporaryFile(
130
+ "w",
131
+ encoding="utf-8",
132
+ dir=folder or ".",
133
+ prefix=".update-cache-",
134
+ suffix=".tmp",
135
+ delete=False,
136
+ )
137
+ try:
138
+ with handle:
139
+ json.dump(payload, handle)
140
+ os.replace(handle.name, cache_file)
141
+ except Exception:
56
142
  try:
57
- with open(cache_file, "r", encoding="utf-8", errors="replace") as f:
58
- cache_data = json.load(f)
59
- if cache_data.get("date") == today:
60
- return cache_data.get("version", "")
61
- except Exception:
143
+ os.unlink(handle.name)
144
+ except OSError:
62
145
  pass
146
+ raise
147
+
63
148
 
64
- # Fetch from Web if cache expired
65
- latest_version = ""
149
+ def _fetch_pypi_version():
150
+ """Returns the version published on PyPI, or "" when it cannot be read."""
151
+ raw = bounded_urlopen(
152
+ PYPI_API_URL,
153
+ timeout=3,
154
+ hard_timeout=UPDATE_FETCH_HARD_TIMEOUT,
155
+ headers={"User-Agent": "GitPR-Updater"},
156
+ )
157
+ if not raw:
158
+ return ""
66
159
 
67
160
  try:
68
- req = urllib.request.Request(PYPI_API_URL, headers={"User-Agent": "GitPR-Updater"})
69
- with urllib.request.urlopen(req, timeout=3) as response:
70
- data = json.loads(response.read().decode())
71
- latest_version = data.get("info", {}).get("version", "")
161
+ data = json.loads(raw.decode("utf-8", errors="replace"))
162
+ version = data["info"]["version"]
163
+ except Exception:
164
+ return ""
72
165
 
73
- if latest_version:
74
- os.makedirs(get_gitpr_dir(), exist_ok=True)
75
- with open(cache_file, "w", encoding="utf-8") as f:
76
- json.dump({"date": today, "version": latest_version}, f)
166
+ # Only a parseable version is trusted: a captive portal or a malformed
167
+ # mirror must not poison the cache for a whole hour.
168
+ if _coerce_version(version) is None:
169
+ return ""
170
+ return version.strip()
77
171
 
78
- except Exception:
79
- pass # Silent failure in case of no internet
80
172
 
173
+ def get_latest_remote_version(force=False):
174
+ """Fetches the latest version published on PyPI, with a one-hour cache.
175
+
176
+ ``force=True`` (the ``--update`` flag) always asks PyPI: that command
177
+ promises an immediate verification, so it must not answer from the cache.
178
+ Returns "" when the remote version is unknown — offline, stalled DNS, or a
179
+ body that is not a version — and the caller then lets the run proceed.
180
+ """
181
+ cache_file = get_update_cache_file()
182
+
183
+ if not force:
184
+ cached = _read_cached_version(cache_file)
185
+ if cached:
186
+ return cached
187
+
188
+ latest_version = _fetch_pypi_version()
189
+ if latest_version:
190
+ try:
191
+ _write_cached_version(cache_file, latest_version)
192
+ except Exception:
193
+ pass # A read-only home or a locked file must not hide the answer
81
194
  return latest_version
82
195
 
83
196
 
197
+ def _is_newer(remote_version, local_version=None):
198
+ """Whether *remote_version* is a version this install should upgrade to.
199
+
200
+ PEP 440 ordering instead of a tuple comparison, so 1.4.10 > 1.4.9 and
201
+ pre/post/dev markers follow the standard. A remote pre-release is ignored
202
+ while the local install is stable: pip will not install it without --pre,
203
+ so blocking would tell the user to run a command that does nothing. An
204
+ unparseable version on either side is "unknown", never "newer".
205
+ """
206
+ remote = _coerce_version(remote_version)
207
+ local = _coerce_version(__version__ if local_version is None else local_version)
208
+ if remote is None or local is None:
209
+ return False
210
+ if remote.is_prerelease and not local.is_prerelease:
211
+ return False
212
+ return remote > local
213
+
214
+
84
215
  def enforce_update_required():
85
216
  """Blocks execution while a newer GitPR version is published on PyPI.
86
217
 
87
218
  Prints the upgrade instructions and returns True when the caller must stop.
88
219
  Returns False when the local version is current, when the remote version is
89
- unknown (offline, so the user could not upgrade anyway) or when the check is
90
- disabled via GITPR_SKIP_UPDATE_CHECK.
220
+ unknown (offline, so the user could not upgrade anyway), when it is a
221
+ pre-release a stable install cannot move to, or when the check is disabled
222
+ via GITPR_SKIP_UPDATE_CHECK.
91
223
  """
92
224
  if is_update_check_disabled():
93
225
  return False
94
226
 
95
227
  latest_version = get_latest_remote_version()
96
228
 
97
- if not latest_version:
98
- return False
99
-
100
- if parse_version(latest_version) <= parse_version(__version__):
229
+ if not _is_newer(latest_version):
101
230
  return False
102
231
 
103
232
  click.echo("")
@@ -121,13 +250,13 @@ def enforce_update_required():
121
250
 
122
251
  def check_and_update():
123
252
  """Function triggered only when the user forces the --update flag."""
124
- latest_version = get_latest_remote_version()
253
+ latest_version = get_latest_remote_version(force=True)
125
254
 
126
255
  if not latest_version:
127
256
  click.secho(__("❌ Could not check for updates at this moment."), fg="red")
128
257
  return
129
258
 
130
- if parse_version(latest_version) > parse_version(__version__):
259
+ if _is_newer(latest_version):
131
260
  click.secho(
132
261
  __(
133
262
  "\n🚀 New GitPR version found (v{latest_version})!",