From b355632f37e656ccce544bab7f5c07de537759db Mon Sep 17 00:00:00 2001 From: Chris Mutel Date: Thu, 31 Oct 2024 11:33:48 +0100 Subject: [PATCH 1/6] Use Snowflake IDs for nodes and edges --- bw2data/backends/base.py | 6 +++--- bw2data/backends/proxies.py | 17 +++++++++++++++-- bw2data/backends/schema.py | 20 +++++++++++++++++--- bw2data/backends/utils.py | 11 ++++++++--- bw2data/snowflake_ids.py | 20 ++++++++++++++++++++ tests/activity_proxy.py | 20 ++++++++++++++++++++ tests/compatibility.py | 37 ++++++++++++++++++++++--------------- tests/database.py | 11 +++++------ tests/ia.py | 16 ++++++++++++---- tests/utils.py | 33 +++++++++------------------------ 10 files changed, 131 insertions(+), 60 deletions(-) create mode 100644 bw2data/snowflake_ids.py diff --git a/bw2data/backends/base.py b/bw2data/backends/base.py index 296076ee..761b809e 100644 --- a/bw2data/backends/base.py +++ b/bw2data/backends/base.py @@ -28,7 +28,7 @@ from bw2data.backends.utils import ( check_exchange, dict_as_activitydataset, - dict_as_exchangedataset, + _dict_as_exchangedataset, get_csv_data_dict, retupleize_geo_strings, ) @@ -525,7 +525,7 @@ def _efficient_write_dataset( if "output" not in exchange: exchange["output"] = (ds["database"], ds["code"]) - exchanges.append(dict_as_exchangedataset(exchange)) + exchanges.append(_dict_as_exchangedataset(exchange)) # Query gets passed as INSERT INTO x VALUES ('?', '?'...) # SQLite3 has a limit of 999 variables, @@ -542,7 +542,7 @@ def _efficient_write_dataset( check_activity_type(ds.get("type")) check_activity_keys(ds) - activities.append(dict_as_activitydataset(ds)) + activities.append(dict_as_activitydataset(ds, add_snowflake_id=True)) if len(activities) > 125: ActivityDataset.insert_many(activities).execute() diff --git a/bw2data/backends/proxies.py b/bw2data/backends/proxies.py index c947129e..be316292 100644 --- a/bw2data/backends/proxies.py +++ b/bw2data/backends/proxies.py @@ -14,7 +14,7 @@ check_exchange_keys, check_exchange_type, ) -from bw2data.backends.utils import dict_as_activitydataset, dict_as_exchangedataset +from bw2data.backends.utils import dict_as_activitydataset, _dict_as_exchangedataset from bw2data.configuration import labels from bw2data.errors import ValidityError from bw2data.logs import stdout_feedback_logger @@ -338,6 +338,8 @@ def save(self, signal: bool = True, data_already_set: bool = False, force_insert check_activity_keys(self) for key, value in dict_as_activitydataset(self._data).items(): + # ID value is either already in `._document` (update) or will be created by + # `SnowflakeIDBaseClass.save()`. if key != "id": setattr(self._document, key, value) @@ -496,6 +498,8 @@ def new_edge(self, **kwargs): exc = Exchange() exc.output = self.key for key in kwargs: + if key == "id": + raise ValueError("`id` must be created automatically") exc[key] = kwargs[key] return exc @@ -510,14 +514,23 @@ def copy(self, code: Optional[str] = None, signal: bool = True, **kwargs): activity = Activity() for key, value in self.items(): if key != "id": + print(key, value) activity[key] = value for k, v in kwargs.items(): + if k == "id": + raise ValueError("`id` must be generated automatically") activity._data[k] = v activity._data["code"] = str(code or uuid.uuid4().hex) activity.save(signal=signal) + print("Copy Document ID", activity._document.id) + print("Original Document ID", self._document.id) + for exc in self.exchanges(): data = copy.deepcopy(exc._data) + if 'id' in data: + # New snowflake ID will be inserted by `_dict_as_exchangedataset` + del data['id'] data["output"] = activity.key # Change `input` for production exchanges if exc["input"] == exc["output"]: @@ -564,7 +577,7 @@ def save(self, signal: bool = True, data_already_set: bool = False, force_insert check_exchange_type(self._data.get("type")) check_exchange_keys(self) - for key, value in dict_as_exchangedataset(self._data).items(): + for key, value in _dict_as_exchangedataset(self._data).items(): setattr(self._document, key, value) self._document.save(signal=signal, force_insert=force_insert) diff --git a/bw2data/backends/schema.py b/bw2data/backends/schema.py index 12ed3e7a..e32a1770 100644 --- a/bw2data/backends/schema.py +++ b/bw2data/backends/schema.py @@ -1,11 +1,25 @@ -from peewee import DoesNotExist, TextField +from peewee import DoesNotExist, Model, TextField, IntegerField from bw2data.errors import UnknownObject from bw2data.signals import SignaledDataset from bw2data.sqlite import PickleField +from bw2data.snowflake_ids import snowflake_id_generator -class ActivityDataset(SignaledDataset): +class SnowflakeIDBaseClass(SignaledDataset): + id = IntegerField(primary_key=True) + + def save(self, **kwargs): + if self.id is None: + # If the primary key is already present, peewee will make an `UPDATE` query. + # This will have no effect if there isn't a matching row + # https://docs.peewee-orm.com/en/latest/peewee/models.html#id4 + self.id = next(snowflake_id_generator) + kwargs['force_insert'] = True + super().save(**kwargs) + + +class ActivityDataset(SnowflakeIDBaseClass): data = PickleField() # Canonical, except for other C fields code = TextField() # Canonical database = TextField() # Canonical @@ -19,7 +33,7 @@ def key(self): return (self.database, self.code) -class ExchangeDataset(SignaledDataset): +class ExchangeDataset(SnowflakeIDBaseClass): data = PickleField() # Canonical, except for other C fields input_code = TextField() # Canonical input_database = TextField() # Canonical diff --git a/bw2data/backends/utils.py b/bw2data/backends/utils.py index afd14859..13797d9c 100644 --- a/bw2data/backends/utils.py +++ b/bw2data/backends/utils.py @@ -1,3 +1,4 @@ +from typing import Any import copy import warnings from typing import Optional @@ -9,6 +10,7 @@ from bw2data.configuration import labels from bw2data.errors import InvalidExchange, UntypedExchange from bw2data.meta import databases, methods +from bw2data.snowflake_ids import snowflake_id_generator def get_csv_data_dict(ds): @@ -66,8 +68,8 @@ def check_exchange(exc): raise ValueError("Invalid amount in exchange {}".format(exc)) -def dict_as_activitydataset(ds): - return { +def dict_as_activitydataset(ds: Any, add_snowflake_id: bool = False) -> dict: + val = { "data": ds, "database": ds["database"], "code": ds["code"], @@ -76,9 +78,12 @@ def dict_as_activitydataset(ds): "product": ds.get("reference product"), "type": ds.get("type", labels.process_node_default), } + if add_snowflake_id: + val['id'] = next(snowflake_id_generator) + return val -def dict_as_exchangedataset(ds): +def _dict_as_exchangedataset(ds: Any) -> dict: return { "data": ds, "input_database": ds["input"][0], diff --git a/bw2data/snowflake_ids.py b/bw2data/snowflake_ids.py new file mode 100644 index 00000000..9a9bf082 --- /dev/null +++ b/bw2data/snowflake_ids.py @@ -0,0 +1,20 @@ +from snowflake import SnowflakeGenerator +import uuid + +# Jan 1, 2024 +# from datetime import datetime +# (datetime(2024, 1, 1) - datetime.utcfromtimestamp(0)).total_seconds() * 1000.0 +EPOCH_START_MS = 1704067200000 + +# From https://softwaremind.com/blog/the-unique-features-of-snowflake-id-and-its-comparison-to-uuid/ +# Snowflake bits: +# Sign bit: 1 bit. It will always be 0. This is reserved for future uses. It can potentially be used +# to distinguish between signed and unsigned numbers. +# Timestamp: 41 bits. Milliseconds since the epoch or custom epoch. +# Datacenter ID: 5 bits, which gives us 2 ^ 5 = 32 datacenters. +# Machine ID: 5 bits, which gives us 2 ^ 5 = 32 machines per datacenter. +# However, `snowflake-id` lumps these two values datacenter and machine id values together into an +# `instance` value with 2 ^ 10 = 1024 possible values. +# Sequence number: 12 bits. For every ID generated on that machine/process, the sequence number is +# incremented by 1. The number is reset to 0 every millisecond. +snowflake_id_generator = SnowflakeGenerator(instance=uuid.getnode() % 1024, epoch=EPOCH_START_MS) diff --git a/tests/activity_proxy.py b/tests/activity_proxy.py index 2484bda8..55a897c0 100644 --- a/tests/activity_proxy.py +++ b/tests/activity_proxy.py @@ -179,6 +179,15 @@ def test_copy(activity): assert cp["name"] == "baz" assert cp["location"] == "bar" assert ExchangeDataset.select().count() == 2 + + for ds in ActivityDataset.select(): + print(ds, ds.name, ds.code) + + cp.save() + + for ds in ActivityDataset.select(): + print(ds, ds.name, ds.code) + assert ActivityDataset.select().count() == 2 assert ( ActivityDataset.select() @@ -232,6 +241,9 @@ def test_copy_with_kwargs(activity): @bw2test def test_delete_activity_parameters(): + from bw2data import projects + print(projects.dir) + db = DatabaseChooser("example") db.register() @@ -241,6 +253,12 @@ def test_delete_activity_parameters(): b.save() a.new_exchange(amount=0, input=b, type="technosphere", formula="foo * bar + 4").save() + print("Iterating over exchanges:") + for exc in ExchangeDataset.select(): + print("Next:", exc, exc.id) + + assert ExchangeDataset.select().count() == 1 + activity_data = [ { "name": "reference_me", @@ -258,6 +276,8 @@ def test_delete_activity_parameters(): parameters.new_activity_parameters(activity_data, "my group") parameters.add_exchanges_to_group("my group", a) + assert ExchangeDataset.select().count() == 1 + assert ActivityParameter.select().count() == 2 assert ParameterizedExchange.select().count() == 1 diff --git a/tests/compatibility.py b/tests/compatibility.py index 25a1432a..54c20b45 100644 --- a/tests/compatibility.py +++ b/tests/compatibility.py @@ -9,6 +9,7 @@ databases, geomapping, get_activity, + get_node, get_multilca_data_objs, get_node, methods, @@ -65,27 +66,32 @@ def setup(): def test_prepare_lca_inputs_basic(setup): d, objs, r = prepare_lca_inputs(demand={("food", "1"): 1}, method=("foo",)) # ID is 3; two biosphere flows, then '1' is next written - assert d == {3: 1} + assert list(d.values()) == [1] assert {o.metadata["id"] for o in objs} == {o.datapackage().metadata["id"] for o in setup} + b1 = get_node(database="biosphere", code='1').id + b2 = get_node(database="biosphere", code='2').id + f1 = get_node(database="food", code='1').id + f2 = get_node(database="food", code='2').id + remapping_expected = { "activity": { - 1: ("biosphere", "1"), - 2: ("biosphere", "2"), - 3: ("food", "1"), - 4: ("food", "2"), + b1: ("biosphere", "1"), + b2: ("biosphere", "2"), + f1: ("food", "1"), + f2: ("food", "2"), }, "product": { - 1: ("biosphere", "1"), - 2: ("biosphere", "2"), - 3: ("food", "1"), - 4: ("food", "2"), + b1: ("biosphere", "1"), + b2: ("biosphere", "2"), + f1: ("food", "1"), + f2: ("food", "2"), }, "biosphere": { - 1: ("biosphere", "1"), - 2: ("biosphere", "2"), - 3: ("food", "1"), - 4: ("food", "2"), + b1: ("biosphere", "1"), + b2: ("biosphere", "2"), + f1: ("food", "1"), + f2: ("food", "2"), }, } assert r == remapping_expected @@ -112,8 +118,9 @@ def test_prepare_lca_inputs_multiple_demands(setup): d, objs, r = prepare_lca_inputs( demands=[{("food", "1"): 1}, {("food", "2"): 10}], method=("foo",) ) - # ID is 3; two biosphere flows, then '1' is next written - assert d == [{3: 1}, {4: 10}] + f1 = get_node(database="food", code='1').id + f2 = get_node(database="food", code='2').id + assert d == [{f1: 1}, {f2: 10}] assert {o.metadata["id"] for o in objs} == {o.datapackage().metadata["id"] for o in setup} diff --git a/tests/database.py b/tests/database.py index 0950c8e1..0070e222 100644 --- a/tests/database.py +++ b/tests/database.py @@ -19,6 +19,7 @@ from bw2data.backends import Activity as PWActivity from bw2data.backends import sqlite3_lci_db from bw2data.database import Database +from bw2data.snowflake_ids import EPOCH_START_MS from bw2data.errors import ( DuplicateNode, InvalidExchange, @@ -63,7 +64,7 @@ def test_get_code(): activity = d.get("1") assert isinstance(activity, PWActivity) assert activity["name"] == "an emission" - assert activity.id == 1 + assert activity.id > EPOCH_START_MS @bw2test @@ -73,7 +74,7 @@ def test_get_kwargs(): activity = d.get(name="an emission") assert isinstance(activity, PWActivity) assert activity["name"] == "an emission" - assert activity.id == 1 + assert activity.id > EPOCH_START_MS @bw2test @@ -541,7 +542,7 @@ def test_processed_array_with_metadata(): "reference product": np.NaN, "unit": "something", "location": np.NaN, - "id": 1, + "id": df.id[0], } ] ) @@ -716,7 +717,7 @@ def test_process_without_exchanges_still_in_processed_array(): package = database.datapackage() array = package.get_resource("a_database_technosphere_matrix.data")[0] - assert array[0] == 1 + assert array[0] == 1. assert array.shape == (1,) @@ -774,8 +775,6 @@ def test_new_node_error(): with pytest.raises(DuplicateNode): database.new_node("foo") - with pytest.raises(DuplicateNode): - database.new_node(code="bar", id=act.id) @bw2test diff --git a/tests/ia.py b/tests/ia.py index c51b9fce..279aa5ad 100644 --- a/tests/ia.py +++ b/tests/ia.py @@ -278,8 +278,11 @@ def test_method_geocollection(): } ) + f1 = get_node(code="1").id + f2 = get_node(code="2").id + m = Method(("foo",)) - m.write([(1, 2, "RU"), (3, 4, ("foo", "bar"))]) + m.write([(f1, 2, "RU"), (f2, 4, ("foo", "bar"))]) assert m.metadata["geocollections"] == ["foo", "world"] @@ -294,11 +297,14 @@ def test_method_geocollection_missing_ok(): } ) + f1 = get_node(code="1").id + f3 = get_node(code="3").id + m = Method(("foo",)) m.write( [ - (1, 2, None), - (3, 4), + (f1, 2, None), + (f3, 4), ] ) assert m.metadata["geocollections"] == ["world"] @@ -313,10 +319,12 @@ def test_method_geocollection_warning(): } ) + f1 = get_node(code="1").id + m = Method(("foo",)) m.write( [ - (1, 2, "Russia"), + (f1, 2, "Russia"), ] ) assert m.metadata["geocollections"] == [] diff --git a/tests/utils.py b/tests/utils.py index 48203ba0..7190b485 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,6 +6,7 @@ from bw2data.backends import Activity as PWActivity from bw2data.errors import MultipleResults, UnknownObject, ValidityError from bw2data.tests import BW2DataTest, bw2test +from bw2data.snowflake_ids import EPOCH_START_MS from bw2data.utils import ( as_uncertainty_dict, combine_methods, @@ -208,7 +209,7 @@ def test_as_uncertainty_dict_set_negative(): def test_get_node_normal(): Database("biosphere").write(biosphere) node = get_node(name="an emission") - assert node.id == 1 + assert node.id > EPOCH_START_MS assert isinstance(node, PWActivity) @@ -229,7 +230,7 @@ def test_get_node_key(): def test_get_node_multiple_filters(): Database("biosphere").write(biosphere) node = get_node(name="an emission", type="emission") - assert node.id == 1 + assert node.id > EPOCH_START_MS assert isinstance(node, PWActivity) @@ -277,7 +278,7 @@ def test_get_node_extended_search(): @bw2test def test_get_activity_activity(): Database("biosphere").write(biosphere) - node = get_node(id=1) + node = get_node(code="1") found = get_activity(node) assert found is node @@ -285,33 +286,17 @@ def test_get_activity_activity(): @bw2test def test_get_activity_id(): Database("biosphere").write(biosphere) - node = get_activity(1) - assert node.id == 1 + node = get_activity(code="1") + node = get_activity(node.id) + assert node.id > EPOCH_START_MS assert isinstance(node, PWActivity) -@bw2test -def test_get_activity_id_different_ints(): - Database("biosphere").write(biosphere) - different_ints = [ - int(1), - np.intp(1), - np.int8(1), - np.int16(1), - np.int32(1), - np.int64(1), - ] - for i in different_ints: - node = get_activity(i) - assert node.id == i - assert isinstance(node, PWActivity) - - @bw2test def test_get_activity_key(): Database("biosphere").write(biosphere) node = get_activity(("biosphere", "1")) - assert node.id == 1 + assert node.id > EPOCH_START_MS assert isinstance(node, PWActivity) @@ -319,7 +304,7 @@ def test_get_activity_key(): def test_get_activity_kwargs(): Database("biosphere").write(biosphere) node = get_activity(name="an emission", type="emission") - assert node.id == 1 + assert node.id > EPOCH_START_MS assert isinstance(node, PWActivity) From 46f06aa8a7834d1949c7235f67c296864085c47d Mon Sep 17 00:00:00 2001 From: Chris Mutel Date: Thu, 31 Oct 2024 11:39:44 +0100 Subject: [PATCH 2/6] Continued progress and cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update compatibility.py Remove debugging print statements Clarify some comments Improve error message Cleanup error messages Co-Authored-By: João Gonçalves --- bw2data/backends/base.py | 3 +++ bw2data/backends/proxies.py | 21 +++++++++------------ bw2data/backends/schema.py | 6 ++++-- bw2data/backends/utils.py | 2 ++ bw2data/snowflake_ids.py | 4 ++-- tests/activity_proxy.py | 13 ------------- tests/compatibility.py | 2 +- 7 files changed, 21 insertions(+), 30 deletions(-) diff --git a/bw2data/backends/base.py b/bw2data/backends/base.py index 761b809e..2eef82bb 100644 --- a/bw2data/backends/base.py +++ b/bw2data/backends/base.py @@ -687,6 +687,9 @@ def new_node(self, code: str = None, **kwargs): kwargs.pop("database") obj["database"] = self.name + if "id" in kwargs: + raise ValueError(f"`id` must be created automatically, but `id={kwargs['id']}` given.") + if code is None: obj["code"] = uuid.uuid4().hex else: diff --git a/bw2data/backends/proxies.py b/bw2data/backends/proxies.py index be316292..677c01cd 100644 --- a/bw2data/backends/proxies.py +++ b/bw2data/backends/proxies.py @@ -497,10 +497,10 @@ def new_edge(self, **kwargs): """Create a new exchange linked to this activity""" exc = Exchange() exc.output = self.key - for key in kwargs: + for key, value in kwargs.items(): if key == "id": - raise ValueError("`id` must be created automatically") - exc[key] = kwargs[key] + raise ValueError(f"`id` must be created automatically, but `id={value}` given.") + exc[key] = value return exc def copy(self, code: Optional[str] = None, signal: bool = True, **kwargs): @@ -514,22 +514,19 @@ def copy(self, code: Optional[str] = None, signal: bool = True, **kwargs): activity = Activity() for key, value in self.items(): if key != "id": - print(key, value) activity[key] = value - for k, v in kwargs.items(): - if k == "id": - raise ValueError("`id` must be generated automatically") - activity._data[k] = v + for key, value in kwargs.items(): + if key == "id": + raise ValueError(f"`id` must be created automatically, but `id={value}` given.") + activity._data[key] = value activity._data["code"] = str(code or uuid.uuid4().hex) activity.save(signal=signal) - print("Copy Document ID", activity._document.id) - print("Original Document ID", self._document.id) - for exc in self.exchanges(): data = copy.deepcopy(exc._data) if 'id' in data: - # New snowflake ID will be inserted by `_dict_as_exchangedataset` + # New snowflake ID will be inserted by `.save()`; shouldn't be copied over + # or specified manually del data['id'] data["output"] = activity.key # Change `input` for production exchanges diff --git a/bw2data/backends/schema.py b/bw2data/backends/schema.py index e32a1770..78087e1d 100644 --- a/bw2data/backends/schema.py +++ b/bw2data/backends/schema.py @@ -11,8 +11,10 @@ class SnowflakeIDBaseClass(SignaledDataset): def save(self, **kwargs): if self.id is None: - # If the primary key is already present, peewee will make an `UPDATE` query. - # This will have no effect if there isn't a matching row + # If the primary key column data is already present (even if the object doesn't exist in + # the database), peewee will make an `UPDATE` query. This will have no effect if there + # isn't a matching row. Need for force an `INSERT` query instead as we generate the ids + # ourselves. # https://docs.peewee-orm.com/en/latest/peewee/models.html#id4 self.id = next(snowflake_id_generator) kwargs['force_insert'] = True diff --git a/bw2data/backends/utils.py b/bw2data/backends/utils.py index 13797d9c..3060dab4 100644 --- a/bw2data/backends/utils.py +++ b/bw2data/backends/utils.py @@ -78,6 +78,8 @@ def dict_as_activitydataset(ds: Any, add_snowflake_id: bool = False) -> dict: "product": ds.get("reference product"), "type": ds.get("type", labels.process_node_default), } + # Use during `insert_many` calls as these skip auto id generation because they don't call + # `.save()` if add_snowflake_id: val['id'] = next(snowflake_id_generator) return val diff --git a/bw2data/snowflake_ids.py b/bw2data/snowflake_ids.py index 9a9bf082..45c0b889 100644 --- a/bw2data/snowflake_ids.py +++ b/bw2data/snowflake_ids.py @@ -13,8 +13,8 @@ # Timestamp: 41 bits. Milliseconds since the epoch or custom epoch. # Datacenter ID: 5 bits, which gives us 2 ^ 5 = 32 datacenters. # Machine ID: 5 bits, which gives us 2 ^ 5 = 32 machines per datacenter. -# However, `snowflake-id` lumps these two values datacenter and machine id values together into an -# `instance` value with 2 ^ 10 = 1024 possible values. +# However, `snowflake-id` lumps the two datacenter and machine id values together into an +# `instance` parameter with 2 ^ 10 = 1024 possible values. # Sequence number: 12 bits. For every ID generated on that machine/process, the sequence number is # incremented by 1. The number is reset to 0 every millisecond. snowflake_id_generator = SnowflakeGenerator(instance=uuid.getnode() % 1024, epoch=EPOCH_START_MS) diff --git a/tests/activity_proxy.py b/tests/activity_proxy.py index 55a897c0..a16af223 100644 --- a/tests/activity_proxy.py +++ b/tests/activity_proxy.py @@ -180,14 +180,8 @@ def test_copy(activity): assert cp["location"] == "bar" assert ExchangeDataset.select().count() == 2 - for ds in ActivityDataset.select(): - print(ds, ds.name, ds.code) - cp.save() - for ds in ActivityDataset.select(): - print(ds, ds.name, ds.code) - assert ActivityDataset.select().count() == 2 assert ( ActivityDataset.select() @@ -241,9 +235,6 @@ def test_copy_with_kwargs(activity): @bw2test def test_delete_activity_parameters(): - from bw2data import projects - print(projects.dir) - db = DatabaseChooser("example") db.register() @@ -253,10 +244,6 @@ def test_delete_activity_parameters(): b.save() a.new_exchange(amount=0, input=b, type="technosphere", formula="foo * bar + 4").save() - print("Iterating over exchanges:") - for exc in ExchangeDataset.select(): - print("Next:", exc, exc.id) - assert ExchangeDataset.select().count() == 1 activity_data = [ diff --git a/tests/compatibility.py b/tests/compatibility.py index 54c20b45..dc6616fb 100644 --- a/tests/compatibility.py +++ b/tests/compatibility.py @@ -110,7 +110,7 @@ def test_prepare_lca_inputs_multiple_demands_data_types(setup): first = get_node(database="food", code="1") second = get_node(database="food", code="2") d, objs, r = prepare_lca_inputs(demands=[{first: 1}, {second.id: 10}], method=("foo",)) - assert d == [{3: 1}, {4: 10}] + assert d == [{first.id: 1}, {second.id: 10}] assert {o.metadata["id"] for o in objs} == {o.datapackage().metadata["id"] for o in setup} From 9780dfadeaddc93efb3bda6548d19922bc682a57 Mon Sep 17 00:00:00 2001 From: Chris Mutel Date: Fri, 8 Nov 2024 10:52:06 +0100 Subject: [PATCH 3/6] Resolve util func naming discrepancy --- bw2data/backends/base.py | 4 ++-- bw2data/backends/proxies.py | 4 ++-- bw2data/backends/utils.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bw2data/backends/base.py b/bw2data/backends/base.py index 2eef82bb..84bda8fd 100644 --- a/bw2data/backends/base.py +++ b/bw2data/backends/base.py @@ -28,7 +28,7 @@ from bw2data.backends.utils import ( check_exchange, dict_as_activitydataset, - _dict_as_exchangedataset, + dict_as_exchangedataset, get_csv_data_dict, retupleize_geo_strings, ) @@ -525,7 +525,7 @@ def _efficient_write_dataset( if "output" not in exchange: exchange["output"] = (ds["database"], ds["code"]) - exchanges.append(_dict_as_exchangedataset(exchange)) + exchanges.append(dict_as_exchangedataset(exchange)) # Query gets passed as INSERT INTO x VALUES ('?', '?'...) # SQLite3 has a limit of 999 variables, diff --git a/bw2data/backends/proxies.py b/bw2data/backends/proxies.py index 677c01cd..ba085c36 100644 --- a/bw2data/backends/proxies.py +++ b/bw2data/backends/proxies.py @@ -14,7 +14,7 @@ check_exchange_keys, check_exchange_type, ) -from bw2data.backends.utils import dict_as_activitydataset, _dict_as_exchangedataset +from bw2data.backends.utils import dict_as_activitydataset, dict_as_exchangedataset from bw2data.configuration import labels from bw2data.errors import ValidityError from bw2data.logs import stdout_feedback_logger @@ -574,7 +574,7 @@ def save(self, signal: bool = True, data_already_set: bool = False, force_insert check_exchange_type(self._data.get("type")) check_exchange_keys(self) - for key, value in _dict_as_exchangedataset(self._data).items(): + for key, value in dict_as_exchangedataset(self._data).items(): setattr(self._document, key, value) self._document.save(signal=signal, force_insert=force_insert) diff --git a/bw2data/backends/utils.py b/bw2data/backends/utils.py index 3060dab4..0e4a55cd 100644 --- a/bw2data/backends/utils.py +++ b/bw2data/backends/utils.py @@ -85,7 +85,7 @@ def dict_as_activitydataset(ds: Any, add_snowflake_id: bool = False) -> dict: return val -def _dict_as_exchangedataset(ds: Any) -> dict: +def dict_as_exchangedataset(ds: Any) -> dict: return { "data": ds, "input_database": ds["input"][0], From e4fc87bea37b4f89706a0d652a3f7bd16b592ac8 Mon Sep 17 00:00:00 2001 From: Chris Mutel Date: Fri, 8 Nov 2024 10:52:20 +0100 Subject: [PATCH 4/6] Single source for snowflake ids --- bw2data/revisions.py | 4 ++-- tests/unit/test_node_edge_events.py | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bw2data/revisions.py b/bw2data/revisions.py index e418e308..ffcdacfc 100644 --- a/bw2data/revisions.py +++ b/bw2data/revisions.py @@ -2,8 +2,8 @@ from typing import Any, Optional, Sequence, TypeVar import deepdiff -from snowflake import SnowflakeGenerator as sfg +from bw2data.snowflake_ids import snowflake_id_generator from bw2data.backends.proxies import Activity, Exchange from bw2data.backends.schema import ActivityDataset, ExchangeDataset, SignaledDataset from bw2data.backends.utils import dict_as_activitydataset, dict_as_exchangedataset @@ -186,7 +186,7 @@ def generate_metadata( ) -> dict[str, Any]: metadata = metadata or {} metadata["parent_revision"] = parent_revision - metadata["revision"] = revision or next(sfg(0)) + metadata["revision"] = revision or next(snowflake_id_generator) metadata["authors"] = metadata.get("authors", "Anonymous") metadata["title"] = metadata.get("title", "Untitled revision") metadata["description"] = metadata.get("description", "No description") diff --git a/tests/unit/test_node_edge_events.py b/tests/unit/test_node_edge_events.py index 205adfe5..810fbbb6 100644 --- a/tests/unit/test_node_edge_events.py +++ b/tests/unit/test_node_edge_events.py @@ -1,13 +1,13 @@ import json import pytest -from snowflake import SnowflakeGenerator as sfg from bw2data import get_node from bw2data.backends.schema import ExchangeDataset from bw2data.database import DatabaseChooser from bw2data.project import projects from bw2data.tests import bw2test +from bw2data.snowflake_ids import snowflake_id_generator @bw2test @@ -68,7 +68,7 @@ def test_node_revision_apply_create(): database = DatabaseChooser("db") database.register() - revision_id = next(sfg(0)) + revision_id = next(snowflake_id_generator) revision = { "data": [ { @@ -172,7 +172,7 @@ def test_node_revision_apply_delete(): node.save() assert len(database) == 1 - revision_id = next(sfg(0)) + revision_id = next(snowflake_id_generator) revision = { "data": [ @@ -261,7 +261,7 @@ def test_node_revision_apply_update(): node = database.new_node(code="A", name="A", location="kalamazoo") node.save() - revision_id = next(sfg(0)) + revision_id = next(snowflake_id_generator) revision = { "data": [ @@ -355,7 +355,7 @@ def test_node_revision_apply_activity_database_change(): node.new_edge(input=node, type="production", amount=1.0).save() assert len(node.exchanges()) == 2 - revision_id = next(sfg(0)) + revision_id = next(snowflake_id_generator) num_revisions_before = len([ fp @@ -459,7 +459,7 @@ def test_node_revision_apply_activity_code_change(): node.new_edge(input=node, type="production", amount=1.0).save() assert len(node.exchanges()) == 2 - revision_id = next(sfg(0)) + revision_id = next(snowflake_id_generator) num_revisions_before = len([ fp @@ -660,9 +660,9 @@ def test_node_revision_apply_activity_copy(): projects.dataset.set_sourced() - revision_id_1 = next(sfg(0)) - revision_id_2 = next(sfg(0)) - revision_id_3 = next(sfg(0)) + revision_id_1 = next(snowflake_id_generator) + revision_id_2 = next(snowflake_id_generator) + revision_id_3 = next(snowflake_id_generator) num_revisions_before = len([ fp From 29d615a3f77592fec69de75d36f8604c228aa9de Mon Sep 17 00:00:00 2001 From: Chris Mutel Date: Fri, 8 Nov 2024 11:42:59 +0100 Subject: [PATCH 5/6] Fix ORM class inheritance hierarchy --- bw2data/backends/base.py | 4 +- bw2data/backends/proxies.py | 4 +- bw2data/backends/schema.py | 20 +------- bw2data/backends/utils.py | 8 ++-- bw2data/project.py | 8 +--- bw2data/revisions.py | 8 ++-- bw2data/snowflake_ids.py | 21 +++++++- tests/compatibility.py | 13 +++-- tests/conftest.py | 1 + tests/database.py | 12 ++--- tests/fixtures/__init__.py | 2 +- tests/test_schema_migrations.py | 19 ++++---- tests/unit/test_node_edge_events.py | 74 +++++++++++++++++------------ tests/unit/test_schema_sourcing.py | 6 +-- tests/utils.py | 2 +- 15 files changed, 106 insertions(+), 96 deletions(-) diff --git a/bw2data/backends/base.py b/bw2data/backends/base.py index 84bda8fd..39f448c3 100644 --- a/bw2data/backends/base.py +++ b/bw2data/backends/base.py @@ -856,7 +856,9 @@ def _add_inventory_geomapping_to_datapackage(self, dp: Datapackage) -> None: dict_iterator=( { "row": row[0], - "col": geomapping[location_mapper(retupleize_geo_strings(row[1]) or config.global_location)], + "col": geomapping[ + location_mapper(retupleize_geo_strings(row[1]) or config.global_location) + ], "amount": 1, } for row in inv_mapping_qs.tuples() diff --git a/bw2data/backends/proxies.py b/bw2data/backends/proxies.py index ba085c36..ab300903 100644 --- a/bw2data/backends/proxies.py +++ b/bw2data/backends/proxies.py @@ -524,10 +524,10 @@ def copy(self, code: Optional[str] = None, signal: bool = True, **kwargs): for exc in self.exchanges(): data = copy.deepcopy(exc._data) - if 'id' in data: + if "id" in data: # New snowflake ID will be inserted by `.save()`; shouldn't be copied over # or specified manually - del data['id'] + del data["id"] data["output"] = activity.key # Change `input` for production exchanges if exc["input"] == exc["output"]: diff --git a/bw2data/backends/schema.py b/bw2data/backends/schema.py index 78087e1d..a92a5579 100644 --- a/bw2data/backends/schema.py +++ b/bw2data/backends/schema.py @@ -1,24 +1,8 @@ -from peewee import DoesNotExist, Model, TextField, IntegerField +from peewee import DoesNotExist, TextField from bw2data.errors import UnknownObject -from bw2data.signals import SignaledDataset +from bw2data.snowflake_ids import SnowflakeIDBaseClass from bw2data.sqlite import PickleField -from bw2data.snowflake_ids import snowflake_id_generator - - -class SnowflakeIDBaseClass(SignaledDataset): - id = IntegerField(primary_key=True) - - def save(self, **kwargs): - if self.id is None: - # If the primary key column data is already present (even if the object doesn't exist in - # the database), peewee will make an `UPDATE` query. This will have no effect if there - # isn't a matching row. Need for force an `INSERT` query instead as we generate the ids - # ourselves. - # https://docs.peewee-orm.com/en/latest/peewee/models.html#id4 - self.id = next(snowflake_id_generator) - kwargs['force_insert'] = True - super().save(**kwargs) class ActivityDataset(SnowflakeIDBaseClass): diff --git a/bw2data/backends/utils.py b/bw2data/backends/utils.py index 0e4a55cd..d3287564 100644 --- a/bw2data/backends/utils.py +++ b/bw2data/backends/utils.py @@ -1,15 +1,15 @@ -from typing import Any import copy import warnings -from typing import Optional +from typing import Any, Optional import numpy as np from bw2data import config -from bw2data.backends.schema import SignaledDataset, get_id +from bw2data.backends.schema import get_id from bw2data.configuration import labels from bw2data.errors import InvalidExchange, UntypedExchange from bw2data.meta import databases, methods +from bw2data.signals import SignaledDataset from bw2data.snowflake_ids import snowflake_id_generator @@ -81,7 +81,7 @@ def dict_as_activitydataset(ds: Any, add_snowflake_id: bool = False) -> dict: # Use during `insert_many` calls as these skip auto id generation because they don't call # `.save()` if add_snowflake_id: - val['id'] = next(snowflake_id_generator) + val["id"] = next(snowflake_id_generator) return val diff --git a/bw2data/project.py b/bw2data/project.py index 618f99e6..994fa4e3 100644 --- a/bw2data/project.py +++ b/bw2data/project.py @@ -7,7 +7,7 @@ from copy import copy from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Sequence, TypeVar +from typing import Any, Optional, Sequence, TypeVar import wrapt from bw_processing import safe_filename @@ -23,12 +23,6 @@ from bw2data.utils import maybe_path -if TYPE_CHECKING: - from bw2data import revisions - from bw2data.backends import schema - SD = TypeVar("SD", bound=schema.SignaledDataset) - - READ_ONLY_PROJECT = """ ***Read only project*** diff --git a/bw2data/revisions.py b/bw2data/revisions.py index ffcdacfc..6148c1a5 100644 --- a/bw2data/revisions.py +++ b/bw2data/revisions.py @@ -3,11 +3,12 @@ import deepdiff -from bw2data.snowflake_ids import snowflake_id_generator from bw2data.backends.proxies import Activity, Exchange -from bw2data.backends.schema import ActivityDataset, ExchangeDataset, SignaledDataset +from bw2data.backends.schema import ActivityDataset, ExchangeDataset from bw2data.backends.utils import dict_as_activitydataset, dict_as_exchangedataset from bw2data.errors import DifferentObjects, IncompatibleClasses, InconsistentData +from bw2data.signals import SignaledDataset +from bw2data.snowflake_ids import snowflake_id_generator from bw2data.utils import get_node try: @@ -16,9 +17,6 @@ from typing_extensions import Self -SD = TypeVar("SD", bound=SignaledDataset) - - class RevisionGraph: """Graph of revisions, edges are based on `metadata.parent_revision`.""" diff --git a/bw2data/snowflake_ids.py b/bw2data/snowflake_ids.py index 45c0b889..3872cafe 100644 --- a/bw2data/snowflake_ids.py +++ b/bw2data/snowflake_ids.py @@ -1,6 +1,10 @@ -from snowflake import SnowflakeGenerator import uuid +from peewee import IntegerField +from snowflake import SnowflakeGenerator + +from bw2data.signals import SignaledDataset + # Jan 1, 2024 # from datetime import datetime # (datetime(2024, 1, 1) - datetime.utcfromtimestamp(0)).total_seconds() * 1000.0 @@ -18,3 +22,18 @@ # Sequence number: 12 bits. For every ID generated on that machine/process, the sequence number is # incremented by 1. The number is reset to 0 every millisecond. snowflake_id_generator = SnowflakeGenerator(instance=uuid.getnode() % 1024, epoch=EPOCH_START_MS) + + +class SnowflakeIDBaseClass(SignaledDataset): + id = IntegerField(primary_key=True) + + def save(self, **kwargs): + if self.id is None: + # If the primary key column data is already present (even if the object doesn't exist in + # the database), peewee will make an `UPDATE` query. This will have no effect if there + # isn't a matching row. Need for force an `INSERT` query instead as we generate the ids + # ourselves. + # https://docs.peewee-orm.com/en/latest/peewee/models.html#id4 + self.id = next(snowflake_id_generator) + kwargs["force_insert"] = True + super().save(**kwargs) diff --git a/tests/compatibility.py b/tests/compatibility.py index dc6616fb..d7777fae 100644 --- a/tests/compatibility.py +++ b/tests/compatibility.py @@ -9,7 +9,6 @@ databases, geomapping, get_activity, - get_node, get_multilca_data_objs, get_node, methods, @@ -69,10 +68,10 @@ def test_prepare_lca_inputs_basic(setup): assert list(d.values()) == [1] assert {o.metadata["id"] for o in objs} == {o.datapackage().metadata["id"] for o in setup} - b1 = get_node(database="biosphere", code='1').id - b2 = get_node(database="biosphere", code='2').id - f1 = get_node(database="food", code='1').id - f2 = get_node(database="food", code='2').id + b1 = get_node(database="biosphere", code="1").id + b2 = get_node(database="biosphere", code="2").id + f1 = get_node(database="food", code="1").id + f2 = get_node(database="food", code="2").id remapping_expected = { "activity": { @@ -118,8 +117,8 @@ def test_prepare_lca_inputs_multiple_demands(setup): d, objs, r = prepare_lca_inputs( demands=[{("food", "1"): 1}, {("food", "2"): 10}], method=("foo",) ) - f1 = get_node(database="food", code='1').id - f2 = get_node(database="food", code='2').id + f1 = get_node(database="food", code="1").id + f2 = get_node(database="food", code="2").id assert d == [{f1: 1}, {f2: 10}] assert {o.metadata["id"] for o in objs} == {o.datapackage().metadata["id"] for o in setup} diff --git a/tests/conftest.py b/tests/conftest.py index db094fa3..c8cf5bcb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ """Fixtures for bw2data""" + import sqlite3 # import pytest diff --git a/tests/database.py b/tests/database.py index 0070e222..7ac20476 100644 --- a/tests/database.py +++ b/tests/database.py @@ -11,15 +11,14 @@ calculation_setups, databases, geomapping, - projects, get_activity, get_id, get_node, + projects, ) from bw2data.backends import Activity as PWActivity from bw2data.backends import sqlite3_lci_db from bw2data.database import Database -from bw2data.snowflake_ids import EPOCH_START_MS from bw2data.errors import ( DuplicateNode, InvalidExchange, @@ -33,6 +32,7 @@ ParameterizedExchange, parameters, ) +from bw2data.snowflake_ids import EPOCH_START_MS from bw2data.tests import bw2test from .fixtures import biosphere @@ -105,10 +105,10 @@ def test_copy(food): def test_copy_metadata(food): d = Database("food") - d.metadata['custom'] = "something" + d.metadata["custom"] = "something" d.copy("repas") assert "repas" in databases - assert databases['repas']['custom'] == 'something' + assert databases["repas"]["custom"] == "something" @bw2test @@ -458,7 +458,7 @@ def test_geomapping_array_includes_only_processes(): @bw2test def test_geomapping_array_normalization(): database = Database("a database") - database.register(location_normalization={'RoW': 'GLO'}) + database.register(location_normalization={"RoW": "GLO"}) database.write( { ("a database", "foo"): { @@ -717,7 +717,7 @@ def test_process_without_exchanges_still_in_processed_array(): package = database.datapackage() array = package.get_resource("a_database_technosphere_matrix.data")[0] - assert array[0] == 1. + assert array[0] == 1.0 assert array.shape == (1,) diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index b7becf7f..06353416 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -1 +1 @@ -from .basic import get_naughty, food2, food, biosphere, lcia +from .basic import biosphere, food, food2, get_naughty, lcia diff --git a/tests/test_schema_migrations.py b/tests/test_schema_migrations.py index 2799bc32..a2efd04e 100644 --- a/tests/test_schema_migrations.py +++ b/tests/test_schema_migrations.py @@ -1,9 +1,10 @@ -from pathlib import Path -from bw2data.project import add_sourced_columns, projects, ProjectDataset, config -from bw2data.tests import bw2test import shutil +from pathlib import Path + from peewee import SqliteDatabase +from bw2data.project import ProjectDataset, add_sourced_columns, config, projects +from bw2data.tests import bw2test original_projects_db = Path(__file__).parent / "fixtures" / "projects.db" @@ -27,13 +28,13 @@ def test_add_sourced_columns(tmp_path): columns = {o.name: o for o in db.get_columns("projectdataset")} assert "is_sourced" in columns - assert columns['is_sourced'].data_type.upper() == 'INTEGER' - assert columns['is_sourced'].default == '0' - assert columns['is_sourced'].null is True + assert columns["is_sourced"].data_type.upper() == "INTEGER" + assert columns["is_sourced"].default == "0" + assert columns["is_sourced"].null is True assert "revision" in columns - assert columns['revision'].data_type.upper() == 'INTEGER' - assert columns['revision'].default is None - assert columns['revision'].null is True + assert columns["revision"].data_type.upper() == "INTEGER" + assert columns["revision"].default is None + assert columns["revision"].null is True db = SqliteDatabase(tmp_path / "projects.backup-is-sourced.db") db.connect() diff --git a/tests/unit/test_node_edge_events.py b/tests/unit/test_node_edge_events.py index 810fbbb6..3400db6a 100644 --- a/tests/unit/test_node_edge_events.py +++ b/tests/unit/test_node_edge_events.py @@ -6,8 +6,8 @@ from bw2data.backends.schema import ExchangeDataset from bw2data.database import DatabaseChooser from bw2data.project import projects -from bw2data.tests import bw2test from bw2data.snowflake_ids import snowflake_id_generator +from bw2data.tests import bw2test @bw2test @@ -357,11 +357,13 @@ def test_node_revision_apply_activity_database_change(): revision_id = next(snowflake_id_generator) - num_revisions_before = len([ - fp - for fp in (projects.dataset.dir / "revisions").iterdir() - if fp.stem.lower() != "head" and fp.is_file() - ]) + num_revisions_before = len( + [ + fp + for fp in (projects.dataset.dir / "revisions").iterdir() + if fp.stem.lower() != "head" and fp.is_file() + ] + ) revision = { "data": [ @@ -393,11 +395,13 @@ def test_node_revision_apply_activity_database_change(): assert exc.input == node assert exc.output == node - num_revisions_after = len([ - fp - for fp in (projects.dataset.dir / "revisions").iterdir() - if fp.stem.lower() != "head" and fp.is_file() - ]) + num_revisions_after = len( + [ + fp + for fp in (projects.dataset.dir / "revisions").iterdir() + if fp.stem.lower() != "head" and fp.is_file() + ] + ) assert num_revisions_after == num_revisions_before @@ -461,11 +465,13 @@ def test_node_revision_apply_activity_code_change(): revision_id = next(snowflake_id_generator) - num_revisions_before = len([ - fp - for fp in (projects.dataset.dir / "revisions").iterdir() - if fp.stem.lower() != "head" and fp.is_file() - ]) + num_revisions_before = len( + [ + fp + for fp in (projects.dataset.dir / "revisions").iterdir() + if fp.stem.lower() != "head" and fp.is_file() + ] + ) revision = { "data": [ @@ -496,11 +502,13 @@ def test_node_revision_apply_activity_code_change(): assert exc.input == node assert exc.output == node - num_revisions_after = len([ - fp - for fp in (projects.dataset.dir / "revisions").iterdir() - if fp.stem.lower() != "head" and fp.is_file() - ]) + num_revisions_after = len( + [ + fp + for fp in (projects.dataset.dir / "revisions").iterdir() + if fp.stem.lower() != "head" and fp.is_file() + ] + ) assert num_revisions_after == num_revisions_before @@ -664,11 +672,13 @@ def test_node_revision_apply_activity_copy(): revision_id_2 = next(snowflake_id_generator) revision_id_3 = next(snowflake_id_generator) - num_revisions_before = len([ - fp - for fp in (projects.dataset.dir / "revisions").iterdir() - if fp.stem.lower() != "head" and fp.is_file() - ]) + num_revisions_before = len( + [ + fp + for fp in (projects.dataset.dir / "revisions").iterdir() + if fp.stem.lower() != "head" and fp.is_file() + ] + ) revisions = [ { @@ -798,11 +808,13 @@ def test_node_revision_apply_activity_copy(): assert exc.output == new_node assert exc["amount"] == 0.1 - num_revisions_after = len([ - fp - for fp in (projects.dataset.dir / "revisions").iterdir() - if fp.stem.lower() != "head" and fp.is_file() - ]) + num_revisions_after = len( + [ + fp + for fp in (projects.dataset.dir / "revisions").iterdir() + if fp.stem.lower() != "head" and fp.is_file() + ] + ) assert num_revisions_after == num_revisions_before diff --git a/tests/unit/test_schema_sourcing.py b/tests/unit/test_schema_sourcing.py index 0eb4861e..7cd90a92 100644 --- a/tests/unit/test_schema_sourcing.py +++ b/tests/unit/test_schema_sourcing.py @@ -12,7 +12,7 @@ @bw2test -@patch("bw2data.backends.schema.SignaledDataset.save") +@patch("bw2data.signals.SignaledDataset.save") def test_signaleddataset_save_is_called(*mocks: Mock): # On saving an `Activity`, the generic `SignaledDataset.save` method is called projects.set_current("activity-event") @@ -26,7 +26,7 @@ def test_signaleddataset_save_is_called(*mocks: Mock): @bw2test @patch("bw2data.signals.signaleddataset_on_save.send") -@patch("bw2data.backends.schema.SignaledDataset.get") +@patch("bw2data.signals.SignaledDataset.get") def test_signal_is_sent(*mocks: Mock): # On saving an `Activity`, the signal is sent projects.set_current("activity-event") @@ -48,7 +48,7 @@ def test_signal_received(*mocks: Mock): db.register() a = db.new_node(code="A", name="A") a.save() - bw2data.project._signal_dataset_saved("test", old=None, new=a._document) + bw2data.project.signal_dispatcher("test", old=None, new=a._document) for m in mocks: assert m.called diff --git a/tests/utils.py b/tests/utils.py index 7190b485..b6a82556 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,8 +5,8 @@ from bw2data import Database, Method, labels, methods from bw2data.backends import Activity as PWActivity from bw2data.errors import MultipleResults, UnknownObject, ValidityError -from bw2data.tests import BW2DataTest, bw2test from bw2data.snowflake_ids import EPOCH_START_MS +from bw2data.tests import BW2DataTest, bw2test from bw2data.utils import ( as_uncertainty_dict, combine_methods, From 209475a3a9416a2ce5136d0bb6a04e7d40ebb9b6 Mon Sep 17 00:00:00 2001 From: Chris Mutel Date: Fri, 8 Nov 2024 11:51:37 +0100 Subject: [PATCH 6/6] fixup! Fix ORM class inheritance hierarchy --- bw2data/project.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bw2data/project.py b/bw2data/project.py index 994fa4e3..f2a3de9b 100644 --- a/bw2data/project.py +++ b/bw2data/project.py @@ -7,7 +7,7 @@ from copy import copy from functools import partial from pathlib import Path -from typing import Any, Optional, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, Optional, Sequence import wrapt from bw_processing import safe_filename @@ -23,6 +23,10 @@ from bw2data.utils import maybe_path +if TYPE_CHECKING: + from bw2data import revisions + + READ_ONLY_PROJECT = """ ***Read only project***