rule-engine-core 0.0.2__tar.gz → 0.0.4__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.
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/PKG-INFO +1 -1
- rule_engine_core-0.0.4/rule_engine_core/db_pool.py +102 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core/entity_dao.py +34 -47
- rule_engine_core-0.0.4/rule_engine_core/metadata_dao.py +80 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core/metadata_store.py +37 -1
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core.egg-info/PKG-INFO +1 -1
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core.egg-info/SOURCES.txt +4 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/setup.py +1 -1
- rule_engine_core-0.0.4/tests/test_metadata_dao.py +79 -0
- rule_engine_core-0.0.4/tests/test_metadata_store_api_fetch.py +137 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/tests/test_rule_creation_storage.py +2 -2
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/README.md +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/pyproject.toml +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core/__init__.py +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core/entity_base.py +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core/entity_types.py +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core/filter.py +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core/parser.py +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core/rule.py +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core.egg-info/dependency_links.txt +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core.egg-info/requires.txt +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core.egg-info/top_level.txt +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/setup.cfg +0 -0
- {rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/tests/test_rule_evaluation.py +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thread-safe psycopg2 connection pool, shared by every service in the monorepo.
|
|
3
|
+
|
|
4
|
+
psycopg2 connections are not thread-safe: sharing one connection across the
|
|
5
|
+
FastAPI request threadpool (or a background worker thread) races —
|
|
6
|
+
`cursor already closed`, mid-transaction commits from unrelated handlers, etc.
|
|
7
|
+
The blessed pattern is one connection per unit of work, checked out of this pool.
|
|
8
|
+
|
|
9
|
+
Usage from a request handler (FastAPI dependency):
|
|
10
|
+
|
|
11
|
+
def get_db_connection():
|
|
12
|
+
with pool.acquire() as conn:
|
|
13
|
+
yield conn
|
|
14
|
+
|
|
15
|
+
@app.get(...)
|
|
16
|
+
def handler(conn = Depends(get_db_connection)):
|
|
17
|
+
SomeDao(conn).do_read()
|
|
18
|
+
|
|
19
|
+
Usage from anywhere else (background thread, startup hook):
|
|
20
|
+
|
|
21
|
+
with pool.acquire() as conn:
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
`acquire()` commits the connection on clean exit and rolls back on exception, so
|
|
25
|
+
each scope is its own transaction. Connections are created from the standard
|
|
26
|
+
POSTGRES_* environment variables unless explicit `conn_kwargs` are supplied.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import threading
|
|
33
|
+
from contextlib import contextmanager
|
|
34
|
+
from typing import Generator, Optional
|
|
35
|
+
|
|
36
|
+
from psycopg2.extensions import connection as PgConnection
|
|
37
|
+
from psycopg2.pool import ThreadedConnectionPool
|
|
38
|
+
|
|
39
|
+
_logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _default_conn_kwargs() -> dict:
|
|
43
|
+
"""psycopg2.connect kwargs read from the standard POSTGRES_* env vars.
|
|
44
|
+
|
|
45
|
+
Single source of truth for env-based connection settings — `create_connection`
|
|
46
|
+
(rule_engine_core.entity_dao) builds one-off connections from the same kwargs.
|
|
47
|
+
"""
|
|
48
|
+
return {
|
|
49
|
+
"dbname": os.getenv("POSTGRES_DB"),
|
|
50
|
+
"user": os.getenv("POSTGRES_USER"),
|
|
51
|
+
"password": os.getenv("POSTGRES_PASSWORD"),
|
|
52
|
+
"host": os.getenv("POSTGRES_HOST", "localhost"),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DbPool:
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
min_conn: int = 2,
|
|
60
|
+
max_conn: int = 10,
|
|
61
|
+
conn_kwargs: Optional[dict] = None,
|
|
62
|
+
):
|
|
63
|
+
kwargs = conn_kwargs or _default_conn_kwargs()
|
|
64
|
+
self._pool = ThreadedConnectionPool(min_conn, max_conn, **kwargs)
|
|
65
|
+
# psycopg2's ThreadedConnectionPool raises PoolError on exhaustion instead
|
|
66
|
+
# of blocking. For an HTTP server "503 on burst" is worse than "wait briefly",
|
|
67
|
+
# so we gate acquire() through a semaphore that blocks until a slot frees up.
|
|
68
|
+
self._semaphore = threading.BoundedSemaphore(max_conn)
|
|
69
|
+
_logger.info("DbPool created: min=%d max=%d", min_conn, max_conn)
|
|
70
|
+
|
|
71
|
+
@contextmanager
|
|
72
|
+
def acquire(self) -> Generator[PgConnection, None, None]:
|
|
73
|
+
"""
|
|
74
|
+
Check out a connection, commit on clean exit, roll back on exception,
|
|
75
|
+
always return to the pool. Blocks if all connections are checked out.
|
|
76
|
+
"""
|
|
77
|
+
self._semaphore.acquire()
|
|
78
|
+
try:
|
|
79
|
+
conn = self._pool.getconn()
|
|
80
|
+
try:
|
|
81
|
+
yield conn
|
|
82
|
+
except Exception:
|
|
83
|
+
try:
|
|
84
|
+
conn.rollback()
|
|
85
|
+
except Exception:
|
|
86
|
+
# If rollback itself fails the conn is probably broken; the pool
|
|
87
|
+
# will eventually replace it. Don't mask the original exception.
|
|
88
|
+
_logger.exception("rollback failed during DbPool.acquire teardown")
|
|
89
|
+
raise
|
|
90
|
+
else:
|
|
91
|
+
try:
|
|
92
|
+
conn.commit()
|
|
93
|
+
except Exception:
|
|
94
|
+
_logger.exception("commit failed during DbPool.acquire teardown")
|
|
95
|
+
raise
|
|
96
|
+
finally:
|
|
97
|
+
self._pool.putconn(conn)
|
|
98
|
+
finally:
|
|
99
|
+
self._semaphore.release()
|
|
100
|
+
|
|
101
|
+
def close_all(self) -> None:
|
|
102
|
+
self._pool.closeall()
|
|
@@ -3,14 +3,14 @@ This module defines the EntityDao class.
|
|
|
3
3
|
This is responsible for storing and managing filter and rule entities.
|
|
4
4
|
We will store these in a postgres database.
|
|
5
5
|
"""
|
|
6
|
-
import
|
|
6
|
+
import threading
|
|
7
7
|
from psycopg2.extensions import connection as PgConnection
|
|
8
8
|
import psycopg2
|
|
9
9
|
from rule_engine_core.parser import Node
|
|
10
|
-
import
|
|
11
|
-
from typing import Optional
|
|
10
|
+
import logging
|
|
12
11
|
import pickle
|
|
13
12
|
import sys
|
|
13
|
+
from rule_engine_core.db_pool import _default_conn_kwargs
|
|
14
14
|
from rule_engine_core.entity_types import EntityEnum, EntityNode
|
|
15
15
|
|
|
16
16
|
# Add an alias for backwards compatibility with pickled data
|
|
@@ -22,46 +22,41 @@ from rule_engine_core.entity_base import EntityBase
|
|
|
22
22
|
|
|
23
23
|
_logger = logging.getLogger(__name__)
|
|
24
24
|
|
|
25
|
-
def create_connection(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
connection
|
|
38
|
-
dbname=config["database"],
|
|
39
|
-
user=config["user"],
|
|
40
|
-
password=config["password"],
|
|
41
|
-
host=config["host"],
|
|
42
|
-
)
|
|
43
|
-
# check and log if the connection is successful
|
|
44
|
-
if connection.closed == 0:
|
|
45
|
-
_logger.info("Connection to the database was successful")
|
|
46
|
-
return connection
|
|
47
|
-
else:
|
|
25
|
+
def create_connection() -> PgConnection:
|
|
26
|
+
"""Open a single new psycopg2 connection from the standard POSTGRES_* env vars.
|
|
27
|
+
|
|
28
|
+
The caller owns the returned connection's lifecycle (must close it, or hand it
|
|
29
|
+
to a component that does). Use this for dedicated, long-lived connections owned
|
|
30
|
+
by a single thread (e.g. a background worker) or for one-off scripts/tests.
|
|
31
|
+
|
|
32
|
+
For request-path or otherwise concurrent access, use rule_engine_core.db_pool.DbPool
|
|
33
|
+
instead: it pools connections and scopes one per unit of work, which is what makes
|
|
34
|
+
the access thread-safe.
|
|
35
|
+
"""
|
|
36
|
+
connection = psycopg2.connect(**_default_conn_kwargs())
|
|
37
|
+
if connection.closed != 0:
|
|
48
38
|
_logger.error("Connection to the database failed")
|
|
49
|
-
|
|
39
|
+
raise RuntimeError("Failed to create a connection to the database")
|
|
40
|
+
_logger.info("Connection to the database was successful")
|
|
41
|
+
return connection
|
|
50
42
|
|
|
51
43
|
class EntityDao:
|
|
52
44
|
"""
|
|
53
45
|
This class is a dao for the postgres database which stores the filters and rules.
|
|
54
46
|
"""
|
|
55
|
-
def __init__(self,
|
|
47
|
+
def __init__(self, connection: PgConnection):
|
|
56
48
|
"""
|
|
57
|
-
|
|
49
|
+
Wrap a caller-supplied psycopg2 connection.
|
|
50
|
+
|
|
51
|
+
The connection is borrowed, not owned: this DAO never closes it. Hand it a
|
|
52
|
+
pooled connection (one per request, via DbPool) on the request path, or a
|
|
53
|
+
dedicated connection (via create_connection) for a single-threaded background
|
|
54
|
+
worker. `_lock` serialises this instance's DB access so a single EntityDao is
|
|
55
|
+
safe to share across threads even on one connection — psycopg2 connections are
|
|
56
|
+
not thread-safe on their own.
|
|
58
57
|
"""
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
config = json.load(f)
|
|
62
|
-
self._connection = create_connection(config if config_file_path is not None else None)
|
|
63
|
-
if self._connection is None:
|
|
64
|
-
raise RuntimeError("Failed to create a connection to the database")
|
|
58
|
+
self._connection = connection
|
|
59
|
+
self._lock = threading.Lock()
|
|
65
60
|
|
|
66
61
|
def get_all_entities(self, entity_type: EntityEnum) -> dict[str, EntityBase]:
|
|
67
62
|
"""
|
|
@@ -76,7 +71,7 @@ class EntityDao:
|
|
|
76
71
|
query = "SELECT * FROM rules"
|
|
77
72
|
else:
|
|
78
73
|
raise ValueError(f"Invalid entity type: {entity_type}")
|
|
79
|
-
with self._connection.cursor() as cursor:
|
|
74
|
+
with self._lock, self._connection.cursor() as cursor:
|
|
80
75
|
cursor.execute(query)
|
|
81
76
|
rows = cursor.fetchall()
|
|
82
77
|
entities = {}
|
|
@@ -111,7 +106,7 @@ class EntityDao:
|
|
|
111
106
|
else:
|
|
112
107
|
raise ValueError(f"Invalid entity type: {entity_enum}")
|
|
113
108
|
|
|
114
|
-
with self._connection.cursor() as cursor:
|
|
109
|
+
with self._lock, self._connection.cursor() as cursor:
|
|
115
110
|
try:
|
|
116
111
|
# there shouldnt be a need to store the expression tree in the database
|
|
117
112
|
# todo: change it later
|
|
@@ -137,7 +132,7 @@ class EntityDao:
|
|
|
137
132
|
query = "SELECT * FROM rules WHERE rule_name = %s"
|
|
138
133
|
else:
|
|
139
134
|
raise ValueError(f"Invalid entity type: {entity_enum}")
|
|
140
|
-
with self._connection.cursor() as cursor:
|
|
135
|
+
with self._lock, self._connection.cursor() as cursor:
|
|
141
136
|
cursor.execute(query, (entity_id,))
|
|
142
137
|
rows = cursor.fetchall()
|
|
143
138
|
if len(rows) > 0:
|
|
@@ -160,7 +155,7 @@ class EntityDao:
|
|
|
160
155
|
else:
|
|
161
156
|
raise ValueError(f"Invalid entity type: {entity_enum}")
|
|
162
157
|
|
|
163
|
-
with self._connection.cursor() as cursor:
|
|
158
|
+
with self._lock, self._connection.cursor() as cursor:
|
|
164
159
|
try:
|
|
165
160
|
cursor.execute(query, (entity_id,))
|
|
166
161
|
self._connection.commit()
|
|
@@ -169,11 +164,3 @@ class EntityDao:
|
|
|
169
164
|
_logger.error(f"Failed to delete entity: {e}")
|
|
170
165
|
self._connection.rollback()
|
|
171
166
|
return False
|
|
172
|
-
|
|
173
|
-
# create destructor for EntityDao class to close the connection
|
|
174
|
-
def __del__(self):
|
|
175
|
-
if self._connection is not None:
|
|
176
|
-
self._connection.close()
|
|
177
|
-
_logger.info("Connection to the database closed")
|
|
178
|
-
else:
|
|
179
|
-
_logger.error("Connection to the database was not established")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DAO and sync helpers for metadata persisted in PostgreSQL.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
import psycopg2
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from psycopg2.extensions import connection as PgConnection
|
|
12
|
+
from psycopg2.extras import Json
|
|
13
|
+
|
|
14
|
+
_logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MetadataDao:
|
|
18
|
+
def __init__(self, connection: PgConnection):
|
|
19
|
+
# Borrowed, not owned: never closed here. Pooled per-request on the request
|
|
20
|
+
# path (via DbPool), or a dedicated connection off the request path. `_lock`
|
|
21
|
+
# serialises DB access so one instance is safe to share across threads.
|
|
22
|
+
self._connection = connection
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
def get_all_metadata(self) -> dict[str, dict]:
|
|
26
|
+
with self._lock, self._connection.cursor() as cursor:
|
|
27
|
+
cursor.execute("SELECT column_name, metadata_json FROM metadata")
|
|
28
|
+
rows = cursor.fetchall()
|
|
29
|
+
return {row[0]: row[1] for row in rows}
|
|
30
|
+
|
|
31
|
+
def get_metadata(self, column_name: str) -> Optional[dict]:
|
|
32
|
+
with self._lock, self._connection.cursor() as cursor:
|
|
33
|
+
cursor.execute(
|
|
34
|
+
"SELECT metadata_json FROM metadata WHERE column_name = %s",
|
|
35
|
+
(column_name,),
|
|
36
|
+
)
|
|
37
|
+
row = cursor.fetchone()
|
|
38
|
+
return row[0] if row is not None else None
|
|
39
|
+
|
|
40
|
+
def upsert_metadata(self, column_name: str, metadata_json: dict) -> bool:
|
|
41
|
+
with self._lock, self._connection.cursor() as cursor:
|
|
42
|
+
try:
|
|
43
|
+
cursor.execute(
|
|
44
|
+
"""
|
|
45
|
+
INSERT INTO metadata (column_name, metadata_json)
|
|
46
|
+
VALUES (%s, %s)
|
|
47
|
+
ON CONFLICT (column_name)
|
|
48
|
+
DO UPDATE SET metadata_json = EXCLUDED.metadata_json
|
|
49
|
+
""",
|
|
50
|
+
(column_name, Json(metadata_json)),
|
|
51
|
+
)
|
|
52
|
+
self._connection.commit()
|
|
53
|
+
return True
|
|
54
|
+
except psycopg2.Error:
|
|
55
|
+
_logger.exception("Failed to upsert metadata for column: %s", column_name)
|
|
56
|
+
self._connection.rollback()
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
def delete_metadata(self, column_name: str) -> bool:
|
|
60
|
+
with self._lock, self._connection.cursor() as cursor:
|
|
61
|
+
try:
|
|
62
|
+
cursor.execute("DELETE FROM metadata WHERE column_name = %s", (column_name,))
|
|
63
|
+
self._connection.commit()
|
|
64
|
+
return True
|
|
65
|
+
except psycopg2.Error:
|
|
66
|
+
_logger.exception("Failed to delete metadata for column: %s", column_name)
|
|
67
|
+
self._connection.rollback()
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
def dump_metadata_to_file(self, metadata_store_file_path: str) -> dict[str, dict]:
|
|
71
|
+
metadata = self.get_all_metadata()
|
|
72
|
+
parent_dir = os.path.dirname(metadata_store_file_path)
|
|
73
|
+
if parent_dir:
|
|
74
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
75
|
+
with open(metadata_store_file_path, "w", encoding="utf-8") as metadata_file:
|
|
76
|
+
json.dump(metadata, metadata_file, indent=4)
|
|
77
|
+
return metadata
|
|
78
|
+
|
|
79
|
+
def sync_metadata_store_file(connection: PgConnection, metadata_store_file_path: str) -> dict[str, dict]:
|
|
80
|
+
return MetadataDao(connection).dump_metadata_to_file(metadata_store_file_path)
|
|
@@ -9,6 +9,17 @@ The MetadataStore class has the following methods:
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from urllib import error, request
|
|
15
|
+
|
|
16
|
+
DEFAULT_METADATA_SERVICE_URL = "http://localhost:8000"
|
|
17
|
+
DEFAULT_METADATA_SERVICE_TIMEOUT_SECONDS = 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_truthy_env(var_name: str, default: str = "false") -> bool:
|
|
21
|
+
return os.getenv(var_name, default).lower() in ("1", "true", "yes")
|
|
22
|
+
|
|
12
23
|
|
|
13
24
|
class MetadataStore:
|
|
14
25
|
def __init__(self, metadata_file_path: str):
|
|
@@ -23,6 +34,31 @@ class MetadataStore:
|
|
|
23
34
|
"""
|
|
24
35
|
Populates the attributes from the metadata store.
|
|
25
36
|
"""
|
|
37
|
+
if _is_truthy_env("FETCH_METADATA_FROM_API"):
|
|
38
|
+
metadata_service_base_url = os.getenv("METADATA_SERVICE_URL", DEFAULT_METADATA_SERVICE_URL).rstrip("/")
|
|
39
|
+
metadata_service_timeout_seconds = int(
|
|
40
|
+
os.getenv("METADATA_SERVICE_TIMEOUT_SECONDS", str(DEFAULT_METADATA_SERVICE_TIMEOUT_SECONDS))
|
|
41
|
+
)
|
|
42
|
+
get_all_metadata_url = f"{metadata_service_base_url}/metadata"
|
|
43
|
+
try:
|
|
44
|
+
with request.urlopen(get_all_metadata_url, timeout=metadata_service_timeout_seconds) as response:
|
|
45
|
+
if response.status != 200:
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
f"Failed to fetch metadata from service: {get_all_metadata_url} returned status {response.status}"
|
|
48
|
+
)
|
|
49
|
+
response_json = json.loads(response.read().decode("utf-8"))
|
|
50
|
+
if not isinstance(response_json, dict):
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"Failed to fetch metadata from service: {get_all_metadata_url} returned non-object payload"
|
|
53
|
+
)
|
|
54
|
+
self._metadata_store.update(response_json)
|
|
55
|
+
except (error.URLError, error.HTTPError, json.JSONDecodeError) as exc:
|
|
56
|
+
logging.getLogger(__name__).exception(
|
|
57
|
+
"Failed to fetch metadata from service in FETCH_METADATA_FROM_API mode",
|
|
58
|
+
)
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
f"Failed to fetch metadata from service in FETCH_METADATA_FROM_API mode: {get_all_metadata_url}"
|
|
61
|
+
) from exc
|
|
26
62
|
attributes_set = set()
|
|
27
63
|
for _, entity_data in self._metadata_store.items():
|
|
28
64
|
for field in entity_data:
|
|
@@ -46,4 +82,4 @@ class MetadataStore:
|
|
|
46
82
|
"""
|
|
47
83
|
Checks if field exists in the store and if the attribute exists for the field.
|
|
48
84
|
"""
|
|
49
|
-
return field in self._metadata_store and attribute in self._metadata_store[field] and self._metadata_store[field][attribute] is not None
|
|
85
|
+
return field in self._metadata_store and attribute in self._metadata_store[field] and self._metadata_store[field][attribute] is not None
|
|
@@ -2,10 +2,12 @@ README.md
|
|
|
2
2
|
pyproject.toml
|
|
3
3
|
setup.py
|
|
4
4
|
rule_engine_core/__init__.py
|
|
5
|
+
rule_engine_core/db_pool.py
|
|
5
6
|
rule_engine_core/entity_base.py
|
|
6
7
|
rule_engine_core/entity_dao.py
|
|
7
8
|
rule_engine_core/entity_types.py
|
|
8
9
|
rule_engine_core/filter.py
|
|
10
|
+
rule_engine_core/metadata_dao.py
|
|
9
11
|
rule_engine_core/metadata_store.py
|
|
10
12
|
rule_engine_core/parser.py
|
|
11
13
|
rule_engine_core/rule.py
|
|
@@ -14,5 +16,7 @@ rule_engine_core.egg-info/SOURCES.txt
|
|
|
14
16
|
rule_engine_core.egg-info/dependency_links.txt
|
|
15
17
|
rule_engine_core.egg-info/requires.txt
|
|
16
18
|
rule_engine_core.egg-info/top_level.txt
|
|
19
|
+
tests/test_metadata_dao.py
|
|
20
|
+
tests/test_metadata_store_api_fetch.py
|
|
17
21
|
tests/test_rule_creation_storage.py
|
|
18
22
|
tests/test_rule_evaluation.py
|
|
@@ -14,7 +14,7 @@ def parse_requirements(filename):
|
|
|
14
14
|
path = os.path.join(os.getenv('BUILD_PATH'), "requirements.txt")
|
|
15
15
|
setup(
|
|
16
16
|
name="rule-engine-core",
|
|
17
|
-
version="0.0.
|
|
17
|
+
version="0.0.4",
|
|
18
18
|
author="Mohit Tripathi",
|
|
19
19
|
author_email="tripathimohit051@gmail.com",
|
|
20
20
|
description="A rule engine core library for Python.",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from rule_engine_core.metadata_dao import MetadataDao, sync_metadata_store_file
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StubCursor:
|
|
5
|
+
def __init__(self, connection):
|
|
6
|
+
self._connection = connection
|
|
7
|
+
self._rows = []
|
|
8
|
+
self._single_row = None
|
|
9
|
+
|
|
10
|
+
def __enter__(self):
|
|
11
|
+
return self
|
|
12
|
+
|
|
13
|
+
def __exit__(self, exc_type, exc, tb):
|
|
14
|
+
return False
|
|
15
|
+
|
|
16
|
+
def execute(self, query, params=None):
|
|
17
|
+
if "SELECT column_name, metadata_json FROM metadata" in query:
|
|
18
|
+
self._rows = [(k, v) for k, v in self._connection.store.items()]
|
|
19
|
+
elif "SELECT metadata_json FROM metadata WHERE column_name = %s" in query:
|
|
20
|
+
value = self._connection.store.get(params[0])
|
|
21
|
+
self._single_row = (value,) if value is not None else None
|
|
22
|
+
elif "INSERT INTO metadata" in query:
|
|
23
|
+
self._connection.store[params[0]] = getattr(params[1], "adapted", params[1])
|
|
24
|
+
elif "DELETE FROM metadata WHERE column_name = %s" in query:
|
|
25
|
+
self._connection.store.pop(params[0], None)
|
|
26
|
+
|
|
27
|
+
def fetchall(self):
|
|
28
|
+
return self._rows
|
|
29
|
+
|
|
30
|
+
def fetchone(self):
|
|
31
|
+
return self._single_row
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StubConnection:
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.store = {"price": {"type": "float"}}
|
|
37
|
+
self.closed = 0
|
|
38
|
+
|
|
39
|
+
def cursor(self):
|
|
40
|
+
return StubCursor(self)
|
|
41
|
+
|
|
42
|
+
def commit(self):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def rollback(self):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def close(self):
|
|
49
|
+
self.closed = 1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_metadata_dao_crud_and_dump(tmp_path):
|
|
53
|
+
stub_connection = StubConnection()
|
|
54
|
+
metadata_dao = MetadataDao(stub_connection)
|
|
55
|
+
|
|
56
|
+
assert metadata_dao.get_all_metadata() == {"price": {"type": "float"}}
|
|
57
|
+
assert metadata_dao.get_metadata("price") == {"type": "float"}
|
|
58
|
+
assert metadata_dao.get_metadata("missing") is None
|
|
59
|
+
|
|
60
|
+
assert metadata_dao.upsert_metadata("country", {"type": "string"})
|
|
61
|
+
assert metadata_dao.get_metadata("country") == {"type": "string"}
|
|
62
|
+
|
|
63
|
+
assert metadata_dao.delete_metadata("country")
|
|
64
|
+
assert metadata_dao.get_metadata("country") is None
|
|
65
|
+
|
|
66
|
+
metadata_file_path = tmp_path / "metadata.json"
|
|
67
|
+
metadata = metadata_dao.dump_metadata_to_file(str(metadata_file_path))
|
|
68
|
+
assert metadata == {"price": {"type": "float"}}
|
|
69
|
+
assert metadata_file_path.exists()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_sync_metadata_store_file_writes_given_path(tmp_path):
|
|
73
|
+
stub_connection = StubConnection()
|
|
74
|
+
metadata_file_path = tmp_path / "metadata.json"
|
|
75
|
+
|
|
76
|
+
metadata = sync_metadata_store_file(stub_connection, str(metadata_file_path))
|
|
77
|
+
|
|
78
|
+
assert metadata == {"price": {"type": "float"}}
|
|
79
|
+
assert metadata_file_path.exists()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from urllib.error import HTTPError, URLError
|
|
3
|
+
import pytest
|
|
4
|
+
from rule_engine_core.metadata_store import MetadataStore
|
|
5
|
+
from rule_engine_core import metadata_store as metadata_store_module
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MockResponse:
|
|
9
|
+
def __init__(self, status: int, payload: dict):
|
|
10
|
+
self.status = status
|
|
11
|
+
self._payload = payload
|
|
12
|
+
|
|
13
|
+
def read(self):
|
|
14
|
+
return json.dumps(self._payload).encode("utf-8")
|
|
15
|
+
|
|
16
|
+
def __enter__(self):
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
def __exit__(self, exc_type, exc, tb):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_populate_attributes_fetches_from_api_when_enabled(monkeypatch, tmp_path):
|
|
24
|
+
metadata_file = tmp_path / "metadata.json"
|
|
25
|
+
metadata_file.write_text(json.dumps({"local_col": {"source": "file"}}), encoding="utf-8")
|
|
26
|
+
|
|
27
|
+
monkeypatch.setenv("FETCH_METADATA_FROM_API", "true")
|
|
28
|
+
monkeypatch.setenv("METADATA_SERVICE_URL", "http://metadata-service:8000")
|
|
29
|
+
monkeypatch.setattr(
|
|
30
|
+
"rule_engine_core.metadata_store.request.urlopen",
|
|
31
|
+
lambda url, timeout=3: MockResponse(200, {"api_col": {"source": "database", "type": "string"}}),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
metadata_store = MetadataStore(str(metadata_file))
|
|
35
|
+
|
|
36
|
+
assert metadata_store.get_all_metadata() == {
|
|
37
|
+
"local_col": {"source": "file"},
|
|
38
|
+
"api_col": {"source": "database", "type": "string"},
|
|
39
|
+
}
|
|
40
|
+
assert metadata_store.does_field_exist("api_col")
|
|
41
|
+
assert metadata_store.attribute_exist_for_field("api_col", "source")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_is_truthy_env_helper(monkeypatch):
|
|
45
|
+
monkeypatch.delenv("FETCH_METADATA_FROM_API", raising=False)
|
|
46
|
+
assert metadata_store_module._is_truthy_env("FETCH_METADATA_FROM_API") is False
|
|
47
|
+
|
|
48
|
+
monkeypatch.setenv("FETCH_METADATA_FROM_API", "1")
|
|
49
|
+
assert metadata_store_module._is_truthy_env("FETCH_METADATA_FROM_API") is True
|
|
50
|
+
|
|
51
|
+
monkeypatch.setenv("FETCH_METADATA_FROM_API", "true")
|
|
52
|
+
assert metadata_store_module._is_truthy_env("FETCH_METADATA_FROM_API") is True
|
|
53
|
+
|
|
54
|
+
monkeypatch.setenv("FETCH_METADATA_FROM_API", "yes")
|
|
55
|
+
assert metadata_store_module._is_truthy_env("FETCH_METADATA_FROM_API") is True
|
|
56
|
+
|
|
57
|
+
monkeypatch.setenv("FETCH_METADATA_FROM_API", "0")
|
|
58
|
+
assert metadata_store_module._is_truthy_env("FETCH_METADATA_FROM_API") is False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_populate_attributes_raises_when_api_fails(monkeypatch, tmp_path):
|
|
62
|
+
metadata_file = tmp_path / "metadata.json"
|
|
63
|
+
metadata_file.write_text(json.dumps({"local_col": {"source": "file"}}), encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
monkeypatch.setenv("FETCH_METADATA_FROM_API", "true")
|
|
66
|
+
def raise_url_error(url, timeout=3):
|
|
67
|
+
raise URLError("service unavailable")
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr(
|
|
70
|
+
"rule_engine_core.metadata_store.request.urlopen",
|
|
71
|
+
raise_url_error,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
with pytest.raises(RuntimeError, match="FETCH_METADATA_FROM_API mode"):
|
|
75
|
+
MetadataStore(str(metadata_file))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_populate_attributes_raises_on_http_error(monkeypatch, tmp_path):
|
|
79
|
+
metadata_file = tmp_path / "metadata.json"
|
|
80
|
+
metadata_file.write_text(json.dumps({"local_col": {"source": "file"}}), encoding="utf-8")
|
|
81
|
+
|
|
82
|
+
monkeypatch.setenv("FETCH_METADATA_FROM_API", "true")
|
|
83
|
+
|
|
84
|
+
def raise_http_error(url, timeout=3):
|
|
85
|
+
raise HTTPError(url, 500, "internal error", hdrs=None, fp=None)
|
|
86
|
+
|
|
87
|
+
monkeypatch.setattr("rule_engine_core.metadata_store.request.urlopen", raise_http_error)
|
|
88
|
+
|
|
89
|
+
with pytest.raises(RuntimeError, match="FETCH_METADATA_FROM_API mode"):
|
|
90
|
+
MetadataStore(str(metadata_file))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_populate_attributes_raises_on_non_dict_api_payload(monkeypatch, tmp_path):
|
|
94
|
+
metadata_file = tmp_path / "metadata.json"
|
|
95
|
+
metadata_file.write_text(json.dumps({"local_col": {"source": "file"}}), encoding="utf-8")
|
|
96
|
+
|
|
97
|
+
monkeypatch.setenv("FETCH_METADATA_FROM_API", "true")
|
|
98
|
+
monkeypatch.setattr(
|
|
99
|
+
"rule_engine_core.metadata_store.request.urlopen",
|
|
100
|
+
lambda url, timeout=3: MockResponse(200, ["not", "a", "dict"]),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
with pytest.raises(RuntimeError, match="non-object payload"):
|
|
104
|
+
MetadataStore(str(metadata_file))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_populate_attributes_merges_api_and_file_metadata(monkeypatch, tmp_path):
|
|
108
|
+
metadata_file = tmp_path / "metadata.json"
|
|
109
|
+
metadata_file.write_text(
|
|
110
|
+
json.dumps(
|
|
111
|
+
{
|
|
112
|
+
"file_col": {"source": "file", "is_sec_column": False},
|
|
113
|
+
"shared_col": {"source": "file", "type": "int"},
|
|
114
|
+
}
|
|
115
|
+
),
|
|
116
|
+
encoding="utf-8",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
monkeypatch.setenv("FETCH_METADATA_FROM_API", "true")
|
|
120
|
+
monkeypatch.setattr(
|
|
121
|
+
"rule_engine_core.metadata_store.request.urlopen",
|
|
122
|
+
lambda url, timeout=3: MockResponse(
|
|
123
|
+
200,
|
|
124
|
+
{
|
|
125
|
+
"api_col": {"source": "database", "type": "string"},
|
|
126
|
+
"shared_col": {"source": "database", "type": "float"},
|
|
127
|
+
},
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
metadata_store = MetadataStore(str(metadata_file))
|
|
132
|
+
|
|
133
|
+
assert metadata_store.get_all_metadata() == {
|
|
134
|
+
"file_col": {"source": "file", "is_sec_column": False},
|
|
135
|
+
"shared_col": {"source": "database", "type": "float"},
|
|
136
|
+
"api_col": {"source": "database", "type": "string"},
|
|
137
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Tests the rule creation and storage functionality
|
|
2
2
|
# These are integration tests that check if the rule engine can create and store rules correctly.
|
|
3
3
|
from rule_engine_core.rule import Rule
|
|
4
|
-
from rule_engine_core.entity_dao import EntityDao
|
|
4
|
+
from rule_engine_core.entity_dao import EntityDao, create_connection
|
|
5
5
|
from rule_engine_core.entity_types import EntityEnum, EntityNode
|
|
6
6
|
from rule_engine_core.filter import Filter
|
|
7
7
|
from rule_engine_core.parser import Node
|
|
@@ -10,7 +10,7 @@ import uuid
|
|
|
10
10
|
|
|
11
11
|
def test_filter_creation_validation_storage_retrieval():
|
|
12
12
|
metadata_store = MetadataStore("tests/test_metadatastore.json")
|
|
13
|
-
entity_dao = EntityDao()
|
|
13
|
+
entity_dao = EntityDao(create_connection())
|
|
14
14
|
# Create a filter
|
|
15
15
|
filter_name = str(uuid.uuid4())
|
|
16
16
|
expression = "col2 > 10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rule_engine_core-0.0.2 → rule_engine_core-0.0.4}/rule_engine_core.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|