newapi-python 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. newapi_python-0.3.0/.mimosa/finding-ledger/v1/events/batch-posttooluse-48aef1dee53250877e9903ca7c2b5602.json +23 -0
  2. newapi_python-0.3.0/.mimosa/finding-ledger/v1/events/batch-pretooluse-6268e66558b9f7a250aa50afc99ccc21.json +23 -0
  3. newapi_python-0.3.0/.mimosa/hook-state/sess_1433cec3-1da2-46c1-88de-192e7415d559.json +1 -0
  4. newapi_python-0.2.0/src/newapi/_client.py → newapi_python-0.3.0/.mimosa/hook-state/sess_1433cec3-1da2-46c1-88de-192e7415d559.mtjfq1ha-24876-f1aebcd4dc.baseline/1252fc55de5f372ed4dcbb82a75b61002c7dfd9076f095c21e54449336bf6ba8.source +22 -6
  5. newapi_python-0.3.0/.mimosa/hook-status/sess_1433cec3-1da2-46c1-88de-192e7415d559-b601df6c7a.json +14 -0
  6. {newapi_python-0.2.0 → newapi_python-0.3.0}/PKG-INFO +5 -2
  7. {newapi_python-0.2.0 → newapi_python-0.3.0}/README.md +4 -1
  8. {newapi_python-0.2.0 → newapi_python-0.3.0}/pyproject.toml +1 -1
  9. newapi_python-0.3.0/src/newapi/__init__.py +75 -0
  10. newapi_python-0.3.0/src/newapi/_client.py +707 -0
  11. {newapi_python-0.2.0 → newapi_python-0.3.0}/tests/test_client.py +51 -3
  12. {newapi_python-0.2.0 → newapi_python-0.3.0}/tests/test_resources.py +1 -0
  13. {newapi_python-0.2.0 → newapi_python-0.3.0}/.gitignore +0 -0
  14. /newapi_python-0.2.0/src/newapi/__init__.py → /newapi_python-0.3.0/.mimosa/hook-state/sess_1433cec3-1da2-46c1-88de-192e7415d559.mtjfq1ha-24876-f1aebcd4dc.baseline/2c0a3ea4b52b71cebbbb4c27ee4939eeecbb634105d6a4ad0b51984b9dbfa04b.source +0 -0
  15. {newapi_python-0.2.0 → newapi_python-0.3.0}/LICENSE +0 -0
  16. {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/_crypto.py +0 -0
  17. {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/_exceptions.py +0 -0
  18. {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/_models.py +0 -0
  19. {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/_resources.py +0 -0
  20. {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/_session_blob.py +0 -0
  21. {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/py.typed +0 -0
  22. {newapi_python-0.2.0 → newapi_python-0.3.0}/tests/test_session_blob.py +0 -0
@@ -0,0 +1,23 @@
1
+ {
2
+ "schemaVersion": "mimosa-finding-ledger-batch/v1",
3
+ "batchId": "posttooluse-48aef1dee53250877e9903ca7c2b5602",
4
+ "runId": null,
5
+ "runStatus": "completed",
6
+ "coverage": {
7
+ "status": "complete",
8
+ "reasons": []
9
+ },
10
+ "source": {
11
+ "component": "zcode-hook",
12
+ "operationId": "PostToolUse"
13
+ },
14
+ "revision": null,
15
+ "diffHash": null,
16
+ "rulesVersion": null,
17
+ "sessionHash": "b601df6c7ab8c95836c9aa408671dade9f8500578f3b99ff004a48d719d630b4",
18
+ "reportRef": null,
19
+ "events": [],
20
+ "observedFindingIds": [],
21
+ "verifiedFiles": [],
22
+ "recordedAt": "2026-09-02T01:47:00.021Z"
23
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "schemaVersion": "mimosa-finding-ledger-batch/v1",
3
+ "batchId": "pretooluse-6268e66558b9f7a250aa50afc99ccc21",
4
+ "runId": null,
5
+ "runStatus": "completed",
6
+ "coverage": {
7
+ "status": "complete",
8
+ "reasons": []
9
+ },
10
+ "source": {
11
+ "component": "zcode-hook",
12
+ "operationId": "PreToolUse"
13
+ },
14
+ "revision": null,
15
+ "diffHash": null,
16
+ "rulesVersion": null,
17
+ "sessionHash": "b601df6c7ab8c95836c9aa408671dade9f8500578f3b99ff004a48d719d630b4",
18
+ "reportRef": null,
19
+ "events": [],
20
+ "observedFindingIds": [],
21
+ "verifiedFiles": [],
22
+ "recordedAt": "2026-09-02T01:46:45.535Z"
23
+ }
@@ -0,0 +1 @@
1
+ {"touched":["/Users/Administrator/eightlabs/newapi/src/newapi/__init__.py","/Users/Administrator/eightlabs/newapi/src/newapi/_client.py"],"bashMutation":true,"reportedFindings":["mimosa-66b5d60954ca90ad7e673501"],"findingEvents":[{"finding_id":"mimosa-66b5d60954ca90ad7e673501","file":"../../../../tmp/live_test_newapi.py","line":24,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"655080fc4deb5d7ba132e4dfa5eeff0e63181ed39fbd3b9c4a74fd5f35f100c8","source":"semgrep"},"status":"blocked","decision":"deny","at":"2026-09-02T01:46:45.217Z"},{"finding_id":"mimosa-66b5d60954ca90ad7e673501","file":"../../../../tmp/live_test_newapi.py","line":24,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"655080fc4deb5d7ba132e4dfa5eeff0e63181ed39fbd3b9c4a74fd5f35f100c8","source":"semgrep","candidate_before_line_hash":"655080fc4deb5d7ba132e4dfa5eeff0e63181ed39fbd3b9c4a74fd5f35f100c8","post_change_line_hash_at_previous_location":"67d27e371a21402ee7ba4fe1136e7650c84740034f83bec4ec36d2baf6964c0f","verification":{"method":"mimosa-posttool-static-rescan","finding_absent":true,"runtime_oracle":false}},"status":"fixed_candidate","decision":"repair_observed","at":"2026-09-02T01:46:59.695Z"},{"finding_id":"mimosa-66b5d60954ca90ad7e673501","file":"../../../../tmp/live_test_newapi.py","line":24,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"655080fc4deb5d7ba132e4dfa5eeff0e63181ed39fbd3b9c4a74fd5f35f100c8","source":"semgrep","candidate_before_line_hash":"655080fc4deb5d7ba132e4dfa5eeff0e63181ed39fbd3b9c4a74fd5f35f100c8","post_change_line_hash_at_previous_location":"67d27e371a21402ee7ba4fe1136e7650c84740034f83bec4ec36d2baf6964c0f","verification":{"method":"mimosa-posttool-static-rescan","finding_absent":true,"runtime_oracle":false}},"status":"static_verified","decision":"static_rescan_passed","at":"2026-09-02T01:46:59.695Z"}],"baseline":{"storageId":"mtjfq1ha-24876-f1aebcd4dc","createdAt":"2026-09-02T01:46:04.414Z","files":{"src/newapi/__init__.py":{"existed":true,"snapshot":"2c0a3ea4b52b71cebbbb4c27ee4939eeecbb634105d6a4ad0b51984b9dbfa04b.source"},"src/newapi/_client.py":{"existed":true,"snapshot":"1252fc55de5f372ed4dcbb82a75b61002c7dfd9076f095c21e54449336bf6ba8.source"}},"complete":false,"candidateLimit":5000,"discoveredFiles":0,"capturedFiles":2,"truncated":false,"omittedAtLeast":0,"firstOmitted":"","errors":[{"stage":"baseline-capture","target":".","reason":"global task baseline was unavailable; captured only the touched file"},{"stage":"baseline-snapshot","target":"../../../../tmp/live_test_newapi.py","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../../../../tmp/live_test_newapi.py","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../../../../tmp/live_test_newapi.py","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../../../../tmp/live_test_newapi.py","reason":"file is outside project"}]},"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-02T01:47:47.717Z"}
@@ -218,6 +218,12 @@ class NewAPI:
218
218
  ) -> int | None:
219
219
  if access_token and session_token:
220
220
  raise ConfigurationError("pass either access_token or session_token, not both")
221
+ if access_token and user_id is None:
222
+ raise ConfigurationError(
223
+ "user_id is required when authenticating with an access_token; "
224
+ "new-api instances reject such requests with 401 "
225
+ "'Unauthorized, New-Api-User header not provided'"
226
+ )
221
227
  if not session_token or user_id is not None:
222
228
  return user_id
223
229
  try:
@@ -342,6 +348,8 @@ class NewAPI:
342
348
  return
343
349
  refresh_token = self._tokens.refresh_token
344
350
  headers: dict[str, str] = {"Origin": self._origin()}
351
+ if self._tokens.user_id is not None:
352
+ headers[_USER_ID_HEADER] = str(self._tokens.user_id)
345
353
  if self._tokens.access_token:
346
354
  headers["Authorization"] = f"{self._tokens.token_type} {self._tokens.access_token}"
347
355
  try:
@@ -510,14 +518,15 @@ class NewAPI:
510
518
  "Accept-Language": self.language,
511
519
  "User-Agent": "newapi-python/0.2.0",
512
520
  }
513
- if authenticated and self._tokens.session_token:
514
- request_headers["Cookie"] = f"{self._session_cookie}={self._tokens.session_token}"
521
+ if authenticated:
515
522
  if self._tokens.user_id is not None:
516
523
  request_headers[_USER_ID_HEADER] = str(self._tokens.user_id)
517
- elif authenticated and self._tokens.access_token:
518
- request_headers["Authorization"] = (
519
- f"{self._tokens.token_type} {self._tokens.access_token}"
520
- )
524
+ if self._tokens.session_token:
525
+ request_headers["Cookie"] = f"{self._session_cookie}={self._tokens.session_token}"
526
+ elif self._tokens.access_token:
527
+ request_headers["Authorization"] = (
528
+ f"{self._tokens.token_type} {self._tokens.access_token}"
529
+ )
521
530
  if refresh_cookie and not self._session_has_refresh_cookie():
522
531
  request_headers["Cookie"] = f"{_REFRESH_COOKIE}={refresh_cookie}"
523
532
  if headers:
@@ -677,12 +686,19 @@ class NewAPI:
677
686
  if isinstance(session, Mapping):
678
687
  raw_session_id = session.get("sid")
679
688
  session_id = raw_session_id if isinstance(raw_session_id, str) else None
689
+ user_id = self._tokens.user_id
690
+ user_data = auth.get("user")
691
+ if isinstance(user_data, Mapping):
692
+ raw_user_id = user_data.get("id")
693
+ if isinstance(raw_user_id, int) and not isinstance(raw_user_id, bool):
694
+ user_id = raw_user_id
680
695
  return SessionTokens(
681
696
  access_token=str(auth["access_token"]),
682
697
  refresh_token=refresh_token,
683
698
  expires_at=expires_at,
684
699
  token_type=str(token_type) if token_type else "Bearer",
685
700
  session_id=session_id,
701
+ user_id=user_id,
686
702
  )
687
703
 
688
704
  def _resolve_quota_per_unit(self) -> float:
@@ -0,0 +1,14 @@
1
+ {
2
+ "schemaVersion": "mimosa-hook-status/v1",
3
+ "recordedAt": "2026-09-02T01:47:48.400Z",
4
+ "sessionId": "sess_1433cec3-1da2-46c1-88de-192e7415d559",
5
+ "event": "PostToolUse",
6
+ "toolName": "Edit",
7
+ "file": "/tmp/live_test_newapi.py",
8
+ "outcome": "inconclusive",
9
+ "coverage": "partial",
10
+ "findingCount": 0,
11
+ "durationMs": 4,
12
+ "hostState": "hook_complete",
13
+ "reportHint": ".mimosa/reports/"
14
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: newapi-python
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python client for the user-facing dashboard API of new-api instances
5
5
  Author: Eight Labs
6
6
  License-Expression: MIT
@@ -55,10 +55,13 @@ client = NewAPI(
55
55
  "https://newapi.example.com",
56
56
  access_token=os.environ["NEWAPI_ACCESS_TOKEN"],
57
57
  refresh_token=os.environ.get("NEWAPI_REFRESH_TOKEN"),
58
+ user_id=int(os.environ["NEWAPI_USER_ID"]),
58
59
  )
59
60
  ```
60
61
 
61
- Pass either the instance origin or its full `/api` URL. Tokens are retained only in memory. If a refresh token is supplied, the client sends it as the `new_api_refresh` cookie, rotates the pair after an authenticated `401`, and refreshes proactively when `expires_at` is known. Users can also generate a long-lived system access token from the dashboard and pass it as `access_token` alone; such tokens cannot refresh browser sessions.
62
+ Pass either the instance origin or its full `/api` URL. Tokens are retained only in memory. If a refresh token is supplied, the client sends it as the `new_api_refresh` cookie, rotates the pair after an authenticated `401`, and refreshes proactively when `expires_at` is known.
63
+
64
+ Users can also generate a long-lived system access token from the dashboard and pass it as `access_token`. Access-token authentication additionally requires `user_id`: new-api instances verify the numeric `New-Api-User` header on every authenticated request and reject requests without it with `401 Unauthorized, New-Api-User header not provided`. The client sends the header automatically from `user_id`, and `login()` and `refresh()` capture the id from the instance's responses. Such tokens cannot refresh browser sessions on their own.
62
65
 
63
66
  The default browser fingerprint is Chrome. Choose another `curl_cffi` fingerprint or configure proxies by supplying your own `curl_cffi.requests.Session`:
64
67
 
@@ -25,10 +25,13 @@ client = NewAPI(
25
25
  "https://newapi.example.com",
26
26
  access_token=os.environ["NEWAPI_ACCESS_TOKEN"],
27
27
  refresh_token=os.environ.get("NEWAPI_REFRESH_TOKEN"),
28
+ user_id=int(os.environ["NEWAPI_USER_ID"]),
28
29
  )
29
30
  ```
30
31
 
31
- Pass either the instance origin or its full `/api` URL. Tokens are retained only in memory. If a refresh token is supplied, the client sends it as the `new_api_refresh` cookie, rotates the pair after an authenticated `401`, and refreshes proactively when `expires_at` is known. Users can also generate a long-lived system access token from the dashboard and pass it as `access_token` alone; such tokens cannot refresh browser sessions.
32
+ Pass either the instance origin or its full `/api` URL. Tokens are retained only in memory. If a refresh token is supplied, the client sends it as the `new_api_refresh` cookie, rotates the pair after an authenticated `401`, and refreshes proactively when `expires_at` is known.
33
+
34
+ Users can also generate a long-lived system access token from the dashboard and pass it as `access_token`. Access-token authentication additionally requires `user_id`: new-api instances verify the numeric `New-Api-User` header on every authenticated request and reject requests without it with `401 Unauthorized, New-Api-User header not provided`. The client sends the header automatically from `user_id`, and `login()` and `refresh()` capture the id from the instance's responses. Such tokens cannot refresh browser sessions on their own.
32
35
 
33
36
  The default browser fingerprint is Chrome. Choose another `curl_cffi` fingerprint or configure proxies by supplying your own `curl_cffi.requests.Session`:
34
37
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "newapi-python"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Python client for the user-facing dashboard API of new-api instances"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,75 @@
1
+ from ._client import NewAPI
2
+ from ._exceptions import (
3
+ APIError,
4
+ AuthenticationError,
5
+ ConfigurationError,
6
+ ConflictError,
7
+ NewAPIError,
8
+ NotFoundError,
9
+ PermissionDeniedError,
10
+ ProtocolError,
11
+ RateLimitError,
12
+ TransportError,
13
+ TwoFactorRequired,
14
+ ValidationError,
15
+ )
16
+ from ._models import (
17
+ Announcement,
18
+ APIKey,
19
+ Balance,
20
+ CheckinResult,
21
+ CheckinStatus,
22
+ Group,
23
+ Log,
24
+ LoginSession,
25
+ Page,
26
+ PaymentConfig,
27
+ PaymentLink,
28
+ Redemption,
29
+ Resource,
30
+ SessionTokens,
31
+ Subscription,
32
+ SubscriptionPlan,
33
+ Token,
34
+ TopUpOrder,
35
+ UsageRecord,
36
+ User,
37
+ )
38
+
39
+ __all__ = [
40
+ "APIError",
41
+ "APIKey",
42
+ "Announcement",
43
+ "AuthenticationError",
44
+ "Balance",
45
+ "CheckinResult",
46
+ "CheckinStatus",
47
+ "ConfigurationError",
48
+ "ConflictError",
49
+ "Group",
50
+ "Log",
51
+ "LoginSession",
52
+ "NewAPI",
53
+ "NewAPIError",
54
+ "NotFoundError",
55
+ "Page",
56
+ "PaymentConfig",
57
+ "PaymentLink",
58
+ "PermissionDeniedError",
59
+ "ProtocolError",
60
+ "RateLimitError",
61
+ "Redemption",
62
+ "Resource",
63
+ "SessionTokens",
64
+ "Subscription",
65
+ "SubscriptionPlan",
66
+ "Token",
67
+ "TopUpOrder",
68
+ "TransportError",
69
+ "TwoFactorRequired",
70
+ "UsageRecord",
71
+ "User",
72
+ "ValidationError",
73
+ ]
74
+
75
+ __version__ = "0.3.0"
@@ -0,0 +1,707 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import threading
5
+ from collections.abc import Mapping
6
+ from dataclasses import replace
7
+ from typing import Any, cast
8
+ from urllib.parse import unquote, urlsplit, urlunsplit
9
+
10
+ from curl_cffi import requests as curl_requests
11
+
12
+ from ._crypto import rsa_oaep_sha256_encrypt
13
+ from ._exceptions import (
14
+ APIError,
15
+ AuthenticationError,
16
+ ConfigurationError,
17
+ ConflictError,
18
+ NotFoundError,
19
+ PermissionDeniedError,
20
+ ProtocolError,
21
+ RateLimitError,
22
+ TransportError,
23
+ TwoFactorRequired,
24
+ ValidationError,
25
+ )
26
+ from ._models import Balance, Resource, SessionTokens, User
27
+ from ._resources import (
28
+ AccountResource,
29
+ DashboardResource,
30
+ GroupsResource,
31
+ LogsResource,
32
+ PaymentResource,
33
+ SubscriptionResource,
34
+ TokensResource,
35
+ TopUpResource,
36
+ )
37
+ from ._session_blob import decode_session_blob
38
+
39
+ _ERROR_TYPES: dict[int, type[APIError]] = {
40
+ 400: ValidationError,
41
+ 401: AuthenticationError,
42
+ 403: PermissionDeniedError,
43
+ 404: NotFoundError,
44
+ 409: ConflictError,
45
+ 422: ValidationError,
46
+ 429: RateLimitError,
47
+ }
48
+ _LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
49
+ _REFRESH_COOKIE = "new_api_refresh"
50
+ _REFRESH_COOKIE_PATTERN = re.compile(rf"(?:^|[;,\s]){_REFRESH_COOKIE}=([^;\s]+)")
51
+ _DEFAULT_SESSION_COOKIE = "session"
52
+ _USER_ID_HEADER = "New-Api-User"
53
+ _DEFAULT_QUOTA_PER_UNIT = 500000.0
54
+
55
+
56
+ def _api_base_url(value: str, allow_insecure: bool) -> str:
57
+ candidate = value.strip()
58
+ if not candidate:
59
+ raise ConfigurationError("base_url must not be empty")
60
+ if "://" not in candidate:
61
+ candidate = f"https://{candidate}"
62
+ parsed = urlsplit(candidate)
63
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
64
+ raise ConfigurationError("base_url must be an HTTP or HTTPS instance URL")
65
+ if parsed.username or parsed.password:
66
+ raise ConfigurationError("base_url must not contain credentials")
67
+ if parsed.query or parsed.fragment:
68
+ raise ConfigurationError("base_url must not contain a query string or fragment")
69
+ local = parsed.hostname in _LOCAL_HOSTS or parsed.hostname.endswith(".localhost")
70
+ if parsed.scheme == "http" and not local and not allow_insecure:
71
+ raise ConfigurationError(
72
+ "remote HTTP would expose credentials; use HTTPS or set allow_insecure=True"
73
+ )
74
+ path = parsed.path.rstrip("/")
75
+ if not path.endswith("/api"):
76
+ path = f"{path}/api"
77
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
78
+
79
+
80
+ def _request_path(value: str) -> str:
81
+ parsed = urlsplit(value)
82
+ if parsed.scheme or parsed.netloc or parsed.fragment:
83
+ raise ConfigurationError("request path must be relative to the configured instance")
84
+ if parsed.query:
85
+ raise ConfigurationError("request path must not contain a query string; use params instead")
86
+ path = unquote(parsed.path).lstrip("/")
87
+ if path == "api":
88
+ path = ""
89
+ elif path.startswith("api/"):
90
+ path = path[len("api/") :]
91
+ if any(segment in {"..", "."} for segment in path.split("/")):
92
+ raise ConfigurationError("request path must not contain parent traversal")
93
+ if not path:
94
+ raise ConfigurationError("request path must identify an endpoint")
95
+ return path
96
+
97
+
98
+ def _query_value(value: Any) -> Any:
99
+ if isinstance(value, bool):
100
+ return "true" if value else "false"
101
+ return value
102
+
103
+
104
+ def _query(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
105
+ if params is None:
106
+ return None
107
+ return {
108
+ str(key): _query_value(value)
109
+ for key, value in params.items()
110
+ if value is not None and value != ""
111
+ }
112
+
113
+
114
+ def _success(response: Any) -> bool:
115
+ return 200 <= int(response.status_code) < 300
116
+
117
+
118
+ def _retry_after(response: Any) -> float | None:
119
+ value = response.headers.get("Retry-After")
120
+ if value is None:
121
+ return None
122
+ try:
123
+ return max(0.0, float(value))
124
+ except ValueError:
125
+ return None
126
+
127
+
128
+ def _set_cookie_values(response: Any) -> list[str]:
129
+ headers = response.headers
130
+ get_list = getattr(headers, "get_list", None)
131
+ if callable(get_list):
132
+ return [str(value) for value in get_list("set-cookie")]
133
+ raw = headers.get("set-cookie")
134
+ if raw is None:
135
+ return []
136
+ if isinstance(raw, str):
137
+ return [raw]
138
+ return [str(value) for value in raw]
139
+
140
+
141
+ class NewAPI:
142
+ """An authenticated user session for one new-api instance."""
143
+
144
+ def __init__(
145
+ self,
146
+ base_url: str,
147
+ *,
148
+ access_token: str | None = None,
149
+ refresh_token: str | None = None,
150
+ expires_at: float | None = None,
151
+ session_id: str | None = None,
152
+ session_token: str | None = None,
153
+ user_id: int | None = None,
154
+ session_cookie: str = _DEFAULT_SESSION_COOKIE,
155
+ timeout: float | tuple[float, float] = 30.0,
156
+ language: str = "en",
157
+ allow_insecure: bool = False,
158
+ impersonate: str | None = "chrome",
159
+ session: Any | None = None,
160
+ ) -> None:
161
+ self.base_url = _api_base_url(base_url, allow_insecure)
162
+ self.language = language
163
+ self._session_cookie = session_cookie
164
+ self._session_cookie_pattern = re.compile(
165
+ rf"(?:^|[;,\s]){re.escape(session_cookie)}=([^;\s]+)"
166
+ )
167
+ resolved_user_id = self._resolve_session_user_id(access_token, session_token, user_id)
168
+ self._tokens = SessionTokens(
169
+ access_token=access_token,
170
+ refresh_token=refresh_token,
171
+ expires_at=expires_at,
172
+ session_id=session_id,
173
+ session_token=session_token,
174
+ user_id=resolved_user_id,
175
+ )
176
+ self._refresh_lock = threading.RLock()
177
+ self._pending_2fa_token: str | None = None
178
+ self.timeout = timeout
179
+ self.impersonate = impersonate
180
+ self._quota_per_unit: float | None = None
181
+ self._owns_http_client = session is None
182
+ if session is None:
183
+ if impersonate:
184
+ self._http: Any = cast(Any, curl_requests.Session)(impersonate=impersonate)
185
+ else:
186
+ self._http = cast(Any, curl_requests.Session)()
187
+ else:
188
+ self._http = session
189
+ self.account = AccountResource(self)
190
+ self.tokens = TokensResource(self)
191
+ self.keys = self.tokens
192
+ self.logs = LogsResource(self)
193
+ self.usage = self.logs
194
+ self.history = self.logs
195
+ self.groups = GroupsResource(self)
196
+ self.dashboard = DashboardResource(self)
197
+ self.topup = TopUpResource(self)
198
+ self.payment = PaymentResource(self)
199
+ self.subscriptions = SubscriptionResource(self)
200
+
201
+ @property
202
+ def session(self) -> SessionTokens:
203
+ return self._tokens
204
+
205
+ @property
206
+ def is_authenticated(self) -> bool:
207
+ return self._tokens.authenticated
208
+
209
+ @property
210
+ def quota_per_unit(self) -> float | None:
211
+ return self._quota_per_unit
212
+
213
+ def _resolve_session_user_id(
214
+ self,
215
+ access_token: str | None,
216
+ session_token: str | None,
217
+ user_id: int | None,
218
+ ) -> int | None:
219
+ if access_token and session_token:
220
+ raise ConfigurationError("pass either access_token or session_token, not both")
221
+ if access_token and user_id is None:
222
+ raise ConfigurationError(
223
+ "user_id is required when authenticating with an access_token; "
224
+ "new-api instances reject such requests with 401 "
225
+ "'Unauthorized, New-Api-User header not provided'"
226
+ )
227
+ if not session_token or user_id is not None:
228
+ return user_id
229
+ try:
230
+ fields = decode_session_blob(session_token)
231
+ except ValueError as error:
232
+ raise ConfigurationError(
233
+ "session_token is not a decodable browser session cookie; "
234
+ "pass user_id alongside it for instances with opaque cookies"
235
+ ) from error
236
+ embedded_id = fields.get("id")
237
+ if not isinstance(embedded_id, int) or isinstance(embedded_id, bool):
238
+ raise ConfigurationError(
239
+ "session_token does not embed a user id; pass user_id explicitly"
240
+ )
241
+ return embedded_id
242
+
243
+ def login(
244
+ self,
245
+ username: str,
246
+ password: str,
247
+ *,
248
+ turnstile_token: str | None = None,
249
+ encrypt_password: bool = True,
250
+ ) -> User:
251
+ if not username.strip() or not password:
252
+ raise ValueError("username and password are required")
253
+ payload: dict[str, Any] = {"username": username}
254
+ key = self._password_encryption_key() if encrypt_password else None
255
+ if key is not None:
256
+ payload["password_encrypted"] = rsa_oaep_sha256_encrypt(
257
+ password.encode(), key["public_key"]
258
+ )
259
+ payload["encryption_key_id"] = key["kid"]
260
+ else:
261
+ payload["password"] = password
262
+ params = {"turnstile": turnstile_token} if turnstile_token else None
263
+ data, response = self._request_with_response(
264
+ "POST",
265
+ "user/login",
266
+ params=params,
267
+ json=payload,
268
+ authenticated=False,
269
+ allow_refresh=False,
270
+ )
271
+ auth = self._auth_mapping(data, "login")
272
+ if auth.get("require_2fa") is True:
273
+ flow_token = auth.get("flow_token")
274
+ if not isinstance(flow_token, str) or not flow_token:
275
+ raise ProtocolError("2FA login response did not contain a flow token")
276
+ self._pending_2fa_token = flow_token
277
+ expires_at = auth.get("expires_at")
278
+ raise TwoFactorRequired(
279
+ flow_token,
280
+ float(expires_at) if isinstance(expires_at, (int, float)) else None,
281
+ )
282
+ return self._accept_auth(auth, response)
283
+
284
+ def complete_2fa(self, code: str, *, flow_token: str | None = None) -> User:
285
+ token = flow_token or self._pending_2fa_token
286
+ if not token:
287
+ raise ValueError("flow_token is required when no 2FA login is pending")
288
+ if not code.strip():
289
+ raise ValueError("code must not be empty")
290
+ data, response = self._request_with_response(
291
+ "POST",
292
+ "user/login/2fa",
293
+ json={"code": code, "flow_token": token},
294
+ authenticated=False,
295
+ allow_refresh=False,
296
+ )
297
+ user = self._accept_auth(self._auth_mapping(data, "2FA login"), response)
298
+ self._pending_2fa_token = None
299
+ return user
300
+
301
+ def refresh(self) -> SessionTokens:
302
+ if self._tokens.session_token and not self._tokens.refresh_token:
303
+ raise AuthenticationError(
304
+ "Session-cookie authentication has no refresh flow; "
305
+ "extract a fresh browser session cookie instead",
306
+ status_code=401,
307
+ code="SESSION_COOKIE_NO_REFRESH",
308
+ )
309
+ with self._refresh_lock:
310
+ refresh_token = self._tokens.refresh_token
311
+ if not refresh_token:
312
+ raise AuthenticationError(
313
+ "No refresh token is available",
314
+ status_code=401,
315
+ code="NO_REFRESH_TOKEN",
316
+ )
317
+ try:
318
+ data, response = self._request_with_response(
319
+ "POST",
320
+ "user/auth/refresh",
321
+ refresh_cookie=refresh_token,
322
+ authenticated=False,
323
+ allow_refresh=False,
324
+ )
325
+ auth = self._auth_mapping(data, "token refresh")
326
+ access_token = auth.get("access_token")
327
+ if not isinstance(access_token, str) or not access_token:
328
+ raise ProtocolError("token refresh did not return an access token")
329
+ rotated = self._harvest_refresh_token(response) or refresh_token
330
+ self._tokens = self._tokens_from_auth(auth, rotated)
331
+ return self._tokens
332
+ except APIError as error:
333
+ if 400 <= error.status_code < 500:
334
+ self.clear_auth()
335
+ raise
336
+
337
+ def logout(self) -> None:
338
+ if self._tokens.session_token and not self._tokens.access_token:
339
+ try:
340
+ self._request(
341
+ "POST",
342
+ "user/logout",
343
+ authenticated=True,
344
+ allow_refresh=False,
345
+ )
346
+ finally:
347
+ self.clear_auth()
348
+ return
349
+ refresh_token = self._tokens.refresh_token
350
+ headers: dict[str, str] = {"Origin": self._origin()}
351
+ if self._tokens.user_id is not None:
352
+ headers[_USER_ID_HEADER] = str(self._tokens.user_id)
353
+ if self._tokens.access_token:
354
+ headers["Authorization"] = f"{self._tokens.token_type} {self._tokens.access_token}"
355
+ try:
356
+ self._request(
357
+ "POST",
358
+ "user/auth/logout",
359
+ refresh_cookie=refresh_token,
360
+ headers=headers,
361
+ authenticated=False,
362
+ allow_refresh=False,
363
+ )
364
+ finally:
365
+ self.clear_auth()
366
+
367
+ def clear_auth(self) -> None:
368
+ self._tokens = SessionTokens()
369
+ self._pending_2fa_token = None
370
+
371
+ def me(self) -> User:
372
+ return self.account.profile()
373
+
374
+ def balance(self) -> Balance:
375
+ profile = self.account.profile()
376
+ quota_per_unit = self._resolve_quota_per_unit()
377
+ try:
378
+ return Balance(
379
+ quota=int(profile.get("quota", 0)),
380
+ used_quota=int(profile.get("used_quota", 0)),
381
+ aff_quota=int(profile.get("aff_quota", 0)),
382
+ quota_per_unit=quota_per_unit,
383
+ )
384
+ except (TypeError, ValueError) as error:
385
+ raise ProtocolError("profile response did not contain valid quota values") from error
386
+
387
+ def status(self) -> Resource:
388
+ data = self._request(
389
+ "GET",
390
+ "status",
391
+ authenticated=False,
392
+ allow_refresh=False,
393
+ )
394
+ if not isinstance(data, Mapping):
395
+ raise ProtocolError("status returned a non-object response")
396
+ quota_per_unit = data.get("quota_per_unit")
397
+ if isinstance(quota_per_unit, (int, float)) and quota_per_unit > 0:
398
+ self._quota_per_unit = float(quota_per_unit)
399
+ return Resource(data)
400
+
401
+ def notice(self) -> Any:
402
+ return self._request("GET", "notice", authenticated=False, allow_refresh=False)
403
+
404
+ def about(self) -> Any:
405
+ return self._request("GET", "about", authenticated=False, allow_refresh=False)
406
+
407
+ def request(
408
+ self,
409
+ method: str,
410
+ path: str,
411
+ *,
412
+ params: Mapping[str, Any] | None = None,
413
+ json: Any = None,
414
+ headers: Mapping[str, str] | None = None,
415
+ authenticated: bool = True,
416
+ ) -> Any:
417
+ return self._request(
418
+ method,
419
+ path,
420
+ params=params,
421
+ json=json,
422
+ headers=headers,
423
+ authenticated=authenticated,
424
+ allow_refresh=True,
425
+ )
426
+
427
+ def close(self) -> None:
428
+ if self._owns_http_client:
429
+ self._http.close()
430
+
431
+ def __enter__(self) -> NewAPI:
432
+ return self
433
+
434
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
435
+ self.close()
436
+
437
+ def _request(
438
+ self,
439
+ method: str,
440
+ path: str,
441
+ *,
442
+ params: Mapping[str, Any] | None = None,
443
+ json: Any = None,
444
+ headers: Mapping[str, str] | None = None,
445
+ authenticated: bool,
446
+ allow_refresh: bool,
447
+ refresh_cookie: str | None = None,
448
+ ) -> Any:
449
+ return self._request_with_response(
450
+ method,
451
+ path,
452
+ params=params,
453
+ json=json,
454
+ headers=headers,
455
+ authenticated=authenticated,
456
+ allow_refresh=allow_refresh,
457
+ refresh_cookie=refresh_cookie,
458
+ )[0]
459
+
460
+ def _request_with_response(
461
+ self,
462
+ method: str,
463
+ path: str,
464
+ *,
465
+ params: Mapping[str, Any] | None = None,
466
+ json: Any = None,
467
+ headers: Mapping[str, str] | None = None,
468
+ authenticated: bool,
469
+ allow_refresh: bool,
470
+ refresh_cookie: str | None = None,
471
+ ) -> tuple[Any, Any]:
472
+ normalized_method = method.upper()
473
+ if authenticated and allow_refresh:
474
+ self._refresh_if_expiring()
475
+ failed_token = self._tokens.access_token
476
+ response = self._send(
477
+ normalized_method,
478
+ path,
479
+ params=params,
480
+ json=json,
481
+ headers=headers,
482
+ authenticated=authenticated,
483
+ refresh_cookie=refresh_cookie,
484
+ )
485
+ if authenticated and self._tokens.session_token:
486
+ self._harvest_session_cookie(response)
487
+ if (
488
+ response.status_code == 401
489
+ and authenticated
490
+ and allow_refresh
491
+ and self._tokens.refresh_token
492
+ ):
493
+ self._refresh_after_unauthorized(failed_token)
494
+ response = self._send(
495
+ normalized_method,
496
+ path,
497
+ params=params,
498
+ json=json,
499
+ headers=headers,
500
+ authenticated=authenticated,
501
+ )
502
+ return self._decode(response), response
503
+
504
+ def _send(
505
+ self,
506
+ method: str,
507
+ path: str,
508
+ *,
509
+ params: Mapping[str, Any] | None,
510
+ json: Any,
511
+ headers: Mapping[str, str] | None,
512
+ authenticated: bool,
513
+ refresh_cookie: str | None = None,
514
+ ) -> Any:
515
+ relative_path = _request_path(path)
516
+ request_headers = {
517
+ "Accept": "application/json",
518
+ "Accept-Language": self.language,
519
+ "User-Agent": "newapi-python/0.3.0",
520
+ }
521
+ if authenticated:
522
+ if self._tokens.user_id is not None:
523
+ request_headers[_USER_ID_HEADER] = str(self._tokens.user_id)
524
+ if self._tokens.session_token:
525
+ request_headers["Cookie"] = f"{self._session_cookie}={self._tokens.session_token}"
526
+ elif self._tokens.access_token:
527
+ request_headers["Authorization"] = (
528
+ f"{self._tokens.token_type} {self._tokens.access_token}"
529
+ )
530
+ if refresh_cookie and not self._session_has_refresh_cookie():
531
+ request_headers["Cookie"] = f"{_REFRESH_COOKIE}={refresh_cookie}"
532
+ if headers:
533
+ request_headers.update(headers)
534
+ try:
535
+ return cast(Any, self._http).request(
536
+ method,
537
+ f"{self.base_url}/{relative_path}",
538
+ params=_query(params),
539
+ json=json,
540
+ headers=request_headers,
541
+ timeout=self.timeout,
542
+ allow_redirects=False,
543
+ )
544
+ except curl_requests.RequestsError as error:
545
+ raise TransportError(f"{method} request to the new-api instance failed") from error
546
+
547
+ def _decode(self, response: Any) -> Any:
548
+ if not response.content:
549
+ if _success(response):
550
+ return None
551
+ self._raise_api_error(response, {})
552
+ try:
553
+ payload = response.json()
554
+ except ValueError as error:
555
+ if not _success(response):
556
+ raise ProtocolError(
557
+ f"new-api returned non-JSON HTTP {response.status_code}"
558
+ ) from error
559
+ raise ProtocolError("new-api returned a non-JSON success response") from error
560
+ if not _success(response):
561
+ self._raise_api_error(response, payload)
562
+ if isinstance(payload, Mapping):
563
+ if "success" in payload:
564
+ if payload.get("success") is not True:
565
+ self._raise_api_error(response, payload)
566
+ return payload.get("data")
567
+ if "message" in payload:
568
+ if payload.get("message") != "success":
569
+ self._raise_api_error(response, payload)
570
+ return payload
571
+ return payload
572
+
573
+ def _raise_api_error(self, response: Any, payload: Any) -> None:
574
+ data = payload if isinstance(payload, Mapping) else {}
575
+ code = data.get("code")
576
+ fallback_message = (
577
+ getattr(response, "reason_phrase", None)
578
+ or getattr(response, "reason", None)
579
+ or f"HTTP {response.status_code}"
580
+ )
581
+ message: Any = data.get("message")
582
+ detail = data.get("data")
583
+ if not isinstance(message, str) or message == "" or message == "error":
584
+ message = detail if isinstance(detail, str) and detail else fallback_message
585
+ status_code = response.status_code
586
+ error_type = _ERROR_TYPES.get(status_code, APIError)
587
+ raise error_type(
588
+ str(message),
589
+ status_code=status_code,
590
+ code=code if isinstance(code, (int, str)) else None,
591
+ retry_after=_retry_after(response),
592
+ )
593
+
594
+ def _origin(self) -> str:
595
+ parsed = urlsplit(self.base_url)
596
+ return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
597
+
598
+ def _password_encryption_key(self) -> Resource | None:
599
+ data = self._request(
600
+ "GET",
601
+ "user/login/encryption-key",
602
+ authenticated=False,
603
+ allow_refresh=False,
604
+ )
605
+ if not isinstance(data, Mapping):
606
+ raise ProtocolError("login encryption key response was not an object")
607
+ key = Resource(data)
608
+ if not key.get("enabled"):
609
+ return None
610
+ if not key.get("kid") or not key.get("public_key"):
611
+ raise ProtocolError("login encryption key response was incomplete")
612
+ return key
613
+
614
+ def _harvest_refresh_token(self, response: Any) -> str | None:
615
+ for value in _set_cookie_values(response):
616
+ match = _REFRESH_COOKIE_PATTERN.search(value)
617
+ if match:
618
+ return match.group(1)
619
+ jar = getattr(self._http, "cookies", None)
620
+ if jar is not None:
621
+ try:
622
+ stored = jar.get(_REFRESH_COOKIE)
623
+ except Exception:
624
+ stored = None
625
+ if isinstance(stored, str) and stored:
626
+ return stored
627
+ return None
628
+
629
+ def _harvest_session_cookie(self, response: Any) -> None:
630
+ for value in _set_cookie_values(response):
631
+ match = self._session_cookie_pattern.search(value)
632
+ if match:
633
+ rotated = match.group(1)
634
+ if rotated and rotated != self._tokens.session_token:
635
+ self._tokens = replace(self._tokens, session_token=rotated)
636
+ return
637
+
638
+ def _session_has_refresh_cookie(self) -> bool:
639
+ jar = getattr(self._http, "cookies", None)
640
+ if jar is None:
641
+ return False
642
+ try:
643
+ stored = jar.get(_REFRESH_COOKIE)
644
+ except Exception:
645
+ return False
646
+ return isinstance(stored, str) and bool(stored)
647
+
648
+ def _refresh_if_expiring(self) -> None:
649
+ with self._refresh_lock:
650
+ if self._tokens.needs_refresh():
651
+ self.refresh()
652
+
653
+ def _refresh_after_unauthorized(self, failed_token: str | None) -> None:
654
+ with self._refresh_lock:
655
+ if self._tokens.access_token and self._tokens.access_token != failed_token:
656
+ return
657
+ self.refresh()
658
+
659
+ def _auth_mapping(self, data: Any, operation: str) -> Mapping[str, Any]:
660
+ if not isinstance(data, Mapping):
661
+ raise ProtocolError(f"{operation} returned a non-object response")
662
+ return data
663
+
664
+ def _accept_auth(self, auth: Mapping[str, Any], response: Any) -> User:
665
+ access_token = auth.get("access_token")
666
+ if not isinstance(access_token, str) or not access_token:
667
+ raise ProtocolError("login did not return an access token")
668
+ user_data = auth.get("user")
669
+ if not isinstance(user_data, Mapping):
670
+ raise ProtocolError("login did not return a user profile")
671
+ self._tokens = self._tokens_from_auth(auth, self._harvest_refresh_token(response))
672
+ return User(user_data)
673
+
674
+ def _tokens_from_auth(
675
+ self,
676
+ auth: Mapping[str, Any],
677
+ refresh_token: str | None,
678
+ ) -> SessionTokens:
679
+ expires_at = None
680
+ raw_expires_at = auth.get("access_expires_at")
681
+ if isinstance(raw_expires_at, (int, float)) and not isinstance(raw_expires_at, bool):
682
+ expires_at = float(raw_expires_at)
683
+ token_type = auth.get("token_type")
684
+ session = auth.get("session")
685
+ session_id = None
686
+ if isinstance(session, Mapping):
687
+ raw_session_id = session.get("sid")
688
+ session_id = raw_session_id if isinstance(raw_session_id, str) else None
689
+ user_id = self._tokens.user_id
690
+ user_data = auth.get("user")
691
+ if isinstance(user_data, Mapping):
692
+ raw_user_id = user_data.get("id")
693
+ if isinstance(raw_user_id, int) and not isinstance(raw_user_id, bool):
694
+ user_id = raw_user_id
695
+ return SessionTokens(
696
+ access_token=str(auth["access_token"]),
697
+ refresh_token=refresh_token,
698
+ expires_at=expires_at,
699
+ token_type=str(token_type) if token_type else "Bearer",
700
+ session_id=session_id,
701
+ user_id=user_id,
702
+ )
703
+
704
+ def _resolve_quota_per_unit(self) -> float:
705
+ if self._quota_per_unit is None:
706
+ self.status()
707
+ return self._quota_per_unit if self._quota_per_unit is not None else _DEFAULT_QUOTA_PER_UNIT
@@ -232,6 +232,7 @@ def test_unauthorized_request_refreshes_once_with_cookie_and_rotates() -> None:
232
232
  "https://example.test",
233
233
  access_token="access-old",
234
234
  refresh_token="refresh-old",
235
+ user_id=1,
235
236
  session=session,
236
237
  )
237
238
 
@@ -276,6 +277,7 @@ def test_proactive_refresh_uses_expiration_timestamp() -> None:
276
277
  access_token="expired",
277
278
  refresh_token="refresh",
278
279
  expires_at=time.time() - 1,
280
+ user_id=1,
279
281
  session=session,
280
282
  )
281
283
 
@@ -287,7 +289,7 @@ def test_proactive_refresh_uses_expiration_timestamp() -> None:
287
289
  def test_refresh_without_cookie_header_when_jar_lacks_it() -> None:
288
290
  def handler(request: httpx.Request) -> httpx.Response:
289
291
  assert request.url.path.endswith("/user/auth/refresh")
290
- assert request.headers.get("cookie") == "new_api_refresh=manual-refresh"
292
+ assert request.headers.get("cookie") == "new_api_refresh=fixture-refresh"
291
293
  return httpx.Response(
292
294
  200,
293
295
  json={
@@ -305,15 +307,16 @@ def test_refresh_without_cookie_header_when_jar_lacks_it() -> None:
305
307
  client = NewAPI(
306
308
  "https://example.test",
307
309
  access_token="expired",
308
- refresh_token="manual-refresh",
310
+ refresh_token="fixture-refresh",
309
311
  expires_at=time.time() - 1,
312
+ user_id=5,
310
313
  session=TestSession(handler),
311
314
  )
312
315
 
313
316
  tokens = client.refresh()
314
317
 
315
318
  assert tokens.access_token == "fresh"
316
- assert tokens.refresh_token == "manual-refresh"
319
+ assert tokens.refresh_token == "fixture-refresh"
317
320
 
318
321
 
319
322
  def test_business_error_envelope_raises_api_error() -> None:
@@ -376,6 +379,7 @@ def test_remote_plaintext_and_unsafe_request_urls_are_rejected() -> None:
376
379
  client = NewAPI(
377
380
  "https://example.test/api",
378
381
  access_token="secret",
382
+ user_id=1,
379
383
  session=TestSession(lambda request: envelope({})),
380
384
  )
381
385
 
@@ -408,6 +412,7 @@ def test_refresh_rejection_clears_authentication() -> None:
408
412
  "https://example.test",
409
413
  access_token="access",
410
414
  refresh_token="refresh",
415
+ user_id=1,
411
416
  session=TestSession(handler),
412
417
  )
413
418
 
@@ -423,12 +428,14 @@ def test_logout_sends_bearer_and_clears_auth() -> None:
423
428
  assert request.url.path.endswith("/user/auth/logout")
424
429
  assert request.headers["Authorization"] == "Bearer access-one"
425
430
  assert request.headers["Origin"] == "https://example.test"
431
+ assert request.headers["New-Api-User"] == "1"
426
432
  return envelope({"revoked_sid": "sid-1"})
427
433
 
428
434
  client = NewAPI(
429
435
  "https://example.test",
430
436
  access_token="access-one",
431
437
  refresh_token="refresh-one",
438
+ user_id=1,
432
439
  session=TestSession(handler),
433
440
  )
434
441
 
@@ -449,6 +456,7 @@ def test_balance_uses_status_quota_per_unit() -> None:
449
456
  client = NewAPI(
450
457
  "https://example.test",
451
458
  access_token="token",
459
+ user_id=1,
452
460
  session=TestSession(handler),
453
461
  )
454
462
 
@@ -585,3 +593,43 @@ def test_session_cookie_is_redacted_from_representations() -> None:
585
593
 
586
594
  assert token not in repr(client.session)
587
595
  assert decode_session_blob(client.session.session_token)["id"] == 42
596
+
597
+
598
+ def test_access_token_without_user_id_is_rejected() -> None:
599
+ with pytest.raises(ConfigurationError) as caught:
600
+ NewAPI("https://example.test", access_token="access")
601
+
602
+ assert "New-Api-User" in str(caught.value)
603
+
604
+
605
+ def test_access_token_auth_sends_new_api_user_header() -> None:
606
+ def handler(request: httpx.Request) -> httpx.Response:
607
+ assert request.headers["Authorization"] == "Bearer token"
608
+ assert request.headers["New-Api-User"] == "42"
609
+ return envelope({"id": 42, "username": "root"})
610
+
611
+ client = NewAPI(
612
+ "https://example.test",
613
+ access_token="token",
614
+ user_id=42,
615
+ session=TestSession(handler),
616
+ )
617
+
618
+ assert client.me().id == 42
619
+
620
+
621
+ def test_login_captures_user_id_for_new_api_user_header() -> None:
622
+ def handler(request: httpx.Request) -> httpx.Response:
623
+ if request.url.path.endswith("/user/login/encryption-key"):
624
+ return no_encryption(request)
625
+ if request.url.path.endswith("/user/login"):
626
+ return login_response()
627
+ assert request.headers.get("New-Api-User") == "1"
628
+ return envelope({"id": 1, "quota": 0})
629
+
630
+ client = NewAPI("https://example.test", session=TestSession(handler))
631
+
632
+ client.login("root", "secret")
633
+
634
+ assert client.me().id == 1
635
+ assert client.session.user_id == 1
@@ -32,6 +32,7 @@ def client(handler: Any) -> NewAPI:
32
32
  return NewAPI(
33
33
  "https://example.test",
34
34
  access_token="token",
35
+ user_id=1,
35
36
  session=TestSession(handler),
36
37
  )
37
38
 
File without changes
File without changes