From f9f200c338f035d0345bbff859fe81b871a0d856 Mon Sep 17 00:00:00 2001 From: Fabian Sandoval Saldias Date: Wed, 28 Dec 2022 15:29:14 +0100 Subject: [PATCH 1/2] Sync CLI options with Readme --- README.md | 28 ++++++++++++++++++++-------- pydantic2ts/cli/script.py | 17 ++++++++++------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b2cb494..4fdc3a1 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,26 @@ $ pip install pydantic-to-typescript --- -### CLI - -| Prop | Description | -| :------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ‑‑module | name or filepath of the python module you would like to convert. All the pydantic models within it will be converted to typescript interfaces. Discoverable submodules will also be checked. | -| ‑‑output | name of the file the typescript definitions should be written to. Ex: './frontend/apiTypes.ts' | -| ‑‑exclude | name of a pydantic model which should be omitted from the resulting typescript definitions. This option can be defined multiple times, ex: `--exclude Foo --exclude Bar` to exclude both the Foo and Bar models from the output. | -| ‑‑json2ts‑cmd | optional, the command used to invoke json2ts. The default is 'json2ts'. Specify this if you have it installed locally (ex: 'yarn json2ts') or if the exact path to the executable is required (ex: /myproject/node_modules/bin/json2ts) | +### CLI options + +`--module MODULE` +: name or filepath of the python module you would like to convert. \ +All the pydantic models within it will be converted to typescript interfaces. \ +Discoverable submodules will also be checked. + +`--output OUTPUT` +: name of the file the typescript definitions should be written to. Ex: './frontend/apiTypes.ts' + +`--exclude EXCLUDE` +: name of a pydantic model which should be omitted from the resulting typescript definitions. \ +This option can be defined multiple times, +ex: `--exclude Foo --exclude Bar` to exclude both the Foo and Bar models from the output. \ + +`--json2ts-cmd JSON2TS_CMD` +: optional, the command used to invoke json2ts. \ +Specify this if you have json-schema-to-typescript installed locally (ex: 'yarn json2ts') +or if the exact path to the executable is required (ex: /myproject/node_modules/bin/json2ts). \ +(default: json2ts) --- diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index 8518395..a39cd43 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -240,27 +240,30 @@ def parse_cli_args(args: List[str] = None) -> argparse.Namespace: ) parser.add_argument( "--module", - help="name or filepath of the python module.\n" + help="name or filepath of the python module you would like to convert.\n" + "All the pydantic models within it will be converted to typescript interfaces.\n" "Discoverable submodules will also be checked.", ) parser.add_argument( "--output", - help="name of the file the typescript definitions should be written to.", + help="name of the file the typescript definitions should be written to. Ex: './frontend/apiTypes.ts'", ) parser.add_argument( "--exclude", action="append", default=[], - help="name of a pydantic model which should be omitted from the results.\n" - "This option can be defined multiple times.", + help="name of a pydantic model which should be omitted from the resulting typescript definitions.\n" + "This option can be defined multiple times,\n" + "ex: `--exclude Foo --exclude Bar` to exclude both the Foo and Bar models from the output.", ) parser.add_argument( "--json2ts-cmd", dest="json2ts_cmd", default="json2ts", - help="path to the json-schema-to-typescript executable.\n" - "Provide this if it's not discoverable or if it's only installed locally (example: 'yarn json2ts').\n" - "(default: json2ts)", + help="optional, the command used to invoke json2ts.\n" + "Specify this if you have json-schema-to-typescript installed locally (ex: 'yarn json2ts')\n" + "or if the exact path to the executable is required (ex: /myproject/node_modules/bin/json2ts).\n" + "(default: json2ts)", ) return parser.parse_args(args) From 8444edaa497c63dd1dcc46550e3b8fcf5da6ebea Mon Sep 17 00:00:00 2001 From: Fabian Sandoval Saldias Date: Tue, 1 Nov 2022 15:23:35 -0700 Subject: [PATCH 2/2] Add --readonly-interfaces option (#28) --- README.md | 5 +++ pydantic2ts/cli/script.py | 37 +++++++++++++--- tests/expected_results/optionals/README.md | 35 +++++++++++++++ tests/expected_results/optionals/input.py | 43 +++++++++++++++++++ tests/expected_results/optionals/output.ts | 32 ++++++++++++++ .../optionals/output_readonly_interfaces.ts | 32 ++++++++++++++ .../output_readonly_interfaces_with_null.ts | 32 ++++++++++++++ .../optionals/output_with_null.ts | 32 ++++++++++++++ tests/test_script.py | 38 +++++++++++++--- 9 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 tests/expected_results/optionals/README.md create mode 100644 tests/expected_results/optionals/input.py create mode 100644 tests/expected_results/optionals/output.ts create mode 100644 tests/expected_results/optionals/output_readonly_interfaces.ts create mode 100644 tests/expected_results/optionals/output_readonly_interfaces_with_null.ts create mode 100644 tests/expected_results/optionals/output_with_null.ts diff --git a/README.md b/README.md index 4fdc3a1..0861913 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ Discoverable submodules will also be checked. This option can be defined multiple times, ex: `--exclude Foo --exclude Bar` to exclude both the Foo and Bar models from the output. \ +`--readonly-interfaces` +: do not mark non-optional properties with default values as optional in the generated interfaces. \ +This is useful if you want an interface for data that is returned by an API (default values are not empty), +in contrast to an interface for data that is sent to an API (default values may be empty). + `--json2ts-cmd JSON2TS_CMD` : optional, the command used to invoke json2ts. \ Specify this if you have json-schema-to-typescript installed locally (ex: 'yarn json2ts') diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index a39cd43..2a0732f 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -9,10 +9,11 @@ from importlib.util import module_from_spec, spec_from_file_location from tempfile import mkdtemp from types import ModuleType -from typing import Any, Dict, List, Tuple, Type +from typing import Any, Dict, List, Tuple, Type, Optional from uuid import uuid4 from pydantic import BaseModel, Extra, create_model +from pydantic.fields import ModelField try: from pydantic.generics import GenericModel @@ -141,7 +142,7 @@ def clean_schema(schema: Dict[str, Any]) -> None: del schema["description"] -def generate_json_schema(models: List[Type[BaseModel]]) -> str: +def generate_json_schema(models: List[Type[BaseModel]], readonly_interfaces: bool) -> str: """ Create a top-level '_Master_' model with references to each of the actual models. Generate the schema for this model, which will include the schemas for all the @@ -152,6 +153,13 @@ def generate_json_schema(models: List[Type[BaseModel]]) -> str: '[k: string]: any' from being added to every interface. This change is reverted once the schema has been generated. """ + + def find_model(name: str) -> Optional[Type[BaseModel]]: + return next((m for m in models if m.__name__ == name), None) + + def find_field(prop: str, model_: Type[BaseModel]) -> ModelField: + return next(f for f in model_.__fields__.values() if f.alias == prop) + model_extras = [getattr(m.Config, "extra", None) for m in models] try: @@ -170,6 +178,12 @@ def generate_json_schema(models: List[Type[BaseModel]]) -> str: for d in schema.get("definitions", {}).values(): clean_schema(d) + if readonly_interfaces: + model = find_model(d["title"]) + if model is not None: + props = d.get("properties", {}).keys() + d["required"] = list(prop for prop in props if not find_field(prop, model).allow_none) + return json.dumps(schema, indent=2) finally: @@ -179,7 +193,7 @@ def generate_json_schema(models: List[Type[BaseModel]]) -> str: def generate_typescript_defs( - module: str, output: str, exclude: Tuple[str] = (), json2ts_cmd: str = "json2ts" + module: str, output: str, exclude: Tuple[str] = (), readonly_interfaces: bool = False, json2ts_cmd: str = "json2ts", ) -> None: """ Convert the pydantic models in a python module into typescript interfaces. @@ -189,6 +203,8 @@ def generate_typescript_defs( :param exclude: optional, a tuple of names for pydantic models which should be omitted from the typescript output. :param json2ts_cmd: optional, the command that will execute json2ts. Provide this if the executable is not discoverable or if it's locally installed (ex: 'yarn json2ts'). + :param readonly_interfaces: optional, do not mark non-optional properties with default values as optional + in the generated interfaces. """ if " " not in json2ts_cmd and not shutil.which(json2ts_cmd): raise Exception( @@ -205,7 +221,7 @@ def generate_typescript_defs( logger.info("Generating JSON schema from pydantic models...") - schema = generate_json_schema(models) + schema = generate_json_schema(models, readonly_interfaces) schema_dir = mkdtemp() schema_file_path = os.path.join(schema_dir, "schema.json") @@ -256,6 +272,14 @@ def parse_cli_args(args: List[str] = None) -> argparse.Namespace: "This option can be defined multiple times,\n" "ex: `--exclude Foo --exclude Bar` to exclude both the Foo and Bar models from the output.", ) + parser.add_argument( + "--readonly-interfaces", + dest="readonly_interfaces", + action="store_true", + help="do not mark non-optional properties with default values as optional in the generated interfaces.\n" + "This is useful if you want an interface for data that is returned by an API (default values are not empty),\n" + "in contrast to an interface for data that is sent to an API (default values may be empty).", + ) parser.add_argument( "--json2ts-cmd", dest="json2ts_cmd", @@ -277,8 +301,9 @@ def main() -> None: return generate_typescript_defs( args.module, args.output, - tuple(args.exclude), - args.json2ts_cmd, + exclude=tuple(args.exclude), + readonly_interfaces=args.readonly_interfaces, + json2ts_cmd=args.json2ts_cmd, ) diff --git a/tests/expected_results/optionals/README.md b/tests/expected_results/optionals/README.md new file mode 100644 index 0000000..0892242 --- /dev/null +++ b/tests/expected_results/optionals/README.md @@ -0,0 +1,35 @@ +# optionals test + +Output files with `_readonly_interfaces` in its name reflect the bahaviour of the `--readonly-interfaces` CLI option. + +Output files with `_with_null` in its name reflect the bahaviour of the `--with-null` CLI option, +that is not yet available. We would at least probably need +[this PR](https://github.com/bcherny/json-schema-to-typescript/pull/411) of our json-schema-to-typescript dependency. + +## Options summary + +| | write to API (w/o null) | read from API (w/o null) | write to API (normal) | read from API (normal) | +|-----------------------|-------------------------|--------------------------|------------------------------|-------------------------------------| +| CLI options | n/a | `--readonly-interfaces` | `--with-null` | `--readonly-interfaces --with-null` | +| required | `field: string` | `field: string` | `field: string` | `field: string` | +| required with default | `field?: string` | `field: string` | `field?: string` | `field: string` | +| optional | `field?: string` | `field?: string` | `field: string | null` | `field: string | null` | +| optional with default | `field?: string` | `field?: string` | `field?: string | null` | `field: string | null` | + +## Explanations + +write to API +: Interfaces are meant for data beeing parsed by pydantic (non-optional fields with default values may be omitted or `null`). +You may use those interfaces for reading too, but the developer would need to know +which fields cannot be `undefined` at which point in the code and use `field!` to access them. +Otherwise, you'd probably need superflous checks against `undefined`. + +read from API +: Interfaces are meant for data generated by pydantic (non-optional fields with default values are present). + +w/o null +: Optional fields that are `None` in Python are not present in JSON, +i.e. `undefined` in JavaScript (instead of beeing `null`). +To support this behaviour in your API, you need to serialize your models with `exclude_none=True`, +i.e. `model.json(exclude_none=True)` or `model.dict(exclude_none=True)`. +But you will not be able to set optional fields to `None`, if they have a default value other than `None`. diff --git a/tests/expected_results/optionals/input.py b/tests/expected_results/optionals/input.py new file mode 100644 index 0000000..cb5afd1 --- /dev/null +++ b/tests/expected_results/optionals/input.py @@ -0,0 +1,43 @@ +from enum import Enum +from typing import Optional, Union + +import pydantic +from pydantic import BaseModel + + +class SomeEnum(str, Enum): + a = "a" + b = "b" + + +class SomeModel(BaseModel): + some_optional: Optional[str] + + +class Foo(BaseModel): + required: str + + default: str = "foo" + default_factory: str = pydantic.Field(default_factory=lambda: "foo") + default_alias: str = pydantic.Field("foo", alias="default_alias_renamed") + + optional: Optional[str] + # TODO: This gets non-optional in output.ts, but should better be optional + # when assuming that null fields are removed from the JSON representation. + optional_nullable: Optional[str] = pydantic.Field(..., nullable=True) + optional_nullable_default: Optional[str] = pydantic.Field("foo", nullable=True) + optional_nullable_default_none: Optional[str] = pydantic.Field(None, nullable=True) + optional_default: Optional[str] = "foo" + optional_default_none: Optional[str] = None + union: Union[str, None] + union_default: Union[str, None] = "foo" + union_default_none: Union[str, None] = None + optional_union: Union[int, Optional[str]] + optional_union_default: Union[int, Optional[str]] = "foo" + optional_union_default_none: Union[int, Optional[str]] = None + + # Force producing a schema definition without a module + # to test the case where find_model() returns None. + some_enum: SomeEnum + + some_optional_non_primitive: Optional[SomeModel] diff --git a/tests/expected_results/optionals/output.ts b/tests/expected_results/optionals/output.ts new file mode 100644 index 0000000..9ade088 --- /dev/null +++ b/tests/expected_results/optionals/output.ts @@ -0,0 +1,32 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export type SomeEnum = "a" | "b"; + +export interface Foo { + required: string; + default?: string; + default_factory?: string; + default_alias_renamed?: string; + optional?: string; + optional_nullable: string; + optional_nullable_default?: string; + optional_nullable_default_none?: string; + optional_default?: string; + optional_default_none?: string; + union?: string; + union_default?: string; + union_default_none?: string; + optional_union?: number | string; + optional_union_default?: number | string; + optional_union_default_none?: number | string; + some_enum: SomeEnum; + some_optional_non_primitive?: SomeModel; +} +export interface SomeModel { + some_optional?: string; +} diff --git a/tests/expected_results/optionals/output_readonly_interfaces.ts b/tests/expected_results/optionals/output_readonly_interfaces.ts new file mode 100644 index 0000000..0e9174d --- /dev/null +++ b/tests/expected_results/optionals/output_readonly_interfaces.ts @@ -0,0 +1,32 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export type SomeEnum = "a" | "b"; + +export interface Foo { + required: string; + default: string; + default_factory: string; + default_alias_renamed: string; + optional?: string; + optional_nullable?: string; + optional_nullable_default?: string; + optional_nullable_default_none?: string; + optional_default?: string; + optional_default_none?: string; + union?: string; + union_default?: string; + union_default_none?: string; + optional_union?: number | string; + optional_union_default?: number | string; + optional_union_default_none?: number | string; + some_enum: SomeEnum; + some_optional_non_primitive?: SomeModel; +} +export interface SomeModel { + some_optional?: string; +} diff --git a/tests/expected_results/optionals/output_readonly_interfaces_with_null.ts b/tests/expected_results/optionals/output_readonly_interfaces_with_null.ts new file mode 100644 index 0000000..ac9d850 --- /dev/null +++ b/tests/expected_results/optionals/output_readonly_interfaces_with_null.ts @@ -0,0 +1,32 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export type SomeEnum = "a" | "b"; + +export interface Foo { + required: string; + default: string; + default_factory: string; + default_alias_renamed: string; + optional: string | null; + optional_nullable: string | null; + optional_nullable_default: string | null; + optional_nullable_default_none: string | null; + optional_default: string | null; + optional_default_none: string | null; + union: string | null; + union_default: string | null; + union_default_none: string | null; + optional_union: number | string | null; + optional_union_default: number | string | null; + optional_union_default_none: number | string | null; + some_enum: SomeEnum; + some_optional_non_primitive?: SomeModel; +} +export interface SomeModel { + some_optional?: string; +} diff --git a/tests/expected_results/optionals/output_with_null.ts b/tests/expected_results/optionals/output_with_null.ts new file mode 100644 index 0000000..5e45bc3 --- /dev/null +++ b/tests/expected_results/optionals/output_with_null.ts @@ -0,0 +1,32 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export type SomeEnum = "a" | "b"; + +export interface Foo { + required: string; + default?: string; + default_factory?: string; + default_alias_renamed?: string; + optional: string | null; + optional_nullable: string | null; + optional_nullable_default?: string | null; + optional_nullable_default_none?: string | null; + optional_default?: string | null; + optional_default_none?: string | null; + union: string | null; + union_default?: string | null; + union_default_none?: string | null; + optional_union: number | string | null; + optional_union_default?: number | string | null; + optional_union_default_none?: number | string | null; + some_enum: SomeEnum; + some_optional_non_primitive?: SomeModel; +} +export interface SomeModel { + some_optional?: string; +} diff --git a/tests/test_script.py b/tests/test_script.py index 8ff3e1f..824a5f2 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -16,14 +16,14 @@ def get_input_module(test_name: str) -> str: return os.path.join(_results_directory(), test_name, "input.py") -def get_expected_output(test_name: str) -> str: - path = os.path.join(_results_directory(), test_name, "output.ts") +def get_expected_output(test_name: str, output_suffix: str) -> str: + path = os.path.join(_results_directory(), test_name, f"output{output_suffix}.ts") with open(path, "r") as f: return f.read() def run_test( - tmpdir, test_name, *, module_path=None, call_from_python=False, exclude=() + tmpdir, test_name, *, module_path=None, call_from_python=False, exclude=(), readonly_interfaces: bool = False ): """ Execute pydantic2ts logic for converting pydantic models into tyepscript definitions. @@ -33,16 +33,20 @@ def run_test( output_path = tmpdir.join(f"cli_{test_name}.ts").strpath if call_from_python: - generate_typescript_defs(module_path, output_path, exclude) + generate_typescript_defs(module_path, output_path, exclude=exclude, readonly_interfaces=readonly_interfaces) else: cmd = f"pydantic2ts --module {module_path} --output {output_path}" for model_to_exclude in exclude: cmd += f" --exclude {model_to_exclude}" + if readonly_interfaces: + cmd += " --readonly-interfaces" subprocess.run(cmd, shell=True) with open(output_path, "r") as f: output = f.read() - assert output == get_expected_output(test_name) + output_suffix = "" + if readonly_interfaces: output_suffix = "_readonly_interfaces" + assert output == get_expected_output(test_name, output_suffix) def test_single_module(tmpdir): @@ -68,6 +72,18 @@ def test_generics(tmpdir): run_test(tmpdir, "generics") +def test_optionals_read(tmpdir): + run_test( + tmpdir, "optionals", readonly_interfaces=True + ) + + +def test_optionals_write(tmpdir): + run_test( + tmpdir, "optionals", readonly_interfaces=False + ) + + def test_excluding_models(tmpdir): run_test( tmpdir, "excluding_models", exclude=("LoginCredentials", "LoginResponseData") @@ -92,6 +108,18 @@ def test_calling_from_python(tmpdir): run_test(tmpdir, "submodules", call_from_python=True) if sys.version_info >= (3, 7): run_test(tmpdir, "generics", call_from_python=True) + run_test( + tmpdir, + "optionals", + call_from_python=True, + readonly_interfaces=True, + ) + run_test( + tmpdir, + "optionals", + call_from_python=True, + readonly_interfaces=False, + ) run_test( tmpdir, "excluding_models",