webdev 2.0.0__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.
- webdev/__init__.py +30 -0
- webdev/__main__.py +29 -0
- webdev/cli.py +330 -0
- webdev/config.py +210 -0
- webdev/index.py +298 -0
- webdev/livereload.py +169 -0
- webdev/net.py +145 -0
- webdev/run.py +124 -0
- webdev/server.py +336 -0
- webdev/static.py +195 -0
- webdev/templates/dark/base.html.jinja2 +17 -0
- webdev/templates/dark/index.html.jinja2 +71 -0
- webdev/templates/dark/partials/sort.js.jinja2 +52 -0
- webdev/templates/dark/partials/styles.css.jinja2 +110 -0
- webdev/tls.py +159 -0
- webdev-2.0.0.dist-info/METADATA +240 -0
- webdev-2.0.0.dist-info/RECORD +20 -0
- webdev-2.0.0.dist-info/WHEEL +4 -0
- webdev-2.0.0.dist-info/entry_points.txt +2 -0
- webdev-2.0.0.dist-info/licenses/LICENSE +202 -0
webdev/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
|
|
2
|
+
# Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing,
|
|
11
|
+
# software distributed under the License is distributed on an
|
|
12
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
13
|
+
# KIND, either express or implied. See the License for the
|
|
14
|
+
# specific language governing permissions and limitations
|
|
15
|
+
# under the License.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
WebDev - a small, fast development web server.
|
|
19
|
+
|
|
20
|
+
Serves any local directory over HTTP with developer-friendly features:
|
|
21
|
+
graceful handling (and auto-recovery) when the served directory disappears,
|
|
22
|
+
incremental automatic port selection, live reload, a modern dark directory
|
|
23
|
+
index, optional HTTPS, and more.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
__version__ = '2.0.0'
|
|
29
|
+
|
|
30
|
+
__all__ = ['__version__']
|
webdev/__main__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
|
|
2
|
+
# Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing,
|
|
11
|
+
# software distributed under the License is distributed on an
|
|
12
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
13
|
+
# KIND, either express or implied. See the License for the
|
|
14
|
+
# specific language governing permissions and limitations
|
|
15
|
+
# under the License.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
Enable ``python -m webdev``.
|
|
19
|
+
|
|
20
|
+
Delegates to :func:`webdev.cli.main` and exits with its return code so the
|
|
21
|
+
package can be launched as an executable module.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from .cli import main
|
|
27
|
+
|
|
28
|
+
if __name__ == '__main__':
|
|
29
|
+
raise SystemExit(main())
|
webdev/cli.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
|
|
2
|
+
# Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing,
|
|
11
|
+
# software distributed under the License is distributed on an
|
|
12
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
13
|
+
# KIND, either express or implied. See the License for the
|
|
14
|
+
# specific language governing permissions and limitations
|
|
15
|
+
# under the License.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
Command-line interface.
|
|
19
|
+
|
|
20
|
+
Preserves the legacy flags (``-i``, ``-p``, ``-f``, ``-r``, ``-u``, ``-v``,
|
|
21
|
+
positional ``path``) and adds a flag for every new feature. Every flag has a
|
|
22
|
+
matching config-file key; precedence is CLI > config file > built-in default.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
from os import getcwd
|
|
29
|
+
from os.path import isdir
|
|
30
|
+
from typing import cast
|
|
31
|
+
from argparse import Action, ArgumentParser, BooleanOptionalAction, Namespace
|
|
32
|
+
|
|
33
|
+
from colorlog import ColoredFormatter
|
|
34
|
+
|
|
35
|
+
from . import __version__
|
|
36
|
+
from .config import Settings, load_config_file
|
|
37
|
+
from .index import ThemeNotFoundError, available_themes
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger('webdev')
|
|
40
|
+
|
|
41
|
+
# Colorized log format (colorlog, ``{``-style). ``log_color`` is replaced with
|
|
42
|
+
# the level color; ``thin_white`` dims the timestamp; ``reset`` clears styling.
|
|
43
|
+
COLOR_FORMAT = (
|
|
44
|
+
' {thin_white}{asctime}{reset} | '
|
|
45
|
+
'{log_color}{levelname:8}{reset} | '
|
|
46
|
+
'{log_color}{message}{reset}'
|
|
47
|
+
)
|
|
48
|
+
# Plain log format used when color is disabled (same layout, no escape codes).
|
|
49
|
+
SIMPLE_FORMAT = ' {asctime} | {levelname:8} | {message}'
|
|
50
|
+
|
|
51
|
+
_V_LEVELS = {
|
|
52
|
+
0: logging.ERROR,
|
|
53
|
+
1: logging.WARNING,
|
|
54
|
+
2: logging.INFO,
|
|
55
|
+
3: logging.DEBUG,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Sentinel marking "flag not provided on the CLI" so config can win.
|
|
59
|
+
_UNSET = object()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _CountFromUnset(Action):
|
|
63
|
+
"""
|
|
64
|
+
Count repeated flags (like ``-v``) while preserving the unset sentinel.
|
|
65
|
+
|
|
66
|
+
A plain ``count`` action cannot increment starting from :data:`_UNSET`, so
|
|
67
|
+
this action treats the sentinel (or a missing value) as zero before
|
|
68
|
+
incrementing. When the flag is never given the value stays :data:`_UNSET`,
|
|
69
|
+
letting a config-file value win during merging.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, option_strings: list[str], dest: str, **kwargs) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Build the action, forcing it to consume no argument values.
|
|
75
|
+
|
|
76
|
+
:param option_strings: The option strings that trigger this action.
|
|
77
|
+
:param dest: The namespace attribute to accumulate the count in.
|
|
78
|
+
:param kwargs: Remaining keyword arguments forwarded to the base action.
|
|
79
|
+
"""
|
|
80
|
+
kwargs['nargs'] = 0
|
|
81
|
+
super().__init__(option_strings, dest, **kwargs)
|
|
82
|
+
|
|
83
|
+
def __call__(self, parser, namespace, values, option_string=None) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Increment the destination counter, seeding the sentinel as zero.
|
|
86
|
+
|
|
87
|
+
:param parser: The parser invoking the action.
|
|
88
|
+
:param namespace: The namespace being populated.
|
|
89
|
+
:param values: The (empty) argument values for this action.
|
|
90
|
+
:param option_string: The option string that triggered the action.
|
|
91
|
+
"""
|
|
92
|
+
current = getattr(namespace, self.dest, _UNSET)
|
|
93
|
+
count = 0 if current is _UNSET else cast(int, current)
|
|
94
|
+
setattr(namespace, self.dest, count + 1)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_parser() -> ArgumentParser:
|
|
98
|
+
"""
|
|
99
|
+
Construct the ``webdev`` command-line argument parser.
|
|
100
|
+
|
|
101
|
+
Every flag defaults to the :data:`_UNSET` sentinel so that options not
|
|
102
|
+
given on the command line can later be filled in from the config file or
|
|
103
|
+
the built-in defaults without being overridden by argparse defaults.
|
|
104
|
+
|
|
105
|
+
:return: A configured :class:`argparse.ArgumentParser` for the CLI.
|
|
106
|
+
"""
|
|
107
|
+
parser = ArgumentParser(
|
|
108
|
+
prog='webdev', description='Small, fast development web server.'
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
'-v',
|
|
112
|
+
'--verbose',
|
|
113
|
+
action=_CountFromUnset,
|
|
114
|
+
default=_UNSET,
|
|
115
|
+
help='Increase verbosity level (repeatable).',
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
'--version',
|
|
119
|
+
action='version',
|
|
120
|
+
version=f'Development Web Server v{__version__}',
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Core / back-compatible options.
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
'-i',
|
|
126
|
+
'--ip',
|
|
127
|
+
default=_UNSET,
|
|
128
|
+
help='IP to bind to. Default: 0.0.0.0',
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
'-p',
|
|
132
|
+
'--port',
|
|
133
|
+
type=int,
|
|
134
|
+
default=_UNSET,
|
|
135
|
+
help='Base port to listen on. Default: 8080.',
|
|
136
|
+
)
|
|
137
|
+
parser.add_argument(
|
|
138
|
+
'-f',
|
|
139
|
+
'--force-port',
|
|
140
|
+
action='store_true',
|
|
141
|
+
default=_UNSET,
|
|
142
|
+
help='Force the use of the given port (disables range probing).',
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
'-r',
|
|
146
|
+
'--range',
|
|
147
|
+
type=int,
|
|
148
|
+
default=_UNSET,
|
|
149
|
+
help='Number of ports to try from the base port. Default: 20.',
|
|
150
|
+
)
|
|
151
|
+
parser.add_argument(
|
|
152
|
+
'-u',
|
|
153
|
+
'--future',
|
|
154
|
+
action='store_true',
|
|
155
|
+
default=_UNSET,
|
|
156
|
+
help='Serve a folder that does not exist yet.',
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# New feature toggles (flag + --no- variant).
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
'--reload',
|
|
162
|
+
action=BooleanOptionalAction,
|
|
163
|
+
default=_UNSET,
|
|
164
|
+
help='Live-reload browsers on file changes. Default: enabled.',
|
|
165
|
+
)
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
'--cache',
|
|
168
|
+
dest='no_cache',
|
|
169
|
+
action='store_false',
|
|
170
|
+
default=_UNSET,
|
|
171
|
+
help='Allow caching (disable no-cache headers).',
|
|
172
|
+
)
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
'--no-cache',
|
|
175
|
+
dest='no_cache',
|
|
176
|
+
action='store_true',
|
|
177
|
+
default=_UNSET,
|
|
178
|
+
help='Send no-cache headers so content is always fresh. Default.',
|
|
179
|
+
)
|
|
180
|
+
parser.add_argument(
|
|
181
|
+
'--spa',
|
|
182
|
+
action=BooleanOptionalAction,
|
|
183
|
+
default=_UNSET,
|
|
184
|
+
help='SPA fallback: serve index.html for unknown routes. Default: off.',
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
'--gzip',
|
|
188
|
+
action=BooleanOptionalAction,
|
|
189
|
+
default=_UNSET,
|
|
190
|
+
help='Gzip-compress responses. Default: enabled.',
|
|
191
|
+
)
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
'--cors',
|
|
194
|
+
action=BooleanOptionalAction,
|
|
195
|
+
default=_UNSET,
|
|
196
|
+
help='Send permissive CORS headers. Default: off.',
|
|
197
|
+
)
|
|
198
|
+
parser.add_argument(
|
|
199
|
+
'--qr',
|
|
200
|
+
action=BooleanOptionalAction,
|
|
201
|
+
default=_UNSET,
|
|
202
|
+
help='Print a QR code / LAN URL at startup. Default: enabled.',
|
|
203
|
+
)
|
|
204
|
+
parser.add_argument(
|
|
205
|
+
'--color',
|
|
206
|
+
action=BooleanOptionalAction,
|
|
207
|
+
default=_UNSET,
|
|
208
|
+
help='Colorize log output. Default: enabled.',
|
|
209
|
+
)
|
|
210
|
+
parser.add_argument(
|
|
211
|
+
'--https',
|
|
212
|
+
action=BooleanOptionalAction,
|
|
213
|
+
default=_UNSET,
|
|
214
|
+
help='Serve over HTTPS (self-signed if no cert/key). Default: off.',
|
|
215
|
+
)
|
|
216
|
+
parser.add_argument(
|
|
217
|
+
'--cert',
|
|
218
|
+
default=_UNSET,
|
|
219
|
+
help='TLS certificate file (with --https).',
|
|
220
|
+
)
|
|
221
|
+
parser.add_argument(
|
|
222
|
+
'--key',
|
|
223
|
+
default=_UNSET,
|
|
224
|
+
help='TLS private key file (with --https).',
|
|
225
|
+
)
|
|
226
|
+
parser.add_argument(
|
|
227
|
+
'--theme',
|
|
228
|
+
default=_UNSET,
|
|
229
|
+
help='Directory index theme. Default: dark.',
|
|
230
|
+
)
|
|
231
|
+
parser.add_argument(
|
|
232
|
+
'--config',
|
|
233
|
+
default=None,
|
|
234
|
+
help='Path to a config file (TOML). Auto-discovered if omitted.',
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
parser.add_argument(
|
|
238
|
+
'path',
|
|
239
|
+
nargs='?',
|
|
240
|
+
default=_UNSET,
|
|
241
|
+
help='Path to serve. Default: current directory.',
|
|
242
|
+
)
|
|
243
|
+
return parser
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _provided(args: Namespace) -> dict:
|
|
247
|
+
"""
|
|
248
|
+
Extract only the options explicitly provided on the command line.
|
|
249
|
+
|
|
250
|
+
Any option still set to the :data:`_UNSET` sentinel is omitted, as is the
|
|
251
|
+
``config`` key, which is handled separately during resolution.
|
|
252
|
+
|
|
253
|
+
:param args: The namespace returned by :meth:`ArgumentParser.parse_args`.
|
|
254
|
+
:return: A mapping of option names to the values supplied on the CLI.
|
|
255
|
+
"""
|
|
256
|
+
out = {}
|
|
257
|
+
for key, value in vars(args).items():
|
|
258
|
+
if key in ('config',):
|
|
259
|
+
continue
|
|
260
|
+
if value is not _UNSET:
|
|
261
|
+
out[key] = value
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def resolve_settings(argv: list[str] | None = None) -> Settings:
|
|
266
|
+
"""
|
|
267
|
+
Parse *argv*, merge with the config file, and return resolved settings.
|
|
268
|
+
|
|
269
|
+
Values are merged with precedence CLI > config file > built-in default, the
|
|
270
|
+
result is normalized, and logging is configured from the resolved verbosity.
|
|
271
|
+
|
|
272
|
+
:param argv: Argument list to parse, or ``None`` to use ``sys.argv``.
|
|
273
|
+
:return: The fully resolved and normalized :class:`Settings` instance.
|
|
274
|
+
"""
|
|
275
|
+
parser = build_parser()
|
|
276
|
+
args = parser.parse_args(argv)
|
|
277
|
+
|
|
278
|
+
cli_values = _provided(args)
|
|
279
|
+
|
|
280
|
+
# Where to look for an auto-discovered config file.
|
|
281
|
+
search_from = cli_values.get('path', getcwd())
|
|
282
|
+
config_values = load_config_file(args.config, search_from)
|
|
283
|
+
|
|
284
|
+
# Precedence: defaults < config file < CLI.
|
|
285
|
+
merged = {**config_values, **cli_values}
|
|
286
|
+
settings = Settings(**merged).normalized()
|
|
287
|
+
|
|
288
|
+
# Configure logging as early as possible.
|
|
289
|
+
level = _V_LEVELS.get(settings.verbose, logging.DEBUG)
|
|
290
|
+
if settings.color:
|
|
291
|
+
formatter: logging.Formatter = ColoredFormatter(COLOR_FORMAT, style='{')
|
|
292
|
+
else:
|
|
293
|
+
formatter = logging.Formatter(SIMPLE_FORMAT, style='{')
|
|
294
|
+
handler = logging.StreamHandler()
|
|
295
|
+
handler.setFormatter(formatter)
|
|
296
|
+
logging.basicConfig(handlers=[handler], level=level, force=True)
|
|
297
|
+
log.debug(f'Resolved settings: {settings}')
|
|
298
|
+
|
|
299
|
+
return settings
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main(argv: list[str] | None = None) -> int:
|
|
303
|
+
"""
|
|
304
|
+
Program entry point: resolve settings, validate, and start the server.
|
|
305
|
+
|
|
306
|
+
Returns a non-zero exit code without serving when the requested path is not
|
|
307
|
+
a directory (and ``--future`` was not given) or when the selected theme is
|
|
308
|
+
unknown.
|
|
309
|
+
|
|
310
|
+
:param argv: Argument list to parse, or ``None`` to use ``sys.argv``.
|
|
311
|
+
:return: Process exit code (``0`` on success, ``1`` on validation errors).
|
|
312
|
+
"""
|
|
313
|
+
from .run import serve
|
|
314
|
+
|
|
315
|
+
settings = resolve_settings(argv)
|
|
316
|
+
|
|
317
|
+
if not settings.future and not isdir(settings.path):
|
|
318
|
+
log.error(f'Not a directory: {settings.path}')
|
|
319
|
+
return 1
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
from .index import validate_theme
|
|
323
|
+
|
|
324
|
+
validate_theme(settings.theme)
|
|
325
|
+
except ThemeNotFoundError as error:
|
|
326
|
+
log.error(str(error))
|
|
327
|
+
log.error('Available themes: {}'.format(', '.join(available_themes())))
|
|
328
|
+
return 1
|
|
329
|
+
|
|
330
|
+
return serve(settings)
|
webdev/config.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
|
|
2
|
+
# Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing,
|
|
11
|
+
# software distributed under the License is distributed on an
|
|
12
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
13
|
+
# KIND, either express or implied. See the License for the
|
|
14
|
+
# specific language governing permissions and limitations
|
|
15
|
+
# under the License.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
Configuration model and config-file loading for WebDev.
|
|
19
|
+
|
|
20
|
+
A single :class:`Settings` dataclass holds every runtime option. Each option is
|
|
21
|
+
exposed both as a CLI flag (see :mod:`webdev.cli`) and as a config-file key.
|
|
22
|
+
|
|
23
|
+
Resolution precedence (highest wins):
|
|
24
|
+
|
|
25
|
+
CLI flag > config file > built-in default
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import sys
|
|
31
|
+
from dataclasses import dataclass, field, fields
|
|
32
|
+
from os import getcwd
|
|
33
|
+
from os.path import abspath
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
if sys.version_info >= (3, 11):
|
|
38
|
+
import tomllib
|
|
39
|
+
else: # pragma: no cover - exercised only on Python 3.10
|
|
40
|
+
import tomli as tomllib
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
#: Candidate config file names auto-discovered when ``--config`` is not given.
|
|
44
|
+
CONFIG_FILENAMES = ('webdev.toml', '.webdev.toml')
|
|
45
|
+
|
|
46
|
+
#: Default directory index theme (and currently the only one shipped).
|
|
47
|
+
DEFAULT_THEME = 'dark'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Settings:
|
|
52
|
+
"""
|
|
53
|
+
Fully resolved runtime settings.
|
|
54
|
+
|
|
55
|
+
Holds every runtime option for the server. Instances are typically produced
|
|
56
|
+
by merging CLI flags, config-file keys, and built-in defaults, then calling
|
|
57
|
+
:meth:`normalized`.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Core / back-compatible options.
|
|
61
|
+
path: str = field(default_factory=getcwd)
|
|
62
|
+
ip: str = '0.0.0.0'
|
|
63
|
+
port: int = 8080
|
|
64
|
+
force_port: bool = False
|
|
65
|
+
range: int = 20
|
|
66
|
+
future: bool = False
|
|
67
|
+
verbose: int = 0
|
|
68
|
+
|
|
69
|
+
# New feature toggles (defaults per the plan).
|
|
70
|
+
no_cache: bool = True
|
|
71
|
+
reload: bool = True
|
|
72
|
+
spa: bool = False
|
|
73
|
+
gzip: bool = True
|
|
74
|
+
cors: bool = False
|
|
75
|
+
qr: bool = True
|
|
76
|
+
color: bool = True
|
|
77
|
+
https: bool = False
|
|
78
|
+
cert: str | None = None
|
|
79
|
+
key: str | None = None
|
|
80
|
+
theme: str = DEFAULT_THEME
|
|
81
|
+
|
|
82
|
+
def normalized(self) -> Settings:
|
|
83
|
+
"""
|
|
84
|
+
Apply derived and validated invariants in place and return self.
|
|
85
|
+
|
|
86
|
+
Resolves :attr:`path` to an absolute path and preserves the historical
|
|
87
|
+
quirk that forcing a port disables range probing.
|
|
88
|
+
|
|
89
|
+
:return: This same :class:`Settings` instance, after normalization.
|
|
90
|
+
"""
|
|
91
|
+
self.path = abspath(self.path)
|
|
92
|
+
# Preserve the historical quirk: forcing a port disables range probing.
|
|
93
|
+
if self.force_port:
|
|
94
|
+
self.range = 0
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
#: Config keys that are not part of :class:`Settings` construction directly.
|
|
99
|
+
_BOOL_KEYS = {
|
|
100
|
+
'force_port',
|
|
101
|
+
'future',
|
|
102
|
+
'no_cache',
|
|
103
|
+
'reload',
|
|
104
|
+
'spa',
|
|
105
|
+
'gzip',
|
|
106
|
+
'cors',
|
|
107
|
+
'qr',
|
|
108
|
+
'color',
|
|
109
|
+
'https',
|
|
110
|
+
}
|
|
111
|
+
_INT_KEYS = {'port', 'range', 'verbose'}
|
|
112
|
+
_STR_KEYS = {'path', 'ip', 'cert', 'key', 'theme'}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _settings_field_names() -> set[str]:
|
|
116
|
+
"""
|
|
117
|
+
Return the set of field names declared on :class:`Settings`.
|
|
118
|
+
|
|
119
|
+
:return: The names of all dataclass fields of :class:`Settings`.
|
|
120
|
+
"""
|
|
121
|
+
return {f.name for f in fields(Settings)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def discover_config_file(start: str | Path) -> Path | None:
|
|
125
|
+
"""
|
|
126
|
+
Find a config file by walking up from *start* to the filesystem root.
|
|
127
|
+
|
|
128
|
+
If *start* is a file, the search begins in its parent directory. Each
|
|
129
|
+
directory is checked for any of :data:`CONFIG_FILENAMES`.
|
|
130
|
+
|
|
131
|
+
:param start: Path to begin the upward search from.
|
|
132
|
+
:return: The first matching config file found, or ``None`` if none exists.
|
|
133
|
+
"""
|
|
134
|
+
current = Path(start).resolve()
|
|
135
|
+
if current.is_file():
|
|
136
|
+
current = current.parent
|
|
137
|
+
for directory in (current, *current.parents):
|
|
138
|
+
for name in CONFIG_FILENAMES:
|
|
139
|
+
candidate = directory / name
|
|
140
|
+
if candidate.is_file():
|
|
141
|
+
return candidate
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_config_file(
|
|
146
|
+
config_path: str | Path | None, search_from: str | Path
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
"""
|
|
149
|
+
Load config values from a TOML file.
|
|
150
|
+
|
|
151
|
+
If *config_path* is given it is used directly; otherwise a file is
|
|
152
|
+
auto-discovered starting at *search_from*. Returns an empty mapping when no
|
|
153
|
+
config is found. Supports both a top-level table and a ``[tool.webdev]``
|
|
154
|
+
table (e.g. inside ``pyproject.toml``).
|
|
155
|
+
|
|
156
|
+
:param config_path: Explicit path to a config file, or ``None`` to
|
|
157
|
+
auto-discover one.
|
|
158
|
+
:param search_from: Location to start auto-discovery from when
|
|
159
|
+
*config_path* is ``None``.
|
|
160
|
+
:return: A mapping of known, type-coerced config keys to their values.
|
|
161
|
+
:raises FileNotFoundError: If *config_path* is given but does not exist.
|
|
162
|
+
"""
|
|
163
|
+
path: Path | None
|
|
164
|
+
if config_path is not None:
|
|
165
|
+
path = Path(config_path)
|
|
166
|
+
if not path.is_file():
|
|
167
|
+
raise FileNotFoundError(f'Config file not found: {path}')
|
|
168
|
+
else:
|
|
169
|
+
path = discover_config_file(search_from)
|
|
170
|
+
|
|
171
|
+
if path is None:
|
|
172
|
+
return {}
|
|
173
|
+
|
|
174
|
+
with path.open('rb') as fd:
|
|
175
|
+
data = tomllib.load(fd)
|
|
176
|
+
|
|
177
|
+
# Allow [tool.webdev] (pyproject-style) or a flat table.
|
|
178
|
+
if 'tool' in data and isinstance(data['tool'], dict):
|
|
179
|
+
data = data['tool'].get('webdev', {}) or {}
|
|
180
|
+
elif 'webdev' in data and isinstance(data['webdev'], dict):
|
|
181
|
+
data = data['webdev']
|
|
182
|
+
|
|
183
|
+
return _coerce_config(data)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _coerce_config(data: dict[str, Any]) -> dict[str, Any]:
|
|
187
|
+
"""
|
|
188
|
+
Keep only known keys and coerce them to their expected types.
|
|
189
|
+
|
|
190
|
+
Hyphenated keys are normalized to underscores, unknown keys are dropped,
|
|
191
|
+
and recognized keys are cast to ``bool``, ``int``, or ``str`` as declared.
|
|
192
|
+
|
|
193
|
+
:param data: Raw mapping parsed from the config file.
|
|
194
|
+
:return: A mapping limited to known :class:`Settings` keys, type-coerced.
|
|
195
|
+
"""
|
|
196
|
+
valid = _settings_field_names()
|
|
197
|
+
result: dict[str, Any] = {}
|
|
198
|
+
for key, value in data.items():
|
|
199
|
+
norm = key.replace('-', '_')
|
|
200
|
+
if norm not in valid:
|
|
201
|
+
continue
|
|
202
|
+
if norm in _BOOL_KEYS:
|
|
203
|
+
result[norm] = bool(value)
|
|
204
|
+
elif norm in _INT_KEYS:
|
|
205
|
+
result[norm] = int(value)
|
|
206
|
+
elif norm in _STR_KEYS:
|
|
207
|
+
result[norm] = str(value)
|
|
208
|
+
else:
|
|
209
|
+
result[norm] = value
|
|
210
|
+
return result
|