asyncz 0.13.0__tar.gz → 0.13.2__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 (59) hide show
  1. {asyncz-0.13.0 → asyncz-0.13.2}/PKG-INFO +5 -4
  2. {asyncz-0.13.0 → asyncz-0.13.2}/README.md +2 -2
  3. asyncz-0.13.2/asyncz/__init__.py +1 -0
  4. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/file_locking.py +11 -0
  5. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/schedulers/base.py +1 -1
  6. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/schedulers/defaults.py +1 -0
  7. asyncz-0.13.2/asyncz/stores/file.py +178 -0
  8. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/stores/sqlalchemy.py +7 -9
  9. {asyncz-0.13.0 → asyncz-0.13.2}/pyproject.toml +2 -1
  10. asyncz-0.13.0/asyncz/__init__.py +0 -1
  11. {asyncz-0.13.0 → asyncz-0.13.2}/.gitignore +0 -0
  12. {asyncz-0.13.0 → asyncz-0.13.2}/LICENSE +0 -0
  13. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/datastructures.py +0 -0
  14. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/enums.py +0 -0
  15. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/events/__init__.py +0 -0
  16. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/events/base.py +0 -0
  17. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/events/constants.py +0 -0
  18. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/exceptions.py +0 -0
  19. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/executors/__init__.py +0 -0
  20. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/executors/asyncio.py +0 -0
  21. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/executors/base.py +0 -0
  22. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/executors/debug.py +0 -0
  23. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/executors/pool.py +0 -0
  24. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/executors/process_pool.py +0 -0
  25. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/executors/types.py +0 -0
  26. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/locks.py +0 -0
  27. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/protocols.py +0 -0
  28. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/py.typed +0 -0
  29. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/schedulers/__init__.py +0 -0
  30. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/schedulers/asgi.py +0 -0
  31. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/schedulers/asyncio.py +0 -0
  32. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/schedulers/datastructures.py +0 -0
  33. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/schedulers/types.py +0 -0
  34. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/schedulers/utils.py +0 -0
  35. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/state.py +0 -0
  36. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/stores/__init__.py +0 -0
  37. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/stores/base.py +0 -0
  38. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/stores/memory.py +0 -0
  39. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/stores/mongo.py +0 -0
  40. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/stores/redis.py +0 -0
  41. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/stores/types.py +0 -0
  42. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/tasks/__init__.py +0 -0
  43. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/tasks/base.py +0 -0
  44. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/tasks/types.py +0 -0
  45. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/__init__.py +0 -0
  46. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/base.py +0 -0
  47. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/combination.py +0 -0
  48. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/cron/__init__.py +0 -0
  49. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/cron/constants.py +0 -0
  50. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/cron/expressions.py +0 -0
  51. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/cron/fields.py +0 -0
  52. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/cron/trigger.py +0 -0
  53. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/cron/types.py +0 -0
  54. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/date.py +0 -0
  55. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/interval.py +0 -0
  56. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/shutdown.py +0 -0
  57. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/triggers/types.py +0 -0
  58. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/typing.py +0 -0
  59. {asyncz-0.13.0 → asyncz-0.13.2}/asyncz/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asyncz
3
- Version: 0.13.0
3
+ Version: 0.13.2
4
4
  Summary: The scheduler that nobody wants but every application needs.
5
5
  Project-URL: Homepage, https://github.com/dymmond/asyncz
6
6
  Project-URL: Documentation, https://asyncz.dymmond.com/
@@ -27,6 +27,7 @@ Classifier: Programming Language :: Python :: 3.9
27
27
  Classifier: Programming Language :: Python :: 3.10
28
28
  Classifier: Programming Language :: Python :: 3.11
29
29
  Classifier: Programming Language :: Python :: 3.12
30
+ Classifier: Programming Language :: Python :: 3.13
30
31
  Classifier: Topic :: Internet
31
32
  Classifier: Topic :: Internet :: WWW/HTTP
32
33
  Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
@@ -43,7 +44,7 @@ Requires-Dist: tzlocal; extra == 'localtime'
43
44
  Provides-Extra: loguru
44
45
  Requires-Dist: loguru<0.8.0,>=0.7.0; extra == 'loguru'
45
46
  Provides-Extra: testing
46
- Requires-Dist: esmerald; extra == 'testing'
47
+ Requires-Dist: esmerald>=3.7.5; extra == 'testing'
47
48
  Requires-Dist: httpx; extra == 'testing'
48
49
  Requires-Dist: lilya; extra == 'testing'
49
50
  Requires-Dist: loguru<0.8.0,>=0.7.0; extra == 'testing'
@@ -71,8 +72,8 @@ Description-Content-Type: text/markdown
71
72
  </p>
72
73
 
73
74
  <p align="center">
74
- <a href="https://github.com/dymmond/asyncz/workflows/Test%20Suite/badge.svg?event=push&branch=main" target="_blank">
75
- <img src="https://github.com/dymmond/asyncz/workflows/Test%20Suite/badge.svg?event=push&branch=main" alt="Test Suite">
75
+ <a href="https://github.com/dymmond/asyncz/actions/workflows/test-suite.yml/badge.svg?event=push&branch=main" target="_blank">
76
+ <img src="https://github.com/dymmond/asyncz/actions/workflows/test-suite.yml/badge.svg?event=push&branch=main" alt="Test Suite">
76
77
  </a>
77
78
 
78
79
  <a href="https://pypi.org/project/asyncz" target="_blank">
@@ -9,8 +9,8 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <a href="https://github.com/dymmond/asyncz/workflows/Test%20Suite/badge.svg?event=push&branch=main" target="_blank">
13
- <img src="https://github.com/dymmond/asyncz/workflows/Test%20Suite/badge.svg?event=push&branch=main" alt="Test Suite">
12
+ <a href="https://github.com/dymmond/asyncz/actions/workflows/test-suite.yml/badge.svg?event=push&branch=main" target="_blank">
13
+ <img src="https://github.com/dymmond/asyncz/actions/workflows/test-suite.yml/badge.svg?event=push&branch=main" alt="Test Suite">
14
14
  </a>
15
15
 
16
16
  <a href="https://pypi.org/project/asyncz" target="_blank">
@@ -0,0 +1 @@
1
+ __version__ = "0.13.2"
@@ -17,7 +17,11 @@ Example Usage::
17
17
  ... f.write('Edgy')
18
18
  """
19
19
 
20
+ from __future__ import annotations
21
+
20
22
  import os
23
+ from collections.abc import Generator
24
+ from contextlib import contextmanager
21
25
  from typing import IO, Any
22
26
 
23
27
  __all__ = ("LOCK_EX", "LOCK_SH", "LOCK_NB", "lock", "unlock")
@@ -114,3 +118,10 @@ else:
114
118
  def unlock(f: IO) -> bool:
115
119
  fcntl.flock(_fd(f), fcntl.LOCK_UN)
116
120
  return True
121
+
122
+
123
+ @contextmanager
124
+ def with_lock(f: IO, flags: int) -> Generator[IO, None, None]:
125
+ lock(f, flags)
126
+ yield f
127
+ unlock(f)
@@ -1138,7 +1138,7 @@ class BaseScheduler(SchedulerType):
1138
1138
  due_tasks: list[TaskType] = store.get_due_tasks(now)
1139
1139
  except Exception as e:
1140
1140
  self.loggers[self.logger_name].warning(
1141
- f"Error getting due tasks from the store {store_alias}: {e}."
1141
+ f'Error getting due tasks from the store "{store_alias}": {e}.'
1142
1142
  )
1143
1143
  retry_wakeup_time = now + timedelta(seconds=self.store_retry_interval)
1144
1144
  if not next_wakeup_time or next_wakeup_time > retry_wakeup_time:
@@ -19,6 +19,7 @@ executors: dict[str, str] = {
19
19
 
20
20
  stores: dict[str, str] = {
21
21
  "memory": "asyncz.stores.memory:MemoryStore",
22
+ "file": "asyncz.stores.file:FileStore",
22
23
  "mongodb": "asyncz.stores.mongo:MongoDBStore",
23
24
  "redis": "asyncz.stores.redis:RedisStore",
24
25
  "sqlalchemy": "asyncz.stores.sqlalchemy:SQLAlchemyStore",
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import glob
4
+ import os
5
+ import pickle
6
+ import shutil
7
+ from contextlib import suppress
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, Optional, Union, cast
11
+
12
+ from asyncz.exceptions import ConflictIdError, TaskLookupError
13
+ from asyncz.file_locking import LOCK_EX, LOCK_SH, with_lock
14
+ from asyncz.stores.base import BaseStore
15
+ from asyncz.tasks import Task
16
+ from asyncz.tasks.types import TaskType
17
+
18
+ if TYPE_CHECKING:
19
+ from asyncz.schedulers.types import SchedulerType
20
+
21
+
22
+ class FileStore(BaseStore):
23
+ """
24
+ Stores tasks via sqlalchemy in a database.
25
+
26
+ Args:
27
+ directory - The directory to store the tasks. String or Path.
28
+ suffix - The task suffix.
29
+ pickle_protocol - Pickle protocol level to use (for serialization), defaults to the
30
+ highest available.
31
+ """
32
+
33
+ forbidden_characters: set[str] = {"/", "\\", "\0", ":"}
34
+
35
+ def __init__(
36
+ self,
37
+ directory: Union[str, os.PathLike],
38
+ suffix: str = ".task",
39
+ mode: int = 0o700,
40
+ cleanup_directory: bool = False,
41
+ pickle_protocol: Optional[int] = pickle.HIGHEST_PROTOCOL,
42
+ **kwargs: Any,
43
+ ) -> None:
44
+ super().__init__(**kwargs)
45
+ self.pickle_protocol = pickle_protocol
46
+ self.directory = Path(directory)
47
+ self.mode = mode
48
+ self.cleanup_directory = cleanup_directory
49
+ self.suffix = suffix
50
+
51
+ def check_task_id(self, task_id: str | None) -> None:
52
+ if task_id is None:
53
+ raise RuntimeError("Task id is None")
54
+ if task_id.startswith("."):
55
+ raise RuntimeError(f'Invalid character in task id: "{task_id}".')
56
+ for char in task_id:
57
+ if char in self.forbidden_characters:
58
+ raise RuntimeError(f'Invalid character in task id: "{task_id}".')
59
+
60
+ def start(self, scheduler: Any, alias: str) -> None:
61
+ """
62
+ When starting omits from the index any documents that lack next_run_time field.
63
+ """
64
+ super().start(scheduler, alias)
65
+ self.directory.mkdir(self.mode, parents=True, exist_ok=True)
66
+ if not self.directory.is_dir():
67
+ raise RuntimeError("Not a directory.")
68
+
69
+ def shutdown(self) -> None:
70
+ if self.cleanup_directory:
71
+ shutil.rmtree(self.directory, ignore_errors=True)
72
+ super().shutdown()
73
+
74
+ def lookup_task(self, task_id: str) -> Optional[TaskType]:
75
+ self.check_task_id(task_id)
76
+ task_path = self.directory / f"{task_id}{self.suffix}"
77
+ try:
78
+ with open(task_path, "rb") as read_ob, with_lock(read_ob, LOCK_SH):
79
+ task = self.rebuild_task(read_ob.read())
80
+ except Exception:
81
+ task_path.unlink(missing_ok=True)
82
+ task = None
83
+ return task
84
+
85
+ def rebuild_task(self, state: Any) -> TaskType:
86
+ state = pickle.loads(self.conditional_decrypt(state))
87
+ task = Task.__new__(Task)
88
+ task.__setstate__(state)
89
+ task.scheduler = cast("SchedulerType", self.scheduler)
90
+ task.store_alias = self.alias
91
+ return task
92
+
93
+ def get_due_tasks(self, now: datetime) -> list[TaskType]:
94
+ return [
95
+ task
96
+ for task in self.get_all_tasks()
97
+ if task.next_run_time is not None and task.next_run_time <= now
98
+ ]
99
+
100
+ def get_tasks(self) -> list[TaskType]:
101
+ tasks: list[tuple[TaskType, os.stat_result]] = []
102
+ with os.scandir(self.directory) as scanner:
103
+ for entry in scanner:
104
+ if not entry.name.endswith(self.suffix) or not entry.is_file():
105
+ continue
106
+ try:
107
+ with open(entry.path, "rb") as read_ob, with_lock(read_ob, LOCK_SH):
108
+ task = self.rebuild_task(read_ob.read())
109
+ tasks.append((task, entry.stat()))
110
+ except Exception:
111
+ with suppress(FileNotFoundError):
112
+ os.unlink(entry.path)
113
+ return [
114
+ task
115
+ for task, _ in sorted(
116
+ tasks,
117
+ key=lambda task_stat: (
118
+ int(task_stat[0].next_run_time is None),
119
+ task_stat[0].next_run_time,
120
+ # sort for task creation not update
121
+ task_stat[1].st_ctime,
122
+ ),
123
+ )
124
+ ]
125
+
126
+ def get_next_run_time(self) -> Optional[datetime]:
127
+ next_run_time: datetime | None = None
128
+ for task in self.get_all_tasks():
129
+ if task.next_run_time is None:
130
+ break
131
+ if next_run_time is None or next_run_time >= task.next_run_time:
132
+ next_run_time = task.next_run_time
133
+ return next_run_time
134
+
135
+ def get_all_tasks(self) -> list[TaskType]:
136
+ return self.get_tasks()
137
+
138
+ def add_task(self, task: TaskType) -> None:
139
+ self.check_task_id(task.id)
140
+ task_path = self.directory / f"{task.id}{self.suffix}"
141
+ try:
142
+ with task_path.open("xb") as write_ob, with_lock(write_ob, LOCK_EX):
143
+ write_ob.write(
144
+ self.conditional_encrypt(
145
+ pickle.dumps(task.__getstate__(), self.pickle_protocol)
146
+ )
147
+ )
148
+ except FileExistsError:
149
+ raise ConflictIdError(task.id) from None
150
+
151
+ def update_task(self, task: TaskType) -> None:
152
+ self.check_task_id(task.id)
153
+ task_path = self.directory / f"{task.id}{self.suffix}"
154
+ try:
155
+ with task_path.open("r+b") as write_ob, with_lock(write_ob, LOCK_EX):
156
+ write_ob.truncate()
157
+ write_ob.write(
158
+ self.conditional_encrypt(
159
+ pickle.dumps(task.__getstate__(), self.pickle_protocol)
160
+ )
161
+ )
162
+ except FileNotFoundError:
163
+ raise TaskLookupError(task.id) from None
164
+
165
+ def delete_task(self, task_id: str) -> None:
166
+ self.check_task_id(task_id)
167
+ task_path = self.directory / f"{task_id}{self.suffix}"
168
+ try:
169
+ task_path.unlink(missing_ok=False)
170
+ except FileNotFoundError:
171
+ raise TaskLookupError(task_id) from None
172
+
173
+ def remove_all_tasks(self) -> None:
174
+ for task_path in self.directory.glob(f"*{glob.escape(self.suffix)}"):
175
+ task_path.unlink(missing_ok=True)
176
+
177
+ def __repr__(self) -> str:
178
+ return f"<{self.__class__.__name__} (directory={self.directory})>"
@@ -41,7 +41,7 @@ class SQLAlchemyStore(BaseStore):
41
41
  super().__init__(**kwargs)
42
42
  self.pickle_protocol = pickle_protocol
43
43
  if isinstance(database, str):
44
- database = sqlalchemy.create_engine(database)
44
+ database = sqlalchemy.create_engine(database, **kwargs)
45
45
  if not database:
46
46
  raise ValueError("database must not be empty or None")
47
47
  self.engine: sqlalchemy.Engine = database
@@ -63,6 +63,10 @@ class SQLAlchemyStore(BaseStore):
63
63
  super().start(scheduler, alias)
64
64
  self.metadata.create_all(self.engine)
65
65
 
66
+ def shutdown(self) -> None:
67
+ self.engine.dispose()
68
+ super().shutdown()
69
+
66
70
  def lookup_task(self, task_id: str) -> Optional[TaskType]:
67
71
  tasks = self.get_tasks(self.table.c.id == task_id, limit=1)
68
72
  return tasks[0] if tasks else None
@@ -82,7 +86,7 @@ class SQLAlchemyStore(BaseStore):
82
86
  def get_tasks(self, conditions: Any = None, limit: int = 0) -> list[TaskType]:
83
87
  tasks: list[TaskType] = []
84
88
  failed_task_ids = []
85
- stmt = self.table.select().order_by(self.table.c.next_run_time.asc())
89
+ stmt = self.table.select().order_by(self.table.c.next_run_time.asc().nullslast())
86
90
  if conditions is not None:
87
91
  stmt = stmt.where(conditions)
88
92
 
@@ -119,9 +123,7 @@ class SQLAlchemyStore(BaseStore):
119
123
  return utc_timestamp_to_datetime(row.next_run_time) if row else None
120
124
 
121
125
  def get_all_tasks(self) -> list[TaskType]:
122
- tasks = self.get_tasks()
123
- self.fix_paused_tasks(tasks)
124
- return tasks
126
+ return self.get_tasks()
125
127
 
126
128
  def add_task(self, task: TaskType) -> None:
127
129
  data = {
@@ -168,9 +170,5 @@ class SQLAlchemyStore(BaseStore):
168
170
  with self.engine.begin() as conn:
169
171
  conn.execute(self.table.delete())
170
172
 
171
- def shutdown(self) -> None:
172
- self.engine.dispose()
173
- super().shutdown()
174
-
175
173
  def __repr__(self) -> str:
176
174
  return f"<{self.__class__.__name__} (database={self.engine.url})>"
@@ -34,6 +34,7 @@ classifiers = [
34
34
  "Programming Language :: Python :: 3.10",
35
35
  "Programming Language :: Python :: 3.11",
36
36
  "Programming Language :: Python :: 3.12",
37
+ "Programming Language :: Python :: 3.13",
37
38
  "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
38
39
  "Topic :: Internet :: WWW/HTTP",
39
40
  ]
@@ -76,7 +77,7 @@ testing = [
76
77
  "pytest-loguru>=0.2.0,<1",
77
78
  "redis>=4.4.0,<6.0.0",
78
79
  "mypy>=0.982,<2.0.0",
79
- "esmerald",
80
+ "esmerald>=3.7.5",
80
81
  "starlette",
81
82
  "lilya",
82
83
  "sqlalchemy",
@@ -1 +0,0 @@
1
- __version__ = "0.13.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes