plain 0.76.0__py3-none-any.whl → 0.78.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.

Potentially problematic release.


This version of plain might be problematic. Click here for more details.

plain/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.78.0](https://github.com/dropseed/plain/releases/plain@0.78.0) (2025-10-17)
4
+
5
+ ### What's changed
6
+
7
+ - Chores have been refactored to use abstract base classes instead of decorated functions ([c4466d3c60](https://github.com/dropseed/plain/commit/c4466d3c60))
8
+ - Added `SHELL_IMPORT` setting to customize what gets automatically imported in `plain shell` ([9055f59c08](https://github.com/dropseed/plain/commit/9055f59c08))
9
+ - Views that return `None` now raise `Http404` instead of returning `ResponseNotFound` ([5bb60016eb](https://github.com/dropseed/plain/commit/5bb60016eb))
10
+ - The `plain chores list` command output formatting now matches the `plain jobs list` format ([4b6881a49e](https://github.com/dropseed/plain/commit/4b6881a49e))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - Update any chores from decorated functions to class-based chores:
15
+
16
+ ```python
17
+ # Before:
18
+ @register_chore("group")
19
+ def chore_name():
20
+ """Description"""
21
+ return "Done!"
22
+
23
+ # After:
24
+ from plain.chores import Chore, register_chore
25
+
26
+ @register_chore
27
+ class ChoreName(Chore):
28
+ """Description"""
29
+
30
+ def run(self):
31
+ return "Done!"
32
+ ```
33
+
34
+ - Import `Chore` base class from `plain.chores` when creating new chores
35
+
36
+ ## [0.77.0](https://github.com/dropseed/plain/releases/plain@0.77.0) (2025-10-13)
37
+
38
+ ### What's changed
39
+
40
+ - The `plain server --reload` now uses `watchfiles` for improved cross-platform file watching ([92e95c5032](https://github.com/dropseed/plain/commit/92e95c5032))
41
+ - Server reloader now watches `.env*` files for changes and triggers automatic reload ([92e95c5032](https://github.com/dropseed/plain/commit/92e95c5032))
42
+ - HTML template additions and deletions now trigger automatic server reload when using `--reload` ([f2f31c288b](https://github.com/dropseed/plain/commit/f2f31c288b))
43
+ - Internal server worker type renamed from "gthread" to "thread" for clarity ([6470748e91](https://github.com/dropseed/plain/commit/6470748e91))
44
+
45
+ ### Upgrade instructions
46
+
47
+ - No changes required
48
+
3
49
  ## [0.76.0](https://github.com/dropseed/plain/releases/plain@0.76.0) (2025-10-12)
4
50
 
5
51
  ### What's changed
plain/chores/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## Overview
10
10
 
11
- Chores are registered functions that can be run at any time to keep an app in a desirable state.
11
+ Chores are registered classes that can be run at any time to keep an app in a desirable state.
12
12
 
13
13
  ![](https://assets.plainframework.com/docs/plain-chores-run.png)
14
14
 
@@ -16,19 +16,19 @@ A good example is the clearing of expired sessions in [`plain.sessions`](/plain-
16
16
 
17
17
  ```python
18
18
  # plain/sessions/chores.py
19
- from plain.chores import register_chore
19
+ from plain.chores import Chore, register_chore
20
20
  from plain.utils import timezone
21
21
 
22
22
  from .models import Session
23
23
 
24
24
 
25
- @register_chore("sessions")
26
- def clear_expired():
27
- """
28
- Delete sessions that have expired.
29
- """
30
- result = Session.query.filter(expires_at__lt=timezone.now()).delete()
31
- return f"{result[0]} expired sessions deleted"
25
+ @register_chore
26
+ class ClearExpired(Chore):
27
+ """Delete sessions that have expired."""
28
+
29
+ def run(self):
30
+ result = Session.query.filter(expires_at__lt=timezone.now()).delete()
31
+ return f"{result[0]} expired sessions deleted"
32
32
  ```
33
33
 
34
34
  ## Running chores
@@ -44,27 +44,29 @@ There are several ways you can run chores depending on your needs:
44
44
 
45
45
  ## Writing chores
46
46
 
47
- A chore is a function decorated with `@register_chore(chore_group_name)`. It can write a description as a docstring, and it can return a value that will be printed when the chore is run.
47
+ A chore is a class that inherits from [`Chore`](./core.py#Chore) and implements the `run()` method. Register the chore using the [`@register_chore`](./registry.py#register_chore) decorator. The chore name is the class's qualified name (`__qualname__`), and the description comes from the class docstring.
48
48
 
49
49
  ```python
50
50
  # app/chores.py
51
- from plain.chores import register_chore
51
+ from plain.chores import Chore, register_chore
52
+
52
53
 
54
+ @register_chore
55
+ class ChoreName(Chore):
56
+ """A chore description can go here."""
53
57
 
54
- @register_chore("app")
55
- def chore_name():
56
- """
57
- A chore description can go here
58
- """
59
- # Do a thing!
60
- return "We did it!"
58
+ def run(self):
59
+ # Do a thing!
60
+ return "We did it!"
61
61
  ```
62
62
 
63
+ ### Best practices
64
+
63
65
  A good chore is:
64
66
 
65
- - Fast
66
- - Idempotent
67
- - Recurring
68
- - Stateless
67
+ - **Fast** - Should complete quickly, not block for long periods
68
+ - **Idempotent** - Safe to run multiple times without side effects
69
+ - **Recurring** - Designed to run regularly, not just once
70
+ - **Stateless** - Doesn't rely on external state between runs
69
71
 
70
72
  If chores are written in `app/chores.py` or `{pkg}/chores.py`, then they will be imported automatically and registered.
plain/chores/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
+ from .core import Chore
1
2
  from .registry import register_chore
2
3
 
3
- __all__ = ["register_chore"]
4
+ __all__ = ["Chore", "register_chore"]
plain/chores/core.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+
7
+ class Chore(ABC):
8
+ """
9
+ Abstract base class for chores.
10
+
11
+ Subclasses must implement:
12
+ - run() method
13
+
14
+ Example:
15
+ @register_chore
16
+ class ClearExpired(Chore):
17
+ '''Delete sessions that have expired.'''
18
+
19
+ def run(self):
20
+ # ... implementation
21
+ return "10 sessions deleted"
22
+ """
23
+
24
+ @abstractmethod
25
+ def run(self) -> Any:
26
+ """Run the chore. Must be implemented by subclasses."""
27
+ pass
plain/chores/registry.py CHANGED
@@ -1,37 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
- from types import FunctionType
4
- from typing import Any
5
-
6
3
  from plain.packages import packages_registry
7
4
 
8
-
9
- class Chore:
10
- def __init__(self, *, group: str, func: FunctionType):
11
- self.group = group
12
- self.func = func
13
- self.name = f"{group}.{func.__name__}"
14
- self.description = func.__doc__.strip() if func.__doc__ else ""
15
-
16
- def __str__(self) -> str:
17
- return self.name
18
-
19
- def run(self) -> Any:
20
- """
21
- Run the chore.
22
- """
23
- return self.func()
5
+ from .core import Chore
24
6
 
25
7
 
26
8
  class ChoresRegistry:
27
- def __init__(self):
28
- self._chores: dict[FunctionType, Chore] = {}
9
+ def __init__(self) -> None:
10
+ self._chores: dict[str, type[Chore]] = {}
29
11
 
30
- def register_chore(self, chore: Chore) -> None:
12
+ def register_chore(self, chore_class: type[Chore]) -> None:
31
13
  """
32
- Register a chore with the specified name.
14
+ Register a chore class.
15
+
16
+ Args:
17
+ chore_class: A Chore subclass to register
33
18
  """
34
- self._chores[chore.func] = chore
19
+ name = f"{chore_class.__module__}.{chore_class.__qualname__}"
20
+ self._chores[name] = chore_class
35
21
 
36
22
  def import_modules(self) -> None:
37
23
  """
@@ -39,9 +25,9 @@ class ChoresRegistry:
39
25
  """
40
26
  packages_registry.autodiscover_modules("chores", include_app=True)
41
27
 
42
- def get_chores(self) -> list[Chore]:
28
+ def get_chores(self) -> list[type[Chore]]:
43
29
  """
44
- Get all registered chores.
30
+ Get all registered chore classes.
45
31
  """
46
32
  return list(self._chores.values())
47
33
 
@@ -49,19 +35,15 @@ class ChoresRegistry:
49
35
  chores_registry = ChoresRegistry()
50
36
 
51
37
 
52
- def register_chore(group: str) -> Any:
38
+ def register_chore(cls: type[Chore]) -> type[Chore]:
53
39
  """
54
- Register a chore with a given group.
40
+ Decorator to register a chore class.
55
41
 
56
42
  Usage:
57
- @register_chore("clear_expired")
58
- def clear_expired():
59
- pass
43
+ @register_chore
44
+ class ClearExpired(Chore):
45
+ def run(self):
46
+ return "Done!"
60
47
  """
61
-
62
- def wrapper(func: FunctionType) -> FunctionType:
63
- chore = Chore(group=group, func=func)
64
- chores_registry.register_chore(chore)
65
- return func
66
-
67
- return wrapper
48
+ chores_registry.register_chore(cls)
49
+ return cls
plain/cli/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  - [Overview](#overview)
6
6
  - [Adding commands](#adding-commands)
7
+ - [Shell](#shell)
7
8
 
8
9
  ## Overview
9
10
 
@@ -39,3 +40,28 @@ An example command!
39
40
  ```
40
41
 
41
42
  Technically you can register a CLI from anywhere, but typically you will do it in either `app/cli.py` or a package's `<pkg>/cli.py`, as those modules will be autoloaded by Plain.
43
+
44
+ ## Shell
45
+
46
+ The `plain shell` command starts an interactive Python shell with your Plain app already loaded.
47
+
48
+ ### SHELL_IMPORT
49
+
50
+ You can customize what gets imported automatically when the shell starts by setting `SHELL_IMPORT` to a module path in your settings:
51
+
52
+ ```python
53
+ # app/settings.py
54
+ SHELL_IMPORT = "app.shell"
55
+ ```
56
+
57
+ Then create that module with the objects you want available:
58
+
59
+ ```python
60
+ # app/shell.py
61
+ from app.projects.models import Project
62
+ from app.users.models import User
63
+
64
+ __all__ = ["Project", "User"]
65
+ ```
66
+
67
+ Now when you run `plain shell`, those objects will be automatically imported and available.
plain/cli/chores.py CHANGED
@@ -13,11 +13,10 @@ def chores() -> None:
13
13
 
14
14
 
15
15
  @chores.command("list")
16
- @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
17
16
  @click.option(
18
17
  "--name", default=None, type=str, help="Name of the chore to run", multiple=True
19
18
  )
20
- def list_chores(group: tuple[str, ...], name: tuple[str, ...]) -> None:
19
+ def list_chores(name: tuple[str, ...]) -> None:
21
20
  """
22
21
  List all registered chores.
23
22
  """
@@ -25,32 +24,33 @@ def list_chores(group: tuple[str, ...], name: tuple[str, ...]) -> None:
25
24
 
26
25
  chores_registry.import_modules()
27
26
 
28
- if group or name:
29
- chores = [
30
- chore
31
- for chore in chores_registry.get_chores()
32
- if (chore.group in group or not group) and (chore.name in name or not name)
27
+ chore_classes = chores_registry.get_chores()
28
+
29
+ if name:
30
+ chore_classes = [
31
+ chore_class
32
+ for chore_class in chore_classes
33
+ if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
33
34
  ]
34
- else:
35
- chores = chores_registry.get_chores()
36
35
 
37
- for chore in chores:
38
- click.secho(f"{chore}", bold=True, nl=False)
39
- if chore.description:
40
- click.echo(f": {chore.description}")
36
+ for chore_class in chore_classes:
37
+ chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
38
+ click.secho(f"{chore_name}", bold=True, nl=False)
39
+ description = chore_class.__doc__.strip() if chore_class.__doc__ else ""
40
+ if description:
41
+ click.secho(f": {description}", dim=True)
41
42
  else:
42
43
  click.echo("")
43
44
 
44
45
 
45
46
  @chores.command("run")
46
- @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
47
47
  @click.option(
48
48
  "--name", default=None, type=str, help="Name of the chore to run", multiple=True
49
49
  )
50
50
  @click.option(
51
51
  "--dry-run", is_flag=True, help="Show what would be done without executing"
52
52
  )
53
- def run_chores(group: tuple[str, ...], name: tuple[str, ...], dry_run: bool) -> None:
53
+ def run_chores(name: tuple[str, ...], dry_run: bool) -> None:
54
54
  """
55
55
  Run the specified chores.
56
56
  """
@@ -58,28 +58,30 @@ def run_chores(group: tuple[str, ...], name: tuple[str, ...], dry_run: bool) ->
58
58
 
59
59
  chores_registry.import_modules()
60
60
 
61
- if group or name:
62
- chores = [
63
- chore
64
- for chore in chores_registry.get_chores()
65
- if (chore.group in group or not group) and (chore.name in name or not name)
61
+ chore_classes = chores_registry.get_chores()
62
+
63
+ if name:
64
+ chore_classes = [
65
+ chore_class
66
+ for chore_class in chore_classes
67
+ if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
66
68
  ]
67
- else:
68
- chores = chores_registry.get_chores()
69
69
 
70
70
  chores_failed = []
71
71
 
72
- for chore in chores:
73
- click.echo(f"{chore.name}:", nl=False)
72
+ for chore_class in chore_classes:
73
+ chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
74
+ click.echo(f"{chore_name}:", nl=False)
74
75
  if dry_run:
75
76
  click.secho(" (dry run)", fg="yellow", nl=False)
76
77
  else:
77
78
  try:
79
+ chore = chore_class()
78
80
  result = chore.run()
79
81
  except Exception:
80
82
  click.secho(" Failed", fg="red")
81
- chores_failed.append(chore)
82
- logger.exception(f"Error running chore {chore.name}")
83
+ chores_failed.append(chore_class)
84
+ logger.exception(f"Error running chore {chore_name}")
83
85
  continue
84
86
 
85
87
  if result is None:
plain/cli/server.py CHANGED
@@ -58,12 +58,6 @@ from plain.cli.runtime import without_runtime_setup
58
58
  is_flag=True,
59
59
  help="Restart workers when code changes (dev only)",
60
60
  )
61
- @click.option(
62
- "--reload-extra-file",
63
- multiple=True,
64
- type=click.Path(exists=True),
65
- help="Additional files to watch for reload (can be used multiple times)",
66
- )
67
61
  @click.option(
68
62
  "--access-log",
69
63
  default="-",
@@ -109,7 +103,6 @@ def server(
109
103
  keyfile: str | None,
110
104
  log_level: str,
111
105
  reload: bool,
112
- reload_extra_file: tuple[str, ...],
113
106
  access_log: str,
114
107
  error_log: str,
115
108
  log_format: str,
@@ -130,7 +123,6 @@ def server(
130
123
  timeout=timeout,
131
124
  max_requests=max_requests,
132
125
  reload=reload,
133
- reload_extra_files=list(reload_extra_file) if reload_extra_file else [],
134
126
  pidfile=pidfile,
135
127
  certfile=certfile,
136
128
  keyfile=keyfile,
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import os.path
5
+ import re
6
+ import sys
7
+ import threading
8
+ from collections.abc import Callable
9
+
10
+ import watchfiles
11
+
12
+ COMPILED_EXT_RE = re.compile(r"py[co]$")
13
+
14
+
15
+ class Reloader(threading.Thread):
16
+ """File change reloader using watchfiles for cross-platform native file watching."""
17
+
18
+ def __init__(self, callback: Callable[[str], None], watch_html: bool) -> None:
19
+ super().__init__()
20
+ self.daemon = True
21
+ self._callback = callback
22
+ self._watch_html = watch_html
23
+
24
+ def get_watch_paths(self) -> set[str]:
25
+ """Get all directories to watch for changes."""
26
+ paths = set()
27
+
28
+ # Get directories from loaded Python modules
29
+ for module in tuple(sys.modules.values()):
30
+ if not hasattr(module, "__file__") or not module.__file__:
31
+ continue
32
+ # Convert .pyc/.pyo to .py and get directory
33
+ file_path = COMPILED_EXT_RE.sub("py", module.__file__)
34
+ dir_path = os.path.dirname(os.path.abspath(file_path))
35
+ if os.path.isdir(dir_path):
36
+ paths.add(dir_path)
37
+
38
+ # Add current working directory for .env files
39
+ cwd = os.getcwd()
40
+ if os.path.isdir(cwd):
41
+ paths.add(cwd)
42
+
43
+ return paths
44
+
45
+ def run(self) -> None:
46
+ """Watch for file changes and trigger callback."""
47
+ watch_paths = self.get_watch_paths()
48
+
49
+ for changes in watchfiles.watch(*watch_paths, rust_timeout=1000):
50
+ for change_type, file_path in changes:
51
+ should_reload = False
52
+ filename = os.path.basename(file_path)
53
+
54
+ # Python files: reload on modify/add
55
+ if change_type in (watchfiles.Change.modified, watchfiles.Change.added):
56
+ if file_path.endswith(".py"):
57
+ should_reload = True
58
+
59
+ # .env files: reload on modify/add/delete
60
+ if change_type in (
61
+ watchfiles.Change.modified,
62
+ watchfiles.Change.added,
63
+ watchfiles.Change.deleted,
64
+ ):
65
+ if filename.startswith(".env"):
66
+ should_reload = True
67
+
68
+ # HTML files: only reload on add/delete (Jinja auto-reloads modifications)
69
+ if self._watch_html and change_type in (
70
+ watchfiles.Change.added,
71
+ watchfiles.Change.deleted,
72
+ ):
73
+ if file_path.endswith(".html"):
74
+ should_reload = True
75
+
76
+ if should_reload:
77
+ self._callback(file_path)
plain/server/README.md CHANGED
@@ -39,8 +39,7 @@ Common options:
39
39
  - `--workers` / `-w` - Number of worker processes (default: 1, or `$WEB_CONCURRENCY` env var)
40
40
  - `--threads` - Number of threads per worker (default: 1)
41
41
  - `--timeout` / `-t` - Worker timeout in seconds (default: 30)
42
- - `--reload` - Enable auto-reload on code changes (default: False)
43
- - `--reload-extra-file` - Additional files to watch for reloading (can be used multiple times)
42
+ - `--reload` - Enable auto-reload on code changes, including `.env*` files (default: False)
44
43
  - `--certfile` - Path to SSL certificate file
45
44
  - `--keyfile` - Path to SSL key file
46
45
  - `--log-level` - Logging level: debug, info, warning, error, critical (default: info)
plain/server/arbiter.py CHANGED
@@ -490,7 +490,7 @@ class Arbiter:
490
490
  # Process Child
491
491
  worker.pid = os.getpid()
492
492
  try:
493
- self.log.info("Plain server worker started pid=%s", worker.pid)
493
+ self.log.info("Server worker started pid=%s", worker.pid)
494
494
  worker.init_process()
495
495
  sys.exit(0)
496
496
  except SystemExit:
@@ -506,7 +506,7 @@ class Arbiter:
506
506
  sys.exit(self.WORKER_BOOT_ERROR)
507
507
  sys.exit(-1)
508
508
  finally:
509
- self.log.info("Worker exiting (pid: %s)", worker.pid)
509
+ self.log.info("Server worker exiting (pid: %s)", worker.pid)
510
510
  try:
511
511
  worker.tmp.close()
512
512
  except Exception:
plain/server/config.py CHANGED
@@ -10,6 +10,8 @@ import os
10
10
  from dataclasses import dataclass
11
11
 
12
12
  from . import util
13
+ from .workers.sync import SyncWorker
14
+ from .workers.thread import ThreadWorker
13
15
 
14
16
 
15
17
  @dataclass
@@ -27,7 +29,6 @@ class Config:
27
29
  timeout: int
28
30
  max_requests: int
29
31
  reload: bool
30
- reload_extra_files: list[str]
31
32
  pidfile: str | None
32
33
  certfile: str | None
33
34
  keyfile: str | None
@@ -41,20 +42,19 @@ class Config:
41
42
  def worker_class_str(self) -> str:
42
43
  # Auto-select based on threads
43
44
  if self.threads > 1:
44
- return "gthread"
45
+ return "thread"
45
46
  return "sync"
46
47
 
47
48
  @property
48
49
  def worker_class(self) -> type:
49
50
  # Auto-select based on threads
50
51
  if self.threads > 1:
51
- uri = "plain.server.workers.gthread.ThreadWorker"
52
+ worker_class = ThreadWorker
52
53
  else:
53
- uri = "plain.server.workers.sync.SyncWorker"
54
+ worker_class = SyncWorker
54
55
 
55
- worker_class = util.load_class(uri)
56
56
  if hasattr(worker_class, "setup"):
57
- worker_class.setup() # type: ignore[call-non-callable] # hasattr check doesn't narrow type
57
+ worker_class.setup()
58
58
  return worker_class
59
59
 
60
60
  @property
plain/server/util.py CHANGED
@@ -10,8 +10,6 @@ import email.utils
10
10
  import errno
11
11
  import fcntl
12
12
  import html
13
- import importlib
14
- import inspect
15
13
  import io
16
14
  import os
17
15
  import random
@@ -20,14 +18,11 @@ import socket
20
18
  import sys
21
19
  import textwrap
22
20
  import time
23
- import traceback
24
21
  import urllib.parse
25
22
  import warnings
26
23
  from collections.abc import Callable
27
24
  from typing import Any
28
25
 
29
- from .workers import SUPPORTED_WORKERS
30
-
31
26
  # Server and Date aren't technically hop-by-hop
32
27
  # headers, but they are in the purview of the
33
28
  # origin server which the WSGI spec says we should
@@ -44,55 +39,6 @@ hop_headers = set(
44
39
  """.split()
45
40
  )
46
41
 
47
-
48
- def load_class(
49
- uri: str | type,
50
- default: str = "plain.server.workers.sync.SyncWorker",
51
- section: str = "plain.server.workers",
52
- ) -> type:
53
- if inspect.isclass(uri):
54
- return uri # type: ignore[return-value]
55
-
56
- components = uri.split(".") # type: ignore[union-attr]
57
- if len(components) == 1:
58
- # Handle short names like "sync" or "gthread"
59
- if uri.startswith("#"): # type: ignore[union-attr]
60
- uri = uri[1:] # type: ignore[union-attr]
61
-
62
- if uri in SUPPORTED_WORKERS:
63
- components = SUPPORTED_WORKERS[uri].split(".")
64
- else:
65
- exc_msg = f"Worker type {uri!r} not found in SUPPORTED_WORKERS"
66
- raise RuntimeError(exc_msg)
67
-
68
- klass = components.pop(-1)
69
-
70
- try:
71
- mod = importlib.import_module(".".join(components))
72
- except Exception:
73
- exc = traceback.format_exc()
74
- msg = "class uri %r invalid or not found: \n\n[%s]"
75
- raise RuntimeError(msg % (uri, exc))
76
- return getattr(mod, klass)
77
-
78
-
79
- positionals = (
80
- inspect.Parameter.POSITIONAL_ONLY,
81
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
82
- )
83
-
84
-
85
- def get_arity(f: Callable[..., Any]) -> int:
86
- sig = inspect.signature(f)
87
- arity = 0
88
-
89
- for param in sig.parameters.values():
90
- if param.kind in positionals:
91
- arity += 1
92
-
93
- return arity
94
-
95
-
96
42
  if sys.platform.startswith("win"):
97
43
 
98
44
  def _waitfor(
@@ -317,19 +263,6 @@ def has_fileno(obj: Any) -> bool:
317
263
  return True
318
264
 
319
265
 
320
- def warn(msg: str) -> None:
321
- print("!!!", file=sys.stderr)
322
-
323
- lines = msg.splitlines()
324
- for i, line in enumerate(lines):
325
- if i == 0:
326
- line = f"WARNING: {line}"
327
- print(f"!!! {line}", file=sys.stderr)
328
-
329
- print("!!!\n", file=sys.stderr)
330
- sys.stderr.flush()
331
-
332
-
333
266
  def make_fail_app(msg: str | bytes) -> Callable[..., Any]:
334
267
  msg = to_bytestring(msg)
335
268
 
@@ -4,9 +4,3 @@
4
4
  # See the LICENSE for more information.
5
5
  #
6
6
  # Vendored and modified for Plain.
7
-
8
- # Supported workers
9
- SUPPORTED_WORKERS = {
10
- "sync": "plain.server.workers.sync.SyncWorker",
11
- "gthread": "plain.server.workers.gthread.ThreadWorker",
12
- }
@@ -17,6 +17,8 @@ from random import randint
17
17
  from ssl import SSLError
18
18
  from typing import TYPE_CHECKING, Any
19
19
 
20
+ from plain.internal.reloader import Reloader
21
+
20
22
  from .. import util
21
23
  from ..http.errors import (
22
24
  ConfigurationProblem,
@@ -32,7 +34,6 @@ from ..http.errors import (
32
34
  UnsupportedTransferCoding,
33
35
  )
34
36
  from ..http.wsgi import Response, default_environ
35
- from ..reloader import reloader_engines
36
37
  from .workertmp import WorkerTmp
37
38
 
38
39
  if TYPE_CHECKING:
@@ -143,16 +144,13 @@ class Worker:
143
144
  if self.cfg.reload:
144
145
 
145
146
  def changed(fname: str) -> None:
146
- self.log.info("Worker reloading: %s modified", fname)
147
+ self.log.debug("Server worker reloading: %s modified", fname)
147
148
  self.alive = False
148
149
  os.write(self.PIPE[1], b"1")
149
150
  time.sleep(0.1)
150
151
  sys.exit(0)
151
152
 
152
- reloader_cls = reloader_engines["auto"]
153
- self.reloader = reloader_cls(
154
- extra_files=self.cfg.reload_extra_files, callback=changed
155
- )
153
+ self.reloader = Reloader(callback=changed, watch_html=True)
156
154
 
157
155
  self.load_wsgi()
158
156
  if self.reloader:
@@ -177,7 +175,6 @@ class Worker:
177
175
  # delete the traceback after use.
178
176
  try:
179
177
  _, exc_val, exc_tb = sys.exc_info()
180
- self.reloader.add_extra_file(exc_val.filename)
181
178
 
182
179
  tb_string = io.StringIO()
183
180
  traceback.print_tb(exc_tb, file=tb_string)
plain/views/base.py CHANGED
@@ -12,12 +12,12 @@ from opentelemetry.semconv._incubating.attributes.code_attributes import (
12
12
  )
13
13
 
14
14
  from plain.http import (
15
+ Http404,
15
16
  JsonResponse,
16
17
  Request,
17
18
  Response,
18
19
  ResponseBase,
19
20
  ResponseNotAllowed,
20
- ResponseNotFound,
21
21
  )
22
22
  from plain.utils.decorators import classonlymethod
23
23
 
@@ -110,8 +110,7 @@ class View:
110
110
  return Response(status_code=value)
111
111
 
112
112
  if value is None:
113
- # TODO raise 404 instead?
114
- return ResponseNotFound()
113
+ raise Http404
115
114
 
116
115
  status_code = 200
117
116
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.76.0
3
+ Version: 0.78.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -9,6 +9,7 @@ Requires-Dist: click>=8.0.0
9
9
  Requires-Dist: jinja2>=3.1.2
10
10
  Requires-Dist: opentelemetry-api>=1.34.1
11
11
  Requires-Dist: opentelemetry-semantic-conventions>=0.55b1
12
+ Requires-Dist: watchfiles>=0.18.0
12
13
  Description-Content-Type: text/markdown
13
14
 
14
15
  # Plain
@@ -1,5 +1,5 @@
1
1
  plain/AGENTS.md,sha256=As6EFSWWHJ9lYIxb2LMRqNRteH45SRs7a_VFslzF53M,1046
2
- plain/CHANGELOG.md,sha256=pAk-j9vETAedPDTgTHXkh_v9wt-DCakHmVKdJzmd1zc,25908
2
+ plain/CHANGELOG.md,sha256=dsVliMKkLQz3IsqxyYeQh4QrFLDkQwyJ-axQ4Jet6aw,27925
3
3
  plain/README.md,sha256=VvzhXNvf4I6ddmjBP9AExxxFXxs7RwyoxdgFm-W5dsg,4072
4
4
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
5
5
  plain/debug.py,sha256=C2OnFHtRGMrpCiHSt-P2r58JypgQZ62qzDBpV4mhtFM,855
@@ -16,14 +16,15 @@ plain/assets/finders.py,sha256=j9sZ2LAJp55hdJ_e1lFevzwicNEMUtv6rLtQgiIkDgY,1589
16
16
  plain/assets/fingerprints.py,sha256=UPJwLmzzqXBp7FPk9XnMkcTps2UW9SnTAv0hGieUnMo,1458
17
17
  plain/assets/urls.py,sha256=Qe0ctXYAQjAlLIKuX6JeLVBOFqzJxggR0RSGkRXmX78,1173
18
18
  plain/assets/views.py,sha256=Zs_wyguh-WljOgW3DiPfpspTVOKC6z3bRSu01-SDcoU,9877
19
- plain/chores/README.md,sha256=ashFQ4kc1h81DqFmOXiGcrmzvJQQMg4ZWdUIFDcJah0,2048
20
- plain/chores/__init__.py,sha256=r9TXtQCH-VbvfnIJ5F8FxgQC35GRWFOfmMZN3q9niLg,67
21
- plain/chores/registry.py,sha256=kKhN7z0XcgGCMnpx9bVWI1klUUl-yAEfcDaLnsSYs6c,1589
22
- plain/cli/README.md,sha256=5C7vsH0ISxu7q5H6buC25MBOILkI_rzdySitswpQgJw,1032
19
+ plain/chores/README.md,sha256=7Dv5MCyqPaohQxAk74HRzKiTvt_yR-IOhbu7R5bdz6M,2437
20
+ plain/chores/__init__.py,sha256=wEdt-oAKS1kz7Ln-puhcCt8XGNdfCP4S3wHESaPU8nI,100
21
+ plain/chores/core.py,sha256=BxsCSJDQvMjYsAH4QhEoW9ZUEAUIwJgTYyHovPGSLjk,578
22
+ plain/chores/registry.py,sha256=IRpx3f6Z1qlqcEpHTe6O6JocNmaplLu7BVOqGrafSXU,1221
23
+ plain/cli/README.md,sha256=Wn6o0fVL-SRMztTTHO71P4QzOvntgO-MqjbRrsk3WAw,1661
23
24
  plain/cli/__init__.py,sha256=6w9T7K2WrPwh6DcaMb2oNt_CWU6Bc57nUTO2Bt1p38Y,63
24
25
  plain/cli/build.py,sha256=Jg5LMbmXHhCXZIYj_Gcjou3yGiEWw8wpbOGGsdk-wZw,3203
25
26
  plain/cli/changelog.py,sha256=yCY887PT_D2viLz9f-uyu07Znqiv2-NEyCBqquNIukw,3590
26
- plain/cli/chores.py,sha256=aaDVTpwBEmyQ4r_YeZ6U7Fw9UOIq5d7okwpq9HdfRbA,2521
27
+ plain/cli/chores.py,sha256=FzQat9ofEZTlCiszGvV3deJwx8G0c-fBhn1mI560Iec,2568
27
28
  plain/cli/core.py,sha256=nL-a7zPtEBIa_XV-VOd9lqEUqmQAhlzsdHNwEmxNkWE,4285
28
29
  plain/cli/docs.py,sha256=PU3v7Z7qgYFG-bClpuDg4JeWwC8uvLYX3ovkQDMseVs,1146
29
30
  plain/cli/formatting.py,sha256=e1doTFalAM11bD_Cvqeu6sTap81JrQcB-4kMjZzAHmY,2737
@@ -34,7 +35,7 @@ plain/cli/print.py,sha256=7kv9ddXpwOHRSWp6FFLfX4wbmhV7neoOBlE0VcXWccw,238
34
35
  plain/cli/registry.py,sha256=Z52nVE2bC2h_B_SENnXctn3mx3UWB0qYg969DVP7XX8,1106
35
36
  plain/cli/runtime.py,sha256=YbGYfwkH0VxfuIMbOCwM9wSWiQKusPn_gVeGod8OFaE,743
36
37
  plain/cli/scaffold.py,sha256=AMAVnTYySgR5nz4sVp3mn0gEGfTKE1N8ZlrVrg2SrFU,1364
37
- plain/cli/server.py,sha256=qe4N-eCnFVuEP5ogIgzU9leurv8gi1riSplvin9TjAI,3297
38
+ plain/cli/server.py,sha256=ngUMtxB90t5FnG7HQwOIkN-pwY6ltKPZZ4PLSqN2Y3E,3001
38
39
  plain/cli/settings.py,sha256=kafbcPzy8khdtzLRyOHRl215va3E7U_h5USOA39UA3k,2008
39
40
  plain/cli/shell.py,sha256=urTp24D4UsKmYi9nT7OOdlT4WhXjkpFVrGYfNNVsXEE,1980
40
41
  plain/cli/startup.py,sha256=1nxXQucDkBxXriEN4wI2tiwG96PBNFndVrOyfzvJFdI,1061
@@ -63,6 +64,7 @@ plain/http/multipartparser.py,sha256=3W9osVGV9LshNF3aAUCBp7OBYTgD6hN2jS7T15BIKCs
63
64
  plain/http/request.py,sha256=ficL1Lh-71tU1SVFKD4beLEJsPk7eesZG0nPPbACMTk,26462
64
65
  plain/http/response.py,sha256=efAJ2M_uwK8EYMXchOk-b0Jrx3Hukch_rPOW9nG5AV8,24842
65
66
  plain/internal/__init__.py,sha256=n2AgdfNelt_tp8CS9JDzHMy_aiTUMPGZiFFwKmNz2fg,262
67
+ plain/internal/reloader.py,sha256=n7B-F-WeUXp37pAnvzKX9tcEbUxHSlYqa4gItyA_zko,2662
66
68
  plain/internal/files/__init__.py,sha256=VctFgox4Q1AWF3klPaoCC5GIw5KeLafYjY5JmN8mAVw,63
67
69
  plain/internal/files/base.py,sha256=TiUIAqBSQCslgAmf5vjrwjbCe2px5Pt0wWLlGc66jXw,4683
68
70
  plain/internal/files/locks.py,sha256=jvLL9kroOo50kUo8dbuajDiFvgSL5NH6x5hudRPPjiQ,4022
@@ -105,17 +107,16 @@ plain/runtime/global_settings.py,sha256=Q-bQP3tNnnuJZvfevGai639RIF_jhd7Dszt-DzTT
105
107
  plain/runtime/user_settings.py,sha256=Tbd0J6bxp18tKmFsQcdlxeFhUQU68PYtsswzQ2IcfNc,11467
106
108
  plain/runtime/utils.py,sha256=sHOv9SWCalBtg32GtZofimM2XaQtf_jAyyf6RQuOlGc,851
107
109
  plain/server/LICENSE,sha256=Xt_dw4qYwQI9qSi2u8yMZeb4HuMRp5tESRKhtvvJBgA,1707
108
- plain/server/README.md,sha256=2dwgogY6rBDUA4dgDrrm7zIK66M_ZdULKNsyuE8UTRE,2731
110
+ plain/server/README.md,sha256=6jXQeZJVDt6Jn-Ff8Q7cZ1Xsh6fYPFfC-rtQ7zSYzag,2661
109
111
  plain/server/__init__.py,sha256=DtRgEcr4IxF4mrtCHloIprk_Q4k1oju4F2VHoyvu4ow,212
110
112
  plain/server/app.py,sha256=ozaqdb-a_T3ps7T5EJwIPM63F_497J4o7kw9Pbq7Ga0,1229
111
- plain/server/arbiter.py,sha256=qqTNgbrL0UzZ8sNUV0QP4Z_nzE4D4Y2smvHL33O-QcE,17403
112
- plain/server/config.py,sha256=LSBi_E5JNmyzNY_XqAPYQ4-rDRw-NqxrMNyvQpFWbzQ,3335
113
+ plain/server/arbiter.py,sha256=89_4CZn6v0kmfF3-h6y5Ax59Tm9vhYiZw7psuoHMe_A,17404
114
+ plain/server/config.py,sha256=-T1w8dbUwwLd898P1HNufe8Kw8VJDYJaeKY-UuKzvTo,3221
113
115
  plain/server/errors.py,sha256=sKl_OJ5Uw-a_r_dZ2o4I8JaKeTrjvY_LR12F6B_p4-g,956
114
116
  plain/server/glogging.py,sha256=Ab49Btbr9UvGRgBk8KGVrzsJaFKN1uD0ZurmIC0GYFY,9692
115
117
  plain/server/pidfile.py,sha256=8Fcl9u7gvUJjY5z01qGAeRsi13_jAM8CRdeyqL3h2i0,2538
116
- plain/server/reloader.py,sha256=x3Oe1qmlprnkNRa-RQeuANdfmpRQvMSFfdTtBPIA5z0,4517
117
118
  plain/server/sock.py,sha256=NFKtlrMstOT3xU2yKI6BLAzv_WE7VEGt3dHDkFPnPSo,6192
118
- plain/server/util.py,sha256=_CjcbHr3ES9lP84s6ficFpZPxrVjntBYqHX4h5lQbCA,10472
119
+ plain/server/util.py,sha256=vlTzH4jk8s-ZxJt0oAz-LaQZq_8lEk86zMDVClluuwY,8758
119
120
  plain/server/http/__init__.py,sha256=kQwTk1l3hYJwVrzr1p-XNAbWYe0icsD7l0ZyGRXMbOI,300
120
121
  plain/server/http/body.py,sha256=cz18F4C_gm9h5XvsStV0ncanBjZO1-pegbmbmiMpsQA,8352
121
122
  plain/server/http/errors.py,sha256=uqrzOzjjdqXfFim64pc58cxPlbsoxRNiLx0WzIJSzkw,3719
@@ -123,10 +124,10 @@ plain/server/http/message.py,sha256=5UdOA7CMqiXW_xy0c3d_rPHOvj5YQQHhbkx4Hf3Ttbc,
123
124
  plain/server/http/parser.py,sha256=TLcqdRS9_008n8MpgLORmjomyZKLuKNzdKA8Cej8mII,1681
124
125
  plain/server/http/unreader.py,sha256=jD2PGZ574FGmQOZlqWfs1VWzp-ttfIso_GzYaId6KYQ,2238
125
126
  plain/server/http/wsgi.py,sha256=D-wVKdgKppDR_ZQd834yXtju60t1Jg5bFc5QEr7Iz38,13897
126
- plain/server/workers/__init__.py,sha256=jRQzuvFZu3pMJqs4JgURZxhxlqBCNiO3do5uIuP8-JA,302
127
- plain/server/workers/base.py,sha256=O0-s-WS0AjTVD8YHGoIGUZupGfYAi9joUkJpwsvMTY0,9761
128
- plain/server/workers/gthread.py,sha256=6F_YfhrlPV3hmxwI8_-jgGq4pCmkQBKVO15Wrg-iQ7Y,13504
127
+ plain/server/workers/__init__.py,sha256=sLq8nrIIf9Wjw5_qQsh6cnHnY7eIqCpzSedKrNmmn7s,145
128
+ plain/server/workers/base.py,sha256=B0aniofq4lT5gYa82W34VTLvEQInOe43QvuG9X14vdE,9602
129
129
  plain/server/workers/sync.py,sha256=I28Icl1aKNIOlVaM76UOqQvjtSetkG-tdc5eiAvAmYA,7272
130
+ plain/server/workers/thread.py,sha256=6F_YfhrlPV3hmxwI8_-jgGq4pCmkQBKVO15Wrg-iQ7Y,13504
130
131
  plain/server/workers/workertmp.py,sha256=egGReVvldlOBQfQGcpLpjt0zvPwR4C_N-UJKG-U_6w4,1299
131
132
  plain/signals/README.md,sha256=XefXqROlDhzw7Z5l_nx6Mhq6n9jjQ-ECGbH0vvhKWYg,272
132
133
  plain/signals/__init__.py,sha256=eAs0kLqptuP6I31dWXeAqRNji3svplpAV4Ez6ktjwXM,131
@@ -181,15 +182,15 @@ plain/utils/timezone.py,sha256=M_I5yvs9NsHbtNBPJgHErvWw9vatzx4M96tRQs5gS3g,6823
181
182
  plain/utils/tree.py,sha256=rj_JpZ2kVD3UExWoKnsRdVCoRjvzkuVOONcHzREjSyw,4766
182
183
  plain/views/README.md,sha256=6mcoSQp60n8qgoIMNDQr29WThpi-NCj8EMqxPNwWpiE,7189
183
184
  plain/views/__init__.py,sha256=a-N1nkklVohJTtz0yD1MMaS0g66HviEjsKydNVVjvVc,392
184
- plain/views/base.py,sha256=fk9zAY5BMVBeM45dWL7A9BMTdUi6eTFMeVDd5kBVdv8,4478
185
+ plain/views/base.py,sha256=yWh6S68PsYcH1dvRdibQIanBYkjo2iJ8IAbR2PTWQrk,4419
185
186
  plain/views/errors.py,sha256=tHD7MNnZcMyiQ46RMAnX1Ne3Zbbkr1zAiVfJyaaLtSQ,1447
186
187
  plain/views/exceptions.py,sha256=-YKH1Jd9Zm_yXiz797PVjJB6VWaPCTXClHIUkG2fq78,198
187
188
  plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
188
189
  plain/views/objects.py,sha256=5y0PoPPo07dQTTcJ_9kZcx0iI1O7regsooAIK4VqXQ0,5579
189
190
  plain/views/redirect.py,sha256=mIpSAFcaEyeLDyv4Fr6g_ektduG4Wfa6s6L-rkdazmM,2154
190
191
  plain/views/templates.py,sha256=9LgDMCv4C7JzLiyw8jHF-i4350ukwgixC_9y4faEGu0,1885
191
- plain-0.76.0.dist-info/METADATA,sha256=ypCMyEzKG6CwHD_QhK2NAYdWYeTNY_-9ZRaWe4RwB3o,4482
192
- plain-0.76.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
193
- plain-0.76.0.dist-info/entry_points.txt,sha256=wvMzY-iREvfqRgyLm77htPp4j_8CQslLoZA15_AnNo8,171
194
- plain-0.76.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
195
- plain-0.76.0.dist-info/RECORD,,
192
+ plain-0.78.0.dist-info/METADATA,sha256=CKPAeh7DtdabJOMtaIdwFIuLWnAtCovImj6qdX9xl4k,4516
193
+ plain-0.78.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
194
+ plain-0.78.0.dist-info/entry_points.txt,sha256=wvMzY-iREvfqRgyLm77htPp4j_8CQslLoZA15_AnNo8,171
195
+ plain-0.78.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
196
+ plain-0.78.0.dist-info/RECORD,,
plain/server/reloader.py DELETED
@@ -1,158 +0,0 @@
1
- from __future__ import annotations
2
-
3
- #
4
- #
5
- # This file is part of gunicorn released under the MIT license.
6
- # See the LICENSE for more information.
7
- #
8
- # Vendored and modified for Plain.
9
- import os
10
- import os.path
11
- import re
12
- import sys
13
- import threading
14
- import time
15
- from collections.abc import Callable, Iterable
16
-
17
- COMPILED_EXT_RE = re.compile(r"py[co]$")
18
-
19
-
20
- class Reloader(threading.Thread):
21
- def __init__(
22
- self,
23
- extra_files: Iterable[str] | None = None,
24
- interval: int = 1,
25
- callback: Callable[[str], None] | None = None,
26
- ) -> None:
27
- super().__init__()
28
- self.daemon = True
29
- self._extra_files: set[str] = set(extra_files or ())
30
- self._interval = interval
31
- self._callback = callback
32
-
33
- def add_extra_file(self, filename: str) -> None:
34
- self._extra_files.add(filename)
35
-
36
- def get_files(self) -> list[str]:
37
- fnames = [
38
- COMPILED_EXT_RE.sub("py", module.__file__) # type: ignore[arg-type]
39
- for module in tuple(sys.modules.values())
40
- if getattr(module, "__file__", None)
41
- ]
42
-
43
- fnames.extend(self._extra_files)
44
-
45
- return fnames
46
-
47
- def run(self) -> None:
48
- mtimes: dict[str, float] = {}
49
- while True:
50
- for filename in self.get_files():
51
- try:
52
- mtime = os.stat(filename).st_mtime
53
- except OSError:
54
- continue
55
- old_time = mtimes.get(filename)
56
- if old_time is None:
57
- mtimes[filename] = mtime
58
- continue
59
- elif mtime > old_time:
60
- if self._callback:
61
- self._callback(filename)
62
- time.sleep(self._interval)
63
-
64
-
65
- has_inotify = False
66
- if sys.platform.startswith("linux"):
67
- try:
68
- import inotify.constants
69
- from inotify.adapters import Inotify
70
-
71
- has_inotify = True
72
- except ImportError:
73
- pass
74
-
75
-
76
- if has_inotify:
77
-
78
- class InotifyReloader(threading.Thread):
79
- event_mask = (
80
- inotify.constants.IN_CREATE
81
- | inotify.constants.IN_DELETE
82
- | inotify.constants.IN_DELETE_SELF
83
- | inotify.constants.IN_MODIFY
84
- | inotify.constants.IN_MOVE_SELF
85
- | inotify.constants.IN_MOVED_FROM
86
- | inotify.constants.IN_MOVED_TO
87
- )
88
-
89
- def __init__(
90
- self,
91
- extra_files: Iterable[str] | None = None,
92
- callback: Callable[[str], None] | None = None,
93
- ) -> None:
94
- super().__init__()
95
- self.daemon = True
96
- self._callback = callback
97
- self._dirs: set[str] = set()
98
- self._watcher = Inotify()
99
-
100
- if extra_files:
101
- for extra_file in extra_files:
102
- self.add_extra_file(extra_file)
103
-
104
- def add_extra_file(self, filename: str) -> None:
105
- dirname = os.path.dirname(filename)
106
-
107
- if dirname in self._dirs:
108
- return None
109
-
110
- self._watcher.add_watch(dirname, mask=self.event_mask)
111
- self._dirs.add(dirname)
112
-
113
- def get_dirs(self) -> set[str]:
114
- fnames = [
115
- os.path.dirname(
116
- os.path.abspath(COMPILED_EXT_RE.sub("py", module.__file__)) # type: ignore[arg-type]
117
- )
118
- for module in tuple(sys.modules.values())
119
- if getattr(module, "__file__", None)
120
- ]
121
-
122
- return set(fnames)
123
-
124
- def run(self) -> None:
125
- self._dirs = self.get_dirs()
126
-
127
- for dirname in self._dirs:
128
- if os.path.isdir(dirname):
129
- self._watcher.add_watch(dirname, mask=self.event_mask)
130
-
131
- for event in self._watcher.event_gen(): # type: ignore[attr-defined]
132
- if event is None:
133
- continue
134
-
135
- filename = event[3] # type: ignore[index]
136
-
137
- self._callback(filename) # type: ignore[misc]
138
-
139
- else:
140
-
141
- class InotifyReloader:
142
- def __init__(
143
- self,
144
- extra_files: Iterable[str] | None = None,
145
- callback: Callable[[str], None] | None = None,
146
- ) -> None:
147
- raise ImportError(
148
- "You must have the inotify module installed to use the inotify reloader"
149
- )
150
-
151
-
152
- preferred_reloader = InotifyReloader if has_inotify else Reloader
153
-
154
- reloader_engines = {
155
- "auto": preferred_reloader,
156
- "poll": Reloader,
157
- "inotify": InotifyReloader,
158
- }
File without changes
File without changes