Repository: Materials-Consortia/optimade-python-tools
Pull request #2097 (2024-06-13 20:29:57)

Validate property names against identifier regular expression
This PR implements property name validation against identifier regular expression as laid out in OPTIMADE specification.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/2097?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 90.70%. Comparing base [(`983b98f`)](https://app.codecov.io/gh/Materials-Consortia/optimade-python-tools/commit/983b98f9573e1bc58602bb78ea31d87d3ce829a0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia) to head [(`bbd43a2`)](https://app.codecov.io/gh/Materials-Consortia/optimade-python-tools/commit/bbd43a2734b82b3fcd222010dd47b4d1b5628d51?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia).


<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #2097   +/-   ##
=======================================
  Coverage   90.69%   90.70%           
=======================================
  Files          75       75           
  Lines        4740     4743    +3     
=======================================
+ Hits         4299     4302    +3     
  Misses        441      441           
```

| [Flag](https://app.codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/2097/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia) | Coverage Δ | |
|---|---|---|
| [project](https://app.codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/2097/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia) | `90.70% <100.00%> (+<0.01%)` | :arrow_up: |
| [validator](https://app.codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/2097/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia) | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/2097?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia).


Patch:
diff --git a/openapi/openapi.json b/openapi/openapi.json
index c5f1a39e..11eb639e 100644
--- a/openapi/openapi.json
+++ b/openapi/openapi.json
@@ -1664,8 +1664,10 @@
             "description": "Description of the entry."
           },
           "properties": {
-            "additionalProperties": {
-              "$ref": "#/components/schemas/EntryInfoProperty"
+            "patternProperties": {
+              "^[a-z_][a-z_0-9]+$": {
+                "$ref": "#/components/schemas/EntryInfoProperty"
+              }
             },
             "type": "object",
             "title": "Properties",
@@ -1674,7 +1676,8 @@
           "output_fields_by_format": {
             "additionalProperties": {
               "items": {
-                "type": "string"
+                "type": "string",
+                "pattern": "^[a-z_][a-z_0-9]+$"
               },
               "type": "array"
             },
diff --git a/optimade/models/entries.py b/optimade/models/entries.py
index 8728a7af..414b4dde 100644
--- a/optimade/models/entries.py
+++ b/optimade/models/entries.py
@@ -8,6 +8,7 @@ from optimade.models.optimade_json import (
     BaseRelationshipResource,
     DataType,
     Relationship,
+    ValidIdentifier,
 )
 from optimade.models.utils import OptimadeField, StrictField, SupportLevel
 
@@ -226,14 +227,14 @@ class EntryInfoResource(BaseModel):
     description: Annotated[str, StrictField(description="Description of the entry.")]
 
     properties: Annotated[
-        dict[str, EntryInfoProperty],
+        dict[ValidIdentifier, EntryInfoProperty],
         StrictField(
             description="A dictionary describing queryable properties for this entry type, where each key is a property name.",
         ),
     ]
 
     output_fields_by_format: Annotated[
-        dict[str, list[str]],
+        dict[str, list[ValidIdentifier]],
         StrictField(
             description="Dictionary of available output fields for this entry type, where the keys are the values of the `formats` list and the values are the keys of the `properties` dictionary.",
         ),
diff --git a/optimade/models/optimade_json.py b/optimade/models/optimade_json.py
index 348cb904..1aace40e 100644
--- a/optimade/models/optimade_json.py
+++ b/optimade/models/optimade_json.py
@@ -4,11 +4,11 @@ from datetime import datetime
 from enum import Enum
 from typing import Annotated, Any, Literal, Optional, Union
 
-from pydantic import BaseModel, ConfigDict, EmailStr, model_validator
+from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
 
 from optimade.models import jsonapi
 from optimade.models.types import SemanticVersion
-from optimade.models.utils import StrictField
+from optimade.models.utils import IDENTIFIER_REGEX, StrictField
 
 __all__ = (
     "DataType",
@@ -25,6 +25,9 @@ __all__ = (
     "Relationship",
 )
 
+ValidIdentifier = Annotated[str, Field(pattern=IDENTIFIER_REGEX)]
+"""A type that constrains strings to valid OPTIMADE identifiers (e.g., property names, ID strings)."""
+
 
 class DataType(Enum):
     """Optimade Data types
diff --git a/optimade/models/utils.py b/optimade/models/utils.py
index 50349e3d..44f3109c 100644
--- a/optimade/models/utils.py
+++ b/optimade/models/utils.py
@@ -26,6 +26,7 @@ OPTIMADE_SCHEMA_EXTENSION_KEYS = ["support", "queryable", "unit", "sortable"]
 OPTIMADE_SCHEMA_EXTENSION_PREFIX = "x-optimade-"
 
 SEMVER_PATTERN = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
+IDENTIFIER_REGEX = r"^[a-z_][a-z_0-9]+$"
 
 
 class SupportLevel(Enum):


Test patch:
diff --git a/tests/models/test_baseinfo.py b/tests/models/test_baseinfo.py
index 6d24a116..49d9bd52 100644
--- a/tests/models/test_baseinfo.py
+++ b/tests/models/test_baseinfo.py
@@ -2,6 +2,7 @@ import pytest
 from pydantic import ValidationError
 
 from optimade.models.baseinfo import AvailableApiVersion
+from optimade.models.entries import EntryInfoResource
 
 
 def test_available_api_versions():
@@ -57,3 +58,34 @@ def test_available_api_versions():
 
     for data in good_combos:
         assert isinstance(AvailableApiVersion(**data), AvailableApiVersion)
+
+
+def test_good_entry_info_custom_properties():
+    test_good_info = {
+        "formats": ["json", "xml"],
+        "description": "good info data",
+        "output_fields_by_format": {"json": ["nelements", "id"]},
+        "properties": {
+            "nelements": {"description": "num elements", "type": "integer"},
+            "_custom_field": {"description": "good custom field", "type": "string"},
+        },
+    }
+    assert EntryInfoResource(**test_good_info)
+
+
+def test_bad_entry_info_custom_properties():
+    """Checks that error is raised if custom field contains upper case letter."""
+    test_bad_info = {
+        "formats": ["json", "xml"],
+        "description": "bad info data",
+        "output_fields_by_format": {"json": ["nelements", "id"]},
+        "properties": {
+            "nelements": {"description": "num elements", "type": "integer"},
+            "_custom_Field": {"description": "bad custom field", "type": "string"},
+        },
+    }
+    with pytest.raises(
+        ValueError,
+        match=".*[type=string_pattern_mismatch, input_value='_custom_Field', input_type=str].*",
+    ):
+        EntryInfoResource(**test_bad_info)