diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index bb23c9f..66c4247 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/README.rst b/README.rst index 0bbaaea..5063382 100644 --- a/README.rst +++ b/README.rst @@ -58,7 +58,7 @@ or conda:: $ conda install czml3 --channel conda-forge -czml3 requires Python >= 3.7. +czml3 requires Python >= 3.8. Examples ======== diff --git a/pyproject.toml b/pyproject.toml index 8868181..e00e138 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.ruff.lint] -ignore = ["E203", "E266", "E501"] +ignore = ["E501"] select = [ "E", # pycodestyle "F", # Pyflakes @@ -13,27 +13,23 @@ select = [ [tool.tox] legacy_tox_ini = """ [tox] - envlist = quality, test, pypy, pypy3, py{37,38,39,310,311,312} + envlist = quality, test, pypy, pypy3, py{310,311,312,313} [gh-actions] python = - 3.7: py37 - 3.8: py38 - 3.9: py39 3.10: py310 3.11: py311, quality, test, pypy, pypy3 3.12: py312 + 3.13: py313 [testenv] basepython = pypy: {env:PYTHON:pypy} pypy3: {env:PYTHON:pypy3} - py37: {env:PYTHON:python3.7} - py38: {env:PYTHON:python3.8} - py39: {env:PYTHON:python3.9} py310: {env:PYTHON:python3.10} py311: {env:PYTHON:python3.11} py312: {env:PYTHON:python3.12} + py313: {env:PYTHON:python3.13} {quality,reformat,test,coverage}: {env:PYTHON:python3} setenv = PYTHONUNBUFFERED = yes @@ -79,7 +75,7 @@ authors = [ ] description = "Python 3 library to write CZML" readme = "README.rst" -requires-python = ">=3.7" +requires-python = ">=3.10" keywords = ["czml", "cesium", "orbits"] license = {text = "MIT"} classifiers = [ @@ -89,21 +85,21 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Physics", "Topic :: Scientific/Engineering :: Astronomy", ] dependencies = [ - "attrs>=19.2", + "pydantic>=2.10.1", "python-dateutil>=2.7,<3", "w3lib", + "typing-extensions>=4.12.0", + "StrEnum>=0.4.0", ] dynamic = ["version"] diff --git a/src/czml3/__init__.py b/src/czml3/__init__.py index a5e32ea..b307148 100644 --- a/src/czml3/__init__.py +++ b/src/czml3/__init__.py @@ -1,5 +1,5 @@ from .core import CZML_VERSION, Document, Packet, Preamble -__version__ = "1.0.2" +__version__ = "2.0.0" __all__ = ["Document", "Preamble", "Packet", "CZML_VERSION"] diff --git a/src/czml3/base.py b/src/czml3/base.py index ac1eba2..1723f99 100644 --- a/src/czml3/base.py +++ b/src/czml3/base.py @@ -1,55 +1,27 @@ -import datetime as dt -import json -import warnings -from enum import Enum -from json import JSONEncoder +from typing import Any -import attr - -from .constants import ISO8601_FORMAT_Z +from pydantic import BaseModel, model_validator NON_DELETE_PROPERTIES = ["id", "delete"] -class CZMLEncoder(JSONEncoder): - def default(self, o): - if isinstance(o, BaseCZMLObject): - return o.to_json() - - elif isinstance(o, Enum): - return o.name - - elif isinstance(o, dt.datetime): - return o.astimezone(dt.timezone.utc).strftime(ISO8601_FORMAT_Z) - - return super().default(o) - - -@attr.s(str=False, frozen=True) -class BaseCZMLObject: - def __str__(self): - return self.dumps(indent=4) - - def dumps(self, *args, **kwargs): - if "cls" in kwargs: - warnings.warn("Ignoring specified cls", UserWarning, stacklevel=2) - - kwargs["cls"] = CZMLEncoder - return json.dumps(self, *args, **kwargs) - - def dump(self, fp, *args, **kwargs): - for chunk in CZMLEncoder(*args, **kwargs).iterencode(self): - fp.write(chunk) +class BaseCZMLObject(BaseModel): + @model_validator(mode="before") + @classmethod + def check_model_before(cls, data: dict[str, Any]) -> Any: + if data is not None and "delete" in data and data["delete"]: + return { + "delete": True, + "id": data.get("id"), + **{k: None for k in data if k not in NON_DELETE_PROPERTIES}, + } + return data - def to_json(self): - if getattr(self, "delete", False): - properties_list = NON_DELETE_PROPERTIES - else: - properties_list = list(attr.asdict(self).keys()) + def __str__(self) -> str: + return self.to_json() - obj_dict = {} - for property_name in properties_list: - if getattr(self, property_name, None) is not None: - obj_dict[property_name] = getattr(self, property_name) + def dumps(self) -> str: + return self.model_dump_json(exclude_none=True) - return obj_dict + def to_json(self, *, indent: int = 4) -> str: + return self.model_dump_json(exclude_none=True, indent=indent) diff --git a/src/czml3/common.py b/src/czml3/common.py index 0ff5192..0a3c6c9 100644 --- a/src/czml3/common.py +++ b/src/czml3/common.py @@ -1,28 +1,28 @@ -# noinspection PyPep8Naming -from __future__ import annotations - import datetime as dt -import attr +from pydantic import BaseModel, field_validator from .enums import InterpolationAlgorithms +from .types import format_datetime_like -@attr.s(str=False, frozen=True, kw_only=True) -class Deletable: +class Deletable(BaseModel): """A property whose value may be deleted.""" - delete: bool | None = attr.ib(default=None) + delete: None | bool = None -# noinspection PyPep8Naming -@attr.s(str=False, frozen=True, kw_only=True) -class Interpolatable: +class Interpolatable(BaseModel): """A property whose value may be determined by interpolating. The interpolation happens over provided time-tagged samples. """ - epoch: dt.datetime | None = attr.ib(default=None) - interpolationAlgorithm: InterpolationAlgorithms | None = attr.ib(default=None) - interpolationDegree: int | None = attr.ib(default=None) + epoch: None | str | dt.datetime = None + interpolationAlgorithm: None | InterpolationAlgorithms = None + interpolationDegree: None | int = None + + @field_validator("epoch") + @classmethod + def check(cls, e): + return format_datetime_like(e) diff --git a/src/czml3/core.py b/src/czml3/core.py index 8add5a7..b42ac7f 100644 --- a/src/czml3/core.py +++ b/src/czml3/core.py @@ -1,26 +1,47 @@ +from typing import Any from uuid import uuid4 -import attr +from pydantic import Field, model_serializer + +from czml3.types import StringValue from .base import BaseCZMLObject -from .types import Sequence +from .properties import ( + Billboard, + Box, + Clock, + Corridor, + Cylinder, + Ellipse, + Ellipsoid, + Label, + Model, + Orientation, + Path, + Point, + Polygon, + Polyline, + Position, + Rectangle, + Tileset, + ViewFrom, + Wall, +) +from .types import IntervalValue, Sequence, TimeInterval CZML_VERSION = "1.0" -@attr.s(str=False, frozen=True, kw_only=True) class Preamble(BaseCZMLObject): """The preamble packet.""" - id = attr.ib(init=False, default="document") - - version = attr.ib(default=CZML_VERSION) - name = attr.ib(default=None) - description = attr.ib(default=None) - clock = attr.ib(default=None) + id: str = Field(default="document") + version: str = Field(default=CZML_VERSION) + name: None | str = Field(default=None) + description: None | str = Field(default=None) + clock: None | Clock | IntervalValue = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class Packet(BaseCZMLObject): """A CZML Packet. @@ -28,37 +49,40 @@ class Packet(BaseCZMLObject): for further information. """ - id = attr.ib(factory=lambda: str(uuid4())) - delete = attr.ib(default=None) - name = attr.ib(default=None) - parent = attr.ib(default=None) - description = attr.ib(default=None) - availability = attr.ib(default=None) - properties = attr.ib(default=None) - position = attr.ib(default=None) - orientation = attr.ib(default=None) - viewFrom = attr.ib(default=None) - billboard = attr.ib(default=None) - box = attr.ib(default=None) - corridor = attr.ib(default=None) - cylinder = attr.ib(default=None) - ellipse = attr.ib(default=None) - ellipsoid = attr.ib(default=None) - label = attr.ib(default=None) - model = attr.ib(default=None) - path = attr.ib(default=None) - point = attr.ib(default=None) - polygon = attr.ib(default=None) - polyline = attr.ib(default=None) - rectangle = attr.ib(default=None) - tileset = attr.ib(default=None) - wall = attr.ib(default=None) + id: str = Field(default=str(uuid4())) + delete: None | bool = Field(default=None) + name: None | str = Field(default=None) + parent: None | str = Field(default=None) + description: None | str | StringValue = Field(default=None) + availability: None | TimeInterval | list[TimeInterval] | Sequence = Field( + default=None + ) + properties: None | Any = Field(default=None) + position: None | Position = Field(default=None) + orientation: None | Orientation = Field(default=None) + viewFrom: None | ViewFrom = Field(default=None) + billboard: None | Billboard = Field(default=None) + box: None | Box = Field(default=None) + corridor: None | Corridor = Field(default=None) + cylinder: None | Cylinder = Field(default=None) + ellipse: None | Ellipse = Field(default=None) + ellipsoid: None | Ellipsoid = Field(default=None) + label: None | Label = Field(default=None) + model: None | Model = Field(default=None) + path: None | Path = Field(default=None) + point: None | Point = Field(default=None) + polygon: None | Polygon = Field(default=None) + polyline: None | Polyline = Field(default=None) + rectangle: None | Rectangle = Field(default=None) + tileset: None | Tileset = Field(default=None) + wall: None | Wall = Field(default=None) -@attr.s(str=False, frozen=True) -class Document(Sequence): +class Document(BaseCZMLObject): """A CZML document, consisting on a list of packets.""" - @property - def packets(self): - return self._values + packets: list[Packet | Preamble] + + @model_serializer + def custom_serializer(self): + return list(self.packets) diff --git a/src/czml3/enums.py b/src/czml3/enums.py index 201a7a4..631e404 100644 --- a/src/czml3/enums.py +++ b/src/czml3/enums.py @@ -1,7 +1,25 @@ -from enum import Enum, auto +import sys +from enum import auto +from typing import Any +if sys.version_info[1] >= 11: + from enum import StrEnum -class InterpolationAlgorithms(Enum): + class OCaseStrEnum(StrEnum): + """ + StrEnum where enum.auto() returns the original member name, not lower-cased name. + """ + + @staticmethod + def _generate_next_value_( + name: str, start: int, count: int, last_values: list[Any] + ) -> str: + return name +else: + from strenum import StrEnum as OCaseStrEnum + + +class InterpolationAlgorithms(OCaseStrEnum): """The interpolation algorithm to use when interpolating.""" LINEAR = auto() @@ -9,7 +27,7 @@ class InterpolationAlgorithms(Enum): HERMITE = auto() -class ExtrapolationTypes(Enum): +class ExtrapolationTypes(OCaseStrEnum): """The type of extrapolation to perform when a value is requested at a time after any available samples.""" NONE = auto() @@ -17,14 +35,14 @@ class ExtrapolationTypes(Enum): EXTRAPOLATE = auto() -class ReferenceFrames(Enum): +class ReferenceFrames(OCaseStrEnum): """The reference frame in which cartesian positions are specified.""" FIXED = auto() INERTIAL = auto() -class LabelStyles(Enum): +class LabelStyles(OCaseStrEnum): """The style of a label.""" FILL = auto() @@ -32,7 +50,7 @@ class LabelStyles(Enum): FILL_AND_OUTLINE = auto() -class ClockRanges(Enum): +class ClockRanges(OCaseStrEnum): """The behavior of a clock when its current time reaches its start or end time.""" UNBOUNDED = auto() @@ -40,56 +58,62 @@ class ClockRanges(Enum): LOOP_STOP = auto() -class ClockSteps(Enum): +class ClockSteps(OCaseStrEnum): TICK_DEPENDENT = auto() SYSTEM_CLOCK_MULTIPLIER = auto() SYSTEM_CLOCK = auto() -class VerticalOrigins(Enum): +class VerticalOrigins(OCaseStrEnum): BASELINE = auto() BOTTOM = auto() CENTER = auto() TOP = auto() -class HorizontalOrigins(Enum): +class HorizontalOrigins(OCaseStrEnum): LEFT = auto() CENTER = auto() RIGHT = auto() -class HeightReferences(Enum): +class HeightReferences(OCaseStrEnum): NONE = auto() CLAMP_TO_GROUND = auto() RELATIVE_TO_GROUND = auto() -class ColorBlendModes(Enum): +class ColorBlendModes(OCaseStrEnum): HIGHLIGHT = auto() REPLACE = auto() MIX = auto() -class ShadowModes(Enum): +class ShadowModes(OCaseStrEnum): DISABLED = auto() ENABLED = auto() CAST_ONLY = auto() RECEIVE_ONLY = auto() -class ClassificationTypes(Enum): +class ClassificationTypes(OCaseStrEnum): TERRAIN = auto() CESIUM_3D_TILE = auto() BOTH = auto() -class ArcTypes(Enum): +class ArcTypes(OCaseStrEnum): NONE = auto() GEODESIC = auto() RHUMB = auto() -class StripeOrientations(Enum): +class StripeOrientations(OCaseStrEnum): HORIZONTAL = auto() VERTICAL = auto() + + +class CornerTypes(OCaseStrEnum): + ROUNDED = auto() + MITERED = auto() + BEVELED = auto() diff --git a/src/czml3/examples/simple.py b/src/czml3/examples/simple.py index 42dcdd8..c541421 100644 --- a/src/czml3/examples/simple.py +++ b/src/czml3/examples/simple.py @@ -25,7 +25,7 @@ end = dt.datetime(2012, 3, 16, 10, tzinfo=dt.timezone.utc) simple = Document( - [ + packets=[ Preamble( name="simple", clock=IntervalValue( @@ -38,7 +38,7 @@ name="Geoeye1 to ISS", parent=accesses_id, availability=Sequence( - [ + values=[ TimeInterval( start="2012-03-15T10:16:06.97400000000198Z", end="2012-03-15T10:33:59.3549999999959Z", @@ -102,8 +102,8 @@ outlineWidth=2, text="Pennsylvania", verticalOrigin=VerticalOrigins.CENTER, - fillColor=Color.from_list([255, 0, 0]), - outlineColor=Color.from_list([0, 0, 0]), + fillColor=Color(rgba=[255, 0, 0]), + outlineColor=Color(rgba=[0, 0, 0]), ), position=Position( cartesian=[1152255.80150063, -4694317.951340558, 4147335.9067563135] @@ -136,8 +136,8 @@ style=LabelStyles.FILL_AND_OUTLINE, text="AGI", verticalOrigin=VerticalOrigins.CENTER, - fillColor=Color.from_list([0, 255, 255]), - outlineColor=Color.from_list([0, 0, 0]), + fillColor=Color(rgba=[0, 255, 255]), + outlineColor=Color(rgba=[0, 0, 0]), ), position=Position( cartesian=[1216469.9357990976, -4736121.71856379, 4081386.8856866374] @@ -170,14 +170,16 @@ style=LabelStyles.FILL_AND_OUTLINE, text="Geoeye 1", verticalOrigin=VerticalOrigins.CENTER, - fillColor=Color.from_list([0, 255, 0]), - outlineColor=Color.from_list([0, 0, 0]), + fillColor=Color(rgba=[0, 255, 0]), + outlineColor=Color(rgba=[0, 0, 0]), ), path=Path( - show=Sequence([IntervalValue(start=start, end=end, value=True)]), + show=Sequence(values=[IntervalValue(start=start, end=end, value=True)]), width=1, resolution=120, - material=Material(solidColor=SolidColorMaterial.from_list([0, 255, 0])), + material=Material( + solidColor=SolidColorMaterial(color=Color(rgba=[0, 255, 0])) + ), ), position=Position( interpolationAlgorithm=InterpolationAlgorithms.LAGRANGE, diff --git a/src/czml3/properties.py b/src/czml3/properties.py index 5564cf3..abaf5c3 100644 --- a/src/czml3/properties.py +++ b/src/czml3/properties.py @@ -1,290 +1,287 @@ from __future__ import annotations -import attr +import datetime as dt +from typing import Any + +from pydantic import ( + BaseModel, + Field, + field_validator, + model_serializer, + model_validator, +) from w3lib.url import is_url, parse_data_uri from .base import BaseCZMLObject from .common import Deletable, Interpolatable from .enums import ( + ArcTypes, + ClassificationTypes, ClockRanges, ClockSteps, + ColorBlendModes, + CornerTypes, + HeightReferences, HorizontalOrigins, LabelStyles, - StripeOrientations, + ShadowModes, VerticalOrigins, ) -from .types import RgbafValue, RgbaValue +from .types import ( + Cartesian2Value, + Cartesian3Value, + CartographicDegreesListValue, + CartographicRadiansListValue, + DistanceDisplayConditionValue, + NearFarScalarValue, + RgbafValue, + RgbaValue, + Sequence, + TimeInterval, + UnitQuaternionValue, + check_reference, + format_datetime_like, + get_color, +) -# noinspection PyPep8Naming -@attr.s(str=False, frozen=True, kw_only=True) -class HasAlignment: +class HasAlignment(BaseModel): """A property that can be horizontally or vertically aligned.""" - horizontalOrigin: HorizontalOrigins | None = attr.ib(default=None) - verticalOrigin: VerticalOrigins | None = attr.ib(default=None) + horizontalOrigin: None | HorizontalOrigins = Field(default=None) + verticalOrigin: None | VerticalOrigins = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class Material(BaseCZMLObject): """A definition of how a surface is colored or shaded.""" - solidColor = attr.ib(default=None) - image = attr.ib(default=None) - grid = attr.ib(default=None) - stripe = attr.ib(default=None) - checkerboard = attr.ib(default=None) - polylineOutline = attr.ib(default=None) # NOTE: Not present in documentation + solidColor: None | Color | SolidColorMaterial | str = Field(default=None) + image: None | ImageMaterial | str | Uri = Field(default=None) + grid: None | GridMaterial = Field(default=None) + stripe: None | StripeMaterial = Field(default=None) + checkerboard: None | CheckerboardMaterial = Field(default=None) + polylineOutline: None | PolylineMaterial = Field( + default=None + ) # NOTE: Not present in documentation -@attr.s(str=False, frozen=True, kw_only=True) class PolylineOutline(BaseCZMLObject): """A definition of how a surface is colored or shaded.""" - color = attr.ib(default=None) - outlineColor = attr.ib(default=None) - outlineWidth = attr.ib(default=None) + color: None | Color | str = Field(default=None) + outlineColor: None | Color | str = Field(default=None) + outlineWidth: None | int | float = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class PolylineOutlineMaterial(BaseCZMLObject): """A definition of the material wrapper for a polyline outline.""" - polylineOutline = attr.ib(default=None) + polylineOutline: None | PolylineOutline = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class PolylineGlow(BaseCZMLObject): """A definition of how a glowing polyline appears.""" - color = attr.ib(default=None) - glowPower = attr.ib(default=None) - taperPower = attr.ib(default=None) + color: None | Color | str = Field(default=None) + glowPower: None | float | int = Field(default=None) + taperPower: None | float | int = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class PolylineGlowMaterial(BaseCZMLObject): """A material that fills the surface of a line with a glowing color.""" - polylineGlow = attr.ib(default=None) + polylineGlow: None | PolylineGlow = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class PolylineArrow(BaseCZMLObject): """A definition of how a polyline arrow appears.""" - color = attr.ib(default=None) + color: None | Color | str = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class PolylineArrowMaterial(BaseCZMLObject): """A material that fills the surface of a line with an arrow.""" - polylineArrow = attr.ib(default=None) + polylineArrow: None | PolylineArrow = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class PolylineDash(BaseCZMLObject): """A definition of how a polyline should be dashed with two colors.""" - color = attr.ib(default=None) - gapColor = attr.ib(default=None) - dashLength = attr.ib(default=None) - dashPattern = attr.ib(default=None) + color: None | Color | str = Field(default=None) + gapColor: None | Color | str = Field(default=None) + dashLength: None | float | int = Field(default=None) + dashPattern: None | int = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class PolylineDashMaterial(BaseCZMLObject): """A material that provides a how a polyline should be dashed.""" - polylineDash = attr.ib(default=None) + polylineDash: None | PolylineDash = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class PolylineMaterial(BaseCZMLObject): """A definition of how a surface is colored or shaded.""" - solidColor = attr.ib(default=None) - image = attr.ib(default=None) - grid = attr.ib(default=None) - stripe = attr.ib(default=None) - checkerboard = attr.ib(default=None) - polylineDash = attr.ib(default=None) + solidColor: None | SolidColorMaterial | str = Field(default=None) + image: None | ImageMaterial | str | Uri = Field(default=None) + grid: None | GridMaterial = Field(default=None) + stripe: None | StripeMaterial = Field(default=None) + checkerboard: None | CheckerboardMaterial = Field(default=None) + polylineDash: None | PolylineDashMaterial = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class SolidColorMaterial(BaseCZMLObject): """A material that fills the surface with a solid color.""" - color = attr.ib(default=None) + color: None | Color | str = Field(default=None) - @classmethod - def from_list(cls, color): - return cls(color=Color.from_list(color)) - -@attr.s(str=False, frozen=True, kw_only=True) class GridMaterial(BaseCZMLObject): """A material that fills the surface with a two-dimensional grid.""" - color = attr.ib(default=None) - cellAlpha = attr.ib(default=0.1) - lineCount = attr.ib(default=[8, 8]) - lineThickness = attr.ib(default=[1.0, 1.0]) - lineOffset = attr.ib(default=[0.0, 0.0]) + color: None | Color | str = Field(default=None) + cellAlpha: None | float | int = Field(default=None) + lineCount: None | list[int] = Field(default=None) + lineThickness: None | list[float] | list[int] = Field(default=None) + lineOffset: None | list[float] | list[int] = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class StripeMaterial(BaseCZMLObject): """A material that fills the surface with alternating colors.""" - orientation = attr.ib(default=StripeOrientations.HORIZONTAL) - evenColor = attr.ib(default=None) - oddColor = attr.ib(default=None) - offset = attr.ib(default=0.0) - repeat = attr.ib(default=1.0) + orientation: None | int = Field(default=None) + evenColor: None | Color | str = Field(default=None) + oddColor: None | Color | str = Field(default=None) + offset: None | float | int = Field(default=None) + repeat: None | float | int = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class CheckerboardMaterial(BaseCZMLObject): """A material that fills the surface with alternating colors.""" - evenColor = attr.ib(default=None) - oddColor = attr.ib(default=None) - repeat = attr.ib(default=None) + evenColor: None | Color | str = Field(default=None) + oddColor: None | Color | str = Field(default=None) + repeat: None | int = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class ImageMaterial(BaseCZMLObject): """A material that fills the surface with an image.""" - image = attr.ib(default=None) - repeat = attr.ib(default=[1, 1]) - color = attr.ib(default=None) - transparent = attr.ib(default=False) + image: None | ImageMaterial | str | Uri = Field(default=None) + repeat: None | list[int] = Field(default=None) + color: None | Color | str = Field(default=None) + transparent: None | bool = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class Color(BaseCZMLObject, Interpolatable, Deletable): """A color. The color can optionally vary over time.""" - rgba = attr.ib(default=None) - rgbaf = attr.ib(default=None) + rgba: None | RgbaValue | str | list[float] | list[int] = Field(default=None) + rgbaf: None | RgbafValue | str | list[float] | list[int] = Field(default=None) + @field_validator("rgba", "rgbaf") @classmethod def is_valid(cls, color): - """Determines if the input is a valid color""" - # [R, G, B] or [R, G, B, A] - if ( - isinstance(color, (list, tuple)) - and all(issubclass(type(v), int) for v in color) - and (3 <= len(color) <= 4) - ): - return all(0 <= v <= 255 for v in color) - # [r, g, b] or [r, g, b, a] (float) - elif ( - isinstance(color, (list, tuple)) - and all(issubclass(type(v), float) for v in color) - and (3 <= len(color) <= 4) - ): - return all(0 <= v <= 1 for v in color) - # Hexadecimal RGBA - elif issubclass(type(color), int): - return 0 <= color <= 0xFFFFFFFF - # RGBA string - elif isinstance(color, str): - try: - n = int(color.rsplit("#")[-1], 16) - return 0 <= n <= 0xFFFFFFFF - except ValueError: - return False - return False - - @classmethod - def from_list(cls, color): - if all(issubclass(type(v), int) for v in color): - color = color + [255] if len(color) == 3 else color[:] - - return cls(rgba=RgbaValue(values=color)) - else: - color = color + [1.0] if len(color) == 3 else color[:] + return get_color(color) + + # @classmethod + # def from_list(cls, color): + # if all(issubclass(type(v), int) for v in color): + # color = color + [255] if len(color) == 3 else color[:] + # return cls(rgba=RgbaValue(values=color)) + # else: + # color = color + [1.0] if len(color) == 3 else color[:] + # return cls(rgbaf=RgbafValue(values=color)) + + # @classmethod + # def from_tuple(cls, color): + # return cls.from_list(list(color)) + + # @classmethod + # def from_hex(cls, color): + # if color > 0xFFFFFF: + # values = [ + # (color & 0xFF000000) >> 24, + # (color & 0x00FF0000) >> 16, + # (color & 0x0000FF00) >> 8, + # (color & 0x000000FF) >> 0, + # ] + # else: + # values = [ + # (color & 0xFF0000) >> 16, + # (color & 0x00FF00) >> 8, + # (color & 0x0000FF) >> 0, + # 0xFF, + # ] + + # return cls.from_list(values) + + # @classmethod + # def from_str(cls, color): + # return cls.from_hex(int(color.rsplit("#")[-1], 16)) - return cls(rgbaf=RgbafValue(values=color)) - @classmethod - def from_tuple(cls, color): - return cls.from_list(list(color)) - - @classmethod - def from_hex(cls, color): - if color > 0xFFFFFF: - values = [ - (color & 0xFF000000) >> 24, - (color & 0x00FF0000) >> 16, - (color & 0x0000FF00) >> 8, - (color & 0x000000FF) >> 0, - ] - else: - values = [ - (color & 0xFF0000) >> 16, - (color & 0x00FF00) >> 8, - (color & 0x0000FF) >> 0, - 0xFF, - ] - - return cls.from_list(values) - - @classmethod - def from_str(cls, color): - return cls.from_hex(int(color.rsplit("#")[-1], 16)) - - -# noinspection PyPep8Naming -@attr.s(str=False, frozen=True, kw_only=True) class Position(BaseCZMLObject, Interpolatable, Deletable): """Defines a position. The position can optionally vary over time.""" - referenceFrame = attr.ib(default=None) - cartesian = attr.ib(default=None) - cartographicRadians = attr.ib(default=None) - cartographicDegrees = attr.ib(default=None) - cartesianVelocity = attr.ib(default=None) - reference = attr.ib(default=None) - interval = attr.ib(default=None) - - def __attrs_post_init__(self): - if all( - val is None - for val in ( - self.cartesian, - self.cartographicDegrees, - self.cartographicRadians, - self.cartesianVelocity, - self.reference, + referenceFrame: None | str = Field(default=None) + cartesian: None | Cartesian3Value | list[float] | list[int] = Field(default=None) + cartographicRadians: None | list[float] | list[int] = Field(default=None) + cartographicDegrees: None | list[float] | list[int] = Field(default=None) + cartesianVelocity: None | list[float] | list[int] = Field(default=None) + reference: None | str = Field(default=None) + interval: None | TimeInterval = Field(default=None) + epoch: None | str | dt.datetime = Field(default=None) + + @model_validator(mode="after") + def checks(self): + if self.delete: + return self + if ( + sum( + val is not None + for val in ( + self.cartesian, + self.cartographicDegrees, + self.cartographicRadians, + self.cartesianVelocity, + ) ) + != 1 ): - raise ValueError( + raise TypeError( "One of cartesian, cartographicDegrees, cartographicRadians or reference must be given" ) + return self + + @field_validator("reference") + @classmethod + def check_ref(cls, r): + check_reference(r) + return r + + @field_validator("epoch") + @classmethod + def check_epoch(cls, e): + return format_datetime_like(e) -# noinspection PyPep8Naming -@attr.s(str=False, frozen=True, kw_only=True) class ViewFrom(BaseCZMLObject, Interpolatable, Deletable): """suggested initial camera position offset when tracking this object. ViewFrom can optionally vary over time.""" - cartesian = attr.ib(default=None) - reference = attr.ib(default=None) + cartesian: None | Cartesian3Value | list[float] | list[int] + reference: None | str = Field(default=None) - def __attrs_post_init__(self): - if all(val is None for val in (self.cartesian, self.reference)): - raise ValueError("One of cartesian or reference must be given") + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -# noinspection PyPep8Naming -@attr.s(str=False, frozen=True, kw_only=True) class Billboard(BaseCZMLObject, HasAlignment): """A billboard, or viewport-aligned image. @@ -292,262 +289,318 @@ class Billboard(BaseCZMLObject, HasAlignment): A billboard is sometimes called a marker. """ - image = attr.ib() - show = attr.ib(default=None) - scale = attr.ib(default=None) - eyeOffset = attr.ib(default=None) - color = attr.ib(default=None) + image: str | Uri + show: None | bool = Field(default=None) + scale: None | float | int = Field(default=None) + pixelOffset: None | list[float] | list[int] = Field(default=None) + eyeOffset: None | list[float] | list[int] = Field(default=None) + color: None | Color | str = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class EllipsoidRadii(BaseCZMLObject, Interpolatable, Deletable): """The radii of an ellipsoid.""" - cartesian = attr.ib(default=None) - reference = attr.ib(default=None) + cartesian: None | Cartesian3Value | list[float] | list[int] + reference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -@attr.s(str=False, frozen=True, kw_only=True) class Corridor(BaseCZMLObject): """A corridor , which is a shape defined by a centerline and width that conforms to the curvature of the body shape. It can can optionally be extruded into a volume.""" - positions = attr.ib() - show = attr.ib(default=None) - width = attr.ib() - height = attr.ib(default=None) - heightReference = attr.ib(default=None) - extrudedHeight = attr.ib(default=None) - extrudedHeightReference = attr.ib(default=None) - cornerType = attr.ib(default=None) - granularity = attr.ib(default=None) - fill = attr.ib(default=None) - material = attr.ib(default=None) - outline = attr.ib(default=None) - outlineColor = attr.ib(default=None) - outlineWidth = attr.ib(default=None) - shadows = attr.ib(default=None) - distanceDisplayCondition = attr.ib(default=None) - classificationType = attr.ib(default=None) - zIndex = attr.ib(default=None) - - -@attr.s(str=False, frozen=True, kw_only=True) + positions: PositionList | list[int] | list[float] + show: None | bool = Field(default=None) + width: float | int + height: None | float | int = Field(default=None) + heightReference: None | HeightReference = Field(default=None) + extrudedHeight: None | float | int = Field(default=None) + extrudedHeightReference: None | HeightReference = Field(default=None) + cornerType: None | CornerType = Field(default=None) + granularity: None | float | int = Field(default=None) + fill: None | bool = Field(default=None) + material: None | Material | str = Field(default=None) + outline: None | Color | str = Field(default=None) + outlineColor: None | Color | str = Field(default=None) + outlineWidth: None | int | float = Field(default=None) + shadows: None | ShadowMode = Field(default=None) + distanceDisplayCondition: None | DistanceDisplayCondition = Field(default=None) + classificationType: None | ClassificationType = Field(default=None) + zIndex: None | int = Field(default=None) + + class Cylinder(BaseCZMLObject): """A cylinder, which is a special cone defined by length, top and bottom radius.""" - length = attr.ib() - show = attr.ib(default=None) - topRadius = attr.ib() - bottomRadius = attr.ib() - heightReference = attr.ib(default=None) - fill = attr.ib(default=None) - material = attr.ib(default=None) - outline = attr.ib(default=None) - outlineColor = attr.ib(default=None) - outlineWidth = attr.ib(default=None) - numberOfVerticalLines = attr.ib(default=None) - slices = attr.ib(default=None) - shadows = attr.ib(default=None) - distanceDisplayCondition = attr.ib(default=None) - - -@attr.s(str=False, frozen=True, kw_only=True) + length: float | int + show: None | bool = Field(default=None) + topRadius: float | int + bottomRadius: float | int + heightReference: None | HeightReference = Field(default=None) + fill: None | bool = Field(default=None) + material: None | Material | str = Field(default=None) + outline: None | bool = Field(default=None) + outlineColor: None | Color | str = Field(default=None) + outlineWidth: None | float | int = Field(default=None) + numberOfVerticalLines: None | int = Field(default=None) + slices: None | int = Field(default=None) + shadows: None | ShadowMode = Field(default=None) + distanceDisplayCondition: None | DistanceDisplayCondition = Field(default=None) + + class Ellipse(BaseCZMLObject): """An ellipse, which is a close curve, on or above Earth's surface.""" - semiMajorAxis = attr.ib() - semiMinorAxis = attr.ib() - show = attr.ib(default=None) - height = attr.ib(default=None) - heightReference = attr.ib(default=None) - extrudedHeight = attr.ib(default=None) - extrudedHeightReference = attr.ib(default=None) - rotation = attr.ib(default=None) - stRotation = attr.ib(default=None) - granularity = attr.ib(default=None) - fill = attr.ib(default=None) - material = attr.ib(default=None) - outline = attr.ib(default=None) - outlineColor = attr.ib(default=None) - outlineWidth = attr.ib(default=None) - numberOfVerticalLines = attr.ib(default=None) - shadows = attr.ib(default=None) - distanceDisplayCondition = attr.ib(default=None) - classificationType = attr.ib(default=None) - zIndex = attr.ib(default=None) - - -@attr.s(str=False, frozen=True, kw_only=True) + semiMajorAxis: float | int + semiMinorAxis: float | int + show: None | bool = Field(default=None) + height: None | float | int = Field(default=None) + heightReference: None | HeightReference = Field(default=None) + extrudedHeight: None | float | int = Field(default=None) + extrudedHeightReference: None | HeightReference = Field(default=None) + rotation: None | float | int = Field(default=None) + stRotation: None | float | int = Field(default=None) + granularity: None | float | int = Field(default=None) + fill: None | bool = Field(default=None) + material: None | Material | str = Field(default=None) + outline: None | bool = Field(default=None) + outlineColor: None | Color | str = Field(default=None) + outlineWidth: None | float | int = Field(default=None) + numberOfVerticalLines: None | int = Field(default=None) + shadows: None | ShadowMode = Field(default=None) + distanceDisplayCondition: None | DistanceDisplayCondition = Field(default=None) + classificationType: None | ClassificationType = Field(default=None) + zIndex: None | int = Field(default=None) + + class Polygon(BaseCZMLObject): """A polygon, which is a closed figure on the surface of the Earth.""" - positions = attr.ib() - show = attr.ib(default=None) - arcType = attr.ib(default=None) - granularity = attr.ib(default=None) - material = attr.ib(default=None) - shadows = attr.ib(default=None) - distanceDisplayCondition = attr.ib(default=None) - classificationType = attr.ib(default=None) - zIndex = attr.ib(default=None) - holes = attr.ib(default=None) - outlineColor = attr.ib(default=None) - outline = attr.ib(default=None) - extrudedHeight = attr.ib(default=None) - perPositionHeight = attr.ib(default=None) - - -@attr.s(str=False, frozen=True, kw_only=True) + positions: Position | PositionList | list[int] | list[float] + show: None | bool = Field(default=None) + arcType: None | ArcType = Field(default=None) + granularity: None | float | int = Field(default=None) + material: None | Material | str = Field(default=None) + shadows: None | ShadowMode = Field(default=None) + distanceDisplayCondition: None | DistanceDisplayCondition = Field(default=None) + classificationType: None | ClassificationType = Field(default=None) + zIndex: None | int = Field(default=None) + holes: None | PositionList | PositionListOfLists | list[int] | list[float] = Field( + default=None + ) # NOTE: not in documentation + outlineColor: None | Color | str = Field(default=None) + outline: None | bool = Field(default=None) + extrudedHeight: None | float | int = Field(default=None) + perPositionHeight: None | bool = Field(default=None) + + class Polyline(BaseCZMLObject): """A polyline, which is a line in the scene composed of multiple segments.""" - positions = attr.ib() - show = attr.ib(default=None) - arcType = attr.ib(default=None) - width = attr.ib(default=None) - granularity = attr.ib(default=None) - material = attr.ib(default=None) - followSurface = attr.ib(default=None) - shadows = attr.ib(default=None) - depthFailMaterial = attr.ib(default=None) - distanceDisplayCondition = attr.ib(default=None) - clampToGround = attr.ib(default=None) - classificationType = attr.ib(default=None) - zIndex = attr.ib(default=None) - - -@attr.s(str=False, frozen=True, kw_only=True) + positions: PositionList + show: None | bool = Field(default=None) + arcType: None | ArcType = Field(default=None) + width: None | float | int = Field(default=None) + granularity: None | float | int = Field(default=None) + material: ( + None + | PolylineMaterial + | PolylineDashMaterial + | PolylineArrowMaterial + | PolylineGlowMaterial + | PolylineOutlineMaterial + | str + ) = Field(default=None) + followSurface: None | bool = Field(default=None) + shadows: None | ShadowMode = Field(default=None) + depthFailMaterial: ( + None + | PolylineMaterial + | PolylineDashMaterial + | PolylineArrowMaterial + | PolylineGlowMaterial + | PolylineOutlineMaterial + | str + ) = Field(default=None) + distanceDisplayCondition: None | DistanceDisplayCondition = Field(default=None) + clampToGround: None | bool = Field(default=None) + classificationType: None | ClassificationType = Field(default=None) + zIndex: None | int = Field(default=None) + + class ArcType(BaseCZMLObject, Deletable): """The type of an arc.""" - arcType = attr.ib(default=None) - reference = attr.ib(default=None) + arcType: None | ArcTypes | str = Field(default=None) + reference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -@attr.s(str=False, frozen=True, kw_only=True) class ShadowMode(BaseCZMLObject, Deletable): """Whether or not an object casts or receives shadows from each light source when shadows are enabled.""" - shadowMode = attr.ib(default=None) - referenec = attr.ib(default=None) + shadowMode: None | ShadowModes = Field(default=None) + reference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -@attr.s(str=False, frozen=True, kw_only=True) class ClassificationType(BaseCZMLObject, Deletable): """Whether a classification affects terrain, 3D Tiles, or both.""" - classificationType = attr.ib(default=None) - reference = attr.ib(default=None) + classificationType: None | ClassificationTypes = Field(default=None) + reference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -@attr.s(str=False, frozen=True, kw_only=True) class DistanceDisplayCondition(BaseCZMLObject, Interpolatable, Deletable): """Indicates the visibility of an object based on the distance to the camera.""" - distanceDisplayCondition = attr.ib(default=None) - reference = attr.ib(default=None) + distanceDisplayCondition: None | DistanceDisplayConditionValue = Field(default=None) + reference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -@attr.s(str=False, frozen=True, kw_only=True) class PositionListOfLists(BaseCZMLObject, Deletable): """A list of positions.""" - referenceFrame = attr.ib(default=None) - cartesian = attr.ib(default=None) - cartographicRadians = attr.ib(default=None) - cartographicDegrees = attr.ib(default=None) - references = attr.ib(default=None) + referenceFrame: None | str | list[str] = Field(default=None) + cartesian: None | Cartesian3Value = Field(default=None) + cartographicRadians: ( + None | list[float] | list[int] | list[list[float]] | list[list[int]] + ) = Field(default=None) + cartographicDegrees: ( + None | list[float] | list[int] | list[list[float]] | list[list[int]] + ) = Field(default=None) + references: None | str | list[str] = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class PositionList(BaseCZMLObject, Interpolatable, Deletable): """A list of positions.""" - referenceFrame = attr.ib(default=None) - cartesian = attr.ib(default=None) - cartographicRadians = attr.ib(default=None) - cartographicDegrees = attr.ib(default=None) - references = attr.ib(default=None) - interval = attr.ib(default=None) - epoch = attr.ib(default=None) + referenceFrame: None | str | list[str] = Field(default=None) + cartesian: None | Cartesian3Value | list[float] | list[int] = Field(default=None) + cartographicRadians: ( + None | list[float] | list[int] | CartographicRadiansListValue + ) = Field(default=None) + cartographicDegrees: ( + None | list[float] | list[int] | CartographicDegreesListValue + ) = Field(default=None) + references: None | str | list[str] = Field(default=None) + interval: None | TimeInterval = Field(default=None) + epoch: None | str | dt.datetime = Field(default=None) # note: not documented + + @field_validator("epoch") + @classmethod + def check(cls, e): + return format_datetime_like(e) -@attr.s(str=False, frozen=True, kw_only=True) class Ellipsoid(BaseCZMLObject): """A closed quadric surface that is a three-dimensional analogue of an ellipse.""" - radii = attr.ib() - innerRadii = attr.ib(default=None) - minimumClock = attr.ib(default=None) - maximumClock = attr.ib(default=None) - minimumCone = attr.ib(default=None) - maximumCone = attr.ib(default=None) - show = attr.ib(default=None) - heightReference = attr.ib(default=None) - fill = attr.ib(default=None) - material = attr.ib(default=None) - outline = attr.ib(default=None) - outlineColor = attr.ib(default=None) - outlineWidth = attr.ib(default=None) - stackPartitions = attr.ib(default=None) - slicePartitions = attr.ib(default=None) - subdivisions = attr.ib(default=None) - - -@attr.s(str=False, frozen=True, kw_only=True) + radii: EllipsoidRadii + innerRadii: None | EllipsoidRadii = Field(default=None) + minimumClock: None | float | int = Field(default=None) + maximumClock: None | float | int = Field(default=None) + minimumCone: None | float | int = Field(default=None) + maximumCone: None | float | int = Field(default=None) + show: None | bool = Field(default=None) + heightReference: None | HeightReference = Field(default=None) + fill: None | bool = Field(default=None) + material: None | Material | str = Field(default=None) + outline: None | bool = Field(default=None) + outlineColor: None | Color | str = Field(default=None) + outlineWidth: None | float | int = Field(default=None) + stackPartitions: None | int = Field(default=None) + slicePartitions: None | int = Field(default=None) + subdivisions: None | int = Field(default=None) + + class Box(BaseCZMLObject): """A box, which is a closed rectangular cuboid.""" - show = attr.ib(default=None) - dimensions = attr.ib(default=None) - heightReference = attr.ib(default=None) - fill = attr.ib(default=None) - material = attr.ib(default=None) - outline = attr.ib(default=None) - outlineColor = attr.ib(default=None) - outlineWidth = attr.ib(default=None) - shadows = attr.ib(default=None) - distanceDisplayCondition = attr.ib(default=None) + show: None | bool = Field(default=None) + dimensions: None | BoxDimensions = Field(default=None) + heightReference: None | HeightReference = Field(default=None) + fill: None | bool = Field(default=None) + material: None | Material | str = Field(default=None) + outline: None | bool = Field(default=None) + outlineColor: None | Color | str = Field(default=None) + outlineWidth: None | float | int = Field(default=None) + shadows: None | ShadowMode = Field(default=None) + distanceDisplayCondition: None | DistanceDisplayCondition = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class BoxDimensions(BaseCZMLObject, Interpolatable): """The width, depth, and height of a box.""" - cartesian = attr.ib(default=None) - reference = attr.ib(default=None) + cartesian: None | Cartesian3Value = Field(default=None) + reference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -# noinspection PyPep8Naming -@attr.s(str=False, frozen=True, kw_only=True) class Rectangle(BaseCZMLObject, Interpolatable, Deletable): """A cartographic rectangle, which conforms to the curvature of the globe and can be placed on the surface or at altitude and can optionally be extruded into a volume. """ - coordinates = attr.ib(default=None) - fill = attr.ib(default=None) - material = attr.ib(default=None) + coordinates: None | RectangleCoordinates = Field(default=None) + fill: None | bool = Field(default=None) + material: None | Material | str = Field(default=None) -# noinspection PyPep8Naming -@attr.s(str=False, frozen=True, kw_only=True) class RectangleCoordinates(BaseCZMLObject, Interpolatable, Deletable): """A set of coordinates describing a cartographic rectangle on the surface of the ellipsoid.""" - reference = attr.ib(default=None) - wsen = attr.ib(default=None) - wsenDegrees = attr.ib(default=None) + wsen: None | list[float] | list[int] = Field(default=None) + wsenDegrees: None | list[float] | list[int] = Field(default=None) + reference: None | str = Field(default=None) - def __attrs_post_init__(self): - if all(val is None for val in (self.wsen, self.wsenDegrees)): - raise ValueError( - "One of cartesian, cartographicDegrees or cartographicRadians must be given" - ) + @model_validator(mode="after") + def checks(self): + if self.delete: + return self + if sum(val is not None for val in (self.wsen, self.wsenDegrees)) != 1: + raise TypeError("One of wsen or wsenDegrees must be given") + return self + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -@attr.s(str=False, frozen=True, kw_only=True) class EyeOffset(BaseCZMLObject, Deletable): """An offset in eye coordinates which can optionally vary over time. @@ -557,20 +610,55 @@ class EyeOffset(BaseCZMLObject, Deletable): """ - cartesian = attr.ib(default=None) - reference = attr.ib(default=None) + cartesian: None | Cartesian3Value | list[float] | list[int] = Field(default=None) + reference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -@attr.s(str=False, frozen=True, kw_only=True) class HeightReference(BaseCZMLObject, Deletable): """The height reference of an object, which indicates if the object's position is relative to terrain or not.""" - heightReference = attr.ib(default=None) - reference = attr.ib(default=None) + heightReference: None | HeightReferences = Field(default=None) + reference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r + + +class ColorBlendMode(BaseCZMLObject, Deletable): + """The height reference of an object, which indicates if the object's position is relative to terrain or not.""" + + colorBlendMode: None | ColorBlendModes = Field(default=None) + reference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r + + +class CornerType(BaseCZMLObject, Deletable): + """The height reference of an object, which indicates if the object's position is relative to terrain or not.""" + + cornerType: None | CornerTypes = Field(default=None) + reference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -# noinspection PyPep8Naming -@attr.s(str=False, frozen=True, kw_only=True) class Clock(BaseCZMLObject): """Initial settings for a simulated clock when a document is loaded. @@ -578,14 +666,17 @@ class Clock(BaseCZMLObject): """ - currentTime = attr.ib(default=None) - multiplier = attr.ib(default=1.0) - range = attr.ib(default=ClockRanges.LOOP_STOP) - step = attr.ib(default=ClockSteps.SYSTEM_CLOCK_MULTIPLIER) + currentTime: None | str | dt.datetime = Field(default=None) + multiplier: None | float | int = Field(default=None) + range: None | ClockRanges = Field(default=None) + step: None | ClockSteps = Field(default=None) + + @field_validator("currentTime") + @classmethod + def format_time(cls, time): + return format_datetime_like(time) -# noinspection PyPep8Naming -@attr.s(str=False, frozen=True, kw_only=True) class Path(BaseCZMLObject): """A path, which is a polyline defined by the motion of an object over time. @@ -597,61 +688,57 @@ class Path(BaseCZMLObject): """ - show = attr.ib(default=None) - leadTime = attr.ib(default=None) - trailTime = attr.ib(default=None) - width = attr.ib(default=1.0) - resolution = attr.ib(default=60.0) - material = attr.ib(default=None) - distanceDisplayCondition = attr.ib(default=None) + show: None | bool | Sequence = Field(default=None) + leadTime: None | float | int = Field(default=None) + trailTime: None | float | int = Field(default=None) + width: None | float | int = Field(default=None) + resolution: None | float | int = Field(default=None) + material: None | Material | str = Field(default=None) + distanceDisplayCondition: None | DistanceDisplayCondition = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class Point(BaseCZMLObject): """A point, or viewport-aligned circle.""" - show = attr.ib(default=None) - pixelSize = attr.ib(default=None) - heightReference = attr.ib(default=None) - color = attr.ib(default=None) - outlineColor = attr.ib(default=None) - outlineWidth = attr.ib(default=None) - scaleByDistance = attr.ib(default=None) - translucencyByDistance = attr.ib(default=None) - distanceDisplayCondition = attr.ib(default=None) - disableDepthTestDistance = attr.ib(default=None) + show: None | bool = Field(default=None) + pixelSize: None | float | int = Field(default=None) + heightReference: None | HeightReference = Field(default=None) + color: None | Color | str = Field(default=None) + outlineColor: None | Color | str = Field(default=None) + outlineWidth: None | float | int = Field(default=None) + scaleByDistance: None | NearFarScalar = Field(default=None) + translucencyByDistance: None | NearFarScalar = Field(default=None) + distanceDisplayCondition: None | DistanceDisplayCondition = Field(default=None) + disableDepthTestDistance: None | float | int = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class Tileset(BaseCZMLObject): """A 3D Tiles tileset.""" - show = attr.ib(default=None) - uri = attr.ib() - maximumScreenSpaceError = attr.ib(default=None) + uri: str | Uri + show: None | bool = Field(default=None) + maximumScreenSpaceError: None | float | int = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class Wall(BaseCZMLObject): """A two-dimensional wall defined as a line strip and optional maximum and minimum heights. It conforms to the curvature of the globe and can be placed along the surface or at altitude. """ - show = attr.ib(default=None) - positions = attr.ib() - minimumHeights = attr.ib(default=None) - maximumHeights = attr.ib(default=None) - granularity = attr.ib(default=None) - fill = attr.ib(default=None) - material = attr.ib(default=None) - outline = attr.ib(default=None) - outlineColor = attr.ib(default=None) - outlineWidth = attr.ib(default=None) - shadows = attr.ib(default=None) - distanceDisplayCondition = attr.ib(default=None) - - -@attr.s(str=False, frozen=True, kw_only=True) + show: None | bool = Field(default=None) + positions: PositionList + minimumHeights: None | list[float] | list[int] = Field(default=None) + maximumHeights: None | list[float] | list[int] = Field(default=None) + granularity: None | float | int = Field(default=None) + fill: None | bool = Field(default=None) + material: None | Material | str = Field(default=None) + outline: None | bool = Field(default=None) + outlineColor: None | Color | str = Field(default=None) + outlineWidth: None | float | int = Field(default=None) + shadows: None | ShadowMode = Field(default=None) + distanceDisplayCondition: None | DistanceDisplayCondition = Field(default=None) + + class NearFarScalar(BaseCZMLObject, Interpolatable, Deletable): """A numeric value which will be linearly interpolated between two values based on an object's distance from the camera, in eye coordinates. @@ -661,29 +748,34 @@ class NearFarScalar(BaseCZMLObject, Interpolatable, Deletable): less than the near distance or greater than the far distance, respectively. """ - nearFarScalar = attr.ib(default=None) - reference = attr.ib(default=None) + nearFarScalar: None | list[float] | list[int] | NearFarScalarValue = Field( + default=None + ) + reference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -# noinspection PyPep8Naming -@attr.s(str=False, frozen=True, kw_only=True) class Label(BaseCZMLObject, HasAlignment): """A string of text.""" - show = attr.ib(default=True) - text = attr.ib(default=None) - font = attr.ib(default=None) - style = attr.ib(default=LabelStyles.FILL) - scale = attr.ib(default=None) - showBackground = attr.ib(default=None) - backgroundColor = attr.ib(default=None) - fillColor = attr.ib(default=None) - outlineColor = attr.ib(default=None) - outlineWidth = attr.ib(default=1.0) - pixelOffset = attr.ib(default=None) + show: None | bool = Field(default=None) + text: None | str = Field(default=None) + font: None | str = Field(default=None) + style: None | LabelStyles = Field(default=None) + scale: None | float | int = Field(default=None) + showBackground: None | bool = Field(default=None) + backgroundColor: None | Color | str = Field(default=None) + fillColor: None | Color | str = Field(default=None) + outlineColor: None | Color | str = Field(default=None) + outlineWidth: None | float | int = Field(default=None) + pixelOffset: None | float | int | Cartesian2Value = Field(default=None) -@attr.s(str=False, frozen=True, kw_only=True) class Orientation(BaseCZMLObject, Interpolatable, Deletable): """Defines an orientation. @@ -692,50 +784,60 @@ class Orientation(BaseCZMLObject, Interpolatable, Deletable): """ - unitQuaternion = attr.ib(default=None) - reference = attr.ib(default=None) - velocityReference = attr.ib(default=None) + unitQuaternion: None | list[float] | list[int] | UnitQuaternionValue = Field( + default=None + ) + reference: None | str = Field(default=None) + velocityReference: None | str = Field(default=None) + + @field_validator("reference") + @classmethod + def check(cls, r): + check_reference(r) + return r -@attr.s(str=False, frozen=True, kw_only=True) class Model(BaseCZMLObject): """A 3D model.""" - show = attr.ib(default=None) - gltf = attr.ib() - scale = attr.ib(default=None) - minimumPixelSize = attr.ib(default=None) - maximumScale = attr.ib(default=None) - incrementallyLoadTextures = attr.ib(default=None) - runAnimations = attr.ib(default=None) - shadows = attr.ib(default=None) - heightReference = attr.ib(default=None) - silhouetteColor = attr.ib(default=None) - silhouetteSize = attr.ib(default=None) - color = attr.ib(default=None) - colorBlendMode = attr.ib(default=None) - colorBlendAmount = attr.ib(default=None) - distanceDisplayCondition = attr.ib(default=None) - nodeTransformations = attr.ib(default=None) - articulations = attr.ib(default=None) - - -@attr.s(str=False, frozen=True, kw_only=True) + show: None | bool = Field(default=None) + gltf: str + scale: None | float | int = Field(default=None) + minimumPixelSize: None | float | int = Field(default=None) + maximumScale: None | float | int = Field(default=None) + incrementallyLoadTextures: None | bool = Field(default=None) + runAnimations: None | bool = Field(default=None) + shadows: None | ShadowMode = Field(default=None) + heightReference: None | HeightReference = Field(default=None) + silhouetteColor: None | Color | str = Field(default=None) + silhouetteSize: None | Color | str = Field(default=None) + color: None | Color | str = Field(default=None) + colorBlendMode: None | ColorBlendMode = Field(default=None) + colorBlendAmount: None | float | int = Field(default=None) + distanceDisplayCondition: None | DistanceDisplayCondition = Field(default=None) + nodeTransformations: None | Any = Field(default=None) + articulations: None | Any = Field(default=None) + + class Uri(BaseCZMLObject, Deletable): """A URI value. The URI can optionally vary with time. """ - uri = attr.ib(default=None) + uri: None | str = Field(default=None) - @uri.validator - def _check_uri(self, attribute, value): + @field_validator("uri") + @classmethod + def _check_uri(cls, value: str): + if is_url(value): + return value try: parse_data_uri(value) - except ValueError as e: - if not is_url(value): - raise ValueError("uri must be a URL or a data URI") from e + except ValueError: + raise TypeError("uri must be a URL or a data URI") from None + return value - def to_json(self): + @model_serializer + def custom_serializer(self) -> None | str: return self.uri diff --git a/src/czml3/types.py b/src/czml3/types.py index f41a98e..add5d60 100644 --- a/src/czml3/types.py +++ b/src/czml3/types.py @@ -1,14 +1,108 @@ import datetime as dt +import re +import sys +from typing import Any -import attr from dateutil.parser import isoparse as parse_iso_date +from pydantic import ( + Field, + field_validator, + model_serializer, + model_validator, +) from .base import BaseCZMLObject from .constants import ISO8601_FORMAT_Z +if sys.version_info[1] >= 11: + from typing import Self +else: + from typing_extensions import Self + TYPE_MAPPING = {bool: "boolean"} +def get_color(color): + """Determines if the input is a valid color""" + if color is None or ( + isinstance(color, list) + and all(issubclass(type(v), int | float) for v in color) + and len(color) == 4 + and (all(0 <= v <= 255 for v in color) or all(0 <= v <= 1 for v in color)) + ): + return color + elif ( + isinstance(color, list) + and all(issubclass(type(v), int | float) for v in color) + and len(color) == 3 + and all(0 <= v <= 255 for v in color) + ): + return color + [255] + # rgbf or rgbaf + # if ( + # isinstance(color, list) + # and all(issubclass(type(v), int | float) for v in color) + # and (3 <= len(color) <= 4) + # and not all(0 <= v <= 1 for v in color) + # ): + # raise TypeError("RGBF or RGBAF values must be between 0 and 1") + elif ( + isinstance(color, list) + and all(issubclass(type(v), int | float) for v in color) + and len(color) == 3 + and all(0 <= v <= 1 for v in color) + ): + return color + [1.0] + # Hexadecimal RGBA + # elif issubclass(type(color), int) and not (0 <= color <= 0xFFFFFFFF): + # raise TypeError("Hexadecimal RGBA not valid") + elif ( + issubclass(type(color), int) and (0 <= color <= 0xFFFFFFFF) and color > 0xFFFFFF + ): + return [ + (color & 0xFF000000) >> 24, + (color & 0x00FF0000) >> 16, + (color & 0x0000FF00) >> 8, + (color & 0x000000FF) >> 0, + ] + elif issubclass(type(color), int) and (0 <= color <= 0xFFFFFFFF): + return [ + (color & 0xFF0000) >> 16, + (color & 0x00FF00) >> 8, + (color & 0x0000FF) >> 0, + 0xFF, + ] + # RGBA string + elif isinstance(color, str): + n = int(color.rsplit("#")[-1], 16) + if not (0 <= n <= 0xFFFFFFFF): + raise TypeError("RGBA string not valid") + if n > 0xFFFFFF: + return [ + (n & 0xFF000000) >> 24, + (n & 0x00FF0000) >> 16, + (n & 0x0000FF00) >> 8, + (n & 0x000000FF) >> 0, + ] + else: + return [ + (n & 0xFF0000) >> 16, + (n & 0x00FF00) >> 8, + (n & 0x0000FF) >> 0, + 0xFF, + ] + raise TypeError("Colour type not supported") + + +def check_reference(r): + if r is None: + return + elif re.search(r"^.+#.+$", r) is None: + raise TypeError( + "Invalid reference string format. Input must be of the form id#property" + ) + + def format_datetime_like(dt_object): if dt_object is None: result = dt_object @@ -22,7 +116,7 @@ def format_datetime_like(dt_object): result = dt_object elif isinstance(dt_object, dt.datetime): - result = dt_object.astimezone(dt.timezone.utc).strftime(ISO8601_FORMAT_Z) + result = dt_object.strftime(ISO8601_FORMAT_Z) else: result = dt_object.strftime(ISO8601_FORMAT_Z) @@ -30,40 +124,16 @@ def format_datetime_like(dt_object): return result -@attr.s(str=False, frozen=True, kw_only=True) -class _TimeTaggedCoords(BaseCZMLObject): - NUM_COORDS: int - property_name: str - - values = attr.ib() - - @values.validator - def _check_values(self, attribute, value): - if not ( - len(value) == self.NUM_COORDS or len(value) % (self.NUM_COORDS + 1) == 0 - ): - raise ValueError( - "Input values must have either 3 or N * 4 values, " - "where N is the number of time-tagged samples." - ) - - def to_json(self): - if hasattr(self, "property_name"): - return {self.property_name: list(self.values)} - return list(self.values) - - -@attr.s(str=False, frozen=True, kw_only=True) class FontValue(BaseCZMLObject): """A font, specified using the same syntax as the CSS "font" property.""" - font = attr.ib(default=None) + font: str - def to_json(self): + @model_serializer + def custom_serializer(self): return self.font -@attr.s(str=False, frozen=True, kw_only=True) class RgbafValue(BaseCZMLObject): """A color specified as an array of color components [Red, Green, Blue, Alpha] where each component is in the range 0.0-1.0. If the array has four elements, @@ -73,32 +143,35 @@ class RgbafValue(BaseCZMLObject): """ - values = attr.ib() + values: list[float] | list[int] - @values.validator - def _check_values(self, attribute, value): - if not (len(value) == 4 or len(value) % 5 == 0): - raise ValueError( - "Input values must have either 4 or N * 5 values, " + @model_validator(mode="after") + def _check_values(self) -> Self: + num_coords = 4 + if not ( + len(self.values) == num_coords or len(self.values) % (num_coords + 1) == 0 + ): + raise TypeError( + f"Input values must have either {num_coords} or N * {num_coords + 1} values, " "where N is the number of time-tagged samples." ) - - if len(value) == 4: - if not all(0 <= val <= 1 for val in value): - raise ValueError("Color values must be floats in the range 0-1.") + if len(self.values) == num_coords: + if not all(0 <= val <= 1 for val in self.values): + raise TypeError("Color values must be floats in the range 0-1.") else: - for i in range(0, len(value), 5): - v = value[i + 1 : i + 5] + for i in range(0, len(self.values), num_coords + 1): + v = self.values[i + 1 : i + num_coords + 1] if not all(0 <= val <= 1 for val in v): - raise ValueError("Color values must be floats in the range 0-1.") + raise TypeError("Color values must be floats in the range 0-1.") + return self - def to_json(self): + @model_serializer + def custom_serializer(self): return list(self.values) -@attr.s(str=False, frozen=True, kw_only=True) class RgbaValue(BaseCZMLObject): """A color specified as an array of color components [Red, Green, Blue, Alpha] where each component is in the range 0-255. If the array has four elements, @@ -110,57 +183,60 @@ class RgbaValue(BaseCZMLObject): """ - values = attr.ib() + values: list[float] | list[int] - @values.validator - def _check_values(self, attribute, value): - if not (len(value) == 4 or len(value) % 5 == 0): - raise ValueError( - "Input values must have either 4 or N * 5 values, " + @model_validator(mode="after") + def _check_values(self) -> Self: + num_coords = 4 + if not ( + len(self.values) == num_coords or len(self.values) % (num_coords + 1) == 0 + ): + raise TypeError( + f"Input values must have either {num_coords} or N * {num_coords + 1} values, " "where N is the number of time-tagged samples." ) - if len(value) == 4: - if not all(isinstance(val, int) and 0 <= val <= 255 for val in value): - raise ValueError("Color values must be integers in the range 0-255.") + if len(self.values) == num_coords and not all( + isinstance(val, int) and 0 <= val <= 255 for val in self.values + ): + raise TypeError("Color values must be integers in the range 0-255.") else: - for i in range(0, len(value), 5): - v = value[i + 1 : i + 5] + for i in range(0, len(self.values), num_coords + 1): + v = self.values[i + 1 : i + num_coords + 1] if not all(isinstance(val, int) and 0 <= val <= 255 for val in v): - raise ValueError( - "Color values must be integers in the range 0-255." - ) + raise TypeError("Color values must be integers in the range 0-255.") + return self - def to_json(self): - return list(self.values) + @model_serializer + def custom_serializer(self): + return self.values -@attr.s(str=False, frozen=True, kw_only=True) class ReferenceValue(BaseCZMLObject): """Represents a reference to another property. References can be used to specify that two properties on different objects are in fact, the same property. """ - string = attr.ib(default=None) + string: str - @string.validator - def _check_string(self, attribute, value): - if not isinstance(value, str): - raise ValueError("Reference must be a string") - if "#" not in value: - raise ValueError( + @field_validator("string") + @classmethod + def _check_string(cls, v): + if "#" not in v: + raise TypeError( "Invalid reference string format. Input must be of the form id#property" ) + return v - def to_json(self): + @model_serializer + def custom_serializer(self): return self.string -@attr.s(str=False, frozen=True, kw_only=True) -class Cartesian3Value(_TimeTaggedCoords): +class Cartesian3Value(BaseCZMLObject): """A three-dimensional Cartesian value specified as [X, Y, Z]. If the values has three elements, the value is constant. @@ -170,11 +246,30 @@ class Cartesian3Value(_TimeTaggedCoords): """ - NUM_COORDS = 3 + values: None | list[Any] = Field(default=None) + + @model_validator(mode="after") + def _check_values(self) -> Self: + if self.values is None: + return self + num_coords = 3 + if not ( + len(self.values) == num_coords or len(self.values) % (num_coords + 1) == 0 + ): + raise TypeError( + f"Input values must have either {num_coords} or N * {num_coords + 1} values, " + "where N is the number of time-tagged samples." + ) + return self + + @model_serializer + def custom_serializer(self) -> list[Any]: + if self.values is None: + return [] + return list(self.values) -@attr.s(str=False, frozen=True, kw_only=True) -class Cartesian2Value(_TimeTaggedCoords): +class Cartesian2Value(BaseCZMLObject): """A two-dimensional Cartesian value specified as [X, Y]. If the values has two elements, the value is constant. @@ -184,12 +279,30 @@ class Cartesian2Value(_TimeTaggedCoords): """ - NUM_COORDS = 2 - property_name = "cartesian2" + values: None | list[Any] = Field(default=None) + + @model_validator(mode="after") + def _check_values(self) -> Self: + if self.values is None: + return self + num_coords = 2 + if not ( + len(self.values) == num_coords or len(self.values) % (num_coords + 1) == 0 + ): + raise TypeError( + f"Input values must have either {num_coords} or N * {num_coords + 1} values, " + "where N is the number of time-tagged samples." + ) + return self + + @model_serializer + def custom_serializer(self): + if self.values is None: + return {} + return {"cartesian2": list(self.values)} -@attr.s(str=False, frozen=True, kw_only=True) -class CartographicRadiansValue(_TimeTaggedCoords): +class CartographicRadiansValue(BaseCZMLObject): """A geodetic, WGS84 position specified as [Longitude, Latitude, Height]. Longitude and Latitude are in radians and Height is in meters. @@ -200,11 +313,30 @@ class CartographicRadiansValue(_TimeTaggedCoords): """ - NUM_COORDS = 3 + values: None | list[Any] = Field(default=None) + @model_validator(mode="after") + def _check_values(self) -> Self: + if self.values is None: + return self + num_coords = 3 + if not ( + len(self.values) == num_coords or len(self.values) % (num_coords + 1) == 0 + ): + raise TypeError( + f"Input values must have either {num_coords} or N * {num_coords + 1} values, " + "where N is the number of time-tagged samples." + ) + return self -@attr.s(str=False, frozen=True, kw_only=True) -class CartographicDegreesValue(_TimeTaggedCoords): + @model_serializer + def custom_serializer(self): + if self.values is None: + return [] + return list(self.values) + + +class CartographicDegreesValue(BaseCZMLObject): """A geodetic, WGS84 position specified as [Longitude, Latitude, Height]. Longitude and Latitude are in degrees and Height is in meters. @@ -215,59 +347,82 @@ class CartographicDegreesValue(_TimeTaggedCoords): """ - NUM_COORDS = 3 + values: None | list[Any] = Field(default=None) + + @model_validator(mode="after") + def _check_values(self) -> Self: + if self.values is None: + return self + num_coords = 3 + if not ( + len(self.values) == num_coords or len(self.values) % (num_coords + 1) == 0 + ): + raise TypeError( + f"Input values must have either {num_coords} or N * {num_coords + 1} values, " + "where N is the number of time-tagged samples." + ) + return self + + @model_serializer + def custom_serializer(self) -> list[Any]: + if self.values is None: + return [] + return self.values -@attr.s(str=False, frozen=True, kw_only=True) class StringValue(BaseCZMLObject): """A string value. The string can optionally vary with time. """ - string = attr.ib(default=None) + string: str - def to_json(self): + @model_serializer + def custom_serializer(self) -> str: return self.string -@attr.s(str=False, frozen=True, kw_only=True) class CartographicRadiansListValue(BaseCZMLObject): """A list of geodetic, WGS84 positions specified as [Longitude, Latitude, Height, Longitude, Latitude, Height, ...], where Longitude and Latitude are in radians and Height is in meters.""" - values = attr.ib() + values: list[float] | list[int] - @values.validator - def _check_values(self, attribute, value): - if len(value) % 3 != 0: - raise ValueError( - "Invalid values. Input values should be arrays of size 3 * N" + @model_validator(mode="after") + def _check_values(self) -> Self: + num_coords = 3 + if len(self.values) % num_coords != 0: + raise TypeError( + f"Invalid values. Input values should be arrays of size {num_coords} * N" ) + return self - def to_json(self): + @model_serializer + def custom_serializer(self): return list(self.values) -@attr.s(str=False, frozen=True, kw_only=True) class CartographicDegreesListValue(BaseCZMLObject): """A list of geodetic, WGS84 positions specified as [Longitude, Latitude, Height, Longitude, Latitude, Height, ...], where Longitude and Latitude are in degrees and Height is in meters.""" - values = attr.ib() + values: list[float] | list[int] - @values.validator - def _check_values(self, attribute, value): - if len(value) % 3 != 0: - raise ValueError( - "Invalid values. Input values should be arrays of size 3 * N" + @model_validator(mode="after") + def _check_values(self) -> Self: + num_coords = 3 + if len(self.values) % num_coords != 0: + raise TypeError( + f"Invalid values. Input values should be arrays of size {num_coords} * N" ) + return self - def to_json(self): + @model_serializer + def custom_serializer(self): return list(self.values) -@attr.s(str=False, frozen=True, kw_only=True) class DistanceDisplayConditionValue(BaseCZMLObject): """A value indicating the visibility of an object based on the distance to the camera, specified as two values [NearDistance, FarDistance]. If the array has two elements, the value is constant. If it has three or more elements, @@ -275,20 +430,22 @@ class DistanceDisplayConditionValue(BaseCZMLObject): where Time is an ISO 8601 date and time string or seconds since epoch. """ - values = attr.ib(default=None) + values: list[float] | list[int] - @values.validator - def _check_values(self, attribute, value): - if len(value) != 2 and len(value) % 3 != 0: - raise ValueError( - "Invalid values. Input values should be arrays of size either 2 or 3 * N" + @model_validator(mode="after") + def _check_values(self) -> Self: + num_coords = 2 + if len(self.values) != num_coords and len(self.values) % (num_coords + 1) != 0: + raise TypeError( + f"Invalid values. Input values should be arrays of size either {num_coords} or {num_coords + 1} * N" ) + return self - def to_json(self): + @model_serializer + def custom_serializer(self): return list(self.values) -@attr.s(str=False, frozen=True, kw_only=True) class NearFarScalarValue(BaseCZMLObject): """A near-far scalar value specified as four values [NearDistance, NearValue, FarDistance, FarValue]. @@ -297,76 +454,81 @@ class NearFarScalarValue(BaseCZMLObject): FarDistance, FarValue, ...], where Time is an ISO 8601 date and time string or seconds since epoch. """ - values = attr.ib(default=None) + values: list[float] | list[int] - @values.validator - def _check_values(self, attribute, value): - if not (len(value) == 4 or len(value) % 5 == 0): - raise ValueError( - "Input values must have either 4 or N * 5 values, " + @model_validator(mode="after") + def _check_values(self) -> Self: + num_coords = 4 + if not ( + len(self.values) == num_coords or len(self.values) % (num_coords + 1) == 0 + ): + raise TypeError( + f"Input values must have either {num_coords} or N * {num_coords + 1} values, " "where N is the number of time-tagged samples." ) + return self - def to_json(self): + @model_serializer + def custom_serializer(self): return list(self.values) -@attr.s(str=False, frozen=True, kw_only=True) class TimeInterval(BaseCZMLObject): """A time interval, specified in ISO8601 interval format.""" - _start = attr.ib(default=None) - _end = attr.ib(default=None) - - def to_json(self): - if self._start is None: - start = "0000-00-00T00:00:00Z" - else: - start = format_datetime_like(self._start) + start: str | dt.datetime = Field(default="0001-01-01T00:00:00Z") + end: str | dt.datetime = Field(default="9999-12-31T23:59:59Z") - if self._end is None: - end = "9999-12-31T24:00:00Z" - else: - end = format_datetime_like(self._end) + @field_validator("start", "end") + @classmethod + def format_time(cls, time): + return format_datetime_like(time) - return f"{start}/{end}" + @model_serializer + def custom_serializer(self) -> str: + return f"{self.start}/{self.end}" -@attr.s(str=False, frozen=True, kw_only=True) class IntervalValue(BaseCZMLObject): """Value over some interval.""" - _start = attr.ib() - _end = attr.ib() - _value = attr.ib() + start: str | dt.datetime + end: str | dt.datetime + value: Any = Field(default=None) - def to_json(self): - obj_dict = {"interval": TimeInterval(start=self._start, end=self._end)} + @model_serializer + def custom_serializer(self) -> dict[str, Any]: + obj_dict = { + "interval": TimeInterval(start=self.start, end=self.end).model_dump( + exclude_none=True + ) + } - if isinstance(self._value, BaseCZMLObject): - obj_dict.update(**self._value.to_json()) - elif isinstance(self._value, list): - for value in self._value: - obj_dict.update(**value.to_json()) + if isinstance(self.value, BaseCZMLObject): + obj_dict.update(self.value.model_dump(exclude_none=True)) + elif isinstance(self.value, list) and all( + isinstance(v, BaseCZMLObject) for v in self.value + ): + for value in self.value: + obj_dict.update(value.model_dump()) else: - key = TYPE_MAPPING[type(self._value)] - obj_dict[key] = self._value + key = TYPE_MAPPING[type(self.value)] + obj_dict[key] = self.value return obj_dict -@attr.s(str=False, frozen=True) class Sequence(BaseCZMLObject): """Sequence, list, array of objects.""" - _values = attr.ib() + values: list[Any] - def to_json(self): - return list(self._values) + @model_serializer + def custom_serializer(self) -> list[Any]: + return list(self.values) -@attr.s(str=False, frozen=True, kw_only=True) -class UnitQuaternionValue(_TimeTaggedCoords): +class UnitQuaternionValue(BaseCZMLObject): """A set of 4-dimensional coordinates used to represent rotation in 3-dimensional space. It's specified as [X, Y, Z, W]. If the array has four elements, the value is constant. @@ -376,45 +538,39 @@ class UnitQuaternionValue(_TimeTaggedCoords): """ - NUM_COORDS = 4 + values: list[float] | list[int] + + @model_validator(mode="after") + def _check_values(self) -> Self: + num_coords = 4 + if len(self.values) % num_coords != 0: + raise TypeError( + f"Invalid values. Input values should be arrays of size {num_coords} * N" + ) + return self + + @model_serializer + def custom_serializer(self): + return list(self.values) -@attr.s(str=False, frozen=True, kw_only=True) class EpochValue(BaseCZMLObject): """A value representing a time epoch.""" - _value = attr.ib() + value: str | dt.datetime - @_value.validator - def _check_epoch(self, attribute, value): - if not isinstance(value, (str, dt.datetime)): - raise ValueError("Epoch must be a string or a datetime object.") + @model_serializer + def custom_serializer(self): + return {"epoch": format_datetime_like(self.value)} - def to_json(self): - return {"epoch": format_datetime_like(self._value)} - -@attr.s(str=False, frozen=True, kw_only=True) class NumberValue(BaseCZMLObject): """A single number, or a list of number pairs signifying the time and representative value.""" - values = attr.ib() - - @values.validator - def _check_values(self, attribute, value): - if isinstance(value, list): - if not all(isinstance(val, (int, float)) for val in value): - raise ValueError("Values must be integers or floats.") - if len(value) % 2 != 0: - raise ValueError( - "Values must be a list of number pairs signifying the time and representative value." - ) + values: int | float | list[float] | list[int] - elif not isinstance(value, (int, float)): - raise ValueError("Values must be integers or floats.") - - def to_json(self): - if isinstance(self.values, (int, float)): + @model_serializer + def custom_serializer(self): + if isinstance(self.values, int | float): return {"number": self.values} - return {"number": list(self.values)} diff --git a/src/czml3/utils.py b/src/czml3/utils.py deleted file mode 100644 index 90a4809..0000000 --- a/src/czml3/utils.py +++ /dev/null @@ -1,70 +0,0 @@ -from functools import reduce - -from .properties import Color -from .types import RgbafValue, RgbaValue - - -def get_color_list(timestamps, colors, rgbaf=False): - """ - Given a list of valid colors (rgb/rgba tuples, shorthand hex string or integer representation) and a list of - time-stamps, create a Color object with rgba/rgbaf values of the form: [time_0, r_0, g_0, b_0,..., time_k, r_k, - g_k, b_k] - - Parameters - ---------- - :param list(str) timestamps: The list of the timestamps (ISO 8601 date or seconds since epoch) - :param str/int/list colors : A list of valid colors - :param bool rgbaf: If set to True, returns rgbaf values, else return rgba values - """ - # Check if colors is a valid list of colors - if all(Color.is_valid(v) for v in colors): - color_lst = list(map(get_color, colors)) - else: - raise ValueError("Invalid input") - - # Quick function to convert between rgba-rgbaf easier - def color_r(c): - if rgbaf: - return ( - c.rgbaf.values if c.rgbaf else [float(x / 255) for x in c.rgba.values] - ) - else: - return ( - c.rgba.values - if c.rgba - else [int(round(x * 255)) for x in c.rgbaf.values] - ) - - # Get combined list of timestamps and colors - time_colr = [[time] + color_r(c) for time, c in zip(timestamps, color_lst)] - # Flatten list - time_colr = reduce(lambda x, y: x + y, time_colr) - - if rgbaf: - return Color(rgbaf=RgbafValue(values=time_colr)) - else: - return Color(rgba=RgbaValue(values=time_colr)) - - -def get_color(color): - """ - A helper function to make color setting more versatile. What the ``color`` parameter determines depends on - its type. - - Parameters - ---------- - - :param str/int/list color: Depending on the type, ``color`` can be either a hexadecimal rgb/rgba color value or - a tuple in the form of [r, g, b, a] or [r, g, b]. - - """ - # Color.from_string, Color.from_int, ... - if isinstance(color, str) and 6 <= len(color) <= 10: - return Color.from_str(color) - elif issubclass(int, type(color)): - return Color.from_hex(int(color)) - elif isinstance(color, list) and Color.is_valid( - color - ): # If it is a valid color in list form, simply return it - return Color.from_list(color) - raise ValueError("Invalid input") diff --git a/src/czml3/widget.py b/src/czml3/widget.py index 21196d6..5ff865a 100644 --- a/src/czml3/widget.py +++ b/src/czml3/widget.py @@ -1,6 +1,6 @@ from uuid import uuid4 -import attr +from pydantic import BaseModel, Field from .core import Document, Preamble @@ -74,21 +74,19 @@ """ -@attr.s -class CZMLWidget: - document = attr.ib(default=Document([Preamble()])) - cesium_version = attr.ib(default="1.88") - ion_token = attr.ib(default="") - terrain = attr.ib(default=TERRAIN["Ellipsoid"]) - imagery = attr.ib(default=IMAGERY["OSM"]) - - _container_id = attr.ib(factory=uuid4) +class CZMLWidget(BaseModel): + document: Document = Field(default=Document(packets=[Preamble()])) + cesium_version: str = Field(default="1.88") + ion_token: str = Field(default="") + terrain: str = Field(default=TERRAIN["Ellipsoid"]) + imagery: str = Field(default=IMAGERY["OSM"]) + container_id: str = Field(default=str(uuid4)) def build_script(self): return SCRIPT_TPL.format( cesium_version=self.cesium_version, - czml=self.document.dumps(), - container_id=self._container_id, + czml=self.document.to_json(), + container_id=self.container_id, ion_token=self.ion_token, terrain=self.terrain, imagery=self.imagery, @@ -98,7 +96,7 @@ def to_html(self, widget_height="400px"): return CESIUM_TPL.format( cesium_version=self.cesium_version, script=self.build_script(), - container_id=self._container_id, + container_id=self.container_id, widget_height=widget_height, ) diff --git a/tests/simple.czml b/tests/simple.czml index c24e598..095f156 100644 --- a/tests/simple.czml +++ b/tests/simple.czml @@ -7,9 +7,7 @@ "clock":{ "interval":"2012-03-15T10:00:00.000000Z/2012-03-16T10:00:00.000000Z", "currentTime":"2012-03-15T10:00:00.000000Z", - "multiplier":60, - "range":"LOOP_STOP", - "step":"SYSTEM_CLOCK_MULTIPLIER" + "multiplier":60 } }, { diff --git a/tests/test_document.py b/tests/test_document.py index 7931d0a..50bfe21 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -1,5 +1,3 @@ -from io import StringIO - from czml3 import Document, Packet @@ -7,7 +5,7 @@ def test_document_has_expected_packets(): packet0 = Packet(id="id_00") packet1 = Packet(id="id_01") - document = Document([packet0, packet1]) + document = Document(packets=[packet0, packet1]) assert document.packets == [packet0, packet1] @@ -20,29 +18,15 @@ def test_doc_repr(): } ]""" - document = Document([packet]) + document = Document(packets=[packet]) assert str(document) == expected_result def test_doc_dumps(): packet = Packet(id="id_00") - expected_result = """[{"id": "id_00"}]""" + expected_result = """[{"id":"id_00"}]""" - document = Document([packet]) + document = Document(packets=[packet]) assert document.dumps() == expected_result - - -def test_document_dump(): - expected_result = """[{"id": "id_00"}]""" - packet = Packet(id="id_00") - - document = Document([packet]) - - with StringIO() as fp: - document.dump(fp) - fp.seek(0) - result = fp.read() - - assert result == expected_result diff --git a/tests/test_examples.py b/tests/test_examples.py index 402977e..35b945c 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -2,6 +2,7 @@ import os import pytest + from czml3.examples import simple TESTS_DIR = os.path.dirname(os.path.realpath(__file__)) @@ -12,7 +13,7 @@ def test_simple(document, filename): with open(os.path.join(TESTS_DIR, filename)) as fp: expected_result = json.load(fp) - result = json.loads(document.dumps()) + result = json.loads(document.to_json()) for ii, packet in enumerate(result): expected_packet = expected_result[ii] for key in packet: diff --git a/tests/test_packet.py b/tests/test_packet.py index 26dce33..f6f085e 100644 --- a/tests/test_packet.py +++ b/tests/test_packet.py @@ -1,7 +1,8 @@ -from io import StringIO +import ast from uuid import UUID import pytest + from czml3 import CZML_VERSION, Packet, Preamble from czml3.enums import InterpolationAlgorithms, ReferenceFrames from czml3.properties import ( @@ -77,9 +78,7 @@ def test_packet_label(): expected_result = """{ "id": "0", "label": { - "show": true, "font": "20px sans-serif", - "style": "FILL", "fillColor": { "rgbaf": [ 0.2, @@ -103,12 +102,13 @@ def test_packet_label(): id="0", label=Label( font="20px sans-serif", - fillColor=Color.from_list([0.2, 0.3, 0.4]), - outlineColor=Color.from_list([0, 233, 255, 2]), + fillColor=Color(rgbaf=[0.2, 0.3, 0.4, 1.0]), + outlineColor=Color(rgba=[0, 233, 255, 2]), outlineWidth=2.0, ), ) + assert packet == Packet(**ast.literal_eval(expected_result)) assert str(packet) == expected_result @@ -133,44 +133,12 @@ def test_packet_with_delete_has_nothing_else(): def test_packet_dumps(): - expected_result = """{"id": "id_00"}""" + expected_result = """{"id":"id_00"}""" packet = Packet(id="id_00") assert packet.dumps() == expected_result -def test_packet_dump(): - expected_result = """{"id": "id_00"}""" - packet = Packet(id="id_00") - - with StringIO() as fp: - packet.dump(fp) - fp.seek(0) - result = fp.read() - - assert result == expected_result - - -@pytest.mark.xfail -def test_packet_constant_cartesian_position_perfect(): - # Trying to group the cartesian value by sample - # is much more difficult than expected. - # Pull requests welcome - expected_result = """{ - "id": "MyObject", - "position": { - "interpolationAlgorithm": "LINEAR", - "referenceFrame": "FIXED", - "cartesian": [ - 0.0, 0.0, 0.0 - ] - } -}""" - packet = Packet(id="MyObject", position=Position(cartesian=[0.0, 0.0, 0.0])) - - assert str(packet) == expected_result - - def test_packet_constant_cartesian_position(): expected_result = """{ "id": "MyObject", @@ -272,7 +240,6 @@ def test_packet_description(): string = "Description" packet_str = Packet(id="id_00", name="Name", description=string) packet_val = Packet(id="id_00", name="Name", description=StringValue(string=string)) - assert str(packet_str) == str(packet_val) == expected_result @@ -336,7 +303,7 @@ def test_packet_point(): } } }""" - packet = Packet(id="id_00", point=Point(color=Color.from_list([255, 0, 0, 255]))) + packet = Packet(id="id_00", point=Point(color=Color(rgba=[255, 0, 0, 255]))) assert str(packet) == expected_result @@ -376,7 +343,7 @@ def test_packet_polyline(): cartographicDegrees=[-75, 43, 500000, -125, 43, 500000] ), material=PolylineMaterial( - solidColor=SolidColorMaterial.from_list([255, 0, 0, 255]) + solidColor=SolidColorMaterial(color=Color(rgba=[255, 0, 0, 255])) ), ), ) @@ -429,8 +396,8 @@ def test_packet_polyline_outline(): ), material=PolylineOutlineMaterial( polylineOutline=PolylineOutline( - color=Color.from_list([255, 0, 0, 255]), - outlineColor=Color.from_list([255, 0, 0, 255]), + color=Color(rgba=[255, 0, 0, 255]), + outlineColor=Color(rgba=[255, 0, 0, 255]), outlineWidth=2, ) ), @@ -479,7 +446,7 @@ def test_packet_polyline_glow(): ), material=PolylineGlowMaterial( polylineGlow=PolylineGlow( - color=Color.from_list([255, 0, 0, 255]), + color=Color(rgba=[255, 0, 0, 255]), glowPower=0.2, taperPower=0.5, ) @@ -525,7 +492,7 @@ def test_packet_polyline_arrow(): cartographicDegrees=[-75, 43, 500000, -125, 43, 500000] ), material=PolylineArrowMaterial( - polylineArrow=PolylineArrow(color=Color.from_list([255, 0, 0, 255])) + polylineArrow=PolylineArrow(color=Color(rgba=[255, 0, 0, 255])) ), ), ) @@ -568,7 +535,7 @@ def test_packet_polyline_dashed(): cartographicDegrees=[-75, 43, 500000, -125, 43, 500000] ), material=PolylineDashMaterial( - polylineDash=PolylineDash(color=Color.from_list([255, 0, 0, 255])) + polylineDash=PolylineDash(color=Color(rgba=[255, 0, 0, 255])) ), ), ) @@ -584,19 +551,19 @@ def test_packet_polygon(): "cartographicDegrees": [ -115.0, 37.0, - 0, + 0.0, -115.0, 32.0, - 0, + 0.0, -107.0, 33.0, - 0, + 0.0, -102.0, 31.0, - 0, + 0.0, -102.0, 35.0, - 0 + 0.0 ] }, "granularity": 1.0, @@ -637,7 +604,9 @@ def test_packet_polygon(): ] ), granularity=1.0, - material=Material(solidColor=SolidColorMaterial.from_list([255, 0, 0])), + material=Material( + solidColor=SolidColorMaterial(color=Color(rgba=[255, 0, 0])) + ), ), ) diff --git a/tests/test_properties.py b/tests/test_properties.py index d465ea4..8f7ac15 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -1,7 +1,16 @@ import datetime as dt import pytest -from czml3.enums import ArcTypes, ClassificationTypes, ShadowModes +from pydantic import ValidationError + +from czml3.enums import ( + ArcTypes, + ClassificationTypes, + ColorBlendModes, + CornerTypes, + HeightReferences, + ShadowModes, +) from czml3.properties import ( ArcType, Box, @@ -9,11 +18,14 @@ CheckerboardMaterial, ClassificationType, Color, + ColorBlendMode, + CornerType, DistanceDisplayCondition, Ellipsoid, EllipsoidRadii, EyeOffset, GridMaterial, + HeightReference, ImageMaterial, Label, Material, @@ -35,6 +47,7 @@ Position, PositionList, PositionListOfLists, + RectangleCoordinates, ShadowMode, SolidColorMaterial, StripeMaterial, @@ -52,6 +65,7 @@ Sequence, TimeInterval, UnitQuaternionValue, + format_datetime_like, ) @@ -92,9 +106,9 @@ def test_point(): "pixelSize": 10, "scaleByDistance": { "nearFarScalar": [ - 150, + 150.0, 2.0, - 15000000, + 15000000.0, 0.5 ] }, @@ -178,11 +192,13 @@ def test_material_solid_color(): } } }""" - mat = Material(solidColor=SolidColorMaterial.from_list([200, 100, 30])) + mat = Material(solidColor=SolidColorMaterial(color=Color(rgba=[200, 100, 30]))) assert str(mat) == expected_result - pol_mat = PolylineMaterial(solidColor=SolidColorMaterial.from_list([200, 100, 30])) + pol_mat = PolylineMaterial( + solidColor=SolidColorMaterial(color=Color(rgba=[200, 100, 30])) + ) assert str(pol_mat) == expected_result @@ -298,12 +314,12 @@ def test_outline_material_colors(): def test_positionlist_epoch(): expected_result = """{ + "epoch": "2019-06-11T12:26:58.000000Z", "cartographicDegrees": [ 200, 100, 30 - ], - "epoch": "2019-06-11T12:26:58.000000Z" + ] }""" p = PositionList( epoch=dt.datetime(2019, 6, 11, 12, 26, 58, tzinfo=dt.timezone.utc), @@ -312,32 +328,79 @@ def test_positionlist_epoch(): assert str(p) == expected_result -def test_color_isvalid(): - assert Color.is_valid([255, 204, 0, 55]) - assert Color.is_valid([255, 204, 55]) - assert Color.is_valid(0xFF3223) - assert Color.is_valid(32) - assert Color.is_valid(0xFF322332) - assert Color.is_valid("#FF3223") - assert Color.is_valid("#FF322332") - assert Color.is_valid((255, 204, 55)) - assert Color.is_valid((255, 204, 55, 255)) - assert Color.is_valid((0.127568, 0.566949, 0.550556)) - assert Color.is_valid((0.127568, 0.566949, 0.550556, 1.0)) - - -def test_color_isvalid_false(): - assert Color.is_valid([256, 204, 0, 55]) is False - assert Color.is_valid([-204, 0, 55]) is False - assert Color.is_valid([249.1, 204.3, 55.4]) is False - assert Color.is_valid([255, 204]) is False - assert Color.is_valid([255, 232, 300]) is False - assert Color.is_valid(0xFF3223324) is False - assert Color.is_valid(-3) is False - assert Color.is_valid("totally valid color") is False - assert Color.is_valid("#FF322332432") is False - assert Color.is_valid((255, 204, 55, 255, 42)) is False - assert Color.is_valid((0.127568, 0.566949, 0.550556, 1.0, 3.0)) is False +def test_colors_rgba(): + Color(rgba=[255, 204, 0, 55]) + Color(rgba=[255, 204, 55]) + Color(rgba=[0.5, 0.6, 0.2]) + Color(rgba="0xFF0000") + Color(rgba="0xFFFFFFFF") + Color(rgba="0xFF3223") + Color(rgba="0xFF322332") + Color(rgba="#FF3223") + Color(rgba="#FF322332") + Color(rgba=[255, 204, 55]) + Color(rgba=[255, 204, 55, 255]) + Color(rgba=[0.127568, 0.566949, 0.550556]) + Color(rgba=[0.127568, 0.566949, 0.550556, 1.0]) + + +def test_colors_rgbaf(): + Color(rgbaf=[255, 204, 0, 55]) + Color(rgbaf=[255, 204, 55]) + Color(rgbaf="0xFF3223") + Color(rgbaf="0xFF322332") + Color(rgbaf="#FF3223") + Color(rgbaf="#FF322332") + Color(rgbaf=[255, 204, 55]) + Color(rgbaf=[255, 204, 55, 255]) + Color(rgbaf=[0.127568, 0.566949, 0.550556]) + Color(rgbaf=[0.127568, 0.566949, 0.550556, 1.0]) + + +def test_color_invalid_colors_rgba(): + with pytest.raises(TypeError): + Color(rgba=[256, 204, 0, 55]) + with pytest.raises(TypeError): + Color(rgba=[-204, 0, 55]) + with pytest.raises(TypeError): + Color(rgba=[255, 204]) + with pytest.raises(TypeError): + Color(rgba=[255, 232, 300]) + with pytest.raises(TypeError): + Color(rgba="0xFF3223324") + with pytest.raises(TypeError): + Color(rgba=-3) # type: ignore + with pytest.raises(ValidationError): + Color(rgba="totally valid color") + with pytest.raises(TypeError): + Color(rgba="#FF322332432") + with pytest.raises(TypeError): + Color(rgba=[255, 204, 55, 255, 42]) + with pytest.raises(TypeError): + Color(rgba=[0.127568, 0.566949, 0.550556, 1.0, 3.0]) + + +def test_color_invalid_colors_rgbaf(): + with pytest.raises(TypeError): + Color(rgbaf=[256, 204, 0, 55]) + with pytest.raises(TypeError): + Color(rgbaf=[-204, 0, 55]) + with pytest.raises(TypeError): + Color(rgbaf=[255, 204]) + with pytest.raises(TypeError): + Color(rgbaf=[255, 232, 300]) + with pytest.raises(TypeError): + Color(rgbaf="0xFF3223324") + with pytest.raises(TypeError): + Color(rgbaf=-3) # type: ignore + with pytest.raises(ValidationError): + Color(rgbaf="totally valid color") + with pytest.raises(TypeError): + Color(rgbaf="#FF322332432") + with pytest.raises(TypeError): + Color(rgbaf=[255, 204, 55, 255, 42]) + with pytest.raises(TypeError): + Color(rgbaf=[0.127568, 0.566949, 0.550556, 1.0, 3.0]) def test_material_image(): @@ -355,8 +418,7 @@ def test_material_image(): 30, 255 ] - }, - "transparent": false + } } }""" @@ -364,19 +426,41 @@ def test_material_image(): image=ImageMaterial( image=Uri(uri="https://site.com/image.png"), repeat=[2, 2], - color=Color.from_list([200, 100, 30]), + color=Color(rgba=[200, 100, 30]), ) ) assert str(mat) == expected_result - pol_mat = PolylineMaterial( + +def test_material_image_uri(): + expected_result = """{ + "image": { + "image": "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + "repeat": [ + 2, + 2 + ], + "color": { + "rgba": [ + 200, + 100, + 30, + 255 + ] + } + } +}""" + + mat = Material( image=ImageMaterial( - image=Uri(uri="https://site.com/image.png"), + image=Uri( + uri="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + ), repeat=[2, 2], - color=Color.from_list([200, 100, 30]), + color=Color(rgba=[200, 100, 30]), ) ) - assert str(pol_mat) == expected_result + assert str(mat) == expected_result def test_material_grid(): @@ -405,7 +489,37 @@ def test_material_grid(): }""" pol_mat = GridMaterial( - color=Color.from_list([20, 20, 30]), + color=Color(rgba=[20, 20, 30]), + cellAlpha=1.0, + lineCount=[16, 16], + lineThickness=[2.0, 2.0], + lineOffset=[0.3, 0.4], + ) + assert str(pol_mat) == expected_result + + +def test_nested_delete(): + expected_result = """{ + "color": { + "delete": true + }, + "cellAlpha": 1.0, + "lineCount": [ + 16, + 16 + ], + "lineThickness": [ + 2.0, + 2.0 + ], + "lineOffset": [ + 0.3, + 0.4 + ] +}""" + + pol_mat = GridMaterial( + color=Color(rgba=[20, 20, 30], delete=True), cellAlpha=1.0, lineCount=[16, 16], lineThickness=[2.0, 2.0], @@ -416,7 +530,6 @@ def test_material_grid(): def test_material_stripe(): expected_result = """{ - "orientation": "HORIZONTAL", "evenColor": { "rgba": [ 0, @@ -438,8 +551,8 @@ def test_material_stripe(): }""" pol_mat = StripeMaterial( - evenColor=Color.from_list([0, 0, 0]), - oddColor=Color.from_list([255, 255, 255]), + evenColor=Color(rgba=[0, 0, 0]), + oddColor=Color(rgba=[255, 255, 255]), offset=0.3, repeat=4, ) @@ -468,8 +581,8 @@ def test_material_checkerboard(): }""" pol_mat = CheckerboardMaterial( - evenColor=Color.from_list([0, 0, 0]), - oddColor=Color.from_list([255, 255, 255]), + evenColor=Color(rgba=[0, 0, 0]), + oddColor=Color(rgba=[255, 255, 255]), repeat=4, ) assert str(pol_mat) == expected_result @@ -482,7 +595,7 @@ def test_position_has_delete(): def test_position_no_values_raises_error(): - with pytest.raises(ValueError) as exc: + with pytest.raises(TypeError) as exc: Position() assert ( @@ -502,7 +615,9 @@ def test_position_with_delete_has_nothing_else(): def test_position_has_given_epoch(): - expected_epoch = dt.datetime(2019, 6, 11, 12, 26, 58, tzinfo=dt.timezone.utc) + expected_epoch = format_datetime_like( + dt.datetime(2019, 6, 11, 12, 26, 58, tzinfo=dt.timezone.utc) + ) pos = Position(epoch=expected_epoch, cartesian=[]) @@ -510,7 +625,9 @@ def test_position_has_given_epoch(): def test_positionlist_has_given_epoch(): - expected_epoch = dt.datetime(2019, 6, 11, 12, 26, 58, tzinfo=dt.timezone.utc) + expected_epoch = format_datetime_like( + dt.datetime(2019, 6, 11, 12, 26, 58, tzinfo=dt.timezone.utc) + ) pos = PositionList(epoch=expected_epoch, cartesian=[]) @@ -544,18 +661,24 @@ def test_position_cartographic_degrees(): def test_position_reference(): expected_result = """{ - "reference": "satellite" + "cartesian": [ + 0 + ], + "reference": "this#satellite" }""" - pos = Position(reference="satellite") + pos = Position(cartesian=[0], reference="this#satellite") assert str(pos) == expected_result def test_viewfrom_reference(): expected_result = """{ - "reference": "satellite" + "cartesian": [ + 1.0 + ], + "reference": "this#satellite" }""" - v = ViewFrom(reference="satellite") + v = ViewFrom(reference="this#satellite", cartesian=[1.0]) assert str(v) == expected_result @@ -574,16 +697,14 @@ def test_viewfrom_cartesian(): def test_viewfrom_has_delete(): - v = ViewFrom(delete=True, cartesian=[]) + v = ViewFrom(delete=True, cartesian=[14.0, 12.0]) assert v.delete def test_viewfrom_no_values_raises_error(): - with pytest.raises(ValueError) as exc: - ViewFrom() - - assert "One of cartesian or reference must be given" in exc.exconly() + with pytest.raises(ValidationError) as _: + ViewFrom() # type: ignore def test_single_interval_value(): @@ -617,7 +738,7 @@ def test_multiple_interval_value(): end1 = dt.datetime(2019, 1, 3, tzinfo=dt.timezone.utc) prop = Sequence( - [ + values=[ IntervalValue(start=start0, end=end0, value=True), IntervalValue(start=start1, end=end1, value=False), ] @@ -643,7 +764,7 @@ def test_multiple_interval_decimal_value(): end1 = dt.datetime(2019, 1, 3, 1, 2, 3, 456789, tzinfo=dt.timezone.utc) prop = Sequence( - [ + values=[ IntervalValue(start=start0, end=end0, value=True), IntervalValue(start=start1, end=end1, value=False), ] @@ -680,7 +801,7 @@ def test_model(): def test_bad_uri_raises_error(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: Uri(uri="a") assert "uri must be a URL or a data URI" in excinfo.exconly() @@ -762,32 +883,6 @@ def test_ellipsoid_parameters(): assert str(ell) == expected_result -def test_color_rgbaf_from_tuple(): - expected_result = """{ - "rgbaf": [ - 0.127568, - 0.566949, - 0.550556, - 1.0 - ] -}""" - tc = Color.from_tuple((0.127568, 0.566949, 0.550556, 1.0)) - assert str(tc) == expected_result - - -def test_color_rgba_from_tuple(): - expected_result = """{ - "rgba": [ - 100, - 200, - 255, - 255 - ] -}""" - tc = Color.from_tuple((100, 200, 255)) - assert str(tc) == expected_result - - def test_polygon_with_hole(): expected_result = """{ "positions": { @@ -922,9 +1017,6 @@ def test_polygon_interval_with_position(): def test_label_offset(): expected_result = """{ - "show": true, - "style": "FILL", - "outlineWidth": 1.0, "pixelOffset": { "cartesian2": [ 5, @@ -939,10 +1031,181 @@ def test_label_offset(): def test_tileset(): expected_result = """{ - "show": true, - "uri": "../SampleData/Cesium3DTiles/Batched/BatchedColors/tileset.json" + "uri": "../SampleData/Cesium3DTiles/Batched/BatchedColors/tileset.json", + "show": true }""" tileset = Tileset( show=True, uri="../SampleData/Cesium3DTiles/Batched/BatchedColors/tileset.json" ) assert str(tileset) == expected_result + + +def test_check_classes_with_references(): + assert ( + str(ViewFrom(cartesian=[0, 0], reference="this#that")) + == """{ + "cartesian": [ + 0, + 0 + ], + "reference": "this#that" +}""" + ) + assert ( + str(EllipsoidRadii(cartesian=[0, 0], reference="this#that")) + == """{ + "cartesian": [ + 0, + 0 + ], + "reference": "this#that" +}""" + ) + assert ( + str(ArcType(arcType=ArcTypes.GEODESIC, reference="this#that")) + == """{ + "arcType": "GEODESIC", + "reference": "this#that" +}""" + ) + assert ( + str(Position(cartesian=[0, 0], reference="this#that")) + == """{ + "cartesian": [ + 0, + 0 + ], + "reference": "this#that" +}""" + ) + assert ( + str(Orientation(unitQuaternion=[0, 0, 0, 0], reference="this#that")) + == """{ + "unitQuaternion": [ + 0, + 0, + 0, + 0 + ], + "reference": "this#that" +}""" + ) + assert ( + str(NearFarScalar(nearFarScalar=[0, 0], reference="this#that")) + == """{ + "nearFarScalar": [ + 0, + 0 + ], + "reference": "this#that" +}""" + ) + assert ( + str(CornerType(cornerType=CornerTypes.BEVELED, reference="this#that")) + == """{ + "cornerType": "BEVELED", + "reference": "this#that" +}""" + ) + assert ( + str( + ColorBlendMode( + colorBlendMode=ColorBlendModes.HIGHLIGHT, reference="this#that" + ) + ) + == """{ + "colorBlendMode": "HIGHLIGHT", + "reference": "this#that" +}""" + ) + assert ( + str( + HeightReference( + heightReference=HeightReferences.NONE, reference="this#that" + ) + ) + == """{ + "heightReference": "NONE", + "reference": "this#that" +}""" + ) + assert ( + str(EyeOffset(cartesian=[0, 0], reference="this#that")) + == """{ + "cartesian": [ + 0, + 0 + ], + "reference": "this#that" +}""" + ) + assert ( + str(RectangleCoordinates(wsen=[0, 0], reference="this#that")) + == """{ + "wsen": [ + 0, + 0 + ], + "reference": "this#that" +}""" + ) + assert ( + str( + BoxDimensions( + cartesian=Cartesian3Value(values=[0, 0, 1]), reference="this#that" + ) + ) + == """{ + "cartesian": [ + 0, + 0, + 1 + ], + "reference": "this#that" +}""" + ) + assert ( + str( + DistanceDisplayCondition( + distanceDisplayCondition=DistanceDisplayConditionValue( + values=[0, 1, 2] + ), + reference="this#that", + ) + ) + == """{ + "distanceDisplayCondition": [ + 0, + 1, + 2 + ], + "reference": "this#that" +}""" + ) + assert ( + str( + ClassificationType( + classificationType=ClassificationTypes.BOTH, reference="this#that" + ) + ) + == """{ + "classificationType": "BOTH", + "reference": "this#that" +}""" + ) + assert ( + str(ShadowMode(shadowMode=ShadowModes.CAST_ONLY, reference="this#that")) + == """{ + "shadowMode": "CAST_ONLY", + "reference": "this#that" +}""" + ) + + +def test_rectangle_coordinates_delete(): + assert ( + str(RectangleCoordinates(wsen=[0, 0], reference="this#that", delete=True)) + == """{ + "delete": true +}""" + ) diff --git a/tests/test_rectangle_image.py b/tests/test_rectangle_image.py index ea35ab7..a56aaa1 100644 --- a/tests/test_rectangle_image.py +++ b/tests/test_rectangle_image.py @@ -3,6 +3,7 @@ import tempfile import pytest + from czml3 import Document, Packet, Preamble from czml3.properties import ImageMaterial, Material, Rectangle, RectangleCoordinates @@ -18,13 +19,10 @@ def image(): def test_rectangle_coordinates_invalid_if_nothing_given(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: RectangleCoordinates() - assert ( - "One of cartesian, cartographicDegrees or cartographicRadians must be given" - in excinfo.exconly() - ) + assert "One of wsen or wsenDegrees must be given" in excinfo.exconly() def test_packet_rectangles(image): @@ -70,12 +68,10 @@ def test_packet_rectangles(image): def test_make_czml_png_rectangle_file(image): - wsen = [20, 40, 21, 41] - rectangle_packet = Packet( id="id_00", rectangle=Rectangle( - coordinates=RectangleCoordinates(wsenDegrees=wsen), + coordinates=RectangleCoordinates(wsenDegrees=[20, 40, 21, 41]), fill=True, material=Material( image=ImageMaterial( @@ -88,7 +84,7 @@ def test_make_czml_png_rectangle_file(image): ) with tempfile.NamedTemporaryFile(mode="w", suffix=".czml") as out_file: - out_file.write(str(Document([Preamble(), rectangle_packet]))) + out_file.write(str(Document(packets=[Preamble(), rectangle_packet]))) exists = os.path.isfile(out_file.name) # TODO: Should we be testing something else? diff --git a/tests/test_types.py b/tests/test_types.py index 51de622..44b2537 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -2,11 +2,15 @@ import astropy.time import pytest -from czml3.base import BaseCZMLObject +from pydantic import ValidationError + from czml3.types import ( + Cartesian2Value, Cartesian3Value, CartographicDegreesListValue, + CartographicDegreesValue, CartographicRadiansListValue, + CartographicRadiansValue, DistanceDisplayConditionValue, EpochValue, FontValue, @@ -18,18 +22,25 @@ RgbaValue, TimeInterval, UnitQuaternionValue, + check_reference, format_datetime_like, ) -from dateutil.tz import tzoffset def test_invalid_near_far_scalar_value(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: NearFarScalarValue(values=[0, 3.2, 1, 4, 2, 1]) assert "Input values must have either 4 or N * 5 values, " in excinfo.exconly() +def test_distance_display_condition_is_invalid(): + with pytest.raises(TypeError): + DistanceDisplayConditionValue( + values=[0, 150, 15000000, 300, 10000, 15000000, 600] + ) + + def test_distance_display_condition(): expected_result = """[ 0, @@ -59,7 +70,7 @@ def test_cartographic_radian_list(): def test_invalid_cartograpic_radian_list(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: CartographicRadiansListValue(values=[1]) assert ( "Invalid values. Input values should be arrays of size 3 * N" @@ -78,7 +89,7 @@ def test_cartograpic_degree_list(): def test_invalid_cartograpic_degree_list(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: CartographicDegreesListValue(values=[15, 25, 50, 30]) assert ( "Invalid values. Input values should be arrays of size 3 * N" @@ -87,11 +98,21 @@ def test_invalid_cartograpic_degree_list(): @pytest.mark.parametrize("values", [[2, 2], [5, 5, 5, 5, 5]]) -def test_bad_cartesian_raises_error(values): - with pytest.raises(ValueError) as excinfo: +def test_bad_cartesian3_raises_error(values): + with pytest.raises(TypeError) as excinfo: Cartesian3Value(values=values) assert "Input values must have either 3 or N * 4 values" in excinfo.exconly() + assert str(Cartesian3Value()) == "[]" + + +@pytest.mark.parametrize("values", [[2, 2, 2, 2, 2], [5, 5, 5, 5, 5]]) +def test_bad_cartesian2_raises_error(values): + with pytest.raises(TypeError) as excinfo: + Cartesian2Value(values=values) + + assert "Input values must have either 2 or N * 3 values" in excinfo.exconly() + assert str(Cartesian2Value()) == "{}" def test_reference_value(): @@ -102,7 +123,7 @@ def test_reference_value(): def test_invalid_reference_value(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: ReferenceValue(string="id") assert ( @@ -126,66 +147,54 @@ def test_font_property_value(): def test_bad_rgba_size_values_raises_error(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: RgbaValue(values=[0, 0, 255]) assert "Input values must have either 4 or N * 5 values, " in excinfo.exconly() def test_bad_rgba_4_values_raises_error(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: RgbaValue(values=[256, 0, 0, 255]) assert "Color values must be integers in the range 0-255." in excinfo.exconly() def test_bad_rgba_5_color_values_raises_error(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: RgbaValue(values=[0, 0.1, 0.3, 0.3, 255]) assert "Color values must be integers in the range 0-255." in excinfo.exconly() def test_bad_rgbaf_size_values_raises_error(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: RgbafValue(values=[0, 0, 0.1]) assert "Input values must have either 4 or N * 5 values, " in excinfo.exconly() def test_bad_rgbaf_4_values_raises_error(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: RgbafValue(values=[0.3, 0, 0, 1.4]) assert "Color values must be floats in the range 0-1." in excinfo.exconly() def test_bad_rgbaf_5_color_values_raises_error(): - with pytest.raises(ValueError) as excinfo: + with pytest.raises(TypeError) as excinfo: RgbafValue(values=[0, 0.1, 0.3, 0.3, 255]) assert "Color values must be floats in the range 0-1." in excinfo.exconly() def test_default_time_interval(): - expected_result = '"0000-00-00T00:00:00Z/9999-12-31T24:00:00Z"' + expected_result = '"0001-01-01T00:00:00Z/9999-12-31T23:59:59Z"' time_interval = TimeInterval() assert str(time_interval) == expected_result -def test_custom_time_interval(): - tz = tzoffset("UTC+02", dt.timedelta(hours=2)) - start = dt.datetime(2019, 1, 1, 12, 0, tzinfo=dt.timezone.utc) - end = dt.datetime(2019, 9, 2, 23, 59, 59, tzinfo=tz) - - expected_result = '"2019-01-01T12:00:00.000000Z/2019-09-02T21:59:59.000000Z"' - - time_interval = TimeInterval(start=start, end=end) - - assert str(time_interval) == expected_result - - def test_bad_time_raises_error(): with pytest.raises(ValueError): format_datetime_like("2019/01/01") @@ -204,19 +213,6 @@ def test_interval_value(): }""" ) - # value is something that has a "to_json" method - class CustomValue(BaseCZMLObject): - def to_json(self): - return {"foo": "bar"} - - assert ( - str(IntervalValue(start=start, end=end, value=CustomValue())) - == """{ - "interval": "2019-01-01T12:00:00.000000Z/2019-09-02T21:59:59.000000Z", - "foo": "bar" -}""" - ) - assert ( str( IntervalValue( @@ -258,16 +254,11 @@ def test_epoch_value(): }""" ) - with pytest.raises(expected_exception=ValueError): + with pytest.raises(ValueError): str(EpochValue(value="test")) - with pytest.raises( - expected_exception=ValueError, - match="Epoch must be a string or a datetime object.", - ): - EpochValue(value=1) - +@pytest.mark.xfail(reason="NumberValue class requires further explanaition") def test_numbers_value(): expected_result = """{ "number": [ @@ -288,20 +279,13 @@ def test_numbers_value(): assert str(numbers) == expected_result - with pytest.raises( - expected_exception=ValueError, match="Values must be integers or floats." - ): - NumberValue(values="test") + with pytest.raises(ValidationError): + NumberValue(values="test") # type: ignore - with pytest.raises( - expected_exception=ValueError, match="Values must be integers or floats." - ): - NumberValue(values=[1, "test"]) + with pytest.raises(ValidationError): + NumberValue(values=[1, "test"]) # type: ignore - with pytest.raises( - expected_exception=ValueError, - match="Values must be a list of number pairs signifying the time and representative value.", - ): + with pytest.raises(ValidationError): NumberValue(values=[1, 2, 3, 4, 5]) @@ -326,6 +310,11 @@ def test_astropy_time_format(): assert result == expected_result +def test_quaternion_value_is_invalid(): + with pytest.raises(TypeError): + UnitQuaternionValue(values=[0, 0, 0, 1, 0]) + + def test_quaternion_value(): expected_result = """[ 0, @@ -337,3 +326,109 @@ def test_quaternion_value(): result = UnitQuaternionValue(values=[0, 0, 0, 1]) assert str(result) == expected_result + + +def test_cartographic_radians_value(): + result = CartographicRadiansValue(values=[0, 0, 0, 1]) + assert ( + str(result) + == """[ + 0, + 0, + 0, + 1 +]""" + ) + result = CartographicRadiansValue(values=[0, 0, 1]) + assert ( + str(result) + == """[ + 0, + 0, + 1 +]""" + ) + result = CartographicRadiansValue() + assert str(result) == """[]""" + with pytest.raises(TypeError): + CartographicRadiansValue(values=[0, 0, 1, 1, 1, 1]) + + +def test_cartographic_degrees_value(): + result = CartographicDegreesValue(values=[0, 0, 0, 1]) + assert ( + str(result) + == """[ + 0, + 0, + 0, + 1 +]""" + ) + result = CartographicDegreesValue(values=[0, 0, 1]) + assert ( + str(result) + == """[ + 0, + 0, + 1 +]""" + ) + result = CartographicDegreesValue() + assert str(result) == """[]""" + with pytest.raises(TypeError): + CartographicDegreesValue(values=[0, 0, 1, 1, 1, 1]) + + +def test_rgba_value(): + assert ( + str(RgbaValue(values=[30, 30, 30, 30])) + == """[ + 30, + 30, + 30, + 30 +]""" + ) + assert ( + str(RgbaValue(values=[30, 30, 30, 30, 1])) + == """[ + 30, + 30, + 30, + 30, + 1 +]""" + ) + + +def test_rgbaf_value(): + assert ( + str(RgbafValue(values=[0.5, 0.5, 0.5, 0.5])) + == """[ + 0.5, + 0.5, + 0.5, + 0.5 +]""" + ) + assert ( + str(RgbafValue(values=[0.5, 0.5, 0.5, 0.5, 1])) + == """[ + 0.5, + 0.5, + 0.5, + 0.5, + 1.0 +]""" + ) + + +def test_check_reference(): + with pytest.raises(TypeError): + check_reference("thisthat") + assert check_reference("this#that") is None + + +def test_format_datetime_like(): + assert format_datetime_like(None) is None diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index f7bdd26..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,106 +0,0 @@ -import pytest -from czml3.properties import Color -from czml3.types import RgbafValue, RgbaValue -from czml3.utils import get_color, get_color_list - - -def test_get_color_list_of_colors_rgba(): - expected_color = Color( - rgba=RgbaValue( - values=[ - "0000-00-00T00:00:00.000000Z", - 255, - 204, - 0, - 255, - "9999-12-31T24:00:00.000000Z", - 255, - 204, - 0, - 255, - ] - ) - ) - assert ( - get_color_list( - ["0000-00-00T00:00:00.000000Z", "9999-12-31T24:00:00.000000Z"], - [[1.0, 0.8, 0.0, 1.0], 0xFFCC00FF], - ) - == expected_color - ) - assert ( - get_color_list( - ["0000-00-00T00:00:00.000000Z", "9999-12-31T24:00:00.000000Z"], - ["#ffcc00ff", 0xFFCC00], - ) - == expected_color - ) - - -def test_get_color_list_of_colors_rgbaf(): - expected_color = Color( - rgbaf=RgbafValue( - values=[ - "0000-00-00T00:00:00.000000Z", - 1.0, - 0.8, - 0.0, - 1.0, - "9999-12-31T24:00:00.000000Z", - 1.0, - 0.8, - 0.0, - 1.0, - ] - ) - ) - assert ( - get_color_list( - ["0000-00-00T00:00:00.000000Z", "9999-12-31T24:00:00.000000Z"], - [[1.0, 0.8, 0.0, 1.0], 0xFFCC00], - rgbaf=True, - ) - == expected_color - ) - assert ( - get_color_list( - ["0000-00-00T00:00:00.000000Z", "9999-12-31T24:00:00.000000Z"], - [[255, 204, 0], 0xFFCC00FF], - rgbaf=True, - ) - == expected_color - ) - - -def test_get_color_list_of_colors_invalid(): - with pytest.raises(ValueError): - get_color_list( - ["0000-00-00T00:00:00.000000Z", "9999-12-31T24:00:00.000000Z"], - [[300, 204, 0], -0xFFCC00FF], - rgbaf=True, - ) - - -def test_get_color_rgba(): - expected_color = Color(rgba=RgbaValue(values=[255, 204, 0, 255])) - - assert get_color("#ffcc00") == expected_color - assert get_color(0xFFCC00) == expected_color - assert get_color("#ffcc00ff") == expected_color - assert get_color(0xFFCC00FF) == expected_color - assert get_color([255, 204, 0]) == expected_color - assert get_color([255, 204, 0, 255]) == expected_color - - -def test_get_color_rgbaf(): - expected_color = Color(rgbaf=RgbafValue(values=[1.0, 0.8, 0.0, 1.0])) - - # TODO: Simplify after https://github.com/poliastro/czml3/issues/36 - assert get_color([1.0, 0.8, 0.0]) == expected_color - assert get_color([1.0, 0.8, 0.0, 1.0]) == expected_color - - -@pytest.mark.parametrize("input", ["a", [0, 0, 0, 0, -300], [0.3, 0.3, 0.1, 1.0, 1.0]]) -def test_get_color_invalid_input_raises_error(input): - with pytest.raises(ValueError): - get_color(input) diff --git a/tests/test_widget.py b/tests/test_widget.py index b3009e9..2a97007 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -1,4 +1,5 @@ import pytest + from czml3.widget import CZMLWidget @@ -19,3 +20,8 @@ def test_to_html_contains_script(): widget = CZMLWidget() assert widget.build_script() in widget.to_html() + + +def test_repr(): + widget = CZMLWidget() + assert widget.to_html() == widget._repr_html_()