rule-engine-core 0.0.3__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.
Files changed (24) hide show
  1. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/PKG-INFO +1 -1
  2. rule_engine_core-0.0.4/rule_engine_core/db_pool.py +102 -0
  3. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core/entity_dao.py +34 -47
  4. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core/metadata_dao.py +13 -22
  5. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core/metadata_store.py +37 -1
  6. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core.egg-info/PKG-INFO +1 -1
  7. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core.egg-info/SOURCES.txt +2 -0
  8. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/setup.py +1 -1
  9. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/tests/test_metadata_dao.py +5 -8
  10. rule_engine_core-0.0.4/tests/test_metadata_store_api_fetch.py +137 -0
  11. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/tests/test_rule_creation_storage.py +2 -2
  12. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/README.md +0 -0
  13. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/pyproject.toml +0 -0
  14. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core/__init__.py +0 -0
  15. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core/entity_base.py +0 -0
  16. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core/entity_types.py +0 -0
  17. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core/filter.py +0 -0
  18. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core/parser.py +0 -0
  19. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core/rule.py +0 -0
  20. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core.egg-info/dependency_links.txt +0 -0
  21. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core.egg-info/requires.txt +0 -0
  22. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/rule_engine_core.egg-info/top_level.txt +0 -0
  23. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/setup.cfg +0 -0
  24. {rule_engine_core-0.0.3 → rule_engine_core-0.0.4}/tests/test_rule_evaluation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rule-engine-core
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: A rule engine core library for Python.
5
5
  Home-page: https://github.com/temp-noob/rule-engine
6
6
  Author: Mohit Tripathi
@@ -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 json
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 os, logging
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(config: Optional[dict] = None) -> PgConnection:
26
- if config is None:
27
- # Load the config from the environment variables
28
- # and assume db is on the same host as the application
29
- # using the default port: 5432
30
- config = {
31
- "database": os.getenv("POSTGRES_DB"),
32
- "user": os.getenv("POSTGRES_USER"),
33
- "password": os.getenv("POSTGRES_PASSWORD"),
34
- "host": os.getenv("POSTGRES_HOST", "localhost"),
35
- # "port": os.getenv("POSTGRES_PORT", 5432),
36
- }
37
- connection = psycopg2.connect(
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
- return None
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, config_file_path: Optional[str] = None):
47
+ def __init__(self, connection: PgConnection):
56
48
  """
57
- Initializes the MetadataStore object with an empty dictionary to store metadata.
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
- if config_file_path is not None:
60
- with open(config_file_path, 'r') as f:
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")
@@ -5,29 +5,31 @@ DAO and sync helpers for metadata persisted in PostgreSQL.
5
5
  import json
6
6
  import os
7
7
  import logging
8
+ import threading
8
9
  import psycopg2
9
10
  from typing import Optional
10
11
  from psycopg2.extensions import connection as PgConnection
11
12
  from psycopg2.extras import Json
12
- from rule_engine_core.entity_dao import create_connection
13
13
 
14
14
  _logger = logging.getLogger(__name__)
15
15
 
16
16
 
17
17
  class MetadataDao:
18
- def __init__(self, config: Optional[dict] = None):
19
- self._connection: Optional[PgConnection] = create_connection(config)
20
- if self._connection is None:
21
- raise RuntimeError("Failed to create a connection to the database")
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()
22
24
 
23
25
  def get_all_metadata(self) -> dict[str, dict]:
24
- with self._connection.cursor() as cursor:
26
+ with self._lock, self._connection.cursor() as cursor:
25
27
  cursor.execute("SELECT column_name, metadata_json FROM metadata")
26
28
  rows = cursor.fetchall()
27
29
  return {row[0]: row[1] for row in rows}
28
30
 
29
31
  def get_metadata(self, column_name: str) -> Optional[dict]:
30
- with self._connection.cursor() as cursor:
32
+ with self._lock, self._connection.cursor() as cursor:
31
33
  cursor.execute(
32
34
  "SELECT metadata_json FROM metadata WHERE column_name = %s",
33
35
  (column_name,),
@@ -36,7 +38,7 @@ class MetadataDao:
36
38
  return row[0] if row is not None else None
37
39
 
38
40
  def upsert_metadata(self, column_name: str, metadata_json: dict) -> bool:
39
- with self._connection.cursor() as cursor:
41
+ with self._lock, self._connection.cursor() as cursor:
40
42
  try:
41
43
  cursor.execute(
42
44
  """
@@ -55,7 +57,7 @@ class MetadataDao:
55
57
  return False
56
58
 
57
59
  def delete_metadata(self, column_name: str) -> bool:
58
- with self._connection.cursor() as cursor:
60
+ with self._lock, self._connection.cursor() as cursor:
59
61
  try:
60
62
  cursor.execute("DELETE FROM metadata WHERE column_name = %s", (column_name,))
61
63
  self._connection.commit()
@@ -74,16 +76,5 @@ class MetadataDao:
74
76
  json.dump(metadata, metadata_file, indent=4)
75
77
  return metadata
76
78
 
77
- def __del__(self):
78
- self.close()
79
-
80
- def close(self):
81
- if self._connection is not None:
82
- self._connection.close()
83
- self._connection = None
84
-
85
-
86
- def sync_metadata_store_file(config: Optional[dict] = None, metadata_store_file_path: Optional[str] = None) -> dict[str, dict]:
87
- resolved_metadata_store_file_path = metadata_store_file_path or os.getenv("METADATA_STORE_FILE_PATH", "metadata.json")
88
- metadata_dao = MetadataDao(config)
89
- return metadata_dao.dump_metadata_to_file(resolved_metadata_store_file_path)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rule-engine-core
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: A rule engine core library for Python.
5
5
  Home-page: https://github.com/temp-noob/rule-engine
6
6
  Author: Mohit Tripathi
@@ -2,6 +2,7 @@ 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
@@ -16,5 +17,6 @@ rule_engine_core.egg-info/dependency_links.txt
16
17
  rule_engine_core.egg-info/requires.txt
17
18
  rule_engine_core.egg-info/top_level.txt
18
19
  tests/test_metadata_dao.py
20
+ tests/test_metadata_store_api_fetch.py
19
21
  tests/test_rule_creation_storage.py
20
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.3",
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.",
@@ -49,10 +49,9 @@ class StubConnection:
49
49
  self.closed = 1
50
50
 
51
51
 
52
- def test_metadata_dao_crud_and_dump(monkeypatch, tmp_path):
52
+ def test_metadata_dao_crud_and_dump(tmp_path):
53
53
  stub_connection = StubConnection()
54
- monkeypatch.setattr("rule_engine_core.metadata_dao.create_connection", lambda _: stub_connection)
55
- metadata_dao = MetadataDao()
54
+ metadata_dao = MetadataDao(stub_connection)
56
55
 
57
56
  assert metadata_dao.get_all_metadata() == {"price": {"type": "float"}}
58
57
  assert metadata_dao.get_metadata("price") == {"type": "float"}
@@ -70,13 +69,11 @@ def test_metadata_dao_crud_and_dump(monkeypatch, tmp_path):
70
69
  assert metadata_file_path.exists()
71
70
 
72
71
 
73
- def test_sync_metadata_store_file_uses_env_path(monkeypatch, tmp_path):
72
+ def test_sync_metadata_store_file_writes_given_path(tmp_path):
74
73
  stub_connection = StubConnection()
75
- monkeypatch.setattr("rule_engine_core.metadata_dao.create_connection", lambda _: stub_connection)
76
- metadata_file_path = tmp_path / "metadata-from-env.json"
77
- monkeypatch.setenv("METADATA_STORE_FILE_PATH", str(metadata_file_path))
74
+ metadata_file_path = tmp_path / "metadata.json"
78
75
 
79
- metadata = sync_metadata_store_file()
76
+ metadata = sync_metadata_store_file(stub_connection, str(metadata_file_path))
80
77
 
81
78
  assert metadata == {"price": {"type": "float"}}
82
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"