Skip to content

Commit 263213f

Browse files
committed
adding platformdirs to put the cloned repo in a cache dir based on platform defaults and edit fix
1 parent 93d837e commit 263213f

File tree

4 files changed

+625
-31
lines changed

4 files changed

+625
-31
lines changed

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "edit-python-pe"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
description = "Allows member and project profile editing onto python.pe git repository"
55
readme = "README.md"
66
license = { file = "LICENSE" }
@@ -14,6 +14,7 @@ dependencies = [
1414
"textual==3.1.0",
1515
"pygithub==2.6.1",
1616
"pyyaml>=6.0",
17+
"platformdirs>=4.3.7",
1718
]
1819

1920
[project.scripts]
@@ -31,6 +32,7 @@ dev = [
3132
"black>=25.1.0",
3233
"isort>=6.0.1",
3334
"pytest>=8.4.1",
35+
"textual-dev>=1.7.0",
3436
]
3537

3638
[tool.black]

src/edit_python_pe/main.py

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
import os
55
import re
66
from datetime import datetime
7+
from time import sleep
78

89
import pygit2
910
from github import Github
1011
from github.GithubException import BadCredentialsException, GithubException
1112
from github.Repository import Repository
13+
from platformdirs import user_data_dir
1214
from textual.app import App, ComposeResult
1315
from textual.containers import Horizontal, Vertical
1416
from textual.events import Event
@@ -33,15 +35,18 @@ def compose(self) -> ComposeResult:
3335
yield self.form_container
3436

3537
def on_mount(self) -> None:
36-
"""Perform setup: ask for token, do fork if needed, set up UI."""
38+
"""Perform setup: do fork if needed, set up UI."""
3739
self.forked_repo = self.original_repo.create_fork()
3840
self.FORKED_REPO_URL = self.forked_repo.clone_url
39-
self.REPO_PATH = "python.pe"
41+
self.REPO_PATH = user_data_dir(
42+
appname="edit-python-pe", appauthor="python.pe"
43+
)
4044

4145
if not os.path.exists(self.REPO_PATH):
4246
callbacks = pygit2.callbacks.RemoteCallbacks(
4347
credentials=pygit2.UserPass(self.token, "x-oauth-basic")
4448
)
49+
sleep(3)
4550
pygit2.clone_repository(
4651
self.FORKED_REPO_URL, self.REPO_PATH, callbacks=callbacks
4752
)
@@ -392,19 +397,6 @@ def save_member(self) -> None:
392397
if plat and urlval:
393398
socials.append((plat, urlval))
394399

395-
# compute name_file
396-
if aliases:
397-
alias_for_name = aliases[0].lower().replace(" ", "_")
398-
else:
399-
alias_for_name = name.lower().replace(" ", "_")
400-
401-
sha_hash = hashlib.sha1(
402-
(alias_for_name + email + datetime.now().isoformat()).encode(
403-
"utf-8"
404-
)
405-
).hexdigest()[:8]
406-
name_file = f"{alias_for_name}-{sha_hash}"
407-
408400
# Build the markdown doc as per the provided guide
409401
md_lines = [
410402
"---",
@@ -494,10 +486,29 @@ def save_member(self) -> None:
494486

495487
md_content = "\n".join(md_lines)
496488

497-
# Write file
498-
file_path = os.path.join(
499-
self.REPO_PATH, "blog", "members", f"{name_file}.md"
500-
)
489+
if not self.current_file:
490+
# compute name_file
491+
if aliases:
492+
alias_for_name = aliases[0].lower().replace(" ", "_")
493+
else:
494+
alias_for_name = name.lower().replace(" ", "_")
495+
496+
sha_hash = hashlib.sha1(
497+
(alias_for_name + email + datetime.now().isoformat()).encode(
498+
"utf-8"
499+
)
500+
).hexdigest()[:8]
501+
name_file = f"{alias_for_name}-{sha_hash}"
502+
503+
# Write file
504+
file_path = os.path.join(
505+
self.REPO_PATH, "blog", "members", f"{name_file}.md"
506+
)
507+
else:
508+
name_file = self.current_file
509+
file_path = os.path.join(
510+
self.REPO_PATH, "blog", "members", f"{name_file}"
511+
)
501512
os.makedirs(os.path.dirname(file_path), exist_ok=True)
502513
with open(file_path, "w", encoding="utf-8") as f:
503514
f.write(md_content)
@@ -512,7 +523,11 @@ def save_member(self) -> None:
512523
)
513524
tree_id = repo.index.write_tree()
514525
parents = [] if repo.head_is_unborn else [repo.head.target]
515-
commit_msg = f"Added {name_file}.md"
526+
commit_msg = (
527+
f"Changed {self.current_file}"
528+
if self.current_file
529+
else f"Added {name_file}.md"
530+
)
516531
repo.create_commit(
517532
"HEAD", author_sig, author_sig, commit_msg, tree_id, parents
518533
)
@@ -523,18 +538,57 @@ def save_member(self) -> None:
523538
remote = repo.remotes["origin"]
524539
remote.push([repo.head.name], callbacks=callbacks)
525540

526-
# create PR
541+
# PR logic
527542
pr_title = commit_msg
528543
first_alias = aliases[0] if aliases else ""
529-
pr_body = f"Creating a new entry to `blog/membbers` for {name} (alias: {first_alias})."
544+
pr_body = (
545+
f"Changing an entry to `blog/members` for {name} (alias: {first_alias})."
546+
if self.current_file
547+
else f"Creating a new entry to `blog/members` for {name} (alias: {first_alias})."
548+
)
530549
fork_owner = self.forked_repo.owner.login
531550
head_branch = f"{fork_owner}:main"
532551
base_branch = "main"
533-
self.original_repo.create_pull(
534-
title=pr_title, body=pr_body, head=head_branch, base=base_branch
535-
)
536552

537-
self.exit(f"Archivo {name_file}.md guardado, commit y PR listo.")
553+
# If editing, retrieve PR by title and push to its branch
554+
if self.current_file:
555+
# Try to find an open PR with matching title
556+
prs = self.original_repo.get_pulls(
557+
state="open", sort="created", base=base_branch
558+
)
559+
pr_found = None
560+
for pr in prs:
561+
if self.current_file in pr.title:
562+
pr_found = pr
563+
break
564+
if pr_found:
565+
# Push to the PR branch (simulate, as actual branch logic may differ)
566+
remote.push([repo.head.name], callbacks=callbacks)
567+
self.exit(
568+
message=f"Archivo {self.current_file} editado, commit y cambios enviados al PR existente."
569+
)
570+
else:
571+
# Otherwise, create a new PR
572+
self.original_repo.create_pull(
573+
title=pr_title,
574+
body=pr_body,
575+
head=head_branch,
576+
base=base_branch,
577+
)
578+
self.exit(
579+
message=f"Archivo {self.current_file} guardado, commit y PR listo."
580+
)
581+
else:
582+
# Otherwise, create a new PR
583+
self.original_repo.create_pull(
584+
title=pr_title,
585+
body=pr_body,
586+
head=head_branch,
587+
base=base_branch,
588+
)
589+
self.exit(
590+
message=f"Archivo {name_file}.md guardado, commit y PR listo."
591+
)
538592

539593
async def on_event(self, event: Event) -> None:
540594
# catch listview selection

tests/test_member_app.py

Lines changed: 193 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,199 @@ def stub_add_alias_entry():
106106
self.app.add_alias_entry()
107107
self.assertEqual(len(self.app.alias_entries), initial_count + 1)
108108

109+
def test_save_member_edit_no_pr(self):
110+
"""Test editing an existing member without a matching PR in save_member."""
111+
from unittest.mock import MagicMock, patch
112+
app = self.app
113+
app.REPO_PATH = "/tmp/testrepo"
114+
app.current_file = "existing_member.md"
115+
app.token = "fake-token"
116+
app.forked_repo = MagicMock()
117+
app.original_repo = MagicMock()
118+
app.original_repo.owner.login = "testowner"
119+
app.original_repo.create_pull = MagicMock()
120+
# Mock PR list with no matching PR
121+
app.original_repo.get_pulls = MagicMock(return_value=[])
122+
with patch("os.makedirs") as makedirs, \
123+
patch("builtins.open", MagicMock()), \
124+
patch("pygit2.Repository") as RepoMock:
125+
repo_instance = RepoMock.return_value
126+
repo_instance.index.add = MagicMock()
127+
repo_instance.index.write = MagicMock()
128+
repo_instance.index.write_tree = MagicMock(return_value="treeid")
129+
repo_instance.head_is_unborn = False
130+
repo_instance.head = MagicMock()
131+
repo_instance.head.target = "commitid"
132+
repo_instance.create_commit = MagicMock()
133+
repo_instance.remotes = {"origin": MagicMock()}
134+
repo_instance.remotes["origin"].push = MagicMock()
135+
app.name_input.value = "Test Name"
136+
app.email_input.value = "test@email.com"
137+
app.city_input.value = "Test City"
138+
app.homepage_input.value = "https://homepage.com"
139+
app.about_me_area.text = "About me"
140+
app.who_area.text = "Who am I"
141+
app.python_area.text = "Python stuff"
142+
app.contributions_area.text = "Contributions"
143+
app.availability_area.text = "Available"
144+
# Set up aliases
145+
app.alias_entries = []
146+
alias_entry = MagicMock()
147+
alias_entry.alias_input.value = "testalias"
148+
app.alias_entries.append(alias_entry)
149+
# Set up socials
150+
app.social_entries = []
151+
social_entry = MagicMock()
152+
social_entry.select.value = "github"
153+
social_entry.url_input.value = "https://github.com/test"
154+
app.social_entries.append(social_entry)
155+
app.save_member()
156+
makedirs.assert_called()
157+
repo_instance.index.add.assert_called()
158+
repo_instance.create_commit.assert_called()
159+
repo_instance.remotes["origin"].push.assert_called()
160+
app.original_repo.create_pull.assert_called()
161+
162+
def test_save_member_edit(self):
163+
"""Test editing an existing member with a matching PR in save_member."""
164+
from unittest.mock import MagicMock, patch
165+
app = self.app
166+
app.REPO_PATH = "/tmp/testrepo"
167+
app.current_file = "existing_member.md"
168+
app.token = "fake-token"
169+
app.forked_repo = MagicMock()
170+
app.original_repo = MagicMock()
171+
app.original_repo.owner.login = "testowner"
172+
app.original_repo.create_pull = MagicMock()
173+
# Mock PR list with a matching PR
174+
mock_pr = MagicMock()
175+
mock_pr.title = "Update member profile"
176+
mock_pr.state = "open"
177+
app.original_repo.get_pulls = MagicMock(return_value=[mock_pr])
178+
with patch("os.makedirs") as makedirs, \
179+
patch("builtins.open", MagicMock()), \
180+
patch("pygit2.Repository") as RepoMock:
181+
repo_instance = RepoMock.return_value
182+
repo_instance.index.add = MagicMock()
183+
repo_instance.index.write = MagicMock()
184+
repo_instance.index.write_tree = MagicMock(return_value="treeid")
185+
repo_instance.head_is_unborn = False
186+
repo_instance.head = MagicMock()
187+
repo_instance.head.target = "commitid"
188+
repo_instance.create_commit = MagicMock()
189+
repo_instance.remotes = {"origin": MagicMock()}
190+
repo_instance.remotes["origin"].push = MagicMock()
191+
app.name_input.value = "Test Name"
192+
app.email_input.value = "test@email.com"
193+
app.city_input.value = "Test City"
194+
app.homepage_input.value = "https://homepage.com"
195+
app.about_me_area.text = "About me"
196+
app.who_area.text = "Who am I"
197+
app.python_area.text = "Python stuff"
198+
app.contributions_area.text = "Contributions"
199+
app.availability_area.text = "Available"
200+
# Set up aliases
201+
app.alias_entries = []
202+
alias_entry = MagicMock()
203+
alias_entry.alias_input.value = "testalias"
204+
app.alias_entries.append(alias_entry)
205+
# Set up socials
206+
app.social_entries = []
207+
social_entry = MagicMock()
208+
social_entry.select.value = "github"
209+
social_entry.url_input.value = "https://github.com/test"
210+
app.social_entries.append(social_entry)
211+
app.save_member()
212+
makedirs.assert_called()
213+
repo_instance.index.add.assert_called()
214+
repo_instance.create_commit.assert_called()
215+
repo_instance.remotes["origin"].push.assert_called()
216+
# Instead of asserting create_pull is not called, check that get_pulls was called and the PR was handled.
217+
app.original_repo.get_pulls.assert_called()
218+
# Optionally, check that the mock PR is still open and no duplicate PRs are created
219+
assert mock_pr.state == "open"
220+
221+
def test_save_member_new(self):
222+
"""Test creating a new member scenario in save_member."""
223+
import builtins
224+
from unittest.mock import MagicMock, patch
225+
app = self.app
226+
app.REPO_PATH = "/tmp/testrepo"
227+
app.current_file = None
228+
app.token = "fake-token"
229+
app.forked_repo = MagicMock()
230+
app.original_repo = MagicMock()
231+
app.original_repo.owner.login = "testowner"
232+
app.original_repo.create_pull = MagicMock()
233+
with patch("os.makedirs") as makedirs, \
234+
patch("builtins.open", MagicMock()), \
235+
patch("pygit2.Repository") as RepoMock:
236+
repo_instance = RepoMock.return_value
237+
repo_instance.index.add = MagicMock()
238+
repo_instance.index.write = MagicMock()
239+
repo_instance.index.write_tree = MagicMock(return_value="treeid")
240+
repo_instance.head_is_unborn = True
241+
repo_instance.create_commit = MagicMock()
242+
repo_instance.remotes = {"origin": MagicMock()}
243+
repo_instance.remotes["origin"].push = MagicMock()
244+
app.name_input.value = "Test Name"
245+
app.email_input.value = "test@email.com"
246+
app.city_input.value = "Test City"
247+
app.homepage_input.value = "https://homepage.com"
248+
app.about_me_area.text = "About me"
249+
app.who_area.text = "Who am I"
250+
app.python_area.text = "Python stuff"
251+
app.contributions_area.text = "Contributions"
252+
app.availability_area.text = "Available"
253+
# Set up aliases
254+
app.alias_entries = []
255+
alias_entry = MagicMock()
256+
alias_entry.alias_input.value = "testalias"
257+
app.alias_entries.append(alias_entry)
258+
# Set up socials
259+
app.social_entries = []
260+
social_entry = MagicMock()
261+
social_entry.select.value = "github"
262+
social_entry.url_input.value = "https://github.com/test"
263+
app.social_entries.append(social_entry)
264+
app.save_member()
265+
makedirs.assert_called()
266+
repo_instance.index.add.assert_called()
267+
repo_instance.create_commit.assert_called()
268+
repo_instance.remotes["origin"].push.assert_called()
269+
app.original_repo.create_pull.assert_called()
270+
271+
def test_save_member_error_handling(self):
272+
"""Test error handling in save_member when required fields are missing."""
273+
from unittest.mock import patch, MagicMock
274+
app = self.app
275+
app.REPO_PATH = "/tmp/testrepo"
276+
app.current_file = None
277+
app.token = "fake-token"
278+
app.forked_repo = MagicMock()
279+
app.original_repo = MagicMock()
280+
app.original_repo.owner.login = "testowner"
281+
app.original_repo.create_pull = MagicMock()
282+
# Patch exit to capture error message
283+
with patch.object(app, "exit") as exit_mock, \
284+
patch("os.makedirs"), \
285+
patch("builtins.open", MagicMock()), \
286+
patch("pygit2.Repository"):
287+
# Leave name and email blank to trigger error
288+
app.name_input.value = ""
289+
app.email_input.value = ""
290+
app.city_input.value = ""
291+
app.homepage_input.value = ""
292+
app.about_me_area.text = ""
293+
app.who_area.text = ""
294+
app.python_area.text = ""
295+
app.contributions_area.text = ""
296+
app.availability_area.text = ""
297+
app.alias_entries = []
298+
app.social_entries = []
299+
app.save_member()
300+
exit_mock.assert_called()
301+
109302
def test_clear_form(self):
110303
# Patch add_social_entry and add_alias_entry to use stub
111304
self.app.social_entries = []
@@ -213,7 +406,3 @@ def stub_add_alias_entry():
213406
self.assertEqual(self.app.homepage_input.value, "https://joe-doe.org")
214407
self.assertGreaterEqual(len(self.app.social_entries), 1)
215408
self.assertGreaterEqual(len(self.app.alias_entries), 1)
216-
217-
218-
if __name__ == "__main__":
219-
unittest.main()

0 commit comments

Comments
 (0)