Skip to content

Commit 206ee90

Browse files
committed
File fetching from PyPI works
1 parent e7d8355 commit 206ee90

File tree

6 files changed

+239
-7
lines changed

6 files changed

+239
-7
lines changed

poetry.lock

Lines changed: 93 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pypi_view/app.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
from starlette.applications import Starlette
44
from starlette.staticfiles import StaticFiles
55
from starlette.requests import Request
6+
from starlette.responses import PlainTextResponse
67
from starlette.routing import Route
78
from starlette.templating import Jinja2Templates
89

10+
from pypi_view import pypi
11+
912

1013
install_root = os.path.dirname(__file__)
1114

@@ -20,3 +23,35 @@
2023
@app.route('/')
2124
async def home(request: Request) -> templates.TemplateResponse:
2225
return templates.TemplateResponse("home.html", {"request": request})
26+
27+
28+
@app.route('/package/{package}')
29+
async def package(request: Request) -> templates.TemplateResponse:
30+
package = request.path_params["package"]
31+
version_to_files = await pypi.files_for_package(package)
32+
return templates.TemplateResponse(
33+
"package.html",
34+
{
35+
"request": request,
36+
"package": package,
37+
"version_to_files": version_to_files,
38+
},
39+
)
40+
41+
42+
@app.route('/package/{package}/{filename}')
43+
async def package_file(request: Request) -> templates.TemplateResponse:
44+
package = request.path_params["package"]
45+
filename = request.path_params["filename"]
46+
archive = await pypi.downloaded_file_path(package, filename)
47+
return PlainTextResponse(
48+
f"Path: {archive} Size: {os.stat(archive)}",
49+
)
50+
51+
52+
@app.route('/package/{package}/{filename}/{archive_path:path}')
53+
async def package_file_archive_path(request: Request) -> templates.TemplateResponse:
54+
print(request.path_params["package"])
55+
print(request.path_params["filename"])
56+
print(request.path_params["archive_path"])
57+
raise NotImplementedError()

pypi_view/pypi.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import base64
2+
import contextlib
3+
import os.path
4+
import itertools
5+
import typing
6+
7+
import aiofiles
8+
import httpx
9+
10+
11+
# TODO: make configurable
12+
PYPI = 'https://pypi.org'
13+
STORAGE_DIR = '/home/ckuehl/tmp/pypi-view'
14+
15+
16+
async def package_metadata(client: httpx.AsyncClient, package: str) -> typing.Dict:
17+
resp = await client.get(f'{PYPI}/pypi/{package}/json')
18+
resp.raise_for_status()
19+
return resp.json()
20+
21+
22+
async def files_for_package(package: str) -> typing.Dict[str, typing.Set[str]]:
23+
async with httpx.AsyncClient() as client:
24+
metadata = await package_metadata(client, package)
25+
26+
return {
27+
version: {file_["filename"] for file_ in files}
28+
for version, files in metadata["releases"].items()
29+
}
30+
31+
32+
class CannotFindFileError(Exception):
33+
pass
34+
35+
36+
def _storage_path(package: str, filename: str) -> str:
37+
return os.path.join(
38+
STORAGE_DIR,
39+
# Base64-encoding the names to calculate the storage path just to be
40+
# extra sure to avoid any path traversal vulnerabilities.
41+
base64.urlsafe_b64encode(package.encode("utf8")).decode("ascii"),
42+
base64.urlsafe_b64encode(filename.encode("utf8")).decode("ascii"),
43+
)
44+
45+
@contextlib.asynccontextmanager
46+
async def _atomic_file(path: str, mode: str = 'w') -> typing.Any:
47+
async with aiofiles.tempfile.NamedTemporaryFile(mode, dir=os.path.dirname(path), delete=False) as f:
48+
try:
49+
yield f
50+
except:
51+
os.remove(f.name)
52+
raise
53+
else:
54+
# This is atomic since the temporary file was created in the same directory.
55+
os.rename(f.name, path)
56+
57+
58+
async def downloaded_file_path(package: str, filename: str) -> str:
59+
"""Return path on filesystem to downloaded PyPI file.
60+
61+
May be instant if the file is already cached; otherwise it will download
62+
it and may take a while.
63+
"""
64+
stored_path = _storage_path(package, filename)
65+
if os.path.exists(stored_path):
66+
return stored_path
67+
68+
async with httpx.AsyncClient() as client:
69+
metadata = await package_metadata(client, package)
70+
71+
# Parsing versions from non-wheel Python packages isn't perfectly
72+
# reliable, so just search through all releases until we find a
73+
# matching file.
74+
for file_ in itertools.chain.from_iterable(metadata["releases"].values()):
75+
if file_["filename"] == filename:
76+
url = file_["url"]
77+
break
78+
else:
79+
raise CannotFindFileError(package, filename)
80+
81+
os.makedirs(os.path.dirname(stored_path), exist_ok=True)
82+
83+
async with _atomic_file(stored_path, 'wb') as f:
84+
async with client.stream('GET', url) as resp:
85+
resp.raise_for_status()
86+
async for chunk in resp.aiter_bytes():
87+
await f.write(chunk)
88+
89+
return stored_path

pypi_view/templates/base.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
<link rel="stylesheet" href="{{url_for('static', path='/site.css')}}" />
55
</head>
66
<body class="page-{{page}}">
7-
<h1>hello!</h1>
8-
7+
{% block content %}{% endblock %}
98
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>
109
{% block extra_js %}{% endblock %}
1110
</body>

pypi_view/templates/package.html

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{% set page = 'package' %}
2+
{% extends 'base.html' %}
3+
4+
{% block content %}
5+
<h1>{{package}}</h1>
6+
{% for version in version_to_files|sort %}
7+
<h2>{{version}}</h2>
8+
<ul>
9+
{% for file in version_to_files[version]|sort %}
10+
<li>
11+
<a href="{{url_for('package_file', package=package, filename=file)}}">{{file}}</a>
12+
</li>
13+
{% endfor %}
14+
</ul>
15+
{% endfor %}
16+
{% endblock %}
17+
18+
{# vim: ft=jinja
19+
#}

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ python = "^3.8"
1212
starlette = "*"
1313
fluffy-code = "^0.0.0"
1414
Jinja2 = "^3.1.2"
15+
httpx = "^0.23.0"
16+
aiofiles = "^22.1.0"
1517

1618

1719
[tool.poetry.group.dev.dependencies]

0 commit comments

Comments
 (0)