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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.0] - 2026-08-04

### Added

- `StaticTokenAuth` and a new `auth=` parameter on `AsyncBCClient`, for embedders
that already hold a Business Central access token. Pass a token string or a
callable — a callable is re-invoked per request, so a long-running process
picks up a refreshed token instead of pinning one that expires. Injected auth
bypasses the profile's `auth_method` entirely, which is what lets a `browser`
profile work somewhere that has no browser and no loopback listener to bind.
- `bcli.queries`, a reusable saved-query module extracted from the CLI: catalog
loading, parameter validation against each parameter's declared
type/pattern/min/max/enum, and `${{ params.X }}` resolution with OData
escaping. `QueryCatalogError`, `QueryError` and `QueryParamError` are exported
from the package root. `bcli q` is now a consumer of this module; its output,
errors and exit codes are unchanged.
- Python 3.14 in the supported-versions classifiers. `requires-python` stays at
`>=3.11`.

### Fixed

- `build_url` now validates `entity_set_name` and `record_id` as single URL path
components, rejecting raw `/`, `\`, `?`, `#` and the `.`/`..` segments. Both
values are spliced directly into the request path, so a record key containing
a path separator could compose a URL addressing a different entity than the
one the endpoint registry was consulted about — the registry lookup and the
`disable_standard_api` gate both key on the entity-set name alone. Keys that
legitimately contain quotes, commas, equals signs, hyphens or parentheses
inside a quoted string are unaffected; percent-encode a separator that is
genuinely part of a key. The same validation applies to the key inside a
bound-action invocation, whose resolver checks the registry for the parent
entity set only.
- `test_no_context_policy_path` no longer asserts against the developer's real
`~/.config/bcli`, so it passes on a machine that has recorded a bcli error
rather than only on a clean CI home.

## [0.6.2] - 2026-07-22

### Fixed
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ build-backend = "hatchling.build"
# installed CLI binary (`bcli`) are unaffected — only `pip install` /
# `uv tool install` use this name.
name = "bc-cli"
version = "0.6.2"
version = "0.7.0"
description = "Python SDK and CLI for Microsoft Dynamics 365 Business Central APIs"
readme = "README.md"
license = "Apache-2.0"
Expand All @@ -26,6 +26,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Office/Business :: Financial :: Accounting",
]
Expand Down
6 changes: 6 additions & 0 deletions src/bcli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""bcli — Python SDK for Microsoft Dynamics 365 Business Central APIs."""

from bcli._version import __version__
from bcli.auth._static import StaticTokenAuth
from bcli.client import AsyncBCClient, BCClient
from bcli.client._safety import DomainRule, SafeContext
from bcli.config import BCConfig, load_config
Expand All @@ -19,6 +20,7 @@
WorkflowError,
)
from bcli.odata import Query
from bcli.queries import QueryCatalogError, QueryError, QueryParamError
from bcli.registry import EndpointRegistry

__all__ = [
Expand All @@ -35,10 +37,14 @@
"ForbiddenError",
"NotFoundError",
"Query",
"QueryCatalogError",
"QueryError",
"QueryParamError",
"RegistryError",
"SafeContext",
"SafetyError",
"ServerError",
"StaticTokenAuth",
"ThrottledError",
"ValidationError",
"WorkflowError",
Expand Down
41 changes: 41 additions & 0 deletions src/bcli/_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,43 @@ def _validate_route_segment(name: str, value: str) -> None:
)


#: Characters that let a value escape the URL component it is spliced into.
#: A record key legitimately contains quotes, commas, equals signs, hyphens and
#: even parentheses inside a quoted string (``'ACME (US)'``) — none of which can
#: start a new path segment. These four can.
_URL_STRUCTURE_CHARS = ("/", "\\", "?", "#")


def validate_record_key(name: str, value: str) -> None:
"""Validate an OData key or entity-set name as a single URL path component.

``entity_set_name`` and ``record_id`` are spliced straight into the path, and
a key is caller-influenced anywhere one is accepted from outside. A raw ``/``
starts a new path segment, so ``foo(1)/../../bar('X'`` composes a URL that
addresses ``bar`` while every earlier check only ever saw ``foo`` — including
the endpoint-registry lookup and the ``disable_standard_api`` gate, both of
which key on the entity-set name alone.

Raises ValueError on empty input, raw path/query delimiters, and the
``.``/``..`` traversal segments.
"""
if not value or not value.strip():
raise ValueError(f"Invalid {name}: must not be empty.")

for char in _URL_STRUCTURE_CHARS:
if char in value:
raise ValueError(
f"Invalid {name} {value!r}: must not contain {char!r}. A key is a "
f"single URL path component — percent-encode the character if it "
f"is genuinely part of the key."
)

if value.strip() in (".", ".."):
raise ValueError(
f"Invalid {name} {value!r}: '.' and '..' are path-traversal segments."
)


def build_url(
*,
environment: str,
Expand All @@ -49,6 +86,10 @@ def build_url(
Custom API:
https://api.businesscentral.dynamics.com/v2.0/{env}/api/{pub}/{grp}/{ver}/companies({id})/{entity}
"""
validate_record_key("entity_set_name", entity_set_name)
if record_id:
validate_record_key("record_id", record_id)

if publisher and group and version:
_validate_route_segment("publisher", publisher)
_validate_route_segment("group", group)
Expand Down
82 changes: 82 additions & 0 deletions src/bcli/auth/_static.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Auth provider for an access token the caller already holds.

Every other provider in this package *acquires* a token — by opening a browser,
by polling a device-code flow, or by exchanging a client secret. Some embedders
have a token already and only need the SDK to use it:

* a hosted service that performs an on-behalf-of exchange per request, so the BC
call runs as the signed-in user rather than as the service;
* a CI job handed a token by its platform;
* a script or notebook pasting one in for a one-off.

None of those can use the browser flow, which opens a browser and binds a
loopback listener on whichever machine runs the process.

Pass either a fixed string or a supplier. Prefer a **supplier** in any
long-running process: it is re-invoked on every call, so a refreshed token is
picked up instead of a pinned one expiring mid-session. Nothing is cached here —
the caller owns the token's lifetime, which is why :meth:`clear_cache` is a
no-op.
"""

from __future__ import annotations

import inspect
from collections.abc import Awaitable, Callable

from bcli.errors import ConfigError

TokenSupplier = Callable[[], "str | Awaitable[str]"]


class StaticTokenAuth:
"""Supply a pre-acquired bearer token. Satisfies the ``AuthProvider`` protocol.

Args:
token: The access token, or a callable returning one (sync or async).
"""

def __init__(self, token: str | TokenSupplier) -> None:
if callable(token):
self._supplier: TokenSupplier | None = token
self._token: str | None = None
else:
self._supplier = None
self._token = self._validated(token)

@staticmethod
def _validated(raw: object) -> str:
"""Reject the two mistakes that would otherwise surface as a puzzling 401."""
if not isinstance(raw, str):
raise ConfigError(
f"Access token must be a string, got {type(raw).__name__}. "
"A token supplier must return the raw token."
)
token = raw.strip()
if not token:
raise ConfigError(
"Access token is empty. Pass the raw bearer token, or a callable "
"that returns one."
)
if token.lower().startswith("bearer "):
raise ConfigError(
"Access token must not include the 'Bearer ' prefix — the transport "
"adds it, so this would send 'Authorization: Bearer Bearer ...'."
)
return token

async def get_access_token(self) -> str:
"""Return the token, re-invoking the supplier if one was given."""
if self._supplier is None:
# Validated in __init__; narrowing for type checkers.
assert self._token is not None
return self._token

supplied = self._supplier()
if inspect.isawaitable(supplied):
supplied = await supplied
return self._validated(supplied)

def clear_cache(self) -> None:
"""No-op — nothing is cached. Supply a callable if you need invalidation."""
return None
23 changes: 20 additions & 3 deletions src/bcli/client/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from pathlib import Path
from typing import Any

from bcli._url import build_companies_url, build_url
from bcli._url import build_companies_url, build_url, validate_record_key
from bcli.auth._base import AuthProvider
from bcli.auth._credentials import ClientCredentialsAuth
from bcli.client._safety import SafeContext
from bcli.client._transport import BCTransport
Expand Down Expand Up @@ -59,7 +60,15 @@ def _parse_bound_action(entity_set_name: str) -> tuple[str, str, str] | None:
m = _BOUND_ACTION_RE.match(entity_set_name)
if m is None:
return None
return m.group("entity"), m.group("key"), m.group("qualified")
entity, key, qualified = m.group("entity"), m.group("key"), m.group("qualified")
# The key group is deliberately permissive (``.+``) so real composite and
# quoted keys pass through unmangled. That also lets a path separator in,
# which matters here because the resolver below splices ``(key)/qualified``
# onto a parent URL and consults the registry about the *parent* only. A key
# carrying ``/`` would therefore address one entity while the gate approved
# another. Validate it as the single path component it is meant to be.
validate_record_key("bound-action record key", key)
return entity, key, qualified


def _is_unbound_action(entity_set_name: str) -> bool:
Expand Down Expand Up @@ -99,6 +108,7 @@ def __init__(
company_id: str | None = None,
# Shared options
timeout: int | None = None,
auth: AuthProvider | None = None,
) -> None:
if tenant_id is not None:
# Programmatic mode — build a synthetic profile
Expand All @@ -125,10 +135,17 @@ def __init__(

self._transport: BCTransport | None = None
self._timeout = timeout or self._config.defaults.timeout
self._injected_auth = auth

def _ensure_transport(self) -> BCTransport:
if self._transport is None:
auth = self._build_auth(self._profile, self._programmatic_secret, self._config)
# An injected provider wins outright: the caller already holds a token,
# so the profile's auth_method is never consulted. That is what lets a
# `browser` profile work server-side, where there is no local browser
# and no loopback listener to bind.
auth = self._injected_auth or self._build_auth(
self._profile, self._programmatic_secret, self._config
)
self._transport = BCTransport(auth, timeout=self._timeout)
return self._transport

Expand Down
62 changes: 62 additions & 0 deletions src/bcli/queries/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Reusable saved-query engine.

Catalog loading, parameter validation, ``${{ }}`` expansion, and discovery
(list/search/info) — the whole pipeline behind ``bcli q <name> key=value``,
extracted from the CLI so any consumer (a workflow step, a remote MCP
server) can run a named, parametrised OData query without hand-rolling it
again or importing CLI code to get at it.

Nothing here performs an HTTP call or talks to a console/terminal — see
:mod:`bcli_cli.commands.query_cmd` for the thin CLI layer built on top of
this package.

Typical flow::

catalog = load_catalog(Path("~/.config/bcli/queries/tech-prod.yaml"))
name = resolve_query_name(catalog, "customer-by-name") # follows aliases
spec = catalog[name]
params = resolve_params(spec.get("params", {}), {"name": "Fabrikam"})
resolved = expand_query(spec, params) # -> ResolvedQuery
query = resolved.to_query() # -> bcli.odata.Query
response = await client.get(resolved.endpoint, query=query)
"""

from __future__ import annotations

from bcli.queries._catalog import (
RESERVED_QUERY_NAMES,
load_catalog,
load_catalog_from_mapping,
resolve_alias,
resolve_query_name,
)
from bcli.queries._errors import QueryCatalogError, QueryError, QueryParamError
from bcli.queries._expand import ODATA_FIELDS, ResolvedQuery, expand_query
from bcli.queries._params import VALID_PARAM_TYPES, resolve_params, validate_param
from bcli.workflow._query_search import (
QueryEntry,
filter_entries,
normalize_queries,
search_entries,
)

__all__ = [
"ODATA_FIELDS",
"RESERVED_QUERY_NAMES",
"VALID_PARAM_TYPES",
"QueryCatalogError",
"QueryEntry",
"QueryError",
"QueryParamError",
"ResolvedQuery",
"expand_query",
"filter_entries",
"load_catalog",
"load_catalog_from_mapping",
"normalize_queries",
"resolve_alias",
"resolve_params",
"resolve_query_name",
"search_entries",
"validate_param",
]
Loading
Loading