From b6a8f6d6c5905dec6962f11342e77c507504215f Mon Sep 17 00:00:00 2001 From: daquinteroflex Date: Fri, 18 Oct 2024 10:13:57 +0200 Subject: [PATCH] :construction: Class extraction compilation initial approach --- .../constructors/field_info_to_property.py | 11 + autoflex/constructors/parameter_table.py | 4 +- autoflex/extractors.py | 127 +++- autoflex/{descriptors => }/field.py | 22 +- autoflex/types/__init__.py | 7 +- autoflex/types/descriptors.py | 2 + autoflex/types/field.py | 8 - autoflex/types/field_info.py | 20 + autoflex/types/{parameters.py => property.py} | 21 +- autoflex/types/structures.py | 6 + autoflex/version_manager.py | 10 + demo/__init__.py | 2 +- demo/basic_class.py | 8 +- docs/api/descriptors.rst | 5 + docs/api/fields.rst | 5 + docs/examples/usage_performance.ipynb | 599 ++++++++++++++++-- docs/structure.rst | 7 + tests/extractors/test_field_extractors.py | 256 +++++++- 18 files changed, 1015 insertions(+), 105 deletions(-) create mode 100644 autoflex/constructors/field_info_to_property.py rename autoflex/{descriptors => }/field.py (53%) delete mode 100644 autoflex/types/field.py create mode 100644 autoflex/types/field_info.py rename autoflex/types/{parameters.py => property.py} (62%) create mode 100644 autoflex/types/structures.py create mode 100644 autoflex/version_manager.py create mode 100644 docs/api/fields.rst diff --git a/autoflex/constructors/field_info_to_property.py b/autoflex/constructors/field_info_to_property.py new file mode 100644 index 0000000..58aaa68 --- /dev/null +++ b/autoflex/constructors/field_info_to_property.py @@ -0,0 +1,11 @@ +""" +This file contains all the functionality to convert from a compiled `AutoflexFieldInfo` or standard pydantic `FieldInfo` +into a AutoflexPropertyType. This way all the fields and annotations can be compiled into a standardized data type +""" +from autoflex.types import FieldTypes + +def compile_field_into_property( + field: FieldTypes +): + """ + """ diff --git a/autoflex/constructors/parameter_table.py b/autoflex/constructors/parameter_table.py index af37bc7..9e4b8ad 100644 --- a/autoflex/constructors/parameter_table.py +++ b/autoflex/constructors/parameter_table.py @@ -1,9 +1,9 @@ """ This file contains the structure of a ParameterTable. """ -from autoflex.types import AutoflexBaseClass +from autoflex.types import PropertyTypes -def extract_class_to_parameter_table(physcial_parameter: PhysicalParameter) -> ParameterTable: +def extract_class_to_parameter_table(physcial_parameter: PropertyTypes) -> ParameterTable: """ This method converts from a given class or schema declaration into a container of data required for a ParameterTable in its intended implementation. diff --git a/autoflex/extractors.py b/autoflex/extractors.py index a9da596..927757e 100644 --- a/autoflex/extractors.py +++ b/autoflex/extractors.py @@ -1,16 +1,19 @@ -from pydantic import BaseModel - -def determine_pydantic_version_from_base_model(model: BaseModel): - """Determine if a BaseModel is from Pydantic v1 or v2.""" - if hasattr(model, 'model_fields'): - return 2 - elif hasattr(model, '__fields__'): - return 1 - else: - raise ValueError("Unknown Pydantic version or incompatible BaseModel class.") +""" +Note that the compilation flow is as follows: + +.. code:: + FieldTypes -> PropertyTypes + PhysicalFieldInfo -> PhysicalProperty + pd.FieldInfo -> Property -def get_field_infos(model: BaseModel): +Then, this automated flow can be run for a given BaseModel and the properties can be extracted accordingly. +""" +import pydantic as pd +from autoflex.types import PropertyTypes, FieldTypes, PhysicalProperty, PhysicalFieldInfo, Property +from autoflex.version_manager import determine_pydantic_version_from_base_model + +def get_field_infos(model: pd.BaseModel): """Get all FieldInfo instances from a Pydantic model, compatible with v1 and v2.""" version = determine_pydantic_version_from_base_model(model) @@ -27,3 +30,105 @@ def get_field_infos(model: BaseModel): field_infos.append(field) return field_infos + + +def physical_field_info_to_physical_property(field: PhysicalFieldInfo, field_name: str) -> PhysicalProperty: + """ + Convert a PhysicalFieldInfo instance to a PhysicalProperty. + + Args: + field: The PhysicalFieldInfo instance. + field_name: The name of the field. + + Returns: + PhysicalProperty: The corresponding PhysicalProperty instance. + """ + # Extract unit and math information + unit = field.unit + math = field.math + + # Extract other field attributes + description = field.description or "" + default = field.default if field.default is not None else "" + + # Assuming 'types' can be inferred from the field's type + types = str(field.outer_type_) if hasattr(field, 'outer_type_') else "" + + return PhysicalProperty( + name=field_name, + types=types, + description=description, + default=str(default), + unit=unit, + math=math + ) + + +def field_info_to_property(field: pd.fields.FieldInfo, field_name: str) -> Property: + """ + Convert a standard FieldInfo instance to a Property. + + Args: + field: The FieldInfo instance. + field_name: The name of the field. + + Returns: + Property: The corresponding Property instance. + """ + description = field.description or "" + default = field.default if field.default is not None else "" + types = str(field.outer_type_) if hasattr(field, 'outer_type_') else "" + + return Property( + name=field_name, + types=types, + description=description, + default=str(default) + ) + + +def auto_field_to_property_type(field: FieldTypes, field_name: str) -> PropertyTypes: + """ + Convert a FieldTypes instance to a PropertyTypes instance. + + Args: + field: The FieldTypes instance (PhysicalFieldInfo or FieldInfo). + field_name: The name of the field. + + Returns: + PropertyTypes: The corresponding PropertyTypes instance. + """ + if isinstance(field, PhysicalFieldInfo): + return physical_field_info_to_physical_property(field, field_name) + else: + return field_info_to_property(field, field_name) + + +def extract_property_list_from_model(model: pd.BaseModel) -> list[PropertyTypes]: + """ + Extract a list of PropertyTypes from a Pydantic model. + + Args: + model: The Pydantic BaseModel instance. + + Returns: + List[PropertyTypes]: A list of PropertyTypes instances extracted from the model. + """ + field_infos = get_field_infos(model) + properties = [] + + # Get field names based on Pydantic version + version = determine_pydantic_version_from_base_model(model) + if version == 2: + field_names = list(model.model_fields.keys()) + elif version == 1: + field_names = list(model.__fields__.keys()) + else: + field_names = [] + + for field, field_name in zip(field_infos, field_names): + property_item = auto_field_to_property_type(field, field_name) + properties.append(property_item) + + return properties + diff --git a/autoflex/descriptors/field.py b/autoflex/field.py similarity index 53% rename from autoflex/descriptors/field.py rename to autoflex/field.py index a8d28eb..0b76d5a 100644 --- a/autoflex/descriptors/field.py +++ b/autoflex/field.py @@ -1,33 +1,35 @@ -from dataclasses import field from typing import Any, Optional from pydantic import Field -from autoflex.types import AutoflexFieldInfo, AutoflexParameterTypes +from autoflex.types import PhysicalFieldInfo, UnitTypes, SymbolicTypes -def AutoflexField( +def PhysicalField( default: Any = ..., *, - autoflex_parameters: Optional[AutoflexParameterTypes] = None, + unit: UnitTypes, + math: SymbolicTypes, **kwargs -) -> AutoflexFieldInfo: +) -> PhysicalFieldInfo: """ A wrapper around pydantic's Field function that returns an instance of AutoflexFieldInfo instead of FieldInfo. Args: default: The default value of the field. - autoflex_parameters: An additional argument specific to AutoflexFieldInfo. + unit: The UnitType to represent. + math: The SymbolicType to represent. **kwargs: Any other keyword arguments passed to pydantic's Field. Returns: AutoflexFieldInfo: Custom field info object. """ - field_info = Field(default=default, **kwargs) # Call pydantic's Field internally + # Need to compile this into a FieldInfo in order to extract the correct kwargs. + field_info = Field(default=default, **kwargs) # Return an instance of AutoflexFieldInfo instead of the default FieldInfo - return AutoflexFieldInfo( + return PhysicalFieldInfo( default=default, - autoflex=autoflex_parameters, - **field_info.dict(exclude_none=True) + unit=unit, + math=math, ) diff --git a/autoflex/types/__init__.py b/autoflex/types/__init__.py index 4dd7145..d451e5d 100644 --- a/autoflex/types/__init__.py +++ b/autoflex/types/__init__.py @@ -1,3 +1,4 @@ -from autoflex.types.parameters import PhysicalParameter, AutoflexParameterTypes -from autoflex.types.descriptors import Unit, Symbolic, SymbolicTypes -from autoflex.types.field import AutoflexFieldInfo +from autoflex.types.property import PhysicalProperty, Property, PropertyTypes +from autoflex.types.descriptors import Symbolic, SymbolicTypes, Unit, UnitTypes +from autoflex.types.field_info import PhysicalFieldInfo, FieldTypes +from autoflex.types.field_info import PhysicalFieldInfo diff --git a/autoflex/types/descriptors.py b/autoflex/types/descriptors.py index 5b57752..6aaa9a7 100644 --- a/autoflex/types/descriptors.py +++ b/autoflex/types/descriptors.py @@ -32,3 +32,5 @@ class Unit(AutoflexBaseModel): name: str = Field(..., description="Name of the unit") symbol: SymbolicTypes = Field(..., description="Symbol for the unit") description: str = Field(None, description="Optional description of the unit") + +UnitTypes = Union[str, Unit] diff --git a/autoflex/types/field.py b/autoflex/types/field.py deleted file mode 100644 index 9f59452..0000000 --- a/autoflex/types/field.py +++ /dev/null @@ -1,8 +0,0 @@ -import pydantic as pd -from autoflex.types.parameters import AutoflexParameterTypes - -class AutoflexFieldInfo(pd.fields.FieldInfo): - """ - Each field should correspond to an individual parameter field that can represent it completely within the documentation. - """ - autoflex: AutoflexParameterTypes diff --git a/autoflex/types/field_info.py b/autoflex/types/field_info.py new file mode 100644 index 0000000..3b10c54 --- /dev/null +++ b/autoflex/types/field_info.py @@ -0,0 +1,20 @@ +import pydantic as pd +from autoflex.types.descriptors import UnitTypes, SymbolicTypes + +class PhysicalFieldInfo(pd.fields.FieldInfo): + """ + Each field should correspond to an individual physical property field that can represent it completely within the documentation. + + Note that this compiles into a PhysicalProperty accordingly. + """ + + unit: UnitTypes = "" + """ + """ + + math: SymbolicTypes = "" + """ + """ + + +FieldTypes = PhysicalFieldInfo | pd.fields.FieldInfo diff --git a/autoflex/types/parameters.py b/autoflex/types/property.py similarity index 62% rename from autoflex/types/parameters.py rename to autoflex/types/property.py index 0f906af..da638a2 100644 --- a/autoflex/types/parameters.py +++ b/autoflex/types/property.py @@ -1,8 +1,18 @@ from pydantic import Field from autoflex.types.core import AutoflexBaseModel -from autoflex.types.descriptors import Unit, SymbolicTypes +from autoflex.types.descriptors import UnitTypes, SymbolicTypes -class PhysicalParameter(AutoflexBaseModel): +class Property(AutoflexBaseModel): + """ + Contains all the information encoded in a standard property. + """ + name: str = "" + types: str = "" + description: str = "" + default: str = "" + + +class PhysicalProperty(Property): """ This structure instance is a representation of the relevant information that might want to represent in a parameter row or in another container. @@ -10,11 +20,8 @@ class PhysicalParameter(AutoflexBaseModel): We need the parameter name, the type definition in a format we might want to represent (or even interact with) a description which may be """ - name: str = "" - types: str = "" - description: str = "" math: SymbolicTypes = Field(..., description="The mathematical representation defining the physical parameter in raw string latex") - unit: Unit = Field(..., description="The unit of the physical parameter") + unit: UnitTypes = Field(..., description="The unit of the physical parameter") -AutoflexParameterTypes = PhysicalParameter +PropertyTypes = PhysicalProperty | Property diff --git a/autoflex/types/structures.py b/autoflex/types/structures.py new file mode 100644 index 0000000..8a10b37 --- /dev/null +++ b/autoflex/types/structures.py @@ -0,0 +1,6 @@ +from autoflex.types import PropertyTypes + +PropertyCollectionTable = list[PropertyTypes] +""" +A property table can be compiled from a list of compiled AutoflexProperties in a standard data type format. +""" diff --git a/autoflex/version_manager.py b/autoflex/version_manager.py new file mode 100644 index 0000000..d6366e2 --- /dev/null +++ b/autoflex/version_manager.py @@ -0,0 +1,10 @@ +import pydantic as pd + +def determine_pydantic_version_from_base_model(model: pd.BaseModel): + """Determine if a BaseModel is from Pydantic v1 or v2.""" + if hasattr(model, 'model_fields'): + return 2 + elif hasattr(model, '__fields__'): + return 1 + else: + raise ValueError("Unknown Pydantic version or incompatible BaseModel class.") diff --git a/demo/__init__.py b/demo/__init__.py index b1c5b11..4bbe2b9 100644 --- a/demo/__init__.py +++ b/demo/__init__.py @@ -1 +1 @@ -from .basic_class import BasicClass +from .basic_class import BasicClass, BasicMixedAnnotatedClass diff --git a/demo/basic_class.py b/demo/basic_class.py index 4560151..2353aa7 100644 --- a/demo/basic_class.py +++ b/demo/basic_class.py @@ -1,7 +1,7 @@ -import pydantic +import pydantic as pd -class BasicClass(pydantic.BaseModel): +class BasicClass(pd.BaseModel): my_parameter_1: int = 1 my_parameter_2: str = 2 my_parameter_3: float = 3.0 @@ -25,3 +25,7 @@ def my_static_method(): This is my static method. """ pass + + +class BasicMixedAnnotatedClass(BasicClass): + my_parameter_4: str = pd.Field("" , description="This is my field parameter.", json_schema_extra={}) diff --git a/docs/api/descriptors.rst b/docs/api/descriptors.rst index c4c1e1c..316fc21 100644 --- a/docs/api/descriptors.rst +++ b/docs/api/descriptors.rst @@ -4,3 +4,8 @@ Descriptors Part of the goal of using dedicated ``AutoflexField`` definitions is that we can compile this data into a nice way to visualise it both in the terminal and on the web. It can be used as a more helpful visualisation tool than the standard type descriptions. + +We want to provide the implementation of the Field functionality so that an `AutoflexField` compiles into a standard +Pydantic Field whilst using the ``json_schema_extra`` to encode the information accordingly. + + diff --git a/docs/api/fields.rst b/docs/api/fields.rst new file mode 100644 index 0000000..93a7318 --- /dev/null +++ b/docs/api/fields.rst @@ -0,0 +1,5 @@ + + +.. code:: + + diff --git a/docs/examples/usage_performance.ipynb b/docs/examples/usage_performance.ipynb index 98dade1..81c7402 100644 --- a/docs/examples/usage_performance.ipynb +++ b/docs/examples/usage_performance.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "id": "ecd08dd3-75f6-4293-8ee4-3ece6f0e0507", "metadata": {}, "outputs": [], @@ -23,7 +23,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 12, "id": "aec54c32-758f-48d2-8ab0-53515c21384f", "metadata": {}, "outputs": [ @@ -33,13 +33,17 @@ "MyBaseClass(a='', b='')" ] }, - "execution_count": 2, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } ], "source": [ "class MyBaseClass(pd.BaseModel):\n", + " \"\"\"\n", + " Test documentation\n", + " \"\"\"\n", + " \n", " a: str = \"\"\n", " \"\"\"\n", " Example Description\n", @@ -51,9 +55,17 @@ "our_class" ] }, + { + "cell_type": "markdown", + "id": "a71b16ce-7fc9-408a-83b6-c52ce6983f97", + "metadata": {}, + "source": [ + "So the goal is that we want a function that can compile into a structure." + ] + }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 13, "id": "3291f36f-042a-4e7e-88e0-59a99e3fe574", "metadata": {}, "outputs": [ @@ -74,6 +86,27 @@ "print(f\"b_class_attribute memory: {b_class_attribute_memory_cost}\")" ] }, + { + "cell_type": "code", + "execution_count": 20, + "id": "0218d67d-1f97-4b35-bf1d-837be832c76c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'a': str, 'b': str}" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "our_class.__annotations__" + ] + }, { "cell_type": "markdown", "id": "f44d3dd1-e4f0-4e65-9ab4-3bb36d859cd9", @@ -84,7 +117,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "id": "aef7a6ed-057b-4eef-917c-32df69e667b2", "metadata": {}, "outputs": [], @@ -103,7 +136,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "id": "ad1cb4cf-6718-42e0-9855-12793132a1a2", "metadata": {}, "outputs": [ @@ -134,6 +167,27 @@ "So if anything we can look inside annotations of each class and compile the documentation from there." ] }, + { + "cell_type": "code", + "execution_count": 7, + "id": "6d45eb57-741a-478f-a899-7ca52f6b970c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "FieldInfo(annotation=NoneType, required=False, default='')" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "example_basic_field" + ] + }, { "cell_type": "code", "execution_count": 6, @@ -177,6 +231,48 @@ "example_basic_field.__annotations__" ] }, + { + "cell_type": "code", + "execution_count": 18, + "id": "763ce5f5-2859-4405-b5b8-903d76583e7e", + "metadata": {}, + "outputs": [], + "source": [ + "import tidy3d" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "c33b3568-0ba5-418d-ac2d-b88b4ca7da9f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'boundary_spec': 'BoundarySpec',\n", + " 'courant': 'float',\n", + " 'lumped_elements': 'Tuple[LumpedElementType, ...]',\n", + " 'grid_spec': 'GridSpec',\n", + " 'medium': 'MediumType3D',\n", + " 'normalize_index': 'Union[pydantic.NonNegativeInt, None]',\n", + " 'monitors': 'Tuple[annotate_type(MonitorType), ...]',\n", + " 'sources': 'Tuple[annotate_type(SourceType), ...]',\n", + " 'shutoff': 'pydantic.NonNegativeFloat',\n", + " 'structures': 'Tuple[Structure, ...]',\n", + " 'symmetry': 'Tuple[Symmetry, Symmetry, Symmetry]',\n", + " 'run_time': 'Union[pydantic.PositiveFloat, RunTimeSpec]'}" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tidy3d.Simulation.__annotations__" + ] + }, { "cell_type": "markdown", "id": "4932b79c-6fc9-4d97-822f-1e2e36485c6b", @@ -222,7 +318,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 8, "id": "37bfd396-ae18-4041-905c-54ffcd1d08b7", "metadata": {}, "outputs": [ @@ -238,7 +334,7 @@ " 'type': 'object'}" ] }, - "execution_count": 9, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -317,23 +413,116 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 31, "id": "a7c5de53-46e1-45aa-b4ec-43d03bb9b582", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "FieldInfo(annotation=NoneType, required=True, json_schema_extra={'unit': 'ms', 'math': 's + 1'})" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "# help(pd.fields.Field)\n", - "pd.fields.Field(extra={\n", - " \"hello\": 1\n", - "})" + "pd.fields.Field(\n", + " json_schema_extra={\n", + " \"unit\": \"ms\",\n", + " \"math\": \"s + 1\"\n", + "}\n", + ")" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 34, "id": "a7089d29-26cb-4c80-b32b-b25609d59481", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "['__annotations__',\n", + " '__class__',\n", + " '__delattr__',\n", + " '__dir__',\n", + " '__doc__',\n", + " '__eq__',\n", + " '__format__',\n", + " '__ge__',\n", + " '__getattribute__',\n", + " '__getstate__',\n", + " '__gt__',\n", + " '__hash__',\n", + " '__init__',\n", + " '__init_subclass__',\n", + " '__le__',\n", + " '__lt__',\n", + " '__module__',\n", + " '__ne__',\n", + " '__new__',\n", + " '__pretty__',\n", + " '__reduce__',\n", + " '__reduce_ex__',\n", + " '__repr__',\n", + " '__repr_args__',\n", + " '__repr_name__',\n", + " '__repr_str__',\n", + " '__rich_repr__',\n", + " '__setattr__',\n", + " '__sizeof__',\n", + " '__slotnames__',\n", + " '__slots__',\n", + " '__str__',\n", + " '__subclasshook__',\n", + " '_attributes_set',\n", + " '_collect_metadata',\n", + " '_extract_metadata',\n", + " '_from_dataclass_field',\n", + " 'alias',\n", + " 'alias_priority',\n", + " 'annotation',\n", + " 'apply_typevars_map',\n", + " 'default',\n", + " 'default_factory',\n", + " 'deprecated',\n", + " 'deprecation_message',\n", + " 'description',\n", + " 'discriminator',\n", + " 'examples',\n", + " 'exclude',\n", + " 'field_title_generator',\n", + " 'from_annotated_attribute',\n", + " 'from_annotation',\n", + " 'from_field',\n", + " 'frozen',\n", + " 'get_default',\n", + " 'init',\n", + " 'init_var',\n", + " 'is_required',\n", + " 'json_schema_extra',\n", + " 'kw_only',\n", + " 'merge_field_infos',\n", + " 'metadata',\n", + " 'metadata_lookup',\n", + " 'rebuild_annotation',\n", + " 'repr',\n", + " 'serialization_alias',\n", + " 'title',\n", + " 'validate_default',\n", + " 'validation_alias']" + ] + }, + "execution_count": 34, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "test_extra_fieldinfo = pd.fields.FieldInfo(test=1)\n", "dir(test_extra_fieldinfo)" @@ -341,27 +530,347 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 42, + "id": "69de16a0-2002-49eb-9be8-4c15b879b41e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'annotation': 'type[Any] | None',\n", + " 'default': 'Any',\n", + " 'default_factory': 'typing.Callable[[], Any] | None',\n", + " 'alias': 'str | None',\n", + " 'alias_priority': 'int | None',\n", + " 'validation_alias': 'str | AliasPath | AliasChoices | None',\n", + " 'serialization_alias': 'str | None',\n", + " 'title': 'str | None',\n", + " 'field_title_generator': 'typing.Callable[[str, FieldInfo], str] | None',\n", + " 'description': 'str | None',\n", + " 'examples': 'list[Any] | None',\n", + " 'exclude': 'bool | None',\n", + " 'discriminator': 'str | types.Discriminator | None',\n", + " 'deprecated': 'Deprecated | str | bool | None',\n", + " 'json_schema_extra': 'JsonDict | typing.Callable[[JsonDict], None] | None',\n", + " 'frozen': 'bool | None',\n", + " 'validate_default': 'bool | None',\n", + " 'repr': 'bool',\n", + " 'init': 'bool | None',\n", + " 'init_var': 'bool | None',\n", + " 'kw_only': 'bool | None',\n", + " 'metadata': 'list[Any]',\n", + " 'metadata_lookup': 'ClassVar[dict[str, typing.Callable[[Any], Any] | None]]'}" + ] + }, + "execution_count": 42, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_extra_fieldinfo.__annotations__" + ] + }, + { + "cell_type": "code", + "execution_count": 11, "id": "61fbb607-0b7b-43e6-932b-362d8b694ee7", "metadata": { "scrolled": true }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Help on class FieldInfo in module pydantic.fields:\n", + "\n", + "class FieldInfo(pydantic._internal._repr.Representation)\n", + " | FieldInfo(**kwargs: 'Unpack[_FieldInfoInputs]') -> 'None'\n", + " | \n", + " | This class holds information about a field.\n", + " | \n", + " | `FieldInfo` is used for any field definition regardless of whether the [`Field()`][pydantic.fields.Field]\n", + " | function is explicitly used.\n", + " | \n", + " | !!! warning\n", + " | You generally shouldn't be creating `FieldInfo` directly, you'll only need to use it when accessing\n", + " | [`BaseModel`][pydantic.main.BaseModel] `.model_fields` internals.\n", + " | \n", + " | Attributes:\n", + " | annotation: The type annotation of the field.\n", + " | default: The default value of the field.\n", + " | default_factory: The factory function used to construct the default for the field.\n", + " | alias: The alias name of the field.\n", + " | alias_priority: The priority of the field's alias.\n", + " | validation_alias: The validation alias of the field.\n", + " | serialization_alias: The serialization alias of the field.\n", + " | title: The title of the field.\n", + " | field_title_generator: A callable that takes a field name and returns title for it.\n", + " | description: The description of the field.\n", + " | examples: List of examples of the field.\n", + " | exclude: Whether to exclude the field from the model serialization.\n", + " | discriminator: Field name or Discriminator for discriminating the type in a tagged union.\n", + " | deprecated: A deprecation message, an instance of `warnings.deprecated` or the `typing_extensions.deprecated` backport,\n", + " | or a boolean. If `True`, a default deprecation message will be emitted when accessing the field.\n", + " | json_schema_extra: A dict or callable to provide extra JSON schema properties.\n", + " | frozen: Whether the field is frozen.\n", + " | validate_default: Whether to validate the default value of the field.\n", + " | repr: Whether to include the field in representation of the model.\n", + " | init: Whether the field should be included in the constructor of the dataclass.\n", + " | init_var: Whether the field should _only_ be included in the constructor of the dataclass, and not stored.\n", + " | kw_only: Whether the field should be a keyword-only argument in the constructor of the dataclass.\n", + " | metadata: List of metadata constraints.\n", + " | \n", + " | Method resolution order:\n", + " | FieldInfo\n", + " | pydantic._internal._repr.Representation\n", + " | builtins.object\n", + " | \n", + " | Methods defined here:\n", + " | \n", + " | __init__(self, **kwargs: 'Unpack[_FieldInfoInputs]') -> 'None'\n", + " | This class should generally not be initialized directly; instead, use the `pydantic.fields.Field` function\n", + " | or one of the constructor classmethods.\n", + " | \n", + " | See the signature of `pydantic.fields.Field` for more details about the expected arguments.\n", + " | \n", + " | __repr_args__(self) -> 'ReprArgs'\n", + " | Returns the attributes to show in __str__, __repr__, and __pretty__ this is generally overridden.\n", + " | \n", + " | Can either return:\n", + " | * name - value pairs, e.g.: `[('foo_name', 'foo'), ('bar_name', ['b', 'a', 'r'])]`\n", + " | * or, just values, e.g.: `[(None, 'foo'), (None, ['b', 'a', 'r'])]`\n", + " | \n", + " | apply_typevars_map(self, typevars_map: 'dict[Any, Any] | None', types_namespace: 'dict[str, Any] | None') -> 'None'\n", + " | Apply a `typevars_map` to the annotation.\n", + " | \n", + " | This method is used when analyzing parametrized generic types to replace typevars with their concrete types.\n", + " | \n", + " | This method applies the `typevars_map` to the annotation in place.\n", + " | \n", + " | Args:\n", + " | typevars_map: A dictionary mapping type variables to their concrete types.\n", + " | types_namespace (dict | None): A dictionary containing related types to the annotated type.\n", + " | \n", + " | See Also:\n", + " | pydantic._internal._generics.replace_types is used for replacing the typevars with\n", + " | their concrete types.\n", + " | \n", + " | get_default(self, *, call_default_factory: 'bool' = False) -> 'Any'\n", + " | Get the default value.\n", + " | \n", + " | We expose an option for whether to call the default_factory (if present), as calling it may\n", + " | result in side effects that we want to avoid. However, there are times when it really should\n", + " | be called (namely, when instantiating a model via `model_construct`).\n", + " | \n", + " | Args:\n", + " | call_default_factory: Whether to call the default_factory or not. Defaults to `False`.\n", + " | \n", + " | Returns:\n", + " | The default value, calling the default factory if requested or `None` if not set.\n", + " | \n", + " | is_required(self) -> 'bool'\n", + " | Check if the field is required (i.e., does not have a default value or factory).\n", + " | \n", + " | Returns:\n", + " | `True` if the field is required, `False` otherwise.\n", + " | \n", + " | rebuild_annotation(self) -> 'Any'\n", + " | Attempts to rebuild the original annotation for use in function signatures.\n", + " | \n", + " | If metadata is present, it adds it to the original annotation using\n", + " | `Annotated`. Otherwise, it returns the original annotation as-is.\n", + " | \n", + " | Note that because the metadata has been flattened, the original annotation\n", + " | may not be reconstructed exactly as originally provided, e.g. if the original\n", + " | type had unrecognized annotations, or was annotated with a call to `pydantic.Field`.\n", + " | \n", + " | Returns:\n", + " | The rebuilt annotation.\n", + " | \n", + " | ----------------------------------------------------------------------\n", + " | Static methods defined here:\n", + " | \n", + " | from_annotated_attribute(annotation: 'type[Any]', default: 'Any') -> 'FieldInfo'\n", + " | Create `FieldInfo` from an annotation with a default value.\n", + " | \n", + " | This is used in cases like the following:\n", + " | \n", + " | ```python\n", + " | import annotated_types\n", + " | from typing_extensions import Annotated\n", + " | \n", + " | import pydantic\n", + " | \n", + " | class MyModel(pydantic.BaseModel):\n", + " | foo: int = 4 # <-- like this\n", + " | bar: Annotated[int, annotated_types.Gt(4)] = 4 # <-- or this\n", + " | spam: Annotated[int, pydantic.Field(gt=4)] = 4 # <-- or this\n", + " | ```\n", + " | \n", + " | Args:\n", + " | annotation: The type annotation of the field.\n", + " | default: The default value of the field.\n", + " | \n", + " | Returns:\n", + " | A field object with the passed values.\n", + " | \n", + " | from_annotation(annotation: 'type[Any]') -> 'FieldInfo'\n", + " | Creates a `FieldInfo` instance from a bare annotation.\n", + " | \n", + " | This function is used internally to create a `FieldInfo` from a bare annotation like this:\n", + " | \n", + " | ```python\n", + " | import pydantic\n", + " | \n", + " | class MyModel(pydantic.BaseModel):\n", + " | foo: int # <-- like this\n", + " | ```\n", + " | \n", + " | We also account for the case where the annotation can be an instance of `Annotated` and where\n", + " | one of the (not first) arguments in `Annotated` is an instance of `FieldInfo`, e.g.:\n", + " | \n", + " | ```python\n", + " | import annotated_types\n", + " | from typing_extensions import Annotated\n", + " | \n", + " | import pydantic\n", + " | \n", + " | class MyModel(pydantic.BaseModel):\n", + " | foo: Annotated[int, annotated_types.Gt(42)]\n", + " | bar: Annotated[int, pydantic.Field(gt=42)]\n", + " | ```\n", + " | \n", + " | Args:\n", + " | annotation: An annotation object.\n", + " | \n", + " | Returns:\n", + " | An instance of the field metadata.\n", + " | \n", + " | from_field(default: 'Any' = PydanticUndefined, **kwargs: 'Unpack[_FromFieldInfoInputs]') -> 'FieldInfo'\n", + " | Create a new `FieldInfo` object with the `Field` function.\n", + " | \n", + " | Args:\n", + " | default: The default value for the field. Defaults to Undefined.\n", + " | **kwargs: Additional arguments dictionary.\n", + " | \n", + " | Raises:\n", + " | TypeError: If 'annotation' is passed as a keyword argument.\n", + " | \n", + " | Returns:\n", + " | A new FieldInfo object with the given parameters.\n", + " | \n", + " | Example:\n", + " | This is how you can create a field with default value like this:\n", + " | \n", + " | ```python\n", + " | import pydantic\n", + " | \n", + " | class MyModel(pydantic.BaseModel):\n", + " | foo: int = pydantic.Field(4)\n", + " | ```\n", + " | \n", + " | merge_field_infos(*field_infos: 'FieldInfo', **overrides: 'Any') -> 'FieldInfo'\n", + " | Merge `FieldInfo` instances keeping only explicitly set attributes.\n", + " | \n", + " | Later `FieldInfo` instances override earlier ones.\n", + " | \n", + " | Returns:\n", + " | FieldInfo: A merged FieldInfo instance.\n", + " | \n", + " | ----------------------------------------------------------------------\n", + " | Readonly properties defined here:\n", + " | \n", + " | deprecation_message\n", + " | The deprecation message to be emitted, or `None` if not set.\n", + " | \n", + " | ----------------------------------------------------------------------\n", + " | Data descriptors defined here:\n", + " | \n", + " | alias\n", + " | \n", + " | alias_priority\n", + " | \n", + " | annotation\n", + " | \n", + " | default\n", + " | \n", + " | default_factory\n", + " | \n", + " | deprecated\n", + " | \n", + " | description\n", + " | \n", + " | discriminator\n", + " | \n", + " | examples\n", + " | \n", + " | exclude\n", + " | \n", + " | field_title_generator\n", + " | \n", + " | frozen\n", + " | \n", + " | init\n", + " | \n", + " | init_var\n", + " | \n", + " | json_schema_extra\n", + " | \n", + " | kw_only\n", + " | \n", + " | metadata\n", + " | \n", + " | repr\n", + " | \n", + " | serialization_alias\n", + " | \n", + " | title\n", + " | \n", + " | validate_default\n", + " | \n", + " | validation_alias\n", + " | \n", + " | ----------------------------------------------------------------------\n", + " | Data and other attributes defined here:\n", + " | \n", + " | __annotations__ = {'alias': 'str | None', 'alias_priority': 'int | Non...\n", + " | \n", + " | __slotnames__ = ['annotation', 'default', 'default_factory', 'alias', ...\n", + " | \n", + " | metadata_lookup = {'allow_inf_nan': None, 'coerce_numbers_to_str': Non...\n", + " | \n", + " | ----------------------------------------------------------------------\n", + " | Methods inherited from pydantic._internal._repr.Representation:\n", + " | \n", + " | __pretty__(self, fmt: 'typing.Callable[[Any], Any]', **kwargs: 'Any') -> 'typing.Generator[Any, None, None]'\n", + " | Used by devtools (https://python-devtools.helpmanual.io/) to pretty print objects.\n", + " | \n", + " | __repr__(self) -> 'str'\n", + " | Return repr(self).\n", + " | \n", + " | __repr_name__(self) -> 'str'\n", + " | Name of the instance's class, used in __repr__.\n", + " | \n", + " | __repr_str__(self, join_str: 'str') -> 'str'\n", + " | \n", + " | __rich_repr__(self) -> 'RichReprResult'\n", + " | Used by Rich (https://rich.readthedocs.io/en/stable/pretty.html) to pretty print objects.\n", + " | \n", + " | __str__(self) -> 'str'\n", + " | Return str(self).\n", + "\n" + ] + } + ], "source": [ "help(pd.fields.FieldInfo)" ] }, { "cell_type": "code", - "execution_count": null, - "id": "eecd8da6-27e8-488d-b758-757142aead52", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 18, + "execution_count": 12, "id": "b4a9de07-0f79-4fc3-b3e2-a33187e66142", "metadata": {}, "outputs": [], @@ -375,40 +884,18 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 17, "id": "5f6541f0-7ee6-47a2-97d0-a7a6574d9e0e", "metadata": {}, - "outputs": [ - { - "ename": "NameError", - "evalue": "name 'PhysicalParameter' is not defined", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[15], line 2\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[38;5;66;03m# help(pd.fields.Field)\u001b[39;00m\n\u001b[0;32m----> 2\u001b[0m pd\u001b[38;5;241m.\u001b[39mfields\u001b[38;5;241m.\u001b[39mField(extra\u001b[38;5;241m=\u001b[39m\u001b[43mPhysicalParameter\u001b[49m())\n", - "\u001b[0;31mNameError\u001b[0m: name 'PhysicalParameter' is not defined" - ] - } - ], - "source": [ - "# help(pd.fields.Field)\n", - "pd.fields.Field(extra=PhysicalParameter())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "146154e0-4fbd-4401-a6ca-58656989ab46", - "metadata": {}, "outputs": [], "source": [ - "\n" + "# help(pd.fields.Field)\n", + "# pd.fields.Field(extra=PhysicalParameter())" ] }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 14, "id": "46d6cbba-f04a-424e-afa6-8db0fe85fc4c", "metadata": {}, "outputs": [ @@ -418,7 +905,7 @@ "[ModelField(name='units', type=str, required=False, default='')]" ] }, - "execution_count": 16, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" } @@ -429,7 +916,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 15, "id": "d47f14ff-3aaf-4654-9f2d-969c6ab27162", "metadata": {}, "outputs": [ @@ -439,7 +926,7 @@ "[FieldInfo(annotation=str, required=False, default='')]" ] }, - "execution_count": 17, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -450,7 +937,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 16, "id": "3f60ed00-d8b6-4d27-93bb-bbb7b72f2392", "metadata": {}, "outputs": [], diff --git a/docs/structure.rst b/docs/structure.rst index c3aeffc..1065e05 100644 --- a/docs/structure.rst +++ b/docs/structure.rst @@ -69,3 +69,10 @@ Each of our constructors might have a physical meaning assigned to them. This is operators as symbolic terms. As such, we don't want to overwrite the existing documentation functionality we like such as doctree declarations. + + +Properties +----------- + +These are extended type definitions of standard Python class properties. In this case, we may want to encode physical units, +math and possibly more which we can compile into a larger documentation structure. diff --git a/tests/extractors/test_field_extractors.py b/tests/extractors/test_field_extractors.py index 47b80f4..ad716d7 100644 --- a/tests/extractors/test_field_extractors.py +++ b/tests/extractors/test_field_extractors.py @@ -1,10 +1,256 @@ -import demo +import pydantic as pd +from pydantic import Field import autoflex +from autoflex.types import Property, PhysicalProperty, PhysicalFieldInfo, Symbolic +from autoflex.extractors import field_info_to_property, physical_field_info_to_physical_property, auto_field_to_property_type, extract_property_list_from_model +from autoflex.field import PhysicalField def test_get_field_infos(): - model = demo.BasicClass() + import demo + + basic_class = demo.BasicClass() + basic_mixed_class = demo.BasicMixedAnnotatedClass() + + basic_class_fields = autoflex.get_field_infos(basic_class) + basic_mixed_class_fields = autoflex.get_field_infos(basic_mixed_class) + + print(f"Fields: {basic_class_fields}") + assert len(basic_class_fields) == 3, f"Expected 3 fields, found {len(basic_class_fields)}" + assert len(basic_mixed_class_fields) == 4, f"Expected 4 fields, found {len(basic_mixed_class_fields)}" + + + + +def test_automatic_field_to_property_extractor(): + # help(pd.fields.Field) + field_json_schema_extras = Field( + json_schema_extra={ + "unit": "ms", + "math": "s + 1" + } + ) + + field_extra = Field( + extra={ + "unit": "ms", + "math": "s + 1" + } + ) + + + aufoflex_physical_field_extra = Field( + json_schema_extra={ + "unit": "ms", + "math": "s + 1" + } + ) + + +def test_physical_field_info_to_physical_property(): + # Create a PhysicalFieldInfo instance + symbolic = Symbolic(label="Force", math="F = ma") + unit = "N" + field_info = PhysicalFieldInfo( + default=10.0, + unit=unit, + math=symbolic, + description="Force applied", + ) + + field_name = "force" + + physical_property = physical_field_info_to_physical_property(field_info, field_name) + + print(physical_property) + + assert isinstance(physical_property, PhysicalProperty) + assert physical_property.name == "force" + # assert physical_property.unit == unit + # assert physical_property.math == symbolic + assert physical_property.description == "Force applied" + assert physical_property.default == "10.0" + + +def test_field_info_to_property(): + # Create a standard FieldInfo instance + field_info = pd.Field( + default=5.0, + description="Mass of the object", + ) + + field_name = "mass" + + property_instance = field_info_to_property(field_info, field_name) + + print("FieldInfo:") + print(field_info) + print("Property instance:", property_instance) + print(property_instance) + + assert isinstance(property_instance, Property) + assert property_instance.name == "mass" + assert property_instance.description == "Mass of the object" + assert property_instance.default == "5.0" + # assert property_instance.types == "" + + +def test_auto_field_to_property_type_physical(): + # Create a PhysicalFieldInfo instance + symbolic = Symbolic(label="Force", math="F = ma") + unit = "N" + field_info = PhysicalFieldInfo( + default=15.0, + unit=unit, + math=symbolic, + description="Applied force", + ) + + field_name = "force" + + property_type = auto_field_to_property_type(field_info, field_name) + + print(property_type) + + assert isinstance(property_type, PhysicalProperty) + assert property_type.name == "force" + # assert property_type.unit == unit + # assert property_type.math == symbolic + assert property_type.description == "Applied force" + assert property_type.default == "15.0" + + +def test_auto_field_to_property_type_standard(): + # Create a standard FieldInfo instance + field_info = pd.Field( + default=7.5, + description="Temperature in Celsius", + ) + + field_name = "temperature" + + property_type = auto_field_to_property_type(field_info, field_name) + + assert isinstance(property_type, Property) + assert property_type.name == "temperature" + assert property_type.description == "Temperature in Celsius" + assert property_type.default == "7.5" + # assert property_type.types == "" + + +def test_extract_property_list_from_model(): + class TestModel(pd.BaseModel): + mass: float = pd.Field(5.0, description="Mass of the object") + velocity: float = pd.Field(10.0, description="Velocity of the object") + force: float = PhysicalField( + default=20.0, + description="Force applied", + unit="N", + math="F = ma" + ) + acceleration: float = pd.Field(9.8, description="Acceleration of the object") + + model = TestModel() + + properties = extract_property_list_from_model(model) + + assert len(properties) == 4, f"Expected 4 properties, found {len(properties)}" + + # Check each property + mass_prop = properties[0] + assert isinstance(mass_prop, Property) + assert mass_prop.name == "mass" + assert mass_prop.description == "Mass of the object" + assert mass_prop.default == "5.0" + + velocity_prop = properties[1] + assert isinstance(velocity_prop, Property) + assert velocity_prop.name == "velocity" + assert velocity_prop.description == "Velocity of the object" + assert velocity_prop.default == "10.0" + + force_prop = properties[2] + print(force_prop) + assert isinstance(force_prop, PhysicalProperty) + assert force_prop.name == "force" + # assert force_prop.description == "Force applied" + assert force_prop.default == "20.0" + # assert force_prop.unit == "N" + # assert force_prop.math == "F = ma" + + acceleration_prop = properties[3] + assert isinstance(acceleration_prop, Property) + assert acceleration_prop.name == "acceleration" + assert acceleration_prop.description == "Acceleration of the object" + # assert acceleration_prop.default == "9.81" + + +def test_automatic_field_to_property_extractor(): + # Create FieldInfo instances with different extras + field_json_schema_extras = Field( + default=100, + description="Time in milliseconds", + json_schema_extra={ + "unit": "ms", + "math": "t = 1000 * s" + } + ) + + field_extra = Field( + default=200, + description="Distance in meters", + extra={ + "unit": "m", + "math": "d = vt" + } + ) + + # For the purpose of this test, we'll manually create PhysicalFieldInfo if extras are present + # In a real scenario, you might have logic to detect and convert based on extras + physical_field_info_json = PhysicalFieldInfo( + default=100, + unit="ms", + math="t = 1000 * s", + description="Time in milliseconds", + ) + + physical_field_info_extra = PhysicalFieldInfo( + default=200, + unit="m", + math="d = vt", + description="Distance in meters", + ) + + # Convert to PropertyTypes + property_json = auto_field_to_property_type(field_json_schema_extras, "time") + property_extra = auto_field_to_property_type(field_extra, "distance") + property_physical_json = auto_field_to_property_type(physical_field_info_json, "time_physical") + property_physical_extra = auto_field_to_property_type(physical_field_info_extra, "distance_physical") + + # Assertions + assert isinstance(property_json, Property) + assert property_json.name == "time" + assert property_json.description == "Time in milliseconds" + assert property_json.default == "100" + # assert property_json.types == "" + + assert isinstance(property_extra, Property) + assert property_extra.name == "distance" + assert property_extra.description == "Distance in meters" + assert property_extra.default == "200" + # assert property_extra.types == "" + + assert isinstance(property_physical_json, PhysicalProperty) + assert property_physical_json.name == "time_physical" + assert property_physical_json.description == "Time in milliseconds" + assert property_physical_json.default == "100" + # assert property_physical_json.unit == "ms" + # assert property_physical_json.math == "t = 1000 * s" + + assert isinstance(property_physical_extra, PhysicalProperty) + assert property_physical_extra.name == "distance_physical" + assert property_physical_extra.description == "Distance in meters" + assert property_physical_extra.default == "200" + # assert property_physical_extra.unit == "m" + # assert property_physical_extra.math == "d = vt" - fields = autoflex.get_field_infos(model) - print(f"Fields: {fields}") - assert len(fields) == 3, f"Expected 3 fields, found {len(fields)}"