Skip to content

Commit 8444eda

Browse files
committed
Add --readonly-interfaces option (#28)
1 parent f9f200c commit 8444eda

File tree

9 files changed

+275
-11
lines changed

9 files changed

+275
-11
lines changed

README.md

+5
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ Discoverable submodules will also be checked.
3131
This option can be defined multiple times,
3232
ex: `--exclude Foo --exclude Bar` to exclude both the Foo and Bar models from the output. \
3333

34+
`--readonly-interfaces`
35+
: do not mark non-optional properties with default values as optional in the generated interfaces. \
36+
This is useful if you want an interface for data that is returned by an API (default values are not empty),
37+
in contrast to an interface for data that is sent to an API (default values may be empty).
38+
3439
`--json2ts-cmd JSON2TS_CMD`
3540
: optional, the command used to invoke json2ts. \
3641
Specify this if you have json-schema-to-typescript installed locally (ex: 'yarn json2ts')

pydantic2ts/cli/script.py

+31-6
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
from importlib.util import module_from_spec, spec_from_file_location
1010
from tempfile import mkdtemp
1111
from types import ModuleType
12-
from typing import Any, Dict, List, Tuple, Type
12+
from typing import Any, Dict, List, Tuple, Type, Optional
1313
from uuid import uuid4
1414

1515
from pydantic import BaseModel, Extra, create_model
16+
from pydantic.fields import ModelField
1617

1718
try:
1819
from pydantic.generics import GenericModel
@@ -141,7 +142,7 @@ def clean_schema(schema: Dict[str, Any]) -> None:
141142
del schema["description"]
142143

143144

144-
def generate_json_schema(models: List[Type[BaseModel]]) -> str:
145+
def generate_json_schema(models: List[Type[BaseModel]], readonly_interfaces: bool) -> str:
145146
"""
146147
Create a top-level '_Master_' model with references to each of the actual models.
147148
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:
152153
'[k: string]: any' from being added to every interface. This change is reverted
153154
once the schema has been generated.
154155
"""
156+
157+
def find_model(name: str) -> Optional[Type[BaseModel]]:
158+
return next((m for m in models if m.__name__ == name), None)
159+
160+
def find_field(prop: str, model_: Type[BaseModel]) -> ModelField:
161+
return next(f for f in model_.__fields__.values() if f.alias == prop)
162+
155163
model_extras = [getattr(m.Config, "extra", None) for m in models]
156164

157165
try:
@@ -170,6 +178,12 @@ def generate_json_schema(models: List[Type[BaseModel]]) -> str:
170178
for d in schema.get("definitions", {}).values():
171179
clean_schema(d)
172180

181+
if readonly_interfaces:
182+
model = find_model(d["title"])
183+
if model is not None:
184+
props = d.get("properties", {}).keys()
185+
d["required"] = list(prop for prop in props if not find_field(prop, model).allow_none)
186+
173187
return json.dumps(schema, indent=2)
174188

175189
finally:
@@ -179,7 +193,7 @@ def generate_json_schema(models: List[Type[BaseModel]]) -> str:
179193

180194

181195
def generate_typescript_defs(
182-
module: str, output: str, exclude: Tuple[str] = (), json2ts_cmd: str = "json2ts"
196+
module: str, output: str, exclude: Tuple[str] = (), readonly_interfaces: bool = False, json2ts_cmd: str = "json2ts",
183197
) -> None:
184198
"""
185199
Convert the pydantic models in a python module into typescript interfaces.
@@ -189,6 +203,8 @@ def generate_typescript_defs(
189203
:param exclude: optional, a tuple of names for pydantic models which should be omitted from the typescript output.
190204
:param json2ts_cmd: optional, the command that will execute json2ts. Provide this if the executable is not
191205
discoverable or if it's locally installed (ex: 'yarn json2ts').
206+
:param readonly_interfaces: optional, do not mark non-optional properties with default values as optional
207+
in the generated interfaces.
192208
"""
193209
if " " not in json2ts_cmd and not shutil.which(json2ts_cmd):
194210
raise Exception(
@@ -205,7 +221,7 @@ def generate_typescript_defs(
205221

206222
logger.info("Generating JSON schema from pydantic models...")
207223

208-
schema = generate_json_schema(models)
224+
schema = generate_json_schema(models, readonly_interfaces)
209225
schema_dir = mkdtemp()
210226
schema_file_path = os.path.join(schema_dir, "schema.json")
211227

@@ -256,6 +272,14 @@ def parse_cli_args(args: List[str] = None) -> argparse.Namespace:
256272
"This option can be defined multiple times,\n"
257273
"ex: `--exclude Foo --exclude Bar` to exclude both the Foo and Bar models from the output.",
258274
)
275+
parser.add_argument(
276+
"--readonly-interfaces",
277+
dest="readonly_interfaces",
278+
action="store_true",
279+
help="do not mark non-optional properties with default values as optional in the generated interfaces.\n"
280+
"This is useful if you want an interface for data that is returned by an API (default values are not empty),\n"
281+
"in contrast to an interface for data that is sent to an API (default values may be empty).",
282+
)
259283
parser.add_argument(
260284
"--json2ts-cmd",
261285
dest="json2ts_cmd",
@@ -277,8 +301,9 @@ def main() -> None:
277301
return generate_typescript_defs(
278302
args.module,
279303
args.output,
280-
tuple(args.exclude),
281-
args.json2ts_cmd,
304+
exclude=tuple(args.exclude),
305+
readonly_interfaces=args.readonly_interfaces,
306+
json2ts_cmd=args.json2ts_cmd,
282307
)
283308

284309

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# optionals test
2+
3+
Output files with `_readonly_interfaces` in its name reflect the bahaviour of the `--readonly-interfaces` CLI option.
4+
5+
Output files with `_with_null` in its name reflect the bahaviour of the `--with-null` CLI option,
6+
that is not yet available. We would at least probably need
7+
[this PR](https://github.com/bcherny/json-schema-to-typescript/pull/411) of our json-schema-to-typescript dependency.
8+
9+
## Options summary
10+
11+
| | write to API (w/o null) | read from API (w/o null) | write to API (normal) | read from API (normal) |
12+
|-----------------------|-------------------------|--------------------------|------------------------------|-------------------------------------|
13+
| CLI options | n/a | `--readonly-interfaces` | `--with-null` | `--readonly-interfaces --with-null` |
14+
| required | `field: string` | `field: string` | `field: string` | `field: string` |
15+
| required with default | `field?: string` | `field: string` | `field?: string` | `field: string` |
16+
| optional | `field?: string` | `field?: string` | `field: string | null` | `field: string | null` |
17+
| optional with default | `field?: string` | `field?: string` | `field?: string | null` | `field: string | null` |
18+
19+
## Explanations
20+
21+
write to API
22+
: Interfaces are meant for data beeing parsed by pydantic (non-optional fields with default values may be omitted or `null`).
23+
You may use those interfaces for reading too, but the developer would need to know
24+
which fields cannot be `undefined` at which point in the code and use `field!` to access them.
25+
Otherwise, you'd probably need superflous checks against `undefined`.
26+
27+
read from API
28+
: Interfaces are meant for data generated by pydantic (non-optional fields with default values are present).
29+
30+
w/o null
31+
: Optional fields that are `None` in Python are not present in JSON,
32+
i.e. `undefined` in JavaScript (instead of beeing `null`).
33+
To support this behaviour in your API, you need to serialize your models with `exclude_none=True`,
34+
i.e. `model.json(exclude_none=True)` or `model.dict(exclude_none=True)`.
35+
But you will not be able to set optional fields to `None`, if they have a default value other than `None`.
+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from enum import Enum
2+
from typing import Optional, Union
3+
4+
import pydantic
5+
from pydantic import BaseModel
6+
7+
8+
class SomeEnum(str, Enum):
9+
a = "a"
10+
b = "b"
11+
12+
13+
class SomeModel(BaseModel):
14+
some_optional: Optional[str]
15+
16+
17+
class Foo(BaseModel):
18+
required: str
19+
20+
default: str = "foo"
21+
default_factory: str = pydantic.Field(default_factory=lambda: "foo")
22+
default_alias: str = pydantic.Field("foo", alias="default_alias_renamed")
23+
24+
optional: Optional[str]
25+
# TODO: This gets non-optional in output.ts, but should better be optional
26+
# when assuming that null fields are removed from the JSON representation.
27+
optional_nullable: Optional[str] = pydantic.Field(..., nullable=True)
28+
optional_nullable_default: Optional[str] = pydantic.Field("foo", nullable=True)
29+
optional_nullable_default_none: Optional[str] = pydantic.Field(None, nullable=True)
30+
optional_default: Optional[str] = "foo"
31+
optional_default_none: Optional[str] = None
32+
union: Union[str, None]
33+
union_default: Union[str, None] = "foo"
34+
union_default_none: Union[str, None] = None
35+
optional_union: Union[int, Optional[str]]
36+
optional_union_default: Union[int, Optional[str]] = "foo"
37+
optional_union_default_none: Union[int, Optional[str]] = None
38+
39+
# Force producing a schema definition without a module
40+
# to test the case where find_model() returns None.
41+
some_enum: SomeEnum
42+
43+
some_optional_non_primitive: Optional[SomeModel]
+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* tslint:disable */
2+
/* eslint-disable */
3+
/**
4+
/* This file was automatically generated from pydantic models by running pydantic2ts.
5+
/* Do not modify it by hand - just update the pydantic models and then re-run the script
6+
*/
7+
8+
export type SomeEnum = "a" | "b";
9+
10+
export interface Foo {
11+
required: string;
12+
default?: string;
13+
default_factory?: string;
14+
default_alias_renamed?: string;
15+
optional?: string;
16+
optional_nullable: string;
17+
optional_nullable_default?: string;
18+
optional_nullable_default_none?: string;
19+
optional_default?: string;
20+
optional_default_none?: string;
21+
union?: string;
22+
union_default?: string;
23+
union_default_none?: string;
24+
optional_union?: number | string;
25+
optional_union_default?: number | string;
26+
optional_union_default_none?: number | string;
27+
some_enum: SomeEnum;
28+
some_optional_non_primitive?: SomeModel;
29+
}
30+
export interface SomeModel {
31+
some_optional?: string;
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* tslint:disable */
2+
/* eslint-disable */
3+
/**
4+
/* This file was automatically generated from pydantic models by running pydantic2ts.
5+
/* Do not modify it by hand - just update the pydantic models and then re-run the script
6+
*/
7+
8+
export type SomeEnum = "a" | "b";
9+
10+
export interface Foo {
11+
required: string;
12+
default: string;
13+
default_factory: string;
14+
default_alias_renamed: string;
15+
optional?: string;
16+
optional_nullable?: string;
17+
optional_nullable_default?: string;
18+
optional_nullable_default_none?: string;
19+
optional_default?: string;
20+
optional_default_none?: string;
21+
union?: string;
22+
union_default?: string;
23+
union_default_none?: string;
24+
optional_union?: number | string;
25+
optional_union_default?: number | string;
26+
optional_union_default_none?: number | string;
27+
some_enum: SomeEnum;
28+
some_optional_non_primitive?: SomeModel;
29+
}
30+
export interface SomeModel {
31+
some_optional?: string;
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* tslint:disable */
2+
/* eslint-disable */
3+
/**
4+
/* This file was automatically generated from pydantic models by running pydantic2ts.
5+
/* Do not modify it by hand - just update the pydantic models and then re-run the script
6+
*/
7+
8+
export type SomeEnum = "a" | "b";
9+
10+
export interface Foo {
11+
required: string;
12+
default: string;
13+
default_factory: string;
14+
default_alias_renamed: string;
15+
optional: string | null;
16+
optional_nullable: string | null;
17+
optional_nullable_default: string | null;
18+
optional_nullable_default_none: string | null;
19+
optional_default: string | null;
20+
optional_default_none: string | null;
21+
union: string | null;
22+
union_default: string | null;
23+
union_default_none: string | null;
24+
optional_union: number | string | null;
25+
optional_union_default: number | string | null;
26+
optional_union_default_none: number | string | null;
27+
some_enum: SomeEnum;
28+
some_optional_non_primitive?: SomeModel;
29+
}
30+
export interface SomeModel {
31+
some_optional?: string;
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* tslint:disable */
2+
/* eslint-disable */
3+
/**
4+
/* This file was automatically generated from pydantic models by running pydantic2ts.
5+
/* Do not modify it by hand - just update the pydantic models and then re-run the script
6+
*/
7+
8+
export type SomeEnum = "a" | "b";
9+
10+
export interface Foo {
11+
required: string;
12+
default?: string;
13+
default_factory?: string;
14+
default_alias_renamed?: string;
15+
optional: string | null;
16+
optional_nullable: string | null;
17+
optional_nullable_default?: string | null;
18+
optional_nullable_default_none?: string | null;
19+
optional_default?: string | null;
20+
optional_default_none?: string | null;
21+
union: string | null;
22+
union_default?: string | null;
23+
union_default_none?: string | null;
24+
optional_union: number | string | null;
25+
optional_union_default?: number | string | null;
26+
optional_union_default_none?: number | string | null;
27+
some_enum: SomeEnum;
28+
some_optional_non_primitive?: SomeModel;
29+
}
30+
export interface SomeModel {
31+
some_optional?: string;
32+
}

0 commit comments

Comments
 (0)