bestagon 0.6.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.

Potentially problematic release.


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

@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: bestagon
3
+ Version: 0.6.0
4
+ Summary: DDD+ES+CQRS framework for python
5
+ Author: Aleksandr Antonov
6
+ Author-email: antonov.o779@gmail.com
7
+ Dynamic: author
8
+ Dynamic: author-email
9
+ Dynamic: summary
@@ -0,0 +1,165 @@
1
+ import asyncio
2
+ import logging
3
+ from dataclasses import dataclass
4
+ from typing import List, Union, Sequence, cast, Tuple
5
+
6
+ import grpc
7
+ from kurrentdbclient import StreamState, NewEvent, DEFAULT_EXCLUDE_FILTER, AsyncKurrentDBClient, AsyncCatchupSubscription
8
+ from kurrentdbclient.common import DEFAULT_WINDOW_SIZE, DEFAULT_CHECKPOINT_INTERVAL_MULTIPLIER
9
+ from kurrentdbclient.exceptions import NotFoundError
10
+
11
+ from bestagon.core.event_store import EventStore, SubscriptionParameters, EventStoreSubscription, StreamEvent, \
12
+ NewStreamEvent
13
+ from bestagon.core.exceptions import IntegrityError
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class KurrentDBSubscriptionParameters(SubscriptionParameters):
21
+ commit_position: Union[int, None] = None
22
+ from_end: bool = False
23
+ resolve_links: bool = False
24
+ filter_exclude: Sequence[str] = DEFAULT_EXCLUDE_FILTER
25
+ filter_include: Sequence[str] = ()
26
+ filter_by_stream_name: bool = False
27
+ include_checkpoints: bool = False
28
+ window_size: int = DEFAULT_WINDOW_SIZE
29
+ checkpoint_interval_multiplier: int = DEFAULT_CHECKPOINT_INTERVAL_MULTIPLIER
30
+ include_caught_up: bool = False
31
+ include_fell_behind: bool = False
32
+ timeout: Union[float, None] = None
33
+ credentials: Union[grpc.CallCredentials, None] = None
34
+
35
+
36
+ class AsyncKurrentDBSubscription(EventStoreSubscription):
37
+ def __init__(self, name: str, parameters: KurrentDBSubscriptionParameters, kdb_subscription: AsyncCatchupSubscription):
38
+ super().__init__(name=name, parameters=parameters)
39
+ self._kdb_subscription = kdb_subscription
40
+
41
+ async def next_event(self) -> StreamEvent:
42
+ if not self.running:
43
+ raise StopAsyncIteration
44
+
45
+ event = await anext(self._kdb_subscription)
46
+ stream_event = StreamEvent(
47
+ stream_name=event.stream_name,
48
+ stream_position=event.stream_position,
49
+ commit_position=event.commit_position,
50
+ event_type=event.type,
51
+ payload=event.data,
52
+ metadata=event.metadata
53
+ )
54
+ return stream_event
55
+
56
+ async def stop(self) -> None:
57
+ if self.running:
58
+ self._running = False
59
+ await self._kdb_subscription.stop()
60
+
61
+
62
+ class AsyncKurrentDBEventStore(EventStore):
63
+ def __init__(self, client: AsyncKurrentDBClient):
64
+ super().__init__()
65
+ self.client = client
66
+
67
+ async def append_events(self, stream_name: str, events: Tuple[NewStreamEvent]) -> None:
68
+ current_version = await self.client.get_current_version(stream_name=stream_name)
69
+ first_event = events[0]
70
+ if current_version == StreamState.NO_STREAM:
71
+ if first_event.stream_position != 0:
72
+ raise IntegrityError('Failed to append event to non existent stream, events position should start from 0')
73
+ elif first_event.stream_position <= current_version:
74
+ raise IntegrityError(f'Failed to append events in stream {stream_name} - event with version {first_event.stream_position} already exists in database')
75
+
76
+ new_events = list()
77
+ for event in events:
78
+ new_event = NewEvent(
79
+ type=event.event_type,
80
+ data=event.payload,
81
+ metadata=event.metadata
82
+ )
83
+ new_events.append(new_event)
84
+
85
+ await self.client.append_events(stream_name=stream_name, current_version=current_version, events=new_events)
86
+
87
+ async def close(self) -> None:
88
+ if self. subscriptions:
89
+ await asyncio.gather(*[sub.stop() for sub in self.subscriptions])
90
+ logger.info('All subscriptions stopped')
91
+
92
+ await self.client.close()
93
+ logger.info('Event store closed')
94
+
95
+ async def connect(self) -> None:
96
+ await self.client.connect()
97
+ logger.info('Event store connected')
98
+
99
+ async def create_subscription(self, subscription_name: str,
100
+ subscription_parameters: KurrentDBSubscriptionParameters) -> AsyncKurrentDBSubscription:
101
+ kdb_subscription = await self.client.subscribe_to_all(
102
+ commit_position=subscription_parameters.commit_position,
103
+ from_end=subscription_parameters.from_end,
104
+ resolve_links=subscription_parameters.resolve_links,
105
+ filter_exclude=subscription_parameters.filter_exclude,
106
+ filter_include=subscription_parameters.filter_include,
107
+ filter_by_stream_name=subscription_parameters.filter_by_stream_name,
108
+ include_checkpoints=subscription_parameters.include_checkpoints,
109
+ window_size=subscription_parameters.window_size,
110
+ checkpoint_interval_multiplier=subscription_parameters.checkpoint_interval_multiplier,
111
+ include_caught_up=subscription_parameters.include_caught_up,
112
+ include_fell_behind=subscription_parameters.include_fell_behind,
113
+ timeout=subscription_parameters.timeout,
114
+ credentials=subscription_parameters.credentials
115
+ )
116
+ kdb_subscription = cast(AsyncCatchupSubscription, kdb_subscription)
117
+
118
+ subscription = AsyncKurrentDBSubscription(name=subscription_name, parameters=subscription_parameters, kdb_subscription=kdb_subscription)
119
+ self._subscriptions.append(subscription)
120
+ return subscription
121
+
122
+ async def create_subscription_to_all(self, subscription_name: str, start_position: int) -> 'EventStoreSubscription':
123
+ params = KurrentDBSubscriptionParameters(commit_position=start_position)
124
+ sub = await self.create_subscription(subscription_name=subscription_name, subscription_parameters=params)
125
+ return sub
126
+
127
+ async def create_subscription_to_events(self, subscription_name: str, events: List[str],
128
+ start_position: int) -> 'EventStoreSubscription':
129
+ params = KurrentDBSubscriptionParameters(
130
+ commit_position=start_position,
131
+ filter_include=events,
132
+ filter_by_stream_name=False
133
+ )
134
+ sub = await self.create_subscription(subscription_name=subscription_name, subscription_parameters=params)
135
+ return sub
136
+
137
+ async def create_subscription_to_stream(self, subscription_name: str, regex: str, start_position: int) -> EventStoreSubscription:
138
+ params = KurrentDBSubscriptionParameters(
139
+ commit_position=start_position,
140
+ filter_include=[regex],
141
+ filter_by_stream_name=True
142
+ )
143
+ return await self.create_subscription(subscription_name=subscription_name, subscription_parameters=params)
144
+
145
+ async def get_stream(self, stream_name: str) -> Tuple[StreamEvent]:
146
+ events = await self.client.get_stream(stream_name=stream_name)
147
+ stream_events = list()
148
+ for event in events:
149
+ stream_event = StreamEvent(
150
+ stream_name=stream_name,
151
+ stream_position=event.stream_position,
152
+ commit_position=event.commit_position,
153
+ event_type=event.type,
154
+ payload=event.data,
155
+ metadata=event.metadata
156
+ )
157
+ stream_events.append(stream_event)
158
+ return tuple(stream_events)
159
+
160
+ async def stream_exists(self, stream_name: str) -> bool:
161
+ try:
162
+ events = await self.client.get_stream(stream_name=stream_name, backwards=True, limit=1)
163
+ return bool(events)
164
+ except NotFoundError:
165
+ return False
@@ -0,0 +1,139 @@
1
+ import asyncio
2
+ import logging
3
+ from abc import abstractmethod
4
+ from typing import Tuple
5
+
6
+ import neo4j
7
+
8
+ from bestagon.core.checkpoint_store import CheckpointStore, Checkpoint
9
+ from bestagon.core.event_processor import Projection
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Neo4jCheckpointStore(CheckpointStore):
15
+ def __init__(self, driver: neo4j.AsyncDriver, database_name: str):
16
+ self.driver = driver
17
+ self.database_name = database_name
18
+
19
+ async def close(self) -> None:
20
+ pass
21
+
22
+ async def delete_checkpoint(self, name: str) -> None:
23
+ cypher = '''
24
+ MATCH (c:Checkpoint {name: $name})
25
+ DETACH DELETE c
26
+ '''
27
+ async with self.driver.session(default_access_mode=neo4j.WRITE_ACCESS, database=self.database_name) as sess:
28
+ await sess.run(cypher, name=name)
29
+ logger.info(f'Checkpoint {name} deleted')
30
+
31
+ async def get_checkpoint(self, name: str) -> Checkpoint:
32
+ cypher = '''
33
+ MATCH (c:Checkpoint {name: $name})
34
+ RETURN c.value
35
+ '''
36
+ async with self.driver.session(database=self.database_name, default_access_mode=neo4j.READ_ACCESS) as sess:
37
+ result = await sess.run(
38
+ cypher, # NOQA
39
+ name=name
40
+ )
41
+ value = await result.single()
42
+ if value is not None:
43
+ value = value.value()
44
+ else:
45
+ value = 0
46
+
47
+ checkpoint = Checkpoint(name=name, value=value)
48
+ return checkpoint
49
+
50
+ async def initialize(self) -> None:
51
+ create_cypher = "CREATE DATABASE $database_name IF NOT EXISTS"
52
+ async with self.driver.session() as sess:
53
+ await sess.run(
54
+ create_cypher, # NOQA
55
+ database_name=self.database_name
56
+ )
57
+
58
+ index_cypher = '''
59
+ CREATE RANGE INDEX checkpoint_name_index
60
+ IF NOT EXISTS
61
+ FOR (n:Checkpoint)
62
+ ON n.name
63
+ '''
64
+ async with self.driver.session(database=self.database_name) as sess:
65
+ await sess.run(index_cypher) # NOQA
66
+
67
+ async def list_checkpoints(self) -> Tuple[Checkpoint, ...]:
68
+ cypher = '''
69
+ MATCH (c:Checkpoint)
70
+ WITH {
71
+ name: c.name,
72
+ value: c.value
73
+ } AS data
74
+ RETURN data
75
+ '''
76
+ async with self.driver.session(default_access_mode=neo4j.READ_ACCESS, database=self.database_name) as sess:
77
+ result = await sess.run(cypher)
78
+ data = await result.value()
79
+ checkpoints = tuple(Checkpoint(**datum) for datum in data)
80
+ return checkpoints
81
+
82
+ async def set_checkpoint(self, checkpoint: Checkpoint) -> None:
83
+ cypher = '''
84
+ MERGE (c:Checkpoint {name: $name})
85
+ SET c.value = $value
86
+ '''
87
+
88
+ async with self.driver.session(database=self.database_name, default_access_mode=neo4j.WRITE_ACCESS) as sess:
89
+ await sess.run(
90
+ cypher, # NOQA
91
+ name=checkpoint.name,
92
+ value=checkpoint.value
93
+ )
94
+ logger.debug(f'New checkpoint set for {checkpoint.name}: {checkpoint.value}')
95
+
96
+
97
+ class Neo4jProjection(Projection):
98
+ def __init__(self, driver: neo4j.AsyncDriver, checkpoint_store: CheckpointStore):
99
+ super().__init__(checkpoint_store=checkpoint_store)
100
+ self.driver = driver
101
+
102
+ @property
103
+ def database_name(self) -> str:
104
+ return self.get_database_name()
105
+
106
+ async def drop(self) -> None:
107
+ # TODO - indexes
108
+ logger.info(f'Dropping projection {self.name}...')
109
+ cypher = '''CREATE OR REPLACE DATABASE $database_name'''
110
+ check_cypher = '''
111
+ SHOW DATABASES
112
+ YIELD name, currentStatus
113
+ WHERE name = $database_name
114
+ RETURN currentStatus AS status
115
+ '''
116
+ async with self.driver.session() as sess:
117
+ await sess.run(cypher, database_name=self.database_name)
118
+
119
+ logger.info('Checking database created')
120
+ while True:
121
+ result = await sess.run(check_cypher, database_name=self.database_name)
122
+ record = await result.single()
123
+ status = record.value()
124
+ if status != 'online':
125
+ logger.info('Waiting for database')
126
+ await asyncio.sleep(1)
127
+ else:
128
+ logger.info(f'Database created')
129
+ break
130
+
131
+ @abstractmethod
132
+ def get_database_name(self) -> str:
133
+ raise NotImplementedError
134
+
135
+ async def initialize(self) -> None:
136
+ # TODO - indexes
137
+ cypher = '''CREATE DATABASE $database_name IF NOT EXISTS'''
138
+ async with self.driver.session() as sess:
139
+ await sess.run(cypher, database_name=self.database_name)
@@ -0,0 +1,246 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass, asdict
3
+ from datetime import datetime, timezone
4
+ from typing import List, Tuple
5
+
6
+ from bestagon.core.exceptions import AggregateIDMismatch, AggregateVersionError
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class DomainEventMetadata:
11
+ """
12
+ DO NOT base business decisions on metadata
13
+ The correlation_id of a message always references the identifier of the message it originates from (that is, the parent message).
14
+ The trace_id on the other hand references to the message identifier which started the chain of messages (that is, the root message).
15
+ """
16
+ # TODO - add correlation_id
17
+ # TODO - add trace_id
18
+ # TODO - ORLY - inherit dict and use getters to get aggregate_id, version etc.
19
+
20
+ timestamp: str # TODO - turn it into field to create automatically # TODO - also can be called audit_time - just a date and time when event occured
21
+ aggregate_id: str
22
+ aggregate_version: int
23
+ aggregate_type: str
24
+
25
+ @staticmethod
26
+ def create_timestamp() -> str:
27
+ return datetime.now(timezone.utc).isoformat()
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DomainEvent:
32
+ # TODO - make things simple - event should contain metadata and data (separate class)
33
+ metadata: DomainEventMetadata
34
+
35
+ def get_metadata_as_dict(self) -> dict:
36
+ # TODO - ORLY???
37
+ return asdict(self.metadata)
38
+
39
+ def get_payload(self) -> dict:
40
+ # TODO - ORLY???
41
+ payload = asdict(self).copy()
42
+ payload.pop('metadata')
43
+ return payload
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Created(DomainEvent):
48
+ pass
49
+
50
+
51
+ class Aggregate(ABC):
52
+ """
53
+ According to Eric Evans book Domain Driven Design: Tackling Complexity in the Heart of Software:
54
+ An AGGREGATE is a cluster of associated objects that we treat as a unit for the purpose of data changes.
55
+
56
+ The class is an implementation of an Event Sourced Aggregate.
57
+ Each event sourced aggregate contains a set of predefined domain events, which discovered during modelling stage using event storming or other knowledge crunching method.
58
+
59
+ # Aggregate creation:
60
+ Event sourced aggregate starts its life cycle from 'Created' event. The only right way to do it currenty is to define factory method on the aggregate, this method
61
+ should create necessary event and then use '_create' method to instantiate aggregate.
62
+
63
+ For example, imagine you have a ShoppingCart aggregate, according to ubiquitious language of your domain it is 'created', so you can create a factory method, called 'create'
64
+ for the specified aggregate:
65
+
66
+ @classmethod
67
+ def create(cls, param_1, param_2) -> 'ShoppingCart':
68
+ metadata = DomainEventMetadata() # Details omitted for simplicity
69
+ event = cls.Created(metadata, param_1, param_2)
70
+ obj = cls._create(event)
71
+ return obj
72
+
73
+ # Consequtive events:
74
+ In addition to 'Created' event, aggregate can have 0 or more events that can happen after creation of aggregate. Each of these events change aggregate state in some way.
75
+ For example, if we talk about shopping cart, then we can 'add_item' to it, so there will be a method on aggregate:
76
+
77
+ def add_item(command: AddShoppingCartItem) -> None:
78
+ # First, you perform validation of input parameters (whether it is a command, entity or just a set of parameters)
79
+ self.validate_command(command)
80
+
81
+ # Second, you execute business logic to figure out what have happened
82
+ # Business logic code in here
83
+
84
+ # Third - you create an Event which records what have happened
85
+ event = self.ItemAdded(
86
+ metadata=metadata,
87
+ item_name=command.item_name
88
+ )
89
+
90
+ # Finally - you trigger event to launch change of state process
91
+ sef.trigger_event(event)
92
+
93
+ When you trigger event, the aggregate calls 'mutate' method which validates the event and passes it to 'apply_event' method.
94
+ This method must know how to change the state of the aggregate and this is your responsibility to make it happen by reimplementing 'apply_event' method.
95
+ As a rule of thumb there is a simple way to do it:
96
+
97
+ def apply_event(event) -> None:
98
+ # 1. Define routing from event type to event handler
99
+ routing = {
100
+ self.ItemAdded: self._when_item_added,
101
+ }
102
+
103
+ event_handler = routing.get(type(event)) # 2. Take the corresponding event handler from routing
104
+ if event_handler is None:
105
+ super().apply_event(event) # Default implementation will raise error if there is no handler
106
+ else:
107
+ event_handler(event) # Handle event
108
+
109
+ Event handler should change the state of the aggregate:
110
+
111
+ def _when_item_added(event) -> None:
112
+ self._items.append(event.event_name)
113
+
114
+ ACHTUNG - your event handlers MUST NOT contain any business logic, it only should change the state of the aggregate based on what is stated in the event.
115
+
116
+ Next step - application module.
117
+ """
118
+
119
+ # TODO - register aggregate type automatically
120
+ # TODO - event handlers take events as input and change the aggregate state
121
+ # TODO - command handlers take commands as input, validate business logic and trigger events
122
+ # TODO - aggregate state changes should occur only in event handlers, NOT command handlers, how to protect aggregate from occasional state change in command handler???
123
+
124
+ INITIAL_VERSION = 0
125
+
126
+ @dataclass(frozen=True)
127
+ class Created(Created):
128
+ """Use it as a base class for Created events"""
129
+ pass
130
+
131
+ @dataclass(frozen=True)
132
+ class Event(DomainEvent):
133
+ """Use it as a base class for consequtive events"""
134
+ pass
135
+
136
+ def __init__(self, event: Created):
137
+ self._aggregate_id = event.metadata.aggregate_id
138
+ self._aggregate_version = event.metadata.aggregate_version
139
+ self._aggregate_created_on = datetime.fromisoformat(event.metadata.timestamp)
140
+ self._aggregate_modified_on = datetime.fromisoformat(event.metadata.timestamp)
141
+
142
+ self._pending_events: List[DomainEvent] = list()
143
+
144
+ @property
145
+ def aggregate_created_on(self) -> datetime:
146
+ """The date and time when the aggregate was created."""
147
+ return self._aggregate_created_on
148
+
149
+ @property
150
+ def aggregate_id(self) -> str:
151
+ return self._aggregate_id
152
+
153
+ @property
154
+ def aggregate_modified_on(self) -> datetime:
155
+ """The date and time when the aggregate was modified."""
156
+ return self._aggregate_modified_on
157
+
158
+ @property
159
+ def aggregate_type(self) -> str:
160
+ return self.get_aggregate_type()
161
+
162
+ @property
163
+ def aggregate_version(self) -> int:
164
+ return self._aggregate_version
165
+
166
+ @property
167
+ def next_version(self) -> int:
168
+ """Convenience property to get the next version number of aggregate"""
169
+ return self.aggregate_version + 1
170
+
171
+ @property
172
+ def pending_events(self) -> Tuple[DomainEvent, ...]:
173
+ return tuple(self._pending_events)
174
+
175
+ @classmethod
176
+ def _create(cls, event: 'Created') -> 'Aggregate':
177
+ """Actually creates new aggregate. Should be used by factory method implemented on specific aggregate instance."""
178
+ obj = cls(event=event)
179
+ obj._pending_events.append(event)
180
+ return obj
181
+
182
+ def apply_event(self, event: DomainEvent) -> None:
183
+ """
184
+ Reimplement to provide change of state of the aggregate when event occur.
185
+ Each event must have the associated event handler.
186
+ """
187
+ raise TypeError(f'Unknown Event {type(event)}')
188
+
189
+ def clear_events(self) -> None:
190
+ """Clears all pending events on the Aggregate"""
191
+ self._pending_events = list()
192
+
193
+ def collect_events(self) -> List[DomainEvent]:
194
+ """Returns the list of pending events in the aggregate and clears pending events"""
195
+ events = self._pending_events
196
+ self.clear_events()
197
+ return events
198
+
199
+ @staticmethod
200
+ @abstractmethod
201
+ def create_id(*args, **kwargs) -> str:
202
+ """
203
+ Reimplement to create ID of the aggregate.
204
+ Aggregate ID MUST BE UNIQUE accross the same aggregate type.
205
+ """
206
+ raise NotImplementedError
207
+
208
+ @staticmethod
209
+ @abstractmethod
210
+ def get_aggregate_type() -> str:
211
+ """
212
+ Aggregate type should be defined during modelling stage and MUST NOT BE CHANGED during the entire aggregate lifecycle.
213
+ It is used by repository to retreive specific aggregate instances and by event store as prefix to event stream.
214
+ """
215
+ raise NotImplementedError
216
+
217
+ def mutate(self, event: DomainEvent) -> None:
218
+ """
219
+ Performs validation of the event and changes the state of the aggregate by calling 'apply_event'.
220
+ After application of the event modifies the version and modification date of the aggregate.
221
+
222
+ Validation:
223
+ - Event.aggregate_id should be equal to Aggregate.id. Raises AggregateIdError if not.
224
+ - Event.aggregate_version should be EXACTLY ONE more than Aggregate.version. Raises AggregateVersionError if not.
225
+ """
226
+ # Event MUST belong to the aggregate
227
+ if self.aggregate_id != event.metadata.aggregate_id:
228
+ raise AggregateIDMismatch(f'ERROR - {event.__class__.__qualname__} aggregate_id does not match Aggregate ID attribute.')
229
+ # Version of the new event MUST BE EXACTLY ONE MORE than the current aggegate version
230
+ if (event.metadata.aggregate_version - self.aggregate_version) != 1:
231
+ raise AggregateVersionError(f'ERROR - event aggregate version should be exactly one more than the aggregate version.')
232
+
233
+ # Change the state of Aggregate
234
+ self.apply_event(event)
235
+
236
+ # Record new version and modification date
237
+ self._aggregate_version = event.metadata.aggregate_version
238
+ self._aggregate_modified_on = event.metadata.timestamp
239
+
240
+ def trigger_event(self, event: Event) -> None:
241
+ """
242
+ Should be called whenever new event occur during aggregate lifecycle.
243
+ Mutates aggregate state and adds event to the list of pending events
244
+ """
245
+ self.mutate(event)
246
+ self._pending_events.append(event)
@@ -0,0 +1,45 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+ from typing import Tuple
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Checkpoint:
8
+ name: str
9
+ value: int
10
+
11
+ def __post_init__(self):
12
+ if not isinstance(self.name, str):
13
+ raise TypeError('Checkpoint name should a string')
14
+ if not self.name:
15
+ raise ValueError('Checkpoint name should be a non empty string')
16
+ if not isinstance(self.value, int):
17
+ raise TypeError('Checkpoint value should be an integer')
18
+ if self.value < 0:
19
+ raise ValueError('Checkpoint value should be a positive integer')
20
+
21
+
22
+ class CheckpointStore(ABC):
23
+ @abstractmethod
24
+ async def close(self) -> None:
25
+ raise NotImplementedError
26
+
27
+ @abstractmethod
28
+ async def delete_checkpoint(self, name: str) -> None:
29
+ raise NotImplementedError
30
+
31
+ @abstractmethod
32
+ async def get_checkpoint(self, name: str) -> Checkpoint:
33
+ raise NotImplementedError
34
+
35
+ @abstractmethod
36
+ async def initialize(self) -> None:
37
+ raise NotImplementedError
38
+
39
+ @abstractmethod
40
+ async def list_checkpoints(self) -> Tuple[Checkpoint, ...]:
41
+ raise NotImplementedError
42
+
43
+ @abstractmethod
44
+ async def set_checkpoint(self, checkpoint: Checkpoint) -> None:
45
+ raise NotImplementedError