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 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