python-materialsdb 0.0.2__tar.gz → 0.1.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.
Files changed (28) hide show
  1. {python-materialsdb-0.0.2/src/python_materialsdb.egg-info → python_materialsdb-0.1.0}/PKG-INFO +23 -6
  2. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/README.md +15 -3
  3. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/pyproject.toml +5 -1
  4. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/setup.cfg +2 -1
  5. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/materialsdb/cache.py +2 -1
  6. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/materialsdb/ifc/project_library.py +4 -4
  7. python_materialsdb-0.1.0/src/materialsdb/query.py +24 -0
  8. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/materialsdb/serialiser.py +24 -1
  9. python_materialsdb-0.1.0/src/materialsdb/store.py +290 -0
  10. python_materialsdb-0.1.0/src/materialsdb/summary.py +75 -0
  11. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0/src/python_materialsdb.egg-info}/PKG-INFO +23 -6
  12. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/python_materialsdb.egg-info/SOURCES.txt +7 -1
  13. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/python_materialsdb.egg-info/requires.txt +3 -0
  14. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/tests/test_project_library.py +8 -0
  15. python_materialsdb-0.1.0/tests/test_query.py +34 -0
  16. python_materialsdb-0.1.0/tests/test_serialiser.py +65 -0
  17. python_materialsdb-0.1.0/tests/test_store.py +109 -0
  18. python_materialsdb-0.1.0/tests/test_summary.py +41 -0
  19. python-materialsdb-0.0.2/tests/test_serialiser.py +0 -13
  20. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/LICENSE.md +0 -0
  21. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/materialsdb/__init__.py +0 -0
  22. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/materialsdb/classes.py +0 -0
  23. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/materialsdb/config.py +0 -0
  24. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/materialsdb/ifc/__init__.py +0 -0
  25. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/materialsdb/ifc/material_psets.json +0 -0
  26. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/materialsdb/utils.py +0 -0
  27. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/python_materialsdb.egg-info/dependency_links.txt +0 -0
  28. {python-materialsdb-0.0.2 → python_materialsdb-0.1.0}/src/python_materialsdb.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: python-materialsdb
3
- Version: 0.0.2
3
+ Version: 0.1.0
4
4
  Summary: A library to work with materialsdb.org open standard for building materials.
5
5
  Home-page: https://github.com/CyrilWaechter/python-materialsdb
6
6
  Author: Cyril Waechter
@@ -15,8 +15,13 @@ Classifier: Operating System :: OS Independent
15
15
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
16
  Classifier: Topic :: Scientific/Engineering
17
17
  Description-Content-Type: text/markdown
18
- Provides-Extra: ifc
19
18
  License-File: LICENSE.md
19
+ Requires-Dist: lxml
20
+ Provides-Extra: ifc
21
+ Requires-Dist: ifcopenshell; extra == "ifc"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-benchmark; extra == "dev"
24
+ Dynamic: license-file
20
25
 
21
26
  python-materialsdb is an unofficial python library for [materialsdb.org][1] an open format and database for building materials.
22
27
 
@@ -45,9 +50,21 @@ Note: in materialsdb standard languages are [ISO 639-1](https://en.wikipedia.org
45
50
 
46
51
  # Usage examples :
47
52
  Check out some [examples](examples):
48
- * [Convert lastest materials data to ifc](examples/generate_ifc_project_libraries.py)
53
+ * [Convert latest materials data to ifc](examples/generate_ifc_project_libraries.py)
49
54
  * [Create your own materialsdb.org compliant XML](examples/create_layers.py)
50
55
 
56
+ # Querying materials :
57
+ The library keeps an sqlite index of the cached materials data for fast
58
+ filtering and single-material access:
59
+
60
+ ```python
61
+ from materialsdb import query
62
+
63
+ query.refresh() # incremental update from cached xml
64
+ rows = query.search("isolant", sort="lambda") # filtered, sorted summaries
65
+ material = query.get_material(rows[0].id) # full material dataclass
66
+ ```
67
+
51
68
  # How to install
52
69
  ## Using pip
53
70
  ```bash
@@ -55,8 +72,8 @@ pip install python-materialsdb
55
72
  ```
56
73
 
57
74
  # Dependencies
58
- * [lxml][2] (BSD) : xml parser
59
- * [ifcopenshell][3] (LGPL) : ifc read/write
75
+ * [lxml][2] (BSD) : xml parser (tested with version 6.1.1)
76
+ * [ifcopenshell][3] (LGPL) : ifc read/write (tested with version 0.8.5)
60
77
 
61
78
  # Third parties :
62
79
  * [materialsdb.org][1] (GPL) : materials schema
@@ -25,9 +25,21 @@ Note: in materialsdb standard languages are [ISO 639-1](https://en.wikipedia.org
25
25
 
26
26
  # Usage examples :
27
27
  Check out some [examples](examples):
28
- * [Convert lastest materials data to ifc](examples/generate_ifc_project_libraries.py)
28
+ * [Convert latest materials data to ifc](examples/generate_ifc_project_libraries.py)
29
29
  * [Create your own materialsdb.org compliant XML](examples/create_layers.py)
30
30
 
31
+ # Querying materials :
32
+ The library keeps an sqlite index of the cached materials data for fast
33
+ filtering and single-material access:
34
+
35
+ ```python
36
+ from materialsdb import query
37
+
38
+ query.refresh() # incremental update from cached xml
39
+ rows = query.search("isolant", sort="lambda") # filtered, sorted summaries
40
+ material = query.get_material(rows[0].id) # full material dataclass
41
+ ```
42
+
31
43
  # How to install
32
44
  ## Using pip
33
45
  ```bash
@@ -35,8 +47,8 @@ pip install python-materialsdb
35
47
  ```
36
48
 
37
49
  # Dependencies
38
- * [lxml][2] (BSD) : xml parser
39
- * [ifcopenshell][3] (LGPL) : ifc read/write
50
+ * [lxml][2] (BSD) : xml parser (tested with version 6.1.1)
51
+ * [ifcopenshell][3] (LGPL) : ifc read/write (tested with version 0.8.5)
40
52
 
41
53
  # Third parties :
42
54
  * [materialsdb.org][1] (GPL) : materials schema
@@ -7,4 +7,8 @@ build-backend = "setuptools.build_meta"
7
7
  [tool.pytest.ini_options]
8
8
  testpaths = [
9
9
  "tests",
10
- ]
10
+ ]
11
+ markers = [
12
+ "benchmark: performance benchmarks (deselected by default)",
13
+ ]
14
+ addopts = "-m 'not benchmark'"
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = python-materialsdb
3
- version = 0.0.2
3
+ version = 0.1.0
4
4
  author = Cyril Waechter
5
5
  author_email = cyrwae@hotmail.com
6
6
  description = A library to work with materialsdb.org open standard for building materials.
@@ -35,6 +35,7 @@ where = src
35
35
 
36
36
  [options.extras_require]
37
37
  ifc = ifcopenshell
38
+ dev = pytest-benchmark
38
39
 
39
40
  [egg_info]
40
41
  tag_build =
@@ -75,7 +75,8 @@ def update_producers_data(url_list=MATERIALSDBINDEXURLLIST):
75
75
  def update_producers_from_index(index):
76
76
  cached_index = parse_cached_index(index)
77
77
  cached_root = cached_index.getroot()
78
- new_index = etree.parse(index)
78
+ with urllib.request.urlopen(index) as response:
79
+ new_index = etree.parse(response)
79
80
  new_root = new_index.getroot()
80
81
  producers_dir = get_producers_dir()
81
82
  has_index_update = False
@@ -209,7 +209,7 @@ class ProjectLibrary:
209
209
  ifcopenshell.api.run(
210
210
  "material.assign_material",
211
211
  file,
212
- product=wall,
212
+ products=[wall],
213
213
  material=assigned_material,
214
214
  )
215
215
  if material.information.roof:
@@ -221,7 +221,7 @@ class ProjectLibrary:
221
221
  ifcopenshell.api.run(
222
222
  "material.assign_material",
223
223
  file,
224
- product=roof,
224
+ products=[roof],
225
225
  material=assigned_material,
226
226
  )
227
227
  if material.information.floor:
@@ -233,7 +233,7 @@ class ProjectLibrary:
233
233
  ifcopenshell.api.run(
234
234
  "material.assign_material",
235
235
  file,
236
- product=slab,
236
+ products=[slab],
237
237
  material=assigned_material,
238
238
  )
239
239
  if material.information.door:
@@ -245,7 +245,7 @@ class ProjectLibrary:
245
245
  ifcopenshell.api.run(
246
246
  "material.assign_material",
247
247
  file,
248
- product=door,
248
+ products=[door],
249
249
  material=assigned_material,
250
250
  )
251
251
 
@@ -0,0 +1,24 @@
1
+ """Convenience facade over the SQLite material store."""
2
+ from functools import lru_cache
3
+ from typing import List, Optional
4
+
5
+ from materialsdb.classes import Material
6
+ from materialsdb.store import MaterialStore, Report
7
+ from materialsdb.summary import MaterialSummary
8
+
9
+
10
+ @lru_cache(maxsize=1)
11
+ def get_store() -> MaterialStore:
12
+ return MaterialStore()
13
+
14
+
15
+ def get_material(material_id: str) -> Optional[Material]:
16
+ return get_store().get(material_id)
17
+
18
+
19
+ def search(text: str, **filters) -> List[MaterialSummary]:
20
+ return get_store().summaries(text=text, **filters)
21
+
22
+
23
+ def refresh(force: bool = False) -> Report:
24
+ return get_store().refresh(force=force)
@@ -9,6 +9,8 @@ See the LICENSE.md file for more details.
9
9
  Author : Cyril Waechter
10
10
  """
11
11
  import re
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from functools import lru_cache
12
14
  from pathlib import Path
13
15
  import typing
14
16
  from typing import Protocol, Tuple, Dict, Type, Optional, Any, Union
@@ -50,6 +52,11 @@ def get_valid_root(tree: objectify.ObjectifiedElement) -> str:
50
52
  return root
51
53
 
52
54
 
55
+ @lru_cache(maxsize=None)
56
+ def cached_type_hints(cls) -> dict:
57
+ return typing.get_type_hints(cls)
58
+
59
+
53
60
  class XmlDeserialiser:
54
61
  def __init__(self):
55
62
  self.schema = etree.XMLSchema(file=get_xml_schema())
@@ -62,13 +69,29 @@ class XmlDeserialiser:
62
69
  tree = objectify.parse(xml_path)
63
70
  return self.from_element(get_valid_root(tree))
64
71
 
72
+ def from_xml_files(self, paths, max_workers=None):
73
+ """Parse multiple producer XML files concurrently.
74
+
75
+ Yields (path, Materials) per successfully parsed file and
76
+ (path, None) for files that could not be parsed."""
77
+
78
+ def load(path):
79
+ try:
80
+ return Path(path), self.from_xml(str(path))
81
+ except Exception as err:
82
+ print(f"{Path(path).name}: could not parse file:\n\t{err}")
83
+ return Path(path), None
84
+
85
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
86
+ yield from executor.map(load, paths)
87
+
65
88
  def from_element(self, element=None, base_class=None):
66
89
  element_name = get_element_name(element)
67
90
  element_class = base_class or getattr(classes, self.cls_name(element_name))
68
91
  kwargs: Dict[str, Any] = {}
69
92
  if element_class.xs_type != "element":
70
93
  kwargs["object"] = element.text or ""
71
- type_hints = typing.get_type_hints(element_class)
94
+ type_hints = cached_type_hints(element_class)
72
95
  for attrib in getattr(element_class, "xml_attributes", ()):
73
96
  value = element.get(attrib)
74
97
  if value is None:
@@ -0,0 +1,290 @@
1
+ import datetime
2
+ import hashlib
3
+ import json
4
+ import sqlite3
5
+ from collections import namedtuple
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+ from lxml import etree, objectify
10
+
11
+ from materialsdb import cache, config
12
+ from materialsdb.classes import Material
13
+ from materialsdb.serialiser import XmlDeserialiser, get_valid_root
14
+ from materialsdb.summary import MaterialSummary, summarize_material
15
+
16
+ Report = namedtuple("Report", ["existing", "updated", "deleted", "skipped"])
17
+
18
+ SCHEMA_VERSION = "1"
19
+
20
+ _SCHEMA = """
21
+ CREATE TABLE IF NOT EXISTS materials (
22
+ id TEXT PRIMARY KEY, company_id TEXT, company TEXT, category TEXT,
23
+ names TEXT, descriptions TEXT,
24
+ lambda_min REAL, lambda_max REAL, thick_min REAL, thick_max REAL,
25
+ usage TEXT, source_file TEXT, xml BLOB);
26
+ CREATE INDEX IF NOT EXISTS idx_company ON materials(company);
27
+ CREATE INDEX IF NOT EXISTS idx_category ON materials(category);
28
+ CREATE INDEX IF NOT EXISTS idx_lambda ON materials(lambda_min);
29
+ CREATE TABLE IF NOT EXISTS producer_files (
30
+ path TEXT PRIMARY KEY, sha256 TEXT, built_at REAL);
31
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
32
+ """
33
+
34
+ _MATERIAL_COLUMNS = (
35
+ "id",
36
+ "company_id",
37
+ "company",
38
+ "category",
39
+ "names",
40
+ "descriptions",
41
+ "lambda_min",
42
+ "lambda_max",
43
+ "thick_min",
44
+ "thick_max",
45
+ "usage",
46
+ "source_file",
47
+ "xml",
48
+ )
49
+ _COLUMN_LIST = ", ".join(_MATERIAL_COLUMNS)
50
+
51
+ _NUMERIC_SORTS = {"lambda": "lambda_min", "thick": "thick_min"}
52
+ _STRING_SORTS = {"company": "company", "category": "category"}
53
+
54
+
55
+ def _sha256(path: Path) -> str:
56
+ return hashlib.sha256(path.read_bytes()).hexdigest()
57
+
58
+
59
+ class MaterialStore:
60
+ SCHEMA_VERSION = SCHEMA_VERSION
61
+
62
+ def __init__(self, db_path: Optional[Path] = None):
63
+ self.db_path = (
64
+ Path(db_path) if db_path else cache.get_cache_folder() / "materials.db"
65
+ )
66
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
67
+ self.connection = sqlite3.connect(str(self.db_path))
68
+ self.connection.execute("PRAGMA journal_mode=WAL")
69
+ self.connection.executescript(_SCHEMA)
70
+ self._ensure_schema_version()
71
+
72
+ # ---------- meta / lifecycle ----------
73
+
74
+ def _ensure_schema_version(self):
75
+ row = self.connection.execute(
76
+ "SELECT value FROM meta WHERE key='schema_version'"
77
+ ).fetchone()
78
+ stored = row[0] if row else None
79
+ if stored != SCHEMA_VERSION:
80
+ self.connection.execute("DROP TABLE IF EXISTS materials")
81
+ self.connection.execute("DROP TABLE IF EXISTS producer_files")
82
+ self.connection.executescript(_SCHEMA)
83
+ self.connection.execute(
84
+ "INSERT INTO meta(key, value) VALUES ('schema_version', ?) "
85
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
86
+ (SCHEMA_VERSION,),
87
+ )
88
+ self.connection.commit()
89
+
90
+ def close(self):
91
+ self.connection.close()
92
+
93
+ # ---------- build / refresh ----------
94
+
95
+ def refresh(self, force=False, paths=None) -> Report:
96
+ if paths is None:
97
+ paths = list(cache.producers())
98
+ else:
99
+ paths = [Path(p) for p in paths]
100
+
101
+ kept = {str(p) for p in paths}
102
+ deleted = []
103
+ for (stored_path,) in self.connection.execute(
104
+ "SELECT path FROM producer_files"
105
+ ).fetchall():
106
+ if stored_path not in kept:
107
+ self.connection.execute(
108
+ "DELETE FROM materials WHERE source_file=?", (stored_path,)
109
+ )
110
+ self.connection.execute(
111
+ "DELETE FROM producer_files WHERE path=?", (stored_path,)
112
+ )
113
+ deleted.append(Path(stored_path))
114
+
115
+ existing, updated, skipped = [], [], []
116
+ deserialiser = XmlDeserialiser()
117
+ for path in paths:
118
+ row = self.connection.execute(
119
+ "SELECT sha256 FROM producer_files WHERE path=?", (str(path),)
120
+ ).fetchone()
121
+ try:
122
+ digest = _sha256(path)
123
+ if not force and row and row[0] == digest:
124
+ existing.append(path)
125
+ continue
126
+ self._upsert_file(deserialiser, path)
127
+ except Exception as err:
128
+ print(f"{path.name}: skipped during store refresh:\n\t{err}")
129
+ skipped.append(path)
130
+ continue
131
+ self.connection.execute(
132
+ "INSERT INTO producer_files(path, sha256, built_at) VALUES (?, ?, ?) "
133
+ "ON CONFLICT(path) DO UPDATE SET sha256=excluded.sha256, "
134
+ "built_at=excluded.built_at",
135
+ (str(path), digest, datetime.datetime.now().timestamp()),
136
+ )
137
+ updated.append(path)
138
+
139
+ self.connection.commit()
140
+ return Report(existing, updated, deleted, skipped)
141
+
142
+ def _upsert_file(self, deserialiser: XmlDeserialiser, path: Path):
143
+ tree = objectify.parse(str(path))
144
+ root = get_valid_root(tree)
145
+ source = deserialiser.from_element(root)
146
+ self.connection.execute(
147
+ "DELETE FROM materials WHERE source_file=?", (str(path),)
148
+ )
149
+ for element in root.material:
150
+ material = deserialiser.from_element(element)
151
+ summary = summarize_material(
152
+ material, company_id=str(source.companyid), company=source.company
153
+ )
154
+ self.connection.execute(
155
+ f"INSERT INTO materials ({_COLUMN_LIST}) "
156
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
157
+ (
158
+ summary.id,
159
+ summary.company_id,
160
+ summary.company,
161
+ summary.category,
162
+ json.dumps(summary.names),
163
+ json.dumps(summary.descriptions),
164
+ summary.lambda_min,
165
+ summary.lambda_max,
166
+ summary.thick_min,
167
+ summary.thick_max,
168
+ json.dumps(summary.usage),
169
+ str(path),
170
+ sqlite3.Binary(etree.tostring(element)),
171
+ ),
172
+ )
173
+
174
+ # ---------- queries ----------
175
+
176
+ def summaries(
177
+ self,
178
+ company=None,
179
+ category=None,
180
+ min_lambda=None,
181
+ max_lambda=None,
182
+ min_thick=None,
183
+ max_thick=None,
184
+ usage=None,
185
+ text=None,
186
+ sort="company",
187
+ ascending=True,
188
+ lang=None,
189
+ ) -> List[MaterialSummary]:
190
+ where, params = [], []
191
+
192
+ def add(condition, value):
193
+ where.append(condition)
194
+ params.append(value)
195
+
196
+ if company:
197
+ add("company=?", company)
198
+ if category:
199
+ add("category=?", category)
200
+ if min_lambda is not None:
201
+ add("lambda_max>=?", min_lambda)
202
+ if max_lambda is not None:
203
+ add("lambda_min<=?", max_lambda)
204
+ if min_thick is not None:
205
+ add("thick_max>=?", min_thick)
206
+ if max_thick is not None:
207
+ add("thick_min<=?", max_thick)
208
+
209
+ query = f"SELECT {_COLUMN_LIST} FROM materials"
210
+ if where:
211
+ query += " WHERE " + " AND ".join(where)
212
+ rows = self.connection.execute(query, params).fetchall()
213
+
214
+ lang = lang or config.get_lang()
215
+ results = [self._row_to_summary(row) for row in rows]
216
+
217
+ if usage:
218
+ results = [r for r in results if r.usage.get(usage)]
219
+ if text:
220
+ needle = text.lower()
221
+ results = [
222
+ r
223
+ for r in results
224
+ if needle in (r.names.get(lang) or r.names.get("") or "").lower()
225
+ ]
226
+ return self._sorted(results, sort, ascending)
227
+
228
+ @staticmethod
229
+ def _sorted(
230
+ results: List[MaterialSummary], sort: str, ascending: bool
231
+ ) -> List[MaterialSummary]:
232
+ reverse = not ascending
233
+ if sort == "name":
234
+ return sorted(
235
+ results,
236
+ key=lambda r: r.names.get("") or "",
237
+ reverse=reverse,
238
+ )
239
+ if sort in _NUMERIC_SORTS:
240
+ attr = _NUMERIC_SORTS[sort]
241
+ return sorted(
242
+ results,
243
+ key=lambda r: (
244
+ (getattr(r, attr) is None) != reverse,
245
+ getattr(r, attr) if getattr(r, attr) is not None else 0,
246
+ ),
247
+ reverse=reverse,
248
+ )
249
+ attr = _STRING_SORTS.get(sort, "company")
250
+ return sorted(results, key=lambda r: str(getattr(r, attr)), reverse=reverse)
251
+
252
+ @staticmethod
253
+ def _row_to_summary(row) -> MaterialSummary:
254
+ (
255
+ id_,
256
+ company_id,
257
+ company,
258
+ category,
259
+ names,
260
+ descriptions,
261
+ lambda_min,
262
+ lambda_max,
263
+ thick_min,
264
+ thick_max,
265
+ usage,
266
+ _source_file,
267
+ _xml,
268
+ ) = row
269
+ return MaterialSummary(
270
+ id=id_,
271
+ company_id=company_id,
272
+ company=company,
273
+ category=category,
274
+ names=json.loads(names),
275
+ descriptions=json.loads(descriptions),
276
+ lambda_min=lambda_min,
277
+ lambda_max=lambda_max,
278
+ thick_min=thick_min,
279
+ thick_max=thick_max,
280
+ usage=json.loads(usage),
281
+ )
282
+
283
+ def get(self, material_id: str) -> Optional[Material]:
284
+ row = self.connection.execute(
285
+ "SELECT xml FROM materials WHERE id=?", (material_id,)
286
+ ).fetchone()
287
+ if row is None:
288
+ return None
289
+ element = objectify.fromstring(bytes(row[0]))
290
+ return XmlDeserialiser().from_element(element)
@@ -0,0 +1,75 @@
1
+ from dataclasses import dataclass
2
+ from typing import Dict, Optional, Tuple
3
+
4
+ from materialsdb import config, utils
5
+ from materialsdb.classes import Material
6
+
7
+ USAGE_FLAGS = ("wall", "roof", "floor", "door")
8
+
9
+
10
+ @dataclass
11
+ class MaterialSummary:
12
+ id: str
13
+ company_id: str
14
+ company: str
15
+ category: str
16
+ names: Dict[str, str]
17
+ descriptions: Dict[str, str]
18
+ lambda_min: Optional[float]
19
+ lambda_max: Optional[float]
20
+ thick_min: Optional[float]
21
+ thick_max: Optional[float]
22
+ usage: Dict[str, bool]
23
+
24
+
25
+ def _localized_dict(items) -> Dict[str, str]:
26
+ result: Dict[str, str] = {}
27
+ for item in items or ():
28
+ result[str(item.lang or "")] = str(item)
29
+ return result
30
+
31
+
32
+ def _min_max(values) -> Tuple[Optional[float], Optional[float]]:
33
+ values = [v for v in values if v is not None]
34
+ if not values:
35
+ return None, None
36
+ return min(values), max(values)
37
+
38
+
39
+ def summarize_material(
40
+ material: Material,
41
+ company_id: str = "",
42
+ company: str = "",
43
+ country: Optional[str] = None,
44
+ ) -> MaterialSummary:
45
+ country = country or config.get_country()
46
+ information = material.information
47
+
48
+ lambdas = []
49
+ thicks = []
50
+ for layer in utils.get_material_layers(material):
51
+ thermal = utils.get_by_country(layer.thermal or (), country)
52
+ geometry = utils.get_by_country(layer.geometry or (), country)
53
+ if thermal is not None:
54
+ lambdas.append(thermal.lambda_value)
55
+ if geometry is not None:
56
+ thicks.append(geometry.thick)
57
+
58
+ lambda_min, lambda_max = _min_max(lambdas)
59
+ thick_min, thick_max = _min_max(thicks)
60
+
61
+ return MaterialSummary(
62
+ id=str(material.id),
63
+ company_id=str(company_id),
64
+ company=str(company),
65
+ category=str(information.group or ""),
66
+ names=_localized_dict(getattr(information.names, "name", ())),
67
+ descriptions=_localized_dict(
68
+ getattr(getattr(information, "explanations", None), "explanation", ())
69
+ ),
70
+ lambda_min=lambda_min,
71
+ lambda_max=lambda_max,
72
+ thick_min=thick_min,
73
+ thick_max=thick_max,
74
+ usage={flag: str(getattr(information, flag)) == "1" for flag in USAGE_FLAGS},
75
+ )
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: python-materialsdb
3
- Version: 0.0.2
3
+ Version: 0.1.0
4
4
  Summary: A library to work with materialsdb.org open standard for building materials.
5
5
  Home-page: https://github.com/CyrilWaechter/python-materialsdb
6
6
  Author: Cyril Waechter
@@ -15,8 +15,13 @@ Classifier: Operating System :: OS Independent
15
15
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
16
  Classifier: Topic :: Scientific/Engineering
17
17
  Description-Content-Type: text/markdown
18
- Provides-Extra: ifc
19
18
  License-File: LICENSE.md
19
+ Requires-Dist: lxml
20
+ Provides-Extra: ifc
21
+ Requires-Dist: ifcopenshell; extra == "ifc"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-benchmark; extra == "dev"
24
+ Dynamic: license-file
20
25
 
21
26
  python-materialsdb is an unofficial python library for [materialsdb.org][1] an open format and database for building materials.
22
27
 
@@ -45,9 +50,21 @@ Note: in materialsdb standard languages are [ISO 639-1](https://en.wikipedia.org
45
50
 
46
51
  # Usage examples :
47
52
  Check out some [examples](examples):
48
- * [Convert lastest materials data to ifc](examples/generate_ifc_project_libraries.py)
53
+ * [Convert latest materials data to ifc](examples/generate_ifc_project_libraries.py)
49
54
  * [Create your own materialsdb.org compliant XML](examples/create_layers.py)
50
55
 
56
+ # Querying materials :
57
+ The library keeps an sqlite index of the cached materials data for fast
58
+ filtering and single-material access:
59
+
60
+ ```python
61
+ from materialsdb import query
62
+
63
+ query.refresh() # incremental update from cached xml
64
+ rows = query.search("isolant", sort="lambda") # filtered, sorted summaries
65
+ material = query.get_material(rows[0].id) # full material dataclass
66
+ ```
67
+
51
68
  # How to install
52
69
  ## Using pip
53
70
  ```bash
@@ -55,8 +72,8 @@ pip install python-materialsdb
55
72
  ```
56
73
 
57
74
  # Dependencies
58
- * [lxml][2] (BSD) : xml parser
59
- * [ifcopenshell][3] (LGPL) : ifc read/write
75
+ * [lxml][2] (BSD) : xml parser (tested with version 6.1.1)
76
+ * [ifcopenshell][3] (LGPL) : ifc read/write (tested with version 0.8.5)
60
77
 
61
78
  # Third parties :
62
79
  * [materialsdb.org][1] (GPL) : materials schema
@@ -6,7 +6,10 @@ src/materialsdb/__init__.py
6
6
  src/materialsdb/cache.py
7
7
  src/materialsdb/classes.py
8
8
  src/materialsdb/config.py
9
+ src/materialsdb/query.py
9
10
  src/materialsdb/serialiser.py
11
+ src/materialsdb/store.py
12
+ src/materialsdb/summary.py
10
13
  src/materialsdb/utils.py
11
14
  src/materialsdb/ifc/__init__.py
12
15
  src/materialsdb/ifc/material_psets.json
@@ -17,4 +20,7 @@ src/python_materialsdb.egg-info/dependency_links.txt
17
20
  src/python_materialsdb.egg-info/requires.txt
18
21
  src/python_materialsdb.egg-info/top_level.txt
19
22
  tests/test_project_library.py
20
- tests/test_serialiser.py
23
+ tests/test_query.py
24
+ tests/test_serialiser.py
25
+ tests/test_store.py
26
+ tests/test_summary.py
@@ -1,4 +1,7 @@
1
1
  lxml
2
2
 
3
+ [dev]
4
+ pytest-benchmark
5
+
3
6
  [ifc]
4
7
  ifcopenshell
@@ -1,6 +1,14 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
1
5
  from materialsdb.ifc import project_library
2
6
 
3
7
 
8
+ @pytest.mark.skipif(
9
+ not Path("example_v103.xml").exists(),
10
+ reason="example_v103.xml not present locally",
11
+ )
4
12
  def test_create_project_library():
5
13
  file = project_library.create_project_library_from_xml("example_v103.xml")
6
14
  file.write("example_v103.ifc")
@@ -0,0 +1,34 @@
1
+ import pytest
2
+
3
+ from materialsdb import query
4
+ from materialsdb.store import MaterialStore
5
+
6
+
7
+ @pytest.fixture(autouse=True)
8
+ def pinned_fr_ch_config(monkeypatch):
9
+ monkeypatch.setattr("materialsdb.config.get_lang", lambda: "fr")
10
+ monkeypatch.setattr("materialsdb.config.get_country", lambda: "CH")
11
+
12
+
13
+ @pytest.fixture
14
+ def isolated_store(tmp_path, monkeypatch, mini_xml):
15
+ query.get_store.cache_clear()
16
+ monkeypatch.setattr(
17
+ "materialsdb.query.MaterialStore",
18
+ lambda: MaterialStore(db_path=tmp_path / "q.db"),
19
+ )
20
+ store = query.get_store()
21
+ store.refresh(paths=[mini_xml])
22
+ yield store
23
+ store.close()
24
+ query.get_store.cache_clear()
25
+
26
+
27
+ def test_get_material(isolated_store):
28
+ material = query.get_material("00000000-0000-0000-0000-000000000002")
29
+ assert material.information.group == "Concrete"
30
+
31
+
32
+ def test_search(isolated_store):
33
+ rows = query.search("beton")
34
+ assert len(rows) == 1
@@ -0,0 +1,65 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ sys.path.append(str(Path(__file__).parent.parent / "src"))
5
+ import pytest
6
+
7
+ from materialsdb.serialiser import XmlDeserialiser, XmlSerialiser
8
+
9
+
10
+ @pytest.mark.skipif(
11
+ not Path("example_v103.xml").exists(),
12
+ reason="example_v103.xml not present locally",
13
+ )
14
+ def test_deserialise_and_serialise():
15
+ xml_path = "example_v103.xml"
16
+ deserialiser = XmlDeserialiser()
17
+ source = deserialiser.from_xml(xml_path)
18
+ serialiser = XmlSerialiser()
19
+ serialiser.to_xml(source, xml_path="test.xml")
20
+
21
+
22
+ def test_mini_fixture_roundtrip(tmp_path, mini_source):
23
+ assert len(mini_source.material) == 3
24
+ assert mini_source.company == "Mini SA"
25
+ out = tmp_path / "roundtrip.xml"
26
+ XmlSerialiser().to_xml(mini_source, xml_path=str(out))
27
+ reparsed = XmlDeserialiser().from_xml(str(out))
28
+ assert len(reparsed.material) == 3
29
+
30
+
31
+ @pytest.mark.skipif(
32
+ not Path("example_v103.xml").exists(),
33
+ reason="example_v103.xml not present locally",
34
+ )
35
+ def test_type_hints_are_cached():
36
+ from materialsdb import serialiser
37
+
38
+ serialiser.cached_type_hints.cache_clear()
39
+ deserialiser = XmlDeserialiser()
40
+ deserialiser.from_xml("example_v103.xml")
41
+ hits_after_first = serialiser.cached_type_hints.cache_info().hits
42
+ deserialiser.from_xml("example_v103.xml")
43
+ assert serialiser.cached_type_hints.cache_info().hits > hits_after_first
44
+
45
+
46
+ import shutil
47
+
48
+
49
+ def test_from_xml_files_reports_corrupt_and_parses_rest(tmp_path, mini_xml):
50
+ good_a = tmp_path / "a.xml"
51
+ good_b = tmp_path / "b.xml"
52
+ bad = tmp_path / "bad.xml"
53
+ shutil.copy(mini_xml, good_a)
54
+ shutil.copy(mini_xml, good_b)
55
+ bad.write_text("<materials><unclosed>", encoding="utf-8")
56
+
57
+ results = {
58
+ path.name: source
59
+ for path, source in XmlDeserialiser().from_xml_files([good_a, good_b, bad])
60
+ }
61
+
62
+ assert results["a.xml"] is not None
63
+ assert len(results["a.xml"].material) == 3
64
+ assert results["b.xml"] is not None
65
+ assert results["bad.xml"] is None
@@ -0,0 +1,109 @@
1
+ import pytest
2
+
3
+ from materialsdb.store import MaterialStore
4
+
5
+
6
+ @pytest.fixture(autouse=True)
7
+ def pinned_fr_ch_config(monkeypatch):
8
+ monkeypatch.setattr("materialsdb.config.get_lang", lambda: "fr")
9
+ monkeypatch.setattr("materialsdb.config.get_country", lambda: "CH")
10
+
11
+
12
+ @pytest.fixture
13
+ def store(tmp_path, mini_xml):
14
+ s = MaterialStore(db_path=tmp_path / "test.db")
15
+ s.refresh(paths=[mini_xml])
16
+ yield s
17
+ s.close()
18
+
19
+
20
+ def test_refresh_populates_all_materials(store):
21
+ assert len(store.summaries()) == 3
22
+
23
+
24
+ def test_get_returns_full_material_equivalent_to_direct_parse(store, mini_source):
25
+ material = store.get("00000000-0000-0000-0000-000000000001")
26
+ original = mini_source.material[0]
27
+ assert str(material.information.names.name[0]) == str(
28
+ original.information.names.name[0]
29
+ )
30
+ assert material.information.group == "Insulation"
31
+ assert material.layers.layer[0].thermal[0].lambda_value == 0.036
32
+
33
+
34
+ def test_get_unknown_id_returns_none(store):
35
+ assert store.get("nope") is None
36
+
37
+
38
+ def test_filters(store):
39
+ insulation = store.summaries(category="Insulation")
40
+ assert len(insulation) == 1
41
+ assert insulation[0].id.endswith("001")
42
+
43
+ assert len(store.summaries(company="Mini SA")) == 3
44
+
45
+ low_lambda = store.summaries(max_lambda=0.1)
46
+ assert {s.id[-3:] for s in low_lambda} == {"001"}
47
+
48
+ walls = store.summaries(usage="wall")
49
+ assert len(walls) == 1
50
+
51
+
52
+ def test_sort_order_and_nulls_last(store):
53
+ rows = store.summaries(sort="lambda")
54
+ assert [r.lambda_min for r in rows] == [0.036, 0.21, None]
55
+
56
+ rows = store.summaries(sort="lambda", ascending=False)
57
+ assert [r.lambda_min for r in rows] == [0.21, 0.036, None]
58
+
59
+ rows = store.summaries(sort="thick", ascending=False)
60
+ assert [r.thick_min for r in rows] == [150, 100, None]
61
+
62
+
63
+ def test_text_filter_uses_configured_lang(monkeypatch, store):
64
+ monkeypatch.setattr("materialsdb.config.get_lang", lambda: "fr")
65
+ rows = store.summaries(text="isol")
66
+ assert len(rows) == 1
67
+ assert rows[0].id.endswith("001")
68
+
69
+
70
+ def test_refresh_is_incremental(store, mini_xml):
71
+ report = store.refresh(paths=[mini_xml])
72
+ assert report.updated == []
73
+ assert len(report.existing) == 1
74
+
75
+
76
+ def test_refresh_rebuilds_changed_file(store, mini_xml, tmp_path):
77
+ copy = tmp_path / "changed.xml"
78
+ copy.write_text(
79
+ mini_xml.read_text(encoding="utf-8").replace("0.036", "0.03"),
80
+ encoding="utf-8",
81
+ )
82
+
83
+ report = store.refresh(paths=[copy])
84
+ assert copy in report.updated
85
+ rows = store.summaries(min_lambda=0.03, max_lambda=0.1)
86
+ assert len(rows) == 1
87
+ assert rows[0].lambda_min == 0.03
88
+
89
+
90
+ def test_deleted_producer_rows_are_removed(store):
91
+ store.refresh(paths=[]) # declare current file set empty
92
+ assert store.summaries() == []
93
+
94
+
95
+ def test_corrupt_producer_is_skipped(store, tmp_path, mini_xml):
96
+ bad = tmp_path / "bad.xml"
97
+ bad.write_text("<materials>", encoding="utf-8")
98
+ report = store.refresh(force=True, paths=[mini_xml, bad])
99
+ assert len(report.existing) + len(report.updated) == 1
100
+ assert report.skipped == [bad]
101
+ assert len(store.summaries()) == 3
102
+
103
+
104
+ def test_nonexistent_producer_is_skipped_without_raising(store, tmp_path, mini_xml):
105
+ ghost = tmp_path / "ghost.xml"
106
+ report = store.refresh(paths=[mini_xml, ghost])
107
+ assert report.existing == [mini_xml]
108
+ assert report.updated == []
109
+ assert len(store.summaries()) == 3
@@ -0,0 +1,41 @@
1
+ from materialsdb.summary import summarize_material
2
+
3
+
4
+ def test_insulation_material_summary_ch(mini_source):
5
+ material = mini_source.material[0]
6
+ s = summarize_material(material, country="CH")
7
+
8
+ assert s.id == "00000000-0000-0000-0000-000000000001"
9
+ assert s.category == "Insulation"
10
+ assert s.names == {"fr": "Isolant A", "de": "Daemmstoff A", "": "Material A"}
11
+ assert s.descriptions == {"fr": "Panneau isolant", "de": "Daemmplatte"}
12
+ assert s.usage["wall"] is True
13
+ assert s.usage["roof"] is False
14
+ assert s.thick_min == 100
15
+ assert s.thick_max == 200
16
+ assert s.lambda_min == 0.036
17
+ assert s.lambda_max == 0.05
18
+
19
+
20
+ def test_french_country_picks_fr_values(mini_source):
21
+ s = summarize_material(mini_source.material[0], country="FR")
22
+ assert s.lambda_min == 0.04
23
+ assert s.lambda_max == 0.05
24
+ assert s.thick_min == 100
25
+ assert s.thick_max == 240
26
+
27
+
28
+ def test_concrete_single_layer(mini_source):
29
+ s = summarize_material(mini_source.material[1], country="CH")
30
+ assert s.category == "Concrete"
31
+ assert s.names == {"fr": "Beton B"}
32
+ assert s.descriptions == {}
33
+ assert s.lambda_min == s.lambda_max == 0.21
34
+ assert s.thick_min == s.thick_max == 150
35
+
36
+
37
+ def test_material_without_layers_has_none_metrics(mini_source):
38
+ s = summarize_material(mini_source.material[2])
39
+ assert s.lambda_min is None
40
+ assert s.thick_max is None
41
+ assert s.usage == {"wall": False, "roof": False, "floor": False, "door": False}
@@ -1,13 +0,0 @@
1
- import sys
2
- from pathlib import Path
3
-
4
- sys.path.append(str(Path(__file__).parent.parent / "src"))
5
- from materialsdb.serialiser import XmlDeserialiser, XmlSerialiser
6
-
7
-
8
- def test_deserialise_and_serialise():
9
- xml_path = "example_v103.xml"
10
- deserialiser = XmlDeserialiser()
11
- source = deserialiser.from_xml(xml_path)
12
- serialiser = XmlSerialiser()
13
- serialiser.to_xml(source, xml_path="test.xml")