plexosdb 1.4.0__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.0
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.0"
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)
@@ -1530,10 +1673,36 @@ class PlexosDB:
1530
1673
  JOIN temp_data_mapping tdm ON b.data_id = tdm.old_id
1531
1674
  """)
1532
1675
 
1676
+ # Copy date_from
1677
+ self._db.execute("""
1678
+ INSERT INTO t_date_from (data_id, date, state)
1679
+ SELECT tdm.new_id, df.date, df.state
1680
+ FROM t_date_from df
1681
+ JOIN temp_data_mapping tdm ON df.data_id = tdm.old_id
1682
+ """)
1683
+
1684
+ # Copy date_to
1685
+ self._db.execute("""
1686
+ INSERT INTO t_date_to (data_id, date, state)
1687
+ SELECT tdm.new_id, dt.date, dt.state
1688
+ FROM t_date_to dt
1689
+ JOIN temp_data_mapping tdm ON dt.data_id = tdm.old_id
1690
+ """)
1691
+
1533
1692
  self._db.execute("DROP TABLE IF EXISTS temp_mapping")
1534
1693
  self._db.execute("DROP TABLE IF EXISTS temp_data_mapping")
1535
1694
  return True
1536
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
+
1537
1706
  def create_object_scenario(
1538
1707
  self,
1539
1708
  object_name: str,
@@ -1701,8 +1870,62 @@ class PlexosDB:
1701
1870
  object_name: str,
1702
1871
  object_class: ClassEnum,
1703
1872
  ) -> None:
1704
- """Delete an attribute from an object."""
1705
- 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))
1706
1929
 
1707
1930
  def delete_category(self, category: str, /, *, class_name: ClassEnum) -> None:
1708
1931
  """Delete a category from the database."""
@@ -1908,7 +2131,7 @@ class PlexosDB:
1908
2131
  object_name: str,
1909
2132
  attribute_name: str,
1910
2133
  ) -> Any:
1911
- """Get attribute details for a specific object."""
2134
+ """Get an assigned attribute value for a specific object."""
1912
2135
  query = """
1913
2136
  SELECT
1914
2137
  t_attribute_data.value
@@ -1940,12 +2163,12 @@ class PlexosDB:
1940
2163
  Returns
1941
2164
  -------
1942
2165
  int
1943
- ID of the category
2166
+ ID of the attribute
1944
2167
 
1945
2168
  Raises
1946
2169
  ------
1947
- AssertionError
1948
- If the category does not exist
2170
+ NotFoundError
2171
+ If the attribute does not exist for the specified class.
1949
2172
  """
1950
2173
  query = """
1951
2174
  SELECT
@@ -1959,7 +2182,9 @@ class PlexosDB:
1959
2182
  AND t_class.name = ?
1960
2183
  """
1961
2184
  result = self._db.fetchone(query, (name, class_enum))
1962
- assert result
2185
+ if not result:
2186
+ msg = f"Attribute '{name}' not found for class '{class_enum}'."
2187
+ raise NotFoundError(msg)
1963
2188
  return cast(int, result[0])
1964
2189
 
1965
2190
  def get_attributes(
@@ -1968,10 +2193,67 @@ class PlexosDB:
1968
2193
  /,
1969
2194
  *,
1970
2195
  object_class: ClassEnum,
1971
- attribute_names: list[str] | None = None,
2196
+ attribute_names: str | Iterable[str] | None = None,
1972
2197
  ) -> list[dict[str, Any]]:
1973
- """Get all attributes for a specific object."""
1974
- 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))
1975
2257
 
1976
2258
  def get_category_id(self, class_enum: ClassEnum, /, name: str) -> int:
1977
2259
  """Return the ID for a given category.
@@ -3028,7 +3310,7 @@ class PlexosDB:
3028
3310
  Parameters
3029
3311
  ----------
3030
3312
  class_enum : ClassEnum
3031
- Class enumeration to list categories for
3313
+ Class enumeration to list attributes
3032
3314
 
3033
3315
  Returns
3034
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