Skip to content

Commit bf49f84

Browse files
committed
Close middleware setup parity gaps
1 parent 3b96e47 commit bf49f84

4 files changed

Lines changed: 155 additions & 18 deletions

File tree

projects/openshell-middleware-init/src/openshell_middleware_init/generator.py

Lines changed: 95 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import ctypes
66
import errno
77
import hashlib
8+
import http.client
89
import json
910
import os
1011
import re
@@ -24,7 +25,6 @@
2425
from datetime import datetime, timezone
2526
from importlib.resources import files
2627
from pathlib import Path
27-
from typing import Any
2828

2929
from openshell_middleware_init import __version__
3030

@@ -36,6 +36,61 @@
3636
_PYTHON_PACKAGE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
3737
_PROJECT_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$")
3838
_NETWORK_ATTEMPTS = 4
39+
_RUST_KEYWORDS = {
40+
"abstract",
41+
"as",
42+
"async",
43+
"await",
44+
"become",
45+
"box",
46+
"break",
47+
"const",
48+
"continue",
49+
"crate",
50+
"do",
51+
"dyn",
52+
"else",
53+
"enum",
54+
"extern",
55+
"false",
56+
"final",
57+
"fn",
58+
"for",
59+
"gen",
60+
"if",
61+
"impl",
62+
"in",
63+
"let",
64+
"loop",
65+
"macro",
66+
"match",
67+
"mod",
68+
"move",
69+
"mut",
70+
"override",
71+
"priv",
72+
"pub",
73+
"ref",
74+
"return",
75+
"self",
76+
"Self",
77+
"static",
78+
"struct",
79+
"super",
80+
"trait",
81+
"true",
82+
"try",
83+
"type",
84+
"typeof",
85+
"union",
86+
"unsized",
87+
"unsafe",
88+
"use",
89+
"virtual",
90+
"where",
91+
"while",
92+
"yield",
93+
}
3994

4095

4196
class InitializationError(RuntimeError):
@@ -74,6 +129,7 @@ class TemplateContext:
74129
distribution_name: str
75130
package_name: str
76131
rust_crate_name: str
132+
rust_lib_name: str
77133
service_name: str
78134

79135
@property
@@ -83,6 +139,7 @@ def replacements(self) -> Mapping[str, str]:
83139
"__DISTRIBUTION_NAME__": self.distribution_name,
84140
"__PACKAGE_NAME__": self.package_name,
85141
"__RUST_CRATE_NAME__": self.rust_crate_name,
142+
"__RUST_LIB_NAME__": self.rust_lib_name,
86143
"__SERVICE_NAME__": self.service_name,
87144
}
88145

@@ -125,6 +182,7 @@ def initialize_project(
125182
dir=destination.parent,
126183
)
127184
)
185+
staging_path.chmod(0o755)
128186
_write_reservation_metadata(reservation, staging_path)
129187
proto, proto_url = downloader(version)
130188
_validate_proto(proto, version)
@@ -185,7 +243,8 @@ def _template_context(name: str, language: str, package_name: str | None) -> Tem
185243
raise InitializationError("--package-name is only valid with --language python")
186244

187245
distribution_name = re.sub(r"[._]+", "-", normalized_name)
188-
derived_package = re.sub(r"[^a-z0-9]+", "_", normalized_name).strip("_")
246+
identifier = re.sub(r"[^a-z0-9]+", "_", normalized_name).strip("_")
247+
derived_package = identifier
189248
if not derived_package or not derived_package[0].isalpha():
190249
derived_package = f"middleware_{derived_package}".rstrip("_")
191250
effective_package = package_name if package_name is not None else derived_package
@@ -195,11 +254,17 @@ def _template_context(name: str, language: str, package_name: str | None) -> Tem
195254
"lowercase letters, digits, and underscores"
196255
)
197256
service_name = normalized_name.replace("_", "-").replace(".", "-")
257+
rust_lib_name = identifier
258+
rust_crate_name = distribution_name
259+
if not rust_lib_name[0].isalpha() or rust_lib_name in _RUST_KEYWORDS:
260+
rust_lib_name = f"middleware_{rust_lib_name}"
261+
rust_crate_name = f"middleware-{distribution_name}"
198262
return TemplateContext(
199263
project_name=normalized_name,
200264
distribution_name=distribution_name,
201265
package_name=effective_package,
202-
rust_crate_name=distribution_name,
266+
rust_crate_name=rust_crate_name,
267+
rust_lib_name=rust_lib_name,
203268
service_name=service_name,
204269
)
205270

@@ -223,9 +288,8 @@ def _resolve_latest_version() -> str:
223288
headers={"User-Agent": f"openshell-middleware-init/{__version__}"},
224289
)
225290
try:
226-
with _urlopen_with_retries(request) as response:
227-
resolved_url = response.geturl()
228-
except (OSError, urllib.error.URLError) as error:
291+
_, resolved_url = _fetch_url(request)
292+
except (OSError, urllib.error.URLError, http.client.IncompleteRead) as error:
229293
raise InitializationError("could not resolve OpenShell's latest release") from error
230294
prefix = f"{_REPOSITORY_URL}/releases/tag/"
231295
if not resolved_url.startswith(prefix):
@@ -243,30 +307,47 @@ def _download_proto(version: str) -> tuple[bytes, str]:
243307
headers={"User-Agent": f"openshell-middleware-init/{__version__}"},
244308
)
245309
try:
246-
with _urlopen_with_retries(request) as response:
247-
return response.read(), url
248-
except (OSError, urllib.error.URLError) as error:
310+
body, _ = _fetch_url(request)
311+
return body, url
312+
except urllib.error.HTTPError as error:
313+
if error.code == 404:
314+
raise InitializationError(
315+
f"{version} does not expose {_PROTO_PATH}; choose a middleware-capable release"
316+
) from error
317+
raise InitializationError(
318+
f"could not download {_PROTO_PATH} for {version}: HTTP {error.code}"
319+
) from error
320+
except (OSError, urllib.error.URLError, http.client.IncompleteRead) as error:
249321
raise InitializationError(
250-
f"{version} does not expose {_PROTO_PATH}; choose a middleware-capable release"
322+
f"could not download {_PROTO_PATH} for {version}: {_network_error_reason(error)}"
251323
) from error
252324

253325

254-
def _urlopen_with_retries(request: urllib.request.Request) -> Any:
255-
"""Open a URL with the same initial attempt plus three retries as the spike."""
326+
def _fetch_url(request: urllib.request.Request) -> tuple[bytes, str]:
327+
"""Fetch a complete response with the same transfer retries as the spike."""
256328
for attempt in range(_NETWORK_ATTEMPTS):
257329
try:
258-
return urllib.request.urlopen(request, timeout=30)
330+
with urllib.request.urlopen(request, timeout=30) as response:
331+
return response.read(), response.geturl()
259332
except urllib.error.HTTPError as error:
260333
retryable = error.code in {408, 429} or 500 <= error.code < 600
261334
if not retryable or attempt == _NETWORK_ATTEMPTS - 1:
262335
raise
263-
except (OSError, urllib.error.URLError):
336+
except (OSError, urllib.error.URLError, http.client.IncompleteRead):
264337
if attempt == _NETWORK_ATTEMPTS - 1:
265338
raise
266339
time.sleep(0.25 * (2**attempt))
267340
raise AssertionError("network retry loop exhausted without returning or raising")
268341

269342

343+
def _network_error_reason(
344+
error: OSError | urllib.error.URLError | http.client.IncompleteRead,
345+
) -> str:
346+
if isinstance(error, urllib.error.URLError):
347+
return str(error.reason)
348+
return str(error)
349+
350+
270351
def _validate_proto(proto: bytes, version: str) -> None:
271352
required_fragments = (
272353
b"package openshell.middleware.v1;",

projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ edition = "2024"
55
rust-version = "1.90"
66
publish = false
77

8+
[lib]
9+
name = "__RUST_LIB_NAME__"
10+
811
[dependencies]
912
prost = "0.14"
1013
prost-types = "0.14"

projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::{env, error::Error, net::SocketAddr};
22

3-
use __PACKAGE_NAME__::middleware_service;
3+
use __RUST_LIB_NAME__::middleware_service;
44
use tonic::transport::Server;
55

66
#[tokio::main]

projects/openshell-middleware-init/tests/test_generator.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import ctypes
44
import email.message
55
import errno
6+
import http.client
67
import json
78
import os
9+
import stat
810
import subprocess
911
import sys
1012
import urllib.error
@@ -77,8 +79,11 @@ def test_generates_rust_project_with_normalized_crate_name(tmp_path: Path) -> No
7779
)
7880

7981
assert result.run_command == "cargo run -- 127.0.0.1:50051"
80-
assert 'name = "request-audit"' in (destination / "Cargo.toml").read_text()
82+
cargo = (destination / "Cargo.toml").read_text()
83+
assert 'name = "request-audit"' in cargo
84+
assert '[lib]\nname = "request_audit"' in cargo
8185
assert "use request_audit::" in (destination / "src/main.rs").read_text()
86+
assert stat.S_IMODE(destination.stat().st_mode) == 0o755
8287
manifest = json.loads((destination / "middleware-dev-manifest.json").read_text())
8388
assert manifest["languages"] == ["rust"]
8489
assert manifest["python_package"] is None
@@ -115,6 +120,33 @@ def test_numeric_project_name_gets_importable_python_package(tmp_path: Path) ->
115120
assert (destination / "src/middleware_123/server.py").is_file()
116121

117122

123+
@pytest.mark.parametrize(
124+
("name", "crate", "library"),
125+
[
126+
("123", "middleware-123", "middleware_123"),
127+
("type", "middleware-type", "middleware_type"),
128+
],
129+
)
130+
def test_rust_project_names_get_valid_explicit_library_names(
131+
tmp_path: Path, name: str, crate: str, library: str
132+
) -> None:
133+
destination = tmp_path / name
134+
135+
initialize_project(
136+
name=name,
137+
language="rust",
138+
requested_version="v0.0.86",
139+
destination=destination,
140+
download_proto=local_proto,
141+
command_runner=no_op_runner,
142+
)
143+
144+
cargo = (destination / "Cargo.toml").read_text()
145+
assert f'name = "{crate}"' in cargo
146+
assert f'[lib]\nname = "{library}"' in cargo
147+
assert f"use {library}::middleware_service;" in (destination / "src/main.rs").read_text()
148+
149+
118150
def test_unsupported_platform_fails_before_filesystem_changes(
119151
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
120152
) -> None:
@@ -378,7 +410,7 @@ def fail(*args: object, **kwargs: object) -> None:
378410
monkeypatch.setattr(generator.urllib.request, "urlopen", fail)
379411
monkeypatch.setattr(generator.time, "sleep", lambda _: None)
380412

381-
with pytest.raises(InitializationError, match="middleware-capable release"):
413+
with pytest.raises(InitializationError, match=r"could not download.*missing"):
382414
generator._download_proto("v1.2.3")
383415

384416

@@ -401,6 +433,26 @@ def transient(*args: object, **kwargs: object) -> FakeResponse:
401433
assert attempts == 3
402434

403435

436+
def test_download_retries_interrupted_response_body(monkeypatch: pytest.MonkeyPatch) -> None:
437+
attempts = 0
438+
439+
class InterruptedResponse(FakeResponse):
440+
def read(self) -> bytes:
441+
raise http.client.IncompleteRead(b"partial")
442+
443+
def interrupted_then_complete(*args: object, **kwargs: object) -> FakeResponse:
444+
nonlocal attempts
445+
del args, kwargs
446+
attempts += 1
447+
return InterruptedResponse() if attempts == 1 else FakeResponse(body=PROTO)
448+
449+
monkeypatch.setattr(generator.urllib.request, "urlopen", interrupted_then_complete)
450+
monkeypatch.setattr(generator.time, "sleep", lambda _: None)
451+
452+
assert generator._download_proto("v1.2.3")[0] == PROTO
453+
assert attempts == 2
454+
455+
404456
@pytest.mark.parametrize(("status", "expected_attempts"), [(503, 4), (404, 1)])
405457
def test_download_retries_only_retryable_http_statuses(
406458
monkeypatch: pytest.MonkeyPatch, status: int, expected_attempts: int
@@ -422,7 +474,8 @@ def fail(*args: object, **kwargs: object) -> None:
422474
monkeypatch.setattr(generator.urllib.request, "urlopen", fail)
423475
monkeypatch.setattr(generator.time, "sleep", lambda _: None)
424476

425-
with pytest.raises(InitializationError, match="middleware-capable release"):
477+
expected_message = "middleware-capable release" if status == 404 else "HTTP 503"
478+
with pytest.raises(InitializationError, match=expected_message):
426479
generator._download_proto("v1.2.3")
427480

428481
assert attempts == expected_attempts

0 commit comments

Comments
 (0)