git-commit-guard 0.5.0__tar.gz → 0.7.0__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.
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: Lint commits
3
+ on: # yamllint disable-line rule:truthy
4
+ pull_request:
5
+ permissions:
6
+ contents: read
7
+ jobs:
8
+ lint-commits:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: write
12
+ steps:
13
+ - name: Checkout code
14
+ # yamllint disable-line rule:line-length
15
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
16
+ with:
17
+ persist-credentials: false
18
+ fetch-depth: 0
19
+ - name: Install Python
20
+ # yamllint disable-line rule:line-length
21
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
22
+ with:
23
+ python-version: '3.12'
24
+ - name: Install uv
25
+ # yamllint disable-line rule:line-length
26
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
27
+ - name: Install commit-guard
28
+ run: uv pip install --system .
29
+ - name: Cache NLTK data
30
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
31
+ with:
32
+ path: ~/nltk_data
33
+ key: nltk-averaged-perceptron-tagger-punkt
34
+ - name: Lint commits
35
+ env:
36
+ BASE_REF: ${{ github.base_ref }}
37
+ run: |-
38
+ commits=$(git log --no-merges --format="%H" "origin/$BASE_REF"..HEAD)
39
+ failed=0
40
+ for sha in $commits; do
41
+ echo "--- checking $sha ---"
42
+ commit-guard --disable signature "$sha" || failed=1
43
+ done
44
+ exit $failed
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: Lint Markdown
3
+ on: # yamllint disable-line rule:truthy
4
+ pull_request:
5
+ permissions:
6
+ contents: read
7
+ jobs:
8
+ lint-md:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ pull-requests: write
13
+ steps:
14
+ - name: Checkout code
15
+ # yamllint disable-line rule:line-length
16
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
17
+ with:
18
+ persist-credentials: false
19
+ - name: Lint Markdown files with reviewdog
20
+ # yamllint disable-line rule:line-length
21
+ uses: reviewdog/action-markdownlint@3667398db9118d7e78f7a63d10e26ce454ba5f58 # v0.26.2
22
+ with:
23
+ github_token: ${{ secrets.GITHUB_TOKEN }}
24
+ reporter: github-pr-review
25
+ level: info
26
+ fail_on_error: true
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: Test
3
+ on: # yamllint disable-line rule:truthy
4
+ pull_request:
5
+ permissions:
6
+ contents: read
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Checkout code
12
+ # yamllint disable-line rule:line-length
13
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
14
+ with:
15
+ persist-credentials: false
16
+ - name: Install uv
17
+ # yamllint disable-line rule:line-length
18
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
19
+ - name: Cache NLTK data
20
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
21
+ with:
22
+ path: ~/nltk_data
23
+ key: nltk-averaged-perceptron-tagger-punkt
24
+ - name: Run tests with coverage
25
+ run: |-
26
+ uv run --dev pytest \
27
+ tests/ --cov=git_commit_guard --cov-report=term-missing
@@ -1 +1,2 @@
1
1
  __pycache__
2
+ .coverage
@@ -10,3 +10,14 @@
10
10
  always_run: true
11
11
  pass_filenames: true
12
12
  minimum_pre_commit_version: 2.8.0
13
+
14
+ - id: commit-guard-signature
15
+ name: commit-guard (signature)
16
+ description: Verify commit is GPG/SSH signed
17
+ entry: commit-guard
18
+ args: [HEAD, --enable, signature]
19
+ language: python
20
+ stages: [post-commit]
21
+ always_run: true
22
+ pass_filenames: false
23
+ minimum_pre_commit_version: 2.8.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-guard
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Opinionated conventional commit message linter with imperative mood detection
5
5
  Project-URL: Homepage, https://github.com/benner/commit-guard
6
6
  Project-URL: Repository, https://github.com/benner/commit-guard
@@ -31,7 +31,8 @@ imperative verb.
31
31
 
32
32
  ```bash
33
33
  $ commit-guard
34
- ✗ subject does not match 'type(scope): description': Merge pull request #5 from fix/branch
34
+ ✗ subject does not match 'type(scope): description':
35
+ Merge pull request #5 from fix/branch
35
36
  ✗ missing 'Signed-off-by' trailer
36
37
  ✗ commit is not signed (GPG/SSH)
37
38
  ```
@@ -125,14 +126,19 @@ repos:
125
126
  rev: v0.1.0
126
127
  hooks:
127
128
  - id: commit-guard
129
+ - id: commit-guard-signature
128
130
  ```
129
131
 
130
- Install the hook:
132
+ Install the hooks:
131
133
 
132
134
  ```bash
133
- pre-commit install --hook-type commit-msg
135
+ pre-commit install --hook-type commit-msg --hook-type post-commit
134
136
  ```
135
137
 
138
+ `commit-guard` runs at the `commit-msg` stage and checks message format.
139
+ `commit-guard-signature` runs at the `post-commit` stage and verifies
140
+ the GPG/SSH signature after the commit object is created.
141
+
136
142
  To selectively enable or disable checks, pass `args`:
137
143
 
138
144
  ```yaml
@@ -144,10 +150,9 @@ To selectively enable or disable checks, pass `args`:
144
150
 
145
151
  commit-guard combines two strategies to detect non-imperative descriptions:
146
152
 
147
- 1. A whitelist common commit verbs (`add`, `fix`, `remove`, etc.)
148
- that pass immediately without NLP.
149
- 2. nltk POS tagging as a fallback — flags words tagged as past tense (`VBD`),
153
+ 1. nltk POS tagging flags words tagged as past tense (`VBD`),
150
154
  gerund (`VBG`), third person (`VBZ`), etc.
155
+ 2. WordNet morphology as a fallback for words the tagger misclassifies.
151
156
 
152
157
  This catches common mistakes like `added logging` or `fixes bug` while
153
158
  keeping false positives low.
@@ -10,7 +10,8 @@ imperative verb.
10
10
 
11
11
  ```bash
12
12
  $ commit-guard
13
- ✗ subject does not match 'type(scope): description': Merge pull request #5 from fix/branch
13
+ ✗ subject does not match 'type(scope): description':
14
+ Merge pull request #5 from fix/branch
14
15
  ✗ missing 'Signed-off-by' trailer
15
16
  ✗ commit is not signed (GPG/SSH)
16
17
  ```
@@ -104,14 +105,19 @@ repos:
104
105
  rev: v0.1.0
105
106
  hooks:
106
107
  - id: commit-guard
108
+ - id: commit-guard-signature
107
109
  ```
108
110
 
109
- Install the hook:
111
+ Install the hooks:
110
112
 
111
113
  ```bash
112
- pre-commit install --hook-type commit-msg
114
+ pre-commit install --hook-type commit-msg --hook-type post-commit
113
115
  ```
114
116
 
117
+ `commit-guard` runs at the `commit-msg` stage and checks message format.
118
+ `commit-guard-signature` runs at the `post-commit` stage and verifies
119
+ the GPG/SSH signature after the commit object is created.
120
+
115
121
  To selectively enable or disable checks, pass `args`:
116
122
 
117
123
  ```yaml
@@ -123,10 +129,9 @@ To selectively enable or disable checks, pass `args`:
123
129
 
124
130
  commit-guard combines two strategies to detect non-imperative descriptions:
125
131
 
126
- 1. A whitelist common commit verbs (`add`, `fix`, `remove`, etc.)
127
- that pass immediately without NLP.
128
- 2. nltk POS tagging as a fallback — flags words tagged as past tense (`VBD`),
132
+ 1. nltk POS tagging flags words tagged as past tense (`VBD`),
129
133
  gerund (`VBG`), third person (`VBZ`), etc.
134
+ 2. WordNet morphology as a fallback for words the tagger misclassifies.
130
135
 
131
136
  This catches common mistakes like `added logging` or `fixes bug` while
132
137
  keeping false positives low.
@@ -33,3 +33,9 @@ build-backend = "hatchling.build"
33
33
 
34
34
  [tool.hatch.version]
35
35
  source = "vcs"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=9.0.2",
40
+ "pytest-cov>=7.1.0",
41
+ ]
@@ -7,6 +7,7 @@ from enum import StrEnum
7
7
  from pathlib import Path
8
8
 
9
9
  import nltk
10
+ from nltk.corpus import wordnet
10
11
 
11
12
  TYPES = frozenset(
12
13
  {
@@ -24,112 +25,7 @@ TYPES = frozenset(
24
25
  }
25
26
  )
26
27
 
27
- IMPERATIVE_VERBS = frozenset(
28
- {
29
- "add",
30
- "allow",
31
- "apply",
32
- "avoid",
33
- "bump",
34
- "change",
35
- "check",
36
- "clean",
37
- "clear",
38
- "configure",
39
- "correct",
40
- "create",
41
- "catch",
42
- "define",
43
- "delete",
44
- "deprecate",
45
- "disable",
46
- "document",
47
- "drop",
48
- "enable",
49
- "enforce",
50
- "ensure",
51
- "exclude",
52
- "export",
53
- "extend",
54
- "extract",
55
- "fix",
56
- "format",
57
- "guard",
58
- "handle",
59
- "ignore",
60
- "implement",
61
- "import",
62
- "improve",
63
- "include",
64
- "increase",
65
- "initialize",
66
- "inline",
67
- "install",
68
- "introduce",
69
- "invalidate",
70
- "limit",
71
- "log",
72
- "make",
73
- "mark",
74
- "merge",
75
- "migrate",
76
- "move",
77
- "normalize",
78
- "open",
79
- "optimize",
80
- "override",
81
- "parse",
82
- "pass",
83
- "patch",
84
- "pin",
85
- "port",
86
- "prevent",
87
- "print",
88
- "provide",
89
- "publish",
90
- "reduce",
91
- "refactor",
92
- "release",
93
- "remove",
94
- "rename",
95
- "reorganize",
96
- "replace",
97
- "require",
98
- "reset",
99
- "resolve",
100
- "restore",
101
- "restrict",
102
- "return",
103
- "revert",
104
- "report",
105
- "run",
106
- "separate",
107
- "set",
108
- "show",
109
- "simplify",
110
- "skip",
111
- "sort",
112
- "split",
113
- "start",
114
- "store",
115
- "stop",
116
- "support",
117
- "suppress",
118
- "switch",
119
- "sync",
120
- "track",
121
- "trim",
122
- "unify",
123
- "update",
124
- "upgrade",
125
- "use",
126
- "wait",
127
- "validate",
128
- "vendor",
129
- "verify",
130
- "wrap",
131
- }
132
- )
28
+ _NON_IMPERATIVE_SUFFIX_RE = re.compile(r"(?:ing|ed)$")
133
29
 
134
30
  SUBJECT_RE = re.compile(
135
31
  r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?!?:\s+(?P<desc>.+)$",
@@ -189,6 +85,7 @@ class Result:
189
85
  def _ensure_nltk_data():
190
86
  _download_if_missing("taggers/averaged_perceptron_tagger_eng")
191
87
  _download_if_missing("tokenizers/punkt_tab")
88
+ _download_if_missing("corpora/wordnet")
192
89
 
193
90
 
194
91
  def _download_if_missing(resource):
@@ -228,12 +125,21 @@ def check_imperative(desc, result):
228
125
  if not tokens:
229
126
  return
230
127
  first = tokens[0]
231
- if first in IMPERATIVE_VERBS:
128
+ if _NON_IMPERATIVE_SUFFIX_RE.search(first):
129
+ result.error(f"expected imperative verb, got '{first}' (non-imperative suffix)")
130
+ return
131
+ base = wordnet.morphy(first, wordnet.VERB)
132
+ if base is not None and base != first:
133
+ result.error(
134
+ f"expected imperative verb, got '{first}' (inflected form of '{base}')"
135
+ )
232
136
  return
233
- tagged = nltk.pos_tag(tokens)
234
- if tagged[0][1] != "VB":
137
+ tagged = nltk.pos_tag(["to", *tokens])
138
+ if tagged[1][1] != "VB":
139
+ if wordnet.morphy(first, wordnet.VERB) == first:
140
+ return
235
141
  result.error(
236
- f"expected imperative verb, got '{tagged[0][0]}' (POS={tagged[0][1]})",
142
+ f"expected imperative verb, got '{tagged[1][0]}' (POS={tagged[1][1]})",
237
143
  )
238
144
 
239
145
 
@@ -369,10 +275,7 @@ def main():
369
275
  check_body(lines, result)
370
276
  if Check.SIGNED_OFF in args.enabled:
371
277
  check_signed_off(args.message, result)
372
- if Check.SIGNATURE in args.enabled:
373
- if args.rev:
374
- check_signature(args.rev, result)
375
- else:
376
- result.warn("signature check skipped (no commit ref)")
278
+ if Check.SIGNATURE in args.enabled and args.rev:
279
+ check_signature(args.rev, result)
377
280
 
378
281
  return _report(result)
File without changes
@@ -0,0 +1,464 @@
1
+ import subprocess
2
+ from argparse import ArgumentParser
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+ from git_commit_guard import (
7
+ Result,
8
+ _download_if_missing,
9
+ _ensure_nltk_data,
10
+ _get_message,
11
+ _parse_checks,
12
+ _report,
13
+ _strip_comments,
14
+ check_body,
15
+ check_imperative,
16
+ check_signature,
17
+ check_signed_off,
18
+ check_subject,
19
+ main,
20
+ )
21
+
22
+ # ruff: noqa: S101 # Use of `assert` detected
23
+
24
+
25
+ @pytest.fixture(scope="session", autouse=True)
26
+ def nltk_data():
27
+ _ensure_nltk_data()
28
+
29
+
30
+ class TestResult:
31
+ def test_ok_when_empty(self):
32
+ assert Result().ok
33
+
34
+ def test_not_ok_with_error(self):
35
+ r = Result()
36
+ r.error("bad")
37
+ assert not r.ok
38
+
39
+ def test_ok_with_only_warn(self):
40
+ r = Result()
41
+ r.warn("hmm")
42
+ assert r.ok
43
+
44
+ def test_ok_with_only_info(self):
45
+ r = Result()
46
+ r.info("fyi")
47
+ assert r.ok
48
+
49
+
50
+ class TestStripComments:
51
+ def test_removes_comment_lines(self):
52
+ assert _strip_comments("# comment\nfoo") == "foo"
53
+
54
+ def test_keeps_non_comment_lines(self):
55
+ assert _strip_comments("foo\nbar") == "foo\nbar"
56
+
57
+ def test_removes_indented_comments(self):
58
+ assert _strip_comments(" # indented\nfoo") == "foo"
59
+
60
+ def test_empty_string(self):
61
+ assert _strip_comments("") == ""
62
+
63
+
64
+ class TestCheckSubject:
65
+ def test_valid_simple(self):
66
+ r = Result()
67
+ desc = check_subject("fix: add token refresh", r)
68
+ assert desc == "add token refresh"
69
+ assert r.ok
70
+
71
+ def test_valid_with_scope(self):
72
+ r = Result()
73
+ desc = check_subject("feat(auth): add login", r)
74
+ assert desc == "add login"
75
+ assert r.ok
76
+
77
+ def test_valid_breaking_change(self):
78
+ r = Result()
79
+ check_subject("feat!: drop v1 support", r)
80
+ assert r.ok
81
+
82
+ def test_valid_breaking_change_with_scope(self):
83
+ r = Result()
84
+ check_subject("feat(api)!: drop v1", r)
85
+ assert r.ok
86
+
87
+ def test_invalid_format(self):
88
+ r = Result()
89
+ desc = check_subject("not a commit", r)
90
+ assert desc is None
91
+ assert not r.ok
92
+
93
+ def test_unknown_type(self):
94
+ r = Result()
95
+ check_subject("unknown: add thing", r)
96
+ assert not r.ok
97
+
98
+ def test_uppercase_description(self):
99
+ r = Result()
100
+ check_subject("fix: Add token", r)
101
+ assert not r.ok
102
+
103
+ def test_trailing_period(self):
104
+ r = Result()
105
+ check_subject("fix: add token.", r)
106
+ assert not r.ok
107
+
108
+ def test_subject_too_long(self):
109
+ r = Result()
110
+ check_subject("fix: " + "a" * 68, r) # 73 chars total
111
+ assert not r.ok
112
+
113
+ def test_subject_at_max_length(self):
114
+ r = Result()
115
+ check_subject("fix: " + "a" * 67, r) # exactly 72 chars
116
+ assert r.ok
117
+
118
+ @pytest.mark.parametrize(
119
+ "type_",
120
+ [
121
+ "feat",
122
+ "fix",
123
+ "docs",
124
+ "style",
125
+ "refactor",
126
+ "perf",
127
+ "test",
128
+ "build",
129
+ "ci",
130
+ "chore",
131
+ "revert",
132
+ ],
133
+ )
134
+ def test_all_valid_types(self, type_):
135
+ r = Result()
136
+ check_subject(f"{type_}: add thing", r)
137
+ assert r.ok
138
+
139
+
140
+ class TestCheckBody:
141
+ def test_subject_only(self):
142
+ r = Result()
143
+ check_body(["fix: add thing"], r)
144
+ assert not r.ok
145
+
146
+ def test_subject_and_blank_only(self):
147
+ r = Result()
148
+ check_body(["fix: add thing", ""], r)
149
+ assert not r.ok
150
+
151
+ def test_valid_body(self):
152
+ r = Result()
153
+ check_body(["fix: add thing", "", "body text here"], r)
154
+ assert r.ok
155
+
156
+ def test_missing_blank_line(self):
157
+ r = Result()
158
+ check_body(["fix: add thing", "body text", "more"], r)
159
+ assert not r.ok
160
+
161
+ def test_blank_body_content(self):
162
+ r = Result()
163
+ check_body(["fix: add thing", "", " "], r)
164
+ assert not r.ok
165
+
166
+ def test_multiline_body(self):
167
+ r = Result()
168
+ check_body(["fix: add thing", "", "line one", "line two"], r)
169
+ assert r.ok
170
+
171
+
172
+ class TestCheckSignedOff:
173
+ def test_valid(self):
174
+ r = Result()
175
+ check_signed_off(
176
+ "fix: add thing\n\nbody\n\nSigned-off-by: John Doe <john@example.com>",
177
+ r,
178
+ )
179
+ assert r.ok
180
+
181
+ def test_missing(self):
182
+ r = Result()
183
+ check_signed_off("fix: add thing\n\nbody", r)
184
+ assert not r.ok
185
+
186
+ def test_malformed_no_email(self):
187
+ r = Result()
188
+ check_signed_off("fix: add thing\n\nSigned-off-by: John Doe", r)
189
+ assert not r.ok
190
+
191
+
192
+ class TestCheckImperative:
193
+ def test_imperative_verb_passes(self):
194
+ r = Result()
195
+ check_imperative("add token refresh", r)
196
+ assert r.ok
197
+
198
+ def test_ed_suffix_fails(self):
199
+ r = Result()
200
+ check_imperative("added token refresh", r)
201
+ assert not r.ok
202
+
203
+ def test_ing_suffix_fails(self):
204
+ r = Result()
205
+ check_imperative("adding token refresh", r)
206
+ assert not r.ok
207
+
208
+ def test_third_person_fails(self):
209
+ r = Result()
210
+ check_imperative("adds token refresh", r)
211
+ assert not r.ok
212
+
213
+ def test_third_person_es_suffix_fails(self):
214
+ r = Result()
215
+ check_imperative("fixes the bug", r)
216
+ assert not r.ok
217
+
218
+ def test_non_whitelist_imperative_passes(self):
219
+ r = Result()
220
+ check_imperative("refactor authentication module", r)
221
+ assert r.ok
222
+
223
+ def test_write_imperative_passes(self):
224
+ r = Result()
225
+ check_imperative("write unit tests", r)
226
+ assert r.ok
227
+
228
+ def test_tagger_misclassified_verb_passes(self):
229
+ # 'disable' is tagged non-VB by the tagger but wordnet confirms it as a verb
230
+ r = Result()
231
+ check_imperative("disable feature flag", r)
232
+ assert r.ok
233
+
234
+ def test_refactor_passes(self):
235
+ r = Result()
236
+ check_imperative("refactor authentication module", r)
237
+ assert r.ok
238
+
239
+ def test_vendor_passes(self):
240
+ r = Result()
241
+ check_imperative("vendor third-party libs", r)
242
+ assert r.ok
243
+
244
+ def test_configure_passes(self):
245
+ r = Result()
246
+ check_imperative("configure logging pipeline", r)
247
+ assert r.ok
248
+
249
+ def test_empty_desc_passes(self):
250
+ r = Result()
251
+ check_imperative("", r)
252
+ assert r.ok
253
+
254
+
255
+ class TestDownloadIfMissing:
256
+ def test_skips_download_when_present(self):
257
+ with (
258
+ patch("git_commit_guard.nltk.data.find"),
259
+ patch("git_commit_guard.nltk.download") as mock_dl,
260
+ ):
261
+ _download_if_missing("tokenizers/punkt_tab")
262
+ mock_dl.assert_not_called()
263
+
264
+ def test_downloads_when_missing(self):
265
+ with (
266
+ patch("git_commit_guard.nltk.data.find", side_effect=LookupError),
267
+ patch("git_commit_guard.nltk.download") as mock_dl,
268
+ ):
269
+ _download_if_missing("tokenizers/punkt_tab")
270
+ mock_dl.assert_called_once_with("punkt_tab", quiet=True)
271
+
272
+
273
+ class TestCheckSignature:
274
+ def test_unsigned_commit(self):
275
+ r = Result()
276
+ proc = MagicMock(returncode=1)
277
+ with patch("git_commit_guard.subprocess.run", return_value=proc):
278
+ check_signature("abc123", r)
279
+ assert not r.ok
280
+
281
+ def test_gpg_signed_commit(self):
282
+ r = Result()
283
+ proc = MagicMock(returncode=0, stderr="gpg signature verified")
284
+ with patch("git_commit_guard.subprocess.run", return_value=proc):
285
+ check_signature("abc123", r)
286
+ assert r.ok
287
+ assert any("GPG" in msg for _, msg in r.errors)
288
+
289
+ def test_ssh_signed_commit(self):
290
+ r = Result()
291
+ proc = MagicMock(returncode=0, stderr="Good ssh signature")
292
+ with patch("git_commit_guard.subprocess.run", return_value=proc):
293
+ check_signature("abc123", r)
294
+ assert r.ok
295
+ assert any("SSH" in msg for _, msg in r.errors)
296
+
297
+
298
+ class TestGetMessage:
299
+ def test_success(self):
300
+ with patch(
301
+ "git_commit_guard.subprocess.check_output",
302
+ return_value="fix: add thing\n\n",
303
+ ):
304
+ assert _get_message("abc123") == "fix: add thing"
305
+
306
+ def test_unknown_revision(self):
307
+ err = subprocess.CalledProcessError(128, "git")
308
+ err.stderr = "fatal: unknown revision 'abc'"
309
+ with (
310
+ patch("git_commit_guard.subprocess.check_output", side_effect=err),
311
+ pytest.raises(SystemExit, match="no commits yet"),
312
+ ):
313
+ _get_message("abc")
314
+
315
+ def test_ambiguous_argument(self):
316
+ err = subprocess.CalledProcessError(128, "git")
317
+ err.stderr = "fatal: ambiguous argument 'HEAD'"
318
+ with (
319
+ patch("git_commit_guard.subprocess.check_output", side_effect=err),
320
+ pytest.raises(SystemExit, match="no commits yet"),
321
+ ):
322
+ _get_message("HEAD")
323
+
324
+ def test_other_git_error(self):
325
+ err = subprocess.CalledProcessError(128, "git")
326
+ err.stderr = "fatal: not a git repository"
327
+ with (
328
+ patch("git_commit_guard.subprocess.check_output", side_effect=err),
329
+ pytest.raises(SystemExit, match="git error"),
330
+ ):
331
+ _get_message("abc")
332
+
333
+
334
+ class TestParseChecks:
335
+ def test_invalid_check_name(self):
336
+ parser = ArgumentParser()
337
+ with pytest.raises(SystemExit):
338
+ _parse_checks(parser, "invalid")
339
+
340
+
341
+ class TestReport:
342
+ def test_all_passed(self, capsys):
343
+ r = Result()
344
+ ret = _report(r)
345
+ assert ret == 0
346
+ assert "all checks passed" in capsys.readouterr().err
347
+
348
+ def test_with_error(self, capsys):
349
+ r = Result()
350
+ r.error("something broke")
351
+ ret = _report(r)
352
+ assert ret == 1
353
+ assert "something broke" in capsys.readouterr().err
354
+
355
+ def test_with_warning_returns_zero(self, capsys):
356
+ r = Result()
357
+ r.warn("heads up")
358
+ ret = _report(r)
359
+ assert ret == 0
360
+ captured = capsys.readouterr().err
361
+ assert "heads up" in captured
362
+ assert "all checks passed" in captured
363
+
364
+
365
+ _VALID_MSG = "fix: add thing\n\nbody text\n\nSigned-off-by: A User <a@b.com>"
366
+
367
+
368
+ class TestMain:
369
+ def test_from_message_file(self, tmp_path, capsys):
370
+ f = tmp_path / "msg"
371
+ f.write_text(_VALID_MSG)
372
+ with patch(
373
+ "sys.argv",
374
+ ["cg", "--message-file", str(f), "--disable", "signature"],
375
+ ):
376
+ assert main() == 0
377
+ assert "all checks passed" in capsys.readouterr().err
378
+
379
+ def test_from_stdin(self):
380
+ stdin = MagicMock()
381
+ stdin.isatty.return_value = False
382
+ stdin.read.return_value = _VALID_MSG
383
+ with (
384
+ patch("sys.argv", ["cg", "--disable", "signature"]),
385
+ patch("sys.stdin", stdin),
386
+ ):
387
+ assert main() == 0
388
+
389
+ def test_from_rev(self):
390
+ with (
391
+ patch("sys.argv", ["cg", "abc123", "--disable", "signature"]),
392
+ patch("git_commit_guard._get_message", return_value=_VALID_MSG),
393
+ ):
394
+ assert main() == 0
395
+
396
+ def test_from_head(self):
397
+ stdin = MagicMock()
398
+ stdin.isatty.return_value = True
399
+ with (
400
+ patch("sys.argv", ["cg", "--disable", "signature"]),
401
+ patch("sys.stdin", stdin),
402
+ patch("git_commit_guard._get_message", return_value=_VALID_MSG),
403
+ ):
404
+ assert main() == 0
405
+
406
+ def test_enable_flag(self, tmp_path):
407
+ f = tmp_path / "msg"
408
+ f.write_text(_VALID_MSG)
409
+ with patch("sys.argv", ["cg", "--message-file", str(f), "--enable", "subject"]):
410
+ assert main() == 0
411
+
412
+ def test_invalid_message_returns_one(self, tmp_path):
413
+ f = tmp_path / "msg"
414
+ f.write_text("not a valid commit")
415
+ with patch(
416
+ "sys.argv",
417
+ [
418
+ "cg",
419
+ "--message-file",
420
+ str(f),
421
+ "--disable",
422
+ "signature,body,signed-off,imperative",
423
+ ],
424
+ ):
425
+ assert main() == 1
426
+
427
+ def test_signature_skipped_without_rev(self, tmp_path, capsys):
428
+ f = tmp_path / "msg"
429
+ f.write_text(_VALID_MSG)
430
+ with patch(
431
+ "sys.argv",
432
+ ["cg", "--message-file", str(f), "--enable", "signature"],
433
+ ):
434
+ ret = main()
435
+ assert ret == 0
436
+ assert "all checks passed" in capsys.readouterr().err
437
+
438
+ def test_imperative_only_no_subject_check(self, tmp_path):
439
+ # imperative enabled, subject not — desc starts as None, parsed from line
440
+ f = tmp_path / "msg"
441
+ f.write_text(_VALID_MSG)
442
+ with patch(
443
+ "sys.argv",
444
+ ["cg", "--message-file", str(f), "--enable", "imperative"],
445
+ ):
446
+ assert main() == 0
447
+
448
+ def test_signature_with_rev(self):
449
+ proc = MagicMock(returncode=0, stderr="gpg signature verified")
450
+ with (
451
+ patch("sys.argv", ["cg", "abc123", "--enable", "signature"]),
452
+ patch("git_commit_guard._get_message", return_value=_VALID_MSG),
453
+ patch("git_commit_guard.subprocess.run", return_value=proc),
454
+ ):
455
+ assert main() == 0
456
+
457
+ def test_invalid_check_name_exits(self, tmp_path):
458
+ f = tmp_path / "msg"
459
+ f.write_text(_VALID_MSG)
460
+ with (
461
+ patch("sys.argv", ["cg", "--message-file", str(f), "--enable", "bogus"]),
462
+ pytest.raises(SystemExit),
463
+ ):
464
+ main()
@@ -23,6 +23,90 @@ wheels = [
23
23
  { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
24
24
  ]
25
25
 
26
+ [[package]]
27
+ name = "coverage"
28
+ version = "7.13.5"
29
+ source = { registry = "https://pypi.org/simple" }
30
+ sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
31
+ wheels = [
32
+ { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
33
+ { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
34
+ { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
35
+ { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
36
+ { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
37
+ { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
38
+ { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
39
+ { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
40
+ { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
41
+ { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
42
+ { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
43
+ { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
44
+ { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
45
+ { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
46
+ { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
47
+ { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
48
+ { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
49
+ { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
50
+ { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
51
+ { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
52
+ { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
53
+ { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
54
+ { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
55
+ { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
56
+ { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
57
+ { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
58
+ { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
59
+ { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
60
+ { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
61
+ { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
62
+ { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
63
+ { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
64
+ { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
65
+ { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
66
+ { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
67
+ { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
68
+ { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
69
+ { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
70
+ { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
71
+ { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
72
+ { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
73
+ { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
74
+ { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
75
+ { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
76
+ { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
77
+ { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
78
+ { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
79
+ { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
80
+ { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
81
+ { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
82
+ { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
83
+ { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
84
+ { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
85
+ { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
86
+ { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
87
+ { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
88
+ { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
89
+ { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
90
+ { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
91
+ { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
92
+ { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
93
+ { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
94
+ { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
95
+ { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
96
+ { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
97
+ { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
98
+ { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
99
+ { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
100
+ { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
101
+ { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
102
+ { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
103
+ { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
104
+ { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
105
+ { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
106
+ { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
107
+ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
108
+ ]
109
+
26
110
  [[package]]
27
111
  name = "git-commit-guard"
28
112
  source = { editable = "." }
@@ -30,9 +114,30 @@ dependencies = [
30
114
  { name = "nltk" },
31
115
  ]
32
116
 
117
+ [package.dev-dependencies]
118
+ dev = [
119
+ { name = "pytest" },
120
+ { name = "pytest-cov" },
121
+ ]
122
+
33
123
  [package.metadata]
34
124
  requires-dist = [{ name = "nltk", specifier = ">=3.9.3" }]
35
125
 
126
+ [package.metadata.requires-dev]
127
+ dev = [
128
+ { name = "pytest", specifier = ">=9.0.2" },
129
+ { name = "pytest-cov", specifier = ">=7.1.0" },
130
+ ]
131
+
132
+ [[package]]
133
+ name = "iniconfig"
134
+ version = "2.3.0"
135
+ source = { registry = "https://pypi.org/simple" }
136
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
137
+ wheels = [
138
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
139
+ ]
140
+
36
141
  [[package]]
37
142
  name = "joblib"
38
143
  version = "1.5.3"
@@ -57,6 +162,63 @@ wheels = [
57
162
  { url = "https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl", hash = "sha256:60b3db6e9995b3dd976b1f0fa7dec22069b2677e759c28eb69b62ddd44870522", size = 1525385, upload-time = "2026-02-24T12:05:46.54Z" },
58
163
  ]
59
164
 
165
+ [[package]]
166
+ name = "packaging"
167
+ version = "26.0"
168
+ source = { registry = "https://pypi.org/simple" }
169
+ sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
170
+ wheels = [
171
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
172
+ ]
173
+
174
+ [[package]]
175
+ name = "pluggy"
176
+ version = "1.6.0"
177
+ source = { registry = "https://pypi.org/simple" }
178
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
179
+ wheels = [
180
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
181
+ ]
182
+
183
+ [[package]]
184
+ name = "pygments"
185
+ version = "2.19.2"
186
+ source = { registry = "https://pypi.org/simple" }
187
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
188
+ wheels = [
189
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
190
+ ]
191
+
192
+ [[package]]
193
+ name = "pytest"
194
+ version = "9.0.2"
195
+ source = { registry = "https://pypi.org/simple" }
196
+ dependencies = [
197
+ { name = "colorama", marker = "sys_platform == 'win32'" },
198
+ { name = "iniconfig" },
199
+ { name = "packaging" },
200
+ { name = "pluggy" },
201
+ { name = "pygments" },
202
+ ]
203
+ sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
204
+ wheels = [
205
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
206
+ ]
207
+
208
+ [[package]]
209
+ name = "pytest-cov"
210
+ version = "7.1.0"
211
+ source = { registry = "https://pypi.org/simple" }
212
+ dependencies = [
213
+ { name = "coverage" },
214
+ { name = "pluggy" },
215
+ { name = "pytest" },
216
+ ]
217
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
218
+ wheels = [
219
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
220
+ ]
221
+
60
222
  [[package]]
61
223
  name = "regex"
62
224
  version = "2026.2.28"