Skip to content

feat(metering): add Python SDK support for the Metering API#2663

Open
krishna1633 wants to merge 10 commits into
masterfrom
krishna/python-sdk-support-for-metering-api
Open

feat(metering): add Python SDK support for the Metering API#2663
krishna1633 wants to merge 10 commits into
masterfrom
krishna/python-sdk-support-for-metering-api

Conversation

@krishna1633

Copy link
Copy Markdown
Contributor

Description

Adds SDK support for the Metering API to retrieve consumption data for a CDF project. Follows the same pattern as the Limits API (#2436).

API spec: versions/v1/metering.preproc.yml

Key Features

  • Data classes: MeteringData, MeteringDataList, MeteringDataPoint
  • API methods on client.metering:
    • retrieve(id, start, end, number_of_datapoints) — GET /metering/meters/{meterId}
    • retrieve_multiple(ids, start, end, number_of_datapoints) — POST /metering/meters/byids
    • list(filter, limit, start, end, number_of_datapoints) — GET/POST /metering/meters[/list]
  • All methods support optional time-series parameters (start + number_of_datapoints together enable historical data)
  • Uses cdf-version: {api_subversion}-alpha header (alpha API)
  • Feature preview warning for alpha endpoint

Implementation Details

  • retrieve() — GET with optional query params for time range
  • retrieve_multiple() — direct _post to /byids (custom meterId field, not standard id/externalId)
  • list() — GET without filter, POST to /list with Prefix filter; time-range params go as query params (GET) or body (POST) via other_params
  • Added MeterId identifier class in utils/_identifier.py (mirrors LimitId)
  • Auto-generated sync client files via pre-commit sync-client-codegen hook

Checklist

  • Tests added (unit + integration)
  • Documentation added (docs/source/metering.rst)
  • All pre-commit hooks pass (ruff, mypy, custom checks, pydoclint, codespell, sync codegen)
  • "metering" added to idempotent POST whitelist in test_meta.py
  • Metering URL retryability test cases added to test_api_client.py

Adds `client.metering` with `retrieve`, `retrieve_multiple`, and `list`
methods backed by the alpha Metering API endpoints.
@codecov

codecov Bot commented Jun 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.91697% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.59%. Comparing base (35f32b7) to head (677c451).
⚠️ Report is 12 commits behind head on master.

Files with missing lines Patch % Lines
tests/tests_integration/test_api/test_metering.py 93.33% 2 Missing ⚠️
cognite/client/utils/_identifier.py 90.90% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2663      +/-   ##
==========================================
+ Coverage   93.11%   93.59%   +0.47%     
==========================================
  Files         490      497       +7     
  Lines       49869    50222     +353     
==========================================
+ Hits        46437    47003     +566     
+ Misses       3432     3219     -213     
Files with missing lines Coverage Δ
cognite/client/_api/limits.py 100.00% <100.00%> (ø)
cognite/client/_api/metering.py 100.00% <100.00%> (ø)
cognite/client/_basic_api_client.py 95.13% <100.00%> (+0.08%) ⬆️
cognite/client/_cognite_client.py 95.74% <100.00%> (+0.05%) ⬆️
cognite/client/_sync_api/limits.py 100.00% <ø> (ø)
cognite/client/_sync_api/metering.py 100.00% <100.00%> (ø)
cognite/client/_sync_cognite_client.py 88.88% <100.00%> (+0.20%) ⬆️
cognite/client/data_classes/__init__.py 100.00% <100.00%> (ø)
cognite/client/data_classes/metering.py 100.00% <100.00%> (ø)
cognite/client/testing.py 100.00% <100.00%> (ø)
... and 5 more

... and 22 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@krishna1633 krishna1633 marked this pull request as ready for review June 4, 2026 09:31
@krishna1633 krishna1633 requested review from a team as code owners June 4, 2026 09:31

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the Metering API to the SDK, adding support for listing and retrieving consumption data through both asynchronous and synchronous clients, along with corresponding data classes, documentation, and tests. The review feedback recommends adding defensive input validation to retrieve_multiple to handle empty lists, single strings, or invalid element types, as well as adding unit tests to verify these defensive checks.

Comment thread cognite/client/_api/metering.py Outdated
Comment thread tests/tests_unit/test_api/test_metering.py

@haakonvt haakonvt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments - did not make it all the way through 😄 A lot looks great btw, much nits here:

Comment thread cognite/client/_api/metering.py Outdated
Comment on lines +28 to +29
def _alpha_headers(self) -> dict[str, str]:
return {"cdf-version": f"{self._config.api_subversion}-alpha"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets move this to utilities + make it check that "alpha" is not already in self._config.api_subversion

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...and please, change existing uses like headers = {"cdf-version": f"{self._config.api_subversion}-alpha"} ❤️

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — moved to BasicAsyncAPIClient._alpha_version_header() with a double-alpha guard. Updated LimitsAPI inline uses as well.

Comment thread cognite/client/_api/metering.py Outdated
Comment on lines +49 to +50
start: int | None = None,
end: int | None = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See what e.g. DatapointsAPI supports for start/end (at least tz aware datetime)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — start and end now accept int (epoch ms), datetime, or relative strings like "2w-ago". Converted to epoch ms via timestamp_to_ms before hitting the API.

Comment thread cognite/client/_api/metering.py Outdated
For instance ``atlas.monthly_ai_tokens`` is the id of the ``atlas`` service metering ``monthly_ai_tokens``.
Service and metering names are always in ``lower_snake_case``.
start (int | None): Start timestamp (inclusive) for historical data, in milliseconds since epoch.
**Must be provided together with** ``number_of_datapoints`` to get time-series data.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must be provided together with

Should you validate this in the code?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a client-side guard would diverge from the SDK pattern of not duplicating server-enforced rules. Caller needs an error to know. It may not be concerned if error is from sdk or API as long as it is clear.
Per the server spec, providing only start without numberOfDatapoints silently returns metadata (server default is 0) — this is documented, expected behavior. The docstring already states Must be provided together with.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We regularly duplicate such logic in the SDK for immediate user feedback, so please add here. I agree that for the number of allowed datapoints, that might change in the future so it should not be validated.

Comment thread cognite/client/_api/metering.py Outdated
params=params,
)

async def retrieve_multiple(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use retrieve_multiple anymore; it is from the time when we did not have typing.overload 😄

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — removed retrieve_multiple, retrieve now uses @overload (single str → MeteringData | None, list → MeteringDataList). Same pattern as UnitsAPI

Comment thread cognite/client/_api/metering.py Outdated

Args:
filter (Prefix | None): Optional ``Prefix`` filter to apply on the ``meterId`` property (only ``Prefix`` filters are supported).
limit (int | None): Maximum number of meters to return. Defaults to 1000. Set to ``None`` or ``-1`` to return all meters.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaults to 1000?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — docstring now correctly says 25 (DEFAULT_LIMIT_READ)

Comment thread cognite/client/_api/metering.py Outdated
other_params = self._time_range_params(start, end, number_of_datapoints) or None

return await self._list(
method="GET" if filter is None else "POST",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't you just always use GET here? Passing no filter is kind of like passing a filter matching everything, right?

    method="GET" if filter is None else "POST",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did not follow.
API supports both . GET is used for the no-filter case since the endpoint doesn't accept a body.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then why can't you always use POST?

Comment thread cognite/client/data_classes/metering.py Outdated
Comment on lines +27 to +28
def __eq__(self, other: Any) -> bool:
return isinstance(other, MeteringDataPoint) and self.dump() == other.dump()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why have you implemented dunder eq?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — removed. CogniteResource provides eq via dump()

Comment thread cognite/client/data_classes/metering.py Outdated
from cognite.client.data_classes._base import CogniteResource, CogniteResourceList, basic_instance_dump


class MeteringDataPoint:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should inherit from CogniteResource

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread cognite/client/data_classes/metering.py Outdated
def _load(cls, resource: dict[str, Any]) -> MeteringDataPoint:
return cls(timestamp=resource["timestamp"], average=resource["average"])

def dump(self) -> dict[str, Any]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing "camel_case" arg (although it doesnt do anything here)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@krishna1633

Copy link
Copy Markdown
Contributor Author

A few comments - did not make it all the way through 😄 A lot looks great btw, much nits here:

Sure. Once I address these comments, will ask you for another iteration so you can all the way through

@krishna1633 krishna1633 requested a review from haakonvt June 4, 2026 10:47
@krishna1633

Copy link
Copy Markdown
Contributor Author

@haakonvt This is ready again

@haakonvt haakonvt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please resolve comments you have addressed


def _alpha_version_header(self) -> dict[str, str]:
subversion = self._config.api_subversion
version = subversion if subversion.endswith("-alpha") else subversion + "-alpha"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be on the safe side

Suggested change
version = subversion if subversion.endswith("-alpha") else subversion + "-alpha"
version = subversion if "alpha" in subversion else subversion + "-alpha"

self._warning.warn()

headers = {"cdf-version": f"{self._config.api_subversion}-alpha"}
headers = self._alpha_version_header()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks!

Comment on lines +107 to +124
self._warning.warn()
if isinstance(id, str):
params = self._time_range_params(start, end, number_of_datapoints) or None
return await self._retrieve(
identifier=MeterId(id),
cls=MeteringData,
headers=self._alpha_version_header(),
params=params,
)
body: dict[str, Any] = {"items": [MeterId(id_).as_dict() for id_ in id]}
body.update(self._time_range_params(start, end, number_of_datapoints))
res = await self._post(
url_path=self._RESOURCE_PATH + "/byids",
json=body,
headers=self._alpha_version_header(),
semaphore=self._get_semaphore("read"),
)
return MeteringDataList._load(res.json()["items"])._maybe_set_client_ref(self._cognite_client)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See if you can use _retrieve_multiple

other_params = self._time_range_params(start, end, number_of_datapoints) or None

return await self._list(
method="GET" if filter is None else "POST",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then why can't you always use POST?

res = cognite_client.metering.list()

assert isinstance(res, MeteringDataList)
assert all(meter.meter_id is not None for meter in res)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use all in tests as they give false positives for empty (unless you first assert on non-empty) :

>>> all(x is None for x in())
True


assert isinstance(res, MeteringDataList)
assert len(res) == len(ids)
assert [m.meter_id for m in res] == ids

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as_ids?

json=ATLAS_METER,
)

meter_id = str(ATLAS_METER["meterId"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already str

Comment on lines +62 to +68
url_pattern = re.compile(re.escape(f"{metering_url}/{ATLAS_METER['meterId']}") + r"\?.*")
httpx_mock.add_response(
method="GET",
url=url_pattern,
status_code=200,
json=ATLAS_METER_WITH_DATA,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks repeated? Fixture

assert f"start={timestamp_to_ms(start_dt)}" in request_url
assert f"end={timestamp_to_ms(end_dt)}" in request_url

def test_retrieve_with_relative_string_start(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So many tests are slightly similar here; please manually make a selection. Consider parametrize.

start_val = int(request_url.split("start=")[1].split("&")[0])
assert start_val > 0

def test_dump_and_load_roundtrip(self) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dump load roundtrips are automatically tested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants