Skip to content

Add --readonly-interfaces option #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,31 @@ $ 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. \

`--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')
or if the exact path to the executable is required (ex: /myproject/node_modules/bin/json2ts). \
(default: json2ts)

---

Expand Down
54 changes: 41 additions & 13 deletions pydantic2ts/cli/script.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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.
Expand All @@ -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(
Expand All @@ -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")

Expand Down Expand Up @@ -240,27 +256,38 @@ 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(
"--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",
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)

Expand All @@ -274,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,
)


Expand Down
35 changes: 35 additions & 0 deletions tests/expected_results/optionals/README.md
Original file line number Diff line number Diff line change
@@ -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`.
43 changes: 43 additions & 0 deletions tests/expected_results/optionals/input.py
Original file line number Diff line number Diff line change
@@ -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]
32 changes: 32 additions & 0 deletions tests/expected_results/optionals/output.ts
Original file line number Diff line number Diff line change
@@ -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;
}
32 changes: 32 additions & 0 deletions tests/expected_results/optionals/output_readonly_interfaces.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
32 changes: 32 additions & 0 deletions tests/expected_results/optionals/output_with_null.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading