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,89 @@
1
+ <!DOCTYPE html><html><head><title>{{title | default('PyGitWeb')}}</title>
2
+ <script>document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e){ document.documentElement.setAttribute('data-bs-theme', e.matches ? 'dark' : 'light'); });</script>
3
+ <link rel="icon" href="/static/logo.svg" type="image/svg+xml">
4
+ <script id="Highlight-script" async src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
5
+ <script id="MathJax-script" async src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-chtml.min.js"></script>
6
+ <script id="GraphViz-script" async src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/1.8.2/viz.js"></script>
7
+ <script id="diff2html-script" async src="https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html-ui.min.js"></script>
8
+ <script id="Showdown-script" async src="https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.js"></script>
9
+ <script id="tabler-script" async src="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/js/tabler.min.js"></script>
10
+ <link href=" https://cdn.jsdelivr.net/npm/diff2html@3.4.56/bundles/css/diff2html.min.css " rel="stylesheet">
11
+ <link rel="preconnect" href="https://fonts.googleapis.com">
12
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
+ <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Fira+Mono:wght@400;500;700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
14
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/css/tabler.min.css">
15
+ <link rel="stylesheet" href="/static/main.css"/>
16
+ <script src="/static/navbar-auth.js" defer></script>
17
+ </head><body>
18
+ <div class="page">
19
+ <aside class="navbar navbar-vertical navbar-expand-sm position-fixed">
20
+ <div class="container-fluid d-flex flex-column vh-100 overflow-y-auto">
21
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
22
+ <span class="navbar-toggler-icon"></span>
23
+ </button>
24
+ <h1 class="navbar-brand navbar-brand-autodark py-3">
25
+ <a href="/" class="d-flex align-items-center text-reset">
26
+ <img src="/static/logo.svg" width="32" height="32" alt="Logo" class="navbar-brand-image me-2" style="object-fit: contain;">
27
+ <span class="nav-link-title">{{ site_name | default('PyGitWeb') }}</span>
28
+ </a>
29
+ </h1>
30
+ <div class="collapse navbar-collapse flex-grow-1" id="sidebar-menu">
31
+ <ul class="navbar-nav pt-lg-3">
32
+ <li class="nav-item">
33
+ <a class="nav-link" href="/">
34
+ <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-folder" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2z" /></svg></span>
35
+ <span class="nav-link-title">Projects</span>
36
+ </a>
37
+ </li>
38
+ <li class="nav-item">
39
+ <a class="nav-link" href="/addproject">
40
+ <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-plus" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg></span>
41
+ <span class="nav-link-title">Add project</span>
42
+ </a>
43
+ </li>
44
+ <li class="nav-item">
45
+ <a class="nav-link" href="/activity">
46
+ <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-activity"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4l3 8l4 -16l3 8h4" /></svg></span>
47
+ <span class="nav-link-title">Activity</span>
48
+ </a>
49
+ </li>
50
+ <li class="nav-item">
51
+ <a class="nav-link" href="#">
52
+ <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-messages"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M21 14l-3 -3h-7a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1h9a1 1 0 0 1 1 1v10" /><path d="M14 15v2a1 1 0 0 1 -1 1h-7l-3 3v-10a1 1 0 0 1 1 -1h2" /></svg></span>
53
+ <span class="nav-link-title">Discussions</span>
54
+ </a>
55
+ </li>
56
+ <li class="nav-item">
57
+ <a class="nav-link" href="#">
58
+ <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-checklist" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 11l3 3l8 -8" /><path d="M20 12v6a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h9" /></svg></span>
59
+ <span class="nav-link-title">Tasks</span>
60
+ </a>
61
+ </li>
62
+ <li class="nav-item dropdown">
63
+ <a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false" role="button">
64
+ <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-settings" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" /><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /></svg></span>
65
+ <span class="nav-link-title">Settings</span>
66
+ </a>
67
+ <div class="dropdown-menu dropdown-menu-column dropdown-menu-column-1">
68
+ <a class="dropdown-item" href="/settings/pygitweb">PyGitWeb</a>
69
+ <a class="dropdown-item" href="/settings/pygit2">PyGit2</a>
70
+ </div>
71
+ </li>
72
+ </ul>
73
+ </div>
74
+ <div class="navbar-user mt-auto py-3 px-3 border-top" id="navbar-user" data-logged-in="false">
75
+ <a class="d-flex align-items-center text-reset text-decoration-none w-100" id="navbar-user-link" href="/login" aria-label="Account">
76
+ <span class="avatar avatar-sm bg-secondary me-2 d-flex align-items-center justify-content-center" id="navbar-user-avatar">
77
+ <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-user" width="22" height="22" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>
78
+ </span>
79
+ <div class="d-none d-xl-block text-start">
80
+ <div class="navbar-user-name text-body">Guest</div>
81
+ <div class="navbar-user-meta text-muted small">Not signed in</div>
82
+ </div>
83
+ </a>
84
+ </div>
85
+ </div>
86
+ </aside>
87
+ <div class="page-wrapper">
88
+ <div class="page-body">
89
+ <div class="container-xl">
@@ -0,0 +1,13 @@
1
+ <h1 class="page-title">Project settings: {{ project_title }}</h1>
2
+ <p class="text-muted">Repository config (local and worktree only).</p>
3
+ <div class="card">
4
+ <div class="card-body">
5
+ <form action="{{ submit_url }}" method="post" class="needs-validation" novalidate>
6
+ {{ table_html | safe }}
7
+ <div class="form-footer mt-3">
8
+ <button type="submit" class="btn btn-primary">Save</button>
9
+ <a href="{{ cancel_url }}" class="btn btn-ghost-secondary">Cancel</a>
10
+ </div>
11
+ </form>
12
+ </div>
13
+ </div>
@@ -0,0 +1,12 @@
1
+ <h1 class="page-title">PyGit2 settings</h1>
2
+ <div class="card">
3
+ <div class="card-body">
4
+ <form action="/settings/pygit2/submit" method="post" class="needs-validation" novalidate>
5
+ {{ form_body | safe }}
6
+ <div class="form-footer">
7
+ <button type="submit" class="btn btn-primary">Save</button>
8
+ <a href="/" class="btn btn-ghost-secondary">Cancel</a>
9
+ </div>
10
+ </form>
11
+ </div>
12
+ </div>
@@ -0,0 +1,12 @@
1
+ <h1 class="page-title">PyGitWeb settings</h1>
2
+ <div class="card">
3
+ <div class="card-body">
4
+ <form action="/settings/pygitweb/submit" method="post" class="needs-validation" novalidate>
5
+ {{ form_body | safe }}
6
+ <div class="form-footer">
7
+ <button type="submit" class="btn btn-primary">Save</button>
8
+ <a href="/" class="btn btn-ghost-secondary">Cancel</a>
9
+ </div>
10
+ </form>
11
+ </div>
12
+ </div>
@@ -0,0 +1,77 @@
1
+ {# Search UI page. Expects: project, project_enc, project_url, search_api_url. #}
2
+
3
+ <h1 class="page-title d-flex align-items-center gap-2">
4
+ <a href="{{ project_url }}" class="text-reset">{{ project }}</a>
5
+ <span class="text-muted">/</span>
6
+ <span>search</span>
7
+ </h1>
8
+
9
+ <div class="card mb-3">
10
+ <div class="card-body">
11
+ <form id="search-form" class="search-container" autocomplete="off" onsubmit="return false;">
12
+ <input
13
+ id="search-patterns"
14
+ class="form-control search"
15
+ type="text"
16
+ spellcheck="false"
17
+ autocomplete="off"
18
+ placeholder="Search regex (e.g. TODO|FIXME)..."
19
+ aria-label="Search pattern"
20
+ >
21
+
22
+ <details id="search-advanced" class="mt-2">
23
+ <summary>Advanced options</summary>
24
+ <div class="row g-2 mt-1">
25
+ <div class="col-md-6">
26
+ <label class="form-label" for="search-paths">Paths (relative, comma separated)</label>
27
+ <input id="search-paths" class="form-control" type="text" placeholder="src,docs">
28
+ <div class="form-hint">Restricted to the project tree.</div>
29
+ </div>
30
+ <div class="col-md-6">
31
+ <label class="form-label" for="search-globs">Globs (comma separated)</label>
32
+ <input id="search-globs" class="form-control" type="text" placeholder="*.py,*.md,!**/vendor/**">
33
+ </div>
34
+ <div class="col-md-3">
35
+ <label class="form-label" for="search-sort">Sort</label>
36
+ <select id="search-sort" class="form-select">
37
+ <option value="">(none)</option>
38
+ <option value="Path">Path</option>
39
+ <option value="-Path">Path (reverse)</option>
40
+ <option value="LastModified">Last modified</option>
41
+ <option value="-LastModified">Last modified (reverse)</option>
42
+ <option value="LastAccessed">Last accessed</option>
43
+ <option value="-LastAccessed">Last accessed (reverse)</option>
44
+ <option value="Created">Created</option>
45
+ <option value="-Created">Created (reverse)</option>
46
+ </select>
47
+ </div>
48
+ <div class="col-md-3">
49
+ <label class="form-label" for="search-max-count">Max matches per file</label>
50
+ <input id="search-max-count" class="form-control" type="number" min="1" value="25">
51
+ </div>
52
+ <div class="col-md-3 align-self-end">
53
+ <label class="form-check">
54
+ <input id="search-heading" class="form-check-input" type="checkbox" checked>
55
+ <span class="form-check-label">Heading (group by file)</span>
56
+ </label>
57
+ </div>
58
+ <div class="col-md-3 align-self-end">
59
+ <label class="form-check">
60
+ <input id="search-multiline" class="form-check-input" type="checkbox">
61
+ <span class="form-check-label">Multiline</span>
62
+ </label>
63
+ </div>
64
+ </div>
65
+ </details>
66
+ </form>
67
+ </div>
68
+ </div>
69
+
70
+ <div id="search-status" class="search-status text-muted mb-2" aria-live="polite"></div>
71
+ <div id="search-results" class="search-results" role="region" aria-label="Search results"></div>
72
+
73
+ <script>
74
+ window.SEARCH_PROJECT = {{ project | tojson }};
75
+ window.SEARCH_API_URL = {{ search_api_url | tojson }};
76
+ </script>
77
+ <script src="/static/search-page.js"></script>
@@ -0,0 +1,27 @@
1
+ {% if kind == "bool" %}
2
+ <div class="mb-3">
3
+ <div class="form-check">
4
+ <input type="checkbox" class="form-check-input" id="{{ safe_name }}" name="{{ field_name }}" value="on"{% if checked %} checked{% endif %}>
5
+ <label class="form-check-label" for="{{ safe_name }}">{{ name }}</label>
6
+ </div>
7
+ <small class="form-hint text-muted">{{ hint_text }}</small>
8
+ </div>
9
+ {% elif kind == "number" %}
10
+ <div class="mb-3">
11
+ <label class="form-label" for="{{ safe_name }}">{{ name }}</label>
12
+ <input type="number" {% if number_step %}step="{{ number_step }}" {% endif %}class="form-control" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val }}">
13
+ <small class="form-hint text-muted">{{ hint_text }}</small>
14
+ </div>
15
+ {% elif kind == "readonly" %}
16
+ <div class="mb-3">
17
+ <label class="form-label" for="{{ safe_name }}">{{ name }}</label>
18
+ <input type="text" class="form-control bg-secondary" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val }}" readonly>
19
+ <small class="form-hint text-muted">{{ hint_text }}</small>
20
+ </div>
21
+ {% else %}
22
+ <div class="mb-3">
23
+ <label class="form-label" for="{{ safe_name }}">{{ name }}</label>
24
+ <input type="text" class="form-control" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val }}">
25
+ <small class="form-hint text-muted">{{ hint_text }}</small>
26
+ </div>
27
+ {% endif %}
@@ -0,0 +1,15 @@
1
+ <table class="table table-vcenter card-table">
2
+ <thead>
3
+ <tr>{% for col in cols %}
4
+ <th>{{col}}</th>
5
+ {% endfor %}
6
+ </tr>
7
+ </thead>
8
+ <tbody>{% for row in rows %}
9
+ <tr>{% for cell in row %}
10
+ <td>{{ cell | safe }}</td>
11
+ {% endfor %}
12
+ </tr>
13
+ {% endfor %}
14
+ </tbody>
15
+ </table>
@@ -0,0 +1,10 @@
1
+ <h1 class="page-title">Account</h1>
2
+ <div class="card">
3
+ <div class="card-body">
4
+ <p class="mb-1"><strong>Username</strong></p>
5
+ <p class="text-muted mb-4">{{ username | e }}</p>
6
+ <p class="mb-0">
7
+ <a class="btn btn-outline-secondary" href="/logout?next=/">Sign out</a>
8
+ </p>
9
+ </div>
10
+ </div>
@@ -0,0 +1,21 @@
1
+ """Shared Jinja2 environment and preamble/postamble for pygitweb templates."""
2
+
3
+ from jinja2 import Environment, PackageLoader, select_autoescape
4
+
5
+ env = Environment(
6
+ loader=PackageLoader("pygitweb", "templates"),
7
+ autoescape=select_autoescape(enabled_extensions=("html", "xml"), default_for_string=True),
8
+ trim_blocks=True,
9
+ lstrip_blocks=True,
10
+ )
11
+ PREAMBLE = env.get_template("preamble.html")
12
+ POSTAMBLE = """</div></div></div></body></html>"""
13
+
14
+
15
+ _ESCAPE_TEMPLATE = env.from_string("{{ value|e }}")
16
+
17
+
18
+ def jinja_escape(value: str | None) -> str | None:
19
+ if value is None:
20
+ return None
21
+ return _ESCAPE_TEMPLATE.render(value=value)
@@ -0,0 +1,243 @@
1
+ """
2
+ Per-project timeline cache for recent repository activity.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import threading
9
+ from typing import Literal, TypedDict
10
+
11
+ import pygit2
12
+
13
+ from pygitweb.config import settings
14
+ from pygitweb.projects import git_get_projects_list
15
+
16
+ TIMELINE_CACHE_SIZE: int = 10
17
+ TIMELINE_CACHE_TOTAL_EVENTS: int = 200
18
+ TIMELINE_CACHE_BATCH_SIZE: int = 5
19
+
20
+
21
+ class TimelineEvent(TypedDict):
22
+ timestamp: int
23
+ oid: str
24
+ kind: Literal["commit", "ref"]
25
+ description: str | None
26
+
27
+
28
+ class TimelineProjectEvent(TypedDict):
29
+ project: str
30
+ event: TimelineEvent
31
+
32
+
33
+ PROJECT_TIMELINE_CACHE: dict[str, list[TimelineEvent]] = {}
34
+ _TIMELINE_CACHE_LOCK = threading.Lock()
35
+ _TIMELINE_WARMING: bool = False
36
+
37
+
38
+ def _commit_description(commit: pygit2.Commit) -> str | None:
39
+ if not commit.message:
40
+ return None
41
+ first_line: str = commit.message.splitlines()[0].strip()
42
+ return first_line or None
43
+
44
+
45
+ def _collect_commit_events(repo: pygit2.Repository) -> list[TimelineEvent]:
46
+ start_points: list[pygit2.Oid | str] = []
47
+ for ref_name in repo.references:
48
+ if not ref_name.startswith("refs/heads/"):
49
+ continue
50
+ try:
51
+ resolved: pygit2.Reference = repo.references[ref_name].resolve()
52
+ oid: pygit2.Oid | str = resolved.target
53
+ start_points.append(oid)
54
+ except (KeyError, pygit2.GitError, ValueError):
55
+ continue
56
+ if not start_points:
57
+ try:
58
+ head_ref: pygit2.Reference = repo.head
59
+ start_points.append(head_ref.target)
60
+ except (pygit2.GitError, ValueError, KeyError):
61
+ pass
62
+ if not start_points:
63
+ return []
64
+
65
+ walker: pygit2.Walker = repo.walk(
66
+ start_points[0],
67
+ pygit2.enums.SortMode(pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL),
68
+ )
69
+ for oid in start_points[1:]:
70
+ try:
71
+ walker.push(oid)
72
+ except (pygit2.GitError, ValueError):
73
+ continue
74
+
75
+ events: list[TimelineEvent] = []
76
+ seen: set[str] = set()
77
+ for commit in walker:
78
+ commit_oid: str = str(commit.id)
79
+ if commit_oid in seen:
80
+ continue
81
+ seen.add(commit_oid)
82
+ events.append({
83
+ "timestamp": commit.commit_time,
84
+ "oid": commit_oid,
85
+ "kind": "commit",
86
+ "description": _commit_description(commit),
87
+ })
88
+ return events
89
+
90
+
91
+ def _collect_ref_events(repo: pygit2.Repository) -> list[TimelineEvent]:
92
+ events: list[TimelineEvent] = []
93
+ for ref_name in repo.references:
94
+ if not ref_name.startswith("refs/tags/"):
95
+ continue
96
+ try:
97
+ ref_obj: pygit2.Reference = repo.references[ref_name].resolve()
98
+ oid_str: str = str(ref_obj.target)
99
+ obj = repo[ref_obj.target]
100
+ except (KeyError, pygit2.GitError, ValueError):
101
+ continue
102
+
103
+ if isinstance(obj, pygit2.Tag):
104
+ tag_tagger = obj.tagger
105
+ timestamp: int = tag_tagger.time if tag_tagger else 0
106
+ description: str | None = ref_name.replace("refs/tags/", "", 1)
107
+ elif isinstance(obj, pygit2.Commit):
108
+ timestamp = obj.commit_time
109
+ description = ref_name.replace("refs/tags/", "", 1)
110
+ else:
111
+ continue
112
+
113
+ events.append({
114
+ "timestamp": timestamp,
115
+ "oid": oid_str,
116
+ "kind": "ref",
117
+ "description": description or None,
118
+ })
119
+ return events
120
+
121
+
122
+ def build_project_timeline_cache(project: str, max_events: int = TIMELINE_CACHE_SIZE) -> list[TimelineEvent]:
123
+ try:
124
+ repo: pygit2.Repository = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
125
+ except (pygit2.GitError, OSError, ValueError):
126
+ return []
127
+
128
+ commit_events: list[TimelineEvent] = _collect_commit_events(repo)
129
+ ref_events: list[TimelineEvent] = _collect_ref_events(repo)
130
+ all_events: list[TimelineEvent] = commit_events + ref_events
131
+ all_events.sort(key=lambda event: (event["timestamp"], event["oid"]), reverse=True)
132
+ return all_events[:max_events]
133
+
134
+
135
+ def warm_timeline_cache() -> None:
136
+ global PROJECT_TIMELINE_CACHE, _TIMELINE_WARMING
137
+ with _TIMELINE_CACHE_LOCK:
138
+ _TIMELINE_WARMING = True
139
+ try:
140
+ new_cache: dict[str, list[TimelineEvent]] = {}
141
+ projects = git_get_projects_list(
142
+ filter_path="",
143
+ paranoid=settings.STRICT_EXPORT,
144
+ export_ok=settings.EXPORT_OK,
145
+ )
146
+ project_names: list[str] = []
147
+ project_event_pool: dict[str, list[TimelineEvent]] = {}
148
+ for project_entry in projects:
149
+ project_name = project_entry.get("path")
150
+ if not isinstance(project_name, str) or not project_name:
151
+ continue
152
+ project_names.append(project_name)
153
+ try:
154
+ project_event_pool[project_name] = build_project_timeline_cache(
155
+ project_name,
156
+ max_events=TIMELINE_CACHE_TOTAL_EVENTS,
157
+ )
158
+ except (pygit2.GitError, OSError, ValueError, KeyError):
159
+ project_event_pool[project_name] = []
160
+
161
+ for project_name in project_names:
162
+ new_cache[project_name] = []
163
+
164
+ selected_events: list[TimelineProjectEvent] = []
165
+ next_index_by_project: dict[str, int] = {project_name: 0 for project_name in project_names}
166
+
167
+ for project_name in project_names:
168
+ if len(selected_events) >= TIMELINE_CACHE_TOTAL_EVENTS:
169
+ break
170
+ project_events = project_event_pool.get(project_name, [])
171
+ if not project_events:
172
+ continue
173
+ selected_events.append({"project": project_name, "event": project_events[0]})
174
+ next_index_by_project[project_name] = 1
175
+
176
+ while len(selected_events) < TIMELINE_CACHE_TOTAL_EVENTS:
177
+ candidate_projects: list[str] = [
178
+ project_name
179
+ for project_name in project_names
180
+ if next_index_by_project[project_name] < len(project_event_pool.get(project_name, []))
181
+ ]
182
+ if not candidate_projects:
183
+ break
184
+ best_project: str = max(
185
+ candidate_projects,
186
+ key=lambda project_name: (
187
+ project_event_pool[project_name][next_index_by_project[project_name]]["timestamp"],
188
+ project_event_pool[project_name][next_index_by_project[project_name]]["oid"],
189
+ project_name,
190
+ ),
191
+ )
192
+ start_index = next_index_by_project[best_project]
193
+ remaining_global = TIMELINE_CACHE_TOTAL_EVENTS - len(selected_events)
194
+ remaining_project = len(project_event_pool[best_project]) - start_index
195
+ take_count = min(TIMELINE_CACHE_BATCH_SIZE, remaining_global, remaining_project)
196
+ for offset in range(take_count):
197
+ selected_events.append({
198
+ "project": best_project,
199
+ "event": project_event_pool[best_project][start_index + offset],
200
+ })
201
+ next_index_by_project[best_project] = start_index + take_count
202
+
203
+ for item in selected_events:
204
+ new_cache[item["project"]].append(item["event"])
205
+ with _TIMELINE_CACHE_LOCK:
206
+ PROJECT_TIMELINE_CACHE = new_cache
207
+ finally:
208
+ with _TIMELINE_CACHE_LOCK:
209
+ _TIMELINE_WARMING = False
210
+
211
+
212
+ def warm_timeline_cache_async() -> bool:
213
+ global _TIMELINE_WARMING
214
+ with _TIMELINE_CACHE_LOCK:
215
+ if _TIMELINE_WARMING:
216
+ return False
217
+ _TIMELINE_WARMING = True
218
+ thread = threading.Thread(target=warm_timeline_cache, daemon=True)
219
+ thread.start()
220
+ return True
221
+
222
+
223
+ def clear_timeline_cache_async() -> bool:
224
+ global PROJECT_TIMELINE_CACHE
225
+ with _TIMELINE_CACHE_LOCK:
226
+ PROJECT_TIMELINE_CACHE = {}
227
+ return True
228
+
229
+
230
+ def get_all_timeline_events() -> list[TimelineProjectEvent]:
231
+ events: list[TimelineProjectEvent] = []
232
+ with _TIMELINE_CACHE_LOCK:
233
+ cache_snapshot = {
234
+ project_name: list(project_events) for project_name, project_events in PROJECT_TIMELINE_CACHE.items()
235
+ }
236
+ for project_name, project_events in cache_snapshot.items():
237
+ for event in project_events:
238
+ events.append({"project": project_name, "event": event})
239
+ events.sort(
240
+ key=lambda item: (item["event"]["timestamp"], item["event"]["oid"], item["project"]),
241
+ reverse=True,
242
+ )
243
+ return events
pygitweb/validation.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ Validation: pathname, ref format, refname, project, action; repo discovery via pygit2.
3
+ Ported from gitweb/gitweb.perl (is_valid_pathname, is_valid_ref_format, is_valid_refname,
4
+ is_valid_project, is_valid_action). Repo check uses pygit2.discover_repository..
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from collections.abc import Callable
12
+
13
+ import pygit2
14
+
15
+
16
+ def is_valid_pathname(input_path: str | None) -> bool:
17
+ """No '.', '..' as path elements, no null, no doubled slashes. Port of is_valid_pathname."""
18
+ if input_path is None:
19
+ return False
20
+ if "\0" in input_path:
21
+ return False
22
+ parts = input_path.strip("/").split("/")
23
+ return all(p not in ("", ".", "..") for p in parts)
24
+
25
+
26
+ def is_valid_ref_format(input_ref: str | None) -> bool:
27
+ """Git-check-ref-format rules: no /., no .., no control/space/special at start/end. Port of is_valid_ref_format."""
28
+ if input_ref is None:
29
+ return False
30
+ if "/." in input_ref or input_ref.startswith(".") or ".." in input_ref:
31
+ return False
32
+ if input_ref.endswith("/") or input_ref.endswith(".lock"):
33
+ return False
34
+ # No ASCII control, space, ~^:?*[
35
+ return not re.search(r"[\x00-\x20\x7f ~^:?*\[\]]", input_ref)
36
+
37
+
38
+ def check_export_ok(
39
+ git_dir: str,
40
+ export_ok: str = "",
41
+ export_auth_hook: Callable[[str], bool] | None = None,
42
+ across_fs: bool = False,
43
+ ) -> bool:
44
+ """True if path is a git repo (via pygit2.discover_repository) and optional export_ok file / auth hook pass."""
45
+ if not os.path.isdir(git_dir):
46
+ return False
47
+ try:
48
+ discovered = pygit2.discover_repository(git_dir, across_fs)
49
+ if not discovered:
50
+ return False
51
+ except (KeyError, pygit2.GitError, OSError):
52
+ return False
53
+ if export_ok and not os.path.isfile(os.path.join(git_dir, export_ok)):
54
+ return False
55
+ return not export_auth_hook or export_auth_hook(git_dir)
56
+
57
+
58
+ def is_valid_project(
59
+ project: str | None,
60
+ projectroot: str,
61
+ export_ok: str,
62
+ strict_export: bool,
63
+ project_in_list: Callable[[str], bool],
64
+ ) -> bool:
65
+ """Pathname valid, dir exists, export_ok, and (if strict) in project list. Port of is_valid_project."""
66
+ if project is None:
67
+ return False
68
+ if not is_valid_pathname(project):
69
+ return False
70
+ full = os.path.join(projectroot, project)
71
+ if not os.path.isdir(full):
72
+ return False
73
+ if not check_export_ok(full, export_ok):
74
+ return False
75
+ return not strict_export or project_in_list(project)