future-framework 3.0.1__tar.gz → 3.1.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 (85) hide show
  1. {future_framework-3.0.1 → future_framework-3.1.0}/PKG-INFO +25 -32
  2. {future_framework-3.0.1 → future_framework-3.1.0}/README.md +3 -3
  3. {future_framework-3.0.1 → future_framework-3.1.0}/future/cli.py +17 -18
  4. {future_framework-3.0.1 → future_framework-3.1.0}/future/interfaces/ITask.py +2 -1
  5. future_framework-3.1.0/future/scheduling.py +73 -0
  6. {future_framework-3.0.1 → future_framework-3.1.0}/future/taskscheduler.py +36 -17
  7. future_framework-3.1.0/pyproject.toml +127 -0
  8. future_framework-3.0.1/pyproject.toml → future_framework-3.1.0/pyproject.toml.orig +50 -50
  9. {future_framework-3.0.1 → future_framework-3.1.0}/LICENSE +0 -0
  10. {future_framework-3.0.1 → future_framework-3.1.0}/future/__init__.py +0 -0
  11. {future_framework-3.0.1 → future_framework-3.1.0}/future/application.py +0 -0
  12. {future_framework-3.0.1 → future_framework-3.1.0}/future/authentication/Auth0Authentication.py +0 -0
  13. {future_framework-3.0.1 → future_framework-3.1.0}/future/authentication/AzureADAuthentication.py +0 -0
  14. {future_framework-3.0.1 → future_framework-3.1.0}/future/authentication/BasicAuthentication.py +0 -0
  15. {future_framework-3.0.1 → future_framework-3.1.0}/future/authentication/KerberosAuthentication.py +0 -0
  16. {future_framework-3.0.1 → future_framework-3.1.0}/future/authentication/KeycloakAuthentication.py +0 -0
  17. {future_framework-3.0.1 → future_framework-3.1.0}/future/authentication/OAuth2Authentication.py +0 -0
  18. {future_framework-3.0.1 → future_framework-3.1.0}/future/authentication/OpenIdConnectAuthentication.py +0 -0
  19. {future_framework-3.0.1 → future_framework-3.1.0}/future/authentication/SAMLAuthentication.py +0 -0
  20. {future_framework-3.0.1 → future_framework-3.1.0}/future/authentication/__init__.py +0 -0
  21. {future_framework-3.0.1 → future_framework-3.1.0}/future/controllers/DebugController.py +0 -0
  22. {future_framework-3.0.1 → future_framework-3.1.0}/future/controllers/GraphQLController.py +0 -0
  23. {future_framework-3.0.1 → future_framework-3.1.0}/future/controllers/OpenAPIController.py +0 -0
  24. {future_framework-3.0.1 → future_framework-3.1.0}/future/controllers/WebSocketController.py +0 -0
  25. {future_framework-3.0.1 → future_framework-3.1.0}/future/controllers/WelcomeController.py +0 -0
  26. {future_framework-3.0.1 → future_framework-3.1.0}/future/controllers/__init__.py +0 -0
  27. {future_framework-3.0.1 → future_framework-3.1.0}/future/database.py +0 -0
  28. {future_framework-3.0.1 → future_framework-3.1.0}/future/databases/ClickhouseDatabase.py +0 -0
  29. {future_framework-3.0.1 → future_framework-3.1.0}/future/databases/ElasticsearchDatabase.py +0 -0
  30. {future_framework-3.0.1 → future_framework-3.1.0}/future/databases/MongoDBDatabase.py +0 -0
  31. {future_framework-3.0.1 → future_framework-3.1.0}/future/databases/MySQLDatabase.py +0 -0
  32. {future_framework-3.0.1 → future_framework-3.1.0}/future/databases/PostgresDatabase.py +0 -0
  33. {future_framework-3.0.1 → future_framework-3.1.0}/future/databases/RedisDatabase.py +0 -0
  34. {future_framework-3.0.1 → future_framework-3.1.0}/future/databases/SQLiteDatabase.py +0 -0
  35. {future_framework-3.0.1 → future_framework-3.1.0}/future/databases/__init__.py +0 -0
  36. {future_framework-3.0.1 → future_framework-3.1.0}/future/exceptions.py +0 -0
  37. {future_framework-3.0.1 → future_framework-3.1.0}/future/interfaces/IAuthentication.py +0 -0
  38. {future_framework-3.0.1 → future_framework-3.1.0}/future/interfaces/IController.py +0 -0
  39. {future_framework-3.0.1 → future_framework-3.1.0}/future/interfaces/IDatabase.py +0 -0
  40. {future_framework-3.0.1 → future_framework-3.1.0}/future/interfaces/IMiddleware.py +0 -0
  41. {future_framework-3.0.1 → future_framework-3.1.0}/future/interfaces/IModel.py +0 -0
  42. {future_framework-3.0.1 → future_framework-3.1.0}/future/interfaces/IPlugin.py +0 -0
  43. {future_framework-3.0.1 → future_framework-3.1.0}/future/interfaces/__init__.py +0 -0
  44. {future_framework-3.0.1 → future_framework-3.1.0}/future/interfacing.py +0 -0
  45. {future_framework-3.0.1 → future_framework-3.1.0}/future/lifespan.py +0 -0
  46. {future_framework-3.0.1 → future_framework-3.1.0}/future/logger.py +0 -0
  47. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/BruteforcePreventionMiddleware.py +0 -0
  48. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/CORSMiddleware.py +0 -0
  49. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/CSRFMiddleware.py +0 -0
  50. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/GZipMiddleware.py +0 -0
  51. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/HTAccessConfuserMiddleware.py +0 -0
  52. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/HeaderConfuserMiddleware.py +0 -0
  53. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/RateLimitMiddleware.py +0 -0
  54. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/ResponseCodeConfuserMiddleware.py +0 -0
  55. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/SQLiConfuserMiddleware.py +0 -0
  56. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/ScopeValidationMiddleware.py +0 -0
  57. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/SessionMiddleware.py +0 -0
  58. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/TestMiddlewareRequest.py +0 -0
  59. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/TestMiddlewareResponse.py +0 -0
  60. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/WebServerConfuserMiddleware.py +0 -0
  61. {future_framework-3.0.1 → future_framework-3.1.0}/future/middleware/__init__.py +0 -0
  62. {future_framework-3.0.1 → future_framework-3.1.0}/future/migrations.py +0 -0
  63. {future_framework-3.0.1 → future_framework-3.1.0}/future/models/PostModel.py +0 -0
  64. {future_framework-3.0.1 → future_framework-3.1.0}/future/models/Query.py +0 -0
  65. {future_framework-3.0.1 → future_framework-3.1.0}/future/models/UserModel.py +0 -0
  66. {future_framework-3.0.1 → future_framework-3.1.0}/future/models/__init__.py +0 -0
  67. {future_framework-3.0.1 → future_framework-3.1.0}/future/openapi.py +0 -0
  68. {future_framework-3.0.1 → future_framework-3.1.0}/future/plugins/ElasticsearchPlugin.py +0 -0
  69. {future_framework-3.0.1 → future_framework-3.1.0}/future/plugins/__init__.py +0 -0
  70. {future_framework-3.0.1 → future_framework-3.1.0}/future/request.py +0 -0
  71. {future_framework-3.0.1 → future_framework-3.1.0}/future/response.py +0 -0
  72. {future_framework-3.0.1 → future_framework-3.1.0}/future/routing.py +0 -0
  73. {future_framework-3.0.1 → future_framework-3.1.0}/future/seeder.py +0 -0
  74. {future_framework-3.0.1 → future_framework-3.1.0}/future/settings.py +0 -0
  75. {future_framework-3.0.1 → future_framework-3.1.0}/future/tasks/CheckDNSTask.py +0 -0
  76. {future_framework-3.0.1 → future_framework-3.1.0}/future/tasks/CheckDiskUsageTask.py +0 -0
  77. {future_framework-3.0.1 → future_framework-3.1.0}/future/tasks/CheckHttpStatusTask.py +0 -0
  78. {future_framework-3.0.1 → future_framework-3.1.0}/future/tasks/CheckMemoryUsageTask.py +0 -0
  79. {future_framework-3.0.1 → future_framework-3.1.0}/future/tasks/CheckSSHBannerTask.py +0 -0
  80. {future_framework-3.0.1 → future_framework-3.1.0}/future/tasks/CheckSystemUptimeTask.py +0 -0
  81. {future_framework-3.0.1 → future_framework-3.1.0}/future/tasks/DailyBackupTask.py +0 -0
  82. {future_framework-3.0.1 → future_framework-3.1.0}/future/tasks/SyncTaskExample.py +0 -0
  83. {future_framework-3.0.1 → future_framework-3.1.0}/future/tasks/__init__.py +0 -0
  84. {future_framework-3.0.1 → future_framework-3.1.0}/future/testclient.py +0 -0
  85. {future_framework-3.0.1 → future_framework-3.1.0}/future/types.py +0 -0
@@ -1,37 +1,31 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: future-framework
3
- Version: 3.0.1
3
+ Version: 3.1.0
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`).
@@ -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,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,35 +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
35
+ self.running = False
30
36
 
31
- if self.start_time is None:
32
- self.start_time = datetime.now()
33
37
  self.calculate_next_run()
34
38
 
35
39
  def calculate_next_run(self) -> None:
@@ -48,21 +52,32 @@ class ScheduledTask:
48
52
  if self.next_run is not None and self.jitter and self.jitter > 0:
49
53
  self.next_run = self.next_run + timedelta(seconds=random.uniform(0, self.jitter))
50
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
+
51
62
 
52
63
  class CronScheduler:
53
64
  """A cron-like scheduler for running background tasks."""
54
65
 
55
- def __init__(self) -> None:
66
+ def __init__(self, clock: Optional[Callable[[], datetime]] = None) -> None:
56
67
  self.tasks: dict[str, ScheduledTask] = {}
57
68
  self.running = False
58
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())
59
74
 
60
75
  def add_task(self, task) -> None:
61
76
  if task.interval is None or task.unit is None or not task.name:
62
77
  log.warning(f"Skipping task '{task.name or '?'}' - missing required parameters")
63
78
  return
64
79
 
65
- scheduled = ScheduledTask(task)
80
+ scheduled = ScheduledTask(task, self._now())
66
81
  self.tasks[task.name] = scheduled
67
82
  jitter_note = f" (jitter 0–{task.jitter}s)" if task.jitter else ""
68
83
  log.info(f"Added scheduled task '{task.name}' to run every {task.interval} {task.unit.value}{jitter_note}")
@@ -85,11 +100,12 @@ class CronScheduler:
85
100
 
86
101
  async def _run_task(self, scheduled: ScheduledTask) -> None:
87
102
  """Run a single task."""
103
+ scheduled.running = True
88
104
  try:
89
105
  log.debug(f"Running scheduled task '{scheduled.name}'")
90
106
  await scheduled.task.run()
91
107
 
92
- scheduled.last_run = datetime.now()
108
+ scheduled.last_run = self._now()
93
109
  scheduled.calculate_next_run()
94
110
  if scheduled.next_run:
95
111
  log.debug(f"Completed scheduled task '{scheduled.name}', next run at {scheduled.next_run.strftime('%Y-%m-%d %H:%M:%S')}")
@@ -99,18 +115,21 @@ class CronScheduler:
99
115
  except Exception as e:
100
116
  log.error(f"Error running scheduled task '{scheduled.name}': {e}")
101
117
  # Don't update last_run on error, so it will retry next cycle
118
+ finally:
119
+ scheduled.running = False
102
120
 
103
121
  async def _scheduler_loop(self) -> None:
104
122
  """Main scheduler loop that checks for tasks to run."""
105
123
  log.info("Starting cron scheduler...")
106
124
 
107
125
  while self.running:
108
- now = datetime.now()
126
+ now = self._now()
109
127
  tasks_to_run = []
110
128
 
111
129
  # Check which tasks need to run
112
130
  for scheduled in self.tasks.values():
113
- if scheduled.next_run and now >= scheduled.next_run:
131
+ if scheduled.is_due(now):
132
+ scheduled.running = True
114
133
  tasks_to_run.append(scheduled)
115
134
 
116
135
  # Run tasks that are due
@@ -0,0 +1,127 @@
1
+ [project]
2
+ name = "future-framework"
3
+ version = "3.1.0"
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.1"
3
+ version = "3.1.0"
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"