Skip to content

Commit 5451b79

Browse files
committed
Update from 50e7a22
1 parent e3c3a4b commit 5451b79

21 files changed

+926
-174
lines changed

javascript/scss/app.scss

+21
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,24 @@ a.status-badge--bad {
124124
a.status-badge--good {
125125
background-color: #148024;
126126
}
127+
128+
img {
129+
max-width: 100%;
130+
}
131+
132+
133+
.link-unstyled, .link-unstyled:link, .link-unstyled:hover {
134+
color: inherit;
135+
text-decoration: inherit;
136+
}
137+
138+
139+
.base-color {
140+
background: $base-color !important;
141+
color: white !important;
142+
}
143+
144+
.secondary-color {
145+
background: $base-color2 !important;
146+
color: white !important;
147+
}

pyproject.toml

+7
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,10 @@ requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2"]
1010

1111
[tool.setuptools_scm]
1212
write_to = "simple_repository_browser/_version.py"
13+
14+
[[tool.mypy.overrides]]
15+
module = [
16+
"diskcache",
17+
"parsley",
18+
]
19+
ignore_missing_imports = true

setup.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
REQUIREMENTS: dict = {
1818
'core': [
19-
'aiohttp',
19+
'httpx',
2020
'aiosqlite',
2121
'diskcache',
2222
'docutils',
@@ -29,7 +29,7 @@
2929
'parsley',
3030
'pkginfo',
3131
'readme-renderer[md]',
32-
'simple-repository',
32+
'simple-repository>=0.4',
3333
'uvicorn',
3434
],
3535
'test': [
@@ -38,6 +38,7 @@
3838
'dev': [
3939
'build',
4040
'pre-commit',
41+
'types-setuptools',
4142
],
4243
}
4344

@@ -46,7 +47,10 @@
4647
name='simple-repository-browser',
4748

4849
author="CERN, BE-CSS-SET",
49-
description='A web interface to browse and search packages in any simple package repository (PEP-503), inspired by PyPI / warehouse',
50+
description=(
51+
'A web interface to browse and search packages in any simple package '
52+
'repository (PEP-503), inspired by PyPI / warehouse'
53+
),
5054
long_description=LONG_DESCRIPTION,
5155
long_description_content_type='text/markdown',
5256
url="https://github.com/simple-repository/simple-repository-browser",

simple_repository_browser/__main__.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,22 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
3232
parser.add_argument("--cache-dir", type=str, default=Path(os.environ.get('XDG_CACHE_DIR', Path.home() / '.cache')) / 'simple-repository-browser')
3333
parser.add_argument("--url-prefix", type=str, default="")
3434
parser.add_argument('--no-popular-project-crawl', dest='crawl_popular_projects', action='store_false', default=True)
35+
parser.add_argument('--templates-dir', default=here / "templates", type=Path)
3536

3637

3738
def handler(args: typing.Any) -> None:
3839
app = AppBuilder(
3940
index_url=args.index_url,
4041
cache_dir=Path(args.cache_dir),
41-
template_paths=[here / "templates", here / "templates" / "base"],
42+
template_paths=[
43+
args.templates_dir,
44+
# Include the base templates so that the given templates directory doesn't have to
45+
# implement *all* of the templates. This must be at a lower precedence than the given
46+
# templates path, so that they can be overriden.
47+
here/"templates"/"base",
48+
# Include the "base" folder, such that upstream templates can inherit from "base/...".
49+
here/"templates",
50+
],
4251
static_files_path=here / "static",
4352
crawl_popular_projects=args.crawl_popular_projects,
4453
url_prefix=args.url_prefix,

simple_repository_browser/_app.py

+10-9
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
import typing
1111
from pathlib import Path
1212

13-
import aiohttp
1413
import aiosqlite
1514
import diskcache
1615
import fastapi
16+
import httpx
17+
from simple_repository import SimpleRepository
1718
from simple_repository.components.http import HttpRepository
1819

1920
from . import controller, crawler, errors, fetch_projects, model, view
@@ -54,12 +55,12 @@ def create_app(self) -> fastapi.FastAPI:
5455

5556
async def lifespan(app: fastapi.FastAPI):
5657
async with (
57-
aiohttp.ClientSession() as session,
58+
httpx.AsyncClient() as http_client,
5859
aiosqlite.connect(self.db_path, timeout=5) as db,
5960
):
6061
_controller = self.create_controller(
6162
model=self.create_model(
62-
session=session,
63+
http_client=http_client,
6364
database=db,
6465
),
6566
view=_view,
@@ -100,29 +101,29 @@ async def catch_exceptions_middleware(request: fastapi.Request, call_next):
100101
def create_view(self) -> view.View:
101102
return view.View(self.template_paths, self.browser_version)
102103

103-
def create_crawler(self, session: aiohttp.ClientSession, source: HttpRepository) -> crawler.Crawler:
104+
def create_crawler(self, http_client: httpx.AsyncClient, source: SimpleRepository) -> crawler.Crawler:
104105
return crawler.Crawler(
105-
session=session,
106+
http_client=http_client,
106107
crawl_popular_projects=self.crawl_popular_projects,
107108
source=source,
108109
projects_db=self.con,
109110
cache=self.cache,
110111
)
111112

112-
def create_model(self, session: aiohttp.ClientSession, database: aiosqlite.Connection) -> model.Model:
113+
def create_model(self, http_client: httpx.AsyncClient, database: aiosqlite.Connection) -> model.Model:
113114
source = MetadataInjector(
114115
HttpRepository(
115116
url=self.index_url,
116-
session=session,
117+
http_client=http_client,
117118
),
118119
database=database,
119-
session=session,
120+
http_client=http_client,
120121
)
121122
return model.Model(
122123
source=source,
123124
projects_db=self.con,
124125
cache=self.cache,
125-
crawler=self.create_crawler(session, source),
126+
crawler=self.create_crawler(http_client, source),
126127
)
127128

128129
def create_controller(self, view: view.View, model: model.Model) -> controller.Controller:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Copyright (C) 2023, CERN
2+
# This software is distributed under the terms of the MIT
3+
# licence, copied verbatim in the file "LICENSE".
4+
# In applying this license, CERN does not waive the privileges and immunities
5+
# granted to it by virtue of its status as Intergovernmental Organization
6+
# or submit itself to any jurisdiction.
7+
8+
import dataclasses
9+
10+
from packaging.utils import parse_wheel_filename
11+
from packaging.version import Version
12+
from simple_repository import model
13+
14+
15+
@dataclasses.dataclass(frozen=True)
16+
class CompatibilityMatrixModel:
17+
matrix: dict[tuple[str, str], model.File]
18+
py_and_abi_names: tuple[str, ...]
19+
platform_names: tuple[str, ...]
20+
21+
22+
def compatibility_matrix(
23+
files: tuple[model.File, ...],
24+
) -> CompatibilityMatrixModel:
25+
"""
26+
Look at the given files, and compute a compatibility matrix.
27+
28+
"""
29+
compat_matrix: dict[tuple[str, str], model.File] = {}
30+
# Track the py_abi_names seen, and store a sort key for those names.
31+
py_abi_names = {}
32+
# Track the platform_names (we sort by name).
33+
platform_names = set()
34+
35+
interpreted_py_abi_tags: dict[tuple[str, str], InterpretedPyAndABITag] = {}
36+
37+
for file in files:
38+
if not file.filename.lower().endswith('.whl'):
39+
continue
40+
_, _, _, tags = parse_wheel_filename(file.filename)
41+
42+
# Ensure that the tags have a consistent sort order. From
43+
# packaging they come as a frozenset, so no such upstream guarantee is provided.
44+
sorted_tags = sorted(tags, key=lambda tag: (tag.platform, tag.abi, tag.interpreter))
45+
46+
for tag in sorted_tags:
47+
inter_abi_key = (tag.interpreter, tag.abi)
48+
if inter_abi_key not in interpreted_py_abi_tags:
49+
interpreted_py_abi_tags[inter_abi_key] = interpret_py_and_abi_tag(tag.interpreter, tag.abi)
50+
51+
tag_interp = interpreted_py_abi_tags[inter_abi_key]
52+
compat_matrix[(tag_interp.nice_name, tag.platform)] = file
53+
54+
# Track the seen tags, and define a sort order.
55+
py_abi_names[tag_interp.nice_name] = (
56+
tag_interp.python_implementation,
57+
tag_interp.python_version,
58+
tag_interp.nice_name,
59+
)
60+
platform_names.add(tag.platform)
61+
62+
r_plat_names = tuple(sorted(platform_names))
63+
r_py_abi_names = tuple(sorted(py_abi_names, key=py_abi_names.__getitem__))
64+
65+
return CompatibilityMatrixModel(compat_matrix, r_py_abi_names, r_plat_names)
66+
67+
68+
# https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/#python-tag
69+
py_tag_implementations = {
70+
'py': 'Python',
71+
'cp': 'CPython',
72+
'ip': 'IronPython',
73+
'pp': 'PyPy',
74+
'jy': 'Jython',
75+
}
76+
77+
78+
@dataclasses.dataclass(frozen=True)
79+
class InterpretedPyAndABITag:
80+
nice_name: str
81+
python_implementation: str | None = None
82+
python_version: Version | None = None
83+
84+
85+
def interpret_py_and_abi_tag(py_tag: str, abi_tag: str) -> InterpretedPyAndABITag:
86+
if py_tag[:2] in py_tag_implementations:
87+
py_impl, version_nodot = py_tag[:2], py_tag[2:]
88+
py_impl = py_tag_implementations.get(py_impl, py_impl)
89+
if '_' in version_nodot:
90+
py_version = Version('.'.join(version_nodot.split('_')))
91+
elif len(version_nodot) == 1:
92+
# e.g. Pure python wheels
93+
py_version = Version(version_nodot)
94+
else:
95+
py_version = Version(f'{version_nodot[0]}.{version_nodot[1:]}')
96+
97+
if abi_tag.startswith(py_tag):
98+
abi_tag_flags = abi_tag[len(py_tag):]
99+
if 'd' in abi_tag_flags:
100+
abi_tag_flags = abi_tag_flags.replace('d', '')
101+
py_impl += ' (debug)'
102+
if 'u' in abi_tag_flags:
103+
abi_tag_flags = abi_tag_flags.replace('u', '')
104+
# A python 2 concept.
105+
py_impl += ' (wide)'
106+
if 'm' in abi_tag_flags:
107+
abi_tag_flags = abi_tag_flags.replace('m', '')
108+
pass
109+
if abi_tag_flags:
110+
py_impl += f' (additional flags: {abi_tag_flags})'
111+
return InterpretedPyAndABITag(f'{py_impl} {py_version}', py_impl, py_version)
112+
elif abi_tag.startswith('pypy') and py_impl == 'PyPy':
113+
abi = abi_tag.split('_')[1]
114+
return InterpretedPyAndABITag(f'{py_impl} {py_version} ({abi})', py_impl, py_version)
115+
elif abi_tag == 'abi3':
116+
# Example PyQt6
117+
return InterpretedPyAndABITag(f'{py_impl} >={py_version} (abi3)', py_impl, py_version)
118+
elif abi_tag == 'none':
119+
# Seen with pydantic-core 2.11.0
120+
return InterpretedPyAndABITag(f'{py_impl} {py_version}', py_impl, py_version)
121+
else:
122+
return InterpretedPyAndABITag(f'{py_impl} {py_version} ({abi_tag})', py_impl, py_version)
123+
124+
return InterpretedPyAndABITag(f'{py_tag} ({abi_tag})')

simple_repository_browser/controller.py

+30-10
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# or submit itself to any jurisdiction.
77

88
import asyncio
9+
import dataclasses
910
import typing
1011
from enum import Enum
1112
from functools import partial
@@ -20,10 +21,18 @@
2021
from . import errors, model, view
2122

2223

24+
@dataclasses.dataclass(frozen=True)
25+
class Route:
26+
fn: typing.Callable
27+
methods: set[str]
28+
response_class: typing.Type
29+
kwargs: dict[str, typing.Any]
30+
31+
2332
class Router:
2433
# A class-level router definition, capable of generating an instance specific router with its "build_fastapi_router".
2534
def __init__(self):
26-
self._routes_register = {}
35+
self._routes_register: dict[str, Route] = {}
2736

2837
def route(
2938
self,
@@ -33,7 +42,7 @@ def route(
3342
**kwargs: typing.Any,
3443
):
3544
def dec(fn):
36-
self._routes_register[path] = (fn, methods, response_class, kwargs)
45+
self._routes_register[path] = Route(fn, methods, response_class, kwargs)
3746
return fn
3847
return dec
3948

@@ -49,11 +58,22 @@ def head(self, path: str, **kwargs: typing.Any):
4958
def build_fastapi_router(self, controller: "Controller") -> fastapi.APIRouter:
5059
router = fastapi.APIRouter()
5160
for path, route in self._routes_register.items():
52-
endpoint, methods, response_class, kwargs = route
53-
bound_endpoint = partial(endpoint, controller)
54-
router.add_api_route(path=path, endpoint=bound_endpoint, response_class=response_class, methods=methods, **kwargs)
61+
bound_endpoint = partial(route.fn, controller)
62+
router.add_api_route(
63+
path=path,
64+
endpoint=bound_endpoint,
65+
response_class=route.response_class,
66+
methods=list(route.methods),
67+
**route.kwargs,
68+
)
5569
return router
5670

71+
def __iter__(self) -> typing.Iterator[tuple[str, Route]]:
72+
return self._routes_register.items().__iter__()
73+
74+
def update(self, new_values: dict[str, Route]) -> None:
75+
self._routes_register.update(new_values)
76+
5777

5878
class ProjectPageSection(str, Enum):
5979
description = "description"
@@ -75,7 +95,7 @@ def create_router(self, static_file_path: Path) -> fastapi.APIRouter:
7595
return router
7696

7797
@router.get("/", name="index")
78-
async def index(self, request: fastapi.Request = None) -> str:
98+
async def index(self, request: fastapi.Request) -> str:
7999
return self.view.index_page(request)
80100

81101
@router.get("/about", name="about")
@@ -96,17 +116,17 @@ async def search(self, request: fastapi.Request, query: str, page: int = 1) -> s
96116
)
97117
return self.view.search_page(response, request)
98118

99-
@router.get("/project/{project_name}", name="project")
100-
@router.get("/project/{project_name}/{version}", name='project_version')
101-
@router.get("/project/{project_name}/{version}/{page_section}", name='project_version_section')
119+
@router.get("/project/{project_name}", name="project", response_model=None)
120+
@router.get("/project/{project_name}/{version}", name='project_version', response_model=None)
121+
@router.get("/project/{project_name}/{version}/{page_section}", name='project_version_section', response_model=None)
102122
async def project(
103123
self,
104124
request: fastapi.Request,
105125
project_name: str,
106126
version: str | None = None,
107127
page_section: ProjectPageSection | None = ProjectPageSection.description,
108128
recache: bool = False,
109-
) -> str:
129+
) -> str | StreamingResponse:
110130
_ = page_section # Handled in javascript.
111131
_version = None
112132
if version:

0 commit comments

Comments
 (0)