From 85a7593a4d87cec9f97013379658ac690a8de33e Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 17:05:55 -0400 Subject: [PATCH] fix(python): use datetime.date/time for date/time formats (#1974) The Python generator collapsed JSON Schema string formats "date" and "time" onto the same "date-time" transformed-string kind, so all three were emitted as datetime.datetime and serialized with .isoformat(), producing a full ISO datetime string for date-only and time-only values. This violated the input schema on round-trip. Keep "date" and "time" as distinct transformed-string kinds through the Python stringTypeMapping, and teach PythonRenderer/ JSONPythonRenderer to emit datetime.date/datetime.time (with dedicated from_date/from_time parsing helpers) instead of always using datetime.datetime. Test coverage: strengthened test/fixtures/python/main.py to assert that generated instances have the correct runtime types (datetime.date/datetime.time/datetime.datetime) for schemas with date/time/date-time properties; this is exercised by the existing test/inputs/schema/date-time.schema fixture, run via `QUICKTEST=true FIXTURE=schema-python script/test`. Co-Authored-By: gpt-5.6-sol via pi --- .../src/language/Python/JSONPythonRenderer.ts | 97 ++++++++++++++++++- .../src/language/Python/PythonRenderer.ts | 20 +++- .../src/language/Python/language.ts | 7 +- test/fixtures/python/main.py | 10 +- 4 files changed, 125 insertions(+), 9 deletions(-) diff --git a/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts b/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts index 20d5fc5aa8..71e234e45d 100644 --- a/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts +++ b/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts @@ -40,6 +40,8 @@ export type ConverterFunction = | "to-class" | "dict" | "union" + | "from-date" + | "from-time" | "from-datetime" | "from-stringified-bool" | "is-type"; @@ -458,13 +460,64 @@ export class JSONPythonRenderer extends PythonRenderer { assert False`); } + protected emitFromDateConverter(): void { + this.emitBlock( + [ + "def from_date(", + this.typingDecl("x", "Any"), + ")", + this.typeHint(" -> ", [ + this.withModuleImport("datetime"), + ".date", + ]), + ":", + ], + () => { + this._haveDateutil = true; + this.emitLine( + "assert isinstance(x, str) and ", + this.withModuleImport("re"), + '.match(r"^\\d{4}-\\d{2}-\\d{2}$", x)', + ); + this.emitLine("return dateutil.parser.parse(x).date()"); + }, + ); + } + + protected emitFromTimeConverter(): void { + this.emitBlock( + [ + "def from_time(", + this.typingDecl("x", "Any"), + ")", + this.typeHint(" -> ", [ + this.withModuleImport("datetime"), + ".time", + ]), + ":", + ], + () => { + this._haveDateutil = true; + this.emitLine( + "assert isinstance(x, str) and ", + this.withModuleImport("re"), + '.match(r"^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})?$", x)', + ); + this.emitLine("return dateutil.parser.parse(x).time()"); + }, + ); + } + protected emitFromDatetimeConverter(): void { this.emitBlock( [ "def from_datetime(", this.typingDecl("x", "Any"), ")", - this.typeHint(" -> ", this.withImport("datetime", "datetime")), + this.typeHint(" -> ", [ + this.withModuleImport("datetime"), + ".datetime", + ]), ":", ], () => { @@ -571,6 +624,16 @@ export class JSONPythonRenderer extends PythonRenderer { return; } + case "from-date": { + this.emitFromDateConverter(); + return; + } + + case "from-time": { + this.emitFromTimeConverter(); + return; + } + case "from-datetime": { this.emitFromDatetimeConverter(); return; @@ -628,8 +691,16 @@ export class JSONPythonRenderer extends PythonRenderer { (enumType) => this.nameForNamedType(enumType), (_unionType) => undefined, (transformedStringType) => { + if (transformedStringType.kind === "date") { + return [this.withModuleImport("datetime"), ".date"]; + } + + if (transformedStringType.kind === "time") { + return [this.withModuleImport("datetime"), ".time"]; + } + if (transformedStringType.kind === "date-time") { - return this.withImport("datetime", "datetime"); + return [this.withModuleImport("datetime"), ".datetime"]; } if (transformedStringType.kind === "uuid") { @@ -731,6 +802,12 @@ export class JSONPythonRenderer extends PythonRenderer { immediateTargetType, ); break; + case "date": + vol = this.convFn("from-date", inputTransformer); + break; + case "time": + vol = this.convFn("from-time", inputTransformer); + break; case "date-time": vol = this.convFn("from-datetime", inputTransformer); break; @@ -767,6 +844,8 @@ export class JSONPythonRenderer extends PythonRenderer { case "enum": vol = this.serializer(inputTransformer, xfer.sourceType); break; + case "date": + case "time": case "date-time": vol = compose(inputTransformer, (v) => [v, ".isoformat()"]); break; @@ -851,6 +930,14 @@ export class JSONPythonRenderer extends PythonRenderer { }, (transformedStringType) => { // FIXME: handle via transformers + if (transformedStringType.kind === "date") { + return this.convFn("from-date", value); + } + + if (transformedStringType.kind === "time") { + return this.convFn("from-time", value); + } + if (transformedStringType.kind === "date-time") { return this.convFn("from-datetime", value); } @@ -942,7 +1029,11 @@ export class JSONPythonRenderer extends PythonRenderer { ]); }, (transformedStringType) => { - if (transformedStringType.kind === "date-time") { + if ( + transformedStringType.kind === "date" || + transformedStringType.kind === "time" || + transformedStringType.kind === "date-time" + ) { return compose(value, (v) => [v, ".isoformat()"]); } diff --git a/packages/quicktype-core/src/language/Python/PythonRenderer.ts b/packages/quicktype-core/src/language/Python/PythonRenderer.ts index e0d60263ab..1a5a35ed25 100644 --- a/packages/quicktype-core/src/language/Python/PythonRenderer.ts +++ b/packages/quicktype-core/src/language/Python/PythonRenderer.ts @@ -37,6 +37,8 @@ import { classNameStyle, snakeNameStyle } from "./utils.js"; export class PythonRenderer extends ConvenienceRenderer { private readonly imports: Map> = new Map(); + private readonly moduleImports: Set = new Set(); + private readonly declaredTypes: Set = new Set(); public constructor( @@ -132,6 +134,11 @@ export class PythonRenderer extends ConvenienceRenderer { return name; } + protected withModuleImport(module: string): Sourcelike { + this.moduleImports.add(module); + return module; + } + protected withTyping(name: string): Sourcelike { return this.withImport("typing", name); } @@ -319,8 +326,16 @@ export class PythonRenderer extends ConvenienceRenderer { ]; }, (transformedStringType) => { + if (transformedStringType.kind === "date") { + return [this.withModuleImport("datetime"), ".date"]; + } + + if (transformedStringType.kind === "time") { + return [this.withModuleImport("datetime"), ".time"]; + } + if (transformedStringType.kind === "date-time") { - return this.withImport("datetime", "datetime"); + return [this.withModuleImport("datetime"), ".datetime"]; } if (transformedStringType.kind === "uuid") { @@ -481,6 +496,9 @@ export class PythonRenderer extends ConvenienceRenderer { } protected emitImports(): void { + this.moduleImports.forEach((module) => { + this.emitLine("import ", module); + }); this.imports.forEach((names, module) => { this.emitLine( "from ", diff --git a/packages/quicktype-core/src/language/Python/language.ts b/packages/quicktype-core/src/language/Python/language.ts index 263026b7e5..465d2e8a0c 100644 --- a/packages/quicktype-core/src/language/Python/language.ts +++ b/packages/quicktype-core/src/language/Python/language.ts @@ -105,10 +105,9 @@ export class PythonTargetLanguage extends TargetLanguage< public get stringTypeMapping(): StringTypeMapping { const mapping: Map = new Map(); - const dateTimeType = "date-time"; - mapping.set("date", dateTimeType); - mapping.set("time", dateTimeType); - mapping.set("date-time", dateTimeType); + mapping.set("date", "date"); + mapping.set("time", "time"); + mapping.set("date-time", "date-time"); mapping.set("uuid", "uuid"); mapping.set("integer-string", "integer-string"); mapping.set("bool-string", "bool-string"); diff --git a/test/fixtures/python/main.py b/test/fixtures/python/main.py index 93aacd51c9..a74d08d325 100644 --- a/test/fixtures/python/main.py +++ b/test/fixtures/python/main.py @@ -1,8 +1,16 @@ import quicktype +import datetime import json import sys import io f = io.open(sys.argv[1], mode="r", encoding="utf-8") -obj = quicktype.top_level_from_dict(json.load(f)) +input_obj = json.load(f) +obj = quicktype.top_level_from_dict(input_obj) + +if isinstance(input_obj, dict) and {"date", "time", "date-time"} <= input_obj.keys(): + assert type(obj.date) is datetime.date + assert type(obj.time) is datetime.time + assert type(obj.date_time) is datetime.datetime + print(json.dumps(quicktype.top_level_to_dict(obj)))