rococo 0.1.2__tar.gz → 0.1.6__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.
- rococo-0.1.6/PKG-INFO +126 -0
- rococo-0.1.6/README.md +111 -0
- rococo-0.1.6/rococo/data/__init__.py +3 -0
- rococo-0.1.6/rococo/data/base.py +13 -0
- rococo-0.1.6/rococo/data/surrealdb.py +46 -0
- rococo-0.1.6/rococo/messaging/__init__.py +3 -0
- rococo-0.1.6/rococo/messaging/base.py +40 -0
- rococo-0.1.6/rococo/messaging/rabbitmq.py +141 -0
- rococo-0.1.6/rococo/messaging/sqs.py +88 -0
- rococo-0.1.6/rococo/models/__init__.py +7 -0
- rococo-0.1.6/rococo/models/login_method.py +15 -0
- rococo-0.1.6/rococo/models/organization.py +10 -0
- rococo-0.1.6/rococo/models/otp_method.py +15 -0
- rococo-0.1.6/rococo/models/person.py +11 -0
- rococo-0.1.6/rococo/models/person_organization_role.py +12 -0
- rococo-0.1.6/rococo/models/versioned_model.py +69 -0
- rococo-0.1.6/rococo.egg-info/PKG-INFO +126 -0
- rococo-0.1.6/rococo.egg-info/SOURCES.txt +23 -0
- rococo-0.1.6/rococo.egg-info/requires.txt +3 -0
- {rococo-0.1.2 → rococo-0.1.6}/setup.py +8 -3
- rococo-0.1.2/PKG-INFO +0 -51
- rococo-0.1.2/README.md +0 -39
- rococo-0.1.2/rococo.egg-info/PKG-INFO +0 -51
- rococo-0.1.2/rococo.egg-info/SOURCES.txt +0 -8
- {rococo-0.1.2 → rococo-0.1.6}/LICENSE +0 -0
- {rococo-0.1.2 → rococo-0.1.6}/rococo/__init__.py +0 -0
- {rococo-0.1.2 → rococo-0.1.6}/rococo.egg-info/dependency_links.txt +0 -0
- {rococo-0.1.2 → rococo-0.1.6}/rococo.egg-info/top_level.txt +0 -0
- {rococo-0.1.2 → rococo-0.1.6}/setup.cfg +0 -0
rococo-0.1.6/PKG-INFO
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: rococo
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: A Python library to help build things the way we want them built
|
|
5
|
+
Home-page: https://github.com/EcorRouge/rococo
|
|
6
|
+
Author: Jay Grieves
|
|
7
|
+
Author-email: jaygrieves@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: surrealdb==0.3.1
|
|
13
|
+
Requires-Dist: boto3==1.28.55
|
|
14
|
+
Requires-Dist: pika==1.3.2
|
|
15
|
+
|
|
16
|
+
# rococo
|
|
17
|
+
A Python library to help build things the way we want them built.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## Basic Usage
|
|
21
|
+
|
|
22
|
+
### Installation
|
|
23
|
+
|
|
24
|
+
Install using pip:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install rococo
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Example
|
|
31
|
+
|
|
32
|
+
#### Models
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from rococo.models import Person
|
|
36
|
+
|
|
37
|
+
# Initialize a Person object from rococo's built-in models.
|
|
38
|
+
someone = Person(first_name="John", last_name="Doe")
|
|
39
|
+
|
|
40
|
+
# Prepare to save the object in the database adding/updating attributes for the object.
|
|
41
|
+
someone.prepare_for_save(changed_by_id="jane_doe")
|
|
42
|
+
|
|
43
|
+
someone.as_dict()
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
'active': True,
|
|
47
|
+
'changed_by_id': 'jane_doe',
|
|
48
|
+
'changed_on': datetime.datetime(2023, 9, 20, 19, 50, 23, 532875),
|
|
49
|
+
'entity_id': 'e06876705b364640a20efc165f6ffb76',
|
|
50
|
+
'first_name': 'John',
|
|
51
|
+
'last_name': 'Doe',
|
|
52
|
+
'previous_version': '7e63a5d0aa0f43b5aa9c8cc0634c41f2',
|
|
53
|
+
'version': '08489d2bc5d74f78b7af0f2c1d9c5498'
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
#### Messaging
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
##### RabbitMQ
|
|
61
|
+
```python
|
|
62
|
+
# Producer
|
|
63
|
+
from rococo.messaging import RabbitMqConnection
|
|
64
|
+
|
|
65
|
+
with RabbitMqConnection('host', 'port', 'username', 'password', 'virtual_host') as conn:
|
|
66
|
+
conn.send_message('queue_name', {'message': 'data'})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Consumer
|
|
70
|
+
from rococo.messaging import RabbitMqConnection
|
|
71
|
+
|
|
72
|
+
def process_message(message_data: dict):
|
|
73
|
+
print(f"Processing message {message_data}...")
|
|
74
|
+
|
|
75
|
+
with RabbitMqConnection('host', 'port', 'username', 'password', 'virtual_host') as conn:
|
|
76
|
+
conn.consume_messages('queue_name', process_message)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
##### SQS
|
|
80
|
+
```python
|
|
81
|
+
# Producer
|
|
82
|
+
from rococo.messaging import SqsConnection
|
|
83
|
+
|
|
84
|
+
with SqsConnection(region_name='us-east-1') as conn:
|
|
85
|
+
conn.send_message('queue_name', {'message': 'data'})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Consumer
|
|
89
|
+
from rococo.messaging import SqsConnection
|
|
90
|
+
|
|
91
|
+
def process_message(message_data: dict):
|
|
92
|
+
print(f"Processing message {message_data}...")
|
|
93
|
+
|
|
94
|
+
with SqsConnection(region_name='us-east-1') as conn:
|
|
95
|
+
conn.consume_messages('queue_name', process_message)
|
|
96
|
+
|
|
97
|
+
# Note: since cleanup is not required for SQS connections, you can also do:
|
|
98
|
+
conn = SqsConnection(region_name='us-east-1')
|
|
99
|
+
conn.send_message('queue_name', {'message': 'data'})
|
|
100
|
+
conn.consume_messages('queue_name', process_message)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### Data
|
|
104
|
+
|
|
105
|
+
##### SurrealDB
|
|
106
|
+
```python
|
|
107
|
+
from rococo.data import SurrealDbAdapter
|
|
108
|
+
|
|
109
|
+
def get_db_connection():
|
|
110
|
+
endpoint = "ws://localhost:8000/rpc"
|
|
111
|
+
username = "myuser"
|
|
112
|
+
password = "mypassword"
|
|
113
|
+
namespace = "test"
|
|
114
|
+
database = "test"
|
|
115
|
+
|
|
116
|
+
return SurrealDbAdapter(endpoint, username, password, namespace, database)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
with get_db_connection() as db:
|
|
120
|
+
db.execute_query("""insert into person {
|
|
121
|
+
user: 'me',
|
|
122
|
+
pass: 'very_safe',
|
|
123
|
+
tags: ['python', 'documentation']
|
|
124
|
+
};""")
|
|
125
|
+
print(db.execute_query("SELECT * FROM person;", {}))
|
|
126
|
+
```
|
rococo-0.1.6/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# rococo
|
|
2
|
+
A Python library to help build things the way we want them built.
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
## Basic Usage
|
|
6
|
+
|
|
7
|
+
### Installation
|
|
8
|
+
|
|
9
|
+
Install using pip:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install rococo
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### Example
|
|
16
|
+
|
|
17
|
+
#### Models
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from rococo.models import Person
|
|
21
|
+
|
|
22
|
+
# Initialize a Person object from rococo's built-in models.
|
|
23
|
+
someone = Person(first_name="John", last_name="Doe")
|
|
24
|
+
|
|
25
|
+
# Prepare to save the object in the database adding/updating attributes for the object.
|
|
26
|
+
someone.prepare_for_save(changed_by_id="jane_doe")
|
|
27
|
+
|
|
28
|
+
someone.as_dict()
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
'active': True,
|
|
32
|
+
'changed_by_id': 'jane_doe',
|
|
33
|
+
'changed_on': datetime.datetime(2023, 9, 20, 19, 50, 23, 532875),
|
|
34
|
+
'entity_id': 'e06876705b364640a20efc165f6ffb76',
|
|
35
|
+
'first_name': 'John',
|
|
36
|
+
'last_name': 'Doe',
|
|
37
|
+
'previous_version': '7e63a5d0aa0f43b5aa9c8cc0634c41f2',
|
|
38
|
+
'version': '08489d2bc5d74f78b7af0f2c1d9c5498'
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
#### Messaging
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
##### RabbitMQ
|
|
46
|
+
```python
|
|
47
|
+
# Producer
|
|
48
|
+
from rococo.messaging import RabbitMqConnection
|
|
49
|
+
|
|
50
|
+
with RabbitMqConnection('host', 'port', 'username', 'password', 'virtual_host') as conn:
|
|
51
|
+
conn.send_message('queue_name', {'message': 'data'})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Consumer
|
|
55
|
+
from rococo.messaging import RabbitMqConnection
|
|
56
|
+
|
|
57
|
+
def process_message(message_data: dict):
|
|
58
|
+
print(f"Processing message {message_data}...")
|
|
59
|
+
|
|
60
|
+
with RabbitMqConnection('host', 'port', 'username', 'password', 'virtual_host') as conn:
|
|
61
|
+
conn.consume_messages('queue_name', process_message)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
##### SQS
|
|
65
|
+
```python
|
|
66
|
+
# Producer
|
|
67
|
+
from rococo.messaging import SqsConnection
|
|
68
|
+
|
|
69
|
+
with SqsConnection(region_name='us-east-1') as conn:
|
|
70
|
+
conn.send_message('queue_name', {'message': 'data'})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Consumer
|
|
74
|
+
from rococo.messaging import SqsConnection
|
|
75
|
+
|
|
76
|
+
def process_message(message_data: dict):
|
|
77
|
+
print(f"Processing message {message_data}...")
|
|
78
|
+
|
|
79
|
+
with SqsConnection(region_name='us-east-1') as conn:
|
|
80
|
+
conn.consume_messages('queue_name', process_message)
|
|
81
|
+
|
|
82
|
+
# Note: since cleanup is not required for SQS connections, you can also do:
|
|
83
|
+
conn = SqsConnection(region_name='us-east-1')
|
|
84
|
+
conn.send_message('queue_name', {'message': 'data'})
|
|
85
|
+
conn.consume_messages('queue_name', process_message)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
#### Data
|
|
89
|
+
|
|
90
|
+
##### SurrealDB
|
|
91
|
+
```python
|
|
92
|
+
from rococo.data import SurrealDbAdapter
|
|
93
|
+
|
|
94
|
+
def get_db_connection():
|
|
95
|
+
endpoint = "ws://localhost:8000/rpc"
|
|
96
|
+
username = "myuser"
|
|
97
|
+
password = "mypassword"
|
|
98
|
+
namespace = "test"
|
|
99
|
+
database = "test"
|
|
100
|
+
|
|
101
|
+
return SurrealDbAdapter(endpoint, username, password, namespace, database)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
with get_db_connection() as db:
|
|
105
|
+
db.execute_query("""insert into person {
|
|
106
|
+
user: 'me',
|
|
107
|
+
pass: 'very_safe',
|
|
108
|
+
tags: ['python', 'documentation']
|
|
109
|
+
};""")
|
|
110
|
+
print(db.execute_query("SELECT * FROM person;", {}))
|
|
111
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from surrealdb import Surreal
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SurrealDbAdapter():
|
|
6
|
+
"""SurrealDB adapter for interacting with SurrealDB."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, endpoint: str, username: str, password: str, namespace: str, db_name: str):
|
|
9
|
+
"""Initializes a new SurrealDB adapter."""
|
|
10
|
+
self._endpoint = endpoint
|
|
11
|
+
self._username = username
|
|
12
|
+
self._password = password
|
|
13
|
+
self._namespace = namespace
|
|
14
|
+
self._db_name = db_name
|
|
15
|
+
self._db = None
|
|
16
|
+
|
|
17
|
+
def __enter__(self):
|
|
18
|
+
"""Context manager entry point for preparing DB connection."""
|
|
19
|
+
self._event_loop = asyncio.new_event_loop()
|
|
20
|
+
self._db = self._event_loop.run_until_complete(self._prepare_db())
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
24
|
+
"""Context manager exit point for closing DB connection."""
|
|
25
|
+
self._event_loop.run_until_complete(self._db.close())
|
|
26
|
+
self._event_loop.stop()
|
|
27
|
+
self._event_loop = None
|
|
28
|
+
self._db = None
|
|
29
|
+
|
|
30
|
+
async def _prepare_db(self):
|
|
31
|
+
"""Prepares the DB connection."""
|
|
32
|
+
db = Surreal(self._endpoint)
|
|
33
|
+
await db.connect()
|
|
34
|
+
await db.signin({"user": self._username, "pass": self._password})
|
|
35
|
+
await db.use(self._namespace, self._db_name)
|
|
36
|
+
return db
|
|
37
|
+
|
|
38
|
+
def execute_query(self, sql, _vars=None):
|
|
39
|
+
"""Executes a query against the DB."""
|
|
40
|
+
if _vars is None:
|
|
41
|
+
_vars = {}
|
|
42
|
+
|
|
43
|
+
if not self._db:
|
|
44
|
+
raise Exception("No connection to SurrealDB.")
|
|
45
|
+
|
|
46
|
+
return self._event_loop.run_until_complete(self._db.query(sql, _vars))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# An abstract Connection class that enforces the implementation of the send_message and consume_messages methods.
|
|
5
|
+
|
|
6
|
+
class Connection:
|
|
7
|
+
"""Abstract class for a connection to a message queue."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def send_message(self, queue_name: str, message: dict):
|
|
11
|
+
"""
|
|
12
|
+
Sends a message to the specified queue.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
queue_name (str): The name of the queue to send the message to.
|
|
16
|
+
message (dict): The message to send.
|
|
17
|
+
"""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def consume_messages(self, queue_name: str, callback_function: callable):
|
|
22
|
+
"""
|
|
23
|
+
Consumes messages from the specified queue.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
queue_name (str): The name of the queue to consume messages from.
|
|
27
|
+
callback_function (callable): The function to call when a message is received.
|
|
28
|
+
"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def __enter__(self):
|
|
34
|
+
"""Performs any initialization required for the connection."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
39
|
+
"""Performs any cleanup required for the connection."""
|
|
40
|
+
pass
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pika
|
|
3
|
+
import functools
|
|
4
|
+
import threading
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from . import Connection
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
class RabbitMqConnection(Connection):
|
|
13
|
+
"""A connection to a RabbitMQ message queue that allows to send and receive messages."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def __init__(self, host: str, port: int, username: str, password: str, virtual_host: str = ''):
|
|
17
|
+
"""
|
|
18
|
+
Initializes a new RabbitMQ connection.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
host (str): The host of the RabbitMQ server.
|
|
22
|
+
port (int): The port of the RabbitMQ server.
|
|
23
|
+
username (str): The username to use when connecting to the RabbitMQ server.
|
|
24
|
+
password (str): The password to use when connecting to the RabbitMQ server.
|
|
25
|
+
virtual_host (str): The virtual host to use when connecting to the RabbitMQ server.
|
|
26
|
+
"""
|
|
27
|
+
self._host = host
|
|
28
|
+
self._port = port
|
|
29
|
+
self._username = username
|
|
30
|
+
self._password = password
|
|
31
|
+
self._virtual_host = virtual_host
|
|
32
|
+
|
|
33
|
+
self._connection = None
|
|
34
|
+
self._channel = None
|
|
35
|
+
|
|
36
|
+
def __enter__(self):
|
|
37
|
+
"""
|
|
38
|
+
Opens a connection to the RabbitMQ server.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
RabbitMqConnection: The connection to the RabbitMQ server.
|
|
42
|
+
"""
|
|
43
|
+
self._connection = pika.BlockingConnection(
|
|
44
|
+
pika.ConnectionParameters(host=self._host, port=self._port, credentials=pika.PlainCredentials(self._username, self._password),
|
|
45
|
+
virtual_host=self._virtual_host))
|
|
46
|
+
self._channel = self._connection.channel()
|
|
47
|
+
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
51
|
+
"""
|
|
52
|
+
Closes the connection to the RabbitMQ server.
|
|
53
|
+
"""
|
|
54
|
+
self._connection.close()
|
|
55
|
+
self._connection = None
|
|
56
|
+
self._channel = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def send_message(self, queue_name: str, message: dict):
|
|
60
|
+
"""
|
|
61
|
+
Sends a message to the specified queue.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
queue_name (str): The name of the queue to send the message to.
|
|
65
|
+
message (dict): The message to send.
|
|
66
|
+
"""
|
|
67
|
+
self._channel.basic_publish(exchange='', routing_key=queue_name, body=json.dumps(message))
|
|
68
|
+
|
|
69
|
+
def consume_messages(self, queue_name: str, callback_function: Callable[[dict], bool], num_threads: int = 1):
|
|
70
|
+
"""
|
|
71
|
+
Consumes messages from the specified queue.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
queue_name (str): The name of the queue to consume messages from.
|
|
75
|
+
callback_function (callable): The function to call when a message is received.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def _ack_message(ch, delivery_tag):
|
|
79
|
+
"""Ack a message by its delivery tag if channel is open."""
|
|
80
|
+
|
|
81
|
+
if ch.is_open:
|
|
82
|
+
ch.basic_ack(delivery_tag)
|
|
83
|
+
|
|
84
|
+
def _do_work(ch, delivery_tag, body, callback):
|
|
85
|
+
"""Callback function that processes the message."""
|
|
86
|
+
|
|
87
|
+
thread_id = threading.get_ident()
|
|
88
|
+
logger.info(
|
|
89
|
+
"Thread id: %s Delivery tag: %s Message body: %s",
|
|
90
|
+
thread_id,
|
|
91
|
+
delivery_tag,
|
|
92
|
+
body,
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
success = callback(body)
|
|
96
|
+
except Exception:
|
|
97
|
+
logger.exception("Error processing message...")
|
|
98
|
+
success = False
|
|
99
|
+
logger.info(
|
|
100
|
+
"Thread id: %s Delivery tag: %s Message body: %s Processed...",
|
|
101
|
+
thread_id,
|
|
102
|
+
delivery_tag,
|
|
103
|
+
body,
|
|
104
|
+
)
|
|
105
|
+
cb = functools.partial(_ack_message, ch, delivery_tag)
|
|
106
|
+
logger.info(f"Sent ack for Delivery tag {delivery_tag}...")
|
|
107
|
+
self._connection.add_callback_threadsafe(cb)
|
|
108
|
+
|
|
109
|
+
def _on_message(ch, method_frame, _header_frame, body, args):
|
|
110
|
+
"""Called when a message is received."""
|
|
111
|
+
|
|
112
|
+
body = json.loads(body.decode())
|
|
113
|
+
(callback,) = args
|
|
114
|
+
delivery_tag = method_frame.delivery_tag
|
|
115
|
+
t = threading.Thread(
|
|
116
|
+
target=_do_work, args=(ch, delivery_tag, body, callback)
|
|
117
|
+
)
|
|
118
|
+
t.start()
|
|
119
|
+
self._threads.append(t)
|
|
120
|
+
|
|
121
|
+
self._channel.queue_declare(queue=queue_name, durable=True)
|
|
122
|
+
|
|
123
|
+
self._threads = []
|
|
124
|
+
self._channel.basic_qos(prefetch_count=num_threads)
|
|
125
|
+
|
|
126
|
+
on_message_callback = functools.partial(_on_message, args=(callback_function,))
|
|
127
|
+
self._channel.basic_consume(
|
|
128
|
+
queue=queue_name, on_message_callback=on_message_callback
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
logger.info(f"Listening to RabbitMQ queue {queue_name} on {self._host}:{self._port} with {num_threads} threads...")
|
|
133
|
+
self._channel.start_consuming()
|
|
134
|
+
except KeyboardInterrupt:
|
|
135
|
+
logger.info("Exiting gracefully...")
|
|
136
|
+
self._channel.stop_consuming()
|
|
137
|
+
finally:
|
|
138
|
+
# Wait for all to complete
|
|
139
|
+
for thread in self._threads:
|
|
140
|
+
thread.join()
|
|
141
|
+
self._connection.close()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import boto3
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from . import Connection
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class SqsConnection(Connection):
|
|
11
|
+
"""A connection to AWS SQS that allows sending and receiving messages to and from queues."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, aws_access_key_id: str = None, aws_access_key_secret: str = None, region_name: str = None):
|
|
14
|
+
"""Initializes a new SQS connection.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
aws_access_key_id (str): The AWS access key ID.
|
|
18
|
+
aws_access_key_secret (str): The AWS access key secret.
|
|
19
|
+
region_name (str): The AWS region name.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
self._aws_access_key_id = aws_access_key_id
|
|
23
|
+
self._aws_access_key_secret = aws_access_key_secret
|
|
24
|
+
self._region_name = region_name
|
|
25
|
+
self._sqs = boto3.resource('sqs', aws_access_key_id=self._aws_access_key_id, aws_secret_access_key=self._aws_access_key_secret, region_name=self._region_name)
|
|
26
|
+
self._queue_map = {}
|
|
27
|
+
|
|
28
|
+
def __enter__(self):
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def send_message(self, queue_name: str, message: dict):
|
|
35
|
+
"""Sends a message to the specified SQS queue.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
queue_name (str): The name of the queue to send the message to.
|
|
39
|
+
message (dict): The message to send.
|
|
40
|
+
"""
|
|
41
|
+
if queue_name in self._queue_map:
|
|
42
|
+
queue = self._queue_map[queue_name]
|
|
43
|
+
else:
|
|
44
|
+
queue = self._sqs.get_queue_by_name(QueueName=queue_name)
|
|
45
|
+
|
|
46
|
+
queue.send_message(QueueUrl=queue_name, MessageBody=json.dumps(message))
|
|
47
|
+
|
|
48
|
+
def consume_messages(self, queue_name: str, callback_function: callable):
|
|
49
|
+
"""Consumes messages from the specified SQS queue.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
queue_name (str): The name of the queue to consume messages from.
|
|
53
|
+
callback_function (callable): The function to call when a message is received.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def _delete_queue_message(queue, handle):
|
|
57
|
+
logger.info("Deleting message.")
|
|
58
|
+
queue.delete_messages(
|
|
59
|
+
Entries=[
|
|
60
|
+
{
|
|
61
|
+
'Id': str(uuid.uuid4()),
|
|
62
|
+
'ReceiptHandle': handle
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
logger.info(f"Connecting to SQS queue: {queue_name}...")
|
|
68
|
+
queue = self._sqs.get_queue_by_name(QueueName=queue_name)
|
|
69
|
+
|
|
70
|
+
while True:
|
|
71
|
+
logger.info(f"Fetching messages from SQS queue: {queue_name}...")
|
|
72
|
+
responses = queue.receive_messages(
|
|
73
|
+
AttributeNames=['All'],
|
|
74
|
+
MaxNumberOfMessages=1,
|
|
75
|
+
WaitTimeSeconds=20
|
|
76
|
+
)
|
|
77
|
+
if not responses:
|
|
78
|
+
logger.info("No messages left in queue.")
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
response = {}
|
|
82
|
+
try:
|
|
83
|
+
response = responses[0]
|
|
84
|
+
body = json.loads(response.body)
|
|
85
|
+
callback_function(body)
|
|
86
|
+
except Exception as _:
|
|
87
|
+
logger.exception("Error processing message...")
|
|
88
|
+
_delete_queue_message(queue, response.receipt_handle)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from . import VersionedModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class LoginMethod(VersionedModel):
|
|
9
|
+
"""A login method model."""
|
|
10
|
+
|
|
11
|
+
person_id: str
|
|
12
|
+
method_type: str
|
|
13
|
+
method_data: Optional[dict]
|
|
14
|
+
email: Optional[str]
|
|
15
|
+
password: Optional[str]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from . import VersionedModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class OtpMethod(VersionedModel):
|
|
9
|
+
"""An OTP method model."""
|
|
10
|
+
|
|
11
|
+
person_id: str
|
|
12
|
+
secret: str
|
|
13
|
+
name: str
|
|
14
|
+
enabled: bool
|
|
15
|
+
recovery_codes: List[str]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from uuid import uuid4
|
|
2
|
+
from dataclasses import dataclass, field, fields
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(kw_only=True)
|
|
8
|
+
class VersionedModel:
|
|
9
|
+
"""A base class for versioned models with common (Big 6) attributes."""
|
|
10
|
+
|
|
11
|
+
entity_id: str = uuid4().hex
|
|
12
|
+
version: str = uuid4().hex
|
|
13
|
+
previous_version: str = '00000000000000000000000000000000'
|
|
14
|
+
active: bool = True
|
|
15
|
+
changed_by_id: str = '00000000000000000000000000000000'
|
|
16
|
+
changed_on: datetime = field(default_factory=lambda: datetime.utcnow())
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def fields(cls) -> List[str]:
|
|
20
|
+
"""Get a list of field names for this model.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
List[str]: A list of field names.
|
|
24
|
+
"""
|
|
25
|
+
return [f.name for f in fields(cls)]
|
|
26
|
+
|
|
27
|
+
def as_dict(self, convert_datetime_to_iso_string: bool = False) -> Dict[str, Any]:
|
|
28
|
+
"""Convert this model to a dictionary.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
convert_datetime_to_iso_string (bool, optional): Whether to convert datetime objects to ISO strings. Defaults to False.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Dict[str, Any]: A dictionary representation of this model.
|
|
35
|
+
"""
|
|
36
|
+
results = self.__dict__
|
|
37
|
+
|
|
38
|
+
if convert_datetime_to_iso_string:
|
|
39
|
+
for key, value in results.items():
|
|
40
|
+
if isinstance(value, datetime):
|
|
41
|
+
results[key] = value.isoformat()
|
|
42
|
+
|
|
43
|
+
return results
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, model_dict: dict):
|
|
47
|
+
"""Create a model from a dictionary.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
model_dict (dict): A dictionary representation of a model.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
VersionedModel: A model object.
|
|
54
|
+
"""
|
|
55
|
+
for key in model_dict:
|
|
56
|
+
if key in cls.__dict__:
|
|
57
|
+
cls.__dict__[key] = model_dict[key]
|
|
58
|
+
|
|
59
|
+
def prepare_for_save(self, changed_by_id: str):
|
|
60
|
+
"""
|
|
61
|
+
Prepare this model for saving to the database.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
changed_by_id (str): The ID of the user making the change.
|
|
65
|
+
"""
|
|
66
|
+
self.previous_version = self.version
|
|
67
|
+
self.version = uuid4().hex
|
|
68
|
+
self.changed_on = datetime.utcnow()
|
|
69
|
+
self.changed_by_id = changed_by_id
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: rococo
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: A Python library to help build things the way we want them built
|
|
5
|
+
Home-page: https://github.com/EcorRouge/rococo
|
|
6
|
+
Author: Jay Grieves
|
|
7
|
+
Author-email: jaygrieves@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: surrealdb==0.3.1
|
|
13
|
+
Requires-Dist: boto3==1.28.55
|
|
14
|
+
Requires-Dist: pika==1.3.2
|
|
15
|
+
|
|
16
|
+
# rococo
|
|
17
|
+
A Python library to help build things the way we want them built.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## Basic Usage
|
|
21
|
+
|
|
22
|
+
### Installation
|
|
23
|
+
|
|
24
|
+
Install using pip:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install rococo
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Example
|
|
31
|
+
|
|
32
|
+
#### Models
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from rococo.models import Person
|
|
36
|
+
|
|
37
|
+
# Initialize a Person object from rococo's built-in models.
|
|
38
|
+
someone = Person(first_name="John", last_name="Doe")
|
|
39
|
+
|
|
40
|
+
# Prepare to save the object in the database adding/updating attributes for the object.
|
|
41
|
+
someone.prepare_for_save(changed_by_id="jane_doe")
|
|
42
|
+
|
|
43
|
+
someone.as_dict()
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
'active': True,
|
|
47
|
+
'changed_by_id': 'jane_doe',
|
|
48
|
+
'changed_on': datetime.datetime(2023, 9, 20, 19, 50, 23, 532875),
|
|
49
|
+
'entity_id': 'e06876705b364640a20efc165f6ffb76',
|
|
50
|
+
'first_name': 'John',
|
|
51
|
+
'last_name': 'Doe',
|
|
52
|
+
'previous_version': '7e63a5d0aa0f43b5aa9c8cc0634c41f2',
|
|
53
|
+
'version': '08489d2bc5d74f78b7af0f2c1d9c5498'
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
#### Messaging
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
##### RabbitMQ
|
|
61
|
+
```python
|
|
62
|
+
# Producer
|
|
63
|
+
from rococo.messaging import RabbitMqConnection
|
|
64
|
+
|
|
65
|
+
with RabbitMqConnection('host', 'port', 'username', 'password', 'virtual_host') as conn:
|
|
66
|
+
conn.send_message('queue_name', {'message': 'data'})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Consumer
|
|
70
|
+
from rococo.messaging import RabbitMqConnection
|
|
71
|
+
|
|
72
|
+
def process_message(message_data: dict):
|
|
73
|
+
print(f"Processing message {message_data}...")
|
|
74
|
+
|
|
75
|
+
with RabbitMqConnection('host', 'port', 'username', 'password', 'virtual_host') as conn:
|
|
76
|
+
conn.consume_messages('queue_name', process_message)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
##### SQS
|
|
80
|
+
```python
|
|
81
|
+
# Producer
|
|
82
|
+
from rococo.messaging import SqsConnection
|
|
83
|
+
|
|
84
|
+
with SqsConnection(region_name='us-east-1') as conn:
|
|
85
|
+
conn.send_message('queue_name', {'message': 'data'})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Consumer
|
|
89
|
+
from rococo.messaging import SqsConnection
|
|
90
|
+
|
|
91
|
+
def process_message(message_data: dict):
|
|
92
|
+
print(f"Processing message {message_data}...")
|
|
93
|
+
|
|
94
|
+
with SqsConnection(region_name='us-east-1') as conn:
|
|
95
|
+
conn.consume_messages('queue_name', process_message)
|
|
96
|
+
|
|
97
|
+
# Note: since cleanup is not required for SQS connections, you can also do:
|
|
98
|
+
conn = SqsConnection(region_name='us-east-1')
|
|
99
|
+
conn.send_message('queue_name', {'message': 'data'})
|
|
100
|
+
conn.consume_messages('queue_name', process_message)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### Data
|
|
104
|
+
|
|
105
|
+
##### SurrealDB
|
|
106
|
+
```python
|
|
107
|
+
from rococo.data import SurrealDbAdapter
|
|
108
|
+
|
|
109
|
+
def get_db_connection():
|
|
110
|
+
endpoint = "ws://localhost:8000/rpc"
|
|
111
|
+
username = "myuser"
|
|
112
|
+
password = "mypassword"
|
|
113
|
+
namespace = "test"
|
|
114
|
+
database = "test"
|
|
115
|
+
|
|
116
|
+
return SurrealDbAdapter(endpoint, username, password, namespace, database)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
with get_db_connection() as db:
|
|
120
|
+
db.execute_query("""insert into person {
|
|
121
|
+
user: 'me',
|
|
122
|
+
pass: 'very_safe',
|
|
123
|
+
tags: ['python', 'documentation']
|
|
124
|
+
};""")
|
|
125
|
+
print(db.execute_query("SELECT * FROM person;", {}))
|
|
126
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
setup.py
|
|
4
|
+
rococo/__init__.py
|
|
5
|
+
rococo.egg-info/PKG-INFO
|
|
6
|
+
rococo.egg-info/SOURCES.txt
|
|
7
|
+
rococo.egg-info/dependency_links.txt
|
|
8
|
+
rococo.egg-info/requires.txt
|
|
9
|
+
rococo.egg-info/top_level.txt
|
|
10
|
+
rococo/data/__init__.py
|
|
11
|
+
rococo/data/base.py
|
|
12
|
+
rococo/data/surrealdb.py
|
|
13
|
+
rococo/messaging/__init__.py
|
|
14
|
+
rococo/messaging/base.py
|
|
15
|
+
rococo/messaging/rabbitmq.py
|
|
16
|
+
rococo/messaging/sqs.py
|
|
17
|
+
rococo/models/__init__.py
|
|
18
|
+
rococo/models/login_method.py
|
|
19
|
+
rococo/models/organization.py
|
|
20
|
+
rococo/models/otp_method.py
|
|
21
|
+
rococo/models/person.py
|
|
22
|
+
rococo/models/person_organization_role.py
|
|
23
|
+
rococo/models/versioned_model.py
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
from setuptools import setup
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name='rococo',
|
|
5
|
-
version='0.1.
|
|
6
|
-
packages=
|
|
5
|
+
version='0.1.6',
|
|
6
|
+
packages=find_packages(),
|
|
7
7
|
url='https://github.com/EcorRouge/rococo',
|
|
8
8
|
license='MIT',
|
|
9
9
|
author='Jay Grieves',
|
|
@@ -11,5 +11,10 @@ setup(
|
|
|
11
11
|
description='A Python library to help build things the way we want them built',
|
|
12
12
|
long_description=open('README.md').read(),
|
|
13
13
|
long_description_content_type='text/markdown',
|
|
14
|
+
install_requires=[
|
|
15
|
+
'surrealdb==0.3.1',
|
|
16
|
+
'boto3==1.28.55',
|
|
17
|
+
'pika==1.3.2'
|
|
18
|
+
],
|
|
14
19
|
python_requires=">=3.10"
|
|
15
20
|
)
|
rococo-0.1.2/PKG-INFO
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: rococo
|
|
3
|
-
Version: 0.1.2
|
|
4
|
-
Summary: A Python library to help build things the way we want them built
|
|
5
|
-
Home-page: https://github.com/EcorRouge/rococo
|
|
6
|
-
Author: Jay Grieves
|
|
7
|
-
Author-email: jaygrieves@gmail.com
|
|
8
|
-
License: MIT
|
|
9
|
-
Requires-Python: >=3.10
|
|
10
|
-
Description-Content-Type: text/markdown
|
|
11
|
-
License-File: LICENSE
|
|
12
|
-
|
|
13
|
-
# rococo
|
|
14
|
-
A Python library to help build things the way we want them built.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
## Basic Usage
|
|
18
|
-
|
|
19
|
-
### Installation
|
|
20
|
-
|
|
21
|
-
Install using pip:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
pip install rococo
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
### Example
|
|
28
|
-
|
|
29
|
-
```python
|
|
30
|
-
from rococo.models import Person
|
|
31
|
-
|
|
32
|
-
# Initialize a Person object from rococo's built-in models.
|
|
33
|
-
someone = Person(first_name="John", last_name="Doe")
|
|
34
|
-
|
|
35
|
-
# Prepare to save the object in the database adding/updating attributes for the object.
|
|
36
|
-
someone.prepare_for_save(changed_by_id="jane_doe")
|
|
37
|
-
|
|
38
|
-
someone.as_dict()
|
|
39
|
-
|
|
40
|
-
{
|
|
41
|
-
'active': True,
|
|
42
|
-
'changed_by_id': 'jane_doe',
|
|
43
|
-
'changed_on': datetime.datetime(2023, 9, 20, 19, 50, 23, 532875),
|
|
44
|
-
'entity_id': 'e06876705b364640a20efc165f6ffb76',
|
|
45
|
-
'first_name': 'John',
|
|
46
|
-
'last_name': 'Doe',
|
|
47
|
-
'previous_version': '7e63a5d0aa0f43b5aa9c8cc0634c41f2',
|
|
48
|
-
'version': '08489d2bc5d74f78b7af0f2c1d9c5498'
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
rococo-0.1.2/README.md
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# rococo
|
|
2
|
-
A Python library to help build things the way we want them built.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## Basic Usage
|
|
6
|
-
|
|
7
|
-
### Installation
|
|
8
|
-
|
|
9
|
-
Install using pip:
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
pip install rococo
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
### Example
|
|
16
|
-
|
|
17
|
-
```python
|
|
18
|
-
from rococo.models import Person
|
|
19
|
-
|
|
20
|
-
# Initialize a Person object from rococo's built-in models.
|
|
21
|
-
someone = Person(first_name="John", last_name="Doe")
|
|
22
|
-
|
|
23
|
-
# Prepare to save the object in the database adding/updating attributes for the object.
|
|
24
|
-
someone.prepare_for_save(changed_by_id="jane_doe")
|
|
25
|
-
|
|
26
|
-
someone.as_dict()
|
|
27
|
-
|
|
28
|
-
{
|
|
29
|
-
'active': True,
|
|
30
|
-
'changed_by_id': 'jane_doe',
|
|
31
|
-
'changed_on': datetime.datetime(2023, 9, 20, 19, 50, 23, 532875),
|
|
32
|
-
'entity_id': 'e06876705b364640a20efc165f6ffb76',
|
|
33
|
-
'first_name': 'John',
|
|
34
|
-
'last_name': 'Doe',
|
|
35
|
-
'previous_version': '7e63a5d0aa0f43b5aa9c8cc0634c41f2',
|
|
36
|
-
'version': '08489d2bc5d74f78b7af0f2c1d9c5498'
|
|
37
|
-
}
|
|
38
|
-
```
|
|
39
|
-
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: rococo
|
|
3
|
-
Version: 0.1.2
|
|
4
|
-
Summary: A Python library to help build things the way we want them built
|
|
5
|
-
Home-page: https://github.com/EcorRouge/rococo
|
|
6
|
-
Author: Jay Grieves
|
|
7
|
-
Author-email: jaygrieves@gmail.com
|
|
8
|
-
License: MIT
|
|
9
|
-
Requires-Python: >=3.10
|
|
10
|
-
Description-Content-Type: text/markdown
|
|
11
|
-
License-File: LICENSE
|
|
12
|
-
|
|
13
|
-
# rococo
|
|
14
|
-
A Python library to help build things the way we want them built.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
## Basic Usage
|
|
18
|
-
|
|
19
|
-
### Installation
|
|
20
|
-
|
|
21
|
-
Install using pip:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
pip install rococo
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
### Example
|
|
28
|
-
|
|
29
|
-
```python
|
|
30
|
-
from rococo.models import Person
|
|
31
|
-
|
|
32
|
-
# Initialize a Person object from rococo's built-in models.
|
|
33
|
-
someone = Person(first_name="John", last_name="Doe")
|
|
34
|
-
|
|
35
|
-
# Prepare to save the object in the database adding/updating attributes for the object.
|
|
36
|
-
someone.prepare_for_save(changed_by_id="jane_doe")
|
|
37
|
-
|
|
38
|
-
someone.as_dict()
|
|
39
|
-
|
|
40
|
-
{
|
|
41
|
-
'active': True,
|
|
42
|
-
'changed_by_id': 'jane_doe',
|
|
43
|
-
'changed_on': datetime.datetime(2023, 9, 20, 19, 50, 23, 532875),
|
|
44
|
-
'entity_id': 'e06876705b364640a20efc165f6ffb76',
|
|
45
|
-
'first_name': 'John',
|
|
46
|
-
'last_name': 'Doe',
|
|
47
|
-
'previous_version': '7e63a5d0aa0f43b5aa9c8cc0634c41f2',
|
|
48
|
-
'version': '08489d2bc5d74f78b7af0f2c1d9c5498'
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|