eventsourcing 9.3.5__tar.gz → 9.4.0a2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (27) hide show
  1. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/LICENSE +1 -1
  2. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/PKG-INFO +9 -7
  3. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/README.md +1 -1
  4. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/application.py +26 -10
  5. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/cipher.py +4 -2
  6. eventsourcing-9.4.0a2/eventsourcing/cryptography.py +96 -0
  7. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/domain.py +29 -9
  8. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/interface.py +23 -5
  9. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/persistence.py +292 -71
  10. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/popo.py +113 -32
  11. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/postgres.py +265 -103
  12. eventsourcing-9.4.0a2/eventsourcing/projection.py +200 -0
  13. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/sqlite.py +143 -36
  14. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/system.py +64 -42
  15. eventsourcing-9.4.0a2/eventsourcing/tests/__init__.py +0 -0
  16. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/tests/application.py +7 -12
  17. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/tests/persistence.py +304 -75
  18. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/utils.py +1 -1
  19. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/pyproject.toml +32 -9
  20. eventsourcing-9.3.5/eventsourcing/__init__.py +0 -1
  21. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/AUTHORS +0 -0
  22. {eventsourcing-9.3.5/eventsourcing/tests → eventsourcing-9.4.0a2/eventsourcing}/__init__.py +0 -0
  23. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/compressor.py +0 -0
  24. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/dispatch.py +0 -0
  25. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/py.typed +0 -0
  26. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/tests/domain.py +0 -0
  27. {eventsourcing-9.3.5 → eventsourcing-9.4.0a2}/eventsourcing/tests/postgres_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  BSD 3-Clause License
2
2
 
3
- Copyright (c) 2023, John Bywater
3
+ Copyright (c) 2025, John Bywater
4
4
  All rights reserved.
5
5
 
6
6
  Redistribution and use in source and binary forms, with or without
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: eventsourcing
3
- Version: 9.3.5
3
+ Version: 9.4.0a2
4
4
  Summary: Event sourcing in Python
5
5
  Home-page: https://github.com/pyeventsourcing/eventsourcing
6
6
  License: BSD 3-Clause
7
7
  Keywords: event sourcing,event store,domain driven design,domain-driven design,ddd,cqrs,cqs
8
8
  Author: John Bywater
9
9
  Author-email: john.bywater@appropriatesoftware.net
10
- Requires-Python: >=3.8,<4.0
11
- Classifier: Development Status :: 5 - Production/Stable
10
+ Requires-Python: >=3.8, !=2.7.*, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*, !=3.7.*
11
+ Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Intended Audience :: Education
14
14
  Classifier: Intended Audience :: Science/Research
@@ -18,22 +18,24 @@ Classifier: Operating System :: OS Independent
18
18
  Classifier: Programming Language :: Python
19
19
  Classifier: Programming Language :: Python :: 3
20
20
  Classifier: Programming Language :: Python :: 3.8
21
- Classifier: Programming Language :: Python :: 3.9
22
21
  Classifier: Programming Language :: Python :: 3.10
23
22
  Classifier: Programming Language :: Python :: 3.11
24
23
  Classifier: Programming Language :: Python :: 3.12
25
24
  Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.9
26
26
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
27
  Provides-Extra: crypto
28
+ Provides-Extra: cryptography
28
29
  Provides-Extra: postgres
29
30
  Requires-Dist: backports.zoneinfo ; python_version < "3.9"
30
- Requires-Dist: psycopg[c,pool] (<=3.2.99999) ; extra == "postgres"
31
- Requires-Dist: pycryptodome (>=3.21,<3.22) ; extra == "crypto"
31
+ Requires-Dist: cryptography (>=44.0,<44.1) ; extra == "cryptography"
32
+ Requires-Dist: psycopg[pool] (<=3.2.99999) ; extra == "postgres"
33
+ Requires-Dist: pycryptodome (>=3.22,<3.23) ; extra == "crypto"
32
34
  Requires-Dist: typing_extensions
33
35
  Project-URL: Repository, https://github.com/pyeventsourcing/eventsourcing
34
36
  Description-Content-Type: text/markdown
35
37
 
36
- [![Build Status](https://github.com/pyeventsourcing/eventsourcing/actions/workflows/runtests.yaml/badge.svg?branch=main)](https://github.com/pyeventsourcing/eventsourcing/tree/main)
38
+ [![Build Status](https://github.com/pyeventsourcing/eventsourcing/actions/workflows/runtests.yaml/badge.svg)](https://github.com/pyeventsourcing/eventsourcing)
37
39
  [![Coverage Status](https://coveralls.io/repos/github/pyeventsourcing/eventsourcing/badge.svg?branch=main)](https://coveralls.io/github/pyeventsourcing/eventsourcing?branch=main)
38
40
  [![Documentation Status](https://readthedocs.org/projects/eventsourcing/badge/?version=stable)](https://eventsourcing.readthedocs.io/en/stable/)
39
41
  [![Latest Release](https://badge.fury.io/py/eventsourcing.svg)](https://pypi.org/project/eventsourcing/)
@@ -1,4 +1,4 @@
1
- [![Build Status](https://github.com/pyeventsourcing/eventsourcing/actions/workflows/runtests.yaml/badge.svg?branch=main)](https://github.com/pyeventsourcing/eventsourcing/tree/main)
1
+ [![Build Status](https://github.com/pyeventsourcing/eventsourcing/actions/workflows/runtests.yaml/badge.svg)](https://github.com/pyeventsourcing/eventsourcing)
2
2
  [![Coverage Status](https://coveralls.io/repos/github/pyeventsourcing/eventsourcing/badge.svg?branch=main)](https://coveralls.io/github/pyeventsourcing/eventsourcing?branch=main)
3
3
  [![Documentation Status](https://readthedocs.org/projects/eventsourcing/badge/?version=stable)](https://eventsourcing.readthedocs.io/en/stable/)
4
4
  [![Latest Release](https://badge.fury.io/py/eventsourcing.svg)](https://pypi.org/project/eventsourcing/)
@@ -34,12 +34,11 @@ from eventsourcing.domain import (
34
34
  DomainEventProtocol,
35
35
  EventSourcingError,
36
36
  MutableOrImmutableAggregate,
37
- ProgrammingError,
38
37
  Snapshot,
39
38
  SnapshotProtocol,
40
39
  TDomainEvent,
41
40
  TMutableOrImmutableAggregate,
42
- create_utc_datetime_now,
41
+ datetime_now_with_tzinfo,
43
42
  )
44
43
  from eventsourcing.persistence import (
45
44
  ApplicationRecorder,
@@ -47,16 +46,18 @@ from eventsourcing.persistence import (
47
46
  DecimalAsStr,
48
47
  EventStore,
49
48
  InfrastructureFactory,
49
+ JSONTranscoder,
50
50
  Mapper,
51
51
  Notification,
52
52
  Recording,
53
53
  Tracking,
54
+ TrackingRecorder,
54
55
  Transcoder,
55
56
  UUIDAsHex,
56
57
  )
57
58
  from eventsourcing.utils import Environment, EnvType, strtobool
58
59
 
59
- if TYPE_CHECKING: # pragma: nocover
60
+ if TYPE_CHECKING:
60
61
  from uuid import UUID
61
62
 
62
63
  ProjectorFunction = Callable[
@@ -70,6 +71,10 @@ MutatorFunction = Callable[
70
71
  ]
71
72
 
72
73
 
74
+ class ProgrammingError(Exception):
75
+ pass
76
+
77
+
73
78
  def project_aggregate(
74
79
  aggregate: TMutableOrImmutableAggregate | None,
75
80
  domain_events: Iterable[DomainEventProtocol],
@@ -435,10 +440,12 @@ class NotificationLog(ABC):
435
440
  @abstractmethod
436
441
  def select(
437
442
  self,
438
- start: int,
443
+ start: int | None,
439
444
  limit: int,
440
445
  stop: int | None = None,
441
446
  topics: Sequence[str] = (),
447
+ *,
448
+ inclusive_of_start: bool = True,
442
449
  ) -> List[Notification]:
443
450
  """
444
451
  Returns a selection of
@@ -523,10 +530,12 @@ class LocalNotificationLog(NotificationLog):
523
530
 
524
531
  def select(
525
532
  self,
526
- start: int,
533
+ start: int | None,
527
534
  limit: int,
528
535
  stop: int | None = None,
529
536
  topics: Sequence[str] = (),
537
+ *,
538
+ inclusive_of_start: bool = True,
530
539
  ) -> List[Notification]:
531
540
  """
532
541
  Returns a selection of
@@ -539,7 +548,11 @@ class LocalNotificationLog(NotificationLog):
539
548
  )
540
549
  raise ValueError(msg)
541
550
  return self.recorder.select_notifications(
542
- start=start, limit=limit, stop=stop, topics=topics
551
+ start=start,
552
+ limit=limit,
553
+ stop=stop,
554
+ topics=topics,
555
+ inclusive_of_start=inclusive_of_start,
543
556
  )
544
557
 
545
558
  @staticmethod
@@ -685,7 +698,9 @@ class Application:
685
698
  _env.update(env)
686
699
  return Environment(name, _env)
687
700
 
688
- def construct_factory(self, env: Environment) -> InfrastructureFactory:
701
+ def construct_factory(
702
+ self, env: Environment
703
+ ) -> InfrastructureFactory[TrackingRecorder]:
689
704
  """
690
705
  Constructs an :class:`~eventsourcing.persistence.InfrastructureFactory`
691
706
  for use by the application.
@@ -705,10 +720,11 @@ class Application:
705
720
  for use by the application.
706
721
  """
707
722
  transcoder = self.factory.transcoder()
708
- self.register_transcodings(transcoder)
723
+ if isinstance(transcoder, JSONTranscoder):
724
+ self.register_transcodings(transcoder)
709
725
  return transcoder
710
726
 
711
- def register_transcodings(self, transcoder: Transcoder) -> None:
727
+ def register_transcodings(self, transcoder: JSONTranscoder) -> None:
712
728
  """
713
729
  Registers :class:`~eventsourcing.persistence.Transcoding`
714
730
  objects on given :class:`~eventsourcing.persistence.JSONTranscoder`.
@@ -967,7 +983,7 @@ class EventSourcedLog(Generic[TDomainEvent]):
967
983
  return logged_cls( # type: ignore
968
984
  originator_id=self.originator_id,
969
985
  originator_version=next_originator_version,
970
- timestamp=create_utc_datetime_now(),
986
+ timestamp=datetime_now_with_tzinfo(),
971
987
  **kwargs,
972
988
  )
973
989
 
@@ -10,13 +10,14 @@ from Crypto.Cipher.AES import key_size
10
10
 
11
11
  from eventsourcing.persistence import Cipher
12
12
 
13
- if TYPE_CHECKING: # pragma: nocover
13
+ if TYPE_CHECKING:
14
14
  from eventsourcing.utils import Environment
15
15
 
16
16
 
17
17
  class AESCipher(Cipher):
18
18
  """
19
- Cipher strategy that uses AES cipher in GCM mode.
19
+ Cipher strategy that uses AES cipher (in GCM mode)
20
+ from the Python pycryptodome package.
20
21
  """
21
22
 
22
23
  CIPHER_KEY = "CIPHER_KEY"
@@ -71,6 +72,7 @@ class AESCipher(Cipher):
71
72
 
72
73
  # Return ciphertext.
73
74
  return nonce + tag + encrypted
75
+ # return nonce + tag + encrypted
74
76
 
75
77
  def construct_cipher(self, nonce: bytes) -> GcmMode:
76
78
  cipher = AES.new(
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from base64 import b64decode, b64encode
5
+ from typing import TYPE_CHECKING
6
+
7
+ from cryptography.exceptions import InvalidTag
8
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
9
+
10
+ from eventsourcing.persistence import Cipher
11
+
12
+ if TYPE_CHECKING:
13
+ from eventsourcing.utils import Environment
14
+
15
+
16
+ class AESCipher(Cipher):
17
+ """
18
+ Cipher strategy that uses AES cipher (in GCM mode)
19
+ from the Python cryptography package.
20
+ """
21
+
22
+ CIPHER_KEY = "CIPHER_KEY"
23
+ KEY_SIZES = (16, 24, 32)
24
+
25
+ @staticmethod
26
+ def create_key(num_bytes: int) -> str:
27
+ """
28
+ Creates AES cipher key, with length num_bytes.
29
+
30
+ :param num_bytes: An int value, either 16, 24, or 32.
31
+
32
+ """
33
+ AESCipher.check_key_size(num_bytes)
34
+ key = AESGCM.generate_key(num_bytes * 8)
35
+ return b64encode(key).decode("utf8")
36
+
37
+ @staticmethod
38
+ def check_key_size(num_bytes: int) -> None:
39
+ if num_bytes not in AESCipher.KEY_SIZES:
40
+ msg = f"Invalid key size: {num_bytes} not in {AESCipher.KEY_SIZES}"
41
+ raise ValueError(msg)
42
+
43
+ @staticmethod
44
+ def random_bytes(num_bytes: int) -> bytes:
45
+ return os.urandom(num_bytes)
46
+
47
+ def __init__(self, environment: Environment):
48
+ """
49
+ Initialises AES cipher with ``cipher_key``.
50
+
51
+ :param str cipher_key: 16, 24, or 32 bytes encoded as base64
52
+ """
53
+ cipher_key = environment.get(self.CIPHER_KEY)
54
+ if not cipher_key:
55
+ msg = f"'{self.CIPHER_KEY}' not in env"
56
+ raise OSError(msg)
57
+ key = b64decode(cipher_key.encode("utf8"))
58
+ AESCipher.check_key_size(len(key))
59
+ self.key = key
60
+
61
+ def encrypt(self, plaintext: bytes) -> bytes:
62
+ """Return ciphertext for given plaintext."""
63
+
64
+ # Construct AES-GCM cipher, with 96-bit nonce.
65
+ aesgcm = AESGCM(self.key)
66
+ nonce = AESCipher.random_bytes(12)
67
+ res = aesgcm.encrypt(nonce, plaintext, None)
68
+ # Put tag at the front for compatibility with eventsourcing.crypto.AESCipher.
69
+ tag = res[-16:]
70
+ encrypted = res[:-16]
71
+ return nonce + tag + encrypted
72
+
73
+ def decrypt(self, ciphertext: bytes) -> bytes:
74
+ """Return plaintext for given ciphertext."""
75
+
76
+ # Split out the nonce, tag, and encrypted data.
77
+ nonce = ciphertext[:12]
78
+ if len(nonce) != 12:
79
+ msg = "Damaged cipher text: invalid nonce length"
80
+ raise ValueError(msg)
81
+
82
+ # Expect tag at the front.
83
+ tag = ciphertext[12:28]
84
+ if len(tag) != 16:
85
+ msg = "Damaged cipher text: invalid tag length"
86
+ raise ValueError(msg)
87
+ encrypted = ciphertext[28:]
88
+
89
+ aesgcm = AESGCM(self.key)
90
+ try:
91
+ plaintext = aesgcm.decrypt(nonce, encrypted + tag, None)
92
+ except InvalidTag as e:
93
+ msg = "Invalid cipher tag"
94
+ raise ValueError(msg) from e
95
+ # Decrypt and verify.
96
+ return plaintext
@@ -25,10 +25,20 @@ from typing import (
25
25
  runtime_checkable,
26
26
  )
27
27
  from uuid import UUID, uuid4
28
+ from warnings import warn
28
29
 
29
30
  from eventsourcing.utils import get_method_name, get_topic, resolve_topic
30
31
 
31
32
  TZINFO: tzinfo = resolve_topic(os.getenv("TZINFO_TOPIC", "datetime:timezone.utc"))
33
+ """
34
+ A Python :py:obj:`tzinfo` object that defaults to UTC (:py:obj:`timezone.utc`). Used
35
+ as the timezone argument in :func:`~eventsourcing.domain.datetime_now_with_tzinfo`.
36
+
37
+ Set environment variable ``TZINFO_TOPIC`` to the topic of a different :py:obj:`tzinfo`
38
+ object so that all your domain model event timestamps are located in that timezone
39
+ (not recommended). It is generally recommended to locate all timestamps in the UTC
40
+ domain and convert to local timezones when presenting values in user interfaces.
41
+ """
32
42
 
33
43
 
34
44
  @runtime_checkable
@@ -153,13 +163,27 @@ class CanMutateProtocol(DomainEventProtocol, Protocol[TMutableOrImmutableAggrega
153
163
  """
154
164
 
155
165
 
156
- def create_utc_datetime_now() -> datetime:
166
+ def datetime_now_with_tzinfo() -> datetime:
157
167
  """
158
168
  Constructs a timezone-aware :class:`datetime` object for the current date and time.
169
+
170
+ Uses :py:obj:`TZINFO` as the timezone.
159
171
  """
160
172
  return datetime.now(tz=TZINFO)
161
173
 
162
174
 
175
+ def create_utc_datetime_now() -> datetime:
176
+ """
177
+ Deprected in favour of :func:`~eventsourcing.domain.datetime_now_with_tzinfo`.
178
+ """
179
+ msg = (
180
+ "'create_utc_datetime_now()' is deprecated, "
181
+ "use 'datetime_now_with_tzinfo()' instead"
182
+ )
183
+ warn(msg, DeprecationWarning, stacklevel=2)
184
+ return datetime_now_with_tzinfo()
185
+
186
+
163
187
  class CanCreateTimestamp:
164
188
  """
165
189
  Provides a create_timestamp() method to subclasses.
@@ -171,7 +195,7 @@ class CanCreateTimestamp:
171
195
  Constructs a timezone-aware :class:`datetime` object
172
196
  representing when an event occurred.
173
197
  """
174
- return create_utc_datetime_now()
198
+ return datetime_now_with_tzinfo()
175
199
 
176
200
 
177
201
  TAggregate = TypeVar("TAggregate", bound="Aggregate")
@@ -386,7 +410,7 @@ def _spec_filter_kwargs_for_method_params(method: Callable[..., Any]) -> set[str
386
410
  return set(method_signature.parameters)
387
411
 
388
412
 
389
- if TYPE_CHECKING: # pragma: nocover
413
+ if TYPE_CHECKING:
390
414
  EventSpecType = Union[str, Type[CanMutateAggregate]]
391
415
 
392
416
  CommandMethod = Callable[..., None]
@@ -668,6 +692,8 @@ class UnboundCommandMethodDecorator:
668
692
  self.__qualname__ = event_decorator.decorated_method.__qualname__
669
693
  self.__annotations__ = event_decorator.decorated_method.__annotations__
670
694
  self.__doc__ = event_decorator.decorated_method.__doc__
695
+ # self.__wrapped__ = event_decorator.decorated_method
696
+ # functools.update_wrapper(self, event_decorator.decorated_method)
671
697
 
672
698
  def __call__(self, *args: Any, **kwargs: Any) -> None:
673
699
  # Expect first argument is an aggregate instance.
@@ -1526,12 +1552,6 @@ class VersionError(OriginatorVersionError):
1526
1552
 
1527
1553
 
1528
1554
  class SnapshotProtocol(DomainEventProtocol, Protocol):
1529
- @property
1530
- def topic(self) -> str:
1531
- """
1532
- Snapshots have a read-only 'topic'.
1533
- """
1534
-
1535
1555
  @property
1536
1556
  def state(self) -> Dict[str, Any]:
1537
1557
  """
@@ -25,7 +25,12 @@ class NotificationLogInterface(ABC):
25
25
 
26
26
  @abstractmethod
27
27
  def get_notifications(
28
- self, start: int, limit: int, topics: Sequence[str] = ()
28
+ self,
29
+ start: int | None,
30
+ limit: int,
31
+ topics: Sequence[str] = (),
32
+ *,
33
+ inclusive_of_start: bool = True,
29
34
  ) -> str:
30
35
  """
31
36
  Returns a serialised list of :class:`~eventsourcing.persistence.Notification`
@@ -68,10 +73,18 @@ class NotificationLogJSONService(NotificationLogInterface, Generic[TApplication]
68
73
  )
69
74
 
70
75
  def get_notifications(
71
- self, start: int, limit: int, topics: Sequence[str] = ()
76
+ self,
77
+ start: int | None,
78
+ limit: int,
79
+ topics: Sequence[str] = (),
80
+ *,
81
+ inclusive_of_start: bool = True,
72
82
  ) -> str:
73
83
  notifications = self.app.notification_log.select(
74
- start=start, limit=limit, topics=topics
84
+ start=start,
85
+ limit=limit,
86
+ topics=topics,
87
+ inclusive_of_start=inclusive_of_start,
75
88
  )
76
89
  return json.dumps(
77
90
  [
@@ -123,10 +136,12 @@ class NotificationLogJSONClient(NotificationLog):
123
136
 
124
137
  def select(
125
138
  self,
126
- start: int,
139
+ start: int | None,
127
140
  limit: int,
128
141
  _: int | None = None,
129
142
  topics: Sequence[str] = (),
143
+ *,
144
+ inclusive_of_start: bool = True,
130
145
  ) -> List[Notification]:
131
146
  """
132
147
  Returns a selection of
@@ -143,7 +158,10 @@ class NotificationLogJSONClient(NotificationLog):
143
158
  )
144
159
  for item in json.loads(
145
160
  self.interface.get_notifications(
146
- start=start, limit=limit, topics=topics
161
+ start=start,
162
+ limit=limit,
163
+ topics=topics,
164
+ inclusive_of_start=inclusive_of_start,
147
165
  )
148
166
  )
149
167
  ]