plain 0.28.0__tar.gz → 0.30.0__tar.gz

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.
Files changed (154) hide show
  1. {plain-0.28.0 → plain-0.30.0}/PKG-INFO +1 -1
  2. {plain-0.28.0 → plain-0.30.0}/plain/__main__.py +1 -1
  3. {plain-0.28.0 → plain-0.30.0}/plain/assets/urls.py +3 -4
  4. {plain-0.28.0 → plain-0.30.0}/plain/assets/views.py +2 -2
  5. {plain-0.28.0 → plain-0.30.0}/plain/cli/README.md +0 -16
  6. plain-0.30.0/plain/cli/__init__.py +3 -0
  7. plain-0.28.0/plain/cli/cli.py → plain-0.30.0/plain/cli/core.py +20 -24
  8. plain-0.30.0/plain/cli/registry.py +62 -0
  9. {plain-0.28.0 → plain-0.30.0}/plain/packages/config.py +0 -2
  10. {plain-0.28.0 → plain-0.30.0}/plain/preflight/urls.py +3 -2
  11. {plain-0.28.0 → plain-0.30.0}/plain/runtime/global_settings.py +1 -1
  12. {plain-0.28.0 → plain-0.30.0}/plain/urls/__init__.py +2 -3
  13. {plain-0.28.0 → plain-0.30.0}/plain/urls/resolvers.py +13 -17
  14. {plain-0.28.0 → plain-0.30.0}/plain/urls/routers.py +12 -50
  15. {plain-0.28.0 → plain-0.30.0}/pyproject.toml +2 -2
  16. {plain-0.28.0 → plain-0.30.0}/tests/app/settings.py +2 -0
  17. {plain-0.28.0 → plain-0.30.0}/tests/app/urls.py +2 -3
  18. plain-0.28.0/plain/cli/__init__.py +0 -3
  19. plain-0.28.0/plain/cli/packages.py +0 -94
  20. {plain-0.28.0 → plain-0.30.0}/.gitignore +0 -0
  21. {plain-0.28.0 → plain-0.30.0}/LICENSE +0 -0
  22. {plain-0.28.0 → plain-0.30.0}/README.md +0 -0
  23. {plain-0.28.0 → plain-0.30.0}/plain/README.md +0 -0
  24. {plain-0.28.0 → plain-0.30.0}/plain/assets/README.md +0 -0
  25. {plain-0.28.0 → plain-0.30.0}/plain/assets/__init__.py +0 -0
  26. {plain-0.28.0 → plain-0.30.0}/plain/assets/compile.py +0 -0
  27. {plain-0.28.0 → plain-0.30.0}/plain/assets/finders.py +0 -0
  28. {plain-0.28.0 → plain-0.30.0}/plain/assets/fingerprints.py +0 -0
  29. {plain-0.28.0 → plain-0.30.0}/plain/cli/formatting.py +0 -0
  30. {plain-0.28.0 → plain-0.30.0}/plain/cli/print.py +0 -0
  31. {plain-0.28.0 → plain-0.30.0}/plain/cli/startup.py +0 -0
  32. {plain-0.28.0 → plain-0.30.0}/plain/csrf/README.md +0 -0
  33. {plain-0.28.0 → plain-0.30.0}/plain/csrf/middleware.py +0 -0
  34. {plain-0.28.0 → plain-0.30.0}/plain/csrf/views.py +0 -0
  35. {plain-0.28.0 → plain-0.30.0}/plain/debug.py +0 -0
  36. {plain-0.28.0 → plain-0.30.0}/plain/exceptions.py +0 -0
  37. {plain-0.28.0 → plain-0.30.0}/plain/forms/README.md +0 -0
  38. {plain-0.28.0 → plain-0.30.0}/plain/forms/__init__.py +0 -0
  39. {plain-0.28.0 → plain-0.30.0}/plain/forms/boundfield.py +0 -0
  40. {plain-0.28.0 → plain-0.30.0}/plain/forms/exceptions.py +0 -0
  41. {plain-0.28.0 → plain-0.30.0}/plain/forms/fields.py +0 -0
  42. {plain-0.28.0 → plain-0.30.0}/plain/forms/forms.py +0 -0
  43. {plain-0.28.0 → plain-0.30.0}/plain/http/README.md +0 -0
  44. {plain-0.28.0 → plain-0.30.0}/plain/http/__init__.py +0 -0
  45. {plain-0.28.0 → plain-0.30.0}/plain/http/cookie.py +0 -0
  46. {plain-0.28.0 → plain-0.30.0}/plain/http/multipartparser.py +0 -0
  47. {plain-0.28.0 → plain-0.30.0}/plain/http/request.py +0 -0
  48. {plain-0.28.0 → plain-0.30.0}/plain/http/response.py +0 -0
  49. {plain-0.28.0 → plain-0.30.0}/plain/internal/__init__.py +0 -0
  50. {plain-0.28.0 → plain-0.30.0}/plain/internal/files/README.md +0 -0
  51. {plain-0.28.0 → plain-0.30.0}/plain/internal/files/__init__.py +0 -0
  52. {plain-0.28.0 → plain-0.30.0}/plain/internal/files/base.py +0 -0
  53. {plain-0.28.0 → plain-0.30.0}/plain/internal/files/locks.py +0 -0
  54. {plain-0.28.0 → plain-0.30.0}/plain/internal/files/move.py +0 -0
  55. {plain-0.28.0 → plain-0.30.0}/plain/internal/files/temp.py +0 -0
  56. {plain-0.28.0 → plain-0.30.0}/plain/internal/files/uploadedfile.py +0 -0
  57. {plain-0.28.0 → plain-0.30.0}/plain/internal/files/uploadhandler.py +0 -0
  58. {plain-0.28.0 → plain-0.30.0}/plain/internal/files/utils.py +0 -0
  59. {plain-0.28.0 → plain-0.30.0}/plain/internal/handlers/__init__.py +0 -0
  60. {plain-0.28.0 → plain-0.30.0}/plain/internal/handlers/base.py +0 -0
  61. {plain-0.28.0 → plain-0.30.0}/plain/internal/handlers/exception.py +0 -0
  62. {plain-0.28.0 → plain-0.30.0}/plain/internal/handlers/wsgi.py +0 -0
  63. {plain-0.28.0 → plain-0.30.0}/plain/internal/middleware/__init__.py +0 -0
  64. {plain-0.28.0 → plain-0.30.0}/plain/internal/middleware/headers.py +0 -0
  65. {plain-0.28.0 → plain-0.30.0}/plain/internal/middleware/https.py +0 -0
  66. {plain-0.28.0 → plain-0.30.0}/plain/internal/middleware/slash.py +0 -0
  67. {plain-0.28.0 → plain-0.30.0}/plain/json.py +0 -0
  68. {plain-0.28.0 → plain-0.30.0}/plain/logs/README.md +0 -0
  69. {plain-0.28.0 → plain-0.30.0}/plain/logs/__init__.py +0 -0
  70. {plain-0.28.0 → plain-0.30.0}/plain/logs/configure.py +0 -0
  71. {plain-0.28.0 → plain-0.30.0}/plain/logs/loggers.py +0 -0
  72. {plain-0.28.0 → plain-0.30.0}/plain/logs/utils.py +0 -0
  73. {plain-0.28.0 → plain-0.30.0}/plain/packages/README.md +0 -0
  74. {plain-0.28.0 → plain-0.30.0}/plain/packages/__init__.py +0 -0
  75. {plain-0.28.0 → plain-0.30.0}/plain/packages/registry.py +0 -0
  76. {plain-0.28.0 → plain-0.30.0}/plain/paginator.py +0 -0
  77. {plain-0.28.0 → plain-0.30.0}/plain/preflight/README.md +0 -0
  78. {plain-0.28.0 → plain-0.30.0}/plain/preflight/__init__.py +0 -0
  79. {plain-0.28.0 → plain-0.30.0}/plain/preflight/files.py +0 -0
  80. {plain-0.28.0 → plain-0.30.0}/plain/preflight/messages.py +0 -0
  81. {plain-0.28.0 → plain-0.30.0}/plain/preflight/registry.py +0 -0
  82. {plain-0.28.0 → plain-0.30.0}/plain/preflight/security.py +0 -0
  83. {plain-0.28.0 → plain-0.30.0}/plain/runtime/README.md +0 -0
  84. {plain-0.28.0 → plain-0.30.0}/plain/runtime/__init__.py +0 -0
  85. {plain-0.28.0 → plain-0.30.0}/plain/runtime/user_settings.py +0 -0
  86. {plain-0.28.0 → plain-0.30.0}/plain/signals/README.md +0 -0
  87. {plain-0.28.0 → plain-0.30.0}/plain/signals/__init__.py +0 -0
  88. {plain-0.28.0 → plain-0.30.0}/plain/signals/dispatch/__init__.py +0 -0
  89. {plain-0.28.0 → plain-0.30.0}/plain/signals/dispatch/dispatcher.py +0 -0
  90. {plain-0.28.0 → plain-0.30.0}/plain/signals/dispatch/license.txt +0 -0
  91. {plain-0.28.0 → plain-0.30.0}/plain/signing.py +0 -0
  92. {plain-0.28.0 → plain-0.30.0}/plain/templates/README.md +0 -0
  93. {plain-0.28.0 → plain-0.30.0}/plain/templates/__init__.py +0 -0
  94. {plain-0.28.0 → plain-0.30.0}/plain/templates/core.py +0 -0
  95. {plain-0.28.0 → plain-0.30.0}/plain/templates/jinja/README.md +0 -0
  96. {plain-0.28.0 → plain-0.30.0}/plain/templates/jinja/__init__.py +0 -0
  97. {plain-0.28.0 → plain-0.30.0}/plain/templates/jinja/environments.py +0 -0
  98. {plain-0.28.0 → plain-0.30.0}/plain/templates/jinja/extensions.py +0 -0
  99. {plain-0.28.0 → plain-0.30.0}/plain/templates/jinja/filters.py +0 -0
  100. {plain-0.28.0 → plain-0.30.0}/plain/templates/jinja/globals.py +0 -0
  101. {plain-0.28.0 → plain-0.30.0}/plain/test/README.md +0 -0
  102. {plain-0.28.0 → plain-0.30.0}/plain/test/__init__.py +0 -0
  103. {plain-0.28.0 → plain-0.30.0}/plain/test/client.py +0 -0
  104. {plain-0.28.0 → plain-0.30.0}/plain/urls/README.md +0 -0
  105. {plain-0.28.0 → plain-0.30.0}/plain/urls/converters.py +0 -0
  106. {plain-0.28.0 → plain-0.30.0}/plain/urls/exceptions.py +0 -0
  107. {plain-0.28.0 → plain-0.30.0}/plain/urls/patterns.py +0 -0
  108. {plain-0.28.0 → plain-0.30.0}/plain/urls/utils.py +0 -0
  109. {plain-0.28.0 → plain-0.30.0}/plain/utils/README.md +0 -0
  110. {plain-0.28.0 → plain-0.30.0}/plain/utils/__init__.py +0 -0
  111. {plain-0.28.0 → plain-0.30.0}/plain/utils/cache.py +0 -0
  112. {plain-0.28.0 → plain-0.30.0}/plain/utils/connection.py +0 -0
  113. {plain-0.28.0 → plain-0.30.0}/plain/utils/crypto.py +0 -0
  114. {plain-0.28.0 → plain-0.30.0}/plain/utils/datastructures.py +0 -0
  115. {plain-0.28.0 → plain-0.30.0}/plain/utils/dateparse.py +0 -0
  116. {plain-0.28.0 → plain-0.30.0}/plain/utils/deconstruct.py +0 -0
  117. {plain-0.28.0 → plain-0.30.0}/plain/utils/decorators.py +0 -0
  118. {plain-0.28.0 → plain-0.30.0}/plain/utils/duration.py +0 -0
  119. {plain-0.28.0 → plain-0.30.0}/plain/utils/encoding.py +0 -0
  120. {plain-0.28.0 → plain-0.30.0}/plain/utils/functional.py +0 -0
  121. {plain-0.28.0 → plain-0.30.0}/plain/utils/hashable.py +0 -0
  122. {plain-0.28.0 → plain-0.30.0}/plain/utils/html.py +0 -0
  123. {plain-0.28.0 → plain-0.30.0}/plain/utils/http.py +0 -0
  124. {plain-0.28.0 → plain-0.30.0}/plain/utils/inspect.py +0 -0
  125. {plain-0.28.0 → plain-0.30.0}/plain/utils/ipv6.py +0 -0
  126. {plain-0.28.0 → plain-0.30.0}/plain/utils/itercompat.py +0 -0
  127. {plain-0.28.0 → plain-0.30.0}/plain/utils/module_loading.py +0 -0
  128. {plain-0.28.0 → plain-0.30.0}/plain/utils/regex_helper.py +0 -0
  129. {plain-0.28.0 → plain-0.30.0}/plain/utils/safestring.py +0 -0
  130. {plain-0.28.0 → plain-0.30.0}/plain/utils/text.py +0 -0
  131. {plain-0.28.0 → plain-0.30.0}/plain/utils/timesince.py +0 -0
  132. {plain-0.28.0 → plain-0.30.0}/plain/utils/timezone.py +0 -0
  133. {plain-0.28.0 → plain-0.30.0}/plain/utils/tree.py +0 -0
  134. {plain-0.28.0 → plain-0.30.0}/plain/validators.py +0 -0
  135. {plain-0.28.0 → plain-0.30.0}/plain/views/README.md +0 -0
  136. {plain-0.28.0 → plain-0.30.0}/plain/views/__init__.py +0 -0
  137. {plain-0.28.0 → plain-0.30.0}/plain/views/base.py +0 -0
  138. {plain-0.28.0 → plain-0.30.0}/plain/views/csrf.py +0 -0
  139. {plain-0.28.0 → plain-0.30.0}/plain/views/errors.py +0 -0
  140. {plain-0.28.0 → plain-0.30.0}/plain/views/exceptions.py +0 -0
  141. {plain-0.28.0 → plain-0.30.0}/plain/views/forms.py +0 -0
  142. {plain-0.28.0 → plain-0.30.0}/plain/views/objects.py +0 -0
  143. {plain-0.28.0 → plain-0.30.0}/plain/views/redirect.py +0 -0
  144. {plain-0.28.0 → plain-0.30.0}/plain/views/templates.py +0 -0
  145. {plain-0.28.0 → plain-0.30.0}/plain/wsgi.py +0 -0
  146. {plain-0.28.0 → plain-0.30.0}/tests/.bolt/assets_collected/assets.json +0 -0
  147. {plain-0.28.0 → plain-0.30.0}/tests/.gitignore +0 -0
  148. {plain-0.28.0 → plain-0.30.0}/tests/app/.gitignore +0 -0
  149. {plain-0.28.0 → plain-0.30.0}/tests/app/test/__init__.py +0 -0
  150. {plain-0.28.0 → plain-0.30.0}/tests/app/test/default_settings.py +0 -0
  151. {plain-0.28.0 → plain-0.30.0}/tests/conftest.py +0 -0
  152. {plain-0.28.0 → plain-0.30.0}/tests/test_cli.py +0 -0
  153. {plain-0.28.0 → plain-0.30.0}/tests/test_runtime.py +0 -0
  154. {plain-0.28.0 → plain-0.30.0}/tests/test_wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.28.0
3
+ Version: 0.30.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
@@ -1,4 +1,4 @@
1
- from .cli import cli
1
+ from .cli.core import cli
2
2
 
3
3
  # Make the CLI available as `python -m plain`
4
4
  if __name__ == "__main__":
@@ -1,12 +1,11 @@
1
1
  from plain.runtime import settings
2
- from plain.urls import RouterBase, path, register_router, reverse
2
+ from plain.urls import Router, path, reverse
3
3
 
4
4
  from .fingerprints import get_fingerprinted_url_path
5
5
  from .views import AssetView
6
6
 
7
7
 
8
- @register_router
9
- class Router(RouterBase):
8
+ class AssetsRouter(Router):
10
9
  namespace = "assets"
11
10
  urls = [
12
11
  path("<path:path>", AssetView, name="asset"),
@@ -29,4 +28,4 @@ def get_asset_url(url_path):
29
28
  if settings.ASSETS_BASE_URL:
30
29
  return settings.ASSETS_BASE_URL + resolved_url_path
31
30
 
32
- return reverse(Router.namespace + ":asset", path=resolved_url_path)
31
+ return reverse(AssetsRouter.namespace + ":asset", path=resolved_url_path)
@@ -206,9 +206,9 @@ class AssetView(View):
206
206
  # or we're already looking at it.
207
207
  return
208
208
 
209
- from .urls import Router
209
+ from .urls import AssetsRouter
210
210
 
211
- namespace = Router.namespace
211
+ namespace = AssetsRouter.namespace
212
212
 
213
213
  return ResponseRedirect(
214
214
  redirect_to=reverse(f"{namespace}:asset", fingerprinted_url_path),
@@ -99,19 +99,3 @@ then any commands you defined.
99
99
  $ plain <pkg> hello
100
100
  Hello, world!
101
101
  ```
102
-
103
- ### Add CLI commands to published packages
104
-
105
- Some packages, like [plain-dev](https://plainframework.com/docs/plain-dev/),
106
- never show up in `INSTALLED_PACKAGES` but still have CLI commands.
107
- These are detected via Python entry points.
108
-
109
- An example with `pyproject.toml` and UV:
110
-
111
- ```toml
112
- # pyproject.toml
113
- [project.entry-points."plain.cli"]
114
- "dev" = "plain.dev:cli"
115
- "pre-commit" = "plain.dev.precommit:cli"
116
- "contrib" = "plain.dev.contribute:cli"
117
- ```
@@ -0,0 +1,3 @@
1
+ from .registry import register_cli
2
+
3
+ __all__ = ["register_cli"]
@@ -1,4 +1,3 @@
1
- import importlib
2
1
  import os
3
2
  import shutil
4
3
  import subprocess
@@ -6,7 +5,6 @@ import sys
6
5
  import tomllib
7
6
  import traceback
8
7
  from importlib.metadata import entry_points
9
- from importlib.util import find_spec
10
8
  from pathlib import Path
11
9
 
12
10
  import click
@@ -20,7 +18,7 @@ from plain.packages import packages_registry
20
18
  from plain.utils.crypto import get_random_string
21
19
 
22
20
  from .formatting import PlainContext
23
- from .packages import EntryPointGroup, InstalledPackagesGroup
21
+ from .registry import cli_registry
24
22
 
25
23
 
26
24
  @click.group()
@@ -381,10 +379,10 @@ def create(package_name):
381
379
  # Create a urls.py file with a default namespace
382
380
  if not (package_dir / "urls.py").exists():
383
381
  (package_dir / "urls.py").write_text(
384
- f"""from plain.urls import path, RouterBase, register_router
382
+ f"""from plain.urls import path, Router
385
383
 
386
- @register_router
387
- class Router(RouterBase):
384
+
385
+ class {package_name.capitalize()}Router(Router):
388
386
  namespace = f"{package_name}"
389
387
  urls = [
390
388
  # path("", views.IndexView, name="index"),
@@ -424,10 +422,15 @@ def generate_secret_key():
424
422
  @plain_cli.command()
425
423
  @click.option("--flat", is_flag=True, help="List all URLs in a flat list")
426
424
  def urls(flat):
427
- """Print all URL patterns under settings.URLS_MODULE"""
425
+ """Print all URL patterns under settings.URLS_ROUTER"""
426
+ from plain.runtime import settings
428
427
  from plain.urls import URLResolver, get_resolver
429
428
 
430
- resolver = get_resolver()
429
+ if not settings.URLS_ROUTER:
430
+ click.secho("URLS_ROUTER is not set", fg="red")
431
+ sys.exit(1)
432
+
433
+ resolver = get_resolver(settings.URLS_ROUTER)
431
434
  if flat:
432
435
 
433
436
  def flat_list(patterns, prefix="", curr_ns=""):
@@ -500,25 +503,21 @@ def urls(flat):
500
503
  print_tree(resolver.url_patterns)
501
504
 
502
505
 
503
- class AppCLIGroup(click.Group):
506
+ class CLIRegistryGroup(click.Group):
504
507
  """
505
- Loads app.cli if it exists as `plain app`
508
+ Click Group that exposes commands from the CLI registry.
506
509
  """
507
510
 
508
- MODULE_NAME = "app.cli"
511
+ def __init__(self, *args, **kwargs):
512
+ super().__init__(*args, **kwargs)
513
+ cli_registry.import_modules()
509
514
 
510
515
  def list_commands(self, ctx):
511
- if find_spec(self.MODULE_NAME):
512
- return ["app"]
513
- else:
514
- return []
516
+ return sorted(cli_registry.get_commands().keys())
515
517
 
516
518
  def get_command(self, ctx, name):
517
- if name != "app":
518
- return
519
-
520
- cli = importlib.import_module(self.MODULE_NAME)
521
- return cli.cli
519
+ commands = cli_registry.get_commands()
520
+ return commands.get(name)
522
521
 
523
522
 
524
523
  class PlainCommandCollection(click.CommandCollection):
@@ -531,9 +530,7 @@ class PlainCommandCollection(click.CommandCollection):
531
530
  plain.runtime.setup()
532
531
 
533
532
  sources = [
534
- InstalledPackagesGroup(),
535
- EntryPointGroup(),
536
- AppCLIGroup(),
533
+ CLIRegistryGroup(),
537
534
  plain_cli,
538
535
  ]
539
536
  except plain.runtime.AppPathNotFound:
@@ -545,7 +542,6 @@ class PlainCommandCollection(click.CommandCollection):
545
542
  )
546
543
 
547
544
  sources = [
548
- EntryPointGroup(),
549
545
  plain_cli,
550
546
  ]
551
547
  except ImproperlyConfigured as e:
@@ -0,0 +1,62 @@
1
+ from importlib import import_module
2
+ from importlib.util import find_spec
3
+
4
+ from plain.packages import packages_registry
5
+
6
+
7
+ class CLIRegistry:
8
+ def __init__(self):
9
+ self._commands = {}
10
+
11
+ def register_command(self, cmd, name):
12
+ """
13
+ Register a CLI command or group with the specified name.
14
+ """
15
+ self._commands[name] = cmd
16
+
17
+ def import_modules(self):
18
+ """
19
+ Import modules from installed packages and app to trigger registration.
20
+ """
21
+ # Import from installed packages
22
+ for package_config in packages_registry.get_package_configs():
23
+ import_name = f"{package_config.name}.cli"
24
+ try:
25
+ import_module(import_name)
26
+ except ModuleNotFoundError:
27
+ pass
28
+
29
+ # Import from app
30
+ import_name = "app.cli"
31
+ if find_spec(import_name):
32
+ try:
33
+ import_module(import_name)
34
+ except ModuleNotFoundError:
35
+ pass
36
+
37
+ def get_commands(self):
38
+ """
39
+ Get all registered commands.
40
+ """
41
+ return self._commands
42
+
43
+
44
+ cli_registry = CLIRegistry()
45
+
46
+
47
+ def register_cli(name):
48
+ """
49
+ Register a CLI command or group with the given name.
50
+
51
+ Usage:
52
+ @register_cli("users")
53
+ @click.group()
54
+ def users_cli():
55
+ pass
56
+ """
57
+
58
+ def wrapper(cmd):
59
+ cli_registry.register_command(cmd, name)
60
+ return cmd
61
+
62
+ return wrapper
@@ -10,8 +10,6 @@ CONFIG_MODULE_NAME = "config"
10
10
  class PackageConfig:
11
11
  """Class representing a Plain application and its configuration."""
12
12
 
13
- migrations_module = "migrations"
14
-
15
13
  def __init__(self, name, *, label=""):
16
14
  # Full Python path to the application e.g. 'plain.admin.admin'.
17
15
  self.name = name
@@ -7,11 +7,12 @@ from . import Error, Warning, register
7
7
 
8
8
  @register
9
9
  def check_url_config(package_configs, **kwargs):
10
- if getattr(settings, "URLS_MODULE", None):
10
+ if getattr(settings, "URLS_ROUTER", None):
11
11
  from plain.urls import get_resolver
12
12
 
13
13
  resolver = get_resolver()
14
14
  return check_resolver(resolver)
15
+
15
16
  return []
16
17
 
17
18
 
@@ -33,7 +34,7 @@ def check_url_namespaces_unique(package_configs, **kwargs):
33
34
  """
34
35
  Warn if URL namespaces used in applications aren't unique.
35
36
  """
36
- if not getattr(settings, "URLS_MODULE", None):
37
+ if not getattr(settings, "URLS_ROUTER", None):
37
38
  return []
38
39
 
39
40
  from plain.urls import get_resolver
@@ -74,7 +74,7 @@ SECRET_KEY: str
74
74
  # secret key rotation.
75
75
  SECRET_KEY_FALLBACKS: list[str] = []
76
76
 
77
- URLS_MODULE = "app.urls"
77
+ URLS_ROUTER: str
78
78
 
79
79
  # List of upload handler classes to be applied in order.
80
80
  FILE_UPLOAD_HANDLERS = [
@@ -6,7 +6,7 @@ from .resolvers import (
6
6
  URLResolver,
7
7
  get_resolver,
8
8
  )
9
- from .routers import RouterBase, include, path, register_router
9
+ from .routers import Router, include, path
10
10
  from .utils import (
11
11
  reverse,
12
12
  reverse_lazy,
@@ -24,6 +24,5 @@ __all__ = [
24
24
  "register_converter",
25
25
  "reverse",
26
26
  "reverse_lazy",
27
- "RouterBase",
28
- "register_router",
27
+ "Router",
29
28
  ]
@@ -8,7 +8,6 @@ attributes of the resolved URL match.
8
8
 
9
9
  import functools
10
10
  import re
11
- from importlib import import_module
12
11
  from pickle import PicklingError
13
12
  from threading import local
14
13
  from urllib.parse import quote
@@ -17,6 +16,7 @@ from plain.preflight.urls import check_resolver
17
16
  from plain.runtime import settings
18
17
  from plain.utils.datastructures import MultiValueDict
19
18
  from plain.utils.http import RFC3986_SUBDELIMS, escape_leading_slashes
19
+ from plain.utils.module_loading import import_string
20
20
  from plain.utils.regex_helper import normalize
21
21
 
22
22
  from .exceptions import NoReverseMatch, Resolver404
@@ -87,30 +87,26 @@ class ResolverMatch:
87
87
  raise PicklingError(f"Cannot pickle {self.__class__.__qualname__}.")
88
88
 
89
89
 
90
- def get_resolver(urls_module=None):
91
- if urls_module is None:
92
- urls_module = settings.URLS_MODULE
90
+ def get_resolver(router=None):
91
+ if router is None:
92
+ router = settings.URLS_ROUTER
93
93
 
94
- return _get_cached_resolver(urls_module)
94
+ return _get_cached_resolver(router)
95
95
 
96
96
 
97
97
  @functools.cache
98
- def _get_cached_resolver(urls_module):
99
- from .routers import routers_registry
98
+ def _get_cached_resolver(router):
99
+ if isinstance(router, str):
100
+ # Do this inside the cached call, primarily for the URLS_ROUTER
101
+ router_class = import_string(router)
102
+ router = router_class()
100
103
 
101
- if isinstance(urls_module, str):
102
- # Need to trigger an import in order for the @register_router
103
- # decorators to run. So this is a sensible entrypoint to do that,
104
- # usually just for the root URLS_MODULE but could be for anything.
105
- urls_module = import_module(urls_module)
106
-
107
- router = routers_registry.get_module_router(urls_module)
108
104
  return URLResolver(pattern=RegexPattern(r"^/"), router=router)
109
105
 
110
106
 
111
107
  @functools.cache
112
108
  def get_ns_resolver(ns_pattern, resolver, converters):
113
- from .routers import RouterBase
109
+ from .routers import Router
114
110
 
115
111
  # Build a namespaced resolver for the given parent urls_module pattern.
116
112
  # This makes it possible to have captured parameters in the parent
@@ -118,13 +114,13 @@ def get_ns_resolver(ns_pattern, resolver, converters):
118
114
  pattern = RegexPattern(ns_pattern)
119
115
  pattern.converters = dict(converters)
120
116
 
121
- class _NestedRouter(RouterBase):
117
+ class _NestedRouter(Router):
122
118
  namespace = ""
123
119
  urls = resolver.url_patterns
124
120
 
125
121
  ns_resolver = URLResolver(pattern=pattern, router=_NestedRouter())
126
122
 
127
- class _NamespacedRouter(RouterBase):
123
+ class _NamespacedRouter(Router):
128
124
  namespace = ""
129
125
  urls = [ns_resolver]
130
126
 
@@ -1,9 +1,6 @@
1
1
  import re
2
- from types import ModuleType
3
2
  from typing import TYPE_CHECKING
4
3
 
5
- from plain.exceptions import ImproperlyConfigured
6
-
7
4
  from .patterns import RegexPattern, RoutePattern, URLPattern
8
5
  from .resolvers import (
9
6
  URLResolver,
@@ -13,7 +10,7 @@ if TYPE_CHECKING:
13
10
  from plain.views import View
14
11
 
15
12
 
16
- class RouterBase:
13
+ class Router:
17
14
  """
18
15
  Base class for defining url patterns.
19
16
 
@@ -25,37 +22,8 @@ class RouterBase:
25
22
  urls: list
26
23
 
27
24
 
28
- class RoutersRegistry:
29
- """Keep track of all the Routers that are explicitly registered in packages."""
30
-
31
- def __init__(self):
32
- self._routers = {}
33
-
34
- def register_router(self, router_class):
35
- router = (
36
- router_class()
37
- ) # Don't necessarily need to instantiate it yet, but will likely add methods.
38
- router_module_name = router_class.__module__
39
- self._routers[router_module_name] = router
40
- return router
41
-
42
- def get_module_router(self, module):
43
- if isinstance(module, str):
44
- module_name = module
45
- else:
46
- module_name = module.__name__
47
-
48
- try:
49
- return self._routers[module_name]
50
- except KeyError as e:
51
- registered_routers = ", ".join(self._routers.keys()) or "None"
52
- raise ImproperlyConfigured(
53
- f"Router {module_name} is not registered with the resolver. Use @register_router on the Router class in urls.py.\n\nRegistered routers: {registered_routers}"
54
- ) from e
55
-
56
-
57
25
  def include(
58
- route: str | re.Pattern, module_or_urls: list | tuple | str | ModuleType
26
+ route: str | re.Pattern, router_or_urls: list | tuple | str | Router
59
27
  ) -> URLResolver:
60
28
  """
61
29
  Include URLs from another module or a nested list of URL patterns.
@@ -67,23 +35,26 @@ def include(
67
35
  else:
68
36
  raise TypeError("include() route must be a string or regex")
69
37
 
70
- if isinstance(module_or_urls, list | tuple):
38
+ if isinstance(router_or_urls, list | tuple):
71
39
  # We were given an explicit list of sub-patterns,
72
40
  # so we generate a router for it
73
- class _IncludeRouter(RouterBase):
41
+ class _IncludeRouter(Router):
74
42
  namespace = ""
75
- urls = module_or_urls
43
+ urls = router_or_urls
76
44
 
77
45
  return URLResolver(pattern=pattern, router=_IncludeRouter())
78
- else:
79
- # We were given a module, so we need to look up the router for that module
80
- module = module_or_urls
81
- router = routers_registry.get_module_router(module)
46
+ elif isinstance(router_or_urls, type) and issubclass(router_or_urls, Router):
47
+ router_class = router_or_urls
48
+ router = router_class()
82
49
 
83
50
  return URLResolver(
84
51
  pattern=pattern,
85
52
  router=router,
86
53
  )
54
+ else:
55
+ raise TypeError(
56
+ f"include() urls must be a list, tuple, or Router class (not a Router() instance): {router_or_urls}"
57
+ )
87
58
 
88
59
 
89
60
  def path(route: str | re.Pattern, view: "View", *, name: str = "") -> URLPattern:
@@ -116,12 +87,3 @@ def path(route: str | re.Pattern, view: "View", *, name: str = "") -> URLPattern
116
87
  return URLPattern(pattern=pattern, view=view, name=name)
117
88
 
118
89
  raise TypeError("view must be a View class or View.as_view()")
119
-
120
-
121
- routers_registry = RoutersRegistry()
122
-
123
-
124
- def register_router(router_class):
125
- """Decorator to register a router class"""
126
- routers_registry.register_router(router_class)
127
- return router_class # Return the class, not the instance
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain"
3
- version = "0.28.0"
3
+ version = "0.30.0"
4
4
  description = "A web framework for building products with Python."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  readme = "README.md"
@@ -11,7 +11,7 @@ dependencies = [
11
11
  requires-python = ">=3.11"
12
12
 
13
13
  [project.scripts]
14
- plain = "plain.cli:cli"
14
+ plain = "plain.cli.core:cli"
15
15
 
16
16
  [tool.uv]
17
17
  dev-dependencies = [
@@ -1,6 +1,8 @@
1
1
  SECRET_KEY = "secret"
2
2
  DEBUG = True
3
3
 
4
+ URLS_ROUTER = "app.urls.AppRouter"
5
+
4
6
  INSTALLED_PACKAGES = [
5
7
  "app.test",
6
8
  ]
@@ -1,4 +1,4 @@
1
- from plain.urls import RouterBase, path, register_router
1
+ from plain.urls import Router, path
2
2
  from plain.views import View
3
3
 
4
4
 
@@ -7,8 +7,7 @@ class TestView(View):
7
7
  return "Hello, world!"
8
8
 
9
9
 
10
- @register_router
11
- class Router(RouterBase):
10
+ class AppRouter(Router):
12
11
  namespace = ""
13
12
  urls = [
14
13
  path("", TestView),
@@ -1,3 +0,0 @@
1
- from .cli import cli
2
-
3
- __all__ = ["cli"]
@@ -1,94 +0,0 @@
1
- import importlib
2
- from importlib.metadata import entry_points
3
- from importlib.util import find_spec
4
-
5
- import click
6
-
7
- from plain.packages import packages_registry
8
-
9
-
10
- class InstalledPackagesGroup(click.Group):
11
- """
12
- Packages in INSTALLED_PACKAGES with a cli.py module
13
- will be discovered automatically.
14
- """
15
-
16
- PLAIN_APPS_PREFIX = "plain."
17
- APP_PREFIX = "app."
18
- MODULE_NAME = "cli"
19
-
20
- def list_commands(self, ctx):
21
- command_names = []
22
-
23
- # Get installed packages with a cli.py module
24
- for app in packages_registry.get_package_configs():
25
- if not find_spec(f"{app.name}.{self.MODULE_NAME}"):
26
- continue
27
-
28
- cli_name = app.name
29
-
30
- # Change plain.{pkg} to just {pkg}
31
- if cli_name.startswith(self.PLAIN_APPS_PREFIX):
32
- cli_name = cli_name[len(self.PLAIN_APPS_PREFIX) :]
33
-
34
- # Change app.{pkg} to just {pkg}
35
- if cli_name.startswith(self.APP_PREFIX):
36
- cli_name = cli_name[len(self.APP_PREFIX) :]
37
-
38
- if cli_name in command_names:
39
- raise ValueError(
40
- f"Duplicate command name {cli_name} found in installed packages."
41
- )
42
-
43
- command_names.append(cli_name)
44
-
45
- return command_names
46
-
47
- def get_command(self, ctx, name):
48
- # Try it as plain.x, app.x, and just x (we don't know ahead of time which it is)
49
- for n in [self.PLAIN_APPS_PREFIX + name, self.APP_PREFIX + name, name]:
50
- try:
51
- if not find_spec(n):
52
- # plain.<name> doesn't exist at all
53
- continue
54
- except ModuleNotFoundError:
55
- continue
56
-
57
- try:
58
- if not find_spec(f"{n}.{self.MODULE_NAME}"):
59
- continue
60
- except ModuleNotFoundError:
61
- continue
62
-
63
- cli = importlib.import_module(f"{n}.{self.MODULE_NAME}")
64
-
65
- # Get the app's cli.py group
66
- try:
67
- return cli.cli
68
- except AttributeError:
69
- continue
70
-
71
-
72
- class EntryPointGroup(click.Group):
73
- """
74
- Python packages can be added to the Plain CLI
75
- via the plain_cli entrypoint in their setup.py.
76
-
77
- This is intended for packages that don't go in INSTALLED_PACKAGES.
78
- """
79
-
80
- ENTRYPOINT_NAME = "plain.cli"
81
-
82
- def list_commands(self, ctx):
83
- rv = []
84
-
85
- for entry_point in entry_points().select(group=self.ENTRYPOINT_NAME):
86
- rv.append(entry_point.name)
87
-
88
- rv.sort()
89
- return rv
90
-
91
- def get_command(self, ctx, name):
92
- for entry_point in entry_points().select(group=self.ENTRYPOINT_NAME):
93
- if entry_point.name == name:
94
- return entry_point.load()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes