Skip to content

Commit 793ebce

Browse files
Use shared binary file opener in upload managers
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent dd7108c commit 793ebce

File tree

9 files changed

+159
-52
lines changed

9 files changed

+159
-52
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ This runs lint, format checks, compile checks, tests, and package build.
7777
- Prefer deterministic unit tests over network-dependent tests.
7878
- Preserve architectural guardrails with focused tests. Current guard suites include:
7979
- `tests/test_architecture_marker_usage.py` (architecture marker coverage across guard modules),
80+
- `tests/test_binary_file_open_helper_usage.py` (shared binary file open helper usage enforcement),
8081
- `tests/test_ci_workflow_quality_gates.py` (CI guard-stage + make-target enforcement),
8182
- `tests/test_computer_action_endpoint_helper_usage.py` (computer-action endpoint-normalization helper usage enforcement),
8283
- `tests/test_contributing_architecture_guard_listing.py` (`CONTRIBUTING.md` architecture-guard inventory completeness enforcement),

hyperbrowser/client/file_utils.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
2+
from contextlib import contextmanager
23
from os import PathLike
3-
from typing import Union
4+
from typing import BinaryIO, Iterator, Union
45

56
from hyperbrowser.exceptions import HyperbrowserError
67
from hyperbrowser.type_utils import is_plain_string
@@ -125,3 +126,38 @@ def ensure_existing_file_path(
125126
if not is_file:
126127
raise HyperbrowserError(not_file_message)
127128
return normalized_path
129+
130+
131+
@contextmanager
132+
def open_binary_file(
133+
file_path: Union[str, PathLike[str]],
134+
*,
135+
open_error_message: str,
136+
) -> Iterator[BinaryIO]:
137+
_validate_error_message_text(
138+
open_error_message,
139+
field_name="open_error_message",
140+
)
141+
try:
142+
normalized_path = os.fspath(file_path)
143+
except HyperbrowserError:
144+
raise
145+
except TypeError as exc:
146+
raise HyperbrowserError(
147+
"file_path must be a string or os.PathLike object",
148+
original_error=exc,
149+
) from exc
150+
except Exception as exc:
151+
raise HyperbrowserError("file_path is invalid", original_error=exc) from exc
152+
if not is_plain_string(normalized_path):
153+
raise HyperbrowserError("file_path must resolve to a string path")
154+
try:
155+
with open(normalized_path, "rb") as file_obj:
156+
yield file_obj
157+
except HyperbrowserError:
158+
raise
159+
except Exception as exc:
160+
raise HyperbrowserError(
161+
open_error_message,
162+
original_error=exc,
163+
) from exc

hyperbrowser/client/managers/async_manager/extension.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import List
22

3-
from hyperbrowser.exceptions import HyperbrowserError
3+
from ...file_utils import open_binary_file
44
from ..extension_create_utils import normalize_extension_create_input
55
from ..extension_utils import parse_extension_list_response_data
66
from ..response_utils import parse_response_model
@@ -14,18 +14,15 @@ def __init__(self, client):
1414
async def create(self, params: CreateExtensionParams) -> ExtensionResponse:
1515
file_path, payload = normalize_extension_create_input(params)
1616

17-
try:
18-
with open(file_path, "rb") as extension_file:
19-
response = await self._client.transport.post(
20-
self._client._build_url("/extensions/add"),
21-
data=payload,
22-
files={"file": extension_file},
23-
)
24-
except OSError as exc:
25-
raise HyperbrowserError(
26-
f"Failed to open extension file at path: {file_path}",
27-
original_error=exc,
28-
) from exc
17+
with open_binary_file(
18+
file_path,
19+
open_error_message=f"Failed to open extension file at path: {file_path}",
20+
) as extension_file:
21+
response = await self._client.transport.post(
22+
self._client._build_url("/extensions/add"),
23+
data=payload,
24+
files={"file": extension_file},
25+
)
2926
return parse_response_model(
3027
response.data,
3128
model=ExtensionResponse,

hyperbrowser/client/managers/async_manager/session.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import IO, List, Optional, Union, overload
33
import warnings
44
from hyperbrowser.exceptions import HyperbrowserError
5+
from ...file_utils import open_binary_file
56
from ..serialization_utils import (
67
serialize_model_dump_or_default,
78
serialize_model_dump_to_dict,
@@ -169,18 +170,15 @@ async def upload_file(
169170
) -> UploadFileResponse:
170171
file_path, file_obj = normalize_upload_file_input(file_input)
171172
if file_path is not None:
172-
try:
173-
with open(file_path, "rb") as file_obj:
174-
files = {"file": file_obj}
175-
response = await self._client.transport.post(
176-
self._client._build_url(f"/session/{id}/uploads"),
177-
files=files,
178-
)
179-
except OSError as exc:
180-
raise HyperbrowserError(
181-
f"Failed to open upload file at path: {file_path}",
182-
original_error=exc,
183-
) from exc
173+
with open_binary_file(
174+
file_path,
175+
open_error_message=f"Failed to open upload file at path: {file_path}",
176+
) as file_obj:
177+
files = {"file": file_obj}
178+
response = await self._client.transport.post(
179+
self._client._build_url(f"/session/{id}/uploads"),
180+
files=files,
181+
)
184182
else:
185183
if file_obj is None:
186184
raise HyperbrowserError(

hyperbrowser/client/managers/sync_manager/extension.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import List
22

3-
from hyperbrowser.exceptions import HyperbrowserError
3+
from ...file_utils import open_binary_file
44
from ..extension_create_utils import normalize_extension_create_input
55
from ..extension_utils import parse_extension_list_response_data
66
from ..response_utils import parse_response_model
@@ -14,18 +14,15 @@ def __init__(self, client):
1414
def create(self, params: CreateExtensionParams) -> ExtensionResponse:
1515
file_path, payload = normalize_extension_create_input(params)
1616

17-
try:
18-
with open(file_path, "rb") as extension_file:
19-
response = self._client.transport.post(
20-
self._client._build_url("/extensions/add"),
21-
data=payload,
22-
files={"file": extension_file},
23-
)
24-
except OSError as exc:
25-
raise HyperbrowserError(
26-
f"Failed to open extension file at path: {file_path}",
27-
original_error=exc,
28-
) from exc
17+
with open_binary_file(
18+
file_path,
19+
open_error_message=f"Failed to open extension file at path: {file_path}",
20+
) as extension_file:
21+
response = self._client.transport.post(
22+
self._client._build_url("/extensions/add"),
23+
data=payload,
24+
files={"file": extension_file},
25+
)
2926
return parse_response_model(
3027
response.data,
3128
model=ExtensionResponse,

hyperbrowser/client/managers/sync_manager/session.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import IO, List, Optional, Union, overload
33
import warnings
44
from hyperbrowser.exceptions import HyperbrowserError
5+
from ...file_utils import open_binary_file
56
from ..serialization_utils import (
67
serialize_model_dump_or_default,
78
serialize_model_dump_to_dict,
@@ -161,18 +162,15 @@ def upload_file(
161162
) -> UploadFileResponse:
162163
file_path, file_obj = normalize_upload_file_input(file_input)
163164
if file_path is not None:
164-
try:
165-
with open(file_path, "rb") as file_obj:
166-
files = {"file": file_obj}
167-
response = self._client.transport.post(
168-
self._client._build_url(f"/session/{id}/uploads"),
169-
files=files,
170-
)
171-
except OSError as exc:
172-
raise HyperbrowserError(
173-
f"Failed to open upload file at path: {file_path}",
174-
original_error=exc,
175-
) from exc
165+
with open_binary_file(
166+
file_path,
167+
open_error_message=f"Failed to open upload file at path: {file_path}",
168+
) as file_obj:
169+
files = {"file": file_obj}
170+
response = self._client.transport.post(
171+
self._client._build_url(f"/session/{id}/uploads"),
172+
files=files,
173+
)
176174
else:
177175
if file_obj is None:
178176
raise HyperbrowserError(

tests/test_architecture_marker_usage.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"tests/test_mapping_keys_access_usage.py",
1313
"tests/test_tool_mapping_reader_usage.py",
1414
"tests/test_display_helper_usage.py",
15+
"tests/test_binary_file_open_helper_usage.py",
1516
"tests/test_ci_workflow_quality_gates.py",
1617
"tests/test_makefile_quality_targets.py",
1718
"tests/test_pyproject_architecture_marker.py",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
pytestmark = pytest.mark.architecture
6+
7+
8+
MODULES = (
9+
"hyperbrowser/client/managers/sync_manager/extension.py",
10+
"hyperbrowser/client/managers/async_manager/extension.py",
11+
"hyperbrowser/client/managers/sync_manager/session.py",
12+
"hyperbrowser/client/managers/async_manager/session.py",
13+
)
14+
15+
16+
def test_managers_use_shared_binary_file_open_helper():
17+
for module_path in MODULES:
18+
module_text = Path(module_path).read_text(encoding="utf-8")
19+
assert "open_binary_file(" in module_text
20+
assert 'with open(file_path, "rb")' not in module_text

tests/test_file_utils.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44

55
import hyperbrowser.client.file_utils as file_utils
6-
from hyperbrowser.client.file_utils import ensure_existing_file_path
6+
from hyperbrowser.client.file_utils import ensure_existing_file_path, open_binary_file
77
from hyperbrowser.exceptions import HyperbrowserError
88

99

@@ -480,3 +480,62 @@ def __iter__(self):
480480
)
481481

482482
assert exc_info.value.original_error is None
483+
484+
485+
def test_open_binary_file_reads_content_and_closes(tmp_path: Path):
486+
file_path = tmp_path / "binary.bin"
487+
file_path.write_bytes(b"content")
488+
489+
with open_binary_file(
490+
str(file_path),
491+
open_error_message="open failed",
492+
) as file_obj:
493+
assert file_obj.read() == b"content"
494+
assert file_obj.closed is False
495+
496+
assert file_obj.closed is True
497+
498+
499+
def test_open_binary_file_rejects_non_string_error_message(tmp_path: Path):
500+
file_path = tmp_path / "binary.bin"
501+
file_path.write_bytes(b"content")
502+
503+
with pytest.raises(HyperbrowserError, match="open_error_message must be a string"):
504+
with open_binary_file(
505+
str(file_path),
506+
open_error_message=123, # type: ignore[arg-type]
507+
):
508+
pass
509+
510+
511+
def test_open_binary_file_rejects_invalid_file_path_type():
512+
with pytest.raises(
513+
HyperbrowserError, match="file_path must be a string or os.PathLike object"
514+
):
515+
with open_binary_file(
516+
123, # type: ignore[arg-type]
517+
open_error_message="open failed",
518+
):
519+
pass
520+
521+
522+
def test_open_binary_file_rejects_non_string_fspath_results():
523+
with pytest.raises(HyperbrowserError, match="file_path must resolve to a string"):
524+
with open_binary_file(
525+
b"/tmp/bytes-path", # type: ignore[arg-type]
526+
open_error_message="open failed",
527+
):
528+
pass
529+
530+
531+
def test_open_binary_file_wraps_open_errors(tmp_path: Path):
532+
missing_path = tmp_path / "missing.bin"
533+
534+
with pytest.raises(HyperbrowserError, match="open failed") as exc_info:
535+
with open_binary_file(
536+
str(missing_path),
537+
open_error_message="open failed",
538+
):
539+
pass
540+
541+
assert isinstance(exc_info.value.original_error, FileNotFoundError)

0 commit comments

Comments
 (0)