noxhere 0.1.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.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ dist/
4
+ build/
5
+ *.egg-info/
noxhere-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.5
2
+ Name: noxhere
3
+ Version: 0.1.0
4
+ Summary: Unified Python SDK for every NoxHere resource
5
+ Project-URL: Homepage, https://app.noxhere.com
6
+ Project-URL: Repository, https://github.com/No-Box-Dev/NoxHere
7
+ Author: No Box Dev
8
+ License: PolyForm-Noncommercial-1.0.0
9
+ Keywords: activity,feedback,incidents,noxhere,planning
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+
16
+ # `noxhere`
17
+
18
+ The unified Python client for every NoxHere resource. Its 149-operation
19
+ catalog is generated from the same OpenAPI contract as `@noxhere/sdk`.
20
+
21
+ ```python
22
+ from noxhere import NoxHereClient
23
+
24
+ nox = NoxHereClient(token="nox_sk_...")
25
+ projects = nox.workspace.list_projects()
26
+ incidents = nox.incidents.get_project_incidents(path={"projectId": "project-1"})
27
+ ```
28
+
29
+ Namespaces are `workspace`, `activity`, `planning`, `feedback`, and
30
+ `incidents`; `connect`, `feed`, `ticket`, `spot`, and `cue` are compatibility
31
+ aliases. Use `noxhere.incidents.create_incident_client` or
32
+ `noxhere.feedback.create_feedback_client` for focused clients.
@@ -0,0 +1,17 @@
1
+ # `noxhere`
2
+
3
+ The unified Python client for every NoxHere resource. Its 149-operation
4
+ catalog is generated from the same OpenAPI contract as `@noxhere/sdk`.
5
+
6
+ ```python
7
+ from noxhere import NoxHereClient
8
+
9
+ nox = NoxHereClient(token="nox_sk_...")
10
+ projects = nox.workspace.list_projects()
11
+ incidents = nox.incidents.get_project_incidents(path={"projectId": "project-1"})
12
+ ```
13
+
14
+ Namespaces are `workspace`, `activity`, `planning`, `feedback`, and
15
+ `incidents`; `connect`, `feed`, `ticket`, `spot`, and `cue` are compatibility
16
+ aliases. Use `noxhere.incidents.create_incident_client` or
17
+ `noxhere.feedback.create_feedback_client` for focused clients.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "noxhere"
7
+ version = "0.1.0"
8
+ description = "Unified Python SDK for every NoxHere resource"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "PolyForm-Noncommercial-1.0.0" }
12
+ authors = [{ name = "No Box Dev" }]
13
+ keywords = ["noxhere", "activity", "planning", "feedback", "incidents"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Typing :: Typed",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://app.noxhere.com"
22
+ Repository = "https://github.com/No-Box-Dev/NoxHere"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/noxhere"]
26
+
27
+ [tool.hatch.build.targets.sdist]
28
+ include = ["src/noxhere", "tests", "scripts", "README.md", "pyproject.toml"]
29
+
30
+ [tool.pyright]
31
+ include = ["src", "tests"]
32
+ pythonVersion = "3.10"
33
+ typeCheckingMode = "strict"
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[3]
8
+ OPENAPI = ROOT / "public" / "openapi.json"
9
+ OUTPUT = Path(__file__).resolve().parents[1] / "src" / "noxhere" / "_operations.py"
10
+ METHODS = {"get", "post", "put", "patch", "delete"}
11
+ NAMESPACES = {
12
+ "NoxConnect": "workspace",
13
+ "Activity": "activity",
14
+ "NoxTicket": "planning",
15
+ "NoxSpot": "feedback",
16
+ "NoxCue": "incidents",
17
+ }
18
+
19
+ document = json.loads(OPENAPI.read_text())
20
+ operations: list[tuple[str, str, str, str]] = []
21
+ for path, path_item in document["paths"].items():
22
+ for method, operation in path_item.items():
23
+ if method not in METHODS or "operationId" not in operation:
24
+ continue
25
+ tag = operation.get("tags", [None])[0]
26
+ namespace = NAMESPACES.get(tag)
27
+ if namespace is None:
28
+ raise RuntimeError(f"No SDK namespace for {operation['operationId']} tag {tag}")
29
+ operations.append((operation["operationId"], method.upper(), path, namespace))
30
+ operations.sort()
31
+ if len({operation[0] for operation in operations}) != len(operations):
32
+ raise RuntimeError("OpenAPI operationId values must be unique")
33
+
34
+ content = (
35
+ "# Generated from public/openapi.json. Do not edit by hand.\n"
36
+ "from typing import Final\n\n"
37
+ f"OPERATIONS: Final = {operations!r}\n"
38
+ )
39
+ if "--check" in sys.argv:
40
+ if not OUTPUT.exists() or OUTPUT.read_text() != content:
41
+ raise SystemExit(f"{OUTPUT} is stale; run python scripts/generate_operations.py")
42
+ else:
43
+ OUTPUT.write_text(content)
44
+ print(f"Generated {len(operations)} operations in {OUTPUT}")
@@ -0,0 +1,3 @@
1
+ from .client import NoxHereApiError, NoxHereClient, ResourceClient, create_noxhere
2
+
3
+ __all__ = ["NoxHereApiError", "NoxHereClient", "ResourceClient", "create_noxhere"]
@@ -0,0 +1,4 @@
1
+ # Generated from public/openapi.json. Do not edit by hand.
2
+ from typing import Final
3
+
4
+ OPERATIONS: Final = [('acknowledgeRepositories', 'POST', '/api/v1/repos/acknowledge', 'workspace'), ('archiveProject', 'POST', '/api/v1/projects/{projectId}/archive', 'workspace'), ('archiveSpec', 'POST', '/api/v1/specs/{specId}/archive', 'planning'), ('assignIssue', 'POST', '/api/v1/assign', 'planning'), ('assignSlackConnectionProject', 'PATCH', '/api/v1/slack/connections/{connectionId}', 'workspace'), ('backfillProjectPullRequests', 'POST', '/api/v1/projects/{projectId}/backfill-prs', 'workspace'), ('closeFeature', 'DELETE', '/api/v1/features/{number}', 'planning'), ('closePullRequest', 'POST', '/api/v1/prs/close', 'activity'), ('createApiToken', 'POST', '/api/v1/api-tokens', 'workspace'), ('createFeature', 'POST', '/api/v1/features', 'planning'), ('createGuestInvitation', 'POST', '/api/v1/guests/invites', 'workspace'), ('createNoxCueCustomFeature', 'POST', '/api/v1/cues/sources/{sourceId}/features', 'incidents'), ('createNoxCueCustomMetric', 'POST', '/api/v1/cues/sources/{sourceId}/custom-metrics', 'incidents'), ('createNoxCueKey', 'POST', '/api/v1/cues/sources/{sourceId}/keys', 'incidents'), ('createNoxCueSource', 'POST', '/api/v1/cues/sources', 'incidents'), ('createNoxSpotSite', 'POST', '/api/v1/spots/sites', 'feedback'), ('createProject', 'POST', '/api/v1/projects', 'workspace'), ('createSpec', 'POST', '/api/v1/specs', 'planning'), ('deleteFeatureAttachment', 'DELETE', '/api/v1/features/{number}/attachments/{attachmentId}', 'planning'), ('deleteNoxCueCustomFeature', 'DELETE', '/api/v1/cues/sources/{sourceId}/features/{featureKey}', 'incidents'), ('deleteNoxCueCustomMetric', 'DELETE', '/api/v1/cues/sources/{sourceId}/custom-metrics/{metricKey}', 'incidents'), ('deleteNoxCueSource', 'DELETE', '/api/v1/cues/sources/{sourceId}', 'incidents'), ('deleteNoxSpotSite', 'DELETE', '/api/v1/spots/sites/{siteId}', 'feedback'), ('deleteSpecAttachment', 'DELETE', '/api/v1/specs/{specId}/attachments/{attachmentId}', 'planning'), ('disconnectConnection', 'POST', '/api/v1/integrations/connections/{provider}/disconnect', 'workspace'), ('disconnectSlackWorkspace', 'POST', '/api/v1/slack/disconnect', 'workspace'), ('downloadFeatureAttachment', 'GET', '/api/v1/features/{number}/attachments/{attachmentId}', 'planning'), ('downloadSpecAttachment', 'GET', '/api/v1/specs/{specId}/attachments/{attachmentId}', 'planning'), ('exchangeLegacyNativeCredential', 'POST', '/api/v1/auth/native/exchange', 'workspace'), ('getActor', 'GET', '/api/v1/actors/{actorId}', 'workspace'), ('getAiSettings', 'GET', '/api/v1/llm-settings', 'activity'), ('getBootstrapStatus', 'GET', '/api/v1/bootstrap-status', 'workspace'), ('getCueProjectActions', 'GET', '/api/v1/projects/{projectId}/cue/actions', 'incidents'), ('getCueProjectAlertRules', 'GET', '/api/v1/projects/{projectId}/cue/alert-rules', 'incidents'), ('getCueProjectAlerts', 'GET', '/api/v1/projects/{projectId}/cue/alerts', 'incidents'), ('getCueProjectDashboard', 'GET', '/api/v1/projects/{projectId}/cue/dashboard', 'incidents'), ('getCueProjectStatEvents', 'GET', '/api/v1/projects/{projectId}/cue/stat-events', 'incidents'), ('getCurrentMember', 'GET', '/api/v1/me', 'workspace'), ('getCurrentWorkSummary', 'GET', '/api/v1/feed/current-summary', 'activity'), ('getEngineerActivity', 'GET', '/api/v1/engineer-activity', 'activity'), ('getEngineerStats', 'GET', '/api/v1/engineer-stats', 'activity'), ('getFeedEvent', 'GET', '/api/v1/events/{id}', 'activity'), ('getGitHubComments', 'GET', '/api/v1/github/comments', 'activity'), ('getGitHubDetails', 'GET', '/api/v1/github/details', 'activity'), ('getGitHubRateLimit', 'GET', '/api/v1/github/rate-limit', 'workspace'), ('getIdentityProfile', 'GET', '/api/v1/auth/profile', 'workspace'), ('getIntegrationStatus', 'GET', '/api/v1/integrations/status', 'workspace'), ('getIssue', 'GET', '/api/v1/issues/{repo}/{number}', 'activity'), ('getNoxCueDailyHealth', 'GET', '/api/v1/cues/metrics', 'incidents'), ('getNoxCueGitHubIssueSettings', 'GET', '/api/v1/cues/github-issues', 'incidents'), ('getNoxCueProjectMetrics', 'GET', '/api/v1/cues/projects/{projectId}/metrics', 'incidents'), ('getNoxCueProjectOverview', 'GET', '/api/v1/cues/project-overview', 'incidents'), ('getNoxFeed', 'GET', '/api/v1/feed', 'activity'), ('getNoxFeedDefaultPrompt', 'GET', '/api/v1/noxfeed/release-notes-prompt', 'activity'), ('getNoxService', 'GET', '/api/v1/services/{service}', 'workspace'), ('getNoxServiceConfig', 'GET', '/api/v1/services/{service}/config', 'workspace'), ('getNoxServiceHealth', 'GET', '/api/v1/services/{service}/health', 'workspace'), ('getNoxServiceSetup', 'GET', '/api/v1/services/{service}/setup', 'workspace'), ('getNoxSpotProjectOverview', 'GET', '/api/v1/spots/project-overview', 'feedback'), ('getNoxSpotResolutionTemplate', 'GET', '/api/v1/spots/sites/{siteId}/resolution-template', 'feedback'), ('getOperatorUsage', 'GET', '/api/v1/operator/usage', 'workspace'), ('getProjectActivity', 'GET', '/api/v1/projects/{projectId}/activity', 'workspace'), ('getProjectFeedback', 'GET', '/api/v1/projects/{projectId}/feedback', 'workspace'), ('getProjectIncident', 'GET', '/api/v1/projects/{projectId}/incidents/{incidentId}', 'workspace'), ('getProjectIncidents', 'GET', '/api/v1/projects/{projectId}/incidents', 'workspace'), ('getProjectIssues', 'GET', '/api/v1/projects/{projectId}/issues', 'workspace'), ('getProjectRouting', 'GET', '/api/v1/projects/routing', 'workspace'), ('getPublicNoxSpotConfig', 'GET', '/api/spots/public/v1/sites/{siteId}/config', 'feedback'), ('getPullRequest', 'GET', '/api/v1/prs/{repo}/{number}', 'activity'), ('getSetupPlan', 'GET', '/api/v1/integrations/setup', 'workspace'), ('getSlackRouting', 'GET', '/api/v1/integrations/slack/routing', 'workspace'), ('getSlackStatus', 'GET', '/api/v1/slack/status', 'workspace'), ('getSpec', 'GET', '/api/v1/specs/{specId}', 'planning'), ('getSyncStatus', 'GET', '/api/v1/sync', 'workspace'), ('getWorkspaceConfig', 'GET', '/api/v1/config/{key}', 'workspace'), ('ingestNoxCueEvent', 'POST', '/api/v1/cues/public/events', 'incidents'), ('listActors', 'GET', '/api/v1/actors', 'workspace'), ('listApiTokens', 'GET', '/api/v1/api-tokens', 'workspace'), ('listConnections', 'GET', '/api/v1/integrations/connections', 'workspace'), ('listFeatureAttachments', 'GET', '/api/v1/features/{number}/attachments', 'planning'), ('listFeatures', 'GET', '/api/v1/features', 'planning'), ('listFeedEvents', 'GET', '/api/v1/events', 'activity'), ('listGitHubTeams', 'GET', '/api/v1/teams', 'workspace'), ('listGuestAccess', 'GET', '/api/v1/guests', 'workspace'), ('listIssues', 'GET', '/api/v1/issues', 'activity'), ('listMembers', 'GET', '/api/v1/members', 'workspace'), ('listNoxCueCustomMetrics', 'GET', '/api/v1/cues/sources/{sourceId}/custom-metrics', 'incidents'), ('listNoxCueEvents', 'GET', '/api/v1/cues/events', 'incidents'), ('listNoxCueFeatures', 'GET', '/api/v1/cues/sources/{sourceId}/features', 'incidents'), ('listNoxCueSources', 'GET', '/api/v1/cues/sources', 'incidents'), ('listNoxServices', 'GET', '/api/v1/services', 'workspace'), ('listNoxSpotSites', 'GET', '/api/v1/spots/sites', 'feedback'), ('listOperationFailures', 'GET', '/api/v1/op-failures', 'workspace'), ('listProjects', 'GET', '/api/v1/projects', 'workspace'), ('listPullRequests', 'GET', '/api/v1/prs', 'activity'), ('listRepositories', 'GET', '/api/v1/repos', 'workspace'), ('listSlackChannels', 'GET', '/api/v1/slack/channels', 'workspace'), ('listSpecAttachments', 'GET', '/api/v1/specs/{specId}/attachments', 'planning'), ('listSpecs', 'GET', '/api/v1/specs', 'planning'), ('patchNoxServiceConfig', 'PATCH', '/api/v1/services/{service}/config', 'workspace'), ('patchSlackRouting', 'PATCH', '/api/v1/integrations/slack/routing', 'workspace'), ('pollNativeDeviceAuthorization', 'POST', '/api/v1/auth/native/device/poll', 'workspace'), ('previewNoxSpotResolutionTemplate', 'POST', '/api/v1/spots/sites/{siteId}/resolution-template/preview', 'feedback'), ('putAiSettings', 'PUT', '/api/v1/llm-settings', 'activity'), ('putNoxCueGitHubIssueSettings', 'PUT', '/api/v1/cues/github-issues', 'incidents'), ('putWorkspaceConfig', 'PUT', '/api/v1/config/{key}', 'workspace'), ('recordAppActivity', 'POST', '/api/v1/app-activity', 'workspace'), ('recoverRepositoryHistory', 'POST', '/api/v1/recover-repo-history', 'workspace'), ('refreshNativeSession', 'POST', '/api/v1/auth/native/refresh', 'workspace'), ('reopenResolvedNoxSpotReport', 'POST', '/api/spots/public/v1/resolution-responses/{token}', 'feedback'), ('restoreProject', 'DELETE', '/api/v1/projects/{projectId}/archive', 'workspace'), ('restoreSpec', 'DELETE', '/api/v1/specs/{specId}/archive', 'planning'), ('retrieveProject', 'GET', '/api/v1/projects/{projectId}/retrieval', 'workspace'), ('retryNoxSpotDeliveries', 'POST', '/api/v1/spots/sites/{siteId}/retry-deliveries', 'feedback'), ('revokeApiToken', 'DELETE', '/api/v1/api-tokens/{id}', 'workspace'), ('revokeBrowserSession', 'POST', '/api/v1/auth/logout', 'workspace'), ('revokeGuestGrant', 'DELETE', '/api/v1/guests/grants/{grantId}', 'workspace'), ('revokeGuestInvitation', 'DELETE', '/api/v1/guests/invites/{inviteId}', 'workspace'), ('revokeNativeSession', 'POST', '/api/v1/auth/native/revoke', 'workspace'), ('revokeNoxCueKey', 'DELETE', '/api/v1/cues/sources/{sourceId}/keys/{keyId}', 'incidents'), ('rotateApiToken', 'POST', '/api/v1/api-tokens/{id}/rotate', 'workspace'), ('searchWorkspace', 'GET', '/api/v1/search', 'activity'), ('setIssueState', 'POST', '/api/v1/issue-state', 'planning'), ('startConnection', 'POST', '/api/v1/integrations/connections/{provider}/start', 'workspace'), ('startNativeDeviceAuthorization', 'POST', '/api/v1/auth/native/device/start', 'workspace'), ('submitPublicNoxSpotErrors', 'POST', '/api/spots/public/v1/errors', 'feedback'), ('submitPublicNoxSpotReport', 'POST', '/api/spots/public/v1/reports', 'feedback'), ('syncGitHubData', 'POST', '/api/v1/sync', 'workspace'), ('syncGitHubEvents', 'POST', '/api/v1/sync-events', 'workspace'), ('testNoxCueSource', 'POST', '/api/v1/cues/sources/{sourceId}/health/test', 'incidents'), ('testNoxSpotResolutionTemplate', 'POST', '/api/v1/spots/sites/{siteId}/resolution-template/test', 'feedback'), ('testSlackDestination', 'POST', '/api/v1/slack/test', 'workspace'), ('testSlackRoute', 'POST', '/api/v1/integrations/slack/test', 'workspace'), ('updateActor', 'PATCH', '/api/v1/actors/{actorId}', 'workspace'), ('updateCueProjectActions', 'PUT', '/api/v1/projects/{projectId}/cue/actions', 'incidents'), ('updateFeature', 'PATCH', '/api/v1/features/{number}', 'planning'), ('updateNoxCueCustomFeature', 'PUT', '/api/v1/cues/sources/{sourceId}/features/{featureKey}', 'incidents'), ('updateNoxCueCustomMetric', 'PUT', '/api/v1/cues/sources/{sourceId}/custom-metrics/{metricKey}', 'incidents'), ('updateNoxCueErrorStatus', 'PUT', '/api/v1/cues/errors/{sourceId}/{fingerprint}', 'incidents'), ('updateNoxCueProjectMetrics', 'PUT', '/api/v1/cues/projects/{projectId}/metrics', 'incidents'), ('updateNoxCueSource', 'PUT', '/api/v1/cues/sources/{sourceId}', 'incidents'), ('updateNoxSpotReport', 'PATCH', '/api/v1/spots/reports/{reportId}', 'feedback'), ('updateNoxSpotResolutionTemplate', 'PATCH', '/api/v1/spots/sites/{siteId}/resolution-template', 'feedback'), ('updateNoxSpotSite', 'PATCH', '/api/v1/spots/sites/{siteId}', 'feedback'), ('updateProjectIncident', 'PATCH', '/api/v1/projects/{projectId}/incidents/{incidentId}', 'workspace'), ('updateProjectRouting', 'PUT', '/api/v1/projects/{projectId}/routing', 'workspace'), ('updateSpec', 'PATCH', '/api/v1/specs/{specId}', 'planning'), ('uploadFeatureAttachment', 'POST', '/api/v1/features/{number}/attachments', 'planning'), ('uploadSpecAttachment', 'POST', '/api/v1/specs/{specId}/attachments', 'planning')]
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable, Mapping, Protocol, Sequence
7
+ from urllib.error import HTTPError
8
+ from urllib.parse import quote, urlencode, urljoin
9
+ from urllib.request import Request, urlopen
10
+
11
+ from ._operations import OPERATIONS
12
+
13
+ JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
14
+ TransportResult = tuple[int, Mapping[str, str], bytes]
15
+
16
+
17
+ class Transport(Protocol):
18
+ def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None) -> TransportResult: ...
19
+
20
+
21
+ class NoxHereApiError(RuntimeError):
22
+ def __init__(self, status: int, operation_id: str, details: Any) -> None:
23
+ super().__init__(f"NoxHere {operation_id} failed with HTTP {status}")
24
+ self.status = status
25
+ self.operation_id = operation_id
26
+ self.details = details
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Operation:
31
+ operation_id: str
32
+ method: str
33
+ path: str
34
+ namespace: str
35
+
36
+
37
+ def _snake(value: str) -> str:
38
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", value).lower()
39
+
40
+
41
+ def _default_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None) -> TransportResult:
42
+ request = Request(url, data=body, headers=dict(headers), method=method)
43
+ try:
44
+ with urlopen(request, timeout=30) as response: # noqa: S310 - URL is bound to configured NoxHere origin
45
+ return response.status, dict(response.headers.items()), response.read()
46
+ except HTTPError as error:
47
+ return error.code, dict(error.headers.items()), error.read()
48
+
49
+
50
+ def _payload(headers: Mapping[str, str], body: bytes) -> Any:
51
+ if not body:
52
+ return None
53
+ content_type = next((value for key, value in headers.items() if key.lower() == "content-type"), "")
54
+ if "json" in content_type.lower():
55
+ return json.loads(body)
56
+ if content_type.lower().startswith("text/"):
57
+ return body.decode()
58
+ return body
59
+
60
+
61
+ class ResourceClient:
62
+ def __init__(self, client: "NoxHereClient", operations: Sequence[Operation]) -> None:
63
+ self._client = client
64
+ self._operations = {_snake(operation.operation_id): operation.operation_id for operation in operations}
65
+ self._operations.update({operation.operation_id: operation.operation_id for operation in operations})
66
+
67
+ def __getattr__(self, name: str) -> Callable[..., Any]:
68
+ operation_id = self._operations.get(name)
69
+ if operation_id is None:
70
+ raise AttributeError(name)
71
+
72
+ def invoke(**kwargs: Any) -> Any:
73
+ return self._client.request(operation_id, **kwargs)
74
+
75
+ return invoke
76
+
77
+ def operation_ids(self) -> tuple[str, ...]:
78
+ return tuple(sorted(set(self._operations.values())))
79
+
80
+
81
+ class NoxHereClient:
82
+ def __init__(
83
+ self,
84
+ *,
85
+ base_url: str = "https://app.noxhere.com",
86
+ token: str | None = None,
87
+ organization: str | None = None,
88
+ project_id: str | None = None,
89
+ csrf_token: str | None = None,
90
+ headers: Mapping[str, str] | None = None,
91
+ transport: Transport = _default_transport,
92
+ ) -> None:
93
+ self.base_url = base_url.rstrip("/") + "/"
94
+ self._transport = transport
95
+ self._headers = dict(headers or {})
96
+ if token:
97
+ self._headers["Authorization"] = f"Bearer {token}"
98
+ if organization:
99
+ self._headers["X-Org"] = organization
100
+ if project_id:
101
+ self._headers["X-Project-ID"] = project_id
102
+ if csrf_token:
103
+ self._headers["X-CSRF-Token"] = csrf_token
104
+ self._operations = {
105
+ item[0]: Operation(operation_id=item[0], method=item[1], path=item[2], namespace=item[3])
106
+ for item in OPERATIONS
107
+ }
108
+ resources = {
109
+ namespace: ResourceClient(self, [operation for operation in self._operations.values() if operation.namespace == namespace])
110
+ for namespace in ("workspace", "activity", "planning", "feedback", "incidents")
111
+ }
112
+ self.workspace = resources["workspace"]
113
+ self.activity = resources["activity"]
114
+ self.planning = resources["planning"]
115
+ self.feedback = resources["feedback"]
116
+ self.incidents = resources["incidents"]
117
+ self.connect = self.workspace
118
+ self.feed = self.activity
119
+ self.ticket = self.planning
120
+ self.spot = self.feedback
121
+ self.cue = self.incidents
122
+
123
+ def request(
124
+ self,
125
+ operation_id: str,
126
+ *,
127
+ path: Mapping[str, str | int] | None = None,
128
+ query: Mapping[str, str | int | float | bool | Sequence[str | int | float | bool] | None] | None = None,
129
+ body: JsonValue | str | bytes | None = None,
130
+ headers: Mapping[str, str] | None = None,
131
+ ) -> Any:
132
+ operation = self._operations.get(operation_id)
133
+ if operation is None:
134
+ raise ValueError(f"Unknown NoxHere operation: {operation_id}")
135
+ values = dict(path or {})
136
+
137
+ def replace(match: re.Match[str]) -> str:
138
+ key = match.group(1)
139
+ if key not in values or values[key] == "":
140
+ raise ValueError(f"Missing path parameter: {key}")
141
+ return quote(str(values[key]), safe="")
142
+
143
+ operation_path = re.sub(r"\{([^}]+)\}", replace, operation.path)
144
+ pairs: list[tuple[str, str]] = []
145
+ for key, value in (query or {}).items():
146
+ if value is None:
147
+ continue
148
+ items = value if isinstance(value, (list, tuple)) else [value]
149
+ pairs.extend((key, str(item).lower() if isinstance(item, bool) else str(item)) for item in items)
150
+ url = urljoin(self.base_url, operation_path)
151
+ if pairs:
152
+ url += "?" + urlencode(pairs)
153
+ request_headers = {**self._headers, **dict(headers or {})}
154
+ encoded: bytes | None
155
+ if body is None:
156
+ encoded = None
157
+ elif isinstance(body, bytes):
158
+ encoded = body
159
+ elif isinstance(body, str):
160
+ encoded = body.encode()
161
+ else:
162
+ request_headers["Content-Type"] = "application/json"
163
+ encoded = json.dumps(body, separators=(",", ":")).encode()
164
+ status, response_headers, response_body = self._transport(operation.method, url, request_headers, encoded)
165
+ result = _payload(response_headers, response_body)
166
+ if not 200 <= status < 300:
167
+ raise NoxHereApiError(status, operation_id, result)
168
+ return result
169
+
170
+ def operation_ids(self) -> tuple[str, ...]:
171
+ return tuple(sorted(self._operations))
172
+
173
+
174
+ def create_noxhere(**options: Any) -> NoxHereClient:
175
+ return NoxHereClient(**options)
@@ -0,0 +1,7 @@
1
+ from typing import Any
2
+
3
+ from .client import NoxHereClient, ResourceClient
4
+
5
+
6
+ def create_feedback_client(**options: Any) -> ResourceClient:
7
+ return NoxHereClient(**options).feedback
@@ -0,0 +1,7 @@
1
+ from typing import Any
2
+
3
+ from .client import NoxHereClient, ResourceClient
4
+
5
+
6
+ def create_incident_client(**options: Any) -> ResourceClient:
7
+ return NoxHereClient(**options).incidents
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import unittest
5
+ from typing import Mapping
6
+
7
+ from noxhere import NoxHereApiError, NoxHereClient
8
+
9
+
10
+ class Recorder:
11
+ def __init__(self, status: int = 200, response: object = None) -> None:
12
+ self.calls: list[tuple[str, str, Mapping[str, str], bytes | None]] = []
13
+ self.status = status
14
+ self.response = response
15
+
16
+ def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None):
17
+ self.calls.append((method, url, headers, body))
18
+ return self.status, {"Content-Type": "application/json"}, json.dumps(self.response).encode()
19
+
20
+
21
+ class ClientTest(unittest.TestCase):
22
+ def test_maps_every_operation_and_alias(self) -> None:
23
+ client = NoxHereClient(transport=Recorder())
24
+ self.assertEqual(len(client.operation_ids()), 149)
25
+ self.assertIs(client.cue, client.incidents)
26
+ self.assertIs(client.spot, client.feedback)
27
+
28
+ def test_encodes_path_query_and_auth(self) -> None:
29
+ recorder = Recorder(response={"ok": True})
30
+ client = NoxHereClient(
31
+ base_url="https://example.test/base/",
32
+ token="nox_sk_test_secret",
33
+ organization="no-box-dev",
34
+ project_id="project-1",
35
+ transport=recorder,
36
+ )
37
+ result = client.activity.get_issue(path={"repo": "owner/repo", "number": 42}, query={"include": ["body", "labels"]})
38
+ self.assertEqual(result, {"ok": True})
39
+ _, url, headers, _ = recorder.calls[0]
40
+ self.assertEqual(url, "https://example.test/api/v1/issues/owner%2Frepo/42?include=body&include=labels")
41
+ self.assertEqual(headers["Authorization"], "Bearer nox_sk_test_secret")
42
+ self.assertEqual(headers["X-Project-ID"], "project-1")
43
+
44
+ def test_json_body_and_error(self) -> None:
45
+ recorder = Recorder(status=403, response={"error": "denied"})
46
+ client = NoxHereClient(transport=recorder)
47
+ with self.assertRaises(NoxHereApiError) as raised:
48
+ client.workspace.create_project(body={"name": "Demo"})
49
+ self.assertEqual(raised.exception.status, 403)
50
+ self.assertEqual(raised.exception.details, {"error": "denied"})
51
+ self.assertEqual(recorder.calls[0][2]["Content-Type"], "application/json")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ unittest.main()