Skip to content

Commit 0cd4461

Browse files
fix(openapi): make client generators portable on Windows (#9759)
* fix(openapi): make client generators portable on Windows * chore(openapi): remove redundant generator f-strings --------- Co-authored-by: 491034170 <142008960+491034170@users.noreply.github.com>
1 parent 37b51ef commit 0cd4461

6 files changed

Lines changed: 137 additions & 31 deletions

backend/scripts/generate_dart_models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,18 +1237,18 @@ def main() -> int:
12371237
if args.all and args.output:
12381238
raise SystemExit('--output cannot be used with --all')
12391239

1240-
spec = json.loads(Path(args.spec).read_text())
1240+
spec = json.loads(Path(args.spec).read_text(encoding='utf-8'))
12411241
groups = tuple(SCHEMA_GROUPS) if args.all else (args.group,)
12421242
for group in groups:
12431243
output_path = Path(args.output) if args.output else SCHEMA_GROUPS[group]['output']
12441244
generated = build_output(spec, group)
12451245
if args.check:
1246-
if not output_path.exists() or output_path.read_text() != generated:
1246+
if not output_path.exists() or output_path.read_text(encoding='utf-8') != generated:
12471247
raise SystemExit(f'{output_path} is stale; run backend/scripts/generate_dart_models.py --group {group}')
12481248
print(f'{output_path} is up to date')
12491249
continue
12501250
output_path.parent.mkdir(parents=True, exist_ok=True)
1251-
output_path.write_text(generated)
1251+
output_path.write_text(generated, encoding='utf-8', newline='\n')
12521252
print(f'wrote {output_path}')
12531253
return 0
12541254

backend/scripts/generate_swift_openapi_types.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,21 @@
1616
import json
1717
import re
1818
import sys
19-
from pathlib import Path
19+
from pathlib import Path, PurePath
2020
from typing import Any
2121

2222
ROOT_DIR = Path(__file__).resolve().parents[2]
2323
DEFAULT_SPEC_PATH = ROOT_DIR / 'docs' / 'api-reference' / 'app-client-openapi.json'
2424
DEFAULT_OUTPUT = ROOT_DIR / 'desktop' / 'macos' / 'Desktop' / 'Sources' / 'Generated' / 'OmiApi.generated.swift'
2525

26+
27+
def source_label_for_path(path: PurePath, root_dir: PurePath = ROOT_DIR) -> str:
28+
try:
29+
return path.relative_to(root_dir).as_posix()
30+
except ValueError:
31+
return path.as_posix()
32+
33+
2634
# Schemas to generate. Keep this the high-traffic desktop read surface; expand
2735
# as the desktop Codable migration progresses. Each entry pulls in transitive
2836
# $ref dependencies automatically.
@@ -198,7 +206,7 @@ def _render_enum(name: str, schema: dict[str, Any]) -> str:
198206
lines.append(' case _unknown = "__unknown__"')
199207
lines.append(' public init(from decoder: Decoder) throws {')
200208
lines.append(' let c = try decoder.singleValueContainer()')
201-
lines.append(f' let raw = try c.decode(String.self)')
209+
lines.append(' let raw = try c.decode(String.self)')
202210
lines.append(f' self = {name}(rawValue: raw) ?? ._unknown')
203211
lines.append(' }')
204212
lines.append('}')
@@ -486,7 +494,7 @@ def _render_patch_struct(name: str, schema: dict[str, Any]) -> str:
486494
lines.append(' var c = encoder.container(keyedBy: CodingKeys.self)')
487495
for swift_name, _, _ in fields:
488496
lines.append(f' switch {swift_name} {{')
489-
lines.append(f' case .omitted: break')
497+
lines.append(' case .omitted: break')
490498
lines.append(f' case .value(let value): try c.encode(value, forKey: .{swift_name})')
491499
lines.append(f' case .null: try c.encodeNil(forKey: .{swift_name})')
492500
lines.append(' }')
@@ -907,20 +915,20 @@ def parse_args() -> argparse.Namespace:
907915

908916
def main() -> int:
909917
args = parse_args()
910-
spec = json.loads(args.spec.read_text())
911-
generated = generate(spec, str(args.spec.relative_to(ROOT_DIR)) if args.spec.is_absolute() else str(args.spec))
918+
spec = json.loads(args.spec.read_text(encoding='utf-8'))
919+
generated = generate(spec, source_label_for_path(args.spec))
912920

913921
if args.check:
914-
existing = args.output.read_text() if args.output.exists() else ''
922+
existing = args.output.read_text(encoding='utf-8') if args.output.exists() else ''
915923
if existing != generated:
916-
rel = args.output.relative_to(ROOT_DIR) if args.output.is_absolute() else args.output
924+
rel = source_label_for_path(args.output)
917925
print(f'{rel} is out of date. Run: python {Path(__file__).name}', file=sys.stderr)
918926
return 1
919927
return 0
920928

921929
args.output.parent.mkdir(parents=True, exist_ok=True)
922-
args.output.write_text(generated)
923-
print(f'wrote {args.output}')
930+
args.output.write_text(generated, encoding='utf-8', newline='\n')
931+
print(f'wrote {source_label_for_path(args.output)}')
924932
return 0
925933

926934

backend/scripts/generate_ts_openapi_types.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import json
1212
import re
1313
import sys
14-
from pathlib import Path
14+
from pathlib import Path, PurePath
1515
from typing import Any
1616

1717
ROOT_DIR = Path(__file__).resolve().parents[2]
@@ -31,6 +31,14 @@ def stable_json(value: Any) -> str:
3131
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + '\n'
3232

3333

34+
def source_label_for_path(spec_path: PurePath, root_dir: PurePath = ROOT_DIR) -> str:
35+
"""Return a stable slash-separated label for generated-file headers."""
36+
try:
37+
return spec_path.relative_to(root_dir).as_posix()
38+
except ValueError:
39+
return spec_path.as_posix()
40+
41+
3442
def ts_identifier(name: str) -> str:
3543
candidate = IDENTIFIER_RE.sub('_', name)
3644
if not candidate or not re.match(r'[A-Za-z_$]', candidate[0]):
@@ -431,31 +439,24 @@ def parse_args() -> argparse.Namespace:
431439
def main() -> int:
432440
args = parse_args()
433441
spec_path = args.spec if args.spec.is_absolute() else (Path.cwd() / args.spec)
434-
spec = json.loads(spec_path.read_text())
435-
try:
436-
source_label = str(spec_path.relative_to(ROOT_DIR))
437-
except ValueError:
438-
source_label = str(spec_path)
439-
rendered = generate(spec, source_label)
442+
spec = json.loads(spec_path.read_text(encoding='utf-8'))
443+
rendered = generate(spec, source_label_for_path(spec_path))
440444

441445
outputs = args.output or DEFAULT_OUTPUTS
442446
stale: list[Path] = []
443447
for output in outputs:
444448
path = output if output.is_absolute() else (Path.cwd() / output)
445449
if args.check:
446-
if not path.exists() or path.read_text() != rendered:
450+
if not path.exists() or path.read_text(encoding='utf-8') != rendered:
447451
stale.append(path)
448452
else:
449453
path.parent.mkdir(parents=True, exist_ok=True)
450-
path.write_text(rendered)
451-
print(f'wrote {path.relative_to(ROOT_DIR)}')
454+
path.write_text(rendered, encoding='utf-8', newline='\n')
455+
print(f'wrote {source_label_for_path(path)}')
452456

453457
if stale:
454458
for path in stale:
455-
try:
456-
label = path.relative_to(ROOT_DIR)
457-
except ValueError:
458-
label = path
459+
label = source_label_for_path(path)
459460
print(f'{label} is stale; run backend/scripts/generate_ts_openapi_types.py', file=sys.stderr)
460461
return 1
461462
return 0

backend/tests/unit/test_app_client_dart_generator.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from __future__ import annotations
22

33
import json
4+
import os
5+
import subprocess
6+
import sys
47
from pathlib import Path
58

69
from models.conversation import Conversation
@@ -40,6 +43,22 @@
4043
CONVERSATION_FIXTURE_PATH = ROOT_DIR / 'backend' / 'testing' / 'e2e' / 'fixtures' / 'conversations.json'
4144

4245

46+
def test_dart_generator_cli_uses_utf8_when_the_process_locale_does_not():
47+
env = os.environ.copy()
48+
env.update({'LANG': 'C', 'LC_ALL': 'C', 'PYTHONCOERCECLOCALE': '0', 'PYTHONUTF8': '0'})
49+
50+
completed = subprocess.run(
51+
[sys.executable, str(generate_dart_models.__file__), '--all', '--check'],
52+
check=False,
53+
capture_output=True,
54+
text=True,
55+
cwd=ROOT_DIR,
56+
env=env,
57+
)
58+
59+
assert completed.returncode == 0, completed.stderr
60+
61+
4362
def test_conversation_wire_dart_is_generated_from_app_client_openapi():
4463
spec = json.loads(SPEC_PATH.read_text())
4564
generated = generate_dart_models.build_output(spec, 'conversation')

backend/tests/unit/test_app_client_swift_generator.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
from __future__ import annotations
99

1010
import json
11-
from pathlib import Path
11+
import os
12+
import subprocess
13+
import sys
14+
from pathlib import Path, PureWindowsPath
1215

1316
from scripts import generate_swift_openapi_types
1417

@@ -149,3 +152,29 @@ def test_swift_generator_emits_required_headers_and_client_default_headers():
149152
assert 'idempotencyKey: String' in generated
150153
assert 'for (name, value) in client.headers' in generated
151154
assert 'req.setValue(String(idempotencyKey), forHTTPHeaderField: "Idempotency-Key")' in generated
155+
156+
157+
def test_swift_source_label_is_stable_for_windows_paths():
158+
root = PureWindowsPath('C:/src/omi')
159+
spec_path = root / 'docs' / 'api-reference' / 'app-client-openapi.json'
160+
161+
assert (
162+
generate_swift_openapi_types.source_label_for_path(spec_path, root)
163+
== 'docs/api-reference/app-client-openapi.json'
164+
)
165+
166+
167+
def test_swift_generator_cli_uses_utf8_when_the_process_locale_does_not():
168+
env = os.environ.copy()
169+
env.update({'LANG': 'C', 'LC_ALL': 'C', 'PYTHONCOERCECLOCALE': '0', 'PYTHONUTF8': '0'})
170+
171+
completed = subprocess.run(
172+
[sys.executable, str(generate_swift_openapi_types.__file__), '--check'],
173+
check=False,
174+
capture_output=True,
175+
text=True,
176+
cwd=ROOT_DIR,
177+
env=env,
178+
)
179+
180+
assert completed.returncode == 0, completed.stderr

backend/tests/unit/test_app_client_ts_generator.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from __future__ import annotations
22

33
import json
4+
import os
45
import re
5-
from pathlib import Path
6+
import subprocess
7+
import sys
8+
from pathlib import Path, PureWindowsPath
69

710
from scripts import generate_ts_openapi_types
811

@@ -11,11 +14,11 @@
1114

1215

1316
def test_typescript_schema_types_are_generated_from_app_client_openapi():
14-
spec = json.loads(SPEC_PATH.read_text())
17+
spec = json.loads(SPEC_PATH.read_text(encoding='utf-8'))
1518
generated = generate_ts_openapi_types.generate(spec, 'docs/api-reference/app-client-openapi.json')
1619

1720
for output in generate_ts_openapi_types.DEFAULT_OUTPUTS:
18-
assert output.read_text() == generated
21+
assert output.read_text(encoding='utf-8') == generated
1922
assert '// GENERATED CODE - DO NOT EDIT.' in generated
2023
assert 'export interface Conversation {' in generated
2124
assert 'export interface GoalResponse {' in generated
@@ -27,7 +30,7 @@ def test_typescript_schema_types_are_generated_from_app_client_openapi():
2730

2831

2932
def test_typescript_operation_response_map_is_generated():
30-
spec = json.loads(SPEC_PATH.read_text())
33+
spec = json.loads(SPEC_PATH.read_text(encoding='utf-8'))
3134
generated = generate_ts_openapi_types.generate(spec, 'docs/api-reference/app-client-openapi.json')
3235

3336
assert 'export interface OmiApiPaths {' in generated
@@ -138,3 +141,49 @@ def test_typescript_generator_emits_type_for_object_unions():
138141
assert 'export type NullableObject =' in generated
139142
assert 'export interface NullableObject' not in generated
140143
assert re.search(r'export type CandidateRecord = \{[\s\S]*?\} \| \{', generated)
144+
145+
146+
def test_typescript_source_label_is_stable_for_windows_paths():
147+
root = PureWindowsPath('C:/src/omi')
148+
spec_path = root / 'docs' / 'api-reference' / 'app-client-openapi.json'
149+
150+
assert (
151+
generate_ts_openapi_types.source_label_for_path(spec_path, root) == 'docs/api-reference/app-client-openapi.json'
152+
)
153+
154+
155+
def test_typescript_generator_cli_uses_utf8_when_the_process_locale_does_not(tmp_path: Path):
156+
spec_path = tmp_path / 'openapi.json'
157+
output_path = tmp_path / 'generated.ts'
158+
spec = {
159+
'components': {
160+
'schemas': {
161+
'Price\u20ac': {
162+
'type': 'object',
163+
'properties': {'currency': {'type': 'string'}},
164+
}
165+
}
166+
},
167+
'paths': {},
168+
}
169+
spec_path.write_text(json.dumps(spec, ensure_ascii=False), encoding='utf-8')
170+
env = os.environ.copy()
171+
env.update({'LANG': 'C', 'LC_ALL': 'C', 'PYTHONCOERCECLOCALE': '0', 'PYTHONUTF8': '0'})
172+
173+
completed = subprocess.run(
174+
[
175+
sys.executable,
176+
str(generate_ts_openapi_types.__file__),
177+
'--spec',
178+
str(spec_path),
179+
'--output',
180+
str(output_path),
181+
],
182+
check=False,
183+
capture_output=True,
184+
text=True,
185+
env=env,
186+
)
187+
188+
assert completed.returncode == 0, completed.stderr
189+
assert 'export interface Price_ {' in output_path.read_text(encoding='utf-8')

0 commit comments

Comments
 (0)