Skip to content

Commit 17fe03a

Browse files
committed
test ld models
1 parent eb8c20d commit 17fe03a

File tree

4 files changed

+236
-4
lines changed

4 files changed

+236
-4
lines changed

datamodel/base.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from collections.abc import Callable
2-
from typing import Any
2+
from typing import Any, Dict
33
# Dataclass
44
from dataclasses import (
55
_FIELD,
66
)
7+
from html import escape
78
from .converters import process_attributes, register_converter
89
from .fields import Field
910
from .exceptions import ValidationError
@@ -13,6 +14,19 @@
1314

1415
TYPE_CONVERTERS = {}
1516

17+
RendererFn = Callable[["BaseModel", bool], str]
18+
19+
HTML_RENDERERS: Dict[str, RendererFn] = {}
20+
21+
def register_renderer(schema_type: str):
22+
"""
23+
Decorator to register a custom renderer function for a given schema_type.
24+
"""
25+
def decorator(fn: RendererFn):
26+
HTML_RENDERERS[schema_type] = fn
27+
return fn
28+
return decorator
29+
1630

1731
class BaseModel(ModelMixin, metaclass=ModelMeta):
1832
"""
@@ -103,3 +117,70 @@ def set(self, name: str, value: Any) -> None:
103117

104118
def get_errors(self):
105119
return self.__errors__
120+
121+
def to_html(self, top_level: bool = True) -> str:
122+
"""to_html.
123+
Convert Model to HTML.
124+
125+
Args:
126+
top_level (bool, optional): If True, adds the @context to the schema.
127+
"""
128+
# 1) Determine the schema type from self.Meta or fallback to class name
129+
schema_type = getattr(self.Meta, 'schema_type', self.__class__.__name__)
130+
131+
if schema_type in HTML_RENDERERS:
132+
return HTML_RENDERERS[schema_type](self, top_level)
133+
134+
# 2) Container opening. For top-level objects, we specify:
135+
# - vocab="https://schema.org/"
136+
# - typeof="Recipe" (or other type)
137+
# For nested objects, we might omit the 'vocab' attribute
138+
# or rely on the parent's scope.
139+
if top_level:
140+
container_open = f'<div vocab="https://schema.org/" typeof="{escape(schema_type)}">'
141+
else:
142+
container_open = f'<div property="{escape(schema_type)}" typeof="{escape(schema_type)}">'
143+
144+
# We'll accumulate our HTML pieces here
145+
pieces = [container_open]
146+
147+
# 3) Iterate over each field in this model
148+
for field_name, value in self.__dict__.items():
149+
# Skip internal or error fields
150+
if field_name.startswith('_') or field_name == '__errors__':
151+
continue
152+
153+
# Optionally skip if None
154+
if value is None:
155+
continue
156+
157+
# 4) If value is a nested model, convert it to HTML as well
158+
if isinstance(value, BaseModel):
159+
nested_html = value.to_html(False)
160+
snippet = f'<div property="{escape(field_name)}">\n{nested_html}\n</div>'
161+
pieces.append(snippet)
162+
163+
elif isinstance(value, list):
164+
# We might iterate and produce multiple lines
165+
for item in value:
166+
if isinstance(item, BaseModel):
167+
nested_html = item.to_html(False)
168+
snippet = f'<div property="{escape(field_name)}">\n{nested_html}\n</div>'
169+
pieces.append(snippet)
170+
else:
171+
# If it's a simple scalar, just output a <span>
172+
# e.g.: <span property="recipeIngredient">3 bananas</span>
173+
val_escaped = escape(str(item))
174+
snippet = f'<span property="{escape(field_name)}">{val_escaped}</span>'
175+
pieces.append(snippet)
176+
else:
177+
# For simple scalars (str, int, etc.):
178+
# We might choose <span> or <meta> based on type.
179+
# We'll do something simple: a <span>
180+
val_escaped = escape(str(value))
181+
snippet = f'<span property="{escape(field_name)}">{val_escaped}</span>'
182+
pieces.append(snippet)
183+
184+
# 4) Close the container
185+
pieces.append('</div>')
186+
return "\n".join(pieces)

datamodel/jsonld/__init__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
Review,
1515
VideoObject,
1616
AdministrativeArea,
17-
Audience
17+
Audience,
18+
Place,
19+
QuantitativeValue,
20+
MonetaryAmount,
21+
JobPosting,
1822
)
1923

2024
JSON_MODEL_MAP = {
@@ -33,5 +37,9 @@
3337
"Review": Review,
3438
"VideoObject": VideoObject,
3539
"AdministrativeArea": AdministrativeArea,
36-
"Audience": Audience
40+
"Audience": Audience,
41+
"Place": Place,
42+
"QuantitativeValue": QuantitativeValue,
43+
"MonetaryAmount": MonetaryAmount,
44+
"JobPosting": JobPosting,
3745
}

datamodel/jsonld/models.py

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Any, List, Optional, Union
2+
from html import escape
23
from datetime import datetime
3-
from ..base import BaseModel, Field
4+
from ..base import BaseModel, Field, register_renderer
45

56

67
class URL(BaseModel):
@@ -120,6 +121,7 @@ class ImageObject(BaseModel):
120121
Corresponds to the JSON-LD "ImageObject" type.
121122
https://schema.org/ImageObject
122123
"""
124+
name: str
123125
url: str = Field(required=True)
124126
width: int = 0
125127
height: int = 0
@@ -362,3 +364,137 @@ class Audience(BaseModel):
362364

363365
class Meta:
364366
schema_type: str = "Audience"
367+
368+
369+
class Place(BaseModel):
370+
address: PostalAddress
371+
372+
class Meta:
373+
schema_type = "Place"
374+
375+
class QuantitativeValue(BaseModel):
376+
value: float
377+
unitText: str
378+
379+
class Meta:
380+
schema_type = "QuantitativeValue"
381+
382+
class MonetaryAmount(BaseModel):
383+
currency: Union[str, float]
384+
value: QuantitativeValue
385+
386+
class Meta:
387+
schema_type = "MonetaryAmount"
388+
389+
class JobPosting(BaseModel):
390+
title: str
391+
description: str
392+
hiringOrganization: Organization
393+
datePosted: str
394+
validThrough: str
395+
jobLocation: Optional[Place]
396+
baseSalary: Optional[MonetaryAmount]
397+
qualifications: str
398+
skills: List[str]
399+
responsibilities: str
400+
educationRequirements: str
401+
experienceRequirements: str
402+
403+
class Meta:
404+
schema_type = "JobPosting"
405+
406+
407+
@register_renderer("ImageObject")
408+
def render_imageobject(model: "BaseModel", top_level: bool) -> str:
409+
"""
410+
Render an ImageObject in a custom way:
411+
<div typeof="ImageObject">
412+
<h2 property="name">...</h2>
413+
<img src="..." alt="..." property="contentUrl"/>
414+
...
415+
</div>
416+
"""
417+
# You can read fields from the model (like name, url, caption, etc.).
418+
# If you used 'url' as the field that is the image's src, we can do:
419+
name = getattr(model, "name", None) # or None if not present
420+
url = getattr(model, "url", None)
421+
caption = getattr(model, "caption", None)
422+
width = getattr(model, "width", None)
423+
height = getattr(model, "height", None)
424+
425+
schema_type = getattr(model.Meta, 'schema_type', model.__class__.__name__)
426+
427+
container_open = ""
428+
if top_level:
429+
container_open = f'<div vocab="https://schema.org/" typeof="{escape(schema_type)}">' # noqa
430+
else:
431+
# when nested, we might do property="image" or property=schema_type
432+
container_open = f'<div property="{escape(schema_type)}" typeof="{escape(schema_type)}">' # noqa
433+
434+
pieces = [container_open]
435+
436+
# Render name as <h2 property="name">Name</h2> if present
437+
if name:
438+
name_esc = escape(str(name))
439+
pieces.append(f'<h2 property="name">{name_esc}</h2>')
440+
441+
# Now create an <img> that has property="contentUrl" or "url"
442+
if url:
443+
url_esc = escape(str(url))
444+
caption_esc = escape(str(caption or ""))
445+
# alt can come from caption
446+
snippet = f'<img property="contentUrl" src="{url_esc}" alt="{caption_esc}"'
447+
if width:
448+
snippet += f' width="{escape(str(width))}"'
449+
if height:
450+
snippet += f' height="{escape(str(height))}"'
451+
snippet += ' />'
452+
pieces.append(snippet)
453+
454+
# If we have other fields not individually handled, we could do a fallback
455+
# to the normal field iteration. But for brevity, let's omit that here.
456+
# E.g. leftover = model.render_remaining_fields(…)
457+
# pieces.append(leftover)
458+
459+
pieces.append('</div>')
460+
return "\n".join(pieces)
461+
462+
463+
@register_renderer("GeoCoordinates")
464+
def render_geocoordinates(model: "BaseModel", top_level: bool) -> str:
465+
"""
466+
Custom renderer for GeoCoordinates:
467+
<div property="geo" typeof="GeoCoordinates">
468+
<meta property="latitude" content="40.75"/>
469+
<meta property="longitude" content="-73.98"/>
470+
...
471+
</div>
472+
"""
473+
lat = getattr(model, "latitude", None)
474+
lng = getattr(model, "longitude", None)
475+
elevation = getattr(model, "elevation", None)
476+
477+
schema_type = getattr(model.Meta, 'schema_type', model.__class__.__name__)
478+
if top_level:
479+
container_open = f'<div vocab="https://schema.org/" typeof="{escape(schema_type)}">' # noqa
480+
else:
481+
container_open = f'<div property="{escape(schema_type)}" typeof="{escape(schema_type)}">' # noqa
482+
483+
pieces = [container_open]
484+
485+
# For numeric fields, we might prefer <meta ... content="..."/>
486+
if lat is not None:
487+
pieces.append(
488+
f'<meta property="latitude" content="{escape(str(lat))}" />'
489+
)
490+
if lng is not None:
491+
pieces.append(
492+
f'<meta property="longitude" content="{escape(str(lng))}" />'
493+
)
494+
if elevation is not None:
495+
pieces.append(
496+
f'<meta property="elevation" content="{escape(str(elevation))}" />'
497+
)
498+
499+
pieces.append('</div>')
500+
return "\n".join(pieces)

examples/test_ld_creation.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
2+
from datamodel.jsonld.models import ImageObject
3+
14
data = """
25
{
36
"@context": "http://schema.org",
@@ -255,3 +258,7 @@
255258
}
256259
}
257260
""" # noqa
261+
262+
263+
img = ImageObject(name="a Mexico Beach", url="mexico-beach.jpg", caption="Sunny, sandy beach.")
264+
print(img.to_html(top_level=True))

0 commit comments

Comments
 (0)