plain 0.76.0__tar.gz → 0.77.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.

Potentially problematic release.


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

Files changed (210) hide show
  1. {plain-0.76.0 → plain-0.77.0}/PKG-INFO +2 -1
  2. {plain-0.76.0 → plain-0.77.0}/plain/CHANGELOG.md +13 -0
  3. {plain-0.76.0 → plain-0.77.0}/plain/cli/server.py +0 -8
  4. plain-0.77.0/plain/internal/reloader.py +77 -0
  5. {plain-0.76.0 → plain-0.77.0}/plain/server/README.md +1 -2
  6. {plain-0.76.0 → plain-0.77.0}/plain/server/arbiter.py +2 -2
  7. {plain-0.76.0 → plain-0.77.0}/plain/server/config.py +6 -6
  8. {plain-0.76.0 → plain-0.77.0}/plain/server/util.py +0 -67
  9. plain-0.77.0/plain/server/workers/__init__.py +6 -0
  10. {plain-0.76.0 → plain-0.77.0}/plain/server/workers/base.py +4 -7
  11. {plain-0.76.0 → plain-0.77.0}/pyproject.toml +2 -1
  12. plain-0.76.0/plain/server/reloader.py +0 -158
  13. plain-0.76.0/plain/server/workers/__init__.py +0 -12
  14. {plain-0.76.0 → plain-0.77.0}/.gitignore +0 -0
  15. {plain-0.76.0 → plain-0.77.0}/LICENSE +0 -0
  16. {plain-0.76.0 → plain-0.77.0}/README.md +0 -0
  17. {plain-0.76.0 → plain-0.77.0}/plain/AGENTS.md +0 -0
  18. {plain-0.76.0 → plain-0.77.0}/plain/README.md +0 -0
  19. {plain-0.76.0 → plain-0.77.0}/plain/__main__.py +0 -0
  20. {plain-0.76.0 → plain-0.77.0}/plain/assets/README.md +0 -0
  21. {plain-0.76.0 → plain-0.77.0}/plain/assets/__init__.py +0 -0
  22. {plain-0.76.0 → plain-0.77.0}/plain/assets/compile.py +0 -0
  23. {plain-0.76.0 → plain-0.77.0}/plain/assets/finders.py +0 -0
  24. {plain-0.76.0 → plain-0.77.0}/plain/assets/fingerprints.py +0 -0
  25. {plain-0.76.0 → plain-0.77.0}/plain/assets/urls.py +0 -0
  26. {plain-0.76.0 → plain-0.77.0}/plain/assets/views.py +0 -0
  27. {plain-0.76.0 → plain-0.77.0}/plain/chores/README.md +0 -0
  28. {plain-0.76.0 → plain-0.77.0}/plain/chores/__init__.py +0 -0
  29. {plain-0.76.0 → plain-0.77.0}/plain/chores/registry.py +0 -0
  30. {plain-0.76.0 → plain-0.77.0}/plain/cli/README.md +0 -0
  31. {plain-0.76.0 → plain-0.77.0}/plain/cli/__init__.py +0 -0
  32. {plain-0.76.0 → plain-0.77.0}/plain/cli/agent/__init__.py +0 -0
  33. {plain-0.76.0 → plain-0.77.0}/plain/cli/agent/docs.py +0 -0
  34. {plain-0.76.0 → plain-0.77.0}/plain/cli/agent/llmdocs.py +0 -0
  35. {plain-0.76.0 → plain-0.77.0}/plain/cli/agent/md.py +0 -0
  36. {plain-0.76.0 → plain-0.77.0}/plain/cli/agent/prompt.py +0 -0
  37. {plain-0.76.0 → plain-0.77.0}/plain/cli/agent/request.py +0 -0
  38. {plain-0.76.0 → plain-0.77.0}/plain/cli/build.py +0 -0
  39. {plain-0.76.0 → plain-0.77.0}/plain/cli/changelog.py +0 -0
  40. {plain-0.76.0 → plain-0.77.0}/plain/cli/chores.py +0 -0
  41. {plain-0.76.0 → plain-0.77.0}/plain/cli/core.py +0 -0
  42. {plain-0.76.0 → plain-0.77.0}/plain/cli/docs.py +0 -0
  43. {plain-0.76.0 → plain-0.77.0}/plain/cli/formatting.py +0 -0
  44. {plain-0.76.0 → plain-0.77.0}/plain/cli/install.py +0 -0
  45. {plain-0.76.0 → plain-0.77.0}/plain/cli/output.py +0 -0
  46. {plain-0.76.0 → plain-0.77.0}/plain/cli/preflight.py +0 -0
  47. {plain-0.76.0 → plain-0.77.0}/plain/cli/print.py +0 -0
  48. {plain-0.76.0 → plain-0.77.0}/plain/cli/registry.py +0 -0
  49. {plain-0.76.0 → plain-0.77.0}/plain/cli/runtime.py +0 -0
  50. {plain-0.76.0 → plain-0.77.0}/plain/cli/scaffold.py +0 -0
  51. {plain-0.76.0 → plain-0.77.0}/plain/cli/settings.py +0 -0
  52. {plain-0.76.0 → plain-0.77.0}/plain/cli/shell.py +0 -0
  53. {plain-0.76.0 → plain-0.77.0}/plain/cli/startup.py +0 -0
  54. {plain-0.76.0 → plain-0.77.0}/plain/cli/upgrade.py +0 -0
  55. {plain-0.76.0 → plain-0.77.0}/plain/cli/urls.py +0 -0
  56. {plain-0.76.0 → plain-0.77.0}/plain/cli/utils.py +0 -0
  57. {plain-0.76.0 → plain-0.77.0}/plain/csrf/README.md +0 -0
  58. {plain-0.76.0 → plain-0.77.0}/plain/csrf/middleware.py +0 -0
  59. {plain-0.76.0 → plain-0.77.0}/plain/csrf/views.py +0 -0
  60. {plain-0.76.0 → plain-0.77.0}/plain/debug.py +0 -0
  61. {plain-0.76.0 → plain-0.77.0}/plain/exceptions.py +0 -0
  62. {plain-0.76.0 → plain-0.77.0}/plain/forms/README.md +0 -0
  63. {plain-0.76.0 → plain-0.77.0}/plain/forms/__init__.py +0 -0
  64. {plain-0.76.0 → plain-0.77.0}/plain/forms/boundfield.py +0 -0
  65. {plain-0.76.0 → plain-0.77.0}/plain/forms/exceptions.py +0 -0
  66. {plain-0.76.0 → plain-0.77.0}/plain/forms/fields.py +0 -0
  67. {plain-0.76.0 → plain-0.77.0}/plain/forms/forms.py +0 -0
  68. {plain-0.76.0 → plain-0.77.0}/plain/http/README.md +0 -0
  69. {plain-0.76.0 → plain-0.77.0}/plain/http/__init__.py +0 -0
  70. {plain-0.76.0 → plain-0.77.0}/plain/http/cookie.py +0 -0
  71. {plain-0.76.0 → plain-0.77.0}/plain/http/multipartparser.py +0 -0
  72. {plain-0.76.0 → plain-0.77.0}/plain/http/request.py +0 -0
  73. {plain-0.76.0 → plain-0.77.0}/plain/http/response.py +0 -0
  74. {plain-0.76.0 → plain-0.77.0}/plain/internal/__init__.py +0 -0
  75. {plain-0.76.0 → plain-0.77.0}/plain/internal/files/__init__.py +0 -0
  76. {plain-0.76.0 → plain-0.77.0}/plain/internal/files/base.py +0 -0
  77. {plain-0.76.0 → plain-0.77.0}/plain/internal/files/locks.py +0 -0
  78. {plain-0.76.0 → plain-0.77.0}/plain/internal/files/move.py +0 -0
  79. {plain-0.76.0 → plain-0.77.0}/plain/internal/files/temp.py +0 -0
  80. {plain-0.76.0 → plain-0.77.0}/plain/internal/files/uploadedfile.py +0 -0
  81. {plain-0.76.0 → plain-0.77.0}/plain/internal/files/uploadhandler.py +0 -0
  82. {plain-0.76.0 → plain-0.77.0}/plain/internal/files/utils.py +0 -0
  83. {plain-0.76.0 → plain-0.77.0}/plain/internal/handlers/__init__.py +0 -0
  84. {plain-0.76.0 → plain-0.77.0}/plain/internal/handlers/base.py +0 -0
  85. {plain-0.76.0 → plain-0.77.0}/plain/internal/handlers/exception.py +0 -0
  86. {plain-0.76.0 → plain-0.77.0}/plain/internal/handlers/wsgi.py +0 -0
  87. {plain-0.76.0 → plain-0.77.0}/plain/internal/middleware/__init__.py +0 -0
  88. {plain-0.76.0 → plain-0.77.0}/plain/internal/middleware/headers.py +0 -0
  89. {plain-0.76.0 → plain-0.77.0}/plain/internal/middleware/hosts.py +0 -0
  90. {plain-0.76.0 → plain-0.77.0}/plain/internal/middleware/https.py +0 -0
  91. {plain-0.76.0 → plain-0.77.0}/plain/internal/middleware/slash.py +0 -0
  92. {plain-0.76.0 → plain-0.77.0}/plain/json.py +0 -0
  93. {plain-0.76.0 → plain-0.77.0}/plain/logs/README.md +0 -0
  94. {plain-0.76.0 → plain-0.77.0}/plain/logs/__init__.py +0 -0
  95. {plain-0.76.0 → plain-0.77.0}/plain/logs/configure.py +0 -0
  96. {plain-0.76.0 → plain-0.77.0}/plain/logs/debug.py +0 -0
  97. {plain-0.76.0 → plain-0.77.0}/plain/logs/formatters.py +0 -0
  98. {plain-0.76.0 → plain-0.77.0}/plain/logs/loggers.py +0 -0
  99. {plain-0.76.0 → plain-0.77.0}/plain/logs/utils.py +0 -0
  100. {plain-0.76.0 → plain-0.77.0}/plain/packages/README.md +0 -0
  101. {plain-0.76.0 → plain-0.77.0}/plain/packages/__init__.py +0 -0
  102. {plain-0.76.0 → plain-0.77.0}/plain/packages/config.py +0 -0
  103. {plain-0.76.0 → plain-0.77.0}/plain/packages/registry.py +0 -0
  104. {plain-0.76.0 → plain-0.77.0}/plain/paginator.py +0 -0
  105. {plain-0.76.0 → plain-0.77.0}/plain/preflight/README.md +0 -0
  106. {plain-0.76.0 → plain-0.77.0}/plain/preflight/__init__.py +0 -0
  107. {plain-0.76.0 → plain-0.77.0}/plain/preflight/checks.py +0 -0
  108. {plain-0.76.0 → plain-0.77.0}/plain/preflight/files.py +0 -0
  109. {plain-0.76.0 → plain-0.77.0}/plain/preflight/registry.py +0 -0
  110. {plain-0.76.0 → plain-0.77.0}/plain/preflight/results.py +0 -0
  111. {plain-0.76.0 → plain-0.77.0}/plain/preflight/security.py +0 -0
  112. {plain-0.76.0 → plain-0.77.0}/plain/preflight/urls.py +0 -0
  113. {plain-0.76.0 → plain-0.77.0}/plain/runtime/README.md +0 -0
  114. {plain-0.76.0 → plain-0.77.0}/plain/runtime/__init__.py +0 -0
  115. {plain-0.76.0 → plain-0.77.0}/plain/runtime/global_settings.py +0 -0
  116. {plain-0.76.0 → plain-0.77.0}/plain/runtime/user_settings.py +0 -0
  117. {plain-0.76.0 → plain-0.77.0}/plain/runtime/utils.py +0 -0
  118. {plain-0.76.0 → plain-0.77.0}/plain/server/LICENSE +0 -0
  119. {plain-0.76.0 → plain-0.77.0}/plain/server/__init__.py +0 -0
  120. {plain-0.76.0 → plain-0.77.0}/plain/server/app.py +0 -0
  121. {plain-0.76.0 → plain-0.77.0}/plain/server/errors.py +0 -0
  122. {plain-0.76.0 → plain-0.77.0}/plain/server/glogging.py +0 -0
  123. {plain-0.76.0 → plain-0.77.0}/plain/server/http/__init__.py +0 -0
  124. {plain-0.76.0 → plain-0.77.0}/plain/server/http/body.py +0 -0
  125. {plain-0.76.0 → plain-0.77.0}/plain/server/http/errors.py +0 -0
  126. {plain-0.76.0 → plain-0.77.0}/plain/server/http/message.py +0 -0
  127. {plain-0.76.0 → plain-0.77.0}/plain/server/http/parser.py +0 -0
  128. {plain-0.76.0 → plain-0.77.0}/plain/server/http/unreader.py +0 -0
  129. {plain-0.76.0 → plain-0.77.0}/plain/server/http/wsgi.py +0 -0
  130. {plain-0.76.0 → plain-0.77.0}/plain/server/pidfile.py +0 -0
  131. {plain-0.76.0 → plain-0.77.0}/plain/server/sock.py +0 -0
  132. {plain-0.76.0 → plain-0.77.0}/plain/server/workers/sync.py +0 -0
  133. /plain-0.76.0/plain/server/workers/gthread.py → /plain-0.77.0/plain/server/workers/thread.py +0 -0
  134. {plain-0.76.0 → plain-0.77.0}/plain/server/workers/workertmp.py +0 -0
  135. {plain-0.76.0 → plain-0.77.0}/plain/signals/README.md +0 -0
  136. {plain-0.76.0 → plain-0.77.0}/plain/signals/__init__.py +0 -0
  137. {plain-0.76.0 → plain-0.77.0}/plain/signals/dispatch/__init__.py +0 -0
  138. {plain-0.76.0 → plain-0.77.0}/plain/signals/dispatch/dispatcher.py +0 -0
  139. {plain-0.76.0 → plain-0.77.0}/plain/signals/dispatch/license.txt +0 -0
  140. {plain-0.76.0 → plain-0.77.0}/plain/signing.py +0 -0
  141. {plain-0.76.0 → plain-0.77.0}/plain/templates/AGENTS.md +0 -0
  142. {plain-0.76.0 → plain-0.77.0}/plain/templates/README.md +0 -0
  143. {plain-0.76.0 → plain-0.77.0}/plain/templates/__init__.py +0 -0
  144. {plain-0.76.0 → plain-0.77.0}/plain/templates/core.py +0 -0
  145. {plain-0.76.0 → plain-0.77.0}/plain/templates/jinja/__init__.py +0 -0
  146. {plain-0.76.0 → plain-0.77.0}/plain/templates/jinja/environments.py +0 -0
  147. {plain-0.76.0 → plain-0.77.0}/plain/templates/jinja/extensions.py +0 -0
  148. {plain-0.76.0 → plain-0.77.0}/plain/templates/jinja/filters.py +0 -0
  149. {plain-0.76.0 → plain-0.77.0}/plain/templates/jinja/globals.py +0 -0
  150. {plain-0.76.0 → plain-0.77.0}/plain/test/README.md +0 -0
  151. {plain-0.76.0 → plain-0.77.0}/plain/test/__init__.py +0 -0
  152. {plain-0.76.0 → plain-0.77.0}/plain/test/client.py +0 -0
  153. {plain-0.76.0 → plain-0.77.0}/plain/test/encoding.py +0 -0
  154. {plain-0.76.0 → plain-0.77.0}/plain/test/exceptions.py +0 -0
  155. {plain-0.76.0 → plain-0.77.0}/plain/urls/README.md +0 -0
  156. {plain-0.76.0 → plain-0.77.0}/plain/urls/__init__.py +0 -0
  157. {plain-0.76.0 → plain-0.77.0}/plain/urls/converters.py +0 -0
  158. {plain-0.76.0 → plain-0.77.0}/plain/urls/exceptions.py +0 -0
  159. {plain-0.76.0 → plain-0.77.0}/plain/urls/patterns.py +0 -0
  160. {plain-0.76.0 → plain-0.77.0}/plain/urls/resolvers.py +0 -0
  161. {plain-0.76.0 → plain-0.77.0}/plain/urls/routers.py +0 -0
  162. {plain-0.76.0 → plain-0.77.0}/plain/urls/utils.py +0 -0
  163. {plain-0.76.0 → plain-0.77.0}/plain/utils/README.md +0 -0
  164. {plain-0.76.0 → plain-0.77.0}/plain/utils/__init__.py +0 -0
  165. {plain-0.76.0 → plain-0.77.0}/plain/utils/cache.py +0 -0
  166. {plain-0.76.0 → plain-0.77.0}/plain/utils/crypto.py +0 -0
  167. {plain-0.76.0 → plain-0.77.0}/plain/utils/datastructures.py +0 -0
  168. {plain-0.76.0 → plain-0.77.0}/plain/utils/dateparse.py +0 -0
  169. {plain-0.76.0 → plain-0.77.0}/plain/utils/deconstruct.py +0 -0
  170. {plain-0.76.0 → plain-0.77.0}/plain/utils/decorators.py +0 -0
  171. {plain-0.76.0 → plain-0.77.0}/plain/utils/duration.py +0 -0
  172. {plain-0.76.0 → plain-0.77.0}/plain/utils/encoding.py +0 -0
  173. {plain-0.76.0 → plain-0.77.0}/plain/utils/functional.py +0 -0
  174. {plain-0.76.0 → plain-0.77.0}/plain/utils/hashable.py +0 -0
  175. {plain-0.76.0 → plain-0.77.0}/plain/utils/html.py +0 -0
  176. {plain-0.76.0 → plain-0.77.0}/plain/utils/http.py +0 -0
  177. {plain-0.76.0 → plain-0.77.0}/plain/utils/inspect.py +0 -0
  178. {plain-0.76.0 → plain-0.77.0}/plain/utils/ipv6.py +0 -0
  179. {plain-0.76.0 → plain-0.77.0}/plain/utils/itercompat.py +0 -0
  180. {plain-0.76.0 → plain-0.77.0}/plain/utils/module_loading.py +0 -0
  181. {plain-0.76.0 → plain-0.77.0}/plain/utils/regex_helper.py +0 -0
  182. {plain-0.76.0 → plain-0.77.0}/plain/utils/safestring.py +0 -0
  183. {plain-0.76.0 → plain-0.77.0}/plain/utils/text.py +0 -0
  184. {plain-0.76.0 → plain-0.77.0}/plain/utils/timesince.py +0 -0
  185. {plain-0.76.0 → plain-0.77.0}/plain/utils/timezone.py +0 -0
  186. {plain-0.76.0 → plain-0.77.0}/plain/utils/tree.py +0 -0
  187. {plain-0.76.0 → plain-0.77.0}/plain/validators.py +0 -0
  188. {plain-0.76.0 → plain-0.77.0}/plain/views/README.md +0 -0
  189. {plain-0.76.0 → plain-0.77.0}/plain/views/__init__.py +0 -0
  190. {plain-0.76.0 → plain-0.77.0}/plain/views/base.py +0 -0
  191. {plain-0.76.0 → plain-0.77.0}/plain/views/errors.py +0 -0
  192. {plain-0.76.0 → plain-0.77.0}/plain/views/exceptions.py +0 -0
  193. {plain-0.76.0 → plain-0.77.0}/plain/views/forms.py +0 -0
  194. {plain-0.76.0 → plain-0.77.0}/plain/views/objects.py +0 -0
  195. {plain-0.76.0 → plain-0.77.0}/plain/views/redirect.py +0 -0
  196. {plain-0.76.0 → plain-0.77.0}/plain/views/templates.py +0 -0
  197. {plain-0.76.0 → plain-0.77.0}/plain/wsgi.py +0 -0
  198. {plain-0.76.0 → plain-0.77.0}/tests/.gitignore +0 -0
  199. {plain-0.76.0 → plain-0.77.0}/tests/app/.gitignore +0 -0
  200. {plain-0.76.0 → plain-0.77.0}/tests/app/settings.py +0 -0
  201. {plain-0.76.0 → plain-0.77.0}/tests/app/test/__init__.py +0 -0
  202. {plain-0.76.0 → plain-0.77.0}/tests/app/test/default_settings.py +0 -0
  203. {plain-0.76.0 → plain-0.77.0}/tests/app/urls.py +0 -0
  204. {plain-0.76.0 → plain-0.77.0}/tests/conftest.py +0 -0
  205. {plain-0.76.0 → plain-0.77.0}/tests/test_cli.py +0 -0
  206. {plain-0.76.0 → plain-0.77.0}/tests/test_csrf.py +0 -0
  207. {plain-0.76.0 → plain-0.77.0}/tests/test_http_hosts.py +0 -0
  208. {plain-0.76.0 → plain-0.77.0}/tests/test_logs.py +0 -0
  209. {plain-0.76.0 → plain-0.77.0}/tests/test_runtime.py +0 -0
  210. {plain-0.76.0 → plain-0.77.0}/tests/test_wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.76.0
3
+ Version: 0.77.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,18 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.77.0](https://github.com/dropseed/plain/releases/plain@0.77.0) (2025-10-13)
4
+
5
+ ### What's changed
6
+
7
+ - The `plain server --reload` now uses `watchfiles` for improved cross-platform file watching ([92e95c5032](https://github.com/dropseed/plain/commit/92e95c5032))
8
+ - Server reloader now watches `.env*` files for changes and triggers automatic reload ([92e95c5032](https://github.com/dropseed/plain/commit/92e95c5032))
9
+ - HTML template additions and deletions now trigger automatic server reload when using `--reload` ([f2f31c288b](https://github.com/dropseed/plain/commit/f2f31c288b))
10
+ - Internal server worker type renamed from "gthread" to "thread" for clarity ([6470748e91](https://github.com/dropseed/plain/commit/6470748e91))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - No changes required
15
+
3
16
  ## [0.76.0](https://github.com/dropseed/plain/releases/plain@0.76.0) (2025-10-12)
4
17
 
5
18
  ### What's 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)
@@ -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)
@@ -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:
@@ -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
@@ -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
 
@@ -0,0 +1,6 @@
1
+ #
2
+ #
3
+ # This file is part of gunicorn released under the MIT license.
4
+ # See the LICENSE for more information.
5
+ #
6
+ # Vendored and modified for Plain.
@@ -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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain"
3
- version = "0.76.0"
3
+ version = "0.77.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"
@@ -9,6 +9,7 @@ dependencies = [
9
9
  "click>=8.0.0",
10
10
  "opentelemetry-api>=1.34.1",
11
11
  "opentelemetry-semantic-conventions>=0.55b1",
12
+ "watchfiles>=0.18.0",
12
13
  ]
13
14
  requires-python = ">=3.13"
14
15
 
@@ -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
- }
@@ -1,12 +0,0 @@
1
- #
2
- #
3
- # This file is part of gunicorn released under the MIT license.
4
- # See the LICENSE for more information.
5
- #
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
- }
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