plexosdb 1.4.1__tar.gz → 1.5.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: plexosdb
3
- Version: 1.4.1
3
+ Version: 1.5.0
4
4
  Summary: SQLite API for plexos XMLs
5
5
  Keywords: PLEXOS,Database,SQLite
6
6
  Author: Pedro Andres Sanchez Perez, Kodi Obika, mcllerena
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plexosdb"
3
- version = "1.4.1"
3
+ version = "1.5.0"
4
4
  readme = "README.md"
5
5
  license = {file = "LICENSE.txt"}
6
6
  keywords = ["PLEXOS", "Database", "SQLite"]
@@ -60,13 +60,21 @@ def check_attribute_exists(
60
60
  Returns
61
61
  -------
62
62
  bool
63
- True if the attribute exists for the object.
64
-
65
- Notes
66
- -----
67
- This check is not yet implemented.
63
+ True if the object has an assigned value for the attribute.
64
+ """
65
+ query = """
66
+ SELECT 1
67
+ FROM t_attribute_data AS data
68
+ JOIN t_object AS obj ON obj.object_id = data.object_id
69
+ JOIN t_attribute AS attr ON attr.attribute_id = data.attribute_id
70
+ JOIN t_class AS class ON class.class_id = obj.class_id
71
+ WHERE obj.name = ?
72
+ AND class.name = ?
73
+ AND attr.name = ?
74
+ AND attr.class_id = obj.class_id
75
+ LIMIT 1
68
76
  """
69
- raise NotImplementedError
77
+ return bool(db._db.query(query, (object_name, object_class, attribute_name)))
70
78
 
71
79
 
72
80
  def check_class_exists(db: PlexosDB, class_enum: ClassEnum) -> bool:
@@ -40,6 +40,7 @@ from .utils import (
40
40
  normalize_names,
41
41
  plan_property_inserts,
42
42
  resolve_membership_id,
43
+ _normalize_attribute_records,
43
44
  )
44
45
  from .xml_handler import XMLHandler
45
46
 
@@ -281,10 +282,6 @@ class PlexosDB:
281
282
  -------
282
283
  int
283
284
  attribute_id
284
-
285
- Notes
286
- -----
287
- By default, we add all objects to the system membership.
288
285
  """
289
286
  object_id = self.get_object_id(object_class, name=object_name)
290
287
  attribute_id = self.get_attribute_id(object_class, name=attribute_name)
@@ -293,7 +290,8 @@ class PlexosDB:
293
290
  query = (
294
291
  f"INSERT INTO {Schema.AttributeData.name}(object_id, attribute_id, value) VALUES({placeholders})"
295
292
  )
296
- attribute_id = self._db.execute(query, params)
293
+ result = self._db.execute(query, params)
294
+ assert result, f"Failed to add attribute '{attribute_name}' to object '{object_name}'."
297
295
  return attribute_id
298
296
 
299
297
  def add_band(
@@ -961,6 +959,140 @@ class PlexosDB:
961
959
  logger.debug(f"Successfully processed {len(records)} property and text records in batches")
962
960
  return
963
961
 
962
+ def add_attributes_from_records(
963
+ self,
964
+ records: list[dict[str, Any]],
965
+ /,
966
+ *,
967
+ object_class: ClassEnum,
968
+ chunksize: int = 10_000,
969
+ ) -> None:
970
+ """Bulk insert attribute values for objects.
971
+
972
+ Efficiently adds multiple object-level attribute values in batches.
973
+ This method is much more efficient than calling `add_attribute` repeatedly.
974
+
975
+ Each record should contain:
976
+ - 'name': object name
977
+ - 'attribute': attribute name
978
+ - 'value': attribute value
979
+
980
+ Alternatively, records may be provided in the following format:
981
+ - {'name': ..., 'Attr1': val1, 'Attr2': val2}
982
+
983
+ Optional:
984
+ - 'state'
985
+
986
+ Parameters
987
+ ----------
988
+ records : list[dict[str, Any]]
989
+ List of attribute records in explicit or wide format.
990
+ object_class : ClassEnum
991
+ Class of the objects.
992
+ chunksize : int, optional
993
+ Batch size for inserts, by default 10_000.
994
+
995
+ Returns
996
+ -------
997
+ None
998
+
999
+ See Also
1000
+ --------
1001
+ add_attribute
1002
+ add_properties_from_records
1003
+ add_memberships_from_records
1004
+
1005
+ Examples
1006
+ --------
1007
+ >>> records = [
1008
+ ... {"name": "2020", "Step Type": 4.0, "Chrono Step Count": 366.0},
1009
+ ... ]
1010
+
1011
+ >>> db.add_attributes_from_records(records, object_class=ClassEnum.Horizon)
1012
+ """
1013
+ if not records:
1014
+ logger.warning("No records provided for bulk attribute insertion")
1015
+ return
1016
+
1017
+ records = _normalize_attribute_records(records)
1018
+
1019
+ if chunksize < 1:
1020
+ msg = f"chunksize must be >= 1, received {chunksize}"
1021
+ raise ValueError(msg)
1022
+
1023
+ class_id = self.get_class_id(object_class)
1024
+
1025
+ object_names = tuple({record["name"] for record in records})
1026
+ name_to_object_id: dict[str, int] = {}
1027
+
1028
+ CHUNK = 900 # noqa: N806
1029
+ for i in range(0, len(object_names), CHUNK):
1030
+ chunk = object_names[i : i + CHUNK]
1031
+ object_placeholders = ", ".join("?" for _ in chunk)
1032
+
1033
+ object_rows = self._db.query(
1034
+ f"""
1035
+ SELECT object_id, name
1036
+ FROM t_object
1037
+ WHERE class_id = ?
1038
+ AND name IN ({object_placeholders})
1039
+ """,
1040
+ (class_id, *chunk),
1041
+ )
1042
+ name_to_object_id.update({name: object_id for object_id, name in object_rows})
1043
+
1044
+ attribute_rows = self._db.query(
1045
+ """
1046
+ SELECT attribute_id, name
1047
+ FROM t_attribute
1048
+ WHERE class_id = ?
1049
+ """,
1050
+ (class_id,),
1051
+ )
1052
+ name_to_attribute_id = {name: attribute_id for attribute_id, name in attribute_rows}
1053
+
1054
+ params: list[tuple[int, int, Any, Any]] = []
1055
+ seen: set[tuple[int, int]] = set()
1056
+
1057
+ for record in records:
1058
+ try:
1059
+ object_id = name_to_object_id[record["name"]]
1060
+ attribute_id = name_to_attribute_id[record["attribute"]]
1061
+ except KeyError as exc:
1062
+ raise KeyError(f"Invalid attribute record: {record}") from exc
1063
+
1064
+ key = (object_id, attribute_id)
1065
+ if key in seen:
1066
+ raise ValueError(
1067
+ f"Duplicate attribute record for object={record['name']!r}, "
1068
+ f"attribute={record['attribute']!r}"
1069
+ )
1070
+ seen.add(key)
1071
+
1072
+ params.append(
1073
+ (
1074
+ object_id,
1075
+ attribute_id,
1076
+ record["value"],
1077
+ record.get("state"),
1078
+ )
1079
+ )
1080
+
1081
+ query = f"""
1082
+ INSERT INTO {Schema.AttributeData.name}
1083
+ (object_id, attribute_id, value, state)
1084
+ VALUES (?, ?, ?, ?)
1085
+ """
1086
+
1087
+ with self._db.transaction():
1088
+ for batch in batched(params, chunksize):
1089
+ result = self._db.executemany(query, list(batch))
1090
+ if not result:
1091
+ msg = f"Failed to add attribute values for {object_class}."
1092
+ raise RuntimeError(msg)
1093
+
1094
+ logger.debug("Added {} attribute values.", len(params))
1095
+
964
1096
  def _handle_dates(
965
1097
  self,
966
1098
  data_id: int,
@@ -1365,14 +1497,25 @@ class PlexosDB:
1365
1497
  original_object_name: str,
1366
1498
  new_object_name: str,
1367
1499
  copy_properties: bool = True,
1500
+ copy_attributes: bool = True,
1368
1501
  ) -> int:
1369
- """Copy an object and its properties, tags, and texts."""
1502
+ """Copy an object and its memberships, attributes, properties, tags and texts."""
1370
1503
  object_id = self.get_object_id(object_class, name=original_object_name)
1371
1504
  category_id = self.query("SELECT category_id from t_object WHERE object_id = ?", (object_id,))
1372
1505
  category = self.query("SELECT name from t_category WHERE category_id = ?", (category_id[0][0],))
1506
+
1373
1507
  new_object_id = self.add_object(object_class, new_object_name, category=category[0][0])
1508
+
1509
+ if copy_attributes:
1510
+ self._copy_object_attributes(
1511
+ old_object_id=object_id,
1512
+ new_object_id=new_object_id,
1513
+ )
1514
+
1374
1515
  membership_mapping = self.copy_object_memberships(
1375
- object_class=object_class, original_name=original_object_name, new_name=new_object_name
1516
+ object_class=object_class,
1517
+ original_name=original_object_name,
1518
+ new_name=new_object_name,
1376
1519
  )
1377
1520
 
1378
1521
  system_collection = get_default_collection(object_class)
@@ -1550,6 +1693,16 @@ class PlexosDB:
1550
1693
  self._db.execute("DROP TABLE IF EXISTS temp_data_mapping")
1551
1694
  return True
1552
1695
 
1696
+ def _copy_object_attributes(self, old_object_id: int, new_object_id: int) -> bool:
1697
+ """Copy attribute values from original object to new object."""
1698
+ query = """
1699
+ INSERT INTO t_attribute_data (object_id, attribute_id, value, state)
1700
+ SELECT ?, attribute_id, value, state
1701
+ FROM t_attribute_data
1702
+ WHERE object_id = ?
1703
+ """
1704
+ return self._db.execute(query, (new_object_id, old_object_id))
1705
+
1553
1706
  def create_object_scenario(
1554
1707
  self,
1555
1708
  object_name: str,
@@ -1717,8 +1870,62 @@ class PlexosDB:
1717
1870
  object_name: str,
1718
1871
  object_class: ClassEnum,
1719
1872
  ) -> None:
1720
- """Delete an attribute from an object."""
1721
- raise NotImplementedError # pragma: no cover
1873
+ """Delete an attribute value from an object.
1874
+
1875
+ Removes an object-level attribute value from the database.
1876
+
1877
+ Parameters
1878
+ ----------
1879
+ attribute_name : str
1880
+ Name of the attribute to delete.
1881
+ object_name : str
1882
+ Name of the object the attribute belongs to.
1883
+ object_class : ClassEnum
1884
+ Class enumeration of the object.
1885
+
1886
+ Returns
1887
+ -------
1888
+ None
1889
+
1890
+ Raises
1891
+ ------
1892
+ NotFoundError
1893
+ If the object does not exist, the attribute is not defined for the
1894
+ object's class or the attribute value is not assigned to the object.
1895
+
1896
+ Examples
1897
+ --------
1898
+ >>> db.delete_attribute(
1899
+ ... "Enabled",
1900
+ ... object_name="Base_Model",
1901
+ ... object_class=ClassEnum.Model,
1902
+ ... )
1903
+ """
1904
+ if not checks_module.check_object_exists(self, object_class, object_name):
1905
+ msg = f"Object = `{object_name}` does not exist for class `{object_class}`."
1906
+ raise NotFoundError(msg)
1907
+
1908
+ object_id = self.get_object_id(object_class, name=object_name)
1909
+ attribute_id = self.get_attribute_id(object_class, name=attribute_name)
1910
+
1911
+ find_query = """
1912
+ SELECT 1
1913
+ FROM t_attribute_data
1914
+ WHERE object_id = ? AND attribute_id = ?
1915
+ """
1916
+ result = self._db.fetchone(find_query, (object_id, attribute_id))
1917
+
1918
+ if not result:
1919
+ msg = f"Attribute '{attribute_name}' not found for object '{object_name}'."
1920
+ raise NotFoundError(msg)
1921
+
1922
+ delete_query = """
1923
+ DELETE FROM t_attribute_data
1924
+ WHERE object_id = ? AND attribute_id = ?
1925
+ """
1926
+
1927
+ with self._db.transaction():
1928
+ self._db.execute(delete_query, (object_id, attribute_id))
1722
1929
 
1723
1930
  def delete_category(self, category: str, /, *, class_name: ClassEnum) -> None:
1724
1931
  """Delete a category from the database."""
@@ -1924,7 +2131,7 @@ class PlexosDB:
1924
2131
  object_name: str,
1925
2132
  attribute_name: str,
1926
2133
  ) -> Any:
1927
- """Get attribute details for a specific object."""
2134
+ """Get an assigned attribute value for a specific object."""
1928
2135
  query = """
1929
2136
  SELECT
1930
2137
  t_attribute_data.value
@@ -1956,12 +2163,12 @@ class PlexosDB:
1956
2163
  Returns
1957
2164
  -------
1958
2165
  int
1959
- ID of the category
2166
+ ID of the attribute
1960
2167
 
1961
2168
  Raises
1962
2169
  ------
1963
- AssertionError
1964
- If the category does not exist
2170
+ NotFoundError
2171
+ If the attribute does not exist for the specified class.
1965
2172
  """
1966
2173
  query = """
1967
2174
  SELECT
@@ -1975,7 +2182,9 @@ class PlexosDB:
1975
2182
  AND t_class.name = ?
1976
2183
  """
1977
2184
  result = self._db.fetchone(query, (name, class_enum))
1978
- assert result
2185
+ if not result:
2186
+ msg = f"Attribute '{name}' not found for class '{class_enum}'."
2187
+ raise NotFoundError(msg)
1979
2188
  return cast(int, result[0])
1980
2189
 
1981
2190
  def get_attributes(
@@ -1984,10 +2193,67 @@ class PlexosDB:
1984
2193
  /,
1985
2194
  *,
1986
2195
  object_class: ClassEnum,
1987
- attribute_names: list[str] | None = None,
2196
+ attribute_names: str | Iterable[str] | None = None,
1988
2197
  ) -> list[dict[str, Any]]:
1989
- """Get all attributes for a specific object."""
1990
- raise NotImplementedError # pragma: no cover
2198
+ """Retrieve assigned attribute values for a specific object.
2199
+
2200
+ Parameters
2201
+ ----------
2202
+ object_name : str
2203
+ Name of the object.
2204
+ object_class : ClassEnum
2205
+ Class of the object.
2206
+ attribute_names : str | Iterable[str] | None, optional
2207
+ Attribute name or names to retrieve. If omitted, retrieves all
2208
+ assigned attribute values.
2209
+
2210
+ Returns
2211
+ -------
2212
+ list[dict[str, Any]]
2213
+ Attribute-value records with ``name``, ``attribute``, ``value``,
2214
+ and ``state`` keys. Returns an empty list when the object has no
2215
+ assigned attribute values.
2216
+
2217
+ Raises
2218
+ ------
2219
+ NotFoundError
2220
+ If the object does not exist for the requested class.
2221
+ """
2222
+ if not checks_module.check_object_exists(self, object_class, object_name):
2223
+ msg = f"Object = `{object_name}` does not exist in class = {object_class}. "
2224
+ msg += "See available objects with list_objects_by_class"
2225
+ raise NotFoundError(msg)
2226
+
2227
+ object_id = self.get_object_id(object_class, object_name)
2228
+ class_id = self.get_class_id(object_class)
2229
+
2230
+ conditions = [
2231
+ "data.object_id = ?",
2232
+ "attr.class_id = ?",
2233
+ ]
2234
+ params: list[Any] = [object_id, class_id]
2235
+
2236
+ if attribute_names is not None:
2237
+ names = normalize_names(attribute_names)
2238
+ if names:
2239
+ placeholders = ", ".join("?" for _ in names)
2240
+ conditions.append(f"attr.name IN ({placeholders})")
2241
+ params.extend(names)
2242
+
2243
+ where_clause = " AND ".join(conditions)
2244
+ query = f"""
2245
+ SELECT
2246
+ obj.name AS name,
2247
+ attr.name AS attribute,
2248
+ data.value,
2249
+ data.state
2250
+ FROM t_attribute_data AS data
2251
+ JOIN t_object AS obj ON obj.object_id = data.object_id
2252
+ JOIN t_attribute AS attr ON attr.attribute_id = data.attribute_id
2253
+ WHERE {where_clause}
2254
+ ORDER BY attr.name
2255
+ """
2256
+ return self._db.fetchall_dict(query, tuple(params))
1991
2257
 
1992
2258
  def get_category_id(self, class_enum: ClassEnum, /, name: str) -> int:
1993
2259
  """Return the ID for a given category.
@@ -3044,7 +3310,7 @@ class PlexosDB:
3044
3310
  Parameters
3045
3311
  ----------
3046
3312
  class_enum : ClassEnum
3047
- Class enumeration to list categories for
3313
+ Class enumeration to list attributes
3048
3314
 
3049
3315
  Returns
3050
3316
  -------
@@ -273,6 +273,39 @@ def _flatten_property_records(records: list[dict[str, Any]]) -> tuple[list[dict[
273
273
  return normalized_records, deprecated_format_used
274
274
 
275
275
 
276
+ def _normalize_attribute_records(
277
+ records: list[dict[str, Any]],
278
+ ) -> list[dict[str, Any]]:
279
+ """Normalize wide or explicit attribute records into explicit records."""
280
+ reserved_fields = {"name", "attribute", "value", "state"}
281
+ normalized: list[dict[str, Any]] = []
282
+
283
+ for record in records:
284
+ if "attribute" in record and "value" in record:
285
+ if "name" not in record:
286
+ raise KeyError(f"Attribute record is missing required 'name': {record}")
287
+ normalized.append(record)
288
+ continue
289
+
290
+ if "name" not in record:
291
+ raise KeyError(f"Attribute record is missing required 'name': {record}")
292
+
293
+ for key, value in record.items():
294
+ if key in reserved_fields:
295
+ continue
296
+
297
+ normalized.append(
298
+ {
299
+ "name": record["name"],
300
+ "attribute": key,
301
+ "value": value,
302
+ "state": record.get("state"),
303
+ }
304
+ )
305
+
306
+ return normalized
307
+
308
+
276
309
  def plan_property_inserts(
277
310
  db: PlexosDB,
278
311
  records: list[dict[str, Any]],
File without changes
File without changes
File without changes
File without changes