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.
- noxhere-0.1.0/.gitignore +5 -0
- noxhere-0.1.0/PKG-INFO +32 -0
- noxhere-0.1.0/README.md +17 -0
- noxhere-0.1.0/pyproject.toml +33 -0
- noxhere-0.1.0/scripts/generate_operations.py +44 -0
- noxhere-0.1.0/src/noxhere/__init__.py +3 -0
- noxhere-0.1.0/src/noxhere/_operations.py +4 -0
- noxhere-0.1.0/src/noxhere/client.py +175 -0
- noxhere-0.1.0/src/noxhere/feedback.py +7 -0
- noxhere-0.1.0/src/noxhere/incidents.py +7 -0
- noxhere-0.1.0/src/noxhere/py.typed +1 -0
- noxhere-0.1.0/tests/test_client.py +55 -0
noxhere-0.1.0/.gitignore
ADDED
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.
|
noxhere-0.1.0/README.md
ADDED
|
@@ -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,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 @@
|
|
|
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()
|