Skip to content

Commit 760769b

Browse files
committed
App basically works
Need to fix some typing errors still.
1 parent 206ee90 commit 760769b

File tree

6 files changed

+257
-28
lines changed

6 files changed

+257
-28
lines changed

poetry.lock

Lines changed: 14 additions & 2 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: 104 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1+
import mimetypes
12
import os.path
23

34
from starlette.applications import Starlette
45
from starlette.staticfiles import StaticFiles
56
from starlette.requests import Request
7+
from starlette.responses import Response
68
from starlette.responses import PlainTextResponse
9+
from starlette.responses import StreamingResponse
710
from starlette.routing import Route
811
from starlette.templating import Jinja2Templates
912

13+
from pypi_view import packaging
1014
from pypi_view import pypi
1115

16+
PACKAGE_TYPE_NOT_SUPPORTED_ERROR = (
17+
"Sorry, this package type is not yet supported (only .zip and .whl supported currently)."
18+
)
19+
1220

1321
install_root = os.path.dirname(__file__)
1422

@@ -21,37 +29,112 @@
2129

2230

2331
@app.route('/')
24-
async def home(request: Request) -> templates.TemplateResponse:
32+
async def home(request: Request) -> Response:
2533
return templates.TemplateResponse("home.html", {"request": request})
2634

2735

2836
@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)
37+
async def package(request: Request) -> Response:
38+
package_name = request.path_params["package"]
39+
try:
40+
version_to_files = await pypi.files_for_package(package_name)
41+
except pypi.PackageDoesNotExist:
42+
return PlainTextResponse(
43+
f"Package {package_name!r} does not exist on PyPI.",
44+
status_code=404,
45+
)
46+
else:
47+
return templates.TemplateResponse(
48+
"package.html",
49+
{
50+
"request": request,
51+
"package": package_name,
52+
"version_to_files": version_to_files,
53+
},
54+
)
55+
56+
57+
@app.route('/package/{package}/{filename}')
58+
async def package_file(request: Request) -> Response:
59+
package_name = request.path_params["package"]
60+
file_name = request.path_params["filename"]
61+
try:
62+
archive = await pypi.downloaded_file_path(package_name, file_name)
63+
except pypi.PackageDoesNotExist:
64+
return PlainTextResponse(
65+
f"Package {package_name!r} does not exist on PyPI.",
66+
status_code=404,
67+
)
68+
except pypi.CannotFindFileError:
69+
return PlainTextResponse(
70+
f"File {file_name!r} does not exist for package {package_name!r}.",
71+
status_code=404,
72+
)
73+
74+
try:
75+
package = packaging.Package.from_path(archive)
76+
except packaging.UnsupportedPackageType:
77+
return PlainTextResponse(
78+
PACKAGE_TYPE_NOT_SUPPORTED_ERROR,
79+
status_code=501,
80+
)
81+
82+
entries = await package.entries()
3283
return templates.TemplateResponse(
33-
"package.html",
84+
"package_file.html",
3485
{
3586
"request": request,
36-
"package": package,
37-
"version_to_files": version_to_files,
87+
"package": package_name,
88+
"filename": file_name,
89+
"entries": entries,
3890
},
3991
)
4092

4193

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-
)
94+
@app.route('/package/{package}/{filename}/{archive_path:path}')
95+
async def package_file_archive_path(request: Request) -> Response:
96+
package_name = request.path_params["package"]
97+
file_name = request.path_params["filename"]
98+
archive_path = request.path_params["archive_path"]
99+
try:
100+
archive = await pypi.downloaded_file_path(package_name, file_name)
101+
except pypi.PackageDoesNotExist:
102+
return PlainTextResponse(
103+
f"Package {package_name!r} does not exist on PyPI.",
104+
status_code=404,
105+
)
106+
except pypi.CannotFindFileError:
107+
return PlainTextResponse(
108+
f"File {file_name!r} does not exist for package {package_name!r}.",
109+
status_code=404,
110+
)
111+
try:
112+
package = packaging.Package.from_path(archive)
113+
except packaging.UnsupportedPackageType:
114+
return PlainTextResponse(
115+
PACKAGE_TYPE_NOT_SUPPORTED_ERROR,
116+
status_code=501,
117+
)
50118

119+
entries = await package.entries()
120+
matching_entries = [entry for entry in entries if entry.path == archive_path]
121+
if len(matching_entries) == 0:
122+
return PlainTextResponse(
123+
f"Path {archive_path!r} does not exist in archive.",
124+
status_code=404,
125+
)
126+
entry = matching_entries[0]
51127

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()
128+
async def transfer_file():
129+
async with package.open_from_archive(archive_path) as f:
130+
data = None
131+
while data is None or len(data) > 0:
132+
data = await f.read(1024)
133+
yield data
134+
135+
mimetype, _ = mimetypes.guess_type(archive_path)
136+
return StreamingResponse(
137+
transfer_file(),
138+
media_type=mimetype or "text/plain",
139+
headers={"Content-Length": str(entry.size)},
140+
)

pypi_view/packaging.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import asyncio
2+
import base64
3+
import contextlib
4+
import enum
5+
import os.path
6+
import typing
7+
import zipfile
8+
from dataclasses import dataclass
9+
10+
11+
class UnsupportedPackageType(Exception):
12+
pass
13+
14+
15+
class PackageType(enum.Enum):
16+
SDIST = enum.auto()
17+
WHEEL = enum.auto()
18+
19+
20+
class PackageFormat(enum.Enum):
21+
ZIPFILE = enum.auto()
22+
TARBALL = enum.auto()
23+
TARBALL_GZ = enum.auto()
24+
TARBALL_BZ2 = enum.auto()
25+
26+
27+
@dataclass(frozen=True)
28+
class PackageEntry:
29+
path: str
30+
size: int
31+
32+
33+
def _package_entries_from_zipfile(path: str) -> typing.Set[PackageEntry]:
34+
with zipfile.ZipFile(path) as zf:
35+
return {
36+
PackageEntry(
37+
path=entry.filename,
38+
size=entry.file_size,
39+
)
40+
for entry in zf.infolist()
41+
}
42+
43+
44+
ArchiveFile = typing.Union[zipfile.ZipExtFile]
45+
46+
47+
class AsyncArchiveFile:
48+
49+
file_: ArchiveFile
50+
51+
def __init__(self, file_: ArchiveFile) -> None:
52+
self.file_ = file_
53+
54+
async def __aenter__(self) -> "AsyncArchiveFile":
55+
return self
56+
57+
async def __aexit__(self, exc_t, exc_v, exc_tb) -> None:
58+
await asyncio.to_thread(self.file_.close)
59+
60+
async def read(self, n_bytes: typing.Optional[int]) -> bytes:
61+
return await asyncio.to_thread(self.file_.read, n_bytes)
62+
63+
64+
@dataclass(frozen=True)
65+
class Package:
66+
package_type: PackageType
67+
package_format: PackageFormat
68+
path: str
69+
70+
@classmethod
71+
def from_path(cls, path: str) -> "Package":
72+
name = base64.b64decode(os.path.basename(path).encode("ascii")).decode("utf8")
73+
74+
if name.endswith(".whl"):
75+
package_type = PackageType.WHEEL
76+
package_format = PackageFormat.ZIPFILE
77+
elif name.endswith(".zip"):
78+
package_type = PackageType.SDIST
79+
package_format = PackageFormat.ZIPFILE
80+
else:
81+
# TODO: Add support for tarballs
82+
raise UnsupportedPackageType(name)
83+
84+
return cls(
85+
package_type=package_type,
86+
package_format=package_format,
87+
path=path,
88+
)
89+
90+
async def entries(self) -> typing.Set[PackageEntry]:
91+
if self.package_format is PackageFormat.ZIPFILE:
92+
return await asyncio.to_thread(_package_entries_from_zipfile, self.path)
93+
else:
94+
raise AssertionError(self.package_format)
95+
96+
@contextlib.asynccontextmanager
97+
async def open_from_archive(self, path: str) -> str:
98+
if self.package_format is PackageFormat.ZIPFILE:
99+
zf = await asyncio.to_thread(zipfile.ZipFile, self.path)
100+
archive_file = await asyncio.to_thread(zf.open, path)
101+
try:
102+
async with AsyncArchiveFile(archive_file) as zip_archive_file:
103+
yield zip_archive_file
104+
finally:
105+
await asyncio.to_thread(zf.close)
106+
else:
107+
raise AssertionError(self.package_format)

pypi_view/pypi.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import typing
66

77
import aiofiles
8+
import aiofiles.os
89
import httpx
910

1011

@@ -13,8 +14,14 @@
1314
STORAGE_DIR = '/home/ckuehl/tmp/pypi-view'
1415

1516

17+
class PackageDoesNotExist(Exception):
18+
pass
19+
20+
1621
async def package_metadata(client: httpx.AsyncClient, package: str) -> typing.Dict:
1722
resp = await client.get(f'{PYPI}/pypi/{package}/json')
23+
if resp.status_code == 404:
24+
raise PackageDoesNotExist(package)
1825
resp.raise_for_status()
1926
return resp.json()
2027

@@ -48,11 +55,11 @@ async def _atomic_file(path: str, mode: str = 'w') -> typing.Any:
4855
try:
4956
yield f
5057
except:
51-
os.remove(f.name)
58+
await aiofiles.os.remove(f.name)
5259
raise
5360
else:
5461
# This is atomic since the temporary file was created in the same directory.
55-
os.rename(f.name, path)
62+
await aiofiles.os.rename(f.name, path)
5663

5764

5865
async def downloaded_file_path(package: str, filename: str) -> str:
@@ -62,7 +69,7 @@ async def downloaded_file_path(package: str, filename: str) -> str:
6269
it and may take a while.
6370
"""
6471
stored_path = _storage_path(package, filename)
65-
if os.path.exists(stored_path):
72+
if await aiofiles.os.path.exists(stored_path):
6673
return stored_path
6774

6875
async with httpx.AsyncClient() as client:
@@ -78,7 +85,7 @@ async def downloaded_file_path(package: str, filename: str) -> str:
7885
else:
7986
raise CannotFindFileError(package, filename)
8087

81-
os.makedirs(os.path.dirname(stored_path), exist_ok=True)
88+
await aiofiles.os.makedirs(os.path.dirname(stored_path), exist_ok=True)
8289

8390
async with _atomic_file(stored_path, 'wb') as f:
8491
async with client.stream('GET', url) as resp:

pypi_view/templates/package_file.html

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

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ readme = "README.md"
88
packages = [{include = "pypi_view"}]
99

1010
[tool.poetry.dependencies]
11-
python = "^3.8"
11+
python = "^3.9"
1212
starlette = "*"
1313
fluffy-code = "^0.0.0"
1414
Jinja2 = "^3.1.2"
@@ -22,6 +22,7 @@ pre-commit = "^2.20.0"
2222
pytest = "^7.1.3"
2323
mypy = "^0.971"
2424
coverage = "^6.4.4"
25+
types-aiofiles = "^22.1.0"
2526

2627
[build-system]
2728
requires = ["poetry-core"]

0 commit comments

Comments
 (0)