pyOpenSourceProjects 0.5.1__tar.gz → 0.6.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.
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/AGENTS.md +28 -3
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/PKG-INFO +2 -1
- pyopensourceprojects-0.6.0/osprojects/__init__.py +1 -0
- pyopensourceprojects-0.6.0/osprojects/git_api.py +79 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/osprojects/github_api.py +28 -28
- pyopensourceprojects-0.6.0/osprojects/gitlab_api.py +44 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/osprojects/osproject.py +82 -17
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/osprojects/version.py +2 -1
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/pyproject.toml +2 -0
- pyopensourceprojects-0.6.0/tests/test_gitlog2wiki.py +122 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/tests/test_osproject.py +1 -1
- pyopensourceprojects-0.5.1/osprojects/__init__.py +0 -1
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/.github/workflows/build.yml +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/.github/workflows/upload-to-pypi.yml +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/.gitignore +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/.project +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/.pydevproject +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/LICENSE +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/README.md +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/mkdocs.yml +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/osprojects/check_project.py +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/osprojects/checkos.py +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/osprojects/editor.py +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/scripts/blackisort +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/scripts/doc +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/scripts/install +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/scripts/installAndTest +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/scripts/release +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/scripts/test +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/tests/__init__.py +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/tests/basetest.py +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/tests/test_github.py +0 -0
- {pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/tests/test_github_api.py +0 -0
|
@@ -162,7 +162,32 @@ except requests.RequestException as e:
|
|
|
162
162
|
def process_items(items: list[dict], filter_key: str) -> Optional[list[str]]:
|
|
163
163
|
"""Process items and return filtered values."""
|
|
164
164
|
values = [item.get(filter_key) for item in items if filter_key in item]
|
|
165
|
-
|
|
165
|
+
result = values if values else None
|
|
166
|
+
return result
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Return Style
|
|
170
|
+
|
|
171
|
+
- Never return expressions directly; always assign to a variable first
|
|
172
|
+
- Avoid multiple return statements; use a single `return` at the end
|
|
173
|
+
- Use a result variable and set it conditionally:
|
|
174
|
+
```python
|
|
175
|
+
# correct
|
|
176
|
+
def get_value(flag: bool) -> Optional[str]:
|
|
177
|
+
result = None
|
|
178
|
+
if flag:
|
|
179
|
+
result = "yes"
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
# wrong - return expression
|
|
183
|
+
def get_value(flag: bool) -> Optional[str]:
|
|
184
|
+
return "yes" if flag else None
|
|
185
|
+
|
|
186
|
+
# wrong - multiple returns
|
|
187
|
+
def get_value(flag: bool) -> Optional[str]:
|
|
188
|
+
if not flag:
|
|
189
|
+
return None
|
|
190
|
+
return "yes"
|
|
166
191
|
```
|
|
167
192
|
|
|
168
193
|
## Version Bumping
|
|
@@ -187,12 +212,12 @@ Then run formatters, tests, commit and push (or use `scripts/release`).
|
|
|
187
212
|
|
|
188
213
|
## Git Workflow
|
|
189
214
|
|
|
190
|
-
1.
|
|
215
|
+
1. Read issue to work on
|
|
191
216
|
2. Make changes following the code style guidelines
|
|
192
217
|
3. Run formatters: `./scripts/blackisort`
|
|
193
218
|
4. Run tests: `./scripts/test`
|
|
194
219
|
5. Commit with a descriptive message
|
|
195
|
-
6. Push
|
|
220
|
+
6. Push
|
|
196
221
|
|
|
197
222
|
## Release
|
|
198
223
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyOpenSourceProjects
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Check python OpenSource Projects to follow standards for README, github workflow and pyprojec.toml - creates badges
|
|
5
5
|
Project-URL: Home, https://github.com/WolfgangFahl/pyOpenSourceProjects
|
|
6
6
|
Project-URL: Documentation, http://wiki.bitplan.com/index.php/pyOpenSourceProjects
|
|
@@ -20,6 +20,7 @@ Requires-Dist: beautifulsoup4>=4.14.2
|
|
|
20
20
|
Requires-Dist: gitpython
|
|
21
21
|
Requires-Dist: packaging>=24.1
|
|
22
22
|
Requires-Dist: py-3rdparty-mediawiki>=0.18.1
|
|
23
|
+
Requires-Dist: pybasemkit>=0.2.2
|
|
23
24
|
Requires-Dist: pylodstorage>=0.17.0
|
|
24
25
|
Requires-Dist: python-dateutil>=2.8.2
|
|
25
26
|
Requires-Dist: ratelimit>=2.2.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.0"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Created on 2026-03-16.
|
|
2
|
+
|
|
3
|
+
@author: wf
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class GenericRepo:
|
|
13
|
+
"""Represents a generic git repository hosted on any forge.
|
|
14
|
+
|
|
15
|
+
Parses owner and project_id from any standard git remote URL
|
|
16
|
+
(SSH: git@host:owner/repo.git or HTTPS: https://host/owner/repo.git).
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
owner (str): The owner of the repository.
|
|
20
|
+
project_id (str): The name/id of the repository.
|
|
21
|
+
url (str): The original remote URL.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
owner: str
|
|
25
|
+
project_id: str
|
|
26
|
+
url: str
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_url(cls, url: str) -> Optional["GenericRepo"]:
|
|
30
|
+
"""Parse owner and project_id from any standard git remote URL.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
url: SSH or HTTPS git remote URL.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
GenericRepo instance or None if the URL cannot be parsed.
|
|
37
|
+
"""
|
|
38
|
+
# Match HTTPS: https://host/owner/repo
|
|
39
|
+
https_pattern = r"https?://[^/]+/(?P<owner>[^/]+)/(?P<project_id>[^/.]+)"
|
|
40
|
+
match = re.match(https_pattern, url)
|
|
41
|
+
|
|
42
|
+
if not match:
|
|
43
|
+
# Match SSH: [user@]host:path - extract last two path components as owner/repo
|
|
44
|
+
ssh_pattern = r"(?:[^@]+@)?[^:]+:(?P<path>.+?)(?:\.git)?$"
|
|
45
|
+
ssh_match = re.match(ssh_pattern, url)
|
|
46
|
+
if ssh_match:
|
|
47
|
+
path = ssh_match.group("path")
|
|
48
|
+
# Split path and take last two components
|
|
49
|
+
parts = [p for p in path.split("/") if p]
|
|
50
|
+
if len(parts) >= 2:
|
|
51
|
+
owner = parts[-2]
|
|
52
|
+
project_id = parts[-1]
|
|
53
|
+
repo = cls(owner=owner, project_id=project_id, url=url)
|
|
54
|
+
return repo
|
|
55
|
+
|
|
56
|
+
repo = None
|
|
57
|
+
if match:
|
|
58
|
+
repo = cls(
|
|
59
|
+
owner=match.group("owner"),
|
|
60
|
+
project_id=match.group("project_id"),
|
|
61
|
+
url=url,
|
|
62
|
+
)
|
|
63
|
+
return repo
|
|
64
|
+
|
|
65
|
+
def projectUrl(self) -> str:
|
|
66
|
+
"""Return a browsable HTTPS project URL derived from the remote URL."""
|
|
67
|
+
# Match SSH URLs: user@host:path or host:path
|
|
68
|
+
ssh = re.match(r"(?:[^@]+@)?(?P<host>[^:]+):(?P<path>.+?)(?:\.git)?$", self.url)
|
|
69
|
+
if ssh:
|
|
70
|
+
url = f"https://{ssh.group('host')}/{ssh.group('path')}"
|
|
71
|
+
else:
|
|
72
|
+
url = re.sub(r"\.git$", "", self.url)
|
|
73
|
+
return url
|
|
74
|
+
|
|
75
|
+
def getIssueRecords(self, limit: int = None, **params) -> List[Dict]:
|
|
76
|
+
"""Not implemented for generic repos."""
|
|
77
|
+
raise NotImplementedError(
|
|
78
|
+
f"getIssueRecords is not supported for generic repo '{self.projectUrl()}'"
|
|
79
|
+
)
|
|
@@ -25,6 +25,8 @@ from backoff import expo, on_exception
|
|
|
25
25
|
from basemkit.yamlable import lod_storable
|
|
26
26
|
from ratelimit import RateLimitException, limits
|
|
27
27
|
|
|
28
|
+
from osprojects.git_api import GenericRepo
|
|
29
|
+
|
|
28
30
|
|
|
29
31
|
class GitHubApi:
|
|
30
32
|
"""
|
|
@@ -96,9 +98,8 @@ class GitHubApi:
|
|
|
96
98
|
|
|
97
99
|
if response.status_code == 302 and not allow_redirects:
|
|
98
100
|
# Return the redirect URL if we're not following redirects
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if response.status_code not in [200, 302]:
|
|
101
|
+
result = response.headers["Location"]
|
|
102
|
+
elif response.status_code not in [200, 302]:
|
|
102
103
|
err_msg = (
|
|
103
104
|
f"Failed to {title} for {url}: {response.status_code} - {response.text}"
|
|
104
105
|
)
|
|
@@ -106,8 +107,9 @@ class GitHubApi:
|
|
|
106
107
|
if response.status_code in [403, 429]:
|
|
107
108
|
raise RateLimitException(err_msg, period_remaining=60)
|
|
108
109
|
raise Exception(err_msg)
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
else:
|
|
111
|
+
result = response
|
|
112
|
+
return result
|
|
111
113
|
|
|
112
114
|
def repos_for_owner(self, owner: str, cache_expiry: int = 300) -> list[dict]:
|
|
113
115
|
"""Retrieve all repositories for the given owner, using cache if
|
|
@@ -130,14 +132,14 @@ class GitHubApi:
|
|
|
130
132
|
if cache_content is not None and (
|
|
131
133
|
cache_age is None or cache_age < cache_expiry
|
|
132
134
|
):
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
repos = cache_content
|
|
136
|
+
else:
|
|
137
|
+
# If cache is not available or expired, retrieve from API
|
|
138
|
+
repos = self.repos_for_owner_via_api(owner)
|
|
137
139
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
# Cache the result
|
|
141
|
+
with open(cache_file, "w") as f:
|
|
142
|
+
json.dump(repos, f)
|
|
141
143
|
|
|
142
144
|
return repos
|
|
143
145
|
|
|
@@ -200,28 +202,28 @@ class GitHubApi:
|
|
|
200
202
|
|
|
201
203
|
|
|
202
204
|
@dataclass
|
|
203
|
-
class GitHubRepo:
|
|
205
|
+
class GitHubRepo(GenericRepo):
|
|
204
206
|
"""Represents a GitHub Repository.
|
|
205
207
|
|
|
206
208
|
Attributes:
|
|
207
209
|
owner (str): The owner of the repository.
|
|
208
210
|
project_id (str): The name/id of the repository.
|
|
211
|
+
url (str): The original remote URL.
|
|
209
212
|
"""
|
|
210
213
|
|
|
211
|
-
owner: str
|
|
212
|
-
project_id: str
|
|
213
|
-
|
|
214
214
|
def __post_init__(self):
|
|
215
215
|
self.github = GitHubApi.get_instance()
|
|
216
216
|
|
|
217
217
|
@classmethod
|
|
218
|
-
def from_url(cls, url: str) ->
|
|
219
|
-
"""Resolve project url to owner and project name.
|
|
218
|
+
def from_url(cls, url: str) -> "GitHubRepo":
|
|
219
|
+
"""Resolve GitHub project url to owner and project name.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
url: SSH or HTTPS GitHub remote URL.
|
|
220
223
|
|
|
221
224
|
Returns:
|
|
222
|
-
|
|
225
|
+
GitHubRepo instance or None if the URL cannot be parsed.
|
|
223
226
|
"""
|
|
224
|
-
# https://www.rfc-editor.org/rfc/rfc3986#appendix-B
|
|
225
227
|
pattern = r"((https?:\/\/github\.com\/)|(git@github\.com:))(?P<owner>[^/?#]+)\/(?P<project_id>[^\./?#]+)(\.git)?"
|
|
226
228
|
match = re.match(pattern=pattern, string=url)
|
|
227
229
|
repo = None
|
|
@@ -229,17 +231,13 @@ class GitHubRepo:
|
|
|
229
231
|
owner = match.group("owner")
|
|
230
232
|
project_id = match.group("project_id")
|
|
231
233
|
if owner and project_id:
|
|
232
|
-
repo = cls(owner=owner, project_id=project_id)
|
|
233
|
-
else:
|
|
234
|
-
pass
|
|
235
|
-
else:
|
|
236
|
-
pass
|
|
234
|
+
repo = cls(owner=owner, project_id=project_id, url=url)
|
|
237
235
|
return repo
|
|
238
236
|
|
|
239
237
|
def ticketUrl(self):
|
|
240
238
|
return f"{self.github.api_url}/repos/{self.owner}/{self.project_id}/issues"
|
|
241
239
|
|
|
242
|
-
def projectUrl(self):
|
|
240
|
+
def projectUrl(self) -> str:
|
|
243
241
|
return f"https://github.com/{self.owner}/{self.project_id}"
|
|
244
242
|
|
|
245
243
|
def getIssueRecords(self, limit: int = None, **params) -> List[Dict]:
|
|
@@ -306,7 +304,6 @@ class GitHubFileSet:
|
|
|
306
304
|
|
|
307
305
|
# Deduplication check
|
|
308
306
|
if sha and not sha in self._sha_set:
|
|
309
|
-
|
|
310
307
|
# Extract fields
|
|
311
308
|
repo_info = api_item.get("repository", {})
|
|
312
309
|
|
|
@@ -433,7 +430,10 @@ class GitHubAction:
|
|
|
433
430
|
raise ValueError("Invalid GitHub Actions URL format")
|
|
434
431
|
|
|
435
432
|
try:
|
|
436
|
-
|
|
433
|
+
owner = path_parts[1]
|
|
434
|
+
project_id = path_parts[2]
|
|
435
|
+
repo_url = f"https://github.com/{owner}/{project_id}"
|
|
436
|
+
repo = GitHubRepo(owner=owner, project_id=project_id, url=repo_url)
|
|
437
437
|
return cls(repo=repo, run_id=int(path_parts[5]), job_id=int(path_parts[7]))
|
|
438
438
|
except (IndexError, ValueError) as e:
|
|
439
439
|
raise ValueError(f"Failed to parse GitHub Actions URL: {e}")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Created on 2026-03-16.
|
|
2
|
+
|
|
3
|
+
@author: wf
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from osprojects.git_api import GenericRepo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class GitLabRepo(GenericRepo):
|
|
15
|
+
"""Represents a GitLab repository.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
owner (str): The owner/namespace of the repository.
|
|
19
|
+
project_id (str): The name/id of the repository.
|
|
20
|
+
url (str): The original remote URL.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_url(cls, url: str) -> Optional["GitLabRepo"]:
|
|
25
|
+
"""Parse owner and project_id from a GitLab remote URL.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
url: SSH or HTTPS GitLab remote URL.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
GitLabRepo instance or None if the URL cannot be parsed.
|
|
32
|
+
"""
|
|
33
|
+
pattern = (
|
|
34
|
+
r"(?:https?://[^/]+/|git@[^:]+:)(?P<owner>[^/]+)/(?P<project_id>[^/.]+)"
|
|
35
|
+
)
|
|
36
|
+
match = re.match(pattern, url)
|
|
37
|
+
repo = None
|
|
38
|
+
if match:
|
|
39
|
+
repo = cls(
|
|
40
|
+
owner=match.group("owner"),
|
|
41
|
+
project_id=match.group("project_id"),
|
|
42
|
+
url=url,
|
|
43
|
+
)
|
|
44
|
+
return repo
|
|
@@ -13,10 +13,13 @@ import subprocess
|
|
|
13
13
|
import sys
|
|
14
14
|
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
|
15
15
|
|
|
16
|
+
from basemkit.base_cmd import BaseCmd
|
|
16
17
|
from dateutil.parser import parse
|
|
17
18
|
from tqdm import tqdm
|
|
18
19
|
|
|
20
|
+
from osprojects.git_api import GenericRepo
|
|
19
21
|
from osprojects.github_api import GitHubApi, GitHubRepo
|
|
22
|
+
from osprojects.gitlab_api import GitLabRepo
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
class Ticket(object):
|
|
@@ -327,16 +330,24 @@ class OsProject:
|
|
|
327
330
|
self.repo_info = None # might be fetched
|
|
328
331
|
self.folder = None # set for local projects
|
|
329
332
|
if owner and project_id:
|
|
330
|
-
|
|
333
|
+
url = f"https://github.com/{owner}/{project_id}"
|
|
334
|
+
self.repo = GitHubRepo(owner=owner, project_id=project_id, url=url)
|
|
331
335
|
|
|
332
336
|
@classmethod
|
|
333
337
|
def fromUrl(cls, url: str) -> "OsProject":
|
|
334
|
-
"""Init OsProject from given url.
|
|
338
|
+
"""Init OsProject from given url.
|
|
339
|
+
|
|
340
|
+
Selects the appropriate repo class based on the remote URL host:
|
|
341
|
+
GitHubRepo for github.com, GitLabRepo for gitlab hosts,
|
|
342
|
+
GenericRepo for everything else.
|
|
343
|
+
"""
|
|
344
|
+
os_project = cls()
|
|
335
345
|
if "github.com" in url:
|
|
336
|
-
os_project = cls()
|
|
337
346
|
os_project.repo = GitHubRepo.from_url(url)
|
|
347
|
+
elif "gitlab" in url:
|
|
348
|
+
os_project.repo = GitLabRepo.from_url(url)
|
|
338
349
|
else:
|
|
339
|
-
|
|
350
|
+
os_project.repo = GenericRepo.from_url(url)
|
|
340
351
|
return os_project
|
|
341
352
|
|
|
342
353
|
@classmethod
|
|
@@ -501,22 +512,18 @@ class OsProject:
|
|
|
501
512
|
"--no-pager",
|
|
502
513
|
"log",
|
|
503
514
|
"--reverse",
|
|
504
|
-
r'--pretty=format:{"name":"%cn","date":"%cI","hash":"%h"}',
|
|
515
|
+
r'--pretty=format:{"name":"%cn","date":"%cI","hash":"%h","subject":"%s"}',
|
|
505
516
|
]
|
|
506
|
-
gitLogCommitSubject = ["git", "log", "--format=%s", "-n", "1"]
|
|
507
517
|
rawCommitLogs = subprocess.check_output(gitlogCmd).decode()
|
|
508
518
|
for rawLog in rawCommitLogs.split("\n"):
|
|
519
|
+
if not rawLog.strip():
|
|
520
|
+
continue
|
|
509
521
|
log = json.loads(rawLog)
|
|
510
522
|
if log.get("date", None) is not None:
|
|
511
523
|
log["date"] = datetime.datetime.fromisoformat(log["date"])
|
|
512
524
|
log["project"] = self.project_id
|
|
513
525
|
log["host"] = self.projectUrl()
|
|
514
526
|
log["path"] = ""
|
|
515
|
-
log["subject"] = subprocess.check_output(
|
|
516
|
-
[*gitLogCommitSubject, log["hash"]]
|
|
517
|
-
)[
|
|
518
|
-
:-1
|
|
519
|
-
].decode() # seperate query to avoid json escaping issues
|
|
520
527
|
commit = Commit()
|
|
521
528
|
for k, v in log.items():
|
|
522
529
|
setattr(commit, k, v)
|
|
@@ -524,15 +531,73 @@ class OsProject:
|
|
|
524
531
|
return commits
|
|
525
532
|
|
|
526
533
|
|
|
534
|
+
class GitLog2WikiCmd(BaseCmd):
|
|
535
|
+
"""Command line interface for gitlog2wiki."""
|
|
536
|
+
|
|
537
|
+
def add_arguments(self, parser):
|
|
538
|
+
"""Add gitlog2wiki-specific arguments to the parser.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
parser: The argument parser to extend.
|
|
542
|
+
"""
|
|
543
|
+
super().add_arguments(parser)
|
|
544
|
+
parser.add_argument(
|
|
545
|
+
"--filter",
|
|
546
|
+
help="Filter commits by date prefix, e.g. 2026, 2026-03, 2026-03-28",
|
|
547
|
+
default=None,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def handle_args(self, args):
|
|
551
|
+
"""Handle parsed arguments and run the command.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
args: Parsed argument namespace.
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
bool: True if handled.
|
|
558
|
+
"""
|
|
559
|
+
handled = super().handle_args(args)
|
|
560
|
+
result = False
|
|
561
|
+
if handled:
|
|
562
|
+
result = True
|
|
563
|
+
else:
|
|
564
|
+
osProject = OsProject.fromRepo()
|
|
565
|
+
if osProject.repo is None:
|
|
566
|
+
try:
|
|
567
|
+
url = subprocess.check_output(
|
|
568
|
+
["git", "config", "--get", "remote.origin.url"]
|
|
569
|
+
)
|
|
570
|
+
url = url.decode().strip("\n")
|
|
571
|
+
print(
|
|
572
|
+
f"Error: Could not parse git remote URL: {url}",
|
|
573
|
+
file=sys.stderr,
|
|
574
|
+
)
|
|
575
|
+
except subprocess.CalledProcessError:
|
|
576
|
+
print(
|
|
577
|
+
"Error: Not in a git repository or no remote.origin.url configured",
|
|
578
|
+
file=sys.stderr,
|
|
579
|
+
)
|
|
580
|
+
result = False
|
|
581
|
+
else:
|
|
582
|
+
commits = osProject.getCommits()
|
|
583
|
+
if args.filter:
|
|
584
|
+
date_filter = args.filter
|
|
585
|
+
commits = [
|
|
586
|
+
c for c in commits if str(c.date.date()).startswith(date_filter)
|
|
587
|
+
]
|
|
588
|
+
print("\n".join([c.toWikiMarkup() for c in commits]))
|
|
589
|
+
result = True
|
|
590
|
+
return result
|
|
591
|
+
|
|
592
|
+
|
|
527
593
|
def gitlog2wiki(_argv=None):
|
|
528
594
|
"""Cmdline interface to get gitlog entries in wiki markup."""
|
|
529
|
-
|
|
530
|
-
if _argv:
|
|
531
|
-
_args = parser.parse_args(args=_argv)
|
|
595
|
+
from osprojects.version import Version
|
|
532
596
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
597
|
+
argv = sys.argv[1:] if _argv is None else _argv
|
|
598
|
+
cmd = GitLog2WikiCmd(Version)
|
|
599
|
+
result = cmd.run(argv)
|
|
600
|
+
return result
|
|
536
601
|
|
|
537
602
|
|
|
538
603
|
def main(_argv=None):
|
|
@@ -12,5 +12,6 @@ class Version(object):
|
|
|
12
12
|
name = "pyOpenSourceProjects"
|
|
13
13
|
version = osprojects.__version__
|
|
14
14
|
date = "2024-07-31"
|
|
15
|
-
updated = "2026-03-
|
|
15
|
+
updated = "2026-03-28"
|
|
16
16
|
description = "Check python OpenSource Projects to follow standards for README, github workflow and pyproject.toml - creates badges"
|
|
17
|
+
doc_url = "http://wiki.bitplan.com/index.php/pyOpenSourceProjects"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Created on 2026-03-28.
|
|
2
|
+
|
|
3
|
+
@author: wf
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import datetime
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
from osprojects.osproject import Commit, GitLog2WikiCmd, gitlog2wiki
|
|
10
|
+
from osprojects.version import Version
|
|
11
|
+
from tests.basetest import BaseTest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestGitLog2WikiFilter(BaseTest):
|
|
15
|
+
"""Test the --filter option of gitlog2wiki."""
|
|
16
|
+
|
|
17
|
+
def _make_commit(self, date_iso: str) -> Commit:
|
|
18
|
+
"""Create a Commit with the given ISO date string.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
date_iso: ISO 8601 date string, e.g. '2026-03-28T10:00:00+00:00'.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A Commit instance with date and hash set.
|
|
25
|
+
"""
|
|
26
|
+
commit = Commit()
|
|
27
|
+
commit.date = datetime.datetime.fromisoformat(date_iso)
|
|
28
|
+
commit.hash = "abc1234"
|
|
29
|
+
commit.subject = "Test commit"
|
|
30
|
+
commit.name = "Tester"
|
|
31
|
+
commit.project = "testproject"
|
|
32
|
+
commit.host = "https://github.com/test/testproject"
|
|
33
|
+
commit.path = ""
|
|
34
|
+
return commit
|
|
35
|
+
|
|
36
|
+
def setUp(self, debug=False, profile=True):
|
|
37
|
+
"""Set up sample commits spanning multiple years/months/days."""
|
|
38
|
+
super().setUp(debug=debug, profile=profile)
|
|
39
|
+
self.commits = [
|
|
40
|
+
self._make_commit("2024-12-01T08:00:00+00:00"),
|
|
41
|
+
self._make_commit("2025-06-15T12:00:00+00:00"),
|
|
42
|
+
self._make_commit("2026-03-01T09:00:00+00:00"),
|
|
43
|
+
self._make_commit("2026-03-28T14:30:00+00:00"),
|
|
44
|
+
self._make_commit("2026-11-05T17:00:00+00:00"),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
def _apply_filter(self, date_filter: str):
|
|
48
|
+
"""Apply the same filter logic as GitLog2WikiCmd.handle_args.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
date_filter: Date prefix string, e.g. '2026', '2026-03', '2026-03-28'.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Filtered list of Commit objects.
|
|
55
|
+
"""
|
|
56
|
+
result = [c for c in self.commits if str(c.date.date()).startswith(date_filter)]
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
def test_filter_by_year(self):
|
|
60
|
+
"""Filter by year should return all commits in that year."""
|
|
61
|
+
filtered = self._apply_filter("2026")
|
|
62
|
+
dates = [str(c.date.date()) for c in filtered]
|
|
63
|
+
self.assertEqual(3, len(filtered))
|
|
64
|
+
self.assertIn("2026-03-01", dates)
|
|
65
|
+
self.assertIn("2026-03-28", dates)
|
|
66
|
+
self.assertIn("2026-11-05", dates)
|
|
67
|
+
|
|
68
|
+
def test_filter_by_month(self):
|
|
69
|
+
"""Filter by year-month should return only commits in that month."""
|
|
70
|
+
filtered = self._apply_filter("2026-03")
|
|
71
|
+
dates = [str(c.date.date()) for c in filtered]
|
|
72
|
+
self.assertEqual(2, len(filtered))
|
|
73
|
+
self.assertIn("2026-03-01", dates)
|
|
74
|
+
self.assertIn("2026-03-28", dates)
|
|
75
|
+
|
|
76
|
+
def test_filter_by_day(self):
|
|
77
|
+
"""Filter by exact day should return only commits on that day."""
|
|
78
|
+
filtered = self._apply_filter("2026-03-28")
|
|
79
|
+
self.assertEqual(1, len(filtered))
|
|
80
|
+
self.assertEqual("2026-03-28", str(filtered[0].date.date()))
|
|
81
|
+
|
|
82
|
+
def test_filter_no_match(self):
|
|
83
|
+
"""Filter with no matching commits should return an empty list."""
|
|
84
|
+
filtered = self._apply_filter("2023")
|
|
85
|
+
self.assertEqual(0, len(filtered))
|
|
86
|
+
|
|
87
|
+
def test_no_filter_returns_all(self):
|
|
88
|
+
"""Without a filter all commits are returned."""
|
|
89
|
+
filtered = self.commits # no filter applied
|
|
90
|
+
self.assertEqual(5, len(filtered))
|
|
91
|
+
|
|
92
|
+
def test_gitlog2wiki_filter_cmdline(self):
|
|
93
|
+
"""Test gitlog2wiki CLI with --filter passes through and filters
|
|
94
|
+
output."""
|
|
95
|
+
if self.inPublicCI():
|
|
96
|
+
return
|
|
97
|
+
output = self.captureOutput(gitlog2wiki, ["--filter", "2022"])
|
|
98
|
+
lines = [line for line in output.strip().split("\n") if line]
|
|
99
|
+
# The very first commit (hash 106254f) is from 2022-01-24
|
|
100
|
+
self.assertTrue(len(lines) >= 1)
|
|
101
|
+
for line in lines:
|
|
102
|
+
self.assertIn("2022", line)
|
|
103
|
+
|
|
104
|
+
def test_gitlog2wiki_no_filter_cmdline(self):
|
|
105
|
+
"""Test gitlog2wiki CLI without --filter returns all commits."""
|
|
106
|
+
if self.inPublicCI():
|
|
107
|
+
return
|
|
108
|
+
output_all = self.captureOutput(gitlog2wiki, [])
|
|
109
|
+
output_filtered = self.captureOutput(gitlog2wiki, ["--filter", "2022"])
|
|
110
|
+
lines_all = [l for l in output_all.strip().split("\n") if l]
|
|
111
|
+
lines_filtered = [l for l in output_filtered.strip().split("\n") if l]
|
|
112
|
+
self.assertGreater(len(lines_all), len(lines_filtered))
|
|
113
|
+
|
|
114
|
+
def test_gitlog2wiki_cmd_class(self):
|
|
115
|
+
"""Test GitLog2WikiCmd can be instantiated with Version."""
|
|
116
|
+
cmd = GitLog2WikiCmd(Version)
|
|
117
|
+
self.assertIsNotNone(cmd)
|
|
118
|
+
self.assertIsInstance(cmd, GitLog2WikiCmd)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
unittest.main()
|
|
@@ -76,7 +76,7 @@ class TestOsProject(BaseTest):
|
|
|
76
76
|
return
|
|
77
77
|
commit = self.getSampleById(Commit, "hash", "106254f")
|
|
78
78
|
expectedCommitMarkup = commit.toWikiMarkup()
|
|
79
|
-
output = self.captureOutput(gitlog2wiki)
|
|
79
|
+
output = self.captureOutput(gitlog2wiki, [])
|
|
80
80
|
outputLines = output.split("\n")
|
|
81
81
|
self.assertTrue(expectedCommitMarkup in outputLines)
|
|
82
82
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.1"
|
|
File without changes
|
{pyopensourceprojects-0.5.1 → pyopensourceprojects-0.6.0}/.github/workflows/upload-to-pypi.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|