Skip to content

Commit 671f475

Browse files
authored
[DE-6014] Add support for limited access keys (#450)
* Add sdk support for limited access keys * Update NoAPIKey error messaging * Use cimg/python:3.7 for build_test as debian buster is EOL * Try using debian bullseye * Fix typecheck errors * Fix isort import errors * Fix api key env var names * Fix nucleus.NucleusClient formatting in docs * Skip test_box_pred_upload_embedding bc of issue with customObjectIndexingJobId * Update os.environ for cli tests * Update test cli os.environ * Skip indexing tests * Update NucleusClient __init__ description * Update reasoning for skipping indexing jobs * Update description of NucleusClient __init__ * Investigate if test_job_listing_and_retrieval doesn't work with limited access keys * Revert "Investigate if test_job_listing_and_retrieval doesn't work with limited access keys" This reverts commit d15e725. * Add sleep in test_job_listing_and_retrieval * Revert "Add sleep in test_job_listing_and_retrieval" This reverts commit 67b8713.
1 parent 892403b commit 671f475

File tree

15 files changed

+143
-31
lines changed

15 files changed

+143
-31
lines changed

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
docker:
1111
# Important: Don't change this otherwise we will stop testing the earliest
1212
# python version we have to support.
13-
- image: python:3.7-buster
13+
- image: python:3.7-bullseye
1414
resource_class: medium
1515
parallelism: 6
1616
steps:

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88

9+
## [0.17.11](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.17.11) - 2025-11-03
10+
11+
### Added
12+
- Support passing a limited access key via `NucleusClient(limited_access_key=...)`. When provided, the client sends the `x-limited-access-key` header on all requests (sync and async).
13+
- Allow using the SDK without a standard API key when a `limited_access_key` is supplied. In this mode, Basic Auth is omitted and only the limited access header is used.
14+
15+
Example usage:
16+
17+
```python
18+
client = nucleus.NucleusClient(limited_access_key="<LIMITED_ACCESS_KEY>")
19+
#...
20+
```
21+
22+
### Changed
23+
- `Connection` accepts `extra_headers` and only includes Basic Auth when `api_key` is provided. This enables header-only auth with limited access keys.
24+
- Header propagation applies across all request paths, including Validate endpoints and concurrent async helpers.
25+
- Tests updated to be tolerant of limited-access-only runs.
26+
- NoAPIKey error messaging updated to account for limited_access_key support.
27+
928
## [0.17.10](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.17.10) - 2025-03-19
1029

1130
### Added

cli/client.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ def init_client():
1212
import nucleus
1313

1414
api_key = os.environ.get("NUCLEUS_API_KEY", None)
15-
if api_key:
16-
client = nucleus.NucleusClient(api_key)
15+
limited_access_key = os.environ.get("NUCLEUS_LIMITED_ACCESS_KEY", None)
16+
if api_key or limited_access_key:
17+
client = nucleus.NucleusClient(api_key=api_key, limited_access_key=limited_access_key)
1718
else:
18-
raise RuntimeError("No NUCLEUS_API_KEY set")
19+
raise RuntimeError(
20+
"Set NUCLEUS_API_KEY or NUCLEUS_LIMITED_ACCESS_KEY"
21+
)
1922
return client

conftest.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,23 @@
1111
if TYPE_CHECKING:
1212
from nucleus import NucleusClient
1313

14-
assert "NUCLEUS_PYTEST_API_KEY" in os.environ, (
15-
"You must set the 'NUCLEUS_PYTEST_API_KEY' environment variable to a valid "
16-
"Nucleus API key to run the test suite"
14+
assert "NUCLEUS_PYTEST_API_KEY" in os.environ or "NUCLEUS_PYTEST_LIMITED_ACCESS_KEY" in os.environ, (
15+
"You must set at least one of 'NUCLEUS_PYTEST_API_KEY' or "
16+
"'NUCLEUS_PYTEST_LIMITED_ACCESS_KEY' environment variables to run the test suite"
1717
)
1818

19-
API_KEY = os.environ["NUCLEUS_PYTEST_API_KEY"]
19+
API_KEY = os.environ.get("NUCLEUS_PYTEST_API_KEY") if "NUCLEUS_PYTEST_API_KEY" in os.environ else None
20+
LIMITED_ACCESS_KEY = os.environ.get("NUCLEUS_PYTEST_LIMITED_ACCESS_KEY") if "NUCLEUS_PYTEST_LIMITED_ACCESS_KEY" in os.environ else None
2021

2122

2223
@pytest.fixture(scope="session")
2324
def CLIENT():
24-
client = nucleus.NucleusClient(API_KEY)
25-
return client
25+
if API_KEY and LIMITED_ACCESS_KEY:
26+
return nucleus.NucleusClient(api_key=API_KEY, limited_access_key=LIMITED_ACCESS_KEY)
27+
if API_KEY:
28+
return nucleus.NucleusClient(api_key=API_KEY)
29+
# LIMITED_ACCESS_KEY only
30+
return nucleus.NucleusClient(limited_access_key=LIMITED_ACCESS_KEY)
2631

2732

2833
@pytest.fixture()

nucleus/__init__.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,19 +176,49 @@ class NucleusClient:
176176
177177
Parameters:
178178
api_key: Follow `this guide <https://scale.com/docs/account#section-api-keys>`_
179-
to retrieve your API keys.
179+
to retrieve your API keys. **Only** optional when ``limited_access_key`` is provided.
180180
use_notebook: Whether the client is being used in a notebook (toggles tqdm
181181
style). Default is ``False``.
182182
endpoint: Base URL of the API. Default is Nucleus's current production API.
183+
limited_access_key: Key enabling additional, scoped access. **Only** optional when ``api_key`` is provided. Reach out to your Scale representative to obtain a limited access key.
184+
185+
Authentication notes:
186+
Some users have Nucleus-only API keys. You can
187+
instantiate the client with only ``limited_access_key`` (no ``api_key``) and the SDK
188+
will authenticate requests using this key only. If both
189+
``api_key`` and ``limited_access_key`` are provided, Basic Auth (``api_key``) and the
190+
additional limited access key will both be sent.
191+
192+
.. code-block:: python
193+
194+
# Using a basic auth key
195+
import nucleus
196+
client = nucleus.NucleusClient(api_key="YOUR_API_KEY", ...)
197+
198+
# Using only a limited access key (no Basic Auth)
199+
import nucleus
200+
client = nucleus.NucleusClient(limited_access_key="YOUR_LIMITED_KEY", ...)
201+
202+
# Using both keys (Basic Auth and limited access header)
203+
client = nucleus.NucleusClient(
204+
api_key="YOUR_API_KEY",
205+
limited_access_key="YOUR_LIMITED_KEY",
206+
...
207+
)
183208
"""
184209

185210
def __init__(
186211
self,
187212
api_key: Optional[str] = None,
188213
use_notebook: bool = False,
189214
endpoint: Optional[str] = None,
215+
limited_access_key: Optional[str] = None,
190216
):
191-
self.api_key = self._set_api_key(api_key)
217+
# Allow usage with only a limited access key
218+
if api_key is None and limited_access_key:
219+
self.api_key = None
220+
else:
221+
self.api_key = self._set_api_key(api_key)
192222
self.tqdm_bar = tqdm.tqdm
193223
if endpoint is None:
194224
self.endpoint = os.environ.get(
@@ -201,8 +231,11 @@ def __init__(
201231
import tqdm.notebook as tqdm_notebook
202232

203233
self.tqdm_bar = tqdm_notebook.tqdm
204-
self.connection = Connection(self.api_key, self.endpoint)
205-
self.validate = Validate(self.api_key, self.endpoint)
234+
self.extra_headers: Dict[str, str] = {}
235+
if limited_access_key:
236+
self.extra_headers["x-limited-access-key"] = limited_access_key
237+
self.connection = Connection(self.api_key, self.endpoint, extra_headers=self.extra_headers)
238+
self.validate = Validate(self.api_key, self.endpoint, extra_headers=self.extra_headers)
206239

207240
def __repr__(self):
208241
return f"NucleusClient(api_key='{self.api_key}', use_notebook={self._use_notebook}, endpoint='{self.endpoint}')"

nucleus/async_utils.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,14 @@ async def _post_form_data(
185185
async with session.post(
186186
endpoint,
187187
data=form,
188-
auth=aiohttp.BasicAuth(client.api_key, ""),
188+
auth=(
189+
(lambda k: aiohttp.BasicAuth(str(k), ""))(
190+
getattr(client, "api_key", None)
191+
)
192+
if getattr(client, "api_key", None) is not None
193+
else None
194+
),
195+
headers=getattr(client, "extra_headers", None),
189196
timeout=DEFAULT_NETWORK_TIMEOUT_SEC,
190197
) as response:
191198
data = await _parse_async_response(
@@ -223,7 +230,14 @@ async def _make_request(
223230
for sleep_time in RetryStrategy.sleep_times() + [-1]:
224231
async with session.get(
225232
endpoint,
226-
auth=aiohttp.BasicAuth(client.api_key, ""),
233+
auth=(
234+
(lambda k: aiohttp.BasicAuth(str(k), ""))(
235+
getattr(client, "api_key", None)
236+
)
237+
if getattr(client, "api_key", None) is not None
238+
else None
239+
),
240+
headers=getattr(client, "extra_headers", None),
227241
timeout=DEFAULT_NETWORK_TIMEOUT_SEC,
228242
) as response:
229243
data = await _parse_async_response(

nucleus/connection.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,21 @@
55
import requests
66

77
from .constants import DEFAULT_NETWORK_TIMEOUT_SEC
8-
from .errors import NucleusAPIError
8+
from .errors import NoAPIKey, NucleusAPIError
99
from .logger import logger
1010
from .retry_strategy import RetryStrategy
1111

1212

1313
class Connection:
1414
"""Wrapper of HTTP requests to the Nucleus endpoint."""
1515

16-
def __init__(self, api_key: str, endpoint: Optional[str] = None):
16+
def __init__(self, api_key: Optional[str] = None, endpoint: Optional[str] = None, extra_headers: Optional[dict] = None):
1717
self.api_key = api_key
1818
self.endpoint = endpoint
19+
self.extra_headers = extra_headers or {}
20+
# Require at least one auth mechanism: Basic (api_key) or limited access header
21+
if self.api_key is None and not self.extra_headers.get("x-limited-access-key"):
22+
raise NoAPIKey()
1923

2024
def __repr__(self):
2125
return (
@@ -63,13 +67,19 @@ def make_request(
6367
logger.info("Make request to %s", endpoint)
6468

6569
for retry_wait_time in RetryStrategy.sleep_times():
70+
auth_kwargs = (
71+
{"auth": (self.api_key, "")} if self.api_key is not None else {}
72+
)
6673
response = requests_command(
6774
endpoint,
6875
json=payload,
69-
headers={"Content-Type": "application/json"},
70-
auth=(self.api_key, ""),
76+
headers={
77+
"Content-Type": "application/json",
78+
**(self.extra_headers or {}),
79+
},
7180
timeout=DEFAULT_NETWORK_TIMEOUT_SEC,
7281
verify=os.environ.get("NUCLEUS_SKIP_SSL_VERIFY", None) is None,
82+
**auth_kwargs,
7383
)
7484
logger.info(
7585
"API request has response code %s", response.status_code

nucleus/errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def __init__(
6868
class NoAPIKey(Exception):
6969
def __init__(
7070
self,
71-
message="You need to pass an API key to the NucleusClient or set the environment variable NUCLEUS_API_KEY",
71+
message="You must provide credentials to NucleusClient: pass api_key or limited_access_key, or set the environment variable NUCLEUS_API_KEY or NUCLEUS_LIMITED_ACCESS_KEY",
7272
):
7373
self.message = message
7474
super().__init__(self.message)

nucleus/validate/client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import List
1+
from typing import List, Optional
22

33
from nucleus.async_job import AsyncJob
44
from nucleus.connection import Connection
@@ -25,8 +25,8 @@
2525
class Validate:
2626
"""Model CI Python Client extension."""
2727

28-
def __init__(self, api_key: str, endpoint: str):
29-
self.connection = Connection(api_key, endpoint)
28+
def __init__(self, api_key: Optional[str], endpoint: str, extra_headers: Optional[dict] = None):
29+
self.connection = Connection(api_key, endpoint, extra_headers=extra_headers)
3030

3131
def __repr__(self):
3232
return f"Validate(connection='{self.connection}')"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ ignore = ["E501", "E741", "E731", "F401"] # Easy ignore for getting it running
2525

2626
[tool.poetry]
2727
name = "scale-nucleus"
28-
version = "0.17.10"
28+
version = "0.17.11"
2929
description = "The official Python client library for Nucleus, the Data Platform for AI"
3030
license = "MIT"
3131
authors = ["Scale AI Nucleus Team <nucleusapi@scaleapi.com>"]

0 commit comments

Comments
 (0)