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.
Files changed (95) hide show
  1. pygitweb/__init__.py +22 -0
  2. pygitweb/__meta__.py +9 -0
  3. pygitweb/actions.py +1303 -0
  4. pygitweb/addproject_permissions_test.py +114 -0
  5. pygitweb/api/__init__.py +0 -0
  6. pygitweb/api/action.py +20 -0
  7. pygitweb/api/plugins/__init__.py +0 -0
  8. pygitweb/api/plugins/project_zip.py +71 -0
  9. pygitweb/api/project.py +67 -0
  10. pygitweb/api/subpage.py +22 -0
  11. pygitweb/auth.py +387 -0
  12. pygitweb/auth.schema.json +106 -0
  13. pygitweb/auth_config.py +243 -0
  14. pygitweb/auth_oauth.py +343 -0
  15. pygitweb/auth_test.py +390 -0
  16. pygitweb/boards_permissions_test.py +113 -0
  17. pygitweb/change_queue.py +110 -0
  18. pygitweb/change_queue_routes_test.py +146 -0
  19. pygitweb/change_queue_test.py +91 -0
  20. pygitweb/comments_permissions_test.py +140 -0
  21. pygitweb/config.py +185 -0
  22. pygitweb/conftest.py +43 -0
  23. pygitweb/dependencies.py +87 -0
  24. pygitweb/formatting.py +182 -0
  25. pygitweb/formatting_test.py +68 -0
  26. pygitweb/git_helpers.py +454 -0
  27. pygitweb/git_helpers_test.py +223 -0
  28. pygitweb/gravatar.py +16 -0
  29. pygitweb/gravatar_test.py +10 -0
  30. pygitweb/hooks_install.py +278 -0
  31. pygitweb/hooks_install_test.py +363 -0
  32. pygitweb/hooks_permissions_test.py +116 -0
  33. pygitweb/main.py +887 -0
  34. pygitweb/merge_requests.py +230 -0
  35. pygitweb/merge_requests_test.py +85 -0
  36. pygitweb/mr_permissions_test.py +148 -0
  37. pygitweb/password_hash.py +74 -0
  38. pygitweb/password_hash_test.py +20 -0
  39. pygitweb/permissions.py +209 -0
  40. pygitweb/permissions_test.py +86 -0
  41. pygitweb/plugin_loader.py +83 -0
  42. pygitweb/projects.py +213 -0
  43. pygitweb/projects_test.py +186 -0
  44. pygitweb/sessions.py +73 -0
  45. pygitweb/settings.py +412 -0
  46. pygitweb/settings.schema.json +73 -0
  47. pygitweb/settings_config.py +122 -0
  48. pygitweb/settings_config_test.py +101 -0
  49. pygitweb/settings_permissions_test.py +76 -0
  50. pygitweb/static/blob-view.js +133 -0
  51. pygitweb/static/board.js +839 -0
  52. pygitweb/static/hook-install.js +53 -0
  53. pygitweb/static/icons/cloud-down.svg +1 -0
  54. pygitweb/static/icons/cloud-exclamation.svg +1 -0
  55. pygitweb/static/icons/cloud-filled.svg +1 -0
  56. pygitweb/static/icons/cloud-storm.svg +1 -0
  57. pygitweb/static/icons/cloud-up.svg +1 -0
  58. pygitweb/static/icons/cloud.svg +1 -0
  59. pygitweb/static/logo.svg +44 -0
  60. pygitweb/static/main.css +1210 -0
  61. pygitweb/static/navbar-auth.js +82 -0
  62. pygitweb/static/readme-render.js +174 -0
  63. pygitweb/static/search-page.js +139 -0
  64. pygitweb/static/summary-ref-switcher.js +130 -0
  65. pygitweb/static/themes/solarized.css +34 -0
  66. pygitweb/tasks.py +595 -0
  67. pygitweb/tasks_permissions_test.py +137 -0
  68. pygitweb/tasks_routes_test.py +430 -0
  69. pygitweb/templates/addproject.html +174 -0
  70. pygitweb/templates/blobdiff.html +57 -0
  71. pygitweb/templates/board.html +31 -0
  72. pygitweb/templates/board_card.html +44 -0
  73. pygitweb/templates/commitdiff.html +57 -0
  74. pygitweb/templates/hooks_summary_cell.html +37 -0
  75. pygitweb/templates/login.html +37 -0
  76. pygitweb/templates/merge_request_summary_cell.html +18 -0
  77. pygitweb/templates/merge_request_tag.html +90 -0
  78. pygitweb/templates/pagination.html +44 -0
  79. pygitweb/templates/preamble.html +89 -0
  80. pygitweb/templates/project_settings.html +13 -0
  81. pygitweb/templates/pygit2_settings.html +12 -0
  82. pygitweb/templates/pygitweb_settings.html +12 -0
  83. pygitweb/templates/search.html +77 -0
  84. pygitweb/templates/settings_form_field.html +27 -0
  85. pygitweb/templates/table.html +15 -0
  86. pygitweb/templates/user_profile.html +10 -0
  87. pygitweb/templates_env.py +21 -0
  88. pygitweb/timeline_cache.py +243 -0
  89. pygitweb/validation.py +75 -0
  90. pygitweb/validation_test.py +167 -0
  91. pygitweb-0.1.0.dist-info/METADATA +165 -0
  92. pygitweb-0.1.0.dist-info/RECORD +95 -0
  93. pygitweb-0.1.0.dist-info/WHEEL +5 -0
  94. pygitweb-0.1.0.dist-info/licenses/LICENSE +21 -0
  95. 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
@@ -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)]