|
| 1 | +# Part of Odoo. See LICENSE file for full copyright and licensing details. |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +from datetime import UTC |
| 6 | +from typing import Any |
| 7 | + |
| 8 | +from odoo import fields |
| 9 | +from odoo.tools.json import scriptsafe |
| 10 | + |
| 11 | + |
| 12 | +class JsonLd: |
| 13 | + """ |
| 14 | + Fluent builder for creating Schema.org JSON-LD structured data. |
| 15 | +
|
| 16 | + Example: |
| 17 | + >>> product = JsonLd("Product", { |
| 18 | + ... "name": "Laptop", |
| 19 | + ... "sku": "LAP-001", |
| 20 | + ... "price": 999.99, |
| 21 | + ... }) |
| 22 | + >>> offer = JsonLd("Offer", { |
| 23 | + ... "price": 999.99, |
| 24 | + ... "priceCurrency": "USD", |
| 25 | + ... "availability": "https://schema.org/InStock" |
| 26 | + ... }) |
| 27 | + >>> product.add_nested({"offers": offer}) |
| 28 | +
|
| 29 | + >>> JsonLd.render_structured_data([product]) |
| 30 | + """ |
| 31 | + __slots__ = ("schema_type", "values") |
| 32 | + |
| 33 | + def __init__(self, schema_type: str, /, values: dict[str, Any] | None = None): |
| 34 | + self.schema_type = schema_type |
| 35 | + self.values = {} |
| 36 | + if values: |
| 37 | + self.set(values) |
| 38 | + |
| 39 | + @staticmethod |
| 40 | + def _has_invalid_types(values, expected_type: type) -> list[str]: |
| 41 | + """Return class names of items that are not instances of *expected_type*, |
| 42 | + in the order they first appear.""" |
| 43 | + return list(dict.fromkeys( |
| 44 | + v.__class__.__name__ |
| 45 | + for v in values |
| 46 | + if v is not None and not isinstance(v, expected_type) |
| 47 | + )) |
| 48 | + |
| 49 | + @staticmethod |
| 50 | + def render_structured_data(builders: list[JsonLd]) -> str | bool: |
| 51 | + """Render a list of JsonLd builders into a JSON-LD string. |
| 52 | + Falsy values (e.g., None) are ignored. If no valid builders remain, |
| 53 | + returns False. |
| 54 | + Args: |
| 55 | + builders: A list of JsonLd instances. |
| 56 | + Returns: |
| 57 | + A JSON string containing the rendered JSON-LD data, or False if |
| 58 | + the list is empty or contains no valid builders. |
| 59 | + Raises: |
| 60 | + TypeError: If any item in *builders* is not a JsonLd instance. |
| 61 | + Example: |
| 62 | + >>> org = JsonLd("Organization", {"name": "Example Corp"}) |
| 63 | + >>> website = JsonLd("WebSite", {"name": "Example Site", "url": "https://example.com"}) |
| 64 | + >>> JsonLd.render_structured_data([org, website]) |
| 65 | + >>> Output: [ |
| 66 | + { |
| 67 | + "@type": "Organization", |
| 68 | + "@context": "https://schema.org", |
| 69 | + "name": "Example Corp" |
| 70 | + }, |
| 71 | + { |
| 72 | + "@type": "WebSite", |
| 73 | + "@context": "https://schema.org", |
| 74 | + "name": "Example Site", |
| 75 | + "url": "https://example.com" |
| 76 | + } |
| 77 | + ] |
| 78 | + """ |
| 79 | + if not builders: |
| 80 | + return False |
| 81 | + invalid = JsonLd._has_invalid_types(builders, JsonLd) |
| 82 | + if invalid: |
| 83 | + raise TypeError( |
| 84 | + f"render_structured_data() expects JsonLd instances, " |
| 85 | + f"got: {', '.join(invalid)}", |
| 86 | + ) |
| 87 | + rendered = [b._to_jsonld_dict() for b in builders if b] |
| 88 | + if not rendered: |
| 89 | + return False |
| 90 | + return scriptsafe.dumps(rendered, ensure_ascii=False) |
| 91 | + |
| 92 | + @staticmethod |
| 93 | + def to_iso_datetime(dt) -> str | None: |
| 94 | + """Convert datetime to ISO-8601 string with timezone information. |
| 95 | + As per https://schema.org/DateTime, the value must be in ISO-8601 format |
| 96 | + and include timezone information. |
| 97 | + Args: |
| 98 | + dt: Datetime object or compatible value |
| 99 | + Returns: |
| 100 | + ISO-8601 formatted string with timezone, or None if dt is falsy |
| 101 | + or cannot be converted. |
| 102 | + Example: |
| 103 | + >>> from datetime import datetime |
| 104 | + >>> dt = datetime(2025, 1, 15, 10, 30) |
| 105 | + >>> JsonLd.to_iso_datetime(dt) |
| 106 | + '2025-01-15T10:30:00+00:00' |
| 107 | + """ |
| 108 | + as_datetime = fields.Datetime.to_datetime(dt) |
| 109 | + if not as_datetime: |
| 110 | + return None |
| 111 | + if not as_datetime.tzinfo: |
| 112 | + as_datetime = as_datetime.replace(tzinfo=UTC) |
| 113 | + return as_datetime.isoformat() |
| 114 | + |
| 115 | + def get(self, key: str, default=None): |
| 116 | + """Retrieve a stored value by key. |
| 117 | + Args: |
| 118 | + key: Property name |
| 119 | + default: Value returned when the key is absent. |
| 120 | + Returns: |
| 121 | + The stored value, or *default*. |
| 122 | + """ |
| 123 | + return self.values.get(key, default) |
| 124 | + |
| 125 | + def set(self, values: dict[str, Any]) -> JsonLd: |
| 126 | + """Set properties on the schema (overwrites existing keys). |
| 127 | + Properties are automatically converted from snake_case to camelCase. |
| 128 | + Args: |
| 129 | + values: Property name-value pairs |
| 130 | + Returns: |
| 131 | + Self for method chaining |
| 132 | + Example: |
| 133 | + >>> product = JsonLd("Product") |
| 134 | + >>> product.set({"name": "Widget", "price": 29.99, "brand": "BrandName"}) |
| 135 | + """ |
| 136 | + for key, value in values.items(): |
| 137 | + if value is None: |
| 138 | + continue |
| 139 | + items = value if isinstance(value, list) else [value] |
| 140 | + if any(isinstance(v, JsonLd) for v in items): |
| 141 | + raise TypeError(f"Key '{key}' contains JsonLd value(s). Use add_nested() instead.") |
| 142 | + self.values[key] = value |
| 143 | + return self |
| 144 | + |
| 145 | + def add_nested(self, values: dict[str, JsonLd | list[JsonLd] | None]) -> JsonLd: |
| 146 | + """Add nested schema builder(s). |
| 147 | + A single nested value is stored as-is; values are converted to a list |
| 148 | + only when multiple nested values exist for the same key. None values |
| 149 | + are ignored. |
| 150 | + Args: |
| 151 | + values: Property name to JsonLd (or list of them) mapping |
| 152 | + Returns: |
| 153 | + Self for method chaining |
| 154 | + Example: |
| 155 | + >>> product = JsonLd("Product", {"name": "Widget"}) |
| 156 | + >>> offer = JsonLd("Offer", {"price": 99.99, "priceCurrency": "USD"}) |
| 157 | + >>> product.add_nested({"offers": offer}) |
| 158 | + >>> isinstance(product.get("offers"), JsonLd) |
| 159 | + True |
| 160 | + >>> |
| 161 | + >>> # Adding another nested item appends to the existing value |
| 162 | + >>> offer2 = JsonLd("Offer", {"price": 79.99, "priceCurrency": "EUR"}) |
| 163 | + >>> product.add_nested({"offers": offer2}) # offers is now [offer, offer2] |
| 164 | + >>> |
| 165 | + >>> # Multiple nested items at once |
| 166 | + >>> product.add_nested({"offers": [offer1, offer2, offer3]}) |
| 167 | + """ |
| 168 | + for key, builder in values.items(): |
| 169 | + if builder is None: |
| 170 | + continue |
| 171 | + items = builder if isinstance(builder, list) else [builder] |
| 172 | + if not items: |
| 173 | + continue |
| 174 | + invalid = self._has_invalid_types(items, JsonLd) |
| 175 | + if invalid: |
| 176 | + raise TypeError( |
| 177 | + f"add_nested() expects JsonLd instances for key '{key}', " |
| 178 | + f"got: {', '.join(invalid)}") |
| 179 | + existing = self.values.get(key) |
| 180 | + if existing is None: |
| 181 | + self.values[key] = items[0] if len(items) == 1 else items |
| 182 | + elif isinstance(existing, JsonLd): |
| 183 | + self.values[key] = [existing, *items] |
| 184 | + elif isinstance(existing, list): |
| 185 | + is_jsonld_list = isinstance(existing[0], JsonLd) |
| 186 | + if not is_jsonld_list: |
| 187 | + raise TypeError(f"Cannot append to '{key}', existing value is not a list of JsonLd") |
| 188 | + existing.extend(items) |
| 189 | + else: |
| 190 | + raise TypeError(f"Cannot append to '{key}', existing type: {existing.__class__.__name__}") |
| 191 | + return self |
| 192 | + |
| 193 | + def _to_jsonld_dict(self, include_context: bool = True) -> dict[str, Any]: |
| 194 | + """Convert this builder to a JSON-LD dictionary. |
| 195 | + Args: |
| 196 | + include_context: Whether to include @context |
| 197 | + Returns: |
| 198 | + JSON-LD dictionary |
| 199 | + """ |
| 200 | + normalized_values = {} |
| 201 | + for key, value in self.values.items(): |
| 202 | + v = self._normalize_value(value) |
| 203 | + if v is not None: |
| 204 | + normalized_values[key] = v |
| 205 | + # Only @id -> reference object |
| 206 | + if len(normalized_values) == 1 and "@id" in normalized_values: |
| 207 | + return normalized_values |
| 208 | + data: dict[str, Any] = {} |
| 209 | + if include_context: |
| 210 | + data["@context"] = "https://schema.org" |
| 211 | + data["@type"] = self.schema_type |
| 212 | + data.update(normalized_values) |
| 213 | + return data |
| 214 | + |
| 215 | + def _normalize_value(self, value): |
| 216 | + """Normalize a value for JSON-LD rendering.""" |
| 217 | + # False is also treated as value. None and empty lists are ignored. |
| 218 | + if value is None: |
| 219 | + return None |
| 220 | + if isinstance(value, JsonLd): |
| 221 | + return value._to_jsonld_dict(include_context=False) |
| 222 | + if isinstance(value, list): |
| 223 | + normalized = [ |
| 224 | + self._normalize_value(v) |
| 225 | + for v in value |
| 226 | + if v is not None |
| 227 | + ] |
| 228 | + if not normalized: |
| 229 | + return None |
| 230 | + # Single item arrays can be unwrapped as per Schema.org spec: |
| 231 | + # a property with one value doesn't need to be wrapped in an array. |
| 232 | + return normalized if len(normalized) > 1 else normalized[0] |
| 233 | + return value |
0 commit comments