diff --git a/examples/config_files/.schemas/Bob_schema.json b/examples/config_files/.schemas/Bob_schema.json new file mode 100644 index 00000000..32e525a1 --- /dev/null +++ b/examples/config_files/.schemas/Bob_schema.json @@ -0,0 +1,13 @@ +{ + "properties": { + "foo": { + "default": 123, + "title": "Foo", + "type": "integer", + "description": "A very important field." + } + }, + "title": "Bob", + "type": "object", + "description": "Some docstring." +} diff --git a/examples/config_files/.schemas/Nested_schema.json b/examples/config_files/.schemas/Nested_schema.json new file mode 100644 index 00000000..6bb14e42 --- /dev/null +++ b/examples/config_files/.schemas/Nested_schema.json @@ -0,0 +1,35 @@ +{ + "$defs": { + "Bob": { + "properties": { + "foo": { + "default": 123, + "title": "Foo", + "type": "integer", + "description": "A very important field." + } + }, + "title": "Bob", + "type": "object", + "description": "Some docstring." + } + }, + "properties": { + "bob": { + "$ref": "#/$defs/Bob", + "description": "bobobobo." + }, + "other_field": { + "title": "Other Field", + "type": "string", + "description": "This is a docstring for the other field." + } + }, + "required": [ + "bob", + "other_field" + ], + "title": "Nested", + "type": "object", + "description": "Some docstring of the 'Nested' class." +} diff --git a/examples/config_files/bob_with_schema.yaml b/examples/config_files/bob_with_schema.yaml new file mode 100644 index 00000000..f7c95f80 --- /dev/null +++ b/examples/config_files/bob_with_schema.yaml @@ -0,0 +1,2 @@ +# yaml-language-server: $schema=.schemas/Bob_schema.json +foo: 222 diff --git a/examples/config_files/nested_with_schema.yaml b/examples/config_files/nested_with_schema.yaml new file mode 100644 index 00000000..c668f55f --- /dev/null +++ b/examples/config_files/nested_with_schema.yaml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=.schemas/Nested_schema.json +bob: + foo: 222 +other_field: babab diff --git a/examples/config_files/schema_example.py b/examples/config_files/schema_example.py new file mode 100644 index 00000000..67931acd --- /dev/null +++ b/examples/config_files/schema_example.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from pathlib import Path + +from simple_parsing.helpers.serialization.yaml_schema import save_yaml_with_schema + + +@dataclass +class Bob: + """Some docstring.""" + + foo: int = 123 + """A very important field.""" + + +@dataclass +class Nested: + """Some docstring of the 'Nested' class.""" + + bob: Bob # inline comment for field `bob` of class `Nested` + """bobobobo.""" + + other_field: str # inline comment for `other_field` of class `Nested` + """This is a docstring for the other field.""" + + +if __name__ == "__main__": + save_yaml_with_schema( + Bob(foo=222), + Path(__file__).parent / "bob_with_schema.yaml", + ) + + save_yaml_with_schema( + Nested(bob=Bob(foo=222), other_field="babab"), + Path(__file__).parent / "nested_with_schema.yaml", + ) diff --git a/poetry.lock b/poetry.lock index c0d4fc47..7e4319ac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,19 @@ # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = true +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} + [[package]] name = "colorama" version = "0.4.6" @@ -202,6 +216,116 @@ files = [ {file = "py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5"}, ] +[[package]] +name = "pydantic" +version = "2.6.1" +description = "Data validation using Python type hints" +optional = true +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.6.1-py3-none-any.whl", hash = "sha256:0b6a909df3192245cb736509a92ff69e4fef76116feffec68e93a567347bae6f"}, + {file = "pydantic-2.6.1.tar.gz", hash = "sha256:4fd5c182a2488dc63e6d32737ff19937888001e2a6d86e94b3f233104a5d1fa9"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.16.2" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.16.2" +description = "" +optional = true +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.16.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3fab4e75b8c525a4776e7630b9ee48aea50107fea6ca9f593c98da3f4d11bf7c"}, + {file = "pydantic_core-2.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8bde5b48c65b8e807409e6f20baee5d2cd880e0fad00b1a811ebc43e39a00ab2"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2924b89b16420712e9bb8192396026a8fbd6d8726224f918353ac19c4c043d2a"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16aa02e7a0f539098e215fc193c8926c897175d64c7926d00a36188917717a05"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:936a787f83db1f2115ee829dd615c4f684ee48ac4de5779ab4300994d8af325b"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:459d6be6134ce3b38e0ef76f8a672924460c455d45f1ad8fdade36796df1ddc8"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9ee4febb249c591d07b2d4dd36ebcad0ccd128962aaa1801508320896575ef"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40a0bd0bed96dae5712dab2aba7d334a6c67cbcac2ddfca7dbcc4a8176445990"}, + {file = "pydantic_core-2.16.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:870dbfa94de9b8866b37b867a2cb37a60c401d9deb4a9ea392abf11a1f98037b"}, + {file = "pydantic_core-2.16.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:308974fdf98046db28440eb3377abba274808bf66262e042c412eb2adf852731"}, + {file = "pydantic_core-2.16.2-cp310-none-win32.whl", hash = "sha256:a477932664d9611d7a0816cc3c0eb1f8856f8a42435488280dfbf4395e141485"}, + {file = "pydantic_core-2.16.2-cp310-none-win_amd64.whl", hash = "sha256:8f9142a6ed83d90c94a3efd7af8873bf7cefed2d3d44387bf848888482e2d25f"}, + {file = "pydantic_core-2.16.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:406fac1d09edc613020ce9cf3f2ccf1a1b2f57ab00552b4c18e3d5276c67eb11"}, + {file = "pydantic_core-2.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ce232a6170dd6532096cadbf6185271e4e8c70fc9217ebe105923ac105da9978"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a90fec23b4b05a09ad988e7a4f4e081711a90eb2a55b9c984d8b74597599180f"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8aafeedb6597a163a9c9727d8a8bd363a93277701b7bfd2749fbefee2396469e"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9957433c3a1b67bdd4c63717eaf174ebb749510d5ea612cd4e83f2d9142f3fc8"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0d7a9165167269758145756db43a133608a531b1e5bb6a626b9ee24bc38a8f7"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dffaf740fe2e147fedcb6b561353a16243e654f7fe8e701b1b9db148242e1272"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8ed79883b4328b7f0bd142733d99c8e6b22703e908ec63d930b06be3a0e7113"}, + {file = "pydantic_core-2.16.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cf903310a34e14651c9de056fcc12ce090560864d5a2bb0174b971685684e1d8"}, + {file = "pydantic_core-2.16.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:46b0d5520dbcafea9a8645a8164658777686c5c524d381d983317d29687cce97"}, + {file = "pydantic_core-2.16.2-cp311-none-win32.whl", hash = "sha256:70651ff6e663428cea902dac297066d5c6e5423fda345a4ca62430575364d62b"}, + {file = "pydantic_core-2.16.2-cp311-none-win_amd64.whl", hash = "sha256:98dc6f4f2095fc7ad277782a7c2c88296badcad92316b5a6e530930b1d475ebc"}, + {file = "pydantic_core-2.16.2-cp311-none-win_arm64.whl", hash = "sha256:ef6113cd31411eaf9b39fc5a8848e71c72656fd418882488598758b2c8c6dfa0"}, + {file = "pydantic_core-2.16.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:88646cae28eb1dd5cd1e09605680c2b043b64d7481cdad7f5003ebef401a3039"}, + {file = "pydantic_core-2.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7b883af50eaa6bb3299780651e5be921e88050ccf00e3e583b1e92020333304b"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bf26c2e2ea59d32807081ad51968133af3025c4ba5753e6a794683d2c91bf6e"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99af961d72ac731aae2a1b55ccbdae0733d816f8bfb97b41909e143de735f522"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02906e7306cb8c5901a1feb61f9ab5e5c690dbbeaa04d84c1b9ae2a01ebe9379"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5362d099c244a2d2f9659fb3c9db7c735f0004765bbe06b99be69fbd87c3f15"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ac426704840877a285d03a445e162eb258924f014e2f074e209d9b4ff7bf380"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b94cbda27267423411c928208e89adddf2ea5dd5f74b9528513f0358bba019cb"}, + {file = "pydantic_core-2.16.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6db58c22ac6c81aeac33912fb1af0e930bc9774166cdd56eade913d5f2fff35e"}, + {file = "pydantic_core-2.16.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396fdf88b1b503c9c59c84a08b6833ec0c3b5ad1a83230252a9e17b7dfb4cffc"}, + {file = "pydantic_core-2.16.2-cp312-none-win32.whl", hash = "sha256:7c31669e0c8cc68400ef0c730c3a1e11317ba76b892deeefaf52dcb41d56ed5d"}, + {file = "pydantic_core-2.16.2-cp312-none-win_amd64.whl", hash = "sha256:a3b7352b48fbc8b446b75f3069124e87f599d25afb8baa96a550256c031bb890"}, + {file = "pydantic_core-2.16.2-cp312-none-win_arm64.whl", hash = "sha256:a9e523474998fb33f7c1a4d55f5504c908d57add624599e095c20fa575b8d943"}, + {file = "pydantic_core-2.16.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:ae34418b6b389d601b31153b84dce480351a352e0bb763684a1b993d6be30f17"}, + {file = "pydantic_core-2.16.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:732bd062c9e5d9582a30e8751461c1917dd1ccbdd6cafb032f02c86b20d2e7ec"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b52776a2e3230f4854907a1e0946eec04d41b1fc64069ee774876bbe0eab55"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef551c053692b1e39e3f7950ce2296536728871110e7d75c4e7753fb30ca87f4"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ebb892ed8599b23fa8f1799e13a12c87a97a6c9d0f497525ce9858564c4575a4"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa6c8c582036275997a733427b88031a32ffa5dfc3124dc25a730658c47a572f"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4ba0884a91f1aecce75202473ab138724aa4fb26d7707f2e1fa6c3e68c84fbf"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7924e54f7ce5d253d6160090ddc6df25ed2feea25bfb3339b424a9dd591688bc"}, + {file = "pydantic_core-2.16.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69a7b96b59322a81c2203be537957313b07dd333105b73db0b69212c7d867b4b"}, + {file = "pydantic_core-2.16.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7e6231aa5bdacda78e96ad7b07d0c312f34ba35d717115f4b4bff6cb87224f0f"}, + {file = "pydantic_core-2.16.2-cp38-none-win32.whl", hash = "sha256:41dac3b9fce187a25c6253ec79a3f9e2a7e761eb08690e90415069ea4a68ff7a"}, + {file = "pydantic_core-2.16.2-cp38-none-win_amd64.whl", hash = "sha256:f685dbc1fdadb1dcd5b5e51e0a378d4685a891b2ddaf8e2bba89bd3a7144e44a"}, + {file = "pydantic_core-2.16.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:55749f745ebf154c0d63d46c8c58594d8894b161928aa41adbb0709c1fe78b77"}, + {file = "pydantic_core-2.16.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b30b0dd58a4509c3bd7eefddf6338565c4905406aee0c6e4a5293841411a1286"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18de31781cdc7e7b28678df7c2d7882f9692ad060bc6ee3c94eb15a5d733f8f7"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5864b0242f74b9dd0b78fd39db1768bc3f00d1ffc14e596fd3e3f2ce43436a33"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8f9186ca45aee030dc8234118b9c0784ad91a0bb27fc4e7d9d6608a5e3d386c"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc6f6c9be0ab6da37bc77c2dda5f14b1d532d5dbef00311ee6e13357a418e646"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa057095f621dad24a1e906747179a69780ef45cc8f69e97463692adbcdae878"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ad84731a26bcfb299f9eab56c7932d46f9cad51c52768cace09e92a19e4cf55"}, + {file = "pydantic_core-2.16.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3b052c753c4babf2d1edc034c97851f867c87d6f3ea63a12e2700f159f5c41c3"}, + {file = "pydantic_core-2.16.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e0f686549e32ccdb02ae6f25eee40cc33900910085de6aa3790effd391ae10c2"}, + {file = "pydantic_core-2.16.2-cp39-none-win32.whl", hash = "sha256:7afb844041e707ac9ad9acad2188a90bffce2c770e6dc2318be0c9916aef1469"}, + {file = "pydantic_core-2.16.2-cp39-none-win_amd64.whl", hash = "sha256:9da90d393a8227d717c19f5397688a38635afec89f2e2d7af0df037f3249c39a"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f60f920691a620b03082692c378661947d09415743e437a7478c309eb0e4f82"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:47924039e785a04d4a4fa49455e51b4eb3422d6eaacfde9fc9abf8fdef164e8a"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6294e76b0380bb7a61eb8a39273c40b20beb35e8c87ee101062834ced19c545"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe56851c3f1d6f5384b3051c536cc81b3a93a73faf931f404fef95217cf1e10d"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9d776d30cde7e541b8180103c3f294ef7c1862fd45d81738d156d00551005784"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:72f7919af5de5ecfaf1eba47bf9a5d8aa089a3340277276e5636d16ee97614d7"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:4bfcbde6e06c56b30668a0c872d75a7ef3025dc3c1823a13cf29a0e9b33f67e8"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ff7c97eb7a29aba230389a2661edf2e9e06ce616c7e35aa764879b6894a44b25"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9b5f13857da99325dcabe1cc4e9e6a3d7b2e2c726248ba5dd4be3e8e4a0b6d0e"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7e41e3ada4cca5f22b478c08e973c930e5e6c7ba3588fb8e35f2398cdcc1545"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60eb8ceaa40a41540b9acae6ae7c1f0a67d233c40dc4359c256ad2ad85bdf5e5"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7beec26729d496a12fd23cf8da9944ee338c8b8a17035a560b585c36fe81af20"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22c5f022799f3cd6741e24f0443ead92ef42be93ffda0d29b2597208c94c3753"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:eca58e319f4fd6df004762419612122b2c7e7d95ffafc37e890252f869f3fb2a"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed957db4c33bc99895f3a1672eca7e80e8cda8bd1e29a80536b4ec2153fa9804"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:459c0d338cc55d099798618f714b21b7ece17eb1a87879f2da20a3ff4c7628e2"}, + {file = "pydantic_core-2.16.2.tar.gz", hash = "sha256:0ba503850d8b8dcc18391f10de896ae51d37fe5fe43dbfb6a35c5c5cad271a06"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pytest" version = "8.0.0" @@ -343,6 +467,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -411,10 +536,11 @@ files = [ ] [extras] +pydantic = ["pydantic"] toml = ["tomli", "tomli-w"] yaml = ["pyyaml"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "babe84c4662a3f7fb04b313fc61736472503b75b1c8cb6e01640e165cdebae5a" +content-hash = "4cfb4856f526ead180b2734cd6d4495752467dd7786b40624e2f5e6ad2b971df" diff --git a/pyproject.toml b/pyproject.toml index 16afd4d7..4a221370 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ typing-extensions = ">=4.5.0" pyyaml = {version = "^6.0.1", optional = true} tomli = {version = "^2.0.1", optional = true} tomli-w = {version = "^1.0.0", optional = true} +pydantic = {version = "^2.6.1", optional = true} [tool.poetry.group.dev.dependencies] pytest = "^8.0.0" @@ -26,6 +27,7 @@ pytest-benchmark = "^4.0.0" [tool.poetry.extras] yaml = ["pyyaml"] toml = ["tomli", "tomli_w"] +pydantic = ["pydantic"] [tool.poetry-dynamic-versioning] diff --git a/simple_parsing/helpers/serialization/__init__.py b/simple_parsing/helpers/serialization/__init__.py index a7667126..cc86945d 100644 --- a/simple_parsing/helpers/serialization/__init__.py +++ b/simple_parsing/helpers/serialization/__init__.py @@ -1,5 +1,5 @@ -from .decoding import * -from .encoding import * +from .decoding import decode_field, get_decoding_fn, register_decoding_fn +from .encoding import SimpleJsonEncoder, encode from .serializable import ( FrozenSerializable, Serializable, @@ -19,9 +19,38 @@ save_yaml, to_dict, ) +from .yaml_schema import save_yaml_with_schema + +JsonSerializable = Serializable try: from .yaml_serialization import YamlSerializable except ImportError: pass -JsonSerializable = Serializable + +__all__ = [ + "JsonSerializable", + "get_decoding_fn", + "register_decoding_fn", + "decode_field", + "SimpleJsonEncoder", + "encode", + "FrozenSerializable", + "Serializable", + "SerializableMixin", + "dump", + "dump_json", + "dump_yaml", + "dumps", + "dumps_json", + "dumps_yaml", + "from_dict", + "load", + "load_json", + "load_yaml", + "save", + "save_json", + "save_yaml", + "to_dict", + "save_yaml_with_schema", +] diff --git a/simple_parsing/helpers/serialization/yaml_schema.py b/simple_parsing/helpers/serialization/yaml_schema.py new file mode 100644 index 00000000..62b8c606 --- /dev/null +++ b/simple_parsing/helpers/serialization/yaml_schema.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import copy +import dataclasses +import inspect +import json +from logging import getLogger as get_logger +from pathlib import Path +from typing import Any, TypeVar + +from typing_extensions import TypeGuard + +from simple_parsing.docstring import get_attribute_docstring, inspect_getdoc +from simple_parsing.helpers.serialization.serializable import dump_yaml +from simple_parsing.utils import Dataclass, PossiblyNestedDict, is_dataclass_type + +logger = get_logger(__name__) + + +def save_yaml_with_schema( + dc: Dataclass, + path: Path, + repo_root: Path | None = Path.cwd(), + generated_schemas_dir: Path | None = None, + gitignore_schemas: bool = True, +) -> Path: + try: + import pydantic + except ModuleNotFoundError: + logger.error("pydantic is required for this feature.") + raise + + json_schema = pydantic.TypeAdapter(type(dc)).json_schema(mode="serialization") + # Add field docstrings as descriptions in the schema! + json_schema = _update_schema_with_descriptions(dc, json_schema=json_schema) + + dc_schema_filename = f"{type(dc).__qualname__}_schema.json" + + if generated_schemas_dir is None: + # Defaults to saving in a .schemas folder next to the config yaml file. + generated_schemas_dir = path.parent / ".schemas" + generated_schemas_dir.mkdir(exist_ok=True, parents=True) + schema_file = generated_schemas_dir / dc_schema_filename + schema_file.write_text(json.dumps(json_schema, indent=2) + "\n") + + if repo_root: + repo_root, _ = _try_make_relative(repo_root, relative_to=Path.cwd()) + generated_schemas_dir, _ = _try_make_relative(generated_schemas_dir, relative_to=repo_root) + + if gitignore_schemas: + # Add a .gitignore in the schemas dir so the schema files aren't tracked by git. + _write_gitignore_file_for_schemas(generated_schemas_dir) + + # Try to write out a relative path to the schema if possible, because we wouldn't want to + # include the absolute paths (e.g. /home/my_user/...) into the config yaml file. + + shema_path_to_write_in_header, success = _try_make_relative(schema_file, path.parent) + + if success: + # The schema is saved in a file relative to the config file, so we just embed the + # *relative* path to the schema as a comment in the first line of the yaml file. + pass + else: + nameof_generated_schemas_dir = f"{generated_schemas_dir=}".partition("=")[0] + logger.warning( + f"Writing the dataclass to a config file at {path} that will include an absolute path " + f"to the schema file. To avoid this, set {nameof_generated_schemas_dir} to `None` or " + f"to a relative path with respect to {path.parent}." + ) + _write_yaml_with_schema_header(dc, path=path, schema_path=shema_path_to_write_in_header) + return schema_file + + +def save_yaml_with_schema_in_vscode_settings( + dc: Dataclass, + path: Path, + repo_root: Path = Path.cwd(), + generated_schemas_dir: Path | None = None, + gitignore_schemas: bool = True, +): + try: + import pydantic + except ModuleNotFoundError: + logger.error("pydantic is required for this feature.") + raise + + json_schema = pydantic.TypeAdapter(type(dc)).json_schema(mode="serialization") + # Add field docstrings as descriptions in the schema! + json_schema = _update_schema_with_descriptions(dc, json_schema=json_schema) + + dc_schema_filename = f"{type(dc).__qualname__}_schema.json" + + if generated_schemas_dir is None: + # Defaults to saving in a .schemas folder next to the config yaml file. + generated_schemas_dir = path.parent / ".schemas" + generated_schemas_dir.mkdir(exist_ok=True, parents=True) + + repo_root, _ = _try_make_relative(repo_root, relative_to=Path.cwd()) + generated_schemas_dir, _ = _try_make_relative(generated_schemas_dir, relative_to=repo_root) + + if gitignore_schemas: + # Add a .gitignore in the schemas dir so the schema files aren't tracked by git. + _write_gitignore_file_for_schemas(generated_schemas_dir) + + schema_file = generated_schemas_dir / dc_schema_filename + schema_file.write_text(json.dumps(json_schema, indent=2) + "\n") + + # We can use a setting in the VsCode editor to associate a schema file with + # a list of config files. + + vscode_dir = repo_root / ".vscode" + vscode_dir.mkdir(exist_ok=True, parents=False) + vscode_settings_file = vscode_dir / "settings.json" + vscode_settings_file.touch() + + try: + vscode_settings: dict[str, Any] = json.loads(vscode_settings_file.read_text()) + except json.decoder.JSONDecodeError: + logger.error("Unable to load the vscode settings file!") + raise + + yaml_schemas_setting: dict[str, str | list[str]] = vscode_settings.setdefault( + "yaml.schemas", {} + ) + + schema_key = str(schema_file.relative_to(repo_root)) + try: + path_to_add = str(path.relative_to(repo_root)) + except ValueError: + path_to_add = str(path) + + files_associated_with_schema: str | list[str] = yaml_schemas_setting.get(schema_key, []) + if isinstance(files_associated_with_schema, str): + existing_value = files_associated_with_schema + files_associated_with_schema = sorted(set([existing_value, path_to_add])) + else: + files_associated_with_schema = sorted(set(files_associated_with_schema + [path_to_add])) + yaml_schemas_setting[schema_key] = files_associated_with_schema + + vscode_settings_file.write_text(json.dumps(vscode_settings, indent=2)) + return schema_file + + +def _write_yaml_with_schema_header(dc: Dataclass, path: Path, schema_path: Path): + with path.open("w") as f: + f.write(f"# yaml-language-server: $schema={schema_path}\n") + dump_yaml(dc, f) + + +def _try_make_relative(p: Path, relative_to: Path) -> tuple[Path, bool]: + try: + return p.relative_to(relative_to), True + except ValueError: + return p, False + + +def _write_gitignore_file_for_schemas(generated_schemas_dir: Path): + gitignore_file = generated_schemas_dir / ".gitignore" + if gitignore_file.exists(): + gitignore_entries = [ + stripped_line + for line in gitignore_file.read_text().splitlines() + if (stripped_line := line.strip()) + ] + else: + gitignore_entries = [] + schema_filename_pattern = "*_schema.json" + if schema_filename_pattern not in gitignore_entries: + gitignore_entries.append(schema_filename_pattern) + gitignore_file.write_text("\n".join(gitignore_entries) + "\n") + + +def _has_default_dataclass_docstring(dc_type: type[Dataclass]) -> bool: + docstring: str | None = inspect_getdoc(dc_type) + return bool(docstring) and docstring.startswith(f"{dc_type.__name__}(") + + +def _get_dc_type_with_name(dataclass_name: str) -> type[Dataclass] | None: + # Get the dataclass type has this classname. + frame = inspect.currentframe() + assert frame + for frame_info in inspect.getouterframes(frame): + if is_dataclass_type(definition_dc_type := frame_info.frame.f_globals.get(dataclass_name)): + return definition_dc_type + return None + + +def _update_schema_with_descriptions( + dc: Dataclass, + json_schema: PossiblyNestedDict[str, str | list[str]], + inplace: bool = True, +): + if not inplace: + json_schema = copy.deepcopy(json_schema) + + if "$defs" in json_schema: + definitions = json_schema["$defs"] + assert isinstance(definitions, dict) + for classname, definition in definitions.items(): + if classname == type(dc).__name__: + definition_dc_type = type(dc) + else: + # Get the dataclass type has this classname. + frame = inspect.currentframe() + assert frame + definition_dc_type = _get_dc_type_with_name(classname) + if not definition_dc_type: + logger.debug( + f"Unable to find the dataclass type for {classname} in the caller globals." + f"Not adding descriptions for this dataclass." + ) + continue + + assert isinstance(definition, dict) + _update_definition_in_schema_using_dc(definition, dc_type=definition_dc_type) + + if "properties" in json_schema: + _update_definition_in_schema_using_dc(json_schema, dc_type=type(dc)) + + return json_schema + + +K = TypeVar("K") +V = TypeVar("V") + + +def is_possibly_nested_dict( + some_dict: Any, k_type: type[K], v_type: type[V] +) -> TypeGuard[PossiblyNestedDict[K, V]]: + return isinstance(some_dict, dict) and all( + isinstance(k, k_type) + and (isinstance(v, v_type) or is_possibly_nested_dict(v, k_type, v_type)) + for k, v in some_dict.items() + ) + + +def _update_definition_in_schema_using_dc(definition: dict[str, Any], dc_type: type[Dataclass]): + # If the class has a docstring that isn't the default one generated by dataclasses, add a + # description. + docstring = inspect_getdoc(dc_type) + if docstring is not None and not _has_default_dataclass_docstring(dc_type): + definition.setdefault("description", docstring) + + if "properties" not in definition: + # Maybe a dataclass without any fields? + return + + assert isinstance(definition["properties"], dict) + dc_fields = {field.name: field for field in dataclasses.fields(dc_type)} + + for property_name, property_values in definition["properties"].items(): + assert isinstance(property_values, dict) + # note: here `property_name` is supposed to be a field of the dataclass. + # double-check just to be sure. + if property_name not in dc_fields: + logger.warning( + RuntimeWarning( + "assuming that properties are dataclass fields, but encountered" + f"property {property_name} which isn't a field of the dataclass {dc_type}" + ) + ) + continue + field_docstring = get_attribute_docstring(dc_type, property_name) + field_desc = field_docstring.help_string.strip() + if field_desc: + property_values.setdefault("description", field_desc) diff --git a/test/helpers/serialization/__init__.py b/test/helpers/serialization/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/helpers/serialization/yaml_schema_test.py b/test/helpers/serialization/yaml_schema_test.py new file mode 100644 index 00000000..6b926506 --- /dev/null +++ b/test/helpers/serialization/yaml_schema_test.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from pathlib import Path + +from pytest_regressions.file_regression import FileRegressionFixture + +from simple_parsing.helpers.serialization.serializable import load_yaml +from simple_parsing.helpers.serialization.yaml_schema import save_yaml_with_schema + +from ...testutils import needs_pydantic + + +@dataclass +class Bob: + """Some docstring.""" + + foo: int = 123 + """A very important field.""" + + +@dataclass +class Nested: + bob: Bob # inline comment for field `bob` of class `Nested` + other_field: str # inline comment for `other_field` of class `Nested` + + +@needs_pydantic +def test_save_with_yaml_schema(tmp_path: Path, file_regression: FileRegressionFixture): + dc = Nested(bob=Bob(foo=222), other_field="babab") + savepath = tmp_path / "nested.yaml" + schemas_dir = tmp_path / ".schemas" + schemas_dir.mkdir() + + schema_file = save_yaml_with_schema(dc, savepath, generated_schemas_dir=schemas_dir) + assert schema_file.exists() + loaded_dc = load_yaml(type(dc), savepath) + assert loaded_dc == dc + # todo: Unsure how I could test that the schema is generated correctly except manually: + file_regression.check(schema_file.read_text() + "\n", extension=".json") diff --git a/test/helpers/serialization/yaml_schema_test/test_save_with_yaml_schema.json b/test/helpers/serialization/yaml_schema_test/test_save_with_yaml_schema.json new file mode 100644 index 00000000..6170c62f --- /dev/null +++ b/test/helpers/serialization/yaml_schema_test/test_save_with_yaml_schema.json @@ -0,0 +1,30 @@ +{ + "$defs": { + "Bob": { + "properties": { + "foo": { + "default": 123, + "title": "Foo", + "type": "integer" + } + }, + "title": "Bob", + "type": "object" + } + }, + "properties": { + "bob": { + "$ref": "#/$defs/Bob" + }, + "other_field": { + "title": "Other Field", + "type": "string" + } + }, + "required": [ + "bob", + "other_field" + ], + "title": "Nested", + "type": "object" +} diff --git a/test/testutils.py b/test/testutils.py index 374df6e5..fb028e12 100644 --- a/test/testutils.py +++ b/test/testutils.py @@ -313,3 +313,12 @@ def format_lists_using_single_quotes(list_of_lists: list[list[Any]]) -> str: raises=ModuleNotFoundError, reason="Test requires tomli and tomli_w to be installed.", ) + +PYDANTIC_INSTALLED = importlib.util.find_spec("pydantic") is not None + +needs_pydantic = pytest.mark.xfail( + not PYDANTIC_INSTALLED, + raises=ModuleNotFoundError, + strict=True, + reason="Test requires pydantic to be installed.", +)