Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 0 additions & 40 deletions .basedpyright/baseline.bfabric.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,46 +235,6 @@
"lineCount": 1
}
},
{
"code": "reportAny",
"range": {
"startColumn": 8,
"endColumn": 16,
"lineCount": 1
}
},
{
"code": "reportAny",
"range": {
"startColumn": 19,
"endColumn": 31,
"lineCount": 1
}
},
{
"code": "reportAny",
"range": {
"startColumn": 12,
"endColumn": 20,
"lineCount": 1
}
},
{
"code": "reportAny",
"range": {
"startColumn": 8,
"endColumn": 16,
"lineCount": 1
}
},
{
"code": "reportAny",
"range": {
"startColumn": 19,
"endColumn": 33,
"lineCount": 1
}
},
{
"code": "reportUnknownMemberType",
"range": {
Expand Down
4 changes: 4 additions & 0 deletions bfabric/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ Minor breaking changes are still possible in `1.X.Y` but we try to announce them

### Fixed

- An unknown query field (e.g. `status` on an endpoint without it), a SOAP server fault, or a connection lost mid-request now raise `BfabricRequestError` / `BfabricUnavailableError` instead of raw suds exceptions, so `bfabric-cli` reports them in one line instead of a traceback.
- `use_client` reports a `ValueError`, `OSError`, YAML error or HTTP error status from the command or from loading the config (e.g. no config file yet) as one `Error:` line instead of a traceback; the new `report_user_errors` context manager applies the same handling elsewhere.
- An unknown config environment (e.g. a typo in `--config-env`) raises a `ValueError` listing the available environments instead of a `KeyError`.
- OAuth requests to an instance recorded with an `http://` base URL are sent over `https://` instead of failing on the server's redirect (loopback hosts excepted). `bfabric.oauth.https_base_url` exposes the upgrade.
- On a CPU without the features polars needs (e.g. AVX2), importing polars through `bfabric` raises polars' `RuntimeWarning` telling you to install `polars[rtcompat]`, instead of the process dying with "Illegal instruction".

### Added

Expand Down
4 changes: 4 additions & 0 deletions bfabric/src/bfabric/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import importlib.metadata
import warnings

from bfabric.bfabric import Bfabric
from bfabric.config.base_url import BaseUrl
from bfabric.config.bfabric_auth import BfabricAuth
from bfabric.config.bfabric_client_config import BfabricAPIEngineType, BfabricClientConfig

# polars only warns about missing CPU features and then dies with SIGILL; raise its install hint instead
warnings.filterwarnings("error", message="Missing required CPU features", category=RuntimeWarning)

__all__ = [
"BaseUrl",
"Bfabric",
Expand Down
2 changes: 1 addition & 1 deletion bfabric/src/bfabric/config/config_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def _load_environment_config_data(config_path: Path | str, force_config_env: str
logger.debug(f"Reading configuration from: {config_file_path} {config_env=}")
config_file = ConfigFile.model_validate(yaml.safe_load(config_file_path.read_text()))
resolved_env = config_file.get_selected_config_env(explicit_config_env=config_env)
env_config = config_file.environments[resolved_env]
env_config = config_file.get_selected_config(explicit_config_env=resolved_env)
return ConfigData(
client=env_config.config,
auth=env_config.auth,
Expand Down
11 changes: 9 additions & 2 deletions bfabric/src/bfabric/config/config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,15 @@ def get_selected_config_env(self, explicit_config_env: str | None) -> str:
return env

def get_selected_config(self, explicit_config_env: str | None = None) -> EnvironmentConfig:
"""Return the selected environment; see :meth:`get_selected_config_env`."""
return self.environments[self.get_selected_config_env(explicit_config_env=explicit_config_env)]
"""Return the selected environment; see :meth:`get_selected_config_env`.

:raises ValueError: if no environment of that name is in the config
"""
name = self.get_selected_config_env(explicit_config_env=explicit_config_env)
if name not in self.environments:
available = ", ".join(sorted(self.environments))
raise ValueError(f"Environment {name!r} not found in the config; available: {available}")
return self.environments[name]


def read_config_file(
Expand Down
36 changes: 32 additions & 4 deletions bfabric/src/bfabric/engine/engine_suds.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from __future__ import annotations

import copy
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any
from urllib.error import URLError

import suds.transport
from suds import MethodNotFound
from suds import MethodNotFound, TypeNotFound, WebFault
from suds.client import Client

from bfabric.engine.response_format_suds import suds_asdict_recursive
Expand All @@ -15,6 +16,8 @@
from bfabric.results.result_container import ResultContainer

if TYPE_CHECKING:
from collections.abc import Iterator

from suds.serviceproxy import ServiceProxy

from bfabric.config import BaseUrl, BfabricAuth
Expand Down Expand Up @@ -58,7 +61,8 @@ def read(
"idonly": return_id_only,
}
service = self._get_suds_service(endpoint)
response = service.read(full_query)
with self._translate_call_errors(endpoint):
response = service.read(full_query) # pyright: ignore[reportAny]
return self._convert_results(response=response, endpoint=endpoint)

def save(
Expand All @@ -78,9 +82,11 @@ def save(
query = {"login": auth.login, "password": auth.password.get_secret_value(), endpoint: obj}
service = self._get_suds_service(endpoint)
try:
response = getattr(service, method)(query)
save_method = getattr(service, method) # pyright: ignore[reportAny]
except MethodNotFound as e:
raise BfabricRequestError(f"SUDS failed to find save method for the {endpoint} endpoint.") from e
with self._translate_call_errors(endpoint):
response = save_method(query) # pyright: ignore[reportAny]
return self._convert_results(response=response, endpoint=endpoint)

def delete(self, endpoint: str, id: int | list[int], auth: BfabricAuth) -> ResultContainer:
Expand All @@ -93,7 +99,8 @@ def delete(self, endpoint: str, id: int | list[int], auth: BfabricAuth) -> Resul
return ResponseDelete.from_empty_request()
query = {"login": auth.login, "password": auth.password.get_secret_value(), "id": id}
service = self._get_suds_service(endpoint)
response = service.delete(query)
with self._translate_call_errors(endpoint):
response = service.delete(query) # pyright: ignore[reportAny]
return ResponseDelete.from_suds(suds_response=response, endpoint=endpoint)

def _get_suds_service(self, endpoint: str) -> ServiceProxy:
Expand All @@ -116,6 +123,27 @@ def _get_suds_service(self, endpoint: str) -> ServiceProxy:
raise BfabricUnavailableError(self._base_url, error) from error
return self._cl[endpoint].service

@contextmanager
def _translate_call_errors(self, endpoint: str) -> Iterator[None]:
"""Translate the suds exceptions a service call raises into errors the callers handle.

None of them is a ``RuntimeError``, so untranslated a typo in a query field (``TypeNotFound``)
escapes the CLI's error handling as a traceback. suds reports an HTTP error status (e.g. a proxy's
502) as a bare ``Exception((status, reason))``, hence the catch-all.
"""
try:
yield
except TypeNotFound as error:
# suds keeps the name only in its message, "Type not found: '<name>'"
name = str(error).removeprefix("Type not found: ")
raise BfabricRequestError(f"Unknown field {name} for the {endpoint!r} endpoint.") from error
except WebFault as error:
raise BfabricRequestError(f"B-Fabric server fault on the {endpoint!r} endpoint: {error}") from error
except OSError as error:
raise BfabricUnavailableError(self._base_url, error) from error
except Exception as error:
raise BfabricRequestError(f"Request to the {endpoint!r} endpoint failed: {error}") from error

def _convert_results(self, response: Any, endpoint: str) -> ResultContainer:
try:
n_available_pages = response["numberofpages"]
Expand Down
2 changes: 1 addition & 1 deletion bfabric/src/bfabric/entities/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def technology_folder_name(self) -> PathSafeStr:
if not _is_technology_list(technology_raw):
raise ValueError("Technology must be a list of strings")
technology = sorted(technology_raw)[0]
return TypeAdapter(PathSafeStr).validate_python(technology)
return TypeAdapter(PathSafeStr).validate_python(technology) # pyright: ignore[reportArgumentType]


def _is_technology_list(technology: ApiResponseDataType) -> TypeGuard[list[str]]:
Expand Down
4 changes: 3 additions & 1 deletion bfabric/src/bfabric/entities/core/uri.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ def invalid(reason: str) -> ValueError:


ValidatedEntityUri = Annotated[str, AfterValidator(_validate_entity_uri)]
entity_uri_adapter: TypeAdapter[str] = TypeAdapter(ValidatedEntityUri)
entity_uri_adapter: TypeAdapter[str] = TypeAdapter( # pyright: ignore[reportUnknownVariableType]
ValidatedEntityUri # pyright: ignore[reportArgumentType]
)


class EntityUri(str):
Expand Down
23 changes: 16 additions & 7 deletions bfabric/src/bfabric/utils/cli_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@
import os
import sys
import traceback
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, NoReturn, TypeVar, cast

import httpx
import yaml
from loguru import logger
from rich.highlighter import RegexHighlighter
from rich.theme import Theme

from bfabric.config import DEFAULT_CONFIG_FILE

if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Iterator

T = TypeVar("T")

Expand Down Expand Up @@ -69,22 +72,28 @@ def wrapper(*args: object, **kwargs: object) -> T:
if "client" in kwargs:
client = cast("Bfabric", kwargs.pop("client"))
else:
try:
with report_user_errors():
client = Bfabric.connect(
config_file_path=config_file or DEFAULT_CONFIG_FILE,
config_file_env=config_env or "default",
)
except (ValueError, RuntimeError) as e:
_report_and_exit(e)
try:
with report_user_errors():
return fn(*args, client=client, **kwargs) # type: ignore[arg-type]
except RuntimeError as e:
_report_and_exit(e)

wrapper.__signature__ = new_sig # type: ignore[reportAttributeAccessIssue]
return wrapper


@contextmanager
def report_user_errors() -> Iterator[None]:
"""Report an error a user can cause (bad argument, missing file, unreadable config, entity not found,
HTTP error status) as one stderr line and exit 1."""
try:
yield
except (RuntimeError, ValueError, OSError, yaml.YAMLError, httpx.HTTPStatusError) as e:
_report_and_exit(e)


def _report_and_exit(error: Exception) -> NoReturn:
"""Reports ``error`` as a single stderr line and exits 1, keeping the traceback for DEBUG runs."""
# Not logger.opt(exception=...): the DEBUG sink runs with loguru's default diagnose=True, which
Expand Down
2 changes: 1 addition & 1 deletion bfabric_asgi_auth/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ demo = [
]
test = [
"bfabric-asgi-auth[demo]",
"pytest>=8.0.0",
"pytest>=8.0.0,<9.2", # shortcut: pytest 9.2 removed an internal pytest-bdd 9.0 uses; lift once pytest-bdd supports it
"pytest-mock>=3.15.1",
"pytest-asyncio>=1.2.0",
"pytest-bdd>=8.1.0",
Expand Down
1 change: 1 addition & 0 deletions bfabric_scripts/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

### Fixed

- `bfabric-cli` reports user errors in one `Error:` line instead of a traceback: an unknown field (e.g. `api read ... --query status ...`), a missing file or config, a missing entity, a malformed config or JSON, an HTTP error from the OAuth endpoints, and an unknown method in `api inspect`. Set `BFABRICPY_LOG_LEVEL=DEBUG` to see the traceback.
- `bfabric-cli login` and `auth device-code` no longer fail on an environment recorded with an `http://` base URL; they use `https://` and save the corrected URL.

### Changed
Expand Down
5 changes: 3 additions & 2 deletions bfabric_scripts/src/bfabric_scripts/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import cyclopts

from bfabric.utils.cli_integration import setup_script_logging
from bfabric.utils.cli_integration import report_user_errors, setup_script_logging
from bfabric_scripts.cli.cli_api import cmd_api
from bfabric_scripts.cli.cli_dataset import cmd_dataset
from bfabric_scripts.cli.cli_executable import cmd_executable
Expand Down Expand Up @@ -31,7 +31,8 @@ def main() -> None:
"""CLI entry point; sets logging up here because commands without ``@use_client`` would
otherwise run at loguru's default DEBUG level."""
setup_script_logging()
app()
with report_user_errors():
app()


if __name__ == "__main__":
Expand Down
8 changes: 6 additions & 2 deletions bfabric_scripts/src/bfabric_scripts/cli/api/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Any

from pydantic import BaseModel, Field
from suds import MethodNotFound # pyright: ignore[reportMissingTypeStubs]
from suds.xsd.query import TypeQuery # pyright: ignore[reportMissingTypeStubs]

from bfabric import Bfabric
Expand Down Expand Up @@ -45,7 +46,7 @@ def parse_method_signature(
"""Parses a SOAP method signature into reusable Pydantic models.

:raises RuntimeError: If the client is not configured to use the SUDS engine.
:raises AttributeError: If the endpoint or method does not exist.
:raises ValueError: If the method does not exist on the endpoint.
"""
# The WSDL introspection below is written against SUDS internals, so reject any other engine up
# front instead of leaking an AttributeError from the private engine.
Expand All @@ -57,7 +58,10 @@ def parse_method_signature(

service = client._engine._get_suds_service(endpoint) # type: ignore[attr-defined] # pyright: ignore[reportPrivateUsage,reportAttributeAccessIssue,reportUnknownVariableType,reportUnknownMemberType]

method = getattr(service, method_name) # pyright: ignore[reportAny,reportUnknownArgumentType]
try:
method = getattr(service, method_name) # pyright: ignore[reportAny,reportUnknownArgumentType]
except MethodNotFound as error:
raise ValueError(f"Unknown method {method_name!r} for the {endpoint!r} endpoint.") from error

binding = method.method.binding.input # pyright: ignore[reportAny]
param_defs = binding.param_defs(method.method) # pyright: ignore[reportAny]
Expand Down
6 changes: 6 additions & 0 deletions tests/bfabric/config/test_config_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ def test_from_file_and_file_does_not_exist(self, mocker):
with pytest.raises(OSError, match="No explicit config provided, and no config file found at"):
load_config_data(config_file_path="/dev/null", config_file_env="TEST", include_auth=True)

def test_from_file_unknown_env(self, mocker, example_config_path):
mock_env = mocker.patch.dict(os.environ)
mock_env.pop("BFABRICPY_CONFIG_OVERRIDE", None)
with pytest.raises(ValueError, match="Environment 'NOSUCHENV' not found in the config"):
load_config_data(config_file_path=example_config_path, config_file_env="NOSUCHENV", include_auth=True)


def test_export_roundtrip(config_data):
exported = export_config_data(config_data)
Expand Down
6 changes: 6 additions & 0 deletions tests/bfabric/config/test_config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ def test_get_selected_config(config_with_auth, mocker):
mock_get_config_env.assert_called_once_with(explicit_config_env=None)


def test_get_selected_config_unknown_env(config_with_auth):
"""A typo in ``--config-env`` names the available environments instead of raising a bare KeyError."""
with pytest.raises(ValueError, match=r"Environment 'PRODUCTOIN' not found in the config; available: .*PRODUCTION"):
config_with_auth.get_selected_config("PRODUCTOIN")


def test_reject_env_name_default(mocker, data_no_auth):
data_no_auth["default"] = {"base_url": "https://example.com/bfabric"}
with pytest.raises(ValueError) as error:
Expand Down
Loading
Loading