eventsourcing 9.5.0b3__py3-none-any.whl

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.
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from base64 import b64decode, b64encode
5
+ from typing import TYPE_CHECKING
6
+
7
+ from Crypto.Cipher import AES
8
+ from Crypto.Cipher._mode_gcm import (
9
+ GcmMode, # pyright: ignore [reportPrivateImportUsage]
10
+ )
11
+ from Crypto.Cipher.AES import key_size
12
+
13
+ from eventsourcing.persistence import Cipher
14
+
15
+ if TYPE_CHECKING:
16
+ from eventsourcing.utils import Environment
17
+
18
+
19
+ class AESCipher(Cipher):
20
+ """Cipher strategy that uses AES cipher (in GCM mode)
21
+ from the Python pycryptodome package.
22
+ """
23
+
24
+ CIPHER_KEY = "CIPHER_KEY"
25
+ KEY_SIZES = key_size
26
+
27
+ @staticmethod
28
+ def create_key(num_bytes: int) -> str:
29
+ """Creates AES cipher key, with length num_bytes.
30
+
31
+ :param num_bytes: An int value, either 16, 24, or 32.
32
+
33
+ """
34
+ AESCipher.check_key_size(num_bytes)
35
+ return b64encode(AESCipher.random_bytes(num_bytes)).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
+ """Initialises AES cipher with ``cipher_key``.
49
+
50
+ :param str cipher_key: 16, 24, or 32 bytes encoded as base64
51
+ """
52
+ cipher_key = environment.get(self.CIPHER_KEY)
53
+ if not cipher_key:
54
+ msg = f"'{self.CIPHER_KEY}' not in env"
55
+ raise OSError(msg)
56
+ key = b64decode(cipher_key.encode("utf8"))
57
+ AESCipher.check_key_size(len(key))
58
+ self.key = key
59
+
60
+ def encrypt(self, plaintext: bytes) -> bytes:
61
+ """Return ciphertext for given plaintext."""
62
+ # Construct AES-GCM cipher, with 96-bit nonce.
63
+ nonce = AESCipher.random_bytes(12)
64
+ cipher = self.construct_cipher(nonce)
65
+
66
+ # Encrypt and digest.
67
+ result = cipher.encrypt_and_digest(plaintext)
68
+ encrypted = result[0]
69
+ tag = result[1]
70
+
71
+ # Return ciphertext.
72
+ return nonce + tag + encrypted
73
+ # return nonce + tag + encrypted
74
+
75
+ def construct_cipher(self, nonce: bytes) -> GcmMode:
76
+ cipher = AES.new(
77
+ self.key,
78
+ AES.MODE_GCM,
79
+ nonce,
80
+ )
81
+ assert isinstance(cipher, GcmMode)
82
+ return cipher
83
+
84
+ def decrypt(self, ciphertext: bytes) -> bytes:
85
+ """Return plaintext for given ciphertext."""
86
+ # Split out the nonce, tag, and encrypted data.
87
+ nonce = ciphertext[:12]
88
+ if len(nonce) != 12:
89
+ msg = "Damaged cipher text: invalid nonce length"
90
+ raise ValueError(msg)
91
+
92
+ tag = ciphertext[12:28]
93
+ if len(tag) != 16:
94
+ msg = "Damaged cipher text: invalid tag length"
95
+ raise ValueError(msg)
96
+ encrypted = ciphertext[28:]
97
+
98
+ # Construct AES cipher, with old nonce.
99
+ cipher = self.construct_cipher(nonce)
100
+
101
+ # Decrypt and verify.
102
+ try:
103
+ plaintext = cipher.decrypt_and_verify(encrypted, tag)
104
+ except ValueError as e:
105
+ msg = f"Cipher text is damaged: {e}"
106
+ raise ValueError(msg) from None
107
+ return plaintext
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ import zlib
4
+
5
+ from eventsourcing.persistence import Compressor
6
+
7
+
8
+ class ZlibCompressor(Compressor):
9
+ def compress(self, data: bytes) -> bytes:
10
+ """Compress bytes using zlib."""
11
+ return zlib.compress(data)
12
+
13
+ def decompress(self, data: bytes) -> bytes:
14
+ """Decompress bytes using zlib."""
15
+ return zlib.decompress(data)
@@ -0,0 +1,91 @@
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
+ """Cipher strategy that uses AES cipher (in GCM mode)
18
+ from the Python cryptography package.
19
+ """
20
+
21
+ CIPHER_KEY = "CIPHER_KEY"
22
+ KEY_SIZES = (16, 24, 32)
23
+
24
+ @staticmethod
25
+ def create_key(num_bytes: int) -> str:
26
+ """Creates AES cipher key, with length num_bytes.
27
+
28
+ :param num_bytes: An int value, either 16, 24, or 32.
29
+
30
+ """
31
+ AESCipher.check_key_size(num_bytes)
32
+ key = AESGCM.generate_key(num_bytes * 8)
33
+ return b64encode(key).decode("utf8")
34
+
35
+ @staticmethod
36
+ def check_key_size(num_bytes: int) -> None:
37
+ if num_bytes not in AESCipher.KEY_SIZES:
38
+ msg = f"Invalid key size: {num_bytes} not in {AESCipher.KEY_SIZES}"
39
+ raise ValueError(msg)
40
+
41
+ @staticmethod
42
+ def random_bytes(num_bytes: int) -> bytes:
43
+ return os.urandom(num_bytes)
44
+
45
+ def __init__(self, environment: Environment):
46
+ """Initialises AES cipher with ``cipher_key``.
47
+
48
+ :param str cipher_key: 16, 24, or 32 bytes encoded as base64
49
+ """
50
+ cipher_key = environment.get(self.CIPHER_KEY)
51
+ if not cipher_key:
52
+ msg = f"'{self.CIPHER_KEY}' not in env"
53
+ raise OSError(msg)
54
+ key = b64decode(cipher_key.encode("utf8"))
55
+ AESCipher.check_key_size(len(key))
56
+ self.key = key
57
+
58
+ def encrypt(self, plaintext: bytes) -> bytes:
59
+ """Return ciphertext for given plaintext."""
60
+ # Construct AES-GCM cipher, with 96-bit nonce.
61
+ aesgcm = AESGCM(self.key)
62
+ nonce = AESCipher.random_bytes(12)
63
+ res = aesgcm.encrypt(nonce, plaintext, None)
64
+ # Put tag at the front for compatibility with eventsourcing.crypto.AESCipher.
65
+ tag = res[-16:]
66
+ encrypted = res[:-16]
67
+ return nonce + tag + encrypted
68
+
69
+ def decrypt(self, ciphertext: bytes) -> bytes:
70
+ """Return plaintext for given ciphertext."""
71
+ # Split out the nonce, tag, and encrypted data.
72
+ nonce = ciphertext[:12]
73
+ if len(nonce) != 12:
74
+ msg = "Damaged cipher text: invalid nonce length"
75
+ raise ValueError(msg)
76
+
77
+ # Expect tag at the front.
78
+ tag = ciphertext[12:28]
79
+ if len(tag) != 16:
80
+ msg = "Damaged cipher text: invalid tag length"
81
+ raise ValueError(msg)
82
+ encrypted = ciphertext[28:]
83
+
84
+ aesgcm = AESGCM(self.key)
85
+ try:
86
+ plaintext = aesgcm.decrypt(nonce, encrypted + tag, None)
87
+ except InvalidTag as e:
88
+ msg = "Invalid cipher tag"
89
+ raise ValueError(msg) from e
90
+ # Decrypt and verify.
91
+ return plaintext
File without changes
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Iterator
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
7
+
8
+ from eventsourcing.persistence import ProgrammingError
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Sequence
12
+
13
+ from typing_extensions import Self
14
+
15
+
16
+ @dataclass
17
+ class DCBQueryItem:
18
+ types: list[str] = field(default_factory=list)
19
+ tags: list[str] = field(default_factory=list)
20
+
21
+
22
+ @dataclass
23
+ class DCBQuery:
24
+ items: list[DCBQueryItem] = field(default_factory=list)
25
+
26
+
27
+ @dataclass
28
+ class DCBAppendCondition:
29
+ fail_if_events_match: DCBQuery = field(default_factory=DCBQuery)
30
+ after: int | None = None
31
+
32
+
33
+ @dataclass
34
+ class DCBEvent:
35
+ type: str
36
+ data: bytes
37
+ tags: list[str] = field(default_factory=list)
38
+
39
+
40
+ @dataclass
41
+ class DCBSequencedEvent:
42
+ event: DCBEvent
43
+ position: int
44
+
45
+
46
+ class DCBReadResponse(Iterator[DCBSequencedEvent], ABC):
47
+ @property
48
+ @abstractmethod
49
+ def head(self) -> int | None:
50
+ pass # pragma: no cover
51
+
52
+ @abstractmethod
53
+ def __next__(self) -> DCBSequencedEvent:
54
+ pass # pragma: no cover
55
+
56
+ # @abstractmethod
57
+ # def next_batch(self) -> list[DCBSequencedEvent]:
58
+ # """
59
+ # Returns a batch of events as a list.
60
+ # Updates the head position similar to __next__.
61
+ # """
62
+
63
+
64
+ class DCBRecorder(ABC):
65
+ @abstractmethod
66
+ def read(
67
+ self,
68
+ query: DCBQuery | None = None,
69
+ *,
70
+ after: int | None = None,
71
+ limit: int | None = None,
72
+ ) -> DCBReadResponse:
73
+ """
74
+ Returns all events, unless 'after' is given then only those with position
75
+ greater than 'after', and unless any query items are given, then only those
76
+ that match at least one query item. An event matches a query item if its type
77
+ is in the item types or there are no item types, and if all the item tags are
78
+ in the event tags.
79
+ """
80
+
81
+ @abstractmethod
82
+ def append(
83
+ self, events: Sequence[DCBEvent], condition: DCBAppendCondition | None = None
84
+ ) -> int:
85
+ """
86
+ Appends given events to the event store, unless the condition fails.
87
+ """
88
+
89
+ @abstractmethod
90
+ def subscribe(
91
+ self,
92
+ query: DCBQuery | None = None,
93
+ *,
94
+ after: int | None = None,
95
+ ) -> DCBSubscription[Self]:
96
+ """
97
+ Returns all events, unless 'after' is given then only those with position
98
+ greater than 'after', and unless any query items are given, then only those
99
+ that match at least one query item. An event matches a query item if its type
100
+ is in the item types or there are no item types, and if all the item tags are
101
+ in the event tags. The subscription will block when the last recorded event
102
+ is received, and then continue when new events are recorded.
103
+ """
104
+
105
+
106
+ TDCBRecorder_co = TypeVar("TDCBRecorder_co", bound=DCBRecorder, covariant=True)
107
+
108
+
109
+ class DCBSubscription(Iterator[DCBSequencedEvent], Generic[TDCBRecorder_co]):
110
+ def __init__(
111
+ self,
112
+ recorder: TDCBRecorder_co,
113
+ query: DCBQuery | None = None,
114
+ after: int | None = None,
115
+ ) -> None:
116
+ self._recorder = recorder
117
+ self._query = query
118
+ self._has_been_entered = False
119
+ self._has_been_stopped = False
120
+ self._last_position: int = after or 0
121
+
122
+ def __enter__(self) -> Self:
123
+ if self._has_been_entered:
124
+ msg = "Already entered subscription context manager"
125
+ raise ProgrammingError(msg)
126
+ self._has_been_entered = True
127
+ return self
128
+
129
+ def __exit__(self, *args: object, **kwargs: Any) -> None:
130
+ if not self._has_been_entered:
131
+ msg = "Not already entered subscription context manager"
132
+ raise ProgrammingError(msg)
133
+ self.stop()
134
+
135
+ def stop(self) -> None:
136
+ """Stops the subscription."""
137
+ self._has_been_stopped = True
138
+
139
+ def __iter__(self) -> Self:
140
+ return self
141
+
142
+ @abstractmethod
143
+ def __next__(self) -> DCBSequencedEvent:
144
+ """Returns the next DCBEvent in the sequence."""
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import TYPE_CHECKING, Any, Generic, cast
5
+
6
+ from eventsourcing.dcb.domain import (
7
+ EnduringObject,
8
+ InitialDecision,
9
+ Perspective,
10
+ Selector,
11
+ TDecision,
12
+ TGroup,
13
+ TPerspective,
14
+ TSlice,
15
+ )
16
+ from eventsourcing.dcb.persistence import (
17
+ DCBEventStore,
18
+ DCBInfrastructureFactory,
19
+ DCBMapper,
20
+ NotFoundError,
21
+ )
22
+ from eventsourcing.utils import Environment, EnvType, resolve_topic
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Mapping
26
+ from types import TracebackType
27
+
28
+ from typing_extensions import Self
29
+
30
+
31
+ class DCBApplication:
32
+ name = "DCBApplication"
33
+ env: Mapping[str, str] = {"PERSISTENCE_MODULE": "eventsourcing.dcb.popo"}
34
+
35
+ def __init_subclass__(cls, **kwargs: Any) -> None:
36
+ if "name" not in cls.__dict__:
37
+ cls.name = cls.__name__
38
+
39
+ def __init__(self, env: EnvType | None = None):
40
+ self.env = self.construct_env(self.name, env)
41
+ self.factory = DCBInfrastructureFactory.construct(self.env)
42
+ self.recorder = self.factory.dcb_recorder()
43
+ if "MAPPER_TOPIC" in self.env:
44
+ # Only need a mapper, event store, and repository
45
+ # if we are using the higher-level abstractions.
46
+ self.mapper = cast(
47
+ DCBMapper[Any], resolve_topic(self.env["MAPPER_TOPIC"])()
48
+ )
49
+ assert isinstance(self.mapper, DCBMapper)
50
+ self.events = DCBEventStore(self.mapper, self.recorder)
51
+ self.repository = DCBRepository(self.events)
52
+
53
+ def construct_env(self, name: str, env: EnvType | None = None) -> Environment:
54
+ """Constructs environment from which application will be configured."""
55
+ _env = dict(type(self).env)
56
+ _env.update(os.environ)
57
+ if env is not None:
58
+ _env.update(env)
59
+ return Environment(name, _env)
60
+
61
+ def do(self, s: TSlice) -> TSlice:
62
+ """
63
+ Advances and executes a slice, then saves new decisions.
64
+ """
65
+ if type(s).do_projection:
66
+ s = self.repository.advance(s)
67
+ s.execute()
68
+ if s.new_decisions:
69
+ self.repository.save(s)
70
+ return s
71
+
72
+ def close(self) -> None:
73
+ self.factory.close()
74
+
75
+ def __enter__(self) -> Self:
76
+ self.factory.__enter__()
77
+ return self
78
+
79
+ def __exit__(
80
+ self,
81
+ exc_type: type[BaseException] | None,
82
+ exc_val: BaseException | None,
83
+ exc_tb: TracebackType | None,
84
+ ) -> None:
85
+ self.close()
86
+ self.factory.__exit__(exc_type, exc_val, exc_tb)
87
+
88
+
89
+ class DCBRepository(Generic[TDecision]):
90
+ def __init__(self, eventstore: DCBEventStore[TDecision]):
91
+ self.eventstore = eventstore
92
+
93
+ def save(self, p: Perspective[TDecision]) -> int:
94
+ return self.eventstore.append(
95
+ events=p.collect_events(),
96
+ cb=p.consistency_boundary(),
97
+ after=p.last_known_position,
98
+ )
99
+
100
+ def get(
101
+ self,
102
+ enduring_object_id: str,
103
+ ) -> EnduringObject[TDecision]:
104
+ cb = [Selector(tags=[enduring_object_id])]
105
+ events = self.eventstore.read(*cb)
106
+ obj: EnduringObject[TDecision] | None = None
107
+ for event in events:
108
+ obj = event.decision.mutate(obj)
109
+ if obj is None:
110
+ raise NotFoundError
111
+ obj.last_known_position = events.head
112
+ return obj
113
+
114
+ def get_many(
115
+ self,
116
+ *enduring_object_ids: str,
117
+ ) -> list[EnduringObject[TDecision] | None]:
118
+ cb = [
119
+ Selector(tags=[enduring_object_id])
120
+ for enduring_object_id in enduring_object_ids
121
+ ]
122
+ tagged_decisions = self.eventstore.read(cb)
123
+ objs: dict[str, EnduringObject[TDecision] | None] = dict.fromkeys(
124
+ enduring_object_ids
125
+ )
126
+ for tagged in tagged_decisions:
127
+ for tag in tagged.tags:
128
+ obj = objs.get(tag)
129
+ if not isinstance(tagged.decision, InitialDecision) and not obj:
130
+ continue
131
+ obj = tagged.decision.mutate(obj)
132
+ objs[tag] = obj
133
+ for obj in objs.values():
134
+ if obj is not None:
135
+ obj.last_known_position = tagged_decisions.head
136
+ return list(objs.values())
137
+
138
+ def get_group(self, cls: type[TGroup], *enduring_object_ids: str) -> TGroup:
139
+ enduring_objects = self.get_many(*enduring_object_ids)
140
+ perspective = cls(*enduring_objects)
141
+ last_known_positions = [
142
+ o.last_known_position
143
+ for o in enduring_objects
144
+ if o and o.last_known_position
145
+ ]
146
+ perspective.last_known_position = (
147
+ max(last_known_positions) if last_known_positions else None
148
+ )
149
+ return perspective
150
+
151
+ def advance(self, p: TPerspective) -> TPerspective:
152
+ events = self.eventstore.read(
153
+ cb=p.consistency_boundary(),
154
+ after=p.last_known_position,
155
+ )
156
+ for event in events:
157
+ event.decision.mutate(p)
158
+ p.last_known_position = events.head
159
+ return p