Skip to content

Commit a93af47

Browse files
committed
Bootstrap on package view page
1 parent 065c91d commit a93af47

File tree

11 files changed

+138
-40
lines changed

11 files changed

+138
-40
lines changed

pypi_view/app.py

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import email
22
import io
3+
import itertools
34
import mimetypes
45
import os.path
56
import re
67

78
import fluffy_code.code
89
import fluffy_code.prebuilt_styles
10+
import packaging.version
911
import pygments.lexers.special
1012
from identify import identify
1113
from starlette.applications import Starlette
@@ -19,13 +21,18 @@
1921
from starlette.staticfiles import StaticFiles
2022
from starlette.templating import Jinja2Templates
2123

22-
from pypi_view import packaging
24+
import pypi_view.packaging
2325
from pypi_view import pypi
2426

2527
PACKAGE_TYPE_NOT_SUPPORTED_ERROR = (
2628
'Sorry, this package type is not yet supported (only .zip and .whl supported currently).'
2729
)
28-
TEXT_RENDER_FILESIZE_LIMIT = 20 * 1024 # 20 KiB
30+
31+
ONE_KB = 2**10
32+
ONE_MB = 2**20
33+
ONE_GB = 2**30
34+
35+
TEXT_RENDER_FILESIZE_LIMIT = 50 * ONE_KB
2936

3037
# Mime types which are allowed to be presented as detected.
3138
# TODO: I think we actually only need to prevent text/html (and any HTML
@@ -81,6 +88,25 @@ async def dispatch(self, request, call_next):
8188
)
8289

8390

91+
def _pluralize(n: int) -> str:
92+
return '' if n == 1 else 's'
93+
94+
95+
def _human_size(size: int) -> str:
96+
if size >= ONE_GB:
97+
return f'{size / ONE_GB:.1f} GiB'
98+
elif size >= ONE_MB:
99+
return f'{size / ONE_MB:.1f} MiB'
100+
elif size >= ONE_KB:
101+
return f'{size / ONE_KB:.1f} KiB'
102+
else:
103+
return '{} {}{}'.format(size, 'byte', _pluralize(size))
104+
105+
106+
templates.env.filters['human_size'] = _human_size
107+
templates.env.filters['pluralize'] = _pluralize
108+
109+
84110
@app.route('/')
85111
async def home(request: Request) -> Response:
86112
return templates.TemplateResponse('home.html', {'request': request})
@@ -89,6 +115,10 @@ async def home(request: Request) -> Response:
89115
@app.route('/package/{package}')
90116
async def package(request: Request) -> Response:
91117
package_name = request.path_params['package']
118+
normalized_package_name = pypi_view.packaging.pep426_normalize(package_name)
119+
if package_name != normalized_package_name:
120+
return RedirectResponse(request.url_for('package', package=normalized_package_name))
121+
92122
try:
93123
version_to_files = await pypi.files_for_package(package_name)
94124
except pypi.PackageDoesNotExist:
@@ -97,12 +127,18 @@ async def package(request: Request) -> Response:
97127
status_code=404,
98128
)
99129
else:
130+
version_to_files_sorted = sorted(
131+
version_to_files.items(),
132+
key=lambda item: packaging.version.parse(item[0]),
133+
reverse=True,
134+
)
100135
return templates.TemplateResponse(
101136
'package.html',
102137
{
103138
'request': request,
104139
'package': package_name,
105-
'version_to_files': version_to_files,
140+
'version_to_files': version_to_files_sorted,
141+
'total_files': len(set(itertools.chain.from_iterable(version_to_files.values()))),
106142
},
107143
)
108144

@@ -111,6 +147,17 @@ async def package(request: Request) -> Response:
111147
async def package_file(request: Request) -> Response:
112148
package_name = request.path_params['package']
113149
file_name = request.path_params['filename']
150+
151+
normalized_package_name = pypi_view.packaging.pep426_normalize(package_name)
152+
if package_name != normalized_package_name:
153+
return RedirectResponse(
154+
request.url_for(
155+
'package_file',
156+
package=normalized_package_name,
157+
filename=file_name,
158+
),
159+
)
160+
114161
try:
115162
archive = await pypi.downloaded_file_path(package_name, file_name)
116163
except pypi.PackageDoesNotExist:
@@ -125,8 +172,8 @@ async def package_file(request: Request) -> Response:
125172
)
126173

127174
try:
128-
package = packaging.Package.from_path(archive)
129-
except packaging.UnsupportedPackageType:
175+
package = pypi_view.packaging.Package.from_path(archive)
176+
except pypi_view.packaging.UnsupportedPackageType:
130177
return PlainTextResponse(
131178
PACKAGE_TYPE_NOT_SUPPORTED_ERROR,
132179
status_code=501,
@@ -173,6 +220,18 @@ async def package_file_archive_path(request: Request) -> Response:
173220
package_name = request.path_params['package']
174221
file_name = request.path_params['filename']
175222
archive_path = request.path_params['archive_path']
223+
224+
normalized_package_name = pypi_view.packaging.pep426_normalize(package_name)
225+
if package_name != normalized_package_name:
226+
return RedirectResponse(
227+
request.url_for(
228+
'package_file_archive_path',
229+
package=normalized_package_name,
230+
filename=file_name,
231+
archive_path=archive_path,
232+
),
233+
)
234+
176235
try:
177236
archive = await pypi.downloaded_file_path(package_name, file_name)
178237
except pypi.PackageDoesNotExist:
@@ -186,8 +245,8 @@ async def package_file_archive_path(request: Request) -> Response:
186245
status_code=404,
187246
)
188247
try:
189-
package = packaging.Package.from_path(archive)
190-
except packaging.UnsupportedPackageType:
248+
package = pypi_view.packaging.Package.from_path(archive)
249+
except pypi_view.packaging.UnsupportedPackageType:
191250
return PlainTextResponse(
192251
PACKAGE_TYPE_NOT_SUPPORTED_ERROR,
193252
status_code=501,

pypi_view/packaging.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
import contextlib
44
import enum
55
import os.path
6+
import re
67
import typing
78
import zipfile
89
from dataclasses import dataclass
910

1011

12+
def pep426_normalize(package_name: str) -> str:
13+
return re.sub(r'[-_.]+', '-', package_name).lower()
14+
15+
1116
class UnsupportedPackageType(Exception):
1217
pass
1318

pypi_view/pypi.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ async def files_for_package(package: str) -> typing.Dict[str, typing.Set[str]]:
3232
return {
3333
version: {file_['filename'] for file_ in files}
3434
for version, files in metadata['releases'].items()
35+
if len(files) > 0
3536
}
3637

3738

pypi_view/static/site.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
body {
1+
.page-package-file .metadata {
2+
width: auto;
23
}

pypi_view/templates/base.html renamed to pypi_view/templates/_base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
</div>
3030
</nav>
3131

32-
<div class="container">
32+
<div class="container mt-4">
3333
{% block content %}{% endblock %}
3434
</div>
3535

pypi_view/templates/_macros.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{% macro package_type_pill(filename) %}
2+
{% if filename.endswith(".whl") %}
3+
<span class="badge text-bg-success">Wheel</span>
4+
{% elif filename.endswith(".egg") %}
5+
<span class="badge text-bg-warning">Egg</span>
6+
{% else %}
7+
<span class="badge text-bg-secondary">Source distribution</span>
8+
{% endif %}
9+
{% endmacro %}
10+
11+
{# vim: ft=jinja
12+
#}

pypi_view/templates/home.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{% set page = 'home' %}
2-
{% extends 'base.html' %}
2+
{% extends '_base.html' %}
33

44
{% block title %}PyPi View{% endblock %}
55

pypi_view/templates/package.html

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
{% set page = 'package' %}
2-
{% extends 'base.html' %}
2+
{% extends '_base.html' %}
3+
{% import '_macros.html' as macros %}
34

45
{% block title %}{{package}} | PyPi View{% endblock %}
56

67
{% block content %}
78
<h1>{{package}}</h1>
8-
{% for version in version_to_files|sort %}
9-
<h2>{{version}}</h2>
10-
<ul>
11-
{% for file in version_to_files[version]|sort %}
12-
<li>
13-
<a href="{{url_for('package_file', package=package, filename=file)}}">{{file}}</a>
14-
</li>
15-
{% endfor %}
16-
</ul>
9+
<p>
10+
<tt>{{package}}</tt> has {{version_to_files|length}} version{{version_to_files|length|pluralize}} and {{total_files}} file{{total_files|pluralize}}.
11+
Select a file below to explore it.
12+
</p>
13+
{% for version, files in version_to_files %}
14+
<div class="card bg-light mb-3">
15+
<div class="card-header"><h5 class="mb-0">{{version}}</h5></div>
16+
<div class="list-group list-group-flush">
17+
{% for file in files|sort %}
18+
<a class="list-group-item list-group-item-action" href="{{url_for('package_file', package=package, filename=file)}}">
19+
<span class="font-monospace small">{{file}}</span>
20+
{{macros.package_type_pill(file)}}
21+
</a>
22+
{% endfor %}
23+
</div>
24+
</div>
1725
{% endfor %}
1826
{% endblock %}
1927

pypi_view/templates/package_file.html

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,52 @@
11
{% set page = 'package-file' %}
22
{% set selected_package_name = package %}
3-
{% extends 'base.html' %}
3+
{% extends '_base.html' %}
4+
{% import '_macros.html' as macros %}
45

56
{% block title %}{{filename}} | {{package}} | PyPi View{% endblock %}
67

78
{% block content %}
8-
<h1>{{filename}}</h1>
9+
<h1><tt>{{filename}}</tt></h1>
10+
<h5>{{macros.package_type_pill(filename)}}</h5>
911

1012
{% if metadata_path %}
11-
<h2>Metadata</h2>
12-
<table border="1">
13+
<h4>Package Metadata</h4>
14+
<table class="table table-bordered table-sm small metadata">
15+
<caption>
16+
Metadata parsed from
17+
<a class="font-monospace" href="{{url_for('package_file_archive_path', package=package, filename=filename, archive_path=metadata_path)}}">
18+
{{metadata_path}}
19+
</a>
20+
</caption>
1321
{% for key, values in metadata.items()|sort %}
1422
{% for value in values %}
1523
<tr>
1624
{% if loop.index == 1 %}
1725
<th rowspan="{{values|length}}">{{key}}</th>
1826
{% endif %}
19-
<td>{{value}}</td>
27+
<td class="font-monospace">{{value}}</td>
2028
</tr>
2129
{% endfor %}
2230
{% endfor %}
2331
</table>
24-
<a href="{{url_for('package_file_archive_path', package=package, filename=filename, archive_path=metadata_path)}}">
25-
View Raw Metadata
26-
</a>
2732
{% endif %}
2833

29-
<h2>Files</h2>
30-
<ul>
31-
{% for entry in entries|sort(attribute="path") %}
32-
<li>
34+
<h4>Package Files</h4>
35+
<div class="card bg-light mb-3">
36+
<div class="list-group list-group-flush">
37+
{% for entry in entries|sort(attribute="path") %}
3338
<a
39+
class="list-group-item list-group-item-action small"
3440
href="{{url_for('package_file_archive_path', package=package, filename=filename, archive_path=entry.path)}}"
35-
>{{entry.path}}</a>
36-
({{entry.size}} bytes)
37-
</li>
38-
{% endfor %}
39-
</ul>
41+
>
42+
<span class="font-monospace">
43+
{{entry.path}}
44+
</span>
45+
{{entry.size|human_size}}
46+
</a>
47+
{% endfor %}
48+
</div>
49+
</div>
4050
{% endblock %}
4151

4252
{# vim: ft=jinja

pypi_view/templates/package_file_archive_path.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{% set page = 'package-file-archive-path' %}
22
{% set selected_package_name = package %}
3-
{% extends 'base.html' %}
3+
{% extends '_base.html' %}
44

55
{% block title %}{{archive_path}} | {{filename}} | {{package}} | PyPi View{% endblock %}
66

@@ -19,7 +19,9 @@
1919
{% block content %}
2020
<h1>{{package}}: {{filename}}</h1>
2121
<h2>{{archive_path}}</h2>
22-
{{rendered_text|safe}}
22+
<div class="font-monospace">
23+
{{rendered_text|safe}}
24+
</div>
2325
<p><a href="?raw">View Raw</a></p>
2426
{% endblock %}
2527

0 commit comments

Comments
 (0)