Repository: dandi/dandi-schema

Issue:
Add a top-level `$schema` field to the generated JSON Schemata
The JSON Schema spec is frequently updated (sometimes introducing backwards incompatibilities), and so we should [declare the JSON Schema version ("dialect") used by our schemata via a top-level `$schema` field](https://github.com/dandi/dandi-schema/issues/new).

For the record, [the current documentation for Pydantic 2.5](https://docs.pydantic.dev/2.5/concepts/json_schema/) states that it produces schemata compliant with [JSON Schema Draft 2020-12](https://json-schema.org/draft/2020-12/release-notes.html), while [the Pydantic 1.10 documentation](https://docs.pydantic.dev/1.10/usage/schema/) doesn't seem to give a specific version.

Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c79e46e..8fbbe97 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,14 +2,14 @@
 # See https://pre-commit.com/hooks.html for more hooks
 repos:
 -   repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.6.0
+    rev: v4.5.0
     hooks:
     -   id: trailing-whitespace
     -   id: end-of-file-fixer
     -   id: check-yaml
     -   id: check-added-large-files
 -   repo: https://github.com/psf/black
-    rev: 24.4.0
+    rev: 24.2.0
     hooks:
     -   id: black
 -   repo: https://github.com/PyCQA/isort
diff --git a/dandischema/models.py b/dandischema/models.py
index 8264472..2de8299 100644
--- a/dandischema/models.py
+++ b/dandischema/models.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 from datetime import date, datetime
 from enum import Enum
 import os
@@ -847,16 +845,6 @@ class Contributor(DandiBaseModel):
         "Contributor", validate_default=True, json_schema_extra={"readOnly": True}
     )
 
-    @model_validator(mode="after")
-    def ensure_contact_person_has_email(self) -> Contributor:
-        role_names = self.roleName
-
-        if role_names is not None and RoleType.ContactPerson in role_names:
-            if self.email is None:
-                raise ValueError("Contact person must have an email address.")
-
-        return self
-
 
 class Organization(Contributor):
     identifier: Optional[RORID] = Field(
diff --git a/dandischema/utils.py b/dandischema/utils.py
index 33ebfe6..c73167c 100644
--- a/dandischema/utils.py
+++ b/dandischema/utils.py
@@ -3,8 +3,8 @@ from __future__ import annotations
 import re
 from typing import Any, Iterator, List, Union, get_args, get_origin
 
-from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
-from pydantic_core import core_schema
+from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode, JsonSchemaValue
+from pydantic_core import CoreSchema, core_schema
 
 TITLE_CASE_LOWER = {
     "a",
@@ -69,6 +69,16 @@ class TransitionalGenerateJsonSchema(GenerateJsonSchema):
     schema generation process are the same as the behavior in Pydantic V1.
     """
 
+    def generate(
+        self, schema: CoreSchema, mode: JsonSchemaMode = "validation"
+    ) -> JsonSchemaValue:
+        json_schema = super().generate(schema, mode)
+
+        # Set the `$schema` key with the schema dialect
+        json_schema["$schema"] = self.schema_dialect
+
+        return json_schema
+
     def nullable_schema(self, schema: core_schema.NullableSchema) -> JsonSchemaValue:
         # Override the default behavior for handling a schema that allows null values
         # With this override, `Optional` fields will not be indicated


Test patch:
diff --git a/dandischema/tests/test_datacite.py b/dandischema/tests/test_datacite.py
index 5663ffc..b02e439 100644
--- a/dandischema/tests/test_datacite.py
+++ b/dandischema/tests/test_datacite.py
@@ -70,7 +70,6 @@ def metadata_basic() -> Dict[str, Any]:
         "contributor": [
             {
                 "name": "A_last, A_first",
-                "email": "nemo@example.com",
                 "roleName": [RoleType("dcite:ContactPerson")],
             }
         ],
diff --git a/dandischema/tests/test_metadata.py b/dandischema/tests/test_metadata.py
index 61ac2d7..a35fa50 100644
--- a/dandischema/tests/test_metadata.py
+++ b/dandischema/tests/test_metadata.py
@@ -156,7 +156,6 @@ def test_mismatch_key(schema_version: str, schema_key: str) -> None:
                     {
                         "schemaKey": "Person",
                         "name": "Last, first",
-                        "email": "nobody@example.com",
                         "roleName": ["dcite:ContactPerson"],
                     }
                 ],
diff --git a/dandischema/tests/test_models.py b/dandischema/tests/test_models.py
index b39af11..a5bc368 100644
--- a/dandischema/tests/test_models.py
+++ b/dandischema/tests/test_models.py
@@ -18,7 +18,6 @@ from ..models import (
     Asset,
     BaseType,
     CommonModel,
-    Contributor,
     DandiBaseModel,
     Dandiset,
     DigestType,
@@ -379,7 +378,6 @@ def test_dantimeta_1() -> None:
         "contributor": [
             {
                 "name": "last name, first name",
-                "email": "someone@dandiarchive.org",
                 "roleName": [RoleType("dcite:ContactPerson")],
             }
         ],
@@ -594,7 +592,6 @@ def test_schemakey_roundtrip() -> None:
         },
         {
             "name": "last2, first2",
-            "email": "nospam@dandiarchive.org",
             "roleName": ["dcite:ContactPerson"],
             "schemaKey": "Person",
             "affiliation": [],
@@ -692,43 +689,3 @@ def test_embargoedaccess() -> None:
             )
         ]
     )
-
-
-_NON_CONTACT_PERSON_ROLES_ARGS: List[List[RoleType]] = [
-    [],
-    [RoleType.Author, RoleType.DataCurator],
-    [RoleType.Funder],
-]
-
-_CONTACT_PERSON_ROLES_ARGS: List[List[RoleType]] = [
-    role_lst + [RoleType.ContactPerson] for role_lst in _NON_CONTACT_PERSON_ROLES_ARGS
-]
-
-
-class TestContributor:
-
-    @pytest.mark.parametrize("roles", _CONTACT_PERSON_ROLES_ARGS)
-    def test_contact_person_without_email(self, roles: List[RoleType]) -> None:
-        """
-        Test creating a `Contributor` instance as a contact person without an email
-        """
-        with pytest.raises(
-            pydantic.ValidationError, match="Contact person must have an email address"
-        ):
-            Contributor(roleName=roles)
-
-    @pytest.mark.parametrize("roles", _NON_CONTACT_PERSON_ROLES_ARGS)
-    def test_non_contact_person_without_email(self, roles: List[RoleType]) -> None:
-        """
-        Test creating a `Contributor` instance as a non-contact person without an email
-        """
-        Contributor(roleName=roles)
-
-    @pytest.mark.parametrize(
-        "roles", _NON_CONTACT_PERSON_ROLES_ARGS + _CONTACT_PERSON_ROLES_ARGS
-    )
-    def test_with_email(self, roles: List[RoleType]) -> None:
-        """
-        Test creating a `Contributor` instance with an email
-        """
-        Contributor(email="nemo@dandiarchive.org", roleName=roles)