hyperflask 0.5.2__tar.gz → 0.6.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 (126) hide show
  1. {hyperflask-0.5.2 → hyperflask-0.6.0}/PKG-INFO +7 -8
  2. {hyperflask-0.5.2 → hyperflask-0.6.0}/README.md +5 -5
  3. {hyperflask-0.5.2 → hyperflask-0.6.0}/pyproject.toml +5 -3
  4. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/__init__.py +3 -0
  5. hyperflask-0.6.0/src/hyperflask/actors.py +128 -0
  6. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/app.py +33 -25
  7. hyperflask-0.6.0/src/hyperflask/cli/__init__.py +135 -0
  8. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/cli/runner.py +57 -39
  9. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/cli/worker.py +5 -2
  10. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/components/core.py +3 -0
  11. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/factory.py +8 -5
  12. hyperflask-0.5.2/src/hyperflask/model.py → hyperflask-0.6.0/src/hyperflask/models.py +2 -22
  13. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/static/app.css +2 -0
  14. hyperflask-0.6.0/src/hyperflask/utils/collections.py +10 -0
  15. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/utils/htmx.py +1 -3
  16. hyperflask-0.5.2/src/hyperflask/actors.py +0 -82
  17. hyperflask-0.5.2/src/hyperflask/cli/__init__.py +0 -46
  18. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/__main__.py +0 -0
  19. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/cli/reloader.py +0 -0
  20. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/cli/scaffold.py +0 -0
  21. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/components/__init__.py +0 -0
  22. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/components/alpine.py +0 -0
  23. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/components/jsx.py +0 -0
  24. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/components/webcomponent.py +0 -0
  25. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/forms/__init__.py +0 -0
  26. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/forms/fields.py +0 -0
  27. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/forms/widgets.py +0 -0
  28. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/layouts/email.mjml +0 -0
  29. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/layouts/web.html +0 -0
  30. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/page_helpers.py +0 -0
  31. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/security.py +0 -0
  32. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/static/alpine-csp.js +0 -0
  33. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/static/alpine.js +0 -0
  34. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/static/app.js +0 -0
  35. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/static/htmx-exts.js +0 -0
  36. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/static/react.js +0 -0
  37. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/static/stimulus.js +0 -0
  38. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/static/translations.js +0 -0
  39. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Accordion.html +0 -0
  40. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/AccordionPanel.html +0 -0
  41. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Alert.html +0 -0
  42. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Avatar.html +0 -0
  43. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/AvatarGroup.html +0 -0
  44. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Badge.html +0 -0
  45. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Breadcrumbs.html +0 -0
  46. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/BreadcrumbsItem.html +0 -0
  47. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Button.html +0 -0
  48. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Card.html +0 -0
  49. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/CardActions.html +0 -0
  50. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/CardTitle.html +0 -0
  51. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/ChatBubble.html +0 -0
  52. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Checkbox.html +0 -0
  53. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/CollapsibleSubMenu.html +0 -0
  54. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Countdown.html +0 -0
  55. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Divider.html +0 -0
  56. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Drawer.html +0 -0
  57. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/DrawerContent.html +0 -0
  58. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/DrawerSide.html +0 -0
  59. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Dropdown.html +0 -0
  60. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/DropdownItem.html +0 -0
  61. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Fieldset.html +0 -0
  62. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/FieldsetLegend.html +0 -0
  63. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/FileInput.html +0 -0
  64. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Filter.html +0 -0
  65. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/FilterItem.html +0 -0
  66. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Footer.html +0 -0
  67. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/FooterLink.html +0 -0
  68. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/FooterNav.html +0 -0
  69. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Indicator.html +0 -0
  70. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Input.html +0 -0
  71. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/InputLabel.html +0 -0
  72. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Join.html +0 -0
  73. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Kbd.html +0 -0
  74. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Label.html +0 -0
  75. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/LabeledCheckbox.html +0 -0
  76. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/LabeledToggle.html +0 -0
  77. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Link.html +0 -0
  78. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/List.html +0 -0
  79. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/ListItem.html +0 -0
  80. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Loading.html +0 -0
  81. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Menu.html +0 -0
  82. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/MenuItem.html +0 -0
  83. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/MenuTitle.html +0 -0
  84. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Modal.html +0 -0
  85. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/ModalAction.html +0 -0
  86. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/ModalTitle.html +0 -0
  87. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Navbar.html +0 -0
  88. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Pagination.html +0 -0
  89. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/PaginationNext.html +0 -0
  90. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/PaginationPage.html +0 -0
  91. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/PaginationPrev.html +0 -0
  92. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Progress.html +0 -0
  93. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/README.md +0 -0
  94. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Radio.html +0 -0
  95. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Range.html +0 -0
  96. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Rating.html +0 -0
  97. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Select.html +0 -0
  98. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Stack.html +0 -0
  99. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Stat.html +0 -0
  100. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Stats.html +0 -0
  101. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Step.html +0 -0
  102. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Steps.html +0 -0
  103. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/SubMenu.html +0 -0
  104. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Swap.html +0 -0
  105. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Tab.html +0 -0
  106. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Table.html +0 -0
  107. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Tabs.html +0 -0
  108. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Textarea.html +0 -0
  109. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Toast.html +0 -0
  110. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Toggle.html +0 -0
  111. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/daisyui/Tooltip.html +0 -0
  112. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/flash.html +0 -0
  113. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/forms.html +0 -0
  114. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/icons.html +0 -0
  115. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/layout.html +0 -0
  116. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/modal.html +0 -0
  117. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/skeleton.html +0 -0
  118. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/ui/sse.html +0 -0
  119. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/utils/__init__.py +0 -0
  120. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/utils/freezer.py +0 -0
  121. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/utils/html.py +0 -0
  122. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/utils/image.py +0 -0
  123. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/utils/markdown.py +0 -0
  124. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/utils/metadata.py +0 -0
  125. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/utils/page_actions.py +0 -0
  126. {hyperflask-0.5.2 → hyperflask-0.6.0}/src/hyperflask/utils/tokens.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperflask
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: Full stack web framework
5
5
  Author: Maxime Bouroumeau-Fuseau
6
6
  Author-email: Maxime Bouroumeau-Fuseau <maxime.bouroumeau@gmail.com>
7
7
  License-Expression: MIT
8
- Requires-Dist: flask>=3.0.0,<4.0.0
8
+ Requires-Dist: flask>=3.1.0,<4.0.0
9
9
  Requires-Dist: flask-assets-pipeline~=0.8
10
10
  Requires-Dist: flask-babel-hyper~=5.1
11
11
  Requires-Dist: flask-collections~=0.3
@@ -13,7 +13,6 @@ Requires-Dist: flask-configurator~=0.1
13
13
  Requires-Dist: flask-debugtoolbar~=0.16
14
14
  Requires-Dist: flask-file-routes~=0.3
15
15
  Requires-Dist: flask-files~=0.2
16
- Requires-Dist: flask-geo~=0.1
17
16
  Requires-Dist: flask-mailman-templates~=0.1
18
17
  Requires-Dist: flask-mercure-sse~=0.4
19
18
  Requires-Dist: flask-sqlorm~=0.3
@@ -43,7 +42,7 @@ Description-Content-Type: text/markdown
43
42
 
44
43
  **Full stack Python web framework to build websites and web apps with as little boilerplate as possible**
45
44
 
46
- [hyperflask.dev](https://hyperflask.dev) • [Get started](https://hyperflask.dev/getting-started) • [Docs](https://hyperflask.dev/guides) • [Example apps](https://github.com/hyperflask/examples)
45
+ [hyperflask.dev](https://hyperflask.dev) • [Get started](https://hyperflask.dev/getting-started) • [Docs](https://hyperflask.dev/guides) • [Example app](https://github.com/hyperflask/getting-started)
47
46
 
48
47
  </div>
49
48
 
@@ -74,11 +73,10 @@ Features and technologies:
74
73
  - Dev environment based on [Development Containers](https://containers.dev/)
75
74
  - Optimized for VScode with breakpoint debugging for frontend and backend
76
75
  - [Mailpit](https://github.com/axllent/mailpit) for email testing
77
- - Deployments using [docker-web-deploy](https://github.com/hyperflask/docker-web-deploy)
78
76
 
79
77
  ## Project status
80
78
 
81
- **Hyperflask is being actively developed and is not yet ready to be used.**
79
+ **Hyperflask is in BETA and can start to be used for non-critical production apps**
82
80
 
83
81
  Checkout the list of all the projects developed as part of the Hyperflask stack and their current development status on the [Hyperflask Github organization homepage](https://github.com/hyperflask).
84
82
 
@@ -116,5 +114,6 @@ A good part of these extensions is developed as part of the Hyperflask project.
116
114
  1. Create your project directory: `mkdir example-project && cd example-project`
117
115
  2. Create and activate a virtualenv: `python -m venv .venv && source .venv/bin/activate`
118
116
  3. `pip install hyperflask`
119
- 4. Run `hyperflask init .`
120
- 5. Start a development server using `hyperflask dev`
117
+ 5. Run `npm install esbuild tailwindcss @tailwindcss/cli htmx.org htmx-ext-sse bootstrap-icons daisyui`
118
+ 4. Run `mkdir pages && echo "Hello world" > pages/index.jpy`
119
+ 6. Start a development server using `hyperflask dev`
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Full stack Python web framework to build websites and web apps with as little boilerplate as possible**
6
6
 
7
- [hyperflask.dev](https://hyperflask.dev) • [Get started](https://hyperflask.dev/getting-started) • [Docs](https://hyperflask.dev/guides) • [Example apps](https://github.com/hyperflask/examples)
7
+ [hyperflask.dev](https://hyperflask.dev) • [Get started](https://hyperflask.dev/getting-started) • [Docs](https://hyperflask.dev/guides) • [Example app](https://github.com/hyperflask/getting-started)
8
8
 
9
9
  </div>
10
10
 
@@ -35,11 +35,10 @@ Features and technologies:
35
35
  - Dev environment based on [Development Containers](https://containers.dev/)
36
36
  - Optimized for VScode with breakpoint debugging for frontend and backend
37
37
  - [Mailpit](https://github.com/axllent/mailpit) for email testing
38
- - Deployments using [docker-web-deploy](https://github.com/hyperflask/docker-web-deploy)
39
38
 
40
39
  ## Project status
41
40
 
42
- **Hyperflask is being actively developed and is not yet ready to be used.**
41
+ **Hyperflask is in BETA and can start to be used for non-critical production apps**
43
42
 
44
43
  Checkout the list of all the projects developed as part of the Hyperflask stack and their current development status on the [Hyperflask Github organization homepage](https://github.com/hyperflask).
45
44
 
@@ -77,5 +76,6 @@ A good part of these extensions is developed as part of the Hyperflask project.
77
76
  1. Create your project directory: `mkdir example-project && cd example-project`
78
77
  2. Create and activate a virtualenv: `python -m venv .venv && source .venv/bin/activate`
79
78
  3. `pip install hyperflask`
80
- 4. Run `hyperflask init .`
81
- 5. Start a development server using `hyperflask dev`
79
+ 5. Run `npm install esbuild tailwindcss @tailwindcss/cli htmx.org htmx-ext-sse bootstrap-icons daisyui`
80
+ 4. Run `mkdir pages && echo "Hello world" > pages/index.jpy`
81
+ 6. Start a development server using `hyperflask dev`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperflask"
3
- version = "0.5.2"
3
+ version = "0.6.0"
4
4
  description = "Full stack web framework"
5
5
  authors = [
6
6
  {"name" = "Maxime Bouroumeau-Fuseau", email = "maxime.bouroumeau@gmail.com"}
@@ -9,7 +9,7 @@ readme = "README.md"
9
9
  license = "MIT"
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
- "flask>=3.0.0,<4.0.0",
12
+ "flask>=3.1.0,<4.0.0",
13
13
  "flask-assets-pipeline ~=0.8",
14
14
  "flask-babel-hyper ~=5.1",
15
15
  "flask-collections ~=0.3",
@@ -17,7 +17,6 @@ dependencies = [
17
17
  "flask-debugtoolbar ~=0.16",
18
18
  "flask-file-routes ~=0.3",
19
19
  "flask-files ~=0.2",
20
- "flask-geo ~=0.1",
21
20
  "flask-mailman-templates ~=0.1",
22
21
  "flask-mercure-sse ~=0.4",
23
22
  #"flask-observability ~=0.1"
@@ -49,6 +48,9 @@ Repository = "https://github.com/hyperflask/hyperflask"
49
48
  [project.scripts]
50
49
  hyperflask = "hyperflask.cli:main"
51
50
 
51
+ [project.entry-points."flask_collections"]
52
+ model = "hyperflask.model:ModelCollection"
53
+
52
54
  [dependency-groups]
53
55
  dev = [
54
56
  "pytest>=8.4.1",
@@ -3,6 +3,8 @@ from .app import Hyperflask
3
3
  from .utils.htmx import htmx_redirect, htmx_oob
4
4
  from .utils.freezer import StaticMode, static, dynamic
5
5
  from .security import csp_nonce
6
+ from .actors import actor, ActorGroup
7
+ from .models import Model
6
8
 
7
9
  # useful exports from hyperflask managed extensions
8
10
  from flask_babel import gettext, _, pgettext, _p, ngettext, _n, npgettext, _np, lazy_gettext, _lazy
@@ -18,3 +20,4 @@ from htmx_flask import request, make_response
18
20
  from slugify import slugify
19
21
  from blinker import signal
20
22
  from periodiq import cron
23
+ from sqlorm import transaction
@@ -0,0 +1,128 @@
1
+ from threading import local
2
+ import typing as t
3
+ import importlib
4
+ from datetime import timedelta
5
+ from dramatiq import Middleware, actor as register_actor, Message
6
+ from flask import has_app_context, current_app
7
+ from sqlorm.helpers import after_commit as sqlorm_after_commit
8
+
9
+
10
+ DRAMATIQ_BROKERS = {
11
+ "amqp": "dramatiq.brokers.rabbitmq:RabbitmqBroker",
12
+ "redis": "dramatiq.brokers.redis:RedisBroker",
13
+ "sqlite": "dramatiq_sqlite:SQLiteBroker",
14
+ }
15
+
16
+
17
+ def discover_broker(broker_cls=None, broker_url=None):
18
+ if not broker_url and broker_cls and "://" in broker_cls:
19
+ broker_url = broker_cls
20
+ broker_cls = None
21
+ if not broker_cls and broker_url:
22
+ if "://" not in broker_url:
23
+ broker_name = "sqlite"
24
+ else:
25
+ broker_name, broker_url = broker_url.split("://", 1)
26
+ if broker_name not in DRAMATIQ_BROKERS:
27
+ raise ValueError(f"Unknown dramatiq broker '{broker_name}'")
28
+ broker_cls = DRAMATIQ_BROKERS[broker_name]
29
+ if isinstance(broker_cls, str):
30
+ module_name, cls_name = broker_cls.split(":")
31
+ module = importlib.import_module(module_name)
32
+ broker_cls = getattr(module, cls_name)
33
+ return broker_cls, broker_url
34
+
35
+
36
+ class actor:
37
+ @classmethod
38
+ def bind(cls, broker):
39
+ return type('bound_actor', (cls,), {'__broker__': broker})
40
+
41
+ def __init__(self, *args, **kwargs):
42
+ self.actor_args = (args, kwargs)
43
+ self.fn = None
44
+ self.actor = None
45
+
46
+ def __call__(self, *args, **kwargs):
47
+ if not self.fn:
48
+ self.fn = args[0]
49
+ if getattr(self, '__broker__', None):
50
+ self.register(self.__broker__)
51
+ elif has_app_context() and getattr(current_app, 'dramatiq_broker', None):
52
+ self.register(current_app.dramatiq_broker)
53
+ return self
54
+ return self.fn(*args, **kwargs)
55
+
56
+ def register(self, broker):
57
+ self.actor = register_actor(broker=broker, *self.actor_args[0], **self.actor_args[1])(self.fn)
58
+
59
+ def __getattr__(self, name):
60
+ if self.actor:
61
+ return getattr(self.actor, name)
62
+ raise AttributeError(name)
63
+
64
+ def send(self, *args, **kwargs):
65
+ return self.send_with_options(args=args, kwargs=kwargs)
66
+
67
+ def send_with_options(
68
+ self,
69
+ *,
70
+ args: tuple = (),
71
+ kwargs: t.Optional[dict[str, t.Any]] = None,
72
+ delay: t.Optional[timedelta | int] = None,
73
+ after_commit: bool = True,
74
+ **options,
75
+ ) -> Message:
76
+ if not self.actor:
77
+ raise RuntimeError("No broker defined for Dramatiq actors.")
78
+
79
+ if isinstance(delay, timedelta):
80
+ delay = int(delay.total_seconds() * 1000)
81
+
82
+ message = self.actor.message_with_options(args=args, kwargs=kwargs, **options)
83
+
84
+ if after_commit:
85
+ sqlorm_after_commit(lambda: self.actor.broker.enqueue(message, delay=delay))
86
+ return message
87
+
88
+ return self.actor.broker.enqueue(message, delay=delay)
89
+
90
+
91
+ class ActorGroup(list):
92
+ def actor(self, *args, **kwargs):
93
+ def decorator(fn):
94
+ a = actor(*args, **kwargs)(fn)
95
+ self.append(a)
96
+ return a
97
+ return decorator
98
+
99
+ def register(self, broker):
100
+ for a in self:
101
+ a.register(broker)
102
+
103
+
104
+ class AppContextMiddleware(Middleware):
105
+ # Setup Flask app for actor. Borrowed from
106
+ # https://github.com/Bogdanp/flask_dramatiq_example.
107
+
108
+ state = local()
109
+
110
+ def __init__(self, app):
111
+ self.app = app
112
+
113
+ def before_process_message(self, broker, message):
114
+ context = self.app.app_context()
115
+ context.push()
116
+
117
+ self.state.context = context
118
+
119
+ def after_process_message(
120
+ self, broker, message, *, result=None, exception=None):
121
+ try:
122
+ context = self.state.context
123
+ context.pop(exception)
124
+ del self.state.context
125
+ except AttributeError:
126
+ pass
127
+
128
+ after_skip_message = after_process_message
@@ -1,5 +1,6 @@
1
1
  # flask
2
- from flask import Flask, has_request_context, request
2
+ from flask import Flask, has_request_context, request, current_app
3
+ from flask.globals import _cv_app
3
4
  from jinja2 import ChoiceLoader, FileSystemLoader, PrefixLoader, TemplateNotFound
4
5
  from werkzeug.middleware.proxy_fix import ProxyFix
5
6
  # hyperflask extensions
@@ -11,7 +12,6 @@ from flask_collections import Collections
11
12
  from flask_file_routes import FileRoutes, ModuleView, page
12
13
  from flask_configurator import Config
13
14
  from flask_babel import Babel, _, _p, _n, _np, lazy_gettext
14
- from flask_geo import Geolocation
15
15
  from flask_files import Files
16
16
  from flask_sqlorm import FlaskSQLORM
17
17
  from flask_mercure_sse import MercureSSE
@@ -33,13 +33,14 @@ from .utils.metadata import metadata_tags
33
33
  from .utils.freezer import StaticMode, Freezer, freezer_url_generator
34
34
  from .utils.image import image_tag
35
35
  from .components import register_components
36
- from .model import Model, File as SQLFileType, UndefinedDatabase
37
- from .actors import AppContextMiddleware, discover_broker, make_actor_decorator
36
+ from .models import Model, File as SQLFileType, UndefinedDatabase
37
+ from .actors import AppContextMiddleware, discover_broker, actor
38
38
  from .security import respond_with_security_headers, csp_nonce
39
39
  from . import page_helpers
40
40
  # others
41
41
  from jinja_super_macros.registry import FileLoader
42
42
  from jinja_wtforms.extractor import map_jinja_call_node_to_func
43
+ from sqlorm import transaction
43
44
  from sqlorm.sql_template import SQLTemplate
44
45
  from periodiq import PeriodiqMiddleware
45
46
  import os
@@ -77,7 +78,6 @@ class Hyperflask(Flask):
77
78
  "SESSION_COOKIE_SAMESITE": "Lax",
78
79
  "SQLORM_URI": database_uri,
79
80
  # Hyperflask specific
80
- "URL_FOR_SERVER_NAME": None,
81
81
  "LAYOUT": "layouts/default.html",
82
82
  "ALPINE": False,
83
83
  "STATIC_MODE": static_mode,
@@ -151,7 +151,7 @@ class Hyperflask(Flask):
151
151
  PrefixLoader({"ui": FileSystemLoader(os.path.join(os.path.dirname(__file__), "ui"))})
152
152
  ])
153
153
 
154
- DebugToolbarExtension(self)
154
+ HyperDebugToolbarExtension(self)
155
155
  self.freezer = Freezer(self, with_no_argument_rules=False, log_url_for=self.config.get('FREEZER_LOG_URL_FOR', False))
156
156
  self.freezer.register_generator(lambda: freezer_url_generator(self))
157
157
  self.csrf = CSRFProtect(self)
@@ -161,7 +161,6 @@ class Hyperflask(Flask):
161
161
  SuperMacros(self)
162
162
  self.components = self.macros # alias
163
163
  Babel(self, extract_locale_from_request="locale")
164
- Geolocation(self)
165
164
  Files(self)
166
165
  MailTemplates(self, template_folder=emails_folder)
167
166
  file_routes = FileRoutes(self, pages_folder=pages_folder, module_view_class=HyperModuleView)
@@ -235,7 +234,9 @@ class Hyperflask(Flask):
235
234
  self.dramatiq_broker = broker_cls(url=broker_url)
236
235
  self.dramatiq_broker.add_middleware(AppContextMiddleware(self))
237
236
  self.dramatiq_broker.add_middleware(PeriodiqMiddleware())
238
- self.actor = make_actor_decorator(self)
237
+ self.actor = actor.bind(self.dramatiq_broker)
238
+ else:
239
+ self.actor = actor
239
240
 
240
241
  Suspense(self, nonce_getter="csp_nonce()")
241
242
 
@@ -281,32 +282,39 @@ class Hyperflask(Flask):
281
282
  scheme = self.config.get('PREFERRED_URL_SCHEME', 'http')
282
283
  if self.config.get('SERVER_NAME'):
283
284
  return f"{scheme}://{self.config['SERVER_NAME']}"
284
- if self.config['URL_FOR_SERVER_NAME']:
285
- return f"{scheme}://{self.config['URL_FOR_SERVER_NAME']}"
286
285
  if has_request_context():
287
286
  return request.host_url
288
- raise RuntimeError("No request context and no SERVER_NAME or URL_FOR_SERVER_NAME configured, cannot determine base_url")
287
+ raise RuntimeError("No request context and no SERVER_NAME configured, cannot determine base_url")
289
288
 
290
289
  def url_for(self, *args, **kwargs):
291
290
  _external = kwargs.get("_external", False)
292
- _scheme = kwargs.get("_scheme", None)
293
- if _external and self.config['URL_FOR_SERVER_NAME']:
294
- # we handle the host and scheme ourselves
295
- kwargs['_external'] = False
296
- kwargs.pop('_scheme', None)
297
- else:
298
- _external = False
299
- url = super().url_for(*args, **kwargs)
300
- if not _external:
291
+ if not has_request_context() and not _external and not self.config.get('SERVER_NAME'):
292
+ self.config['SERVER_NAME'] = 'http://localhost:5000'
293
+ app_ctx = _cv_app.get(None)
294
+ if app_ctx is not None:
295
+ prev_url_adapter = app_ctx.url_adapter
296
+ app_ctx.url_adapter = self.create_url_adapter(None)
297
+ url = super().url_for(*args, **kwargs)
298
+ if app_ctx is not None:
299
+ app_ctx.url_adapter = prev_url_adapter
300
+ url = url[:len(self.config['SERVER_NAME']):]
301
+ self.config['SERVER_NAME'] = None
301
302
  return url
302
- if not _scheme:
303
- _scheme = self.config.get('PREFERRED_URL_SCHEME', 'http')
304
- url = f"{_scheme}://{self.config['URL_FOR_SERVER_NAME']}{url}"
305
- return url
303
+ return super().url_for(*args, **kwargs)
306
304
 
307
305
 
308
306
  class HyperModuleView(ModuleView):
309
- module_globals = dict(ModuleView.module_globals, asset_url=asset_url, static_url=static_url)
307
+ module_globals = dict(ModuleView.module_globals,
308
+ asset_url=asset_url,
309
+ static_url=static_url,
310
+ transaction=transaction)
310
311
 
311
312
  def _render_template(self):
312
313
  return render_template(page.template)
314
+
315
+
316
+ class HyperDebugToolbarExtension(DebugToolbarExtension):
317
+ def _show_toolbar(self):
318
+ if current_app.config.get("DEBUG_TB_ENABLED", True) is False:
319
+ return False
320
+ return super()._show_toolbar()
@@ -0,0 +1,135 @@
1
+ import os
2
+ import json
3
+ import click
4
+ from flask import current_app
5
+ from flask.cli import FlaskGroup, shell_command, routes_command, ScriptInfo, NoAppException
6
+ from flask_assets_pipeline import cli as assets_cli
7
+ from ..factory import create_app
8
+ from ..security import generate_csp_policy
9
+ from ..utils.freezer import StaticMode
10
+ from .runner import serve_command, run_command, dev_command
11
+ from .worker import worker_command, scheduler_command
12
+ from .scaffold import gen
13
+
14
+
15
+ def _create_app():
16
+ try:
17
+ if os.environ.get("FLASK_APP") == "0":
18
+ raise NoAppException("")
19
+ info = ScriptInfo(os.environ.get("FLASK_APP"))
20
+ return info.load_app()
21
+ except NoAppException:
22
+ return create_app(os.environ.get("FLASK_ROOT"))
23
+
24
+
25
+ cli = FlaskGroup(name='kantree', help='Kantree', add_default_commands=False, create_app=_create_app)
26
+ cli.add_command(shell_command)
27
+ cli.add_command(routes_command)
28
+ cli.add_command(serve_command)
29
+ cli.add_command(run_command)
30
+ cli.add_command(dev_command)
31
+ cli.add_command(worker_command)
32
+ cli.add_command(scheduler_command)
33
+ cli.add_command(gen)
34
+
35
+
36
+ @cli.command("build")
37
+ @click.pass_context
38
+ def build_command(ctx):
39
+ ctx.invoke(assets_cli.build)
40
+ if StaticMode(current_app.config['STATIC_MODE']) != StaticMode.DYNAMIC:
41
+ current_app.freezer.freeze()
42
+
43
+
44
+ @cli.command("csp-header")
45
+ def csp_header_command():
46
+ click.echo(generate_csp_policy())
47
+
48
+
49
+ @cli.command("encode-session-cookie")
50
+ @click.argument("data")
51
+ def encode_session_cookie_command(data):
52
+ """Encode a session cookie value from JSON data"""
53
+ serializer = current_app.session_interface.get_signing_serializer(current_app)
54
+ value = serializer.dumps(json.loads(data))
55
+ click.echo(value)
56
+
57
+
58
+ @cli.command("decode-session-cookie")
59
+ @click.argument("cookie", required=False)
60
+ @click.option("--cookie-file", "-f", type=click.Path(exists=True), help="Path to a file containing a list of cookies (from test-request command)")
61
+ def decode_session_cookie_command(cookie, cookie_file):
62
+ """Decode a session cookie value to JSON data"""
63
+ if not cookie and cookie_file:
64
+ with open(cookie_file, "r") as f:
65
+ for cookie_data in f.readlines():
66
+ key, value = cookie_data.split(";", 1)[0].split("=", 1)
67
+ if key.strip() == current_app.config["SESSION_COOKIE_NAME"]:
68
+ cookie = value.strip()
69
+ break
70
+ elif not cookie:
71
+ click.echo("Either COOKIE argument or --cookie-file option must be provided", err=True)
72
+ return
73
+ try:
74
+ serializer = current_app.session_interface.get_signing_serializer(current_app)
75
+ data = serializer.loads(cookie)
76
+ click.echo(json.dumps(data, indent=2))
77
+ except Exception as e:
78
+ click.echo("Cannot decode cookie value", err=True)
79
+ return
80
+
81
+
82
+ @cli.command("test-request")
83
+ @click.argument("path")
84
+ @click.option("--method", "-m", default="GET", help="HTTP method to use")
85
+ @click.option("--data", "-d", default=None, help="Data to send with the request")
86
+ @click.option("--header", "-h", multiple=True, help="Headers to include in the request")
87
+ @click.option("--follow-redirects/--no-follow-redirects", default=True, help="Whether to follow redirects")
88
+ @click.option("--session", "-s", help="JSON data that will be used to populate the session cookie")
89
+ @click.option("--cookie", "-c", multiple=True, help="Cookies to include in the request")
90
+ @click.option("--cookie-file", "-f", type=click.Path(), help="Path to read and write cookies from, helpful for preserving cookies between requests")
91
+ @click.pass_context
92
+ def test_request_command(ctx, path, method, data, header, follow_redirects, cookie, session, cookie_file):
93
+ """Simulate a request to the application and display the response. The path can include query parameters.
94
+ """
95
+ current_app.config["DEBUG_TB_ENABLED"] = False
96
+ client = current_app.test_client()
97
+ headers = []
98
+ cookies = {}
99
+ for header_ in header:
100
+ key, value = header_.split(":", 1)
101
+ headers.append((key.strip(), value.strip()))
102
+ if cookie_file and os.path.exists(cookie_file):
103
+ with open(cookie_file, "r") as f:
104
+ for cookie_data in f.readlines():
105
+ key, value = cookie_data.split(";", 1)[0].split("=", 1)
106
+ cookies[key.strip()] = value.strip()
107
+ client.set_cookie(key=key.strip(), value=value.strip())
108
+ if cookie:
109
+ for cookie_ in cookie:
110
+ key, value = cookie_.split("=", 1)
111
+ cookies[key.strip()] = value.strip()
112
+ client.set_cookie(key=key.strip(), value=value.strip())
113
+ if session:
114
+ serializer = current_app.session_interface.get_signing_serializer(current_app)
115
+ session_cookie = serializer.dumps(json.loads(session))
116
+ client.set_cookie(key=current_app.config["SESSION_COOKIE_NAME"], value=session_cookie)
117
+
118
+ resp = client.open(path=path, method=method, data=data, headers=headers, follow_redirects=follow_redirects)
119
+
120
+ click.echo(f"HTTP/1.1 {resp.status}")
121
+ for key, value in resp.headers.items():
122
+ click.echo(f"{key}: {value}")
123
+ if key.lower() == "set-cookie":
124
+ name, value = value.split(";", 1)[0].split("=", 1)
125
+ cookies[name.strip()] = value.strip()
126
+ click.echo("")
127
+ click.echo(resp.get_data(as_text=True))
128
+ if cookie_file and cookies:
129
+ with open(cookie_file, "w") as f:
130
+ for name, value in cookies.items():
131
+ f.write(f"{name}={value}\n")
132
+
133
+
134
+ def main():
135
+ cli.main()
@@ -16,16 +16,17 @@ from . import reloader # import hacky patch of werkzeug reloader
16
16
 
17
17
 
18
18
  @click.command("serve")
19
+ @click.option("--gunicorn/--no-gunicorn", default=True, is_flag=True, help="Use Gunicorn to serve the app.")
19
20
  @click.option('--gunicorn-config')
20
21
  @click.option("--init-db", is_flag=True)
21
22
  @pass_script_info
22
23
  @click.pass_context
23
- def serve_command(ctx, info, host, port, gunicorn_config, init_db, **run_kwargs):
24
+ def serve_command(ctx, info, host, port, gunicorn, gunicorn_config, init_db, **run_kwargs):
24
25
  if init_db:
25
26
  print("Initializing database...")
26
27
  Popen([sys.argv[0], "db", "init"]).wait()
27
28
 
28
- if os.environ.get("FLASK_DEBUG") == "1":
29
+ if not gunicorn or os.environ.get("FLASK_DEBUG") == "1":
29
30
  ctx.invoke(flask_run_command, host=host, port=port, **run_kwargs)
30
31
  return
31
32
 
@@ -45,12 +46,13 @@ serve_command.params = flask_run_command.params + serve_command.params
45
46
  @click.argument('processes', nargs=-1)
46
47
  @click.option("--host", "-h", default="127.0.0.1", help="The interface to bind to.")
47
48
  @click.option("--port", "-p", default=5000, help="The port to bind to.")
48
- @click.option('--concurrency')
49
- @click.option("--extend-procfile", is_flag=True)
50
- @click.option("--init-db/--no-init-db", is_flag=True, default=None)
51
- @click.option('--gunicorn-config')
52
- @click.option("--dev", is_flag=True)
53
- def run_command(processes, host, port, concurrency, extend_procfile, init_db, gunicorn_config, dev):
49
+ @click.option('--concurrency', help="Number of concurrent processes to run.")
50
+ @click.option("--extend-procfile", is_flag=True, help="Extend an existing Procfile instead of using it")
51
+ @click.option("--init-db/--no-init-db", is_flag=True, default=None, help="Initialize the database before starting processes.")
52
+ @click.option('--gunicorn-config', help="A gunicorn configuration value for the web process in production (in the form key=value)")
53
+ @click.option("--logfile", help="File to log output of all processes to instead of stdout.")
54
+ @click.option("--dev", is_flag=True, help="Run in development mode with debug enabled.")
55
+ def run_command(processes, host, port, concurrency, extend_procfile, init_db, gunicorn_config, logfile, dev):
54
56
  if dev:
55
57
  os.environ["FLASK_DEBUG"] = "1"
56
58
 
@@ -58,57 +60,36 @@ def run_command(processes, host, port, concurrency, extend_procfile, init_db, gu
58
60
  print("Initializing database...")
59
61
  Popen([sys.argv[0], "db", "init"]).wait()
60
62
 
61
- _processes = {}
62
- procfile = None
63
63
  config = load_config()
64
64
 
65
65
  if not dev and not config.get('SECRET_KEY'):
66
66
  print("Critical: No SECRET_KEY set, cannot run in production mode.")
67
67
  sys.exit(1)
68
68
 
69
+ _processes = get_default_processes(dev=dev, gunicorn_config=gunicorn_config, host=host)
70
+
69
71
  for filename in [f"Procfile.{config['ENV']}", "Procfile"]:
70
72
  if os.path.exists(filename):
71
73
  with open(filename) as f:
72
74
  procfile = parse_procfile(f.read()).processes
75
+ if extend_procfile:
76
+ _processes.update(procfile)
77
+ else:
78
+ _processes = procfile
73
79
  break
74
80
 
75
- _processes = {
76
- "web": [sys.argv[0], "serve", "--host", host, "--port", "$PORT"],
77
- "worker": [sys.argv[0], "worker"],
78
- "scheduler": [sys.argv[0], "scheduler"]
79
- }
80
- if gunicorn_config:
81
- _processes["web"].extend(["--gunicorn-config", gunicorn_config])
81
+ processes = {name: cmd for name, cmd in _processes.items() if not processes or name in processes}
82
+ env = {}
82
83
  if dev:
83
- _processes["assets"] = [sys.argv[0], "assets", "dev"]
84
- _processes["worker"].extend(["-p", "1", "-t", "1"])
84
+ env = {"FLASK_DEBUG": "1"}
85
85
  else:
86
- _processes["mercurehub"] = [sys.executable, "-m", "flask_mercure_sse.server", "--port", "$PORT"]
87
-
88
- if procfile and extend_procfile:
89
- _processes.update(procfile)
90
- elif procfile:
91
- _processes = procfile
92
-
93
- processes = {name: " ".join(_processes[name]) if isinstance(_processes[name], list) else _processes[name]
94
- for name in _processes if not processes or name in processes}
95
- env = {}
96
- if not dev:
97
86
  env = {
98
87
  "MERCURE_SUBSCRIBER_SECRET_KEY": config.get("MERCURE_SUBSCRIBER_SECRET_KEY", config['SECRET_KEY']),
99
88
  "MERCURE_PUBLISHER_SECRET_KEY": config.get("MERCURE_PUBLISHER_SECRET_KEY", config['SECRET_KEY']),
100
89
  }
101
90
 
102
- manager = ProcessManager(Printer(sys.stdout, colour=True))
103
- for p in environ.expand_processes(processes,
104
- concurrency=_parse_concurrency(concurrency),
105
- port=port):
106
- e = os.environ.copy()
107
- e.update(env)
108
- e.update(p.env)
109
- manager.add_process(p.name, p.cmd, quiet=p.quiet, env=e)
110
-
111
91
  print("Starting Hyperflask processes...")
92
+ manager = create_process_manager(processes, port=port, concurrency=concurrency, env=env, logfile=logfile)
112
93
  manager.loop()
113
94
  sys.exit(manager.returncode)
114
95
 
@@ -125,6 +106,43 @@ def dev_command(ctx, **kwargs):
125
106
  dev_command.params = list(run_command.params)
126
107
 
127
108
 
109
+ def get_default_processes(dev=False, gunicorn_config=None, host="127.0.0.1"):
110
+ processes = {
111
+ "web": [sys.argv[0], "serve", "--host", host, "--port", "$PORT"],
112
+ "worker": [sys.argv[0], "worker"],
113
+ "scheduler": [sys.argv[0], "scheduler"]
114
+ }
115
+ if gunicorn_config:
116
+ processes["web"].extend(["--gunicorn-config", gunicorn_config])
117
+ if dev:
118
+ processes["assets"] = [sys.argv[0], "assets", "dev"]
119
+ processes["worker"].extend(["-p", "1", "-t", "1"])
120
+ else:
121
+ processes["mercurehub"] = [sys.executable, "-m", "flask_mercure_sse.server", "--port", "$PORT"]
122
+ return processes
123
+
124
+
125
+ def create_process_manager(processes, port=5000, concurrency=None, env=None, logfile=None):
126
+ processes = {name: " ".join(cmd) if isinstance(cmd, list) else cmd for name, cmd in processes.items()}
127
+ manager = ProcessManager(Printer(sys.stdout, colour=True))
128
+ if not env:
129
+ env = {}
130
+ env["BASE_PORT"] = str(port)
131
+ for p in environ.expand_processes(processes,
132
+ concurrency=_parse_concurrency(concurrency),
133
+ port=port):
134
+ e = os.environ.copy()
135
+ e.update(env)
136
+ e.update(p.env)
137
+ manager.add_process(p.name, p.cmd, quiet=p.quiet, env=e)
138
+
139
+ if logfile:
140
+ log_file = open(logfile, "a")
141
+ manager.printer.output = log_file
142
+
143
+ return manager
144
+
145
+
128
146
  class ProcessManager(BaseProcessManager):
129
147
  # This override prevents the manager to stop if the worker and scheduler exit because dramatiq is unused
130
148
  def _any_stopped(self):