future-framework 3.0.2__tar.gz → 3.1.1__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 (86) hide show
  1. {future_framework-3.0.2 → future_framework-3.1.1}/PKG-INFO +25 -32
  2. {future_framework-3.0.2 → future_framework-3.1.1}/README.md +3 -3
  3. {future_framework-3.0.2 → future_framework-3.1.1}/future/application.py +2 -1
  4. {future_framework-3.0.2 → future_framework-3.1.1}/future/cli.py +17 -18
  5. {future_framework-3.0.2 → future_framework-3.1.1}/future/interfaces/ITask.py +2 -1
  6. future_framework-3.1.1/future/logger.py +110 -0
  7. future_framework-3.1.1/future/scheduling.py +73 -0
  8. {future_framework-3.0.2 → future_framework-3.1.1}/future/taskscheduler.py +31 -17
  9. future_framework-3.1.1/pyproject.toml +127 -0
  10. future_framework-3.0.2/pyproject.toml → future_framework-3.1.1/pyproject.toml.orig +50 -50
  11. future_framework-3.0.2/future/logger.py +0 -53
  12. {future_framework-3.0.2 → future_framework-3.1.1}/LICENSE +0 -0
  13. {future_framework-3.0.2 → future_framework-3.1.1}/future/__init__.py +0 -0
  14. {future_framework-3.0.2 → future_framework-3.1.1}/future/authentication/Auth0Authentication.py +0 -0
  15. {future_framework-3.0.2 → future_framework-3.1.1}/future/authentication/AzureADAuthentication.py +0 -0
  16. {future_framework-3.0.2 → future_framework-3.1.1}/future/authentication/BasicAuthentication.py +0 -0
  17. {future_framework-3.0.2 → future_framework-3.1.1}/future/authentication/KerberosAuthentication.py +0 -0
  18. {future_framework-3.0.2 → future_framework-3.1.1}/future/authentication/KeycloakAuthentication.py +0 -0
  19. {future_framework-3.0.2 → future_framework-3.1.1}/future/authentication/OAuth2Authentication.py +0 -0
  20. {future_framework-3.0.2 → future_framework-3.1.1}/future/authentication/OpenIdConnectAuthentication.py +0 -0
  21. {future_framework-3.0.2 → future_framework-3.1.1}/future/authentication/SAMLAuthentication.py +0 -0
  22. {future_framework-3.0.2 → future_framework-3.1.1}/future/authentication/__init__.py +0 -0
  23. {future_framework-3.0.2 → future_framework-3.1.1}/future/controllers/DebugController.py +0 -0
  24. {future_framework-3.0.2 → future_framework-3.1.1}/future/controllers/GraphQLController.py +0 -0
  25. {future_framework-3.0.2 → future_framework-3.1.1}/future/controllers/OpenAPIController.py +0 -0
  26. {future_framework-3.0.2 → future_framework-3.1.1}/future/controllers/WebSocketController.py +0 -0
  27. {future_framework-3.0.2 → future_framework-3.1.1}/future/controllers/WelcomeController.py +0 -0
  28. {future_framework-3.0.2 → future_framework-3.1.1}/future/controllers/__init__.py +0 -0
  29. {future_framework-3.0.2 → future_framework-3.1.1}/future/database.py +0 -0
  30. {future_framework-3.0.2 → future_framework-3.1.1}/future/databases/ClickhouseDatabase.py +0 -0
  31. {future_framework-3.0.2 → future_framework-3.1.1}/future/databases/ElasticsearchDatabase.py +0 -0
  32. {future_framework-3.0.2 → future_framework-3.1.1}/future/databases/MongoDBDatabase.py +0 -0
  33. {future_framework-3.0.2 → future_framework-3.1.1}/future/databases/MySQLDatabase.py +0 -0
  34. {future_framework-3.0.2 → future_framework-3.1.1}/future/databases/PostgresDatabase.py +0 -0
  35. {future_framework-3.0.2 → future_framework-3.1.1}/future/databases/RedisDatabase.py +0 -0
  36. {future_framework-3.0.2 → future_framework-3.1.1}/future/databases/SQLiteDatabase.py +0 -0
  37. {future_framework-3.0.2 → future_framework-3.1.1}/future/databases/__init__.py +0 -0
  38. {future_framework-3.0.2 → future_framework-3.1.1}/future/exceptions.py +0 -0
  39. {future_framework-3.0.2 → future_framework-3.1.1}/future/interfaces/IAuthentication.py +0 -0
  40. {future_framework-3.0.2 → future_framework-3.1.1}/future/interfaces/IController.py +0 -0
  41. {future_framework-3.0.2 → future_framework-3.1.1}/future/interfaces/IDatabase.py +0 -0
  42. {future_framework-3.0.2 → future_framework-3.1.1}/future/interfaces/IMiddleware.py +0 -0
  43. {future_framework-3.0.2 → future_framework-3.1.1}/future/interfaces/IModel.py +0 -0
  44. {future_framework-3.0.2 → future_framework-3.1.1}/future/interfaces/IPlugin.py +0 -0
  45. {future_framework-3.0.2 → future_framework-3.1.1}/future/interfaces/__init__.py +0 -0
  46. {future_framework-3.0.2 → future_framework-3.1.1}/future/interfacing.py +0 -0
  47. {future_framework-3.0.2 → future_framework-3.1.1}/future/lifespan.py +0 -0
  48. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/BruteforcePreventionMiddleware.py +0 -0
  49. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/CORSMiddleware.py +0 -0
  50. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/CSRFMiddleware.py +0 -0
  51. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/GZipMiddleware.py +0 -0
  52. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/HTAccessConfuserMiddleware.py +0 -0
  53. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/HeaderConfuserMiddleware.py +0 -0
  54. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/RateLimitMiddleware.py +0 -0
  55. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/ResponseCodeConfuserMiddleware.py +0 -0
  56. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/SQLiConfuserMiddleware.py +0 -0
  57. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/ScopeValidationMiddleware.py +0 -0
  58. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/SessionMiddleware.py +0 -0
  59. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/TestMiddlewareRequest.py +0 -0
  60. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/TestMiddlewareResponse.py +0 -0
  61. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/WebServerConfuserMiddleware.py +0 -0
  62. {future_framework-3.0.2 → future_framework-3.1.1}/future/middleware/__init__.py +0 -0
  63. {future_framework-3.0.2 → future_framework-3.1.1}/future/migrations.py +0 -0
  64. {future_framework-3.0.2 → future_framework-3.1.1}/future/models/PostModel.py +0 -0
  65. {future_framework-3.0.2 → future_framework-3.1.1}/future/models/Query.py +0 -0
  66. {future_framework-3.0.2 → future_framework-3.1.1}/future/models/UserModel.py +0 -0
  67. {future_framework-3.0.2 → future_framework-3.1.1}/future/models/__init__.py +0 -0
  68. {future_framework-3.0.2 → future_framework-3.1.1}/future/openapi.py +0 -0
  69. {future_framework-3.0.2 → future_framework-3.1.1}/future/plugins/ElasticsearchPlugin.py +0 -0
  70. {future_framework-3.0.2 → future_framework-3.1.1}/future/plugins/__init__.py +0 -0
  71. {future_framework-3.0.2 → future_framework-3.1.1}/future/request.py +0 -0
  72. {future_framework-3.0.2 → future_framework-3.1.1}/future/response.py +0 -0
  73. {future_framework-3.0.2 → future_framework-3.1.1}/future/routing.py +0 -0
  74. {future_framework-3.0.2 → future_framework-3.1.1}/future/seeder.py +0 -0
  75. {future_framework-3.0.2 → future_framework-3.1.1}/future/settings.py +0 -0
  76. {future_framework-3.0.2 → future_framework-3.1.1}/future/tasks/CheckDNSTask.py +0 -0
  77. {future_framework-3.0.2 → future_framework-3.1.1}/future/tasks/CheckDiskUsageTask.py +0 -0
  78. {future_framework-3.0.2 → future_framework-3.1.1}/future/tasks/CheckHttpStatusTask.py +0 -0
  79. {future_framework-3.0.2 → future_framework-3.1.1}/future/tasks/CheckMemoryUsageTask.py +0 -0
  80. {future_framework-3.0.2 → future_framework-3.1.1}/future/tasks/CheckSSHBannerTask.py +0 -0
  81. {future_framework-3.0.2 → future_framework-3.1.1}/future/tasks/CheckSystemUptimeTask.py +0 -0
  82. {future_framework-3.0.2 → future_framework-3.1.1}/future/tasks/DailyBackupTask.py +0 -0
  83. {future_framework-3.0.2 → future_framework-3.1.1}/future/tasks/SyncTaskExample.py +0 -0
  84. {future_framework-3.0.2 → future_framework-3.1.1}/future/tasks/__init__.py +0 -0
  85. {future_framework-3.0.2 → future_framework-3.1.1}/future/testclient.py +0 -0
  86. {future_framework-3.0.2 → future_framework-3.1.1}/future/types.py +0 -0
@@ -1,37 +1,31 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: future-framework
3
- Version: 3.0.2
3
+ Version: 3.1.1
4
4
  Summary: ASGI-based framework for web APIs
5
- License: LICENSE
6
- License-File: LICENSE
7
5
  Author: nicolaipre
8
- Requires-Python: >=3.12,<4.0
9
- Classifier: License :: Other/Proprietary License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.12
12
- Classifier: Programming Language :: Python :: 3.13
13
- Classifier: Programming Language :: Python :: 3.14
14
- Classifier: Programming Language :: Python :: 3.15
15
- Requires-Dist: aiomysql (>=0.2.0,<0.3.0)
16
- Requires-Dist: aiosqlite (>=0.21.0,<0.22.0)
17
- Requires-Dist: asynch (>=0.2.5,<0.3.0)
18
- Requires-Dist: asyncpg (>=0.30.0,<0.31.0)
19
- Requires-Dist: clickhouse-driver (>=0.2.11,<0.3.0)
20
- Requires-Dist: elasticsearch (>=8.15.0,<9)
21
- Requires-Dist: faker (>=40.0.0,<41.0.0)
22
- Requires-Dist: httpx (>=0.28.1,<0.29.0)
23
- Requires-Dist: inflection (>=0.5.1,<0.6.0)
24
- Requires-Dist: pymongo (>=4.17.0,<5.0.0)
25
- Requires-Dist: python-dotenv (>=1.2.2,<2.0.0)
26
- Requires-Dist: redis (>=8.0.0,<9.0.0)
27
- Requires-Dist: rich (>=15.0.0,<16.0.0)
28
- Requires-Dist: sqlalchemy (>=2.0.51,<3.0.0)
29
- Requires-Dist: strawberry-graphql (>=0.323.0,<0.324.0)
30
- Requires-Dist: uvicorn (>=0.51.0,<0.52.0)
31
- Project-URL: Bug Tracker, https://github.com/nicolaipre/future-framework/issues
32
- Project-URL: Documentation, https://nicolaipre.github.io/future-framework/
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Dist: uvicorn>=0.51.0,<0.52.0
9
+ Requires-Dist: httpx>=0.28.1,<0.29.0
10
+ Requires-Dist: rich>=15.0.0,<16.0.0
11
+ Requires-Dist: elasticsearch>=8.15.0,<9.0.0
12
+ Requires-Dist: strawberry-graphql>=0.323.0,<0.324.0
13
+ Requires-Dist: python-dotenv>=1.2.2,<2.0.0
14
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.51,<3.0.0
15
+ Requires-Dist: inflection>=0.5.1,<0.6.0
16
+ Requires-Dist: pymongo>=4.17.0,<5.0.0
17
+ Requires-Dist: aiosqlite>=0.21.0,<0.22.0
18
+ Requires-Dist: aiomysql>=0.2.0,<0.3.0
19
+ Requires-Dist: asyncpg>=0.30.0,<0.31.0
20
+ Requires-Dist: asynch>=0.2.5,<0.3.0
21
+ Requires-Dist: clickhouse-driver>=0.2.11,<0.3.0
22
+ Requires-Dist: redis>=8.0.0,<9.0.0
23
+ Requires-Dist: faker>=40.0.0,<41.0.0
24
+ Requires-Python: >=3.12, <4.0
33
25
  Project-URL: Homepage, https://github.com/nicolaipre/future-framework
34
26
  Project-URL: Repository, https://github.com/nicolaipre/future-framework
27
+ Project-URL: Documentation, https://nicolaipre.github.io/future-framework/
28
+ Project-URL: Bug Tracker, https://github.com/nicolaipre/future-framework/issues
35
29
  Description-Content-Type: text/markdown
36
30
 
37
31
  # Future
@@ -42,12 +36,12 @@ Description-Content-Type: text/markdown
42
36
  [![Coverage](https://codecov.io/gh/nicolaipre/future-framework/branch/master/graph/badge.svg)](https://codecov.io/gh/nicolaipre/future-framework)
43
37
 
44
38
  ## Documentation
45
- Published at **[nicolaipre.github.io/future-framework](https://nicolaipre.github.io/future-framework/)**. To preview locally: `poetry install --with docs && poetry run mkdocs serve`.
39
+ Published at **[nicolaipre.github.io/future-framework](https://nicolaipre.github.io/future-framework/)**. To preview locally: `uv sync --group docs && uv run mkdocs serve`.
46
40
 
47
41
  ## Install
48
42
  ```bash
49
- poetry add future-framework
50
- poetry run future init myproject
43
+ uv add future-framework
44
+ uv run future init myproject
51
45
  ```
52
46
 
53
47
  Import name is `future` (`from future.application import Future`).
@@ -71,4 +65,3 @@ app.run(host="127.0.0.1", port=8000)
71
65
 
72
66
  ## License
73
67
  See [LICENSE](LICENSE). Versioning: [CHANGELOG.md](CHANGELOG.md).
74
-
@@ -6,12 +6,12 @@
6
6
  [![Coverage](https://codecov.io/gh/nicolaipre/future-framework/branch/master/graph/badge.svg)](https://codecov.io/gh/nicolaipre/future-framework)
7
7
 
8
8
  ## Documentation
9
- Published at **[nicolaipre.github.io/future-framework](https://nicolaipre.github.io/future-framework/)**. To preview locally: `poetry install --with docs && poetry run mkdocs serve`.
9
+ Published at **[nicolaipre.github.io/future-framework](https://nicolaipre.github.io/future-framework/)**. To preview locally: `uv sync --group docs && uv run mkdocs serve`.
10
10
 
11
11
  ## Install
12
12
  ```bash
13
- poetry add future-framework
14
- poetry run future init myproject
13
+ uv add future-framework
14
+ uv run future init myproject
15
15
  ```
16
16
 
17
17
  Import name is `future` (`from future.application import Future`).
@@ -16,7 +16,7 @@ from rich.table import Table
16
16
  from rich.text import Text
17
17
 
18
18
  from future.exceptions import ErrorHandler, HTTPException
19
- from future.logger import log
19
+ from future.logger import create_uvicorn_logging_config, log
20
20
  from future.interfaces.IMiddleware import IMiddleware
21
21
  from future.openapi import get_openapi_config, is_docs_path, openapi_routes, rebuild_spec_from_routes, set_openapi_config, spec_path
22
22
  from future.request import Request
@@ -656,6 +656,7 @@ class Future:
656
656
  ssl_certfile=tls_cert,
657
657
  ssl_keyfile_password=tls_password,
658
658
  timeout_graceful_shutdown=3,
659
+ log_config=create_uvicorn_logging_config(),
659
660
  log_level="info",
660
661
  lifespan="on",
661
662
  access_log=access_log,
@@ -23,13 +23,13 @@ STUB_KINDS = {
23
23
 
24
24
  ```bash
25
25
  cp .env.example .env # DB_DATABASE=database → database.sqlite
26
- poetry install
27
- poetry run future make:migration ExampleModel
28
- poetry run future migrate
29
- poetry run future make:seed ExampleModel
30
- poetry run future seed
31
- poetry run python run.py
32
- poetry run future routes
26
+ uv sync
27
+ uv run future make:migration ExampleModel
28
+ uv run future migrate
29
+ uv run future make:seed ExampleModel
30
+ uv run future seed
31
+ uv run python run.py
32
+ uv run future routes
33
33
  ```
34
34
  """).lstrip("\n"),
35
35
  },
@@ -62,23 +62,23 @@ STUB_KINDS = {
62
62
  "pyproject": {
63
63
  "path": "pyproject.toml",
64
64
  "template": dedent("""
65
- [tool.poetry]
65
+ [project]
66
66
  name = "future-boilerplate"
67
67
  version = "0.0.1"
68
68
  description = "Boilerplate app for the Future framework."
69
- authors = ["nicolaipre"]
69
+ requires-python = ">=3.12,<4.0"
70
70
  readme = "README.md"
71
+ dependencies = [
72
+ "python-dotenv>=1.0.1,<2.0.0",
73
+ "future-framework>=3.0.2,<4.0.0",
74
+ ]
71
75
 
72
- [tool.poetry.dependencies]
73
- python = "^3.12"
74
- python-dotenv = "^1.0.1"
75
- future-framework = "^2.0.0"
76
76
  # Or, while developing Future next to this app:
77
- # future-framework = { path = "../future-framework", develop = true }
77
+ # [tool.uv.sources]
78
+ # future-framework = { path = "../future-framework", editable = true }
78
79
 
79
- [build-system]
80
- requires = ["poetry-core"]
81
- build-backend = "poetry.core.masonry.api"
80
+ [tool.uv]
81
+ package = false
82
82
  """).lstrip("\n"),
83
83
  },
84
84
  "license": {
@@ -114,7 +114,6 @@ STUB_KINDS = {
114
114
  .vscode/
115
115
  .env
116
116
  .venv
117
- poetry.lock
118
117
  __pycache__/
119
118
  *.sqlite
120
119
  *.sqlite3
@@ -2,7 +2,7 @@ from datetime import datetime
2
2
  from typing import Optional
3
3
 
4
4
  from future.interfacing import Interface
5
- from future.taskscheduler import Unit
5
+ from future.scheduling import Unit, WorkingHours
6
6
 
7
7
 
8
8
  class ITask(Interface):
@@ -11,6 +11,7 @@ class ITask(Interface):
11
11
  unit: Optional[Unit] = None
12
12
  start_time: Optional[datetime] = None
13
13
  jitter: Optional[float] = None
14
+ working_hours: Optional[WorkingHours] = None
14
15
 
15
16
  async def run(self) -> None:
16
17
  raise NotImplementedError
@@ -0,0 +1,110 @@
1
+ import atexit
2
+ import logging
3
+ import logging.config
4
+ import logging.handlers
5
+
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from uvicorn.logging import AccessFormatter, DefaultFormatter
10
+
11
+ from future.settings import LOGGING_CONFIG
12
+
13
+
14
+ class UtcTimestampFormatter:
15
+ """Render log timestamps as unambiguous ISO 8601 UTC values."""
16
+
17
+ def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
18
+ return datetime.fromtimestamp(record.created, timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
19
+
20
+
21
+ # ANSI color codes
22
+ class ColoredFormatter(UtcTimestampFormatter, logging.Formatter):
23
+ """Custom formatter with colors like uvicorn"""
24
+
25
+ COLORS = {
26
+ "DEBUG": "\033[36m", # Cyan
27
+ "INFO": "\033[32m", # Green
28
+ "WARNING": "\033[33m", # Yellow
29
+ "ERROR": "\033[31m", # Red
30
+ "CRITICAL": "\033[35m", # Magenta
31
+ }
32
+ RESET = "\033[0m"
33
+
34
+ def format(self, record: logging.LogRecord) -> str:
35
+ message = super().format(record)
36
+ timestamp = self.formatTime(record)
37
+ levelname = record.levelname
38
+ if levelname in self.COLORS:
39
+ # Calculate padding based on original levelname length BEFORE adding colors
40
+ padding = " " * (9 - len(levelname)) # 9 is max level length (CRITICAL) + 1
41
+ colored_levelname = f"{self.COLORS[levelname]}{levelname}{self.RESET}"
42
+ return f"{timestamp} {colored_levelname}:{padding}{message}"
43
+ return f"{timestamp} {levelname}: {message}"
44
+
45
+
46
+ class UvicornDefaultFormatter(UtcTimestampFormatter, DefaultFormatter):
47
+ """Uvicorn server formatter using Future's UTC timestamp format."""
48
+
49
+
50
+ class UvicornAccessFormatter(UtcTimestampFormatter, AccessFormatter):
51
+ """Uvicorn access formatter using Future's UTC timestamp format."""
52
+
53
+
54
+ def create_uvicorn_logging_config() -> dict[str, Any]:
55
+ """Return a fresh Uvicorn logging config for each server invocation."""
56
+ return {
57
+ "version": 1,
58
+ "disable_existing_loggers": False,
59
+ "formatters": {
60
+ "default": {
61
+ "()": UvicornDefaultFormatter,
62
+ "fmt": "%(asctime)s %(levelprefix)s %(message)s",
63
+ "use_colors": None,
64
+ },
65
+ "access": {
66
+ "()": UvicornAccessFormatter,
67
+ "fmt": '%(asctime)s %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
68
+ },
69
+ },
70
+ "handlers": {
71
+ "default": {
72
+ "formatter": "default",
73
+ "class": "logging.StreamHandler",
74
+ "stream": "ext://sys.stderr",
75
+ },
76
+ "access": {
77
+ "formatter": "access",
78
+ "class": "logging.StreamHandler",
79
+ "stream": "ext://sys.stdout",
80
+ },
81
+ },
82
+ "loggers": {
83
+ "uvicorn": {"handlers": ["default"], "level": "INFO", "propagate": False},
84
+ "uvicorn.error": {"level": "INFO"},
85
+ "uvicorn.access": {"handlers": ["access"], "level": "INFO", "propagate": False},
86
+ },
87
+ }
88
+
89
+
90
+ # Configure logging using settings
91
+ logging.config.dictConfig(LOGGING_CONFIG)
92
+
93
+ log = logging.getLogger("future")
94
+ formatter = ColoredFormatter("%(message)s")
95
+
96
+ # QueueHandler itself does not format; the listener's stdout handler does.
97
+ # Handler level NOTSET so Future's log.setLevel(APP_DEBUG) alone controls filtering.
98
+ queue_handler = logging.getHandlerByName("queue_handler")
99
+ if queue_handler is not None and isinstance(queue_handler, logging.handlers.QueueHandler):
100
+ listener = getattr(queue_handler, "listener", None)
101
+ if listener is not None:
102
+ for handler in listener.handlers:
103
+ handler.setFormatter(formatter)
104
+ handler.setLevel(logging.NOTSET)
105
+ listener.start()
106
+ atexit.register(listener.stop)
107
+ else:
108
+ for handler in log.handlers:
109
+ handler.setFormatter(formatter)
110
+ handler.setLevel(logging.NOTSET)
@@ -0,0 +1,73 @@
1
+ from datetime import datetime, time
2
+ from enum import Enum, IntEnum
3
+ from typing import Optional
4
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
5
+
6
+
7
+ class Unit(Enum):
8
+ SECONDS = "seconds"
9
+ MINUTES = "minutes"
10
+ HOURS = "hours"
11
+ DAYS = "days"
12
+
13
+
14
+ class Weekday(IntEnum):
15
+ MONDAY = 0
16
+ TUESDAY = 1
17
+ WEDNESDAY = 2
18
+ THURSDAY = 3
19
+ FRIDAY = 4
20
+ SATURDAY = 5
21
+ SUNDAY = 6
22
+
23
+
24
+ class WorkingHours:
25
+ """A recurring local-time window in which a scheduled task may run."""
26
+
27
+ def __init__(self, start: time, end: time, timezone: str = "UTC", weekdays: Optional[frozenset[Weekday]] = None) -> None:
28
+ self.start = start
29
+ self.end = end
30
+ self.timezone = timezone
31
+ self.weekdays = weekdays if weekdays is not None else frozenset({Weekday.MONDAY, Weekday.TUESDAY, Weekday.WEDNESDAY, Weekday.THURSDAY, Weekday.FRIDAY})
32
+ self._validate()
33
+
34
+ def _validate(self) -> None:
35
+ if self.start.tzinfo is not None or self.end.tzinfo is not None:
36
+ raise ValueError("Working-hours start and end must be timezone-naive times")
37
+ if self.start == self.end:
38
+ raise ValueError("Working-hours start and end must be different")
39
+
40
+ try:
41
+ ZoneInfo(self.timezone)
42
+ except ZoneInfoNotFoundError as error:
43
+ raise ValueError(f"Unknown working-hours timezone: {self.timezone}") from error
44
+
45
+ try:
46
+ weekdays = frozenset(Weekday(day) for day in self.weekdays)
47
+ except ValueError as error:
48
+ raise ValueError("Working-hours weekdays must contain valid Weekday values") from error
49
+ if not weekdays:
50
+ raise ValueError("Working-hours weekdays cannot be empty")
51
+ self.weekdays = weekdays
52
+
53
+ def allows(self, moment: datetime) -> bool:
54
+ """Return whether an aware instant falls within this working window."""
55
+ if moment.tzinfo is None:
56
+ raise ValueError("WorkingHours.allows requires a timezone-aware datetime")
57
+
58
+ local = moment.astimezone(ZoneInfo(self.timezone))
59
+ local_time = local.time()
60
+ weekday = Weekday(local.weekday())
61
+
62
+ if self.start < self.end:
63
+ return weekday in self.weekdays and self.start <= local_time < self.end
64
+
65
+ # Overnight windows belong to the day on which they open. For example,
66
+ # Friday 22:00-06:00 includes early Saturday morning.
67
+ if local_time >= self.start:
68
+ opening_day = weekday
69
+ elif local_time < self.end:
70
+ opening_day = Weekday((weekday - 1) % 7)
71
+ else:
72
+ return False
73
+ return opening_day in self.weekdays
@@ -1,36 +1,39 @@
1
1
  import asyncio
2
2
  import random
3
3
 
4
- from datetime import datetime, timedelta
5
- from enum import Enum
6
- from typing import Optional
4
+ from datetime import datetime, timedelta, timezone as datetime_timezone
5
+ from typing import Callable, Optional
7
6
 
8
7
  from future.logger import log
8
+ from future.scheduling import Unit, Weekday, WorkingHours
9
9
 
10
+ __all__ = ["CronScheduler", "ScheduledTask", "Unit", "Weekday", "WorkingHours"]
10
11
 
11
- class Unit(Enum):
12
- SECONDS = "seconds"
13
- MINUTES = "minutes"
14
- HOURS = "hours"
15
- DAYS = "days"
12
+
13
+ def _as_utc(moment: datetime) -> datetime:
14
+ """Normalize datetimes for safe comparison and interval arithmetic."""
15
+ if moment.tzinfo is None:
16
+ moment = moment.astimezone()
17
+ return moment.astimezone(datetime_timezone.utc)
16
18
 
17
19
 
18
20
  class ScheduledTask:
19
21
  """Represents a scheduled task with its timing configuration."""
20
22
 
21
- def __init__(self, task) -> None:
23
+ def __init__(self, task, now: Optional[datetime] = None) -> None:
22
24
  self.task = task
23
25
  self.name = task.name
24
26
  self.interval = task.interval
25
27
  self.unit = task.unit
26
- self.start_time = task.start_time
28
+ self.start_time = _as_utc(task.start_time) if task.start_time is not None else _as_utc(now or datetime.now(datetime_timezone.utc))
27
29
  self.jitter = task.jitter
30
+ self.working_hours = getattr(task, "working_hours", None)
31
+ if self.working_hours is not None and not isinstance(self.working_hours, WorkingHours):
32
+ raise TypeError("task.working_hours must be a WorkingHours instance or None")
28
33
  self.last_run: Optional[datetime] = None
29
34
  self.next_run: Optional[datetime] = None
30
35
  self.running = False
31
36
 
32
- if self.start_time is None:
33
- self.start_time = datetime.now()
34
37
  self.calculate_next_run()
35
38
 
36
39
  def calculate_next_run(self) -> None:
@@ -49,21 +52,32 @@ class ScheduledTask:
49
52
  if self.next_run is not None and self.jitter and self.jitter > 0:
50
53
  self.next_run = self.next_run + timedelta(seconds=random.uniform(0, self.jitter))
51
54
 
55
+ def is_due(self, now: datetime) -> bool:
56
+ """Return whether the task is due and permitted to run now."""
57
+ now = _as_utc(now)
58
+ if self.running or self.next_run is None or now < self.next_run:
59
+ return False
60
+ return self.working_hours is None or self.working_hours.allows(now)
61
+
52
62
 
53
63
  class CronScheduler:
54
64
  """A cron-like scheduler for running background tasks."""
55
65
 
56
- def __init__(self) -> None:
66
+ def __init__(self, clock: Optional[Callable[[], datetime]] = None) -> None:
57
67
  self.tasks: dict[str, ScheduledTask] = {}
58
68
  self.running = False
59
69
  self.check_interval = 1.0 # Check every second for tasks to run
70
+ self._clock = clock or (lambda: datetime.now(datetime_timezone.utc))
71
+
72
+ def _now(self) -> datetime:
73
+ return _as_utc(self._clock())
60
74
 
61
75
  def add_task(self, task) -> None:
62
76
  if task.interval is None or task.unit is None or not task.name:
63
77
  log.warning(f"Skipping task '{task.name or '?'}' - missing required parameters")
64
78
  return
65
79
 
66
- scheduled = ScheduledTask(task)
80
+ scheduled = ScheduledTask(task, self._now())
67
81
  self.tasks[task.name] = scheduled
68
82
  jitter_note = f" (jitter 0–{task.jitter}s)" if task.jitter else ""
69
83
  log.info(f"Added scheduled task '{task.name}' to run every {task.interval} {task.unit.value}{jitter_note}")
@@ -91,7 +105,7 @@ class CronScheduler:
91
105
  log.debug(f"Running scheduled task '{scheduled.name}'")
92
106
  await scheduled.task.run()
93
107
 
94
- scheduled.last_run = datetime.now()
108
+ scheduled.last_run = self._now()
95
109
  scheduled.calculate_next_run()
96
110
  if scheduled.next_run:
97
111
  log.debug(f"Completed scheduled task '{scheduled.name}', next run at {scheduled.next_run.strftime('%Y-%m-%d %H:%M:%S')}")
@@ -109,12 +123,12 @@ class CronScheduler:
109
123
  log.info("Starting cron scheduler...")
110
124
 
111
125
  while self.running:
112
- now = datetime.now()
126
+ now = self._now()
113
127
  tasks_to_run = []
114
128
 
115
129
  # Check which tasks need to run
116
130
  for scheduled in self.tasks.values():
117
- if scheduled.next_run and now >= scheduled.next_run and not scheduled.running:
131
+ if scheduled.is_due(now):
118
132
  scheduled.running = True
119
133
  tasks_to_run.append(scheduled)
120
134
 
@@ -0,0 +1,127 @@
1
+ [project]
2
+ name = "future-framework"
3
+ version = "3.1.1"
4
+ description = "ASGI-based framework for web APIs"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ readme = "README.md"
8
+ requires-python = ">=3.12,<4.0"
9
+ dependencies = [
10
+ "uvicorn>=0.51.0,<0.52.0",
11
+ "httpx>=0.28.1,<0.29.0",
12
+ "rich>=15.0.0,<16.0.0",
13
+ "elasticsearch>=8.15.0,<9.0.0",
14
+ "strawberry-graphql>=0.323.0,<0.324.0",
15
+ "python-dotenv>=1.2.2,<2.0.0",
16
+ "sqlalchemy[asyncio]>=2.0.51,<3.0.0",
17
+ "inflection>=0.5.1,<0.6.0",
18
+ "pymongo>=4.17.0,<5.0.0",
19
+ "aiosqlite>=0.21.0,<0.22.0",
20
+ "aiomysql>=0.2.0,<0.3.0",
21
+ "asyncpg>=0.30.0,<0.31.0",
22
+ "asynch>=0.2.5,<0.3.0",
23
+ "clickhouse-driver>=0.2.11,<0.3.0",
24
+ "redis>=8.0.0,<9.0.0",
25
+ "faker>=40.0.0,<41.0.0",
26
+ ]
27
+
28
+ [[project.authors]]
29
+ name = "nicolaipre"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/nicolaipre/future-framework"
33
+ Repository = "https://github.com/nicolaipre/future-framework"
34
+ Documentation = "https://nicolaipre.github.io/future-framework/"
35
+ "Bug Tracker" = "https://github.com/nicolaipre/future-framework/issues"
36
+
37
+ [project.scripts]
38
+ future = "future.cli:main"
39
+
40
+ [dependency-groups]
41
+ dev = [
42
+ "pytest>=9.1.0,<10.0.0",
43
+ "pytest-asyncio>=1.4.0,<2.0.0",
44
+ "coverage>=7.15.0,<8.0.0",
45
+ "ruff>=0.15.0,<0.16.0",
46
+ "mypy>=2.3.0,<3.0.0",
47
+ "types-psutil>=7.2.0,<8.0.0",
48
+ "types-requests>=2.32.0,<3.0.0",
49
+ "twine>=6.2.0,<7.0.0",
50
+ ]
51
+ docs = ["mkdocs>=1.6.1,<2.0.0"]
52
+
53
+ [tool.pytest.ini_options]
54
+ asyncio_mode = "auto"
55
+ asyncio_default_fixture_loop_scope = "function"
56
+
57
+ [tool.ruff]
58
+ line-length = 256
59
+ extend-exclude = [
60
+ ".venv",
61
+ "venv",
62
+ "build",
63
+ "dist",
64
+ ]
65
+
66
+ [tool.ruff.lint]
67
+ select = [
68
+ "E",
69
+ "F",
70
+ "W",
71
+ "B",
72
+ ]
73
+ ignore = [
74
+ "E501",
75
+ "E261",
76
+ "E266",
77
+ "E302",
78
+ "E303",
79
+ "E402",
80
+ "E731",
81
+ "W291",
82
+ "W293",
83
+ "B008",
84
+ "B007",
85
+ "B009",
86
+ "B904",
87
+ "F541",
88
+ ]
89
+
90
+ [tool.ruff.lint.per-file-ignores]
91
+ "**/__init__.py" = ["F401"]
92
+ "tests/**" = [
93
+ "B011",
94
+ "F401",
95
+ ]
96
+
97
+ [tool.ruff.format]
98
+ quote-style = "double"
99
+ indent-style = "space"
100
+ line-ending = "auto"
101
+ skip-magic-trailing-comma = true
102
+ docstring-code-format = false
103
+
104
+ [tool.uv.build-backend]
105
+ module-name = "future"
106
+ module-root = ""
107
+
108
+ [tool.mypy]
109
+ python_version = "3.12"
110
+ warn_return_any = false
111
+ warn_unused_configs = true
112
+ disallow_untyped_defs = false
113
+ ignore_missing_imports = true
114
+ check_untyped_defs = false
115
+ pretty = true
116
+ disable_error_code = [
117
+ "misc",
118
+ "var-annotated",
119
+ "assignment",
120
+ "arg-type",
121
+ "import-untyped",
122
+ ]
123
+ exclude = ['(?x)(build/|dist/|\.venv/)']
124
+
125
+ [build-system]
126
+ requires = ["uv_build>=0.12.18,<0.13"]
127
+ build-backend = "uv_build"
@@ -1,55 +1,54 @@
1
- [tool.poetry]
1
+ [project]
2
2
  name = "future-framework"
3
- version = "3.0.2"
3
+ version = "3.1.1"
4
4
  description = "ASGI-based framework for web APIs"
5
- authors = ["nicolaipre"]
6
- license = "LICENSE"
5
+ authors = [{ name = "nicolaipre" }]
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
7
8
  readme = "README.md"
8
- homepage = "https://github.com/nicolaipre/future-framework"
9
- repository = "https://github.com/nicolaipre/future-framework"
10
- documentation = "https://nicolaipre.github.io/future-framework/"
11
- packages = [{ include = "future" }]
9
+ requires-python = ">=3.12,<4.0"
10
+ dependencies = [
11
+ "uvicorn>=0.51.0,<0.52.0",
12
+ "httpx>=0.28.1,<0.29.0",
13
+ "rich>=15.0.0,<16.0.0",
14
+ "elasticsearch>=8.15.0,<9.0.0",
15
+ "strawberry-graphql>=0.323.0,<0.324.0",
16
+ "python-dotenv>=1.2.2,<2.0.0",
17
+ "sqlalchemy[asyncio]>=2.0.51,<3.0.0",
18
+ "inflection>=0.5.1,<0.6.0",
19
+ "pymongo>=4.17.0,<5.0.0",
20
+ "aiosqlite>=0.21.0,<0.22.0",
21
+ "aiomysql>=0.2.0,<0.3.0",
22
+ "asyncpg>=0.30.0,<0.31.0",
23
+ "asynch>=0.2.5,<0.3.0",
24
+ "clickhouse-driver>=0.2.11,<0.3.0",
25
+ "redis>=8.0.0,<9.0.0",
26
+ "faker>=40.0.0,<41.0.0",
27
+ ]
12
28
 
13
- [tool.poetry.urls]
29
+ [project.urls]
30
+ Homepage = "https://github.com/nicolaipre/future-framework"
31
+ Repository = "https://github.com/nicolaipre/future-framework"
32
+ Documentation = "https://nicolaipre.github.io/future-framework/"
14
33
  "Bug Tracker" = "https://github.com/nicolaipre/future-framework/issues"
15
34
 
16
- [tool.poetry.dependencies]
17
- python = "^3.12"
18
- uvicorn = "^0.51.0"
19
- httpx = "^0.28.1"
20
- rich = "^15.0.0"
21
- elasticsearch = ">=8.15.0,<9"
22
- strawberry-graphql = "^0.323.0"
23
- python-dotenv = "^1.2.2"
24
- sqlalchemy = "^2.0.51"
25
- inflection = "^0.5.1"
26
- pymongo = "^4.17.0"
27
- aiosqlite = "^0.21.0"
28
- aiomysql = "^0.2.0"
29
- asyncpg = "^0.30.0"
30
- asynch = "^0.2.5"
31
- clickhouse-driver = "^0.2.11"
32
- redis = "^8.0.0"
33
- faker = "^40.0.0"
34
-
35
- [tool.poetry.group.dev.dependencies]
36
- pytest = "^9.1.0"
37
- pytest-asyncio = "^1.4.0"
38
- coverage = "^7.15.0"
39
- ruff = "^0.15.0"
40
- mypy = "^2.3.0"
41
- types-psutil = "^7.2.0"
42
- types-requests = "^2.32.0"
43
- build = "^1.5.0"
44
- twine = "^6.2.0"
45
-
46
- [tool.poetry.group.docs.dependencies]
47
- mkdocs = "^1.6.1"
35
+ [project.scripts]
36
+ future = "future.cli:main"
48
37
 
49
- [tool.poetry-dynamic-versioning]
50
- enable = true
51
- vcs = "git"
52
- style = "semver"
38
+ [dependency-groups]
39
+ dev = [
40
+ "pytest>=9.1.0,<10.0.0",
41
+ "pytest-asyncio>=1.4.0,<2.0.0",
42
+ "coverage>=7.15.0,<8.0.0",
43
+ "ruff>=0.15.0,<0.16.0",
44
+ "mypy>=2.3.0,<3.0.0",
45
+ "types-psutil>=7.2.0,<8.0.0",
46
+ "types-requests>=2.32.0,<3.0.0",
47
+ "twine>=6.2.0,<7.0.0",
48
+ ]
49
+ docs = [
50
+ "mkdocs>=1.6.1,<2.0.0",
51
+ ]
53
52
 
54
53
  [tool.pytest.ini_options]
55
54
  asyncio_mode = "auto"
@@ -98,8 +97,12 @@ skip-magic-trailing-comma = true
98
97
  docstring-code-format = false
99
98
 
100
99
  [build-system]
101
- requires = ["poetry-core"]
102
- build-backend = "poetry.core.masonry.api"
100
+ requires = ["uv_build>=0.12.18,<0.13"]
101
+ build-backend = "uv_build"
102
+
103
+ [tool.uv.build-backend]
104
+ module-name = "future"
105
+ module-root = ""
103
106
 
104
107
  [tool.mypy]
105
108
  python_version = "3.12"
@@ -112,6 +115,3 @@ pretty = true
112
115
  # Gradual typing; Model.__table__ overrides and similar are normal in this codebase.
113
116
  disable_error_code = ["misc", "var-annotated", "assignment", "arg-type", "import-untyped"]
114
117
  exclude = ["(?x)(build/|dist/|\\.venv/)"]
115
-
116
- [tool.poetry.scripts]
117
- future = "future.cli:main"
@@ -1,53 +0,0 @@
1
- import atexit
2
- import logging
3
- import logging.config
4
- import logging.handlers
5
-
6
- from future.settings import LOGGING_CONFIG
7
-
8
-
9
- # ANSI color codes
10
- class ColoredFormatter(logging.Formatter):
11
- """Custom formatter with colors like uvicorn"""
12
-
13
- COLORS = {
14
- "DEBUG": "\033[36m", # Cyan
15
- "INFO": "\033[32m", # Green
16
- "WARNING": "\033[33m", # Yellow
17
- "ERROR": "\033[31m", # Red
18
- "CRITICAL": "\033[35m", # Magenta
19
- }
20
- RESET = "\033[0m"
21
-
22
- def format(self, record: logging.LogRecord) -> str:
23
- # Add color to levelname
24
- levelname = record.levelname
25
- if levelname in self.COLORS:
26
- # Calculate padding based on original levelname length BEFORE adding colors
27
- padding = " " * (9 - len(levelname)) # 9 is max level length (CRITICAL) + 1
28
- colored_levelname = f"{self.COLORS[levelname]}{levelname}{self.RESET}"
29
- return f"{colored_levelname}:{padding}{record.getMessage()}"
30
- return super().format(record)
31
-
32
-
33
- # Configure logging using settings
34
- logging.config.dictConfig(LOGGING_CONFIG)
35
-
36
- log = logging.getLogger("future")
37
- formatter = ColoredFormatter("%(message)s")
38
-
39
- # QueueHandler itself does not format; the listener's stdout handler does.
40
- # Handler level NOTSET so Future's log.setLevel(APP_DEBUG) alone controls filtering.
41
- queue_handler = logging.getHandlerByName("queue_handler")
42
- if queue_handler is not None and isinstance(queue_handler, logging.handlers.QueueHandler):
43
- listener = getattr(queue_handler, "listener", None)
44
- if listener is not None:
45
- for handler in listener.handlers:
46
- handler.setFormatter(formatter)
47
- handler.setLevel(logging.NOTSET)
48
- listener.start()
49
- atexit.register(listener.stop)
50
- else:
51
- for handler in log.handlers:
52
- handler.setFormatter(formatter)
53
- handler.setLevel(logging.NOTSET)