openepd 6.13.1__py3-none-any.whl → 7.0.0__py3-none-any.whl

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 (102) hide show
  1. openepd/__init__.py +4 -4
  2. openepd/__version__.py +1 -1
  3. openepd/api/average_dataset/generic_estimate_sync_api.py +11 -10
  4. openepd/api/average_dataset/industry_epd_sync_api.py +9 -8
  5. openepd/api/base_sync_client.py +53 -9
  6. openepd/api/category/sync_api.py +1 -1
  7. openepd/api/dto/base.py +4 -4
  8. openepd/api/dto/common.py +24 -16
  9. openepd/api/dto/meta.py +15 -11
  10. openepd/api/dto/mf.py +9 -8
  11. openepd/api/epd/dto.py +43 -33
  12. openepd/api/epd/sync_api.py +9 -9
  13. openepd/api/pcr/sync_api.py +2 -2
  14. openepd/bundle/model.py +11 -10
  15. openepd/bundle/reader.py +12 -5
  16. openepd/bundle/writer.py +17 -6
  17. openepd/m49/__init__.py +2 -0
  18. openepd/m49/const.py +5 -2
  19. openepd/m49/{geo_converter.py → utils.py} +24 -2
  20. openepd/model/base.py +60 -43
  21. openepd/model/category.py +13 -10
  22. openepd/model/common.py +100 -55
  23. openepd/model/declaration.py +93 -64
  24. openepd/model/epd.py +51 -43
  25. openepd/model/generic_estimate.py +28 -13
  26. openepd/model/industry_epd.py +15 -9
  27. openepd/model/lcia.py +132 -113
  28. openepd/model/org.py +54 -33
  29. openepd/model/pcr.py +38 -32
  30. openepd/model/specs/asphalt.py +31 -22
  31. openepd/model/specs/base.py +11 -9
  32. openepd/model/specs/concrete.py +60 -39
  33. openepd/model/specs/range/aggregates.py +9 -9
  34. openepd/model/specs/range/aluminium.py +7 -7
  35. openepd/model/specs/range/asphalt.py +22 -19
  36. openepd/model/specs/range/cladding.py +16 -16
  37. openepd/model/specs/range/cmu.py +10 -9
  38. openepd/model/specs/range/concrete.py +36 -27
  39. openepd/model/specs/range/conveying_equipment.py +16 -15
  40. openepd/model/specs/range/electrical.py +24 -22
  41. openepd/model/specs/range/finishes.py +109 -104
  42. openepd/model/specs/range/fire_and_smoke_protection.py +7 -7
  43. openepd/model/specs/range/furnishings.py +16 -12
  44. openepd/model/specs/range/manufacturing_inputs.py +16 -16
  45. openepd/model/specs/range/masonry.py +16 -16
  46. openepd/model/specs/range/mechanical.py +47 -47
  47. openepd/model/specs/range/mechanical_insulation.py +7 -7
  48. openepd/model/specs/range/network_infrastructure.py +54 -46
  49. openepd/model/specs/range/openings.py +36 -31
  50. openepd/model/specs/range/plumbing.py +15 -13
  51. openepd/model/specs/range/precast_concrete.py +20 -16
  52. openepd/model/specs/range/sheathing.py +18 -18
  53. openepd/model/specs/range/steel.py +25 -25
  54. openepd/model/specs/range/thermal_moisture_protection.py +20 -20
  55. openepd/model/specs/range/utility_piping.py +9 -9
  56. openepd/model/specs/range/wood.py +19 -19
  57. openepd/model/specs/range/wood_joists.py +8 -8
  58. openepd/model/specs/singular/__init__.py +9 -5
  59. openepd/model/specs/singular/aggregates.py +22 -15
  60. openepd/model/specs/singular/aluminium.py +20 -5
  61. openepd/model/specs/singular/asphalt.py +44 -20
  62. openepd/model/specs/singular/cladding.py +38 -23
  63. openepd/model/specs/singular/cmu.py +26 -11
  64. openepd/model/specs/singular/common.py +3 -2
  65. openepd/model/specs/singular/concrete.py +85 -48
  66. openepd/model/specs/singular/conveying_equipment.py +30 -17
  67. openepd/model/specs/singular/deprecated/__init__.py +3 -2
  68. openepd/model/specs/singular/deprecated/concrete.py +68 -33
  69. openepd/model/specs/singular/deprecated/steel.py +28 -15
  70. openepd/model/specs/singular/electrical.py +69 -41
  71. openepd/model/specs/singular/finishes.py +250 -140
  72. openepd/model/specs/singular/fire_and_smoke_protection.py +9 -6
  73. openepd/model/specs/singular/furnishings.py +16 -14
  74. openepd/model/specs/singular/manufacturing_inputs.py +23 -14
  75. openepd/model/specs/singular/masonry.py +66 -21
  76. openepd/model/specs/singular/mechanical.py +48 -47
  77. openepd/model/specs/singular/mechanical_insulation.py +7 -6
  78. openepd/model/specs/singular/mixins/conduit_mixin.py +13 -10
  79. openepd/model/specs/singular/network_infrastructure.py +111 -52
  80. openepd/model/specs/singular/openings.py +127 -95
  81. openepd/model/specs/singular/plumbing.py +15 -12
  82. openepd/model/specs/singular/precast_concrete.py +68 -54
  83. openepd/model/specs/singular/sheathing.py +47 -27
  84. openepd/model/specs/singular/steel.py +69 -45
  85. openepd/model/specs/singular/thermal_moisture_protection.py +36 -20
  86. openepd/model/specs/singular/utility_piping.py +11 -8
  87. openepd/model/specs/singular/wood.py +48 -24
  88. openepd/model/specs/singular/wood_joists.py +19 -6
  89. openepd/model/standard.py +15 -8
  90. openepd/model/validation/common.py +9 -3
  91. openepd/model/validation/numbers.py +0 -13
  92. openepd/model/validation/quantity.py +53 -25
  93. openepd/model/versioning.py +9 -6
  94. openepd/patch_pydantic.py +0 -93
  95. {openepd-6.13.1.dist-info → openepd-7.0.0.dist-info}/METADATA +1 -1
  96. openepd-7.0.0.dist-info/RECORD +142 -0
  97. openepd/compat/__init__.py +0 -15
  98. openepd/compat/compat_functional_validators.py +0 -25
  99. openepd/compat/pydantic.py +0 -30
  100. openepd-6.13.1.dist-info/RECORD +0 -145
  101. {openepd-6.13.1.dist-info → openepd-7.0.0.dist-info}/LICENSE +0 -0
  102. {openepd-6.13.1.dist-info → openepd-7.0.0.dist-info}/WHEEL +0 -0
openepd/model/common.py CHANGED
@@ -16,18 +16,23 @@
16
16
  from enum import StrEnum
17
17
  from typing import Annotated, Any
18
18
 
19
- from openepd.compat.pydantic import pyd
19
+ import pydantic
20
+ import pydantic_core
21
+
20
22
  from openepd.model.base import BaseOpenEpdSchema
21
- from openepd.model.validation.numbers import RatioFloat
22
23
 
23
24
 
24
25
  class Amount(BaseOpenEpdSchema):
25
26
  """A value-and-unit pairing for amounts that do not have an uncertainty."""
26
27
 
27
- qty: float | None = pyd.Field(description="How much of this in the amount.", ge=0, default=None)
28
- unit: str | None = pyd.Field(description="Which unit. SI units are preferred.", example="kg", default=None)
28
+ qty: float | None = pydantic.Field(description="How much of this in the amount.", ge=0, default=None)
29
+ unit: str | None = pydantic.Field(
30
+ description="Which unit. SI units are preferred.",
31
+ examples=["kg"],
32
+ default=None,
33
+ )
29
34
 
30
- @pyd.root_validator
35
+ @pydantic.model_validator(mode="before")
31
36
  def check_qty_or_unit(cls, values: dict[str, Any]):
32
37
  """Ensure that qty or unit is provided."""
33
38
  if values.get("qty") is None and values.get("unit") is None:
@@ -68,12 +73,13 @@ class Distribution(StrEnum):
68
73
  class Measurement(BaseOpenEpdSchema):
69
74
  """A scientific value with units and uncertainty."""
70
75
 
71
- mean: float = pyd.Field(description="Mean (expected) value of the measurement")
72
- unit: str | None = pyd.Field(description="Measurement unit")
73
- rsd: pyd.PositiveFloat | None = pyd.Field(
74
- description="Relative standard deviation, i.e. standard_deviation/mean", default=None
76
+ mean: float = pydantic.Field(description="Mean (expected) value of the measurement")
77
+ unit: str | None = pydantic.Field(description="Measurement unit")
78
+ rsd: pydantic.PositiveFloat | None = pydantic.Field(
79
+ description="Relative standard deviation, i.e. standard_deviation/mean",
80
+ default=None,
75
81
  )
76
- dist: Distribution | None = pyd.Field(
82
+ dist: Distribution | None = pydantic.Field(
77
83
  description="Statistical distribution of the measurement error.", default=None
78
84
  )
79
85
 
@@ -98,26 +104,29 @@ class Ingredient(BaseOpenEpdSchema):
98
104
  gwp_fraction, citation and evidence_type.
99
105
  """
100
106
 
101
- qty: float | None = pyd.Field(
102
- description="Number of declared units of this consumed. Negative values indicate an outflow."
107
+ qty: float | None = pydantic.Field(
108
+ description="Number of declared units of this consumed. Negative values indicate an outflow.",
109
+ default=None,
103
110
  )
104
- link: pyd.AnyUrl | None = pyd.Field(
105
- description="Link to this object's OpenEPD declaration. "
106
- "An OpenIndustryEPD or OpenLCI link is also acceptable.",
111
+ link: pydantic.AnyUrl | None = pydantic.Field(
112
+ description="Link to this object's OpenEPD declaration. An OpenIndustryEPD or OpenLCI link is also acceptable.",
107
113
  default=None,
108
114
  )
109
115
 
110
- gwp_fraction: RatioFloat | None = pyd.Field(
116
+ gwp_fraction: float | None = pydantic.Field(
111
117
  default=None,
112
118
  description="Fraction of product's A1-A3 GWP this flow represents. This value, along with the specificity of "
113
119
  "the reference, are used to caclulate supply chain specificity.",
120
+ ge=0,
121
+ le=1,
114
122
  )
115
- evidence_type: IngredientEvidenceTypeEnum | None = pyd.Field(
116
- default=None, description="Type of evidence used, which can be used to calculate degree of specificity"
123
+ evidence_type: IngredientEvidenceTypeEnum | None = pydantic.Field(
124
+ default=None,
125
+ description="Type of evidence used, which can be used to calculate degree of specificity",
117
126
  )
118
- citation: str | None = pyd.Field(default=None, description="Text citation describing the data source ")
127
+ citation: str | None = pydantic.Field(default=None, description="Text citation describing the data source ")
119
128
 
120
- @pyd.root_validator(skip_on_failure=True)
129
+ @pydantic.model_validator(mode="before")
121
130
  def _validate_evidence(cls, values: dict[str, Any]) -> dict[str, Any]:
122
131
  # gwp_fraction should be backed by some type of evidence (fraction coming from product EPD etc) to be accounted
123
132
  # for in the calculation of uncertainty
@@ -133,19 +142,20 @@ class Ingredient(BaseOpenEpdSchema):
133
142
  class LatLng(BaseOpenEpdSchema):
134
143
  """A latitude and longitude."""
135
144
 
136
- lat: float = pyd.Field(description="Latitude", example=47.6062)
137
- lng: float = pyd.Field(description="Longitude", example=-122.3321)
145
+ lat: float = pydantic.Field(description="Latitude", examples=[47.6062])
146
+ lng: float = pydantic.Field(description="Longitude", examples=[-122.3321])
138
147
 
139
148
 
140
149
  class Location(BaseOpenEpdSchema):
141
150
  """A location on the Earth's surface."""
142
151
 
143
- pluscode: str | None = pyd.Field(default=None, description="Open Location code of this location")
144
- latlng: LatLng | None = pyd.Field(default=None, description="Latitude and longitude of this location")
145
- address: str | None = pyd.Field(default=None, description="Text address, preferably geocoded")
146
- country: str | None = pyd.Field(default=None, description="2-alpha country code")
147
- jurisdiction: str | None = pyd.Field(
148
- default=None, description="Province, State, or similar subdivision below the level of a country"
152
+ pluscode: str | None = pydantic.Field(default=None, description="Open Location code of this location")
153
+ latlng: LatLng | None = pydantic.Field(default=None, description="Latitude and longitude of this location")
154
+ address: str | None = pydantic.Field(default=None, description="Text address, preferably geocoded")
155
+ country: str | None = pydantic.Field(default=None, description="2-alpha country code")
156
+ jurisdiction: str | None = pydantic.Field(
157
+ default=None,
158
+ description="Province, State, or similar subdivision below the level of a country",
149
159
  )
150
160
 
151
161
 
@@ -173,36 +183,69 @@ ATTACHMENT_KNOWN_KEYS: dict[str, str] = {
173
183
  }
174
184
 
175
185
 
176
- class AttachmentDict(dict[str, pyd.AnyUrl]):
186
+ class AttachmentDict(dict[str, pydantic.AnyUrl]):
177
187
  """Special form of dict for attachments."""
178
188
 
179
189
  @classmethod
180
- def __modify_schema__(cls, field_schema: dict[str, Any], field: pyd.fields.ModelField | None):
181
- # This may be generalized later to combine, for example, enum descriptions and field descriptions to provide
182
- # a better result.
183
- field_description = field.field_info.description if field else ""
190
+ def __get_pydantic_core_schema__(
191
+ cls, source: type[Any], handler: pydantic.GetCoreSchemaHandler
192
+ ) -> pydantic_core.core_schema.CoreSchema:
193
+ return pydantic_core.core_schema.no_info_after_validator_function(
194
+ cls._validate,
195
+ pydantic_core.core_schema.dict_schema(),
196
+ serialization=pydantic_core.core_schema.plain_serializer_function_ser_schema(
197
+ cls._serialize,
198
+ info_arg=False,
199
+ return_schema=pydantic_core.core_schema.dict_schema(),
200
+ ),
201
+ )
202
+
203
+ @classmethod
204
+ def _validate(cls, value: Any) -> "AttachmentDict":
205
+ # Ensure the input is a dict.
206
+ if not isinstance(value, dict):
207
+ raise TypeError("AttachmentDict must be a dict")
208
+
209
+ return cls(value)
210
+
211
+ @classmethod
212
+ def _serialize(cls, value: "AttachmentDict") -> dict[str, Any]:
213
+ # Serialize each AnyUrl value to its string representation.
214
+ return {k: str(v) for k, v in value.items()}
215
+
216
+ @classmethod
217
+ def __get_pydantic_json_schema__(cls, core_schema: dict[str, Any], handler: Any) -> dict[str, Any]:
218
+ # Obtain the default schema using the handler.
219
+ schema = handler(core_schema)
220
+
221
+ # Get the description from the default schema (if any)
222
+ field_description = schema.get("description", "")
184
223
  if field_description:
185
224
  field_description = field_description.strip()
186
225
  if not field_description.endswith("."):
187
226
  field_description += "."
188
227
  field_description += " "
189
228
 
190
- field_schema["description"] = field_description + "Extra properties of string -> URL allowed."
191
- field_schema["properties"] = {
229
+ # Update the schema with our custom description and properties.
230
+ schema["description"] = field_description + "Extra properties of string -> URL allowed."
231
+ schema["properties"] = {
192
232
  k: {"type": "string", "format": "uri", "description": v} for k, v in ATTACHMENT_KNOWN_KEYS.items()
193
233
  }
194
- field_schema["additionalProperties"] = True
234
+ schema["additionalProperties"] = True
235
+ return schema
195
236
 
196
237
 
197
- class WithAttachmentsMixin(pyd.BaseModel):
238
+ class WithAttachmentsMixin(pydantic.BaseModel):
198
239
  """Mixin for objects that can have attachments."""
199
240
 
200
- attachments: AttachmentDict = pyd.Field(
241
+ attachments: AttachmentDict | None = pydantic.Field(
201
242
  description="Dict of URLs relevant to this entry",
202
- example={
203
- "Contact Us": "https://www.c-change-labs.com/en/contact-us/",
204
- "LinkedIn": "https://www.linkedin.com/company/c-change-labs/",
205
- },
243
+ examples=[
244
+ {
245
+ "Contact Us": "https://www.c-change-labs.com/en/contact-us/",
246
+ "LinkedIn": "https://www.linkedin.com/company/c-change-labs/",
247
+ }
248
+ ],
206
249
  default=None,
207
250
  )
208
251
 
@@ -218,14 +261,16 @@ class WithAttachmentsMixin(pyd.BaseModel):
218
261
  self.set_attachment(name, url)
219
262
 
220
263
 
221
- class WithAltIdsMixin(pyd.BaseModel):
264
+ class WithAltIdsMixin(pydantic.BaseModel):
222
265
  """Mixin for objects that can have alt_ids."""
223
266
 
224
- alt_ids: dict[Annotated[str, pyd.Field(max_length=200)], str] | None = pyd.Field(
267
+ alt_ids: dict[Annotated[str, pydantic.Field(max_length=200)], str] | None = pydantic.Field(
225
268
  description="Dict identifiers for this entry.",
226
- example={
227
- "oekobau.dat": "bdda4364-451f-4df2-a68b-5912469ee4c9",
228
- },
269
+ examples=[
270
+ {
271
+ "oekobau.dat": "bdda4364-451f-4df2-a68b-5912469ee4c9",
272
+ }
273
+ ],
229
274
  default=None,
230
275
  )
231
276
 
@@ -262,7 +307,7 @@ class OpenEPDUnit(StrEnum):
262
307
  class RangeBase(BaseOpenEpdSchema):
263
308
  """Base class for range types having min and max and order between them."""
264
309
 
265
- @pyd.root_validator
310
+ @pydantic.model_validator(mode="before")
266
311
  def _validate_range_bounds(cls, values: dict[str, Any]) -> dict[str, Any]:
267
312
  min_boundary = values.get("min")
268
313
  max_boundary = values.get("max")
@@ -274,28 +319,28 @@ class RangeBase(BaseOpenEpdSchema):
274
319
  class RangeFloat(RangeBase):
275
320
  """Structure representing a range of floats."""
276
321
 
277
- min: float | None = pyd.Field(default=None, example=3.1)
278
- max: float | None = pyd.Field(default=None, example=5.8)
322
+ min: float | None = pydantic.Field(default=None, examples=[3.1])
323
+ max: float | None = pydantic.Field(default=None, examples=[5.8])
279
324
 
280
325
 
281
326
  class RangeInt(RangeBase):
282
327
  """Structure representing a range of ints1."""
283
328
 
284
- min: int | None = pyd.Field(default=None, example=2)
285
- max: int | None = pyd.Field(default=None, example=3)
329
+ min: int | None = pydantic.Field(default=None, examples=[2])
330
+ max: int | None = pydantic.Field(default=None, examples=[3])
286
331
 
287
332
 
288
333
  class RangeRatioFloat(RangeFloat):
289
334
  """Range of ratios (0-1 to 0-10)."""
290
335
 
291
- min: float | None = pyd.Field(default=None, example=0.2, ge=0, le=1)
292
- max: float | None = pyd.Field(default=None, example=0.65, ge=0, le=1)
336
+ min: float | None = pydantic.Field(default=None, examples=[0.2], ge=0, le=1)
337
+ max: float | None = pydantic.Field(default=None, examples=[0.65], ge=0, le=1)
293
338
 
294
339
 
295
340
  class RangeAmount(RangeFloat):
296
341
  """Structure representing a range of quantities."""
297
342
 
298
- unit: str | None = pyd.Field(default=None, description="Unit of the range.")
343
+ unit: str | None = pydantic.Field(default=None, description="Unit of the range.")
299
344
 
300
345
 
301
346
  class EnumGroupingAware:
@@ -17,7 +17,8 @@ import abc
17
17
  import datetime
18
18
  from enum import StrEnum
19
19
 
20
- from openepd.compat.pydantic import pyd
20
+ import pydantic
21
+
21
22
  from openepd.model.base import BaseOpenEpdSchema, OpenXpdUUID, RootDocument
22
23
  from openepd.model.common import Amount
23
24
  from openepd.model.geography import Geography
@@ -36,55 +37,60 @@ THIRD_PARTY_VERIFIER_DESCRIPTION = "JSON object for Org that performed a critica
36
37
  class BaseDeclaration(RootDocument, abc.ABC):
37
38
  """Base class for declaration-related documents (EPDs, Industry-wide EPDs, Generic Estimates)."""
38
39
 
39
- id: OpenXpdUUID | None = pyd.Field(
40
+ id: OpenXpdUUID | None = pydantic.Field(
40
41
  description="The unique ID for this document. To ensure global uniqueness, should be registered at "
41
42
  "open-xpd-uuid.cqd.io/register or a coordinating registry.",
42
- example="1u7zsed8",
43
+ examples=["1u7zsed8"],
43
44
  default=None,
44
45
  )
45
- date_of_issue: datetime.datetime | None = pyd.Field(
46
- example=datetime.datetime(day=11, month=9, year=2019, tzinfo=datetime.timezone.utc),
46
+ date_of_issue: datetime.datetime | None = pydantic.Field(
47
+ examples=[datetime.datetime(day=11, month=9, year=2019, tzinfo=datetime.timezone.utc)],
47
48
  description="Date the document was issued. This should be the first day on which the document is valid.",
49
+ default=None,
48
50
  )
49
- valid_until: datetime.datetime | None = pyd.Field(
50
- example=datetime.datetime(day=11, month=9, year=2028, tzinfo=datetime.timezone.utc),
51
+ valid_until: datetime.datetime | None = pydantic.Field(
52
+ examples=[datetime.datetime(day=11, month=9, year=2028, tzinfo=datetime.timezone.utc)],
51
53
  description="Last date the document is valid on, including any extensions.",
54
+ default=None,
52
55
  )
53
56
 
54
- version: pyd.NonNegativeInt | None = pyd.Field(
57
+ version: pydantic.NonNegativeInt | None = pydantic.Field(
55
58
  description="Version of this document. The document's issuer should increment it anytime even a single "
56
59
  "character changes, as this value is used to determine the most recent version.",
57
- example=1,
60
+ examples=[1],
58
61
  default=None,
59
62
  )
60
63
 
61
- declared_unit: Amount | None = pyd.Field(
64
+ declared_unit: Amount | None = pydantic.Field(
62
65
  description="SI declared unit for this document. If a functional unit is "
63
66
  "utilized, the declared unit shall refer to the amount of "
64
- "product associated with the A1-A3 life cycle stage."
67
+ "product associated with the A1-A3 life cycle stage.",
68
+ default=None,
65
69
  )
66
- kg_per_declared_unit: AmountMass | None = pyd.Field(
70
+ kg_per_declared_unit: AmountMass | None = pydantic.Field(
67
71
  default=None,
68
72
  description="Mass of the product, in kilograms, per declared unit",
69
- example=Amount(qty=12.5, unit="kg").to_serializable(exclude_unset=True),
73
+ examples=[Amount(qty=12.5, unit="kg").to_serializable(exclude_unset=True)],
70
74
  )
71
- compliance: list[Standard] = pyd.Field(
72
- description="Standard(s) to which this document is compliant.", default_factory=list
75
+ compliance: list[Standard] = pydantic.Field(
76
+ description="Standard(s) to which this document is compliant.",
77
+ default_factory=list,
73
78
  )
74
79
 
75
80
  # TODO: add product_alt_names? E.g. ILCD has a list of synonymous names
76
- product_classes: dict[str, str | list[str]] = pyd.Field(
77
- description="List of classifications, including Masterformat and UNSPC", default_factory=dict
81
+ product_classes: dict[str, str | list[str]] = pydantic.Field(
82
+ description="List of classifications, including Masterformat and UNSPC",
83
+ default_factory=dict,
78
84
  )
79
85
 
80
- language: str | None = pyd.Field(
86
+ language: str | None = pydantic.Field(
81
87
  min_length=2,
82
88
  max_length=2,
83
- strip_whitespace=True,
84
89
  description="Language this document is captured in, as an ISO 639-1 code",
85
- example="en",
90
+ examples=["en"],
91
+ default=None,
86
92
  )
87
- private: bool | None = pyd.Field(
93
+ private: bool | None = pydantic.Field(
88
94
  default=False,
89
95
  description="This document's author does not wish the contents published. "
90
96
  "Useful for draft, partial, or confidential declarations. "
@@ -94,19 +100,20 @@ class BaseDeclaration(RootDocument, abc.ABC):
94
100
  "incomplete EPDs.",
95
101
  )
96
102
 
97
- pcr: Pcr | None = pyd.Field(
103
+ pcr: Pcr | None = pydantic.Field(
98
104
  description="JSON object for product category rules. Should point to the "
99
105
  "most-specific PCR that applies; the PCR entry should point to any "
100
106
  "parent PCR.",
101
107
  default=None,
102
108
  )
103
- lca_discussion: str | None = pyd.Field(
109
+ lca_discussion: str | None = pydantic.Field(
104
110
  max_length=20000,
105
111
  description="""A rich text description containing information for experts reviewing the document contents.
106
- Text descriptions required by ISO 14025, ISO 21930, EN 15804,, relevant PCRs, or program instructions and which do not
112
+ Text descriptions required by ISO 14025, ISO 21930, EN 15804, relevant PCRs, or program instructions and which do not
107
113
  have specific openEPD fields should be entered here. This field may be large, and may contain multiple sections
108
114
  separated by github flavored markdown formatting.""",
109
- example="""# Packaging
115
+ examples=[
116
+ """# Packaging
110
117
 
111
118
  Information on product-specific packaging: type, composition and possible reuse of packaging materials (paper,
112
119
  strapping, pallets, foils, drums, etc.) shall be included in this Section. The EPD shall describe specific packaging
@@ -125,96 +132,118 @@ class BaseDeclaration(RootDocument, abc.ABC):
125
132
  Use-stage environmental impacts of flooring products during building operations depend on product cleaning assumptions.
126
133
  Information on cleaning frequency and cleaning products shall be provided based on the manufacturer’s recommendations.
127
134
  In the absence of primary data, cleaning assumptions shall be documented.
128
- """,
135
+ """
136
+ ],
137
+ default=None,
129
138
  )
130
139
 
131
- product_image_small: pyd.AnyUrl | None = pyd.Field(
132
- description="Pointer to image illustrating the product, which is no more than 200x200 pixels", default=None
140
+ product_image_small: pydantic.AnyUrl | None = pydantic.Field(
141
+ description="Pointer to image illustrating the product, which is no more than 200x200 pixels",
142
+ default=None,
133
143
  )
134
- product_image: pyd.AnyUrl | pyd.FileUrl | None = pyd.Field(
135
- description="pointer to image illustrating the product no more than 10MB", default=None
144
+ product_image: pydantic.AnyUrl | pydantic.FileUrl | None = pydantic.Field(
145
+ description="pointer to image illustrating the product no more than 10MB",
146
+ default=None,
136
147
  )
137
- declaration_url: str | None = pyd.Field(
148
+ declaration_url: str | None = pydantic.Field(
138
149
  description="Link to data object on original registrar's site",
139
- example="https://epd-online.com/EmbeddedEpdList/Download/6029",
150
+ examples=["https://epd-online.com/EmbeddedEpdList/Download/6029"],
151
+ default=None,
140
152
  )
141
- kg_C_per_declared_unit: AmountMass | None = pyd.Field(
153
+ kg_C_per_declared_unit: AmountMass | None = pydantic.Field(
142
154
  default=None,
143
155
  description="Mass of elemental carbon, per declared unit, contained in the product itself at the manufacturing "
144
156
  "facility gate. Used (among other things) to check a carbon balance or calculate incineration "
145
157
  "emissions. The source of carbon (e.g. biogenic) is not relevant in this field.",
146
- example=Amount(qty=8.76, unit="kgCO2e").to_serializable(exclude_unset=True),
158
+ examples=[Amount(qty=8.76, unit="kgCO2e").to_serializable(exclude_unset=True)],
147
159
  )
148
- kg_C_biogenic_per_declared_unit: AmountGWP | None = pyd.Field(
160
+ kg_C_biogenic_per_declared_unit: AmountGWP | None = pydantic.Field(
149
161
  default=None,
150
162
  description="Mass of elemental carbon from biogenic sources, per declared unit, contained in the product "
151
163
  "itself at the manufacturing facility gate. It may be presumed that any biogenic carbon content "
152
164
  "has been accounted for as -44/12 kgCO2e per kg C in stages A1-A3, per EN15804 and ISO 21930.",
153
- example=Amount(qty=8.76, unit="kgCO2e").to_serializable(exclude_unset=True),
165
+ examples=[Amount(qty=8.76, unit="kgCO2e").to_serializable(exclude_unset=True)],
154
166
  )
155
- product_service_life_years: float | None = pyd.Field(
167
+ product_service_life_years: float | None = pydantic.Field(
156
168
  gt=0.0009,
157
169
  lt=101,
158
170
  description="Reference service life of the product, in years. Serves as a maximum for replacement interval, "
159
171
  "which may also be constrained by usage or the service life of what the product goes into "
160
172
  "(e.g. a building).",
161
- example=50.0,
173
+ examples=[50.0],
174
+ default=None,
162
175
  )
163
176
 
177
+ @pydantic.field_validator("language", mode="before")
178
+ def strip_language(cls, value: str | None) -> str | None:
179
+ """Strip whitespace from language."""
180
+ if isinstance(value, str):
181
+ return value.strip()
182
+ return value
183
+
164
184
 
165
- class AverageDatasetMixin(pyd.BaseModel, title="Average Dataset"):
185
+ class AverageDatasetMixin(pydantic.BaseModel, title="Average Dataset"):
166
186
  """Fields common for average dataset (Industry-wide EPDs, Generic Estimates)."""
167
187
 
168
- description: str | None = pyd.Field(
188
+ description: str | None = pydantic.Field(
169
189
  max_length=2000,
170
190
  description="1-paragraph description of the average dataset. Supports plain text or github flavored markdown.",
191
+ default=None,
171
192
  )
172
193
 
173
- geography: list[Geography] | None = pyd.Field(
194
+ geography: list[Geography] | None = pydantic.Field(
174
195
  description="Jurisdiction(s) in which the LCA result is applicable. An empty array, or absent properties, "
175
196
  "implies global applicability.",
197
+ default=None,
176
198
  )
177
199
 
178
- specs: SpecsRange | None = pyd.Field(
179
- default=None, description="Average dataset material performance specifications."
200
+ specs: SpecsRange | None = pydantic.Field(
201
+ default=None,
202
+ description="Average dataset material performance specifications.",
180
203
  )
181
204
 
182
205
 
183
- class WithProgramOperatorMixin(pyd.BaseModel):
206
+ class WithProgramOperatorMixin(pydantic.BaseModel):
184
207
  """Object which has a connection to ProgramOperator."""
185
208
 
186
- program_operator: Org | None = pyd.Field(description=PROGRAM_OPERATOR_DESCRIPTION)
187
- program_operator_doc_id: str | None = pyd.Field(
188
- max_length=200, description="Document identifier from Program Operator.", example="123-456.789/b"
209
+ program_operator: Org | None = pydantic.Field(description=PROGRAM_OPERATOR_DESCRIPTION, default=None)
210
+ program_operator_doc_id: str | None = pydantic.Field(
211
+ max_length=200,
212
+ description="Document identifier from Program Operator.",
213
+ examples=["123-456.789/b"],
214
+ default=None,
189
215
  )
190
- program_operator_version: str | None = pyd.Field(
191
- max_length=200, description="Document version number from Program Operator.", example="4.3.0"
216
+ program_operator_version: str | None = pydantic.Field(
217
+ max_length=200, description="Document version number from Program Operator.", examples=["4.3.0"], default=None
192
218
  )
193
219
 
194
220
 
195
- class WithVerifierMixin(pyd.BaseModel):
221
+ class WithVerifierMixin(pydantic.BaseModel):
196
222
  """Set of fields related to verifier."""
197
223
 
198
- third_party_verifier: Org | None = pyd.Field(description=THIRD_PARTY_VERIFIER_DESCRIPTION)
199
- third_party_verification_url: pyd.AnyUrl | None = pyd.Field(
224
+ third_party_verifier: Org | None = pydantic.Field(description=THIRD_PARTY_VERIFIER_DESCRIPTION, default=None)
225
+ third_party_verification_url: pydantic.AnyUrl | None = pydantic.Field(
200
226
  description="Optional link to a verification statement.",
201
- example="https://we-verify-epds.com/en/letters/123-456.789b.pdf",
227
+ examples=["https://we-verify-epds.com/en/letters/123-456.789b.pdf"],
228
+ default=None,
202
229
  )
203
- third_party_verifier_email: pyd.EmailStr | None = pyd.Field(
204
- description="Email address of the third party verifier", example="john.doe@example.com", default=None
230
+ third_party_verifier_email: pydantic.EmailStr | None = pydantic.Field(
231
+ description="Email address of the third party verifier",
232
+ examples=["john.doe@example.com"],
233
+ default=None,
205
234
  )
206
235
 
207
236
 
208
- class WithEpdDeveloperMixin(pyd.BaseModel):
237
+ class WithEpdDeveloperMixin(pydantic.BaseModel):
209
238
  """Set of fields related to EPD Developer."""
210
239
 
211
- epd_developer: Org | None = pyd.Field(
240
+ epd_developer: Org | None = pydantic.Field(
212
241
  description=DEVELOPER_DESCRIPTION,
213
242
  default=None,
214
243
  )
215
- epd_developer_email: pyd.EmailStr | None = pyd.Field(
244
+ epd_developer_email: pydantic.EmailStr | None = pydantic.Field(
216
245
  default=None,
217
- example="john.doe@we-do-lca.com",
246
+ examples=["john.doe@we-do-lca.com"],
218
247
  description="Email contact for inquiries about development of this EPD. "
219
248
  "This must be an email which can be publicly shared.",
220
249
  )
@@ -223,18 +252,18 @@ class WithEpdDeveloperMixin(pyd.BaseModel):
223
252
  class RefBase(BaseOpenEpdSchema, title="Ref Object"):
224
253
  """Base class for reference-style objects."""
225
254
 
226
- id: OpenXpdUUID | None = pyd.Field(
255
+ id: OpenXpdUUID | None = pydantic.Field(
227
256
  description="The unique ID for this object. To ensure global uniqueness, should be registered at "
228
257
  "open-xpd-uuid.cqd.io/register or a coordinating registry.",
229
- example="1u7zsed8",
258
+ examples=["1u7zsed8"],
230
259
  default=None,
231
260
  )
232
261
 
233
- name: str | None = pyd.Field(max_length=200, description="Name of the object", default=None)
262
+ name: str | None = pydantic.Field(max_length=200, description="Name of the object", default=None)
234
263
 
235
- ref: ReferenceStr | None = pyd.Field(
264
+ ref: ReferenceStr | None = pydantic.Field(
236
265
  default=None,
237
- example="https://openepd.buildingtransparency.org/api/generic_estimates/EC300001",
266
+ examples=["https://openepd.buildingtransparency.org/api/generic_estimates/EC300001"],
238
267
  description="Reference to this JSON object",
239
268
  )
240
269