Skip to content

Commit

Permalink
cleaner bypass of the singleton for library tests
Browse files Browse the repository at this point in the history
  • Loading branch information
gtfierro committed Jul 9, 2024
1 parent 8bae03d commit 112a127
Show file tree
Hide file tree
Showing 5 changed files with 91 additions and 304 deletions.
5 changes: 0 additions & 5 deletions buildingmotif/building_motif/building_motif.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,6 @@
class BuildingMOTIF(metaclass=Singleton):
"""Manages BuildingMOTIF data classes."""

# we specify the metaclass as an attribute (__metaclass__ = Singleton) instead of providing
# it as a keyword arg above (BuildingMOTIF(metaclass=Singleton)) because it allows us to monkeypatch
# BuildingMOTIF for tests to ignore the singleton behavior
__metaclass__ = Singleton

def __init__(
self,
db_uri: str,
Expand Down
1 change: 1 addition & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def bm():
"""
BuildingMotif instance for tests involving dataclasses and API calls
"""
BuildingMOTIF.clean()
bm = BuildingMOTIF("sqlite://")
# add tables to db
bm.setup_tables()
Expand Down
193 changes: 0 additions & 193 deletions tests/library/conftest.py
Original file line number Diff line number Diff line change
@@ -1,193 +0,0 @@
import logging
import os
from typing import Optional

from pytest import MonkeyPatch
from rdflib import Graph
from rdflib.namespace import NamespaceManager
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker

import buildingmotif
import buildingmotif.building_motif.building_motif
import buildingmotif.building_motif.singleton
from buildingmotif.building_motif.building_motif import BuildingMotifEngine
from buildingmotif.database.graph_connection import GraphConnection
from buildingmotif.database.table_connection import TableConnection
from buildingmotif.database.tables import Base as BuildingMOTIFBase
from buildingmotif.database.utils import (
_custom_json_deserializer,
_custom_json_serializer,
)
from buildingmotif.namespaces import bind_prefixes

# The fact that BuildingMOTIF is a singleton class is a problem for testing templates.
# We want to have tests which are parameterized by the library and template name; this makes
# it possible to use pytest filters (with the "-k" flag) to run tests for specific libraries or templates.
# We want each (library, template) pair to operate in a "clean" environment, so that the tests are isolated.
# However, the singleton pattern means that the BuildingMOTIF instance is shared across all tests.
# We can work around this by patching BuildingMOTIF to ignore the singleton pattern.

# "instances" is a dictionary that maps the name of the module to the BuildingMOTIF instance.
instances = {}


# non-singleton BuildingMOTIF
class BuildingMOTIF:
"""Manages BuildingMOTIF data classes."""

def __init__(
self,
db_uri: str,
shacl_engine: Optional[str] = "pyshacl",
log_level=logging.WARNING,
) -> None:
"""Class constructor.
:param db_uri: database URI
:type db_uri: str
:param shacl_engine: the name of the engine to use for validation: "pyshacl" or "topquadrant". Using topquadrant
requires Java to be installed on this machine, and the "topquadrant" feature on BuildingMOTIF,
defaults to "pyshacl"
:type shacl_engine: str, optional
:param log_level: logging level of detail
:type log_level: int
:default log_level: INFO
"""
self.db_uri = db_uri
self.shacl_engine = shacl_engine
self.engine = create_engine(
db_uri,
echo=False,
json_serializer=_custom_json_serializer,
json_deserializer=_custom_json_deserializer,
)
self.session_factory = sessionmaker(bind=self.engine, autoflush=True)
self.Session = scoped_session(self.session_factory)

self.setup_logging(log_level)

# setup tables automatically if using a in-memory sqlite database
if self._is_in_memory_sqlite():
self.setup_tables()

self.table_connection = TableConnection(self.engine, self)
self.graph_connection = GraphConnection(
BuildingMotifEngine(self.engine, self.Session)
)

g = Graph()
bind_prefixes(g)
self.template_ns_mgr: NamespaceManager = NamespaceManager(g)

@property
def session(self):
return self.Session()

def setup_tables(self):
"""Creates all tables in the underlying database."""
BuildingMOTIFBase.metadata.create_all(self.engine)

def _is_in_memory_sqlite(self) -> bool:
"""Returns true if the BuildingMOTIF instance uses an in-memory SQLite
database.
"""
if self.engine.dialect.name != "sqlite":
return False
# get the 'filename' of the database; if this is empty, the db is in-memory
raw_conn = self.engine.raw_connection()
filename = (
raw_conn.cursor()
.execute("select file from pragma_database_list where name='main';", ())
.fetchone()
)
# length is 0 if the db is in-memory
return not len(filename[0])

def setup_logging(self, log_level):
"""Create log file with DEBUG level and stdout handler with specified
logging level.
:param log_level: logging level of detail
:type log_level: int
"""
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(asctime)s | %(name)s | %(levelname)s: %(message)s"
)

log_file_handler = logging.FileHandler(
os.path.join(os.getcwd(), "BuildingMOTIF.log"), mode="w"
)
log_file_handler.setLevel(logging.DEBUG)
log_file_handler.setFormatter(formatter)

engine_logger = logging.getLogger("sqlalchemy.engine")
pool_logger = logging.getLogger("sqlalchemy.pool")

engine_logger.setLevel(logging.WARN)
pool_logger.setLevel(logging.WARN)

stream_handler = logging.StreamHandler()
stream_handler.setLevel(log_level)
stream_handler.setFormatter(formatter)

root_logger.addHandler(log_file_handler)
root_logger.addHandler(stream_handler)

def close(self) -> None:
"""Close session and engine."""
self.session.close()
self.engine.dispose()


# The normal get_building_motif method uses the fact that BuildingMOTIF is a singleton class.
# We need to mock this method so that we can create a new instance of BuildingMOTIF for each test.
# We use the "instances" dictionary to store the BuildingMOTIF instance for each module.
# If the instance does not exist, we create a new one and store it in the dictionary.
# TODO: how to handle testing different shacl_engines?
def mock_building_motif():
global instances
from buildingmotif import BuildingMOTIF

name = os.environ["bmotif_module"]
if name not in instances:
instances[name] = BuildingMOTIF("sqlite://", shacl_engine="topquadrant")
instances[name].setup_tables()
return instances[name]


# This is a context ("with PatchBuildingMotif()") that patches the "get_building_motif" method
# to use the correct BuildingMOTIF instance for each test. We have to patch the get_building_motif
class PatchBuildingMotif:
def __enter__(self):
self.monkeypatch = MonkeyPatch()
self.monkeypatch.setattr(
buildingmotif.dataclasses.library, "get_building_motif", mock_building_motif
)
self.monkeypatch.setattr(
buildingmotif.dataclasses.model, "get_building_motif", mock_building_motif
)
self.monkeypatch.setattr(
buildingmotif.dataclasses.shape_collection,
"get_building_motif",
mock_building_motif,
)
self.monkeypatch.setattr(
buildingmotif.dataclasses.template,
"get_building_motif",
mock_building_motif,
)
self.monkeypatch.setattr(
buildingmotif.building_motif.building_motif,
"get_building_motif",
mock_building_motif,
)
self.monkeypatch.setattr(
buildingmotif, "get_building_motif", mock_building_motif
)
return self.monkeypatch

def __exit__(self, *args):
self.monkeypatch.undo()
76 changes: 35 additions & 41 deletions tests/library/test_223p_templates.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import os
from typing import Tuple

from rdflib import Graph, Namespace, URIRef

from buildingmotif import BuildingMOTIF
from buildingmotif.dataclasses import Library, Model
from buildingmotif.namespaces import RDF, S223, bind_prefixes
from tests.library.conftest import BuildingMOTIF, PatchBuildingMotif, instances

libraries = [
"libraries/ashrae/223p/nrel-templates",
Expand All @@ -30,18 +29,17 @@


def setup_building_motif_s223() -> Tuple[BuildingMOTIF, Library]:
with PatchBuildingMotif():
os.environ["bmotif_module"] = __file__
bm = BuildingMOTIF("sqlite://", shacl_engine="topquadrant")
instances[__file__] = bm
# bm = get_building_motif()
bm.setup_tables()
s223 = Library.load(
ontology_graph="libraries/ashrae/223p/ontology/223p.ttl",
run_shacl_inference=False,
)
bm.session.commit()
return bm, s223
BuildingMOTIF.clean() # clean the singleton, but keep the instance
bm = BuildingMOTIF("sqlite://", shacl_engine="topquadrant")
bm.setup_tables()
# bm = get_building_motif()
s223 = Library.load(
ontology_graph="libraries/ashrae/223p/ontology/223p.ttl",
run_shacl_inference=False,
)
bm.session.commit()
BuildingMOTIF.clean()
return bm, s223


def plug_223_connection_points(g: Graph):
Expand Down Expand Up @@ -74,45 +72,41 @@ def plug_223_connection_points(g: Graph):

def test_223p_template(bm, s223, library, template):
# set the module to this file; this helps the monkeypatch determine which BuildingMOTIF instance to use
with PatchBuildingMotif():
os.environ["bmotif_module"] = __file__
try:
MODEL = Namespace("urn:ex/")
m = Model.create(MODEL)
_, g = template.inline_dependencies().fill(MODEL, include_optional=False)
assert isinstance(g, Graph), "was not a graph"
bind_prefixes(g)
plug_223_connection_points(g)
m.add_graph(g)
ctx = m.validate(
[s223.get_shape_collection()], error_on_missing_imports=False
)
except Exception as e:
bm.session.rollback()
raise e
assert ctx.valid, ctx.report_string
BuildingMOTIF.instance = bm
try:
MODEL = Namespace("urn:ex/")
m = Model.create(MODEL)
_, g = template.inline_dependencies().fill(MODEL, include_optional=False)
assert isinstance(g, Graph), "was not a graph"
bind_prefixes(g)
plug_223_connection_points(g)
m.add_graph(g)
ctx = m.validate([s223.get_shape_collection()], error_on_missing_imports=False)
except Exception as e:
bm.session.rollback()
raise e
assert ctx.valid, ctx.report_string


def pytest_generate_tests(metafunc):
# set the module to this file; this helps the monkeypatch determine which BuildingMOTIF instance to use
os.environ["bmotif_module"] = __file__
# setup building motif
bm, s223 = setup_building_motif_s223()
BuildingMOTIF.instance = bm
if "test_223p_template" == metafunc.function.__name__:
params = []
ids = []
for library_name in libraries:
with PatchBuildingMotif():
library = Library.load(
directory=library_name,
run_shacl_inference=False,
infer_templates=False,
)
templates = library.get_templates()
params.extend([(bm, s223, library, template) for template in templates])
library = Library.load(
directory=library_name,
run_shacl_inference=False,
infer_templates=False,
)
templates = library.get_templates()
params.extend([(bm, s223, library, template) for template in templates])

# remove all templates in 'to skip'
params = [p for p in params if p[3].name not in to_skip[p[2].name]]
# library name - template name
ids = [f"{p[2].name}-{p[3].name}" for p in params]
metafunc.parametrize("bm,s223,library,template", params, ids=ids)
BuildingMOTIF.clean()
Loading

0 comments on commit 112a127

Please sign in to comment.