pygitweb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pygitweb/__init__.py +22 -0
- pygitweb/__meta__.py +9 -0
- pygitweb/actions.py +1303 -0
- pygitweb/addproject_permissions_test.py +114 -0
- pygitweb/api/__init__.py +0 -0
- pygitweb/api/action.py +20 -0
- pygitweb/api/plugins/__init__.py +0 -0
- pygitweb/api/plugins/project_zip.py +71 -0
- pygitweb/api/project.py +67 -0
- pygitweb/api/subpage.py +22 -0
- pygitweb/auth.py +387 -0
- pygitweb/auth.schema.json +106 -0
- pygitweb/auth_config.py +243 -0
- pygitweb/auth_oauth.py +343 -0
- pygitweb/auth_test.py +390 -0
- pygitweb/boards_permissions_test.py +113 -0
- pygitweb/change_queue.py +110 -0
- pygitweb/change_queue_routes_test.py +146 -0
- pygitweb/change_queue_test.py +91 -0
- pygitweb/comments_permissions_test.py +140 -0
- pygitweb/config.py +185 -0
- pygitweb/conftest.py +43 -0
- pygitweb/dependencies.py +87 -0
- pygitweb/formatting.py +182 -0
- pygitweb/formatting_test.py +68 -0
- pygitweb/git_helpers.py +454 -0
- pygitweb/git_helpers_test.py +223 -0
- pygitweb/gravatar.py +16 -0
- pygitweb/gravatar_test.py +10 -0
- pygitweb/hooks_install.py +278 -0
- pygitweb/hooks_install_test.py +363 -0
- pygitweb/hooks_permissions_test.py +116 -0
- pygitweb/main.py +887 -0
- pygitweb/merge_requests.py +230 -0
- pygitweb/merge_requests_test.py +85 -0
- pygitweb/mr_permissions_test.py +148 -0
- pygitweb/password_hash.py +74 -0
- pygitweb/password_hash_test.py +20 -0
- pygitweb/permissions.py +209 -0
- pygitweb/permissions_test.py +86 -0
- pygitweb/plugin_loader.py +83 -0
- pygitweb/projects.py +213 -0
- pygitweb/projects_test.py +186 -0
- pygitweb/sessions.py +73 -0
- pygitweb/settings.py +412 -0
- pygitweb/settings.schema.json +73 -0
- pygitweb/settings_config.py +122 -0
- pygitweb/settings_config_test.py +101 -0
- pygitweb/settings_permissions_test.py +76 -0
- pygitweb/static/blob-view.js +133 -0
- pygitweb/static/board.js +839 -0
- pygitweb/static/hook-install.js +53 -0
- pygitweb/static/icons/cloud-down.svg +1 -0
- pygitweb/static/icons/cloud-exclamation.svg +1 -0
- pygitweb/static/icons/cloud-filled.svg +1 -0
- pygitweb/static/icons/cloud-storm.svg +1 -0
- pygitweb/static/icons/cloud-up.svg +1 -0
- pygitweb/static/icons/cloud.svg +1 -0
- pygitweb/static/logo.svg +44 -0
- pygitweb/static/main.css +1210 -0
- pygitweb/static/navbar-auth.js +82 -0
- pygitweb/static/readme-render.js +174 -0
- pygitweb/static/search-page.js +139 -0
- pygitweb/static/summary-ref-switcher.js +130 -0
- pygitweb/static/themes/solarized.css +34 -0
- pygitweb/tasks.py +595 -0
- pygitweb/tasks_permissions_test.py +137 -0
- pygitweb/tasks_routes_test.py +430 -0
- pygitweb/templates/addproject.html +174 -0
- pygitweb/templates/blobdiff.html +57 -0
- pygitweb/templates/board.html +31 -0
- pygitweb/templates/board_card.html +44 -0
- pygitweb/templates/commitdiff.html +57 -0
- pygitweb/templates/hooks_summary_cell.html +37 -0
- pygitweb/templates/login.html +37 -0
- pygitweb/templates/merge_request_summary_cell.html +18 -0
- pygitweb/templates/merge_request_tag.html +90 -0
- pygitweb/templates/pagination.html +44 -0
- pygitweb/templates/preamble.html +89 -0
- pygitweb/templates/project_settings.html +13 -0
- pygitweb/templates/pygit2_settings.html +12 -0
- pygitweb/templates/pygitweb_settings.html +12 -0
- pygitweb/templates/search.html +77 -0
- pygitweb/templates/settings_form_field.html +27 -0
- pygitweb/templates/table.html +15 -0
- pygitweb/templates/user_profile.html +10 -0
- pygitweb/templates_env.py +21 -0
- pygitweb/timeline_cache.py +243 -0
- pygitweb/validation.py +75 -0
- pygitweb/validation_test.py +167 -0
- pygitweb-0.1.0.dist-info/METADATA +165 -0
- pygitweb-0.1.0.dist-info/RECORD +95 -0
- pygitweb-0.1.0.dist-info/WHEEL +5 -0
- pygitweb-0.1.0.dist-info/licenses/LICENSE +21 -0
- pygitweb-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pygit2
|
|
10
|
+
import pytest
|
|
11
|
+
from httpx import ASGITransport
|
|
12
|
+
|
|
13
|
+
from pygitweb.change_queue import CHANGE_QUEUE
|
|
14
|
+
from pygitweb.config import settings
|
|
15
|
+
from pygitweb.main import app
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
|
|
19
|
+
(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
|
|
20
|
+
index = repo.index
|
|
21
|
+
index.add("README.md")
|
|
22
|
+
index.write()
|
|
23
|
+
tree = index.write_tree()
|
|
24
|
+
sig = pygit2.Signature("tester", "tester@example.com")
|
|
25
|
+
repo.create_commit("HEAD", sig, sig, "initial", tree, [])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(scope="module")
|
|
29
|
+
def updates_env(tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
|
|
30
|
+
root: Path = tmp_path_factory.mktemp("updates-routes")
|
|
31
|
+
alpha_dir: Path = root / "alpha"
|
|
32
|
+
alpha_dir.mkdir()
|
|
33
|
+
_create_initial_commit(pygit2.init_repository(str(alpha_dir), bare=False), alpha_dir)
|
|
34
|
+
beta_dir: Path = root / "group" / "beta"
|
|
35
|
+
beta_dir.mkdir(parents=True)
|
|
36
|
+
_create_initial_commit(pygit2.init_repository(str(beta_dir), bare=False), beta_dir)
|
|
37
|
+
with (
|
|
38
|
+
patch.object(settings, "PROJECTROOT", str(root)),
|
|
39
|
+
patch.object(settings, "PROJECTS_LIST", str(root)),
|
|
40
|
+
patch.object(settings, "PROJECT_MAXDEPTH", 3),
|
|
41
|
+
patch.object(settings, "STRICT_EXPORT", False),
|
|
42
|
+
patch.object(settings, "EXPORT_OK", ""),
|
|
43
|
+
patch.object(settings, "LIST_ALL", True),
|
|
44
|
+
patch.object(settings, "AUTH", False),
|
|
45
|
+
patch.object(settings, "MAXLOAD", None),
|
|
46
|
+
):
|
|
47
|
+
yield {"root": str(root), "alpha": "alpha", "beta": "group/beta"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _client() -> httpx.AsyncClient:
|
|
51
|
+
return httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestUpdatesLongPoll:
|
|
55
|
+
def test_updates_returns_200_empty_after_timeout(self, updates_env: dict[str, str]) -> None:
|
|
56
|
+
async def scenario() -> tuple[int, bytes]:
|
|
57
|
+
async with _client() as client:
|
|
58
|
+
with patch.object(CHANGE_QUEUE, "DEFAULT_TIMEOUT_SECONDS", 0.05):
|
|
59
|
+
resp = await client.get(
|
|
60
|
+
f"/project/{updates_env['alpha']}",
|
|
61
|
+
params={"a": "log", "updates": "true"},
|
|
62
|
+
)
|
|
63
|
+
return resp.status_code, resp.content
|
|
64
|
+
|
|
65
|
+
status, body = asyncio.run(scenario())
|
|
66
|
+
assert status == 200
|
|
67
|
+
assert body == b""
|
|
68
|
+
|
|
69
|
+
def test_updates_returns_200_empty_when_shutting_down(self, updates_env: dict[str, str]) -> None:
|
|
70
|
+
async def scenario() -> tuple[int, bytes]:
|
|
71
|
+
async with _client() as client:
|
|
72
|
+
app.state.shutting_down = True
|
|
73
|
+
try:
|
|
74
|
+
resp = await client.get(
|
|
75
|
+
f"/project/{updates_env['alpha']}",
|
|
76
|
+
params={"a": "log", "updates": "true"},
|
|
77
|
+
)
|
|
78
|
+
return resp.status_code, resp.content
|
|
79
|
+
finally:
|
|
80
|
+
app.state.shutting_down = False
|
|
81
|
+
|
|
82
|
+
status, body = asyncio.run(scenario())
|
|
83
|
+
assert status == 200
|
|
84
|
+
assert body == b""
|
|
85
|
+
|
|
86
|
+
def test_updates_returns_data_after_notify(self, updates_env: dict[str, str]) -> None:
|
|
87
|
+
async def scenario() -> tuple[int, str]:
|
|
88
|
+
async with _client() as client:
|
|
89
|
+
poll: asyncio.Task[httpx.Response] = asyncio.create_task(
|
|
90
|
+
client.get(
|
|
91
|
+
f"/project/{updates_env['alpha']}",
|
|
92
|
+
params={"a": "log", "updates": "true"},
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
await asyncio.sleep(0.05)
|
|
96
|
+
notify_resp = await client.post(
|
|
97
|
+
"/_internal/notify",
|
|
98
|
+
params={"project": updates_env["alpha"]},
|
|
99
|
+
)
|
|
100
|
+
assert notify_resp.status_code == 200
|
|
101
|
+
assert notify_resp.json()["waiters_woken"] == 1
|
|
102
|
+
resp: httpx.Response = await poll
|
|
103
|
+
return resp.status_code, resp.text
|
|
104
|
+
|
|
105
|
+
status, body = asyncio.run(scenario())
|
|
106
|
+
assert status == 200
|
|
107
|
+
assert "Log" in body
|
|
108
|
+
|
|
109
|
+
def test_updates_with_pf_wakes_on_any_matching_project(self, updates_env: dict[str, str]) -> None:
|
|
110
|
+
async def scenario() -> int:
|
|
111
|
+
async with _client() as client:
|
|
112
|
+
poll: asyncio.Task[httpx.Response] = asyncio.create_task(
|
|
113
|
+
client.get(
|
|
114
|
+
f"/project/{updates_env['alpha']}",
|
|
115
|
+
params={"a": "heads", "updates": "true", "pf": "group"},
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
await asyncio.sleep(0.05)
|
|
119
|
+
notify_resp = await client.post(
|
|
120
|
+
"/_internal/notify",
|
|
121
|
+
params={"project": updates_env["beta"]},
|
|
122
|
+
)
|
|
123
|
+
assert notify_resp.status_code == 200
|
|
124
|
+
assert notify_resp.json()["waiters_woken"] == 1
|
|
125
|
+
return (await poll).status_code
|
|
126
|
+
|
|
127
|
+
assert asyncio.run(scenario()) == 200
|
|
128
|
+
|
|
129
|
+
def test_updates_rejected_for_unsupported_action(self, updates_env: dict[str, str]) -> None:
|
|
130
|
+
async def scenario() -> int:
|
|
131
|
+
async with _client() as client:
|
|
132
|
+
resp = await client.get(
|
|
133
|
+
f"/project/{updates_env['alpha']}",
|
|
134
|
+
params={"a": "tree", "updates": "true"},
|
|
135
|
+
)
|
|
136
|
+
return resp.status_code
|
|
137
|
+
|
|
138
|
+
assert asyncio.run(scenario()) == 400
|
|
139
|
+
|
|
140
|
+
def test_notify_unknown_project_returns_404(self, updates_env: dict[str, str]) -> None:
|
|
141
|
+
async def scenario() -> int:
|
|
142
|
+
async with _client() as client:
|
|
143
|
+
resp = await client.post("/_internal/notify", params={"project": "no-such-project"})
|
|
144
|
+
return resp.status_code
|
|
145
|
+
|
|
146
|
+
assert asyncio.run(scenario()) == 404
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from pygitweb.change_queue import ChangeQueue
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestChangeQueue:
|
|
9
|
+
def test_notify_wakes_waiter_with_project_name(self) -> None:
|
|
10
|
+
async def scenario() -> tuple[int, str | None, int]:
|
|
11
|
+
queue: ChangeQueue = ChangeQueue()
|
|
12
|
+
waiter: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["alpha"], timeout=2.0))
|
|
13
|
+
await asyncio.sleep(0)
|
|
14
|
+
woken: int = await queue.notify("alpha")
|
|
15
|
+
result: str | None = await waiter
|
|
16
|
+
leftover: int = await queue.waiter_count()
|
|
17
|
+
return woken, result, leftover
|
|
18
|
+
|
|
19
|
+
woken, result, leftover = asyncio.run(scenario())
|
|
20
|
+
assert woken == 1
|
|
21
|
+
assert result == "alpha"
|
|
22
|
+
assert leftover == 0
|
|
23
|
+
|
|
24
|
+
def test_wait_returns_none_on_timeout(self) -> None:
|
|
25
|
+
async def scenario() -> tuple[str | None, int]:
|
|
26
|
+
queue: ChangeQueue = ChangeQueue()
|
|
27
|
+
outcome: str | None = await queue.wait_for_changes(["alpha"], timeout=0.05)
|
|
28
|
+
leftover: int = await queue.waiter_count("alpha")
|
|
29
|
+
return outcome, leftover
|
|
30
|
+
|
|
31
|
+
outcome, leftover = asyncio.run(scenario())
|
|
32
|
+
assert outcome is None
|
|
33
|
+
assert leftover == 0
|
|
34
|
+
|
|
35
|
+
def test_notify_only_wakes_subscribed_waiters(self) -> None:
|
|
36
|
+
async def scenario() -> tuple[int, str | None]:
|
|
37
|
+
queue: ChangeQueue = ChangeQueue()
|
|
38
|
+
waiter: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["alpha"], timeout=0.2))
|
|
39
|
+
await asyncio.sleep(0)
|
|
40
|
+
woken: int = await queue.notify("beta")
|
|
41
|
+
outcome: str | None = await waiter
|
|
42
|
+
return woken, outcome
|
|
43
|
+
|
|
44
|
+
woken, outcome = asyncio.run(scenario())
|
|
45
|
+
assert woken == 0
|
|
46
|
+
assert outcome is None
|
|
47
|
+
|
|
48
|
+
def test_wait_for_multiple_projects_wakes_on_any(self) -> None:
|
|
49
|
+
async def scenario() -> tuple[str | None, int]:
|
|
50
|
+
queue: ChangeQueue = ChangeQueue()
|
|
51
|
+
waiter: asyncio.Task[str | None] = asyncio.create_task(
|
|
52
|
+
queue.wait_for_changes(["alpha", "group/beta"], timeout=2.0)
|
|
53
|
+
)
|
|
54
|
+
await asyncio.sleep(0)
|
|
55
|
+
await queue.notify("group/beta")
|
|
56
|
+
outcome: str | None = await waiter
|
|
57
|
+
leftover: int = await queue.waiter_count()
|
|
58
|
+
return outcome, leftover
|
|
59
|
+
|
|
60
|
+
outcome, leftover = asyncio.run(scenario())
|
|
61
|
+
assert outcome == "group/beta"
|
|
62
|
+
assert leftover == 0
|
|
63
|
+
|
|
64
|
+
def test_notify_returns_zero_when_no_waiters(self) -> None:
|
|
65
|
+
async def scenario() -> int:
|
|
66
|
+
queue: ChangeQueue = ChangeQueue()
|
|
67
|
+
return await queue.notify("alpha")
|
|
68
|
+
|
|
69
|
+
assert asyncio.run(scenario()) == 0
|
|
70
|
+
|
|
71
|
+
def test_wait_returns_none_when_shutdown_event_set(self) -> None:
|
|
72
|
+
async def scenario() -> str | None:
|
|
73
|
+
queue: ChangeQueue = ChangeQueue()
|
|
74
|
+
sd: asyncio.Event = asyncio.Event()
|
|
75
|
+
sd.set()
|
|
76
|
+
return await queue.wait_for_changes(["alpha"], timeout=2.0, shutdown_event=sd)
|
|
77
|
+
|
|
78
|
+
assert asyncio.run(scenario()) is None
|
|
79
|
+
|
|
80
|
+
def test_wait_returns_none_when_shutdown_beats_notify(self) -> None:
|
|
81
|
+
async def scenario() -> str | None:
|
|
82
|
+
queue: ChangeQueue = ChangeQueue()
|
|
83
|
+
sd: asyncio.Event = asyncio.Event()
|
|
84
|
+
waiter: asyncio.Task[str | None] = asyncio.create_task(
|
|
85
|
+
queue.wait_for_changes(["alpha"], timeout=2.0, shutdown_event=sd)
|
|
86
|
+
)
|
|
87
|
+
await asyncio.sleep(0)
|
|
88
|
+
sd.set()
|
|
89
|
+
return await waiter
|
|
90
|
+
|
|
91
|
+
assert asyncio.run(scenario()) is None
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
import pygit2
|
|
8
|
+
import pytest
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from fastapi.testclient import TestClient
|
|
11
|
+
|
|
12
|
+
from pygittools.tasks import BOARD_REF_PREFIX, TASK_REF_PREFIX, Board, Task
|
|
13
|
+
from pygitweb.auth_config import AuthConfig, LocalUser
|
|
14
|
+
from pygitweb.config import settings
|
|
15
|
+
from pygitweb.conftest import client_as_user
|
|
16
|
+
from pygitweb.permissions import Permission, PermissionsMap, permission_key
|
|
17
|
+
from pygitweb.tasks import EMPTY_TREE_OID, comment_router
|
|
18
|
+
|
|
19
|
+
_COMMENTS_AUTH = AuthConfig(
|
|
20
|
+
auth_mode="local",
|
|
21
|
+
local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
|
|
22
|
+
oauth_permissions=PermissionsMap.model_validate({
|
|
23
|
+
"*": [],
|
|
24
|
+
permission_key(Permission.COMMENTS, "demo"): ["viewer"],
|
|
25
|
+
permission_key(Permission.TASKS, "demo"): ["admin"],
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_client() -> TestClient:
|
|
31
|
+
app = FastAPI()
|
|
32
|
+
app.include_router(comment_router, prefix="/comments")
|
|
33
|
+
return TestClient(app)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
|
|
37
|
+
(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
|
|
38
|
+
index = repo.index
|
|
39
|
+
index.add("README.md")
|
|
40
|
+
index.write()
|
|
41
|
+
tree = index.write_tree()
|
|
42
|
+
sig = pygit2.Signature("tester", "tester@example.com")
|
|
43
|
+
repo.create_commit("HEAD", sig, sig, "initial", tree, [])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def comment_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
|
|
48
|
+
repo_dir = tmp_path / "demo"
|
|
49
|
+
repo = pygit2.init_repository(str(repo_dir), bare=False)
|
|
50
|
+
_create_initial_commit(repo, repo_dir)
|
|
51
|
+
board_ref = f"{BOARD_REF_PREFIX}Tasks"
|
|
52
|
+
board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="")
|
|
53
|
+
task_ref = f"{TASK_REF_PREFIX}task_1"
|
|
54
|
+
task = Task(EMPTY_TREE_OID, task_ref, tagger="", title="Task", description="")
|
|
55
|
+
task_oid = task.write(repo)
|
|
56
|
+
board.tasks = [str(task_oid)]
|
|
57
|
+
board.update_message()
|
|
58
|
+
board.write(repo)
|
|
59
|
+
with (
|
|
60
|
+
patch.object(settings, "PROJECTROOT", str(tmp_path)),
|
|
61
|
+
patch.object(settings, "PROJECTS_LIST", str(tmp_path)),
|
|
62
|
+
patch.object(settings, "STRICT_EXPORT", False),
|
|
63
|
+
patch.object(settings, "EXPORT_OK", ""),
|
|
64
|
+
patch.object(settings, "LIST_ALL", True),
|
|
65
|
+
patch("pygitweb.auth_config.auth_config", _COMMENTS_AUTH),
|
|
66
|
+
patch("pygitweb.auth.auth_config", _COMMENTS_AUTH),
|
|
67
|
+
):
|
|
68
|
+
client = _build_client()
|
|
69
|
+
yield {
|
|
70
|
+
"client": client,
|
|
71
|
+
"project": "demo",
|
|
72
|
+
"board": "Tasks",
|
|
73
|
+
"task": task_ref,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_comment_create_open_when_auth_disabled(comment_env: dict[str, str]) -> None:
|
|
78
|
+
client = comment_env["client"]
|
|
79
|
+
assert isinstance(client, TestClient)
|
|
80
|
+
with patch.object(settings, "AUTH", False):
|
|
81
|
+
r = client.post(
|
|
82
|
+
"/comments/create",
|
|
83
|
+
params={
|
|
84
|
+
"project": comment_env["project"],
|
|
85
|
+
"board": comment_env["board"],
|
|
86
|
+
"task": comment_env["task"],
|
|
87
|
+
"content": "note",
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
assert r.status_code == 200
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_comment_create_401_without_login(comment_env: dict[str, str]) -> None:
|
|
94
|
+
client = comment_env["client"]
|
|
95
|
+
assert isinstance(client, TestClient)
|
|
96
|
+
with patch.object(settings, "AUTH", True):
|
|
97
|
+
r = client.post(
|
|
98
|
+
"/comments/create",
|
|
99
|
+
params={
|
|
100
|
+
"project": comment_env["project"],
|
|
101
|
+
"board": comment_env["board"],
|
|
102
|
+
"task": comment_env["task"],
|
|
103
|
+
"content": "note",
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
assert r.status_code == 401
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_comment_create_allowed_with_comments_grant_only(comment_env: dict[str, str]) -> None:
|
|
110
|
+
"""User with COMMENTS but not TASKS can add a comment."""
|
|
111
|
+
client = comment_env["client"]
|
|
112
|
+
assert isinstance(client, TestClient)
|
|
113
|
+
with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
|
|
114
|
+
r = client.post(
|
|
115
|
+
"/comments/create",
|
|
116
|
+
params={
|
|
117
|
+
"project": comment_env["project"],
|
|
118
|
+
"board": comment_env["board"],
|
|
119
|
+
"task": comment_env["task"],
|
|
120
|
+
"content": "from viewer",
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
assert r.status_code == 200
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_comment_create_forbidden_without_comments_grant(comment_env: dict[str, str]) -> None:
|
|
127
|
+
"""User with TASKS but not COMMENTS cannot add a comment."""
|
|
128
|
+
client = comment_env["client"]
|
|
129
|
+
assert isinstance(client, TestClient)
|
|
130
|
+
with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
|
|
131
|
+
r = client.post(
|
|
132
|
+
"/comments/create",
|
|
133
|
+
params={
|
|
134
|
+
"project": comment_env["project"],
|
|
135
|
+
"board": comment_env["board"],
|
|
136
|
+
"task": comment_env["task"],
|
|
137
|
+
"content": "from admin",
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
assert r.status_code == 403
|
pygitweb/config.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyGitWeb configuration: env-driven settings via pydantic-settings, constants, and loadavg.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pydantic import Field, field_validator, model_validator
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
|
|
14
|
+
# Actions (most identical to gitweb.perl actions)
|
|
15
|
+
ACTIONS: set[str] = {
|
|
16
|
+
"blame",
|
|
17
|
+
"blame_incremental",
|
|
18
|
+
"blame_data",
|
|
19
|
+
"blame_raw",
|
|
20
|
+
"blobdiff",
|
|
21
|
+
"blobpatch",
|
|
22
|
+
"blob",
|
|
23
|
+
"blob_plain",
|
|
24
|
+
"commitdiff",
|
|
25
|
+
"commit",
|
|
26
|
+
"heads",
|
|
27
|
+
"history",
|
|
28
|
+
"log",
|
|
29
|
+
"patch",
|
|
30
|
+
"patches",
|
|
31
|
+
"remotes",
|
|
32
|
+
"rss",
|
|
33
|
+
"atom",
|
|
34
|
+
"search",
|
|
35
|
+
"search_help",
|
|
36
|
+
"shortlog",
|
|
37
|
+
"summary",
|
|
38
|
+
"tag",
|
|
39
|
+
"tags",
|
|
40
|
+
"tree",
|
|
41
|
+
"snapshot",
|
|
42
|
+
"object",
|
|
43
|
+
"opml",
|
|
44
|
+
"project_list",
|
|
45
|
+
"project_index",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Map file extension to highlight.js language
|
|
49
|
+
BLOB_LANG: dict[str, str] = {
|
|
50
|
+
"py": "python",
|
|
51
|
+
"js": "javascript",
|
|
52
|
+
"ts": "typescript",
|
|
53
|
+
"jsx": "javascript",
|
|
54
|
+
"tsx": "typescript",
|
|
55
|
+
"html": "html",
|
|
56
|
+
"htm": "html",
|
|
57
|
+
"css": "css",
|
|
58
|
+
"scss": "scss",
|
|
59
|
+
"json": "json",
|
|
60
|
+
"md": "markdown",
|
|
61
|
+
"sh": "bash",
|
|
62
|
+
"bash": "bash",
|
|
63
|
+
"yml": "yaml",
|
|
64
|
+
"yaml": "yaml",
|
|
65
|
+
"xml": "xml",
|
|
66
|
+
"go": "go",
|
|
67
|
+
"rs": "rust",
|
|
68
|
+
"java": "java",
|
|
69
|
+
"c": "c",
|
|
70
|
+
"h": "c",
|
|
71
|
+
"cpp": "cpp",
|
|
72
|
+
"cc": "cpp",
|
|
73
|
+
"cxx": "cpp",
|
|
74
|
+
"sql": "sql",
|
|
75
|
+
"r": "r",
|
|
76
|
+
"rb": "ruby",
|
|
77
|
+
"php": "php",
|
|
78
|
+
"swift": "swift",
|
|
79
|
+
"kt": "kotlin",
|
|
80
|
+
"vue": "xml",
|
|
81
|
+
"toml": "toml",
|
|
82
|
+
"ini": "ini",
|
|
83
|
+
"cfg": "ini",
|
|
84
|
+
"dockerfile": "dockerfile",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Settings(BaseSettings):
|
|
89
|
+
"""
|
|
90
|
+
Runtime settings from ~/.pygitweb/settings.json (or PYGITWEB_SETTINGS_CONFIG),
|
|
91
|
+
PYGITWEB_* env vars, and optionally a .env file. On first run the file is created
|
|
92
|
+
from the environment. When the file exists, env vars override file values (with a warning).
|
|
93
|
+
|
|
94
|
+
When AUTH is true, PyGitWeb uses OAuth2 password flow (POST /token), Bearer tokens,
|
|
95
|
+
and browser login. Local credentials live in the auth JSON file (see PYGITWEB_AUTH_CONFIG).
|
|
96
|
+
|
|
97
|
+
If PYGITWEB_AUTH is omitted entirely (or empty), authentication defaults to enabled
|
|
98
|
+
and a random password is written to the auth config file on first run (see stderr).
|
|
99
|
+
Set PYGITWEB_AUTH=0 or PYGITWEB_AUTH=1 explicitly as needed.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
model_config = SettingsConfigDict(
|
|
103
|
+
env_prefix="PYGITWEB_",
|
|
104
|
+
env_file=".env",
|
|
105
|
+
env_file_encoding="utf-8",
|
|
106
|
+
case_sensitive=False,
|
|
107
|
+
validate_assignment=True,
|
|
108
|
+
extra="ignore",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
PROJECTROOT: str = Field(default_factory=lambda: str(Path.home()))
|
|
112
|
+
PROJECTS_LIST: str = "" # falls back to PROJECTROOT when blank
|
|
113
|
+
PROJECT_MAXDEPTH: int = 1
|
|
114
|
+
SITE_NAME: str = "PyGitWeb"
|
|
115
|
+
EXPORT_OK: str = ""
|
|
116
|
+
LIST_ALL: bool = True
|
|
117
|
+
STRICT_EXPORT: bool = False
|
|
118
|
+
GIT: str = "git"
|
|
119
|
+
MAXLOAD: float | None = None
|
|
120
|
+
|
|
121
|
+
AUTH: bool | None = None
|
|
122
|
+
AUTH_CONFIG: str = ""
|
|
123
|
+
SETTINGS_CONFIG: str = ""
|
|
124
|
+
|
|
125
|
+
@field_validator("AUTH", mode="before")
|
|
126
|
+
@classmethod
|
|
127
|
+
def _coerce_auth(cls, v: object) -> bool | None:
|
|
128
|
+
if v is None:
|
|
129
|
+
return None
|
|
130
|
+
if isinstance(v, str) and v.strip() == "":
|
|
131
|
+
return None
|
|
132
|
+
if isinstance(v, bool):
|
|
133
|
+
return v
|
|
134
|
+
if isinstance(v, int):
|
|
135
|
+
return v != 0
|
|
136
|
+
if isinstance(v, str):
|
|
137
|
+
s = v.strip().lower()
|
|
138
|
+
if s in ("none", "false", "0", "no", "off"):
|
|
139
|
+
return False
|
|
140
|
+
if s in ("true", "1", "yes", "on"):
|
|
141
|
+
return True
|
|
142
|
+
return None
|
|
143
|
+
return bool(v)
|
|
144
|
+
|
|
145
|
+
@model_validator(mode="after")
|
|
146
|
+
def _defaults(self) -> Settings:
|
|
147
|
+
if not self.PROJECTS_LIST:
|
|
148
|
+
self.PROJECTS_LIST = self.PROJECTROOT
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _finalize_auth_settings(s: Settings) -> None:
|
|
153
|
+
implicit_auth = s.AUTH is None
|
|
154
|
+
if implicit_auth:
|
|
155
|
+
s.AUTH = True
|
|
156
|
+
if implicit_auth and s.AUTH:
|
|
157
|
+
cfg_path = Path(s.AUTH_CONFIG).expanduser() if s.AUTH_CONFIG else Path.home() / ".pygitweb" / "auth.json"
|
|
158
|
+
print(
|
|
159
|
+
"WARNING: PyGitWeb: PYGITWEB_AUTH was not set; authentication defaults to ON.\n"
|
|
160
|
+
" Set PYGITWEB_AUTH=0 to disable, or PYGITWEB_AUTH=1 and configure "
|
|
161
|
+
f"{cfg_path}.\n",
|
|
162
|
+
file=sys.stderr,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
from pygitweb.settings_config import init_settings # noqa: E402
|
|
167
|
+
|
|
168
|
+
settings = init_settings()
|
|
169
|
+
|
|
170
|
+
from pygitweb.auth_config import init_auth_config # noqa: E402
|
|
171
|
+
|
|
172
|
+
auth_config = init_auth_config(settings)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_loadavg() -> float:
|
|
176
|
+
"""First element of load average, or 0 if unavailable. Port of get_loadavg."""
|
|
177
|
+
try:
|
|
178
|
+
return os.getloadavg()[0]
|
|
179
|
+
except (OSError, AttributeError):
|
|
180
|
+
pass
|
|
181
|
+
try:
|
|
182
|
+
with open("/proc/loadavg") as f:
|
|
183
|
+
return float(f.read().split()[0])
|
|
184
|
+
except (OSError, ValueError):
|
|
185
|
+
return 0.0
|
pygitweb/conftest.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Pytest fixtures for pygitweb (test env vars are set in the repo-root conftest.py)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from fastapi.testclient import TestClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def clear_client_cookies(client: TestClient) -> None:
|
|
14
|
+
client.cookies.clear()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_client_session(client: TestClient, username: str) -> None:
|
|
18
|
+
from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
|
|
19
|
+
from pygitweb.sessions import create_session
|
|
20
|
+
|
|
21
|
+
client.cookies.set(ACCESS_TOKEN_COOKIE_NAME, create_session(username, auth_method="local"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def client_as_user(client: TestClient, username: str) -> Generator[None, None, None]:
|
|
26
|
+
set_client_session(client, username)
|
|
27
|
+
try:
|
|
28
|
+
yield
|
|
29
|
+
finally:
|
|
30
|
+
clear_client_cookies(client)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture(autouse=True)
|
|
34
|
+
def _reset_pygitweb_app_lifecycle_state() -> None:
|
|
35
|
+
"""Starlette TestClient lifespan sets shutting_down on exit; httpx ASGITransport never runs lifespan.
|
|
36
|
+
|
|
37
|
+
Stale state made long-poll tests see ``shutting_down`` and skip ``wait_for_changes``, so notify woke 0 waiters.
|
|
38
|
+
"""
|
|
39
|
+
from pygitweb.main import app
|
|
40
|
+
|
|
41
|
+
app.state.shutting_down = False
|
|
42
|
+
app.state.shutdown_event = asyncio.Event()
|
|
43
|
+
yield
|
pygitweb/dependencies.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI dependencies for shared request validation (exported repositories, etc.).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
from fastapi import Depends, Form, HTTPException, Query
|
|
10
|
+
|
|
11
|
+
from pygitweb.config import settings
|
|
12
|
+
from pygitweb.projects import git_get_projects_list, project_in_list
|
|
13
|
+
from pygitweb.validation import is_valid_project
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _visible_projects_list() -> list[dict[str, Any]]:
|
|
17
|
+
return git_get_projects_list(
|
|
18
|
+
filter_path="",
|
|
19
|
+
paranoid=settings.STRICT_EXPORT,
|
|
20
|
+
export_ok=settings.EXPORT_OK,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def project_visible_in_list(project: str) -> bool:
|
|
25
|
+
"""True if ``project`` appears in the same listing as the project picker."""
|
|
26
|
+
return project_in_list(project, _visible_projects_list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def require_valid_project(project: str) -> str:
|
|
30
|
+
"""Reject empty or unknown project paths (export_ok, strict list, etc.)."""
|
|
31
|
+
if not project or project == "":
|
|
32
|
+
raise HTTPException(status_code=400, detail="Project needed")
|
|
33
|
+
if not is_valid_project(
|
|
34
|
+
project,
|
|
35
|
+
settings.PROJECTROOT,
|
|
36
|
+
settings.EXPORT_OK,
|
|
37
|
+
settings.STRICT_EXPORT,
|
|
38
|
+
project_visible_in_list,
|
|
39
|
+
):
|
|
40
|
+
raise HTTPException(status_code=404, detail="No such project")
|
|
41
|
+
return project
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def require_valid_project_name(name: str) -> str:
|
|
45
|
+
"""Same as ``require_valid_project`` for a path parameter named ``name``."""
|
|
46
|
+
return require_valid_project(name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def require_valid_project_from_query(
|
|
50
|
+
project: Annotated[str, Query(..., description="Project path")],
|
|
51
|
+
) -> str:
|
|
52
|
+
return require_valid_project(project)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_valid_project_form(project: Annotated[str, Form()]) -> str:
|
|
56
|
+
return require_valid_project(project)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def require_notify_project(
|
|
60
|
+
project: Annotated[str | None, Query(alias="project", description="Repository path")] = None,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Internal notify: project must appear in the visible list (hook / long-poll contract)."""
|
|
63
|
+
if not project or not project.strip():
|
|
64
|
+
raise HTTPException(status_code=400, detail="project query parameter required")
|
|
65
|
+
p = project.strip()
|
|
66
|
+
if not project_visible_in_list(p):
|
|
67
|
+
raise HTTPException(status_code=404, detail="No such project")
|
|
68
|
+
return p
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def board_create_resolved_project(
|
|
72
|
+
p: Annotated[str | None, Query(alias="p")] = None,
|
|
73
|
+
project: Annotated[str | None, Query(alias="project")] = None,
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Resolve ``?p=`` / ``?project=`` then validate as a normal project path."""
|
|
76
|
+
chosen = (project or p or "").strip()
|
|
77
|
+
if not chosen:
|
|
78
|
+
raise HTTPException(status_code=400, detail="Project needed (use ?project= or ?p=)")
|
|
79
|
+
return require_valid_project(chosen)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
ValidatedPathProject = Annotated[str, Depends(require_valid_project)]
|
|
83
|
+
ValidatedQueryProject = Annotated[str, Depends(require_valid_project_from_query)]
|
|
84
|
+
ValidatedSettingsProject = Annotated[str, Depends(require_valid_project_name)]
|
|
85
|
+
ValidatedBoardCreateProject = Annotated[str, Depends(board_create_resolved_project)]
|
|
86
|
+
ValidatedNotifyProject = Annotated[str, Depends(require_notify_project)]
|
|
87
|
+
ValidatedFormProject = Annotated[str, Depends(require_valid_project_form)]
|