pypiserver 2.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pypiserver/__init__.py ADDED
@@ -0,0 +1,222 @@
1
+ import functools
2
+ import pathlib
3
+ import re as _re
4
+ import sys
5
+ import typing as t
6
+
7
+ from pypiserver.bottle_wrapper import Bottle
8
+ from pypiserver.config import Config, RunConfig, strtobool
9
+
10
+ __version__ = "2.4.2"
11
+ version = __version__
12
+ __version_info__ = tuple(_re.split("[.-]", __version__))
13
+ __updated__ = "2026-09-20 13:04:37"
14
+
15
+ __title__ = "pypiserver"
16
+ __summary__ = "A minimal PyPI server for use with pip/easy_install."
17
+ __uri__ = "https://github.com/pypiserver/pypiserver"
18
+
19
+
20
+ def identity(x):
21
+ return x
22
+
23
+
24
+ def backwards_compat_kwargs(kwargs: dict, warn: bool = True) -> dict:
25
+ """Return a dict with deprecated kwargs converted to new kwargs.
26
+
27
+ :param kwargs: the incoming kwargs to convert
28
+ :param warn: whether to output a warning to stderr if there are deprecated
29
+ kwargs found in the incoming kwargs
30
+ """
31
+ # A mapping of deprecated kwargs to a 2-tuple of their corresponding updated
32
+ # kwarg and a function to convert the value of the deprecated kwarg to a
33
+ # value for the new kwarg. `identity` is just a function that returns
34
+ # whatever it is passed and is used in cases where the only change from
35
+ # a legacy kwarg is its name.
36
+ backwards_compat = {
37
+ "authenticated": ("authenticate", identity),
38
+ "passwords": ("password_file", identity),
39
+ # `root` could be a string or an array of strings. Handle both cases,
40
+ # converting strings to Path instances.
41
+ "root": (
42
+ "roots",
43
+ lambda root: [
44
+ # Convert strings to absolute Path instances
45
+ pathlib.Path(r).expanduser().resolve()
46
+ for r in ([root] if isinstance(root, str) else root)
47
+ ],
48
+ ),
49
+ # `redirect_to_fallback` was changed to `disable_fallback` for clearer
50
+ # use as a flag to disable the default behavior. Since its behavior
51
+ # is the opposite, we negate it.
52
+ "redirect_to_fallback": (
53
+ "disable_fallback",
54
+ lambda redirect: not redirect,
55
+ ),
56
+ "server": ("server_method", identity),
57
+ # `welcome_msg` now is just provided as text, so that anyone using
58
+ # pypiserver as a library doesn't need to worry about distributing
59
+ # files if they don't need to. If we're still passed an old-style
60
+ # `welcome_file` argument, we go ahead and resolve it to an absolute
61
+ # path and read the text.
62
+ "welcome_file": (
63
+ "welcome_msg",
64
+ lambda p: pathlib.Path(p).expanduser().resolve().read_text(),
65
+ ),
66
+ }
67
+ # Warn the user if they're using any deprecated arguments
68
+ if warn and any(k in backwards_compat for k in kwargs):
69
+ # Make nice instructions like `Please replace the following:
70
+ # 'authenticated' with 'authenticate'` and print to stderr.
71
+ replacement_strs = (
72
+ f"'{k}' with '{backwards_compat[k][0]}'"
73
+ for k in filter(lambda k: k in kwargs, backwards_compat)
74
+ )
75
+ warn_str = (
76
+ "You are using deprecated arguments. Please replace the following: \n"
77
+ f" {', '.join(replacement_strs)}"
78
+ )
79
+ print(warn_str, file=sys.stderr)
80
+
81
+ # Create an iterable of 2-tuple to collect into the updated dictionary. Each
82
+ # item will either be the existing key-value pair from kwargs, or, if the
83
+ # keyword is a legacy keyword, the new key and potentially adjusted value
84
+ # for that keyword. Note that depending on the order the argument are
85
+ # specified, this _could_ mean an updated legacy keyword could override
86
+ # a new argument if that argument is also specified. However, in that
87
+ # case, our updated kwargs dictionary would have a different number of
88
+ # keys compared to our incoming dictionary, so we check for that case
89
+ # below.
90
+ rv_iter = (
91
+ (
92
+ (k, v)
93
+ if k not in backwards_compat
94
+ else (backwards_compat[k][0], backwards_compat[k][1](v))
95
+ )
96
+ for k, v in kwargs.items()
97
+ )
98
+ updated_kwargs = dict(rv_iter)
99
+
100
+ # If our dictionaries have different lengths, we must have gotten duplicate
101
+ # legacy/modern keys. Figure out which keys were dupes and throw an error.
102
+ if len(updated_kwargs) != len(kwargs):
103
+ legacy_to_modern = {k: v[0] for k, v in backwards_compat.items()}
104
+ dupes = [
105
+ (k, v)
106
+ for k, v in legacy_to_modern.items()
107
+ if k in kwargs and v in kwargs
108
+ ]
109
+ raise ValueError(
110
+ "Keyword arguments for pypiserver app() constructor contained "
111
+ "duplicate legacy and modern keys. Duplicates are shown below, in "
112
+ "the form (legacy_key, modern_key):\n"
113
+ f"{dupes}"
114
+ )
115
+
116
+ return updated_kwargs
117
+
118
+
119
+ def app(**kwargs: t.Any) -> Bottle:
120
+ """Construct a bottle app running pypiserver.
121
+
122
+ :param kwds: Any overrides for defaults. Any property of RunConfig
123
+ (or its base), defined in `pypiserver.config`, may be overridden.
124
+ """
125
+ config = Config.default_with_overrides(**backwards_compat_kwargs(kwargs))
126
+ return setup_routes_from_config(app_from_config(config), config)
127
+
128
+
129
+ def app_from_config(config: RunConfig) -> Bottle:
130
+ """Construct a bottle app from the provided RunConfig."""
131
+ # The _app module instantiates a Bottle instance directly when it is
132
+ # imported. That is `_app.app`. We directly mutate some global variables
133
+ # on the imported `_app` module so that its endpoints will behave as
134
+ # we expect.
135
+ _app = __import__("_app", globals(), locals(), ["."], 1)
136
+ # Because we're about to mutate our import, we pop it out of the imported
137
+ # modules map, so that any future imports do not receive our mutated version
138
+ sys.modules.pop("pypiserver._app", None)
139
+ _app.config = config
140
+ # Add a reference to our config on the Bottle app for easy access in testing
141
+ # and other contexts.
142
+ _app.app._pypiserver_config = config
143
+ return _app.app
144
+
145
+
146
+ def setup_routes_from_config(app: Bottle, config: RunConfig) -> Bottle:
147
+ """Set up additional routes supplied from the config."""
148
+
149
+ def _setup_health_endpoint(app, config):
150
+ if config.health_endpoint in [route.rule for route in app.routes]:
151
+ raise RuntimeError(
152
+ "Provided health endpoint overlaps with existing routes"
153
+ )
154
+ app.route(config.health_endpoint, "GET", lambda: "Ok")
155
+
156
+ _setup_health_endpoint(app, config)
157
+ return app
158
+
159
+
160
+ T = t.TypeVar("T")
161
+
162
+
163
+ def paste_app_factory(_global_config, **local_conf):
164
+ """Parse a paste config and return an app.
165
+
166
+ The paste config is entirely strings, so we need to parse those
167
+ strings into values usable for the config, if they're present.
168
+ """
169
+
170
+ def to_bool(val: t.Optional[str]) -> t.Optional[bool]:
171
+ """Convert a string value, if provided, to a bool."""
172
+ return val if val is None else strtobool(val)
173
+
174
+ def to_int(val: t.Optional[str]) -> t.Optional[int]:
175
+ """Convert a string value, if provided, to an int."""
176
+ return val if val is None else int(val)
177
+
178
+ def to_list(
179
+ val: t.Optional[str],
180
+ sep: str = " ",
181
+ transform: t.Callable[[str], T] = str.strip,
182
+ ) -> t.Optional[t.List[T]]:
183
+ """Convert a string value, if provided, to a list.
184
+
185
+ :param sep: the separator between items in the string representation
186
+ of the list
187
+ :param transform: an optional function to call on each string item of
188
+ the list
189
+ """
190
+ if val is None:
191
+ return val
192
+ return list(filter(None, map(transform, val.split(sep))))
193
+
194
+ def _make_root(root: str) -> pathlib.Path:
195
+ """Convert a specified string root into an absolute Path instance."""
196
+ return pathlib.Path(root.strip()).expanduser().resolve()
197
+
198
+ # A map of config keys we expect in the paste config to the appropriate
199
+ # function to parse the string config value. This map includes both
200
+ # current and legacy keys.
201
+ maps = {
202
+ "cache_control": to_int,
203
+ "roots": functools.partial(to_list, sep="\n", transform=_make_root),
204
+ # root is a deprecated argument for roots
205
+ "root": functools.partial(to_list, sep="\n", transform=_make_root),
206
+ "disable_fallback": to_bool,
207
+ # redirect_to_fallback is a deprecated argument for disable_fallback
208
+ "redirect_to_fallback": to_bool,
209
+ "overwrite": to_bool,
210
+ "authenticate": functools.partial(to_list, sep=" "),
211
+ # authenticated is a deprecated argument for authenticate
212
+ "authenticated": functools.partial(to_list, sep=" "),
213
+ "verbosity": to_int,
214
+ }
215
+
216
+ # First, convert values from strings to whatever types we need, or leave
217
+ # them as strings if there's no mapping function available for them.
218
+ mapped_conf = {k: maps.get(k, identity)(v) for k, v in local_conf.items()}
219
+ # Convert any legacy key/value pairs into their modern form.
220
+ updated_conf = backwards_compat_kwargs(mapped_conf)
221
+
222
+ return app(**updated_conf)
pypiserver/__main__.py ADDED
@@ -0,0 +1,231 @@
1
+ #! /usr/bin/env python3
2
+ """Entrypoint for pypiserver."""
3
+
4
+ import enum
5
+ import functools as ft
6
+ import importlib
7
+ import logging
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import IO, Any
12
+ from wsgiref.simple_server import WSGIRequestHandler
13
+
14
+ from pypiserver.config import Config, UpdateConfig
15
+
16
+ log = logging.getLogger("pypiserver.main")
17
+
18
+
19
+ def init_logging(
20
+ level: int = logging.NOTSET,
21
+ frmt: str | None = None,
22
+ filename: str | Path | None = None,
23
+ stream: IO | None = sys.stderr,
24
+ logger: logging.Logger | None = None,
25
+ ) -> None:
26
+ """Configure the specified logger, or the root logger otherwise."""
27
+ logger = logger or logging.getLogger()
28
+ logger.setLevel(level)
29
+
30
+ formatter = logging.Formatter(frmt)
31
+ if len(logger.handlers) == 0 and stream is not None:
32
+ handler: logging.Handler = logging.StreamHandler(stream)
33
+ handler.setFormatter(formatter)
34
+ logger.addHandler(logging.StreamHandler(stream))
35
+
36
+ if filename:
37
+ handler = logging.FileHandler(filename)
38
+ handler.setFormatter(formatter)
39
+ logger.addHandler(handler)
40
+
41
+
42
+ class WsgiHandler(WSGIRequestHandler):
43
+ """A simple request handler to configure logging."""
44
+
45
+ # The default `FixedHandler` that bottle's `WSGIRefServer` uses does not
46
+ # log in a particularly predictable or configurable way. We'll pass this
47
+ # in to use instead.
48
+ def address_string(self) -> str: # Prevent reverse DNS lookups please.
49
+ # This method copied directly from bottle's `FixedHandler` and
50
+ # maintained on the Chesterton's fence principle (i.e. I don't know
51
+ # why it's important, so I'm not going to get rid of it)
52
+ return self.client_address[0]
53
+
54
+ def log_message( # pylint: disable=redefined-builtin
55
+ self, format: str, *args: Any
56
+ ) -> None:
57
+ """Log a message."""
58
+ # The log_message method on the `HttpRequestHandler` base class just
59
+ # writes directly to stderr. We'll use its same formatting, but pass
60
+ # it through the logger instead.
61
+ log.info(
62
+ "%s - - [%s] %s\n",
63
+ self.address_string(),
64
+ self.log_date_time_string(),
65
+ format % args,
66
+ )
67
+
68
+
69
+ class AutoServer(enum.Enum):
70
+ """Expected servers that can be automaticlaly selected by bottle."""
71
+
72
+ Waitress = enum.auto()
73
+ PasteServer = enum.auto()
74
+ Twisted = enum.auto()
75
+ CherryPy = enum.auto()
76
+ Cheroot = enum.auto()
77
+ WsgiRef = enum.auto()
78
+
79
+
80
+ # Possible automatically selected servers. This MUST match the available
81
+ # auto servers in bottle.py
82
+ AUTO_SERVER_IMPORTS = (
83
+ (AutoServer.Waitress, "waitress"),
84
+ (AutoServer.PasteServer, "paste"),
85
+ (AutoServer.Twisted, "twisted.web"),
86
+ (AutoServer.CherryPy, "cherrypy"),
87
+ (AutoServer.Cheroot, "cheroot"),
88
+ # this should always be available because it's part of the stdlib
89
+ (AutoServer.WsgiRef, "wsgiref"),
90
+ )
91
+
92
+
93
+ def _can_import(name: str) -> bool:
94
+ """Attempt to import a module. Return a bool indicating success."""
95
+ try:
96
+ importlib.import_module(name)
97
+ return True
98
+ except ImportError:
99
+ return False
100
+
101
+
102
+ def guess_auto_server() -> AutoServer:
103
+ """Guess which server bottle will use for the auto setting."""
104
+ # Return the first server that can be imported.
105
+ server = next(
106
+ (s for s, i in AUTO_SERVER_IMPORTS if _can_import(i)),
107
+ None,
108
+ )
109
+ if server is None:
110
+ raise RuntimeError(
111
+ "Unexpected error determining bottle auto server. There may be an "
112
+ "issue with this python environment. Please report this bug at "
113
+ "https://github.com/pypiserver/pypiserver/issues"
114
+ )
115
+ return server
116
+
117
+
118
+ def main(argv: Sequence[str] | None = None) -> None:
119
+ """Application entrypoint for pypiserver.
120
+
121
+ This function drives the application (as opposed to the library)
122
+ implementation of pypiserver. Usage from the commandline will result in
123
+ this function being called.
124
+ """
125
+ # pylint: disable=import-outside-toplevel
126
+ import pypiserver # pylint: disable=redefined-outer-name
127
+
128
+ if argv is None:
129
+ # The first item in sys.argv is the name of the python file being
130
+ # executed, which we don't need
131
+ argv = sys.argv[1:]
132
+
133
+ config = Config.from_args(argv)
134
+
135
+ init_logging(
136
+ level=config.log_level,
137
+ filename=config.log_file,
138
+ frmt=config.log_frmt,
139
+ stream=config.log_stream,
140
+ )
141
+
142
+ # Check to see if we were asked to run an update command instead of running
143
+ # the server
144
+ if isinstance(config, UpdateConfig):
145
+ from pypiserver.manage import update_all_packages
146
+
147
+ update_all_packages(
148
+ config.roots,
149
+ config.download_directory,
150
+ dry_run=not config.execute,
151
+ stable_only=config.allow_unstable,
152
+ ignorelist=config.ignorelist,
153
+ )
154
+ return
155
+
156
+ # Fixes #49:
157
+ # The gevent server adapter needs to patch some
158
+ # modules BEFORE importing bottle!
159
+ if config.server_method.startswith("gevent"):
160
+ import gevent.monkey # @UnresolvedImport
161
+
162
+ gevent.monkey.patch_all()
163
+
164
+ from pypiserver import bottle_wrapper as bottle
165
+
166
+ bottle.debug(config.verbosity > 1)
167
+ bottle._stderr = ft.partial( # pylint: disable=protected-access
168
+ _logwrite, logging.getLogger(bottle.__name__), logging.INFO
169
+ )
170
+
171
+ # Here `app` is a Bottle instance, which we pass to bottle.run() to run
172
+ # the server
173
+ app = pypiserver.app_from_config(config)
174
+ app = pypiserver.setup_routes_from_config(app, config)
175
+
176
+ if config.server_method == "gunicorn":
177
+ # When bottle runs gunicorn, gunicorn tries to pull its arguments from
178
+ # sys.argv. Because pypiserver's arguments don't match gunicorn's,
179
+ # this leads to errors.
180
+ # Gunicorn can be configured by using a `gunicorn.conf.py` config file
181
+ # or by specifying the `GUNICORN_CMD_ARGS` env var. See gunicorn
182
+ # docs for more info.
183
+ sys.argv = ["gunicorn"]
184
+
185
+ wsgi_kwargs = {"handler_class": WsgiHandler}
186
+
187
+ if config.server_method == "auto":
188
+ expected_server = guess_auto_server()
189
+ extra_kwargs = (
190
+ wsgi_kwargs if expected_server is AutoServer.WsgiRef else {}
191
+ )
192
+ log.debug(
193
+ "Server 'auto' selected. Expecting bottle to run '%s'. "
194
+ "Passing extra keyword args: %s",
195
+ expected_server.name,
196
+ extra_kwargs,
197
+ )
198
+ else:
199
+ extra_kwargs = wsgi_kwargs if config.server_method == "wsgiref" else {}
200
+ log.debug(
201
+ "Running bottle with selected server '%s'", config.server_method
202
+ )
203
+
204
+ # We strip&check because bottle mount() will raise error if path has no segment.
205
+ if config.server_base_url.strip("/"):
206
+ main_app = bottle.Bottle()
207
+ main_app.mount(config.server_base_url, app)
208
+ else:
209
+ main_app = app
210
+
211
+ bottle.run(
212
+ app=main_app,
213
+ host=config.host,
214
+ port=config.port,
215
+ server=config.server_method,
216
+ **extra_kwargs,
217
+ )
218
+
219
+
220
+ def _logwrite(logger: logging.Logger, level: int, msg: str | None) -> None:
221
+ if msg:
222
+ line_endings = ["\r\n", "\n\r", "\n"]
223
+ for le in line_endings: # pylint: disable=invalid-name
224
+ if msg.endswith(le):
225
+ msg = msg[: -len(le)]
226
+ if msg:
227
+ logger.log(level, msg)
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()