ascent-science 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,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: ascent-science
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Ascent Science algorithm and evidence platform
5
+ Author: Ascent Science
6
+ License-Expression: LicenseRef-Proprietary
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: Typing :: Typed
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Ascent Science Python SDK
14
+
15
+ The SDK is the supported Python client for an Ascent Science server. It does
16
+ not install solver runtimes locally.
17
+
18
+ ```python
19
+ from ascent import Client
20
+
21
+ client = Client(endpoint="http://127.0.0.1:8000/v1", api_key="asc_live_...")
22
+ job = client.algorithms.submit(
23
+ "GraphColoring",
24
+ inputs={"data_size": 32},
25
+ parameters={"nodes": 12, "edge_density": 0.2},
26
+ idempotency_key="graph-demo-1",
27
+ )
28
+ result = job.wait(timeout=60)
29
+ print(result.result)
30
+ ```
31
+
32
+ `ASCENT_ENDPOINT` and `ASCENT_API_KEY` can be used instead of constructor
33
+ arguments. API keys are created in an authenticated browser session and shown
34
+ only once.
35
+
@@ -0,0 +1,23 @@
1
+ # Ascent Science Python SDK
2
+
3
+ The SDK is the supported Python client for an Ascent Science server. It does
4
+ not install solver runtimes locally.
5
+
6
+ ```python
7
+ from ascent import Client
8
+
9
+ client = Client(endpoint="http://127.0.0.1:8000/v1", api_key="asc_live_...")
10
+ job = client.algorithms.submit(
11
+ "GraphColoring",
12
+ inputs={"data_size": 32},
13
+ parameters={"nodes": 12, "edge_density": 0.2},
14
+ idempotency_key="graph-demo-1",
15
+ )
16
+ result = job.wait(timeout=60)
17
+ print(result.result)
18
+ ```
19
+
20
+ `ASCENT_ENDPOINT` and `ASCENT_API_KEY` can be used instead of constructor
21
+ arguments. API keys are created in an authenticated browser session and shown
22
+ only once.
23
+
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ascent-science"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the Ascent Science algorithm and evidence platform"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "LicenseRef-Proprietary"
12
+ authors = [{name = "Ascent Science"}]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Typing :: Typed",
17
+ ]
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+
22
+ [tool.setuptools.package-data]
23
+ ascent = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,38 @@
1
+ """Public interface for the Ascent Science Python SDK."""
2
+
3
+ from .async_client import AsyncClient, AsyncJob
4
+ from .client import Client, Job
5
+ from .errors import (
6
+ AscentError,
7
+ AuthenticationError,
8
+ JobFailedError,
9
+ JobTimeoutError,
10
+ NotFoundError,
11
+ PermissionDeniedError,
12
+ RateLimitError,
13
+ ServerError,
14
+ TransportError,
15
+ ValidationError,
16
+ )
17
+ from .models import JobResult
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "AscentError",
23
+ "AsyncClient",
24
+ "AsyncJob",
25
+ "AuthenticationError",
26
+ "Client",
27
+ "Job",
28
+ "JobFailedError",
29
+ "JobResult",
30
+ "JobTimeoutError",
31
+ "NotFoundError",
32
+ "PermissionDeniedError",
33
+ "RateLimitError",
34
+ "ServerError",
35
+ "TransportError",
36
+ "ValidationError",
37
+ "__version__",
38
+ ]
@@ -0,0 +1,86 @@
1
+ """Small JSON HTTP transport built on the Python standard library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import socket
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.parse import urlencode
9
+ from urllib.request import Request, urlopen
10
+
11
+ from .errors import (
12
+ AuthenticationError,
13
+ NotFoundError,
14
+ PermissionDeniedError,
15
+ RateLimitError,
16
+ ServerError,
17
+ TransportError,
18
+ ValidationError,
19
+ )
20
+
21
+
22
+ ERROR_TYPES = {
23
+ 400: ValidationError,
24
+ 401: AuthenticationError,
25
+ 403: PermissionDeniedError,
26
+ 404: NotFoundError,
27
+ 409: ValidationError,
28
+ 422: ValidationError,
29
+ 429: RateLimitError,
30
+ }
31
+
32
+
33
+ class JsonTransport:
34
+ def __init__(self, endpoint, api_key, timeout=30.0, user_agent="ascent-science-python/0.1.0"):
35
+ endpoint = str(endpoint).strip().rstrip("/")
36
+ if not endpoint.startswith(("http://", "https://")):
37
+ raise ValueError("endpoint must start with http:// or https://")
38
+ if not api_key:
39
+ raise ValueError("api_key is required")
40
+ self.endpoint = endpoint
41
+ self.api_key = str(api_key)
42
+ self.timeout = float(timeout)
43
+ self.user_agent = user_agent
44
+
45
+ def request(self, method, path, *, payload=None, query=None, headers=None, timeout=None):
46
+ url = self.endpoint + "/" + path.lstrip("/")
47
+ if query:
48
+ url += "?" + urlencode(query)
49
+ body = None
50
+ request_headers = {
51
+ "Accept": "application/json",
52
+ "Authorization": "Bearer " + self.api_key,
53
+ "User-Agent": self.user_agent,
54
+ }
55
+ if payload is not None:
56
+ body = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
57
+ request_headers["Content-Type"] = "application/json"
58
+ request_headers.update(headers or {})
59
+ request = Request(url, data=body, method=method, headers=request_headers)
60
+ try:
61
+ with urlopen(request, timeout=self.timeout if timeout is None else timeout) as response:
62
+ raw = response.read()
63
+ return json.loads(raw.decode("utf-8")) if raw else None
64
+ except HTTPError as error:
65
+ try:
66
+ raw = error.read()
67
+ finally:
68
+ error.close()
69
+ try:
70
+ response = json.loads(raw.decode("utf-8")) if raw else {}
71
+ except (UnicodeDecodeError, json.JSONDecodeError):
72
+ response = {}
73
+ detail = response.get("error", response)
74
+ if isinstance(detail, dict):
75
+ message = detail.get("message") or detail.get("code") or str(detail)
76
+ details = detail.get("details")
77
+ request_id = detail.get("request_id") or error.headers.get("X-Request-Id")
78
+ else:
79
+ message, details = str(detail or error.reason), None
80
+ request_id = error.headers.get("X-Request-Id")
81
+ exception_type = ERROR_TYPES.get(error.code, ServerError if error.code >= 500 else TransportError)
82
+ raise exception_type(
83
+ message, status_code=error.code, request_id=request_id, details=details
84
+ ) from None
85
+ except (URLError, socket.timeout, TimeoutError, OSError) as error:
86
+ raise TransportError("Unable to reach Ascent endpoint: %s" % error) from error
@@ -0,0 +1,106 @@
1
+ """Async facade for applications using asyncio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from .client import Client
8
+
9
+
10
+ class AsyncJob:
11
+ def __init__(self, job):
12
+ self._job = job
13
+
14
+ async def refresh(self):
15
+ await asyncio.to_thread(self._job.refresh)
16
+ return self
17
+
18
+ async def cancel(self):
19
+ await asyncio.to_thread(self._job.cancel)
20
+ return self
21
+
22
+ async def logs(self, *, after=0, limit=200):
23
+ return await asyncio.to_thread(self._job.logs, after=after, limit=limit)
24
+
25
+ async def wait(self, *, timeout=None, poll_interval=0.25, max_interval=3.0):
26
+ return await asyncio.to_thread(
27
+ self._job.wait, timeout=timeout, poll_interval=poll_interval, max_interval=max_interval
28
+ )
29
+
30
+ def __getattr__(self, name):
31
+ return getattr(self._job, name)
32
+
33
+
34
+ class AsyncAlgorithmsResource:
35
+ def __init__(self, resource):
36
+ self._resource = resource
37
+
38
+ async def list(self):
39
+ return await asyncio.to_thread(self._resource.list)
40
+
41
+ async def retrieve(self, name):
42
+ return await asyncio.to_thread(self._resource.retrieve, name)
43
+
44
+ async def submit(self, algorithm, **kwargs):
45
+ return AsyncJob(await asyncio.to_thread(self._resource.submit, algorithm, **kwargs))
46
+
47
+
48
+ class AsyncJobsResource:
49
+ def __init__(self, resource):
50
+ self._resource = resource
51
+
52
+ async def list(self, *, limit=30):
53
+ jobs = await asyncio.to_thread(self._resource.list, limit=limit)
54
+ return [AsyncJob(job) for job in jobs]
55
+
56
+ async def retrieve(self, job_id):
57
+ return AsyncJob(await asyncio.to_thread(self._resource.retrieve, job_id))
58
+
59
+
60
+ class AsyncPipelinesResource:
61
+ def __init__(self, resource):
62
+ self._resource = resource
63
+
64
+ async def submit(self, algorithms, data, **kwargs):
65
+ job = await asyncio.to_thread(self._resource.submit, algorithms, data, **kwargs)
66
+ return AsyncJob(job)
67
+
68
+
69
+ class AsyncExperimentsResource:
70
+ def __init__(self, resource):
71
+ self._resource = resource
72
+
73
+ async def list(self, *, limit=30):
74
+ return await asyncio.to_thread(self._resource.list, limit=limit)
75
+
76
+ async def retrieve(self, experiment_id):
77
+ return await asyncio.to_thread(self._resource.retrieve, experiment_id)
78
+
79
+
80
+ class AsyncEvidenceResource:
81
+ def __init__(self, resource):
82
+ self._resource = resource
83
+
84
+ async def bundle(self):
85
+ return await asyncio.to_thread(self._resource.bundle)
86
+
87
+
88
+ class AsyncClient:
89
+ """Async facade with the same configuration as :class:`Client`."""
90
+
91
+ def __init__(self, **kwargs):
92
+ self._client = Client(**kwargs)
93
+ self.algorithms = AsyncAlgorithmsResource(self._client.algorithms)
94
+ self.jobs = AsyncJobsResource(self._client.jobs)
95
+ self.pipelines = AsyncPipelinesResource(self._client.pipelines)
96
+ self.experiments = AsyncExperimentsResource(self._client.experiments)
97
+ self.evidence = AsyncEvidenceResource(self._client.evidence)
98
+
99
+ async def close(self):
100
+ await asyncio.to_thread(self._client.close)
101
+
102
+ async def __aenter__(self):
103
+ return self
104
+
105
+ async def __aexit__(self, *args):
106
+ await self.close()
@@ -0,0 +1,167 @@
1
+ """Synchronous Ascent Science client and resource objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import random
7
+ import time
8
+ from urllib.parse import quote
9
+
10
+ from ._transport import JsonTransport
11
+ from .errors import JobFailedError, JobTimeoutError
12
+ from .models import JobResult
13
+
14
+
15
+ class Job:
16
+ def __init__(self, client, value):
17
+ self._client = client
18
+ self._value = JobResult.from_dict(value)
19
+
20
+ def _replace(self, value):
21
+ self._value = JobResult.from_dict(value)
22
+ return self
23
+
24
+ def refresh(self):
25
+ return self._replace(self._client._request("GET", "jobs/" + self.id)["job"])
26
+
27
+ def cancel(self):
28
+ return self._replace(self._client._request("POST", "jobs/" + self.id + "/cancel", payload={})["job"])
29
+
30
+ def logs(self, *, after=0, limit=200):
31
+ return self._client._request("GET", "jobs/" + self.id + "/logs", query={"after": after, "limit": limit})["logs"]
32
+
33
+ def wait(self, *, timeout=None, poll_interval=0.25, max_interval=3.0):
34
+ deadline = None if timeout is None else time.monotonic() + float(timeout)
35
+ delay = max(0.05, float(poll_interval))
36
+ while True:
37
+ self.refresh()
38
+ if self.done:
39
+ if self.status == "succeeded":
40
+ return self._value
41
+ if self.status == "timed_out":
42
+ raise JobTimeoutError(self.error or "The server-side job timed out.")
43
+ raise JobFailedError(self.error or "Job ended with status %s." % self.status)
44
+ if deadline is not None and time.monotonic() >= deadline:
45
+ raise JobTimeoutError("Timed out while waiting for job %s." % self.id)
46
+ remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
47
+ sleep_for = delay + random.uniform(0.0, min(0.1, delay / 4))
48
+ time.sleep(sleep_for if remaining is None else min(sleep_for, remaining))
49
+ delay = min(float(max_interval), delay * 1.5)
50
+
51
+ def __getattr__(self, name):
52
+ try:
53
+ return getattr(self._value, name)
54
+ except AttributeError:
55
+ raise AttributeError(name) from None
56
+
57
+ def __repr__(self):
58
+ return "Job(id=%r, status=%r)" % (self.id, self.status)
59
+
60
+
61
+ class AlgorithmsResource:
62
+ def __init__(self, client):
63
+ self._client = client
64
+
65
+ def list(self):
66
+ return self._client._request("GET", "algorithms")["categories"]
67
+
68
+ def retrieve(self, name):
69
+ return self._client._request("GET", "algorithms/" + quote(str(name), safe=""))
70
+
71
+ def submit(
72
+ self,
73
+ algorithm,
74
+ *,
75
+ inputs=None,
76
+ parameters=None,
77
+ execution=None,
78
+ dataset_version_id=None,
79
+ idempotency_key=None
80
+ ):
81
+ payload = {
82
+ "kind": "algorithm",
83
+ "algorithm": algorithm,
84
+ "input": inputs or {},
85
+ "parameters": parameters or {},
86
+ "execution": execution or {},
87
+ }
88
+ if dataset_version_id:
89
+ payload["dataset_version_id"] = dataset_version_id
90
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
91
+ response = self._client._request("POST", "jobs", payload=payload, headers=headers)
92
+ return Job(self._client, response["job"])
93
+
94
+
95
+ class JobsResource:
96
+ def __init__(self, client):
97
+ self._client = client
98
+
99
+ def list(self, *, limit=30):
100
+ return [Job(self._client, item) for item in self._client._request("GET", "jobs", query={"limit": limit})["jobs"]]
101
+
102
+ def retrieve(self, job_id):
103
+ return Job(self._client, self._client._request("GET", "jobs/" + str(job_id))["job"])
104
+
105
+
106
+ class PipelinesResource:
107
+ def __init__(self, client):
108
+ self._client = client
109
+
110
+ def submit(self, algorithms, data, *, iterations=50, execution=None, idempotency_key=None):
111
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
112
+ response = self._client._request(
113
+ "POST",
114
+ "jobs",
115
+ payload={
116
+ "kind": "pipeline",
117
+ "input": {"algorithms": list(algorithms), "data": list(data), "iterations": iterations},
118
+ "execution": execution or {},
119
+ },
120
+ headers=headers,
121
+ )
122
+ return Job(self._client, response["job"])
123
+
124
+
125
+ class ExperimentsResource:
126
+ def __init__(self, client):
127
+ self._client = client
128
+
129
+ def list(self, *, limit=30):
130
+ return self._client._request("GET", "experiments", query={"limit": limit})["experiments"]
131
+
132
+ def retrieve(self, experiment_id):
133
+ return self._client._request("GET", "experiments/" + str(experiment_id))
134
+
135
+
136
+ class EvidenceResource:
137
+ def __init__(self, client):
138
+ self._client = client
139
+
140
+ def bundle(self):
141
+ return self._client._request("GET", "evidence")
142
+
143
+
144
+ class Client:
145
+ """Client for the versioned Ascent HTTP API."""
146
+
147
+ def __init__(self, *, endpoint=None, api_key=None, timeout=30.0):
148
+ endpoint = endpoint or os.environ.get("ASCENT_ENDPOINT", "http://127.0.0.1:8000/v1")
149
+ api_key = api_key or os.environ.get("ASCENT_API_KEY") or os.environ.get("ASCENT_TOKEN")
150
+ self._transport = JsonTransport(endpoint, api_key, timeout=timeout)
151
+ self.algorithms = AlgorithmsResource(self)
152
+ self.jobs = JobsResource(self)
153
+ self.pipelines = PipelinesResource(self)
154
+ self.experiments = ExperimentsResource(self)
155
+ self.evidence = EvidenceResource(self)
156
+
157
+ def _request(self, method, path, **kwargs):
158
+ return self._transport.request(method, path, **kwargs)
159
+
160
+ def close(self):
161
+ return None
162
+
163
+ def __enter__(self):
164
+ return self
165
+
166
+ def __exit__(self, *args):
167
+ self.close()
@@ -0,0 +1,48 @@
1
+ """Public SDK exception hierarchy."""
2
+
3
+
4
+ class AscentError(Exception):
5
+ """Base class for all SDK errors."""
6
+
7
+ def __init__(self, message, *, status_code=None, request_id=None, details=None):
8
+ super().__init__(message)
9
+ self.status_code = status_code
10
+ self.request_id = request_id
11
+ self.details = details
12
+
13
+
14
+ class AuthenticationError(AscentError):
15
+ pass
16
+
17
+
18
+ class PermissionDeniedError(AscentError):
19
+ pass
20
+
21
+
22
+ class NotFoundError(AscentError):
23
+ pass
24
+
25
+
26
+ class ValidationError(AscentError):
27
+ pass
28
+
29
+
30
+ class RateLimitError(AscentError):
31
+ pass
32
+
33
+
34
+ class ServerError(AscentError):
35
+ pass
36
+
37
+
38
+ class TransportError(AscentError):
39
+ pass
40
+
41
+
42
+ class JobFailedError(AscentError):
43
+ pass
44
+
45
+
46
+ class JobTimeoutError(AscentError):
47
+ pass
48
+
@@ -0,0 +1,56 @@
1
+ """Typed response objects exposed by the SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Dict, Optional
7
+
8
+
9
+ TERMINAL_JOB_STATUSES = frozenset({"succeeded", "failed", "cancelled", "timed_out"})
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class JobResult:
14
+ id: str
15
+ kind: str
16
+ status: str
17
+ payload: Dict[str, Any]
18
+ result: Optional[Dict[str, Any]]
19
+ error: Optional[str]
20
+ created_at: str
21
+ started_at: Optional[str]
22
+ completed_at: Optional[str]
23
+ raw: Dict[str, Any]
24
+
25
+ @classmethod
26
+ def from_dict(cls, value):
27
+ return cls(
28
+ id=value["id"],
29
+ kind=value["kind"],
30
+ status=value["status"],
31
+ payload=value.get("payload") or {},
32
+ result=value.get("result"),
33
+ error=value.get("error"),
34
+ created_at=value["created_at"],
35
+ started_at=value.get("started_at"),
36
+ completed_at=value.get("completed_at"),
37
+ raw=dict(value),
38
+ )
39
+
40
+ @property
41
+ def done(self):
42
+ return self.status in TERMINAL_JOB_STATUSES
43
+
44
+ @property
45
+ def experiment(self):
46
+ return (self.result or {}).get("experiment")
47
+
48
+ @property
49
+ def output(self):
50
+ return (self.result or {}).get("output")
51
+
52
+ @property
53
+ def fingerprint(self):
54
+ experiment = self.experiment or {}
55
+ return experiment.get("fingerprint") or experiment.get("request_fingerprint")
56
+
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: ascent-science
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Ascent Science algorithm and evidence platform
5
+ Author: Ascent Science
6
+ License-Expression: LicenseRef-Proprietary
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: Typing :: Typed
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Ascent Science Python SDK
14
+
15
+ The SDK is the supported Python client for an Ascent Science server. It does
16
+ not install solver runtimes locally.
17
+
18
+ ```python
19
+ from ascent import Client
20
+
21
+ client = Client(endpoint="http://127.0.0.1:8000/v1", api_key="asc_live_...")
22
+ job = client.algorithms.submit(
23
+ "GraphColoring",
24
+ inputs={"data_size": 32},
25
+ parameters={"nodes": 12, "edge_density": 0.2},
26
+ idempotency_key="graph-demo-1",
27
+ )
28
+ result = job.wait(timeout=60)
29
+ print(result.result)
30
+ ```
31
+
32
+ `ASCENT_ENDPOINT` and `ASCENT_API_KEY` can be used instead of constructor
33
+ arguments. API keys are created in an authenticated browser session and shown
34
+ only once.
35
+
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/ascent/__init__.py
4
+ src/ascent/_transport.py
5
+ src/ascent/async_client.py
6
+ src/ascent/client.py
7
+ src/ascent/errors.py
8
+ src/ascent/models.py
9
+ src/ascent/py.typed
10
+ src/ascent_science.egg-info/PKG-INFO
11
+ src/ascent_science.egg-info/SOURCES.txt
12
+ src/ascent_science.egg-info/dependency_links.txt
13
+ src/ascent_science.egg-info/top_level.txt