├── .github └── workflows │ ├── python-app.yml │ └── python-publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── pyproject.toml └── simple_repository_server ├── __init__.py ├── __main__.py ├── _http_response_iterator.py ├── py.typed ├── routers ├── __init__.py └── simple.py ├── tests ├── __init__.py ├── api │ ├── __init__.py │ └── test_simple_router.py ├── integration │ ├── __init__.py │ └── test_repo_dependency_injection.py ├── test__http_response_iterator.py └── unit │ ├── __init__.py │ └── test_utils.py └── utils.py /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | name: Python application 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python 3.11 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: "3.11" 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install .[dev] mypy 26 | - name: Type check 27 | run: | 28 | python -m mypy ./simple_repository_server 29 | - name: Test with pytest 30 | run: | 31 | python -m pytest ./simple_repository_server 32 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package to PyPI when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | release-build: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.x" 28 | 29 | - name: Build release distributions 30 | run: | 31 | # NOTE: put your own distribution build steps here. 32 | python -m pip install build 33 | python -m build 34 | 35 | - name: Upload distributions 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: release-dists 39 | path: dist/ 40 | 41 | pypi-publish: 42 | runs-on: ubuntu-latest 43 | needs: 44 | - release-build 45 | permissions: 46 | # IMPORTANT: this permission is mandatory for trusted publishing 47 | id-token: write 48 | 49 | # Dedicated environments with protections for publishing are strongly recommended. 50 | # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules 51 | environment: 52 | name: pypi 53 | url: https://pypi.org/p/simple-repository-server 54 | 55 | steps: 56 | - name: Retrieve release distributions 57 | uses: actions/download-artifact@v4 58 | with: 59 | name: release-dists 60 | path: dist/ 61 | 62 | - name: Publish release distributions to PyPI 63 | uses: pypa/gh-action-pypi-publish@release/v1 64 | with: 65 | packages-dir: dist/ 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | simple_repository_server/_version.py 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 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 | repos: 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: "v4.4.0" 11 | hooks: 12 | - id: trailing-whitespace 13 | - id: end-of-file-fixer 14 | - id: check-yaml 15 | - id: check-toml 16 | 17 | - repo: https://github.com/asottile/add-trailing-comma 18 | rev: "v2.4.0" 19 | hooks: 20 | - id: add-trailing-comma 21 | args: 22 | - "--py36-plus" 23 | 24 | - repo: https://github.com/PyCQA/isort 25 | rev: "5.12.0" 26 | hooks: 27 | - id: isort 28 | 29 | - repo: https://github.com/pycqa/flake8 30 | rev: "6.0.0" 31 | hooks: 32 | - id: flake8 33 | args: 34 | - "--max-line-length=120" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 simple-repository 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # simple-repository-server 2 | 3 | A tool for running a PEP-503 simple Python package repository, including features such as dist metadata (PEP-658) and JSON API (PEP-691) 4 | 5 | ## Installation 6 | 7 | ```bash 8 | python -m pip install simple-repository-server 9 | ``` 10 | 11 | ## Usage 12 | 13 | The ``simple-repository-server`` is designed to be used as a library, but also includes a convenient command line interface for running 14 | a simple repository service: 15 | 16 | ```bash 17 | $ simple-repository-server --help 18 | usage: simple-repository-server [-h] [--port PORT] repository-url [repository-url ...] 19 | 20 | Run a Simple Repository Server 21 | 22 | positional arguments: 23 | repository-url 24 | 25 | options: 26 | -h, --help show this help message and exit 27 | --port PORT 28 | ``` 29 | 30 | The simplest example of this is to simply mirror the Python Package Index: 31 | 32 | ```bash 33 | python -m simple_repository_server https://pypi.org/simple/ 34 | ``` 35 | 36 | However, if multiple repositories are provided, the ``PrioritySelectedProjectsRepository`` component will be used to 37 | combine them together in a way that mitigates the [dependency confusion attack](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610). 38 | 39 | The server handles PEP-691 content negotiation to serve either HTML or JSON formats. 40 | Per PEP-691, the default (fallback) content type is HTML, but a JSON response can 41 | be previewed in the browser by adding the ``?format=application/vnd.pypi.simple.v1+json`` 42 | querystring to any of the repository URLs. 43 | 44 | The server has been configured to include PEP-658 metadata, even if the upstream repository does 45 | not include such metadata. This is done on the fly, and as a result the distribution will be 46 | temporarily downloaded to the server in order to extract and serve the metadata. 47 | 48 | It is possible to use the resulting repository as input for the 49 | [``simple-repository-browser``](https://github.com/simple-repository/simple-repository-browser), which 50 | offers a web interface to browse and search packages in any simple package repository (PEP-503), 51 | inspired by PyPI / warehouse. 52 | 53 | It is expected that as new features appear in the underlying ``simple-repository`` library, those 54 | which make general sense to enable by default will be introduced into the CLI without providing a 55 | mechanism to disable those features. For more control, please see the "Non CLI usage" section. 56 | 57 | ## Non CLI usage 58 | 59 | This project provides a number of tools in order to build a repository service using FastAPI. 60 | For cases when control of the repository configuration is required, and where details of the 61 | ASGI environment need more precise control, it is expected that ``simple-repository-server`` is used 62 | as a library instead of a CLI. 63 | 64 | Currently, the API for this functionality is under development, and will certainly change in the 65 | future. 66 | 67 | ## License and Support 68 | 69 | This code has been released under the MIT license. 70 | It is an initial prototype which is developed in-house, and _not_ currently openly developed. 71 | 72 | It is hoped that the release of this prototype will trigger interest from other parties that have similar needs. 73 | With sufficient collaborative interest there is the potential for the project to be openly 74 | developed, and to power Python package repositories across many domains. 75 | 76 | Please get in touch at https://github.com/orgs/simple-repository/discussions to share how 77 | this project may be useful to you. This will help us to gauge the level of interest and 78 | provide valuable insight when deciding whether to commit future resources to the project. 79 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 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 | [build-system] 9 | requires = ["setuptools>=61", "setuptools_scm>=8"] 10 | build-backend = "setuptools.build_meta" 11 | 12 | [project] 13 | name = "simple-repository-server" 14 | dynamic = ["version"] 15 | requires-python = ">=3.11" 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Programming Language :: Python :: 3", 19 | "Framework :: FastAPI", 20 | "Operating System :: OS Independent", 21 | "Typing :: Typed", 22 | ] 23 | authors = [ 24 | {name = "Phil Elson"}, 25 | {name = "Ivan Sinkarenko"}, 26 | {name = "Francesco Iannaccone"}, 27 | {name = "Wouter Koorn"}, 28 | ] 29 | dependencies = [ 30 | "httpx", 31 | "fastapi>=0.100.0", 32 | "packaging", 33 | "uvicorn[standard]", 34 | "simple-repository>=0.6.0", 35 | ] 36 | readme = "README.md" 37 | description = "A tool for running a PEP-503 simple Python package repository, including features such as dist metadata (PEP-658) and JSON API (PEP-691)" 38 | 39 | [project.urls] 40 | Homepage = "https://github.com/simple-repository/simple-repository-server" 41 | 42 | [project.optional-dependencies] 43 | test = [ 44 | "pytest", 45 | "pytest_asyncio", 46 | "pytest_httpx", 47 | "starlette>=0.26.1", 48 | "pytest_httpserver", 49 | ] 50 | dev = [ 51 | "simple-repository-server[test]", 52 | ] 53 | 54 | [project.scripts] 55 | simple-repository-server = "simple_repository_server.__main__:main" 56 | [tool.setuptools_scm] 57 | version_file = "simple_repository_server/_version.py" 58 | 59 | [tool.isort] 60 | py_version = 39 61 | line_length = 100 62 | multi_line_output = 3 63 | include_trailing_comma = true 64 | force_grid_wrap = 0 65 | use_parentheses = true 66 | ensure_newline_before_comments = true 67 | force_sort_within_sections = true 68 | 69 | [tool.mypy] 70 | python_version = "3.11" 71 | exclude = "simple_repository_server/tests" 72 | ignore_missing_imports = false 73 | strict = true 74 | 75 | [tool.setuptools.packages.find] 76 | namespaces = false 77 | -------------------------------------------------------------------------------- /simple_repository_server/__init__.py: -------------------------------------------------------------------------------- 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 | from ._version import version as __version__ # noqa 9 | 10 | __all__ = ['__version__'] 11 | -------------------------------------------------------------------------------- /simple_repository_server/__main__.py: -------------------------------------------------------------------------------- 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 argparse 9 | from contextlib import asynccontextmanager 10 | import logging 11 | from pathlib import Path 12 | import typing 13 | from urllib.parse import urlparse 14 | 15 | import fastapi 16 | from fastapi import FastAPI 17 | import httpx 18 | from simple_repository.components.core import SimpleRepository 19 | from simple_repository.components.http import HttpRepository 20 | from simple_repository.components.local import LocalRepository 21 | from simple_repository.components.metadata_injector import MetadataInjectorRepository 22 | from simple_repository.components.priority_selected import PrioritySelectedProjectsRepository 23 | import uvicorn 24 | 25 | from simple_repository_server.routers import simple 26 | 27 | 28 | def is_url(url: str) -> bool: 29 | return urlparse(url).scheme in ("http", "https") 30 | 31 | 32 | def configure_parser(parser: argparse.ArgumentParser) -> None: 33 | parser.description = "Run a Python Package Index" 34 | 35 | parser.add_argument("--host", default="0.0.0.0") 36 | parser.add_argument("--port", type=int, default=8000) 37 | parser.add_argument("repository_url", metavar="repository-url", type=str, nargs="+") 38 | 39 | 40 | def create_repository( 41 | repository_urls: list[str], 42 | http_client: httpx.AsyncClient, 43 | ) -> SimpleRepository: 44 | base_repos: list[SimpleRepository] = [] 45 | repo: SimpleRepository 46 | for repo_url in repository_urls: 47 | if is_url(repo_url): 48 | repo = HttpRepository( 49 | url=repo_url, 50 | http_client=http_client, 51 | ) 52 | else: 53 | repo = LocalRepository( 54 | index_path=Path(repo_url), 55 | ) 56 | base_repos.append(repo) 57 | 58 | if len(base_repos) > 1: 59 | repo = PrioritySelectedProjectsRepository(base_repos) 60 | else: 61 | repo = base_repos[0] 62 | return MetadataInjectorRepository(repo, http_client) 63 | 64 | 65 | def create_app(repository_urls: list[str]) -> fastapi.FastAPI: 66 | @asynccontextmanager 67 | async def lifespan(app: FastAPI) -> typing.AsyncIterator[None]: 68 | async with httpx.AsyncClient() as http_client: 69 | repo = create_repository(repository_urls, http_client) 70 | app.include_router(simple.build_router(repo, http_client=http_client)) 71 | yield 72 | 73 | app = FastAPI( 74 | openapi_url=None, # Disables automatic OpenAPI documentation (Swagger & Redoc) 75 | lifespan=lifespan, 76 | ) 77 | return app 78 | 79 | 80 | def handler(args: typing.Any) -> None: 81 | host: str = args.host 82 | port: int = args.port 83 | repository_urls: list[str] = args.repository_url 84 | uvicorn.run( 85 | app=create_app(repository_urls), 86 | host=host, 87 | port=port, 88 | ) 89 | 90 | 91 | def main() -> None: 92 | logging.basicConfig(level=logging.INFO) 93 | parser = argparse.ArgumentParser() 94 | configure_parser(parser) 95 | args = parser.parse_args() 96 | handler(args) 97 | 98 | 99 | if __name__ == '__main__': 100 | main() 101 | -------------------------------------------------------------------------------- /simple_repository_server/_http_response_iterator.py: -------------------------------------------------------------------------------- 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 | from __future__ import annotations 9 | 10 | import typing 11 | 12 | import httpx 13 | 14 | 15 | class HttpResponseIterator: 16 | """ 17 | A class providing a generator to iterate over response body bytes from an httpx request. 18 | 19 | This class creates an iterator that allows you to iterate over the bytes of a response body 20 | obtained from an httpx request. Additionally, it provides access to the response status code 21 | and headers before the streaming response is constructed. It is particularly designed to be 22 | used with Starlette's streaming responses, enabling access to headers and status code before 23 | the response is returned by an API endpoint. The class will keep the httpx session alive 24 | until the entire response content is accessed. 25 | """ 26 | 27 | PROXIED_REQUEST_HEADERS = { 28 | 'accept', 29 | 'user-agent', 30 | 'accept-encoding', 31 | 'if-unmodified-since', 32 | 'if-range', 'if-none-match', 33 | 'if-modified-since', 34 | 'if-match', 35 | 'range', 36 | 'referer', 37 | } 38 | 39 | def __init__(self, http_client: httpx.AsyncClient, url: str): 40 | """ 41 | Do not call the constructor of this class directly. 42 | Use StreamResponseIterator.create_iterator. 43 | """ 44 | self.http_client = http_client 45 | self.url: str = url 46 | self.status_code: int 47 | self.headers: typing.Mapping[str, str] 48 | self._agen: typing.AsyncGenerator[bytes, None] 49 | 50 | def __aiter__(self) -> HttpResponseIterator: 51 | return self 52 | 53 | async def __anext__(self) -> bytes: 54 | return await self._agen.__anext__() 55 | 56 | @classmethod 57 | async def create_iterator( 58 | cls, 59 | http_client: httpx.AsyncClient, 60 | url: str, 61 | *, 62 | request_headers: typing.Mapping[str, str] | None = None, 63 | ) -> HttpResponseIterator: 64 | iterator = HttpResponseIterator( 65 | http_client=http_client, 66 | url=url, 67 | ) 68 | request_headers = request_headers or {} 69 | headers = { 70 | header_name: header_value for header_name, header_value in request_headers.items() 71 | if header_name.lower() in cls.PROXIED_REQUEST_HEADERS 72 | } 73 | 74 | async def agenerator() -> typing.AsyncGenerator[bytes, None]: 75 | async with iterator.http_client.stream( 76 | method="GET", 77 | url=iterator.url, 78 | headers=headers, 79 | ) as resp: 80 | # Expose the response status and headers. 81 | iterator.status_code, iterator.headers = resp.status_code, resp.headers 82 | 83 | # The first time that anext is called, set status_code and 84 | # headers, without yielding the first byte of the stream. 85 | yield b"" 86 | async for chunk in resp.aiter_raw(1024 * 1024): 87 | yield chunk 88 | 89 | iterator._agen = agenerator() 90 | # Call anext to set the values of status_code and headers. 91 | await iterator.__anext__() 92 | 93 | return iterator 94 | -------------------------------------------------------------------------------- /simple_repository_server/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simple-repository/simple-repository-server/d9a861d82761cd6ae1b40ceb06ffbdbcc1697780/simple_repository_server/py.typed -------------------------------------------------------------------------------- /simple_repository_server/routers/__init__.py: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /simple_repository_server/routers/simple.py: -------------------------------------------------------------------------------- 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 functools 9 | import hashlib 10 | import typing 11 | 12 | from fastapi import APIRouter, Depends, HTTPException 13 | import fastapi.params 14 | from fastapi.responses import ( 15 | FileResponse, 16 | HTMLResponse, 17 | PlainTextResponse, 18 | RedirectResponse, 19 | Response, 20 | StreamingResponse, 21 | ) 22 | import httpx 23 | import packaging.utils 24 | import packaging.version 25 | from simple_repository import SimpleRepository, content_negotiation, errors, model, serializer 26 | 27 | from .. import utils 28 | from .._http_response_iterator import HttpResponseIterator 29 | 30 | 31 | def get_response_format( 32 | request: fastapi.Request, 33 | format: str | None = None, 34 | ) -> content_negotiation.Format: 35 | """ 36 | A fastapi dependent which can optionally enable a PEP-691 format querystring, 37 | for example: 38 | 39 | /simple/some-project/?format=application/vnd.pypi.simple.v1+json 40 | 41 | """ 42 | if format: 43 | # Allow the consumer to request a format as a query string such as 44 | # {URL}?format=application/vnd.pypi.simple.v1+json 45 | # Note: + in urls are interpreted as spaces by 46 | # urllib.parse.parse_qsl, used by FastAPI. 47 | requested_format = format.replace(" ", "+") 48 | else: 49 | requested_format = request.headers.get("Accept", "") 50 | 51 | try: 52 | response_format = content_negotiation.select_response_format( 53 | content_type=requested_format, 54 | ) 55 | except errors.UnsupportedSerialization as e: 56 | raise HTTPException(status_code=406, detail=str(e)) 57 | 58 | return response_format 59 | 60 | 61 | def build_router( 62 | resource_repository: SimpleRepository, 63 | *, 64 | http_client: httpx.AsyncClient, 65 | prefix: str = "/simple/", 66 | repo_factory: typing.Optional[typing.Callable[..., SimpleRepository]] = None, 67 | ) -> APIRouter: 68 | """ 69 | Build a FastAPI router for the given repository and http_client. 70 | 71 | Note that for the simple end-points, the repository is an injected 72 | dependency, meaning that you can add your own dependencies into the repository 73 | (see the test_repo_dependency_injection for an example of this). 74 | 75 | """ 76 | if not prefix.endswith("/"): 77 | raise ValueError("Prefix must end in '/'") 78 | 79 | if repo_factory is None: 80 | # If no repo factory is provided, use the same repository that we want to 81 | # use for resource handling. 82 | def repo_factory() -> SimpleRepository: 83 | return resource_repository 84 | 85 | simple_router = APIRouter( 86 | tags=["simple"], 87 | default_response_class=HTMLResponse, 88 | ) 89 | #: To be fixed by https://github.com/tiangolo/fastapi/pull/2763 90 | get = functools.partial(simple_router.api_route, methods=["HEAD", "GET"]) 91 | 92 | @get(prefix) 93 | async def project_list( 94 | response_format: typing.Annotated[content_negotiation.Format, Depends(get_response_format)], 95 | repository: typing.Annotated[SimpleRepository, Depends(repo_factory)], 96 | ) -> Response: 97 | project_list = await repository.get_project_list() 98 | 99 | serialized_project_list = serializer.serialize( 100 | page=project_list, 101 | format=response_format, 102 | ) 103 | 104 | return Response( 105 | serialized_project_list, 106 | media_type=response_format.value, 107 | ) 108 | 109 | @get(prefix + "{project_name}/") 110 | async def simple_project_page( 111 | request: fastapi.Request, 112 | project_name: str, 113 | repository: typing.Annotated[SimpleRepository, Depends(repo_factory)], 114 | response_format: typing.Annotated[content_negotiation.Format, Depends(get_response_format)], 115 | ) -> Response: 116 | normed_prj_name = packaging.utils.canonicalize_name(project_name) 117 | if normed_prj_name != project_name: 118 | # Update the original path params with the normed name. 119 | path_params = request.path_params | {'project_name': normed_prj_name} 120 | correct_url = utils.relative_url_for( 121 | request=request, 122 | name="simple_project_page", 123 | **path_params, 124 | ) 125 | if request.url.query: 126 | correct_url = correct_url + "?" + request.url.query 127 | return RedirectResponse( 128 | url=correct_url, 129 | status_code=301, 130 | ) 131 | 132 | try: 133 | package_releases = await repository.get_project_page(project_name) 134 | except errors.PackageNotFoundError as e: 135 | raise HTTPException(404, str(e)) 136 | 137 | # Point all resource URLs to this router. The router may choose to redirect these 138 | # back to the original source, but this means that all resource requests go through 139 | # this server (it may be desirable to be able to disable this behaviour in the 140 | # future, though it would mean that there is the potential for a SimpleRepository 141 | # to have implemented a resource handler, yet it never sees the request). 142 | project_releases = utils.replace_urls(package_releases, project_name, request) 143 | 144 | serialized_project_page = serializer.serialize( 145 | page=project_releases, 146 | format=response_format, 147 | ) 148 | return Response( 149 | serialized_project_page, 150 | media_type=response_format.value, 151 | ) 152 | 153 | @get("/resources/{project_name}/{resource_name}") 154 | async def resources( 155 | request: fastapi.Request, 156 | resource_name: str, 157 | project_name: str, 158 | ) -> fastapi.Response: 159 | 160 | req_ctx = model.RequestContext( 161 | resource_repository, 162 | context=dict(request.headers.items()), 163 | ) 164 | 165 | try: 166 | resource = await resource_repository.get_resource( 167 | project_name, 168 | resource_name, 169 | request_context=req_ctx, 170 | ) 171 | except errors.ResourceUnavailable as e: 172 | raise HTTPException(status_code=404, detail=str(e)) from e 173 | except errors.InvalidPackageError as e: 174 | raise HTTPException(status_code=502, detail=str(e)) from e 175 | 176 | if isinstance(resource, model.TextResource): 177 | # Use the first 12 characters of the metadata digest as ETag 178 | text_hash = hashlib.sha256(resource.text.encode('UTF-8')).hexdigest()[:12] 179 | etag = f'"{text_hash}"' 180 | response_headers = {'ETag': etag} 181 | if etag == request.headers.get("if-none-match"): 182 | raise HTTPException( 183 | 304, 184 | headers=response_headers, 185 | ) 186 | return PlainTextResponse( 187 | content=resource.text, 188 | headers=response_headers, 189 | ) 190 | 191 | if isinstance(resource, model.HttpResource): 192 | response_iterator = await HttpResponseIterator.create_iterator( 193 | http_client=http_client, 194 | url=resource.url, 195 | request_headers=request.headers, 196 | ) 197 | return StreamingResponse( 198 | content=response_iterator, 199 | status_code=response_iterator.status_code, 200 | headers=response_iterator.headers, 201 | ) 202 | 203 | if isinstance(resource, model.LocalResource): 204 | ctx_etag = resource.context.get("etag") 205 | response_headers = {"ETag": ctx_etag} if ctx_etag else {} 206 | if client_etag := request.headers.get("if-none-match"): 207 | if client_etag == ctx_etag: 208 | raise HTTPException( 209 | 304, 210 | headers=response_headers, 211 | ) 212 | return FileResponse( 213 | path=resource.path, 214 | media_type="application/octet-stream", 215 | headers=response_headers, 216 | ) 217 | 218 | raise ValueError("Unsupported resource type") 219 | 220 | return simple_router 221 | -------------------------------------------------------------------------------- /simple_repository_server/tests/__init__.py: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /simple_repository_server/tests/api/__init__.py: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /simple_repository_server/tests/api/test_simple_router.py: -------------------------------------------------------------------------------- 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 pathlib 9 | import typing 10 | from unittest import mock 11 | 12 | from fastapi import FastAPI 13 | import httpx 14 | import pytest 15 | from pytest_httpx import HTTPXMock 16 | from simple_repository import errors, model 17 | from simple_repository.components.core import SimpleRepository 18 | from starlette.testclient import TestClient 19 | 20 | import simple_repository_server.routers.simple as simple_router 21 | 22 | 23 | @pytest.fixture 24 | def mock_repo() -> mock.AsyncMock: 25 | mock_repo = mock.AsyncMock(spec=SimpleRepository) 26 | return mock_repo 27 | 28 | 29 | @pytest.fixture 30 | def client(tmp_path: pathlib.PosixPath, mock_repo: mock.AsyncMock) -> typing.Generator[TestClient, None, None]: 31 | app = FastAPI() 32 | http_client = httpx.AsyncClient() 33 | app.include_router(simple_router.build_router(mock_repo, http_client=http_client)) 34 | 35 | with TestClient(app) as test_client: 36 | yield test_client 37 | 38 | 39 | @pytest.mark.parametrize( 40 | "headers", [{}, {"Accept": "text/html"}, {"Accept": "*/*"}], 41 | ) 42 | def test_simple_project_list(client: TestClient, headers: dict[str, str], mock_repo: mock.AsyncMock) -> None: 43 | assert isinstance(client.app, FastAPI) 44 | mock_repo.get_project_list.return_value = model.ProjectList( 45 | meta=model.Meta("1.0"), 46 | projects=frozenset([ 47 | model.ProjectListElement("a"), 48 | ]), 49 | ) 50 | 51 | expected = """ 52 | 53 |
54 | 55 |