diff --git a/README.md b/README.md index 02e3e57..95df953 100644 --- a/README.md +++ b/README.md @@ -12,36 +12,37 @@ Useful for any scenario in which python and javascript applications are interact This tool requires that you have the lovely json2ts CLI utility installed. Instructions can be found here: https://www.npmjs.com/package/json-schema-to-typescript -### Installation +## Installation ```bash -$ pip install pydantic-to-typescript +pip install pydantic-to-typescript ``` -### Pydantic V2 support +## Pydantic V2 support If you are encountering issues with `pydantic>2`, it is most likely because you're using an old version of `pydantic-to-typescript`. Run `pip install 'pydantic-to-typescript>2'` and/or add `pydantic-to-typescript>=2` to your project requirements. -### CI/CD +## CI/CD You can now use `pydantic-to-typescript` to automatically validate and/or update typescript definitions as part of your CI/CD pipeline. The github action can be found here: https://github.com/marketplace/actions/pydantic-to-typescript. The available inputs are documented here: https://github.com/phillipdupuis/pydantic-to-typescript/blob/master/action.yml. -### CLI +## 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) | +| 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) | +| ‑‑all‑fields‑required | optional (off by default). Treats all fields as required (present) in the generated TypeScript interfaces. | --- -### Usage +## Usage Define your pydantic models (ex: /backend/api.py): @@ -74,13 +75,13 @@ def login(body: LoginCredentials): Execute the command for converting these models into typescript definitions, via: ```bash -$ pydantic2ts --module backend.api --output ./frontend/apiTypes.ts +pydantic2ts --module backend.api --output ./frontend/apiTypes.ts ``` or: ```bash -$ pydantic2ts --module ./backend/api.py --output ./frontend/apiTypes.ts +pydantic2ts --module ./backend/api.py --output ./frontend/apiTypes.ts ``` or: @@ -138,3 +139,65 @@ async function login( } } ``` + +### Treating all fields as required + +If you would like to treat all fields as required in the generated TypeScript interfaces, you can use the `--all-fields-required` flag. + +This is useful, for example, when representing a response from your Python backend API—since Pydantic will populate any missing fields with defaults before sending the response. + +#### Example + +```python +from pydantic import BaseModel, Field +from typing import Annotated, Literal, Optional + +class ExampleModel(BaseModel): + literal_str_with_default: Literal["c"] = "c" + int_with_default: int = 1 + int_with_pydantic_default: Annotated[int, Field(default=2)] + int_list_with_default_factory: Annotated[list[int], Field(default_factory=list)] + nullable_int: Optional[int] + nullable_int_with_default: Optional[int] = 3 + nullable_int_with_null_default: Optional[int] = None +``` + +Executing with `--all-fields-required`: + +```bash +pydantic2ts --module backend.api --output ./frontend/apiTypes.ts --all-fields-required +``` + +```ts +export interface ExampleModel { + literal_str_with_default: "c"; + int_with_default: number; + int_with_pydantic_default: number; + int_list_with_default_factory: number[]; + nullable_int: number | null; + nullable_int_with_default: number | null; + nullable_int_with_null_default: number | null; +} +``` + +Executing without `--all-fields-required`: + +```bash +pydantic2ts --module backend.api --output ./frontend/apiTypes.ts +``` + +```ts +export interface ExampleModel { + literal_str_with_default?: "c"; + int_with_default?: number; + int_with_pydantic_default?: number; + int_list_with_default_factory?: number[]; + nullable_int: number | null; // optional if Pydantic V1 + nullable_int_with_default?: number | null; + nullable_int_with_null_default?: number | null; +} +``` + +> [!NOTE] +> If you're using Pydantic V1, `nullable_int` will also be optional (`nullable_int?: number | null`) when executing without `--all-fields-required`. See [Pydantic docs](https://docs.pydantic.dev/2.10/concepts/models/#required-fields): +> > In Pydantic V1, fields annotated with `Optional` or `Any` would be given an implicit default of `None` even if no default was explicitly specified. This behavior has changed in Pydantic V2, and there are no longer any type annotations that will result in a field having an implicit default value. diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index 76fc9a9..27b116d 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -158,7 +158,9 @@ def _extract_pydantic_models(module: ModuleType) -> List[type]: return models -def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None: +def _clean_json_schema( + schema: Dict[str, Any], model: Any = None, all_fields_required: bool = False +) -> None: """ Clean up the resulting JSON schemas via the following steps: @@ -170,6 +172,9 @@ def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None: resulting typescript file (which is a LOT of unnecessary noise). 3) If it's a V1 model, ensure that nullability is properly represented. https://github.com/pydantic/pydantic/issues/1270 + 4) If all_fields_required is True, ensure that all properties are included in the + "required" list of the schema, so they don't get marked as optional in the + resulting typescript definitions. """ description = schema.get("description") @@ -198,6 +203,16 @@ def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None: exc_info=True, ) + if all_fields_required: + _treat_all_fields_as_required(schema) + + +def _treat_all_fields_as_required(schema: Dict[str, Any]) -> None: + required_properties = schema.setdefault("required", []) + for prop_name in schema.get("properties", {}).keys(): + if prop_name not in required_properties: + required_properties.append(prop_name) + def _clean_output_file(output_filename: str) -> None: """ @@ -266,7 +281,7 @@ def _schema_generation_overrides( setattr(config, key, value) -def _generate_json_schema(models: List[type]) -> str: +def _generate_json_schema(models: List[type], all_fields_required: bool = False) -> 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 @@ -291,7 +306,9 @@ def _generate_json_schema(models: List[type]) -> str: defs: Dict[str, Any] = master_schema.get(defs_key, {}) for name, schema in defs.items(): - _clean_json_schema(schema, models_by_name.get(name)) + _clean_json_schema( + schema, models_by_name.get(name), all_fields_required=all_fields_required + ) return json.dumps(master_schema, indent=2) @@ -301,6 +318,7 @@ def generate_typescript_defs( output: str, exclude: Tuple[str, ...] = (), json2ts_cmd: str = "json2ts", + all_fields_required: bool = False, ) -> None: """ Convert the pydantic models in a python module into typescript interfaces. @@ -313,6 +331,9 @@ def generate_typescript_defs( :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 all_fields_required: optional, treat all model fields (including + those with defaults) as required in generated + TypeScript definitions. """ if " " not in json2ts_cmd and not shutil.which(json2ts_cmd): raise Exception( @@ -335,7 +356,7 @@ def generate_typescript_defs( LOG.info("Generating JSON schema from pydantic models...") - schema = _generate_json_schema(models) + schema = _generate_json_schema(models, all_fields_required=all_fields_required) schema_dir = mkdtemp() schema_file_path = os.path.join(schema_dir, "schema.json") @@ -392,6 +413,12 @@ def parse_cli_args(args: Optional[List[str]] = None) -> argparse.Namespace: "Provide this if it's not discoverable or if it's only installed locally (example: 'yarn json2ts').\n" "(default: json2ts)", ) + parser.add_argument( + "--all-fields-required", + action="store_true", + default=False, + help="Treat all fields (including those with defaults) as required in generated TypeScript definitions.", + ) return parser.parse_args(args) @@ -406,6 +433,7 @@ def main() -> None: args.output, tuple(args.exclude), args.json2ts_cmd, + all_fields_required=args.all_fields_required, )