eolaswork 0.1.0__py3-none-any.whl

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.
eolaswork/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """EolasWork Python SDK.
2
+
3
+ Programmatic access to the EolasWork agentic platform: create tasks
4
+ (conversations), launch agentic runs against roles / teams / skills,
5
+ upload + download files, stream events, and receive webhook callbacks
6
+ on terminal-state transitions.
7
+
8
+ Public surface:
9
+ Client / AsyncClient - entry points
10
+ EolasWorkError + subclasses - exception hierarchy
11
+ Account, Task, Run, RunEvent, - response dataclasses
12
+ File, Model, Role, Team, Skill,
13
+ Followup, MemoryEntry, ApiKey
14
+
15
+ See README.md for usage examples.
16
+ """
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ from .async_client import AsyncClient
21
+ from .client import Client
22
+ from .errors import (
23
+ APIConnectionError,
24
+ APITimeoutError,
25
+ AuthenticationError,
26
+ ConflictError,
27
+ EolasWorkError,
28
+ NotFoundError,
29
+ PermissionDeniedError,
30
+ RateLimitError,
31
+ ServerError,
32
+ ValidationError,
33
+ )
34
+ from .types import (
35
+ Account,
36
+ ApiKey,
37
+ File,
38
+ Followup,
39
+ MemoryEntry,
40
+ Model,
41
+ Role,
42
+ Run,
43
+ RunEvent,
44
+ Skill,
45
+ Task,
46
+ Team,
47
+ )
48
+
49
+ __all__ = [
50
+ "__version__",
51
+ "Client",
52
+ "AsyncClient",
53
+ # Errors
54
+ "EolasWorkError",
55
+ "AuthenticationError",
56
+ "PermissionDeniedError",
57
+ "NotFoundError",
58
+ "ConflictError",
59
+ "ValidationError",
60
+ "RateLimitError",
61
+ "ServerError",
62
+ "APIConnectionError",
63
+ "APITimeoutError",
64
+ # Types
65
+ "Account",
66
+ "Task",
67
+ "Run",
68
+ "RunEvent",
69
+ "File",
70
+ "Model",
71
+ "Role",
72
+ "Team",
73
+ "Skill",
74
+ "Followup",
75
+ "MemoryEntry",
76
+ "ApiKey",
77
+ ]
@@ -0,0 +1,123 @@
1
+ """Async mirror of SyncTransport.
2
+
3
+ Shares the same _handle / status-mapping logic; differs only in
4
+ awaiting httpx.AsyncClient. The two transports import the shared
5
+ constants (RETRY_STATUSES, SAFE_VERBS, _retry_after) from _transport
6
+ so the two stay in lock-step.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import random
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from ._config import ClientConfig
18
+ from ._transport import RETRY_STATUSES, SAFE_VERBS, _retry_after
19
+ from .errors import (
20
+ APIConnectionError,
21
+ APITimeoutError,
22
+ EolasWorkError,
23
+ error_for_status,
24
+ )
25
+
26
+
27
+ class AsyncTransport:
28
+ def __init__(self, config: ClientConfig):
29
+ self._config = config
30
+ self._client = httpx.AsyncClient(
31
+ base_url=config.base_url,
32
+ timeout=config.timeout,
33
+ transport=config.transport,
34
+ headers={
35
+ "user-agent": config.user_agent,
36
+ "authorization": f"Bearer {config.api_key}",
37
+ },
38
+ )
39
+
40
+ async def aclose(self) -> None:
41
+ await self._client.aclose()
42
+
43
+ async def __aenter__(self):
44
+ return self
45
+
46
+ async def __aexit__(self, *_):
47
+ await self.aclose()
48
+
49
+ async def request(
50
+ self,
51
+ method: str,
52
+ path: str,
53
+ *,
54
+ params: dict[str, Any] | None = None,
55
+ json: Any = None,
56
+ files: Any = None,
57
+ data: dict[str, Any] | None = None,
58
+ idempotency_key: str | None = None,
59
+ extra_headers: dict[str, str] | None = None,
60
+ ) -> Any:
61
+ headers = dict(extra_headers or {})
62
+ if idempotency_key:
63
+ headers["idempotency-key"] = idempotency_key
64
+
65
+ retry_eligible = method.upper() in SAFE_VERBS or idempotency_key is not None
66
+ attempts = max(self._config.max_retries + 1, 1) if retry_eligible else 1
67
+
68
+ for attempt in range(attempts):
69
+ try:
70
+ response = await self._client.request(
71
+ method, path, params=params, json=json, files=files,
72
+ data=data, headers=headers,
73
+ )
74
+ except httpx.TimeoutException as exc:
75
+ if attempt + 1 < attempts:
76
+ await self._sleep(attempt, None)
77
+ continue
78
+ raise APITimeoutError(str(exc)) from exc
79
+ except httpx.HTTPError as exc:
80
+ if attempt + 1 < attempts:
81
+ await self._sleep(attempt, None)
82
+ continue
83
+ raise APIConnectionError(str(exc)) from exc
84
+
85
+ if response.status_code in RETRY_STATUSES and attempt + 1 < attempts:
86
+ await self._sleep(attempt, _retry_after(response))
87
+ continue
88
+
89
+ return self._handle(response)
90
+
91
+ raise EolasWorkError("retry loop exited without response")
92
+
93
+ async def stream(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
94
+ """Open an async streaming response. Caller awaits + closes."""
95
+ req = self._client.build_request(method, path, **kwargs)
96
+ return await self._client.send(req, stream=True)
97
+
98
+ def _handle(self, response: httpx.Response) -> Any:
99
+ request_id = response.headers.get("x-request-id")
100
+ if response.status_code == 204 or not response.content:
101
+ if 200 <= response.status_code < 300:
102
+ return None
103
+ raise error_for_status(response.status_code, request_id=request_id, body=None)
104
+ try:
105
+ body = response.json()
106
+ except Exception:
107
+ body = response.text
108
+ if 200 <= response.status_code < 300:
109
+ return body
110
+ raise error_for_status(
111
+ response.status_code,
112
+ request_id=request_id,
113
+ body=body,
114
+ retry_after=_retry_after(response),
115
+ )
116
+
117
+ @staticmethod
118
+ async def _sleep(attempt: int, retry_after_sec: float | None) -> None:
119
+ if retry_after_sec is not None:
120
+ await asyncio.sleep(retry_after_sec)
121
+ return
122
+ base = min(0.5 * (2 ** attempt), 8.0)
123
+ await asyncio.sleep(base + random.uniform(0, base / 2))
eolaswork/_config.py ADDED
@@ -0,0 +1,74 @@
1
+ """Client-wide configuration: API key, base URL, timeouts, retries.
2
+
3
+ Resolution order for every field:
4
+ 1. Explicit constructor argument (wins)
5
+ 2. Corresponding environment variable
6
+ 3. Built-in default
7
+
8
+ Centralized here so both Client and AsyncClient share one canonical
9
+ factory and any future config key only needs to be added in one place.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from .errors import EolasWorkError
19
+
20
+ # Default base URL points at the production EolasWork host. Self-hosted
21
+ # / on-prem (e.g. HCL) deployments override via EOLASWORK_BASE_URL or
22
+ # the explicit `base_url=` arg.
23
+ DEFAULT_BASE_URL = "https://nexa.aihq.ie"
24
+ DEFAULT_TIMEOUT = 60.0
25
+ DEFAULT_MAX_RETRIES = 3
26
+
27
+
28
+ @dataclass
29
+ class ClientConfig:
30
+ """Resolved config for one Client / AsyncClient instance.
31
+
32
+ Constructed eagerly so a missing API key fails fast at construction
33
+ time, not on the first network call. The dataclass field defaults
34
+ are placeholders; the __init__ override below performs real
35
+ env-var resolution.
36
+ """
37
+
38
+ api_key: str = field(default="")
39
+ base_url: str = field(default="")
40
+ timeout: float = DEFAULT_TIMEOUT
41
+ max_retries: int = DEFAULT_MAX_RETRIES
42
+ # httpx.BaseTransport for tests. Untyped on purpose so we don't pull
43
+ # httpx into this module's import surface; transport modules import
44
+ # it themselves where needed.
45
+ transport: Any = None
46
+ user_agent: str = field(default="")
47
+
48
+ def __init__(
49
+ self,
50
+ api_key: str | None = None,
51
+ base_url: str | None = None,
52
+ timeout: float | None = None,
53
+ max_retries: int | None = None,
54
+ transport: Any = None,
55
+ user_agent: str | None = None,
56
+ ):
57
+ self.api_key = api_key or os.environ.get("EOLASWORK_API_KEY", "")
58
+ if not self.api_key:
59
+ raise EolasWorkError(
60
+ "api_key required - pass api_key= or set EOLASWORK_API_KEY"
61
+ )
62
+ # Strip any trailing slash so resource modules can confidently
63
+ # concatenate paths starting with `/api/...`.
64
+ self.base_url = (
65
+ base_url or os.environ.get("EOLASWORK_BASE_URL", DEFAULT_BASE_URL)
66
+ ).rstrip("/")
67
+ self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
68
+ self.max_retries = max_retries if max_retries is not None else DEFAULT_MAX_RETRIES
69
+ self.transport = transport
70
+ # Defer the __version__ import to construction time so a circular
71
+ # import (eolaswork.__init__ -> ClientConfig -> __version__)
72
+ # can't fire at module load.
73
+ from . import __version__
74
+ self.user_agent = user_agent or f"eolaswork-python/{__version__}"
@@ -0,0 +1,65 @@
1
+ """SSE iterators for the EolasWork run event stream.
2
+
3
+ The backend emits standard text/event-stream lines:
4
+ event: <kind>
5
+ data: <json>
6
+ (blank line)
7
+ id: <optional event id>
8
+
9
+ We yield RunEvent dataclasses with `kind` (the event name), `action_id`
10
+ extracted from the JSON payload when present, and `payload` carrying
11
+ the parsed JSON body of the event.
12
+
13
+ httpx-sse provides the connect_sse / aconnect_sse context managers
14
+ that wrap an httpx Client + request + EventSource setup; we wire
15
+ those to our transports' underlying clients.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import AsyncIterator, Iterator
22
+
23
+ from httpx_sse import aconnect_sse, connect_sse
24
+
25
+ from ._atransport import AsyncTransport
26
+ from ._transport import SyncTransport
27
+ from .types import RunEvent
28
+
29
+
30
+ def iter_run_events(transport: SyncTransport, run_id: str) -> Iterator[RunEvent]:
31
+ # connect_sse owns the underlying httpx.Client streaming response;
32
+ # using it as a context manager guarantees the socket is closed
33
+ # when the caller stops iterating (or an exception fires).
34
+ with connect_sse(
35
+ transport._client, "GET", f"/api/runs/{run_id}/stream"
36
+ ) as event_source:
37
+ for sse in event_source.iter_sse():
38
+ yield _to_event(sse.event, sse.data)
39
+
40
+
41
+ async def aiter_run_events(
42
+ transport: AsyncTransport, run_id: str
43
+ ) -> AsyncIterator[RunEvent]:
44
+ async with aconnect_sse(
45
+ transport._client, "GET", f"/api/runs/{run_id}/stream"
46
+ ) as event_source:
47
+ async for sse in event_source.aiter_sse():
48
+ yield _to_event(sse.event, sse.data)
49
+
50
+
51
+ def _to_event(event_name: str | None, raw_data: str) -> RunEvent:
52
+ try:
53
+ payload = json.loads(raw_data) if raw_data else {}
54
+ except json.JSONDecodeError:
55
+ # Non-JSON data event - rare, surface verbatim under `raw` so
56
+ # nothing is silently dropped.
57
+ payload = {"raw": raw_data}
58
+ return RunEvent(
59
+ kind=event_name or "message",
60
+ # SSE events frequently carry the relevant action id in
61
+ # payload.actionId; lift it onto the dataclass as a first-class
62
+ # field so consumers don't have to keep digging into payload.
63
+ action_id=payload.get("actionId") if isinstance(payload, dict) else None,
64
+ payload=payload if isinstance(payload, dict) else {"raw": payload},
65
+ )
@@ -0,0 +1,175 @@
1
+ """Synchronous HTTP transport for the eolaswork SDK.
2
+
3
+ Wraps httpx.Client to apply auth, JSON encoding, error mapping, and
4
+ retries. Resources never see httpx.Response directly - they call
5
+ `transport.request()` and get either parsed JSON or None (for 204s)
6
+ back, with exceptions raised for any non-2xx status.
7
+
8
+ Streaming: `transport.stream()` returns a raw httpx.Response with
9
+ `stream=True` for SSE/binary use. The streaming module + files
10
+ resource own it from there.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import random
16
+ import time
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ from ._config import ClientConfig
22
+ from .errors import (
23
+ APIConnectionError,
24
+ APITimeoutError,
25
+ EolasWorkError,
26
+ error_for_status,
27
+ )
28
+
29
+ # Status codes worth retrying when the request is safe to repeat. 429
30
+ # and the transient 5xx classes only. Connection errors are retried
31
+ # unconditionally (when retry-eligible).
32
+ RETRY_STATUSES = {429, 502, 503, 504}
33
+
34
+ # HTTP verbs we treat as retry-safe by default. POST is included ONLY
35
+ # when the caller passed an Idempotency-Key (resources do this for
36
+ # runs.create so a network blip doesn't spawn a duplicate run).
37
+ SAFE_VERBS = {"GET", "DELETE", "PUT", "HEAD"}
38
+
39
+
40
+ class SyncTransport:
41
+ """Thin sync wrapper around httpx.Client.
42
+
43
+ Designed to be one-per-Client so the auth header and base URL are
44
+ bound once on construction. Close via .close() or use as a context
45
+ manager.
46
+ """
47
+
48
+ def __init__(self, config: ClientConfig):
49
+ self._config = config
50
+ self._client = httpx.Client(
51
+ base_url=config.base_url,
52
+ timeout=config.timeout,
53
+ transport=config.transport,
54
+ headers={
55
+ "user-agent": config.user_agent,
56
+ "authorization": f"Bearer {config.api_key}",
57
+ },
58
+ )
59
+
60
+ def close(self) -> None:
61
+ self._client.close()
62
+
63
+ def __enter__(self):
64
+ return self
65
+
66
+ def __exit__(self, *_):
67
+ self.close()
68
+
69
+ def request(
70
+ self,
71
+ method: str,
72
+ path: str,
73
+ *,
74
+ params: dict[str, Any] | None = None,
75
+ json: Any = None,
76
+ files: Any = None,
77
+ data: dict[str, Any] | None = None,
78
+ idempotency_key: str | None = None,
79
+ extra_headers: dict[str, str] | None = None,
80
+ ) -> Any:
81
+ """Make a request and return parsed JSON / None / raise on error."""
82
+ headers = dict(extra_headers or {})
83
+ if idempotency_key:
84
+ headers["idempotency-key"] = idempotency_key
85
+
86
+ retry_eligible = method.upper() in SAFE_VERBS or idempotency_key is not None
87
+ attempts = max(self._config.max_retries + 1, 1) if retry_eligible else 1
88
+
89
+ for attempt in range(attempts):
90
+ try:
91
+ response = self._client.request(
92
+ method, path, params=params, json=json, files=files,
93
+ data=data, headers=headers,
94
+ )
95
+ except httpx.TimeoutException as exc:
96
+ if attempt + 1 < attempts:
97
+ self._sleep_backoff(attempt, None)
98
+ continue
99
+ raise APITimeoutError(str(exc)) from exc
100
+ except httpx.HTTPError as exc:
101
+ if attempt + 1 < attempts:
102
+ self._sleep_backoff(attempt, None)
103
+ continue
104
+ raise APIConnectionError(str(exc)) from exc
105
+
106
+ if response.status_code in RETRY_STATUSES and attempt + 1 < attempts:
107
+ self._sleep_backoff(attempt, _retry_after(response))
108
+ continue
109
+
110
+ return self._handle(response)
111
+
112
+ # Unreachable - the loop returns or raises every iteration. The
113
+ # raise here exists for type-checker satisfaction.
114
+ raise EolasWorkError("retry loop exited without response")
115
+
116
+ def stream(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
117
+ """Open a streaming response without consuming the body.
118
+
119
+ Caller is responsible for closing it (or using a context manager).
120
+ Used by the SSE iterator and the files resource's download path.
121
+ Streaming is single-shot (no retry) - SSE has its own reconnect
122
+ logic in _streaming.py.
123
+ """
124
+ req = self._client.build_request(method, path, **kwargs)
125
+ return self._client.send(req, stream=True)
126
+
127
+ def _handle(self, response: httpx.Response) -> Any:
128
+ request_id = response.headers.get("x-request-id")
129
+ if response.status_code == 204 or not response.content:
130
+ if 200 <= response.status_code < 300:
131
+ return None
132
+ raise error_for_status(response.status_code, request_id=request_id, body=None)
133
+
134
+ try:
135
+ body = response.json()
136
+ except Exception:
137
+ # Non-JSON body (e.g. binary download accidentally routed
138
+ # through .request()) - hand back the text so error_for_status
139
+ # at least has something.
140
+ body = response.text
141
+
142
+ if 200 <= response.status_code < 300:
143
+ return body
144
+ raise error_for_status(
145
+ response.status_code,
146
+ request_id=request_id,
147
+ body=body,
148
+ retry_after=_retry_after(response),
149
+ )
150
+
151
+ @staticmethod
152
+ def _sleep_backoff(attempt: int, retry_after_sec: float | None) -> None:
153
+ if retry_after_sec is not None:
154
+ time.sleep(retry_after_sec)
155
+ return
156
+ # Exponential with jitter: 0.5s, 1s, 2s, ... capped at 8s.
157
+ # Jitter avoids thundering-herd on coordinated retry from many
158
+ # clients after a service blip.
159
+ base = min(0.5 * (2 ** attempt), 8.0)
160
+ time.sleep(base + random.uniform(0, base / 2))
161
+
162
+
163
+ def _retry_after(response: httpx.Response) -> float | None:
164
+ """Parse the Retry-After header value as seconds.
165
+
166
+ Accepts the integer-seconds form only; HTTP-date is rare in
167
+ practice and supporting it just adds parsing surface area.
168
+ """
169
+ raw = response.headers.get("retry-after")
170
+ if not raw:
171
+ return None
172
+ try:
173
+ return float(raw)
174
+ except ValueError:
175
+ return None
@@ -0,0 +1,75 @@
1
+ """Async mirror of Client.
2
+
3
+ Same surface as the sync Client but every method is awaitable. Use
4
+ inside FastAPI / aiohttp / any asyncio agent framework.
5
+
6
+ Example:
7
+ import asyncio
8
+ from eolaswork import AsyncClient
9
+
10
+ async def main():
11
+ async with AsyncClient(api_key="nxa_...") as client:
12
+ task = await client.tasks.create(title="async run")
13
+ run = await client.runs.create(task_id=task.id, prompt="hi")
14
+ async for event in client.runs.astream(run.id):
15
+ print(event)
16
+
17
+ asyncio.run(main())
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from ._atransport import AsyncTransport
25
+ from ._config import ClientConfig
26
+ from .resources.account import AsyncAccount
27
+ from .resources.api_keys import AsyncApiKeys
28
+ from .resources.files import AsyncFiles
29
+ from .resources.followups import AsyncFollowups
30
+ from .resources.memory import AsyncMemory
31
+ from .resources.models import AsyncModels
32
+ from .resources.roles import AsyncRoles
33
+ from .resources.runs import AsyncRuns
34
+ from .resources.skills import AsyncSkills
35
+ from .resources.tasks import AsyncTasks
36
+ from .resources.teams import AsyncTeams
37
+
38
+
39
+ class AsyncClient:
40
+ def __init__(
41
+ self,
42
+ api_key: str | None = None,
43
+ base_url: str | None = None,
44
+ timeout: float | None = None,
45
+ max_retries: int | None = None,
46
+ transport: Any = None,
47
+ ):
48
+ self._config = ClientConfig(
49
+ api_key=api_key,
50
+ base_url=base_url,
51
+ timeout=timeout,
52
+ max_retries=max_retries,
53
+ transport=transport,
54
+ )
55
+ self._transport = AsyncTransport(self._config)
56
+ self.account = AsyncAccount(self._transport)
57
+ self.api_keys = AsyncApiKeys(self._transport)
58
+ self.tasks = AsyncTasks(self._transport)
59
+ self.runs = AsyncRuns(self._transport)
60
+ self.files = AsyncFiles(self._transport)
61
+ self.roles = AsyncRoles(self._transport)
62
+ self.teams = AsyncTeams(self._transport)
63
+ self.skills = AsyncSkills(self._transport)
64
+ self.models = AsyncModels(self._transport)
65
+ self.followups = AsyncFollowups(self._transport)
66
+ self.memory = AsyncMemory(self._transport)
67
+
68
+ async def aclose(self) -> None:
69
+ await self._transport.aclose()
70
+
71
+ async def __aenter__(self):
72
+ return self
73
+
74
+ async def __aexit__(self, *_):
75
+ await self.aclose()
eolaswork/client.py ADDED
@@ -0,0 +1,77 @@
1
+ """Synchronous Client facade.
2
+
3
+ Constructed once per process / call site. Holds the resolved
4
+ ClientConfig, the SyncTransport, and one resource instance per
5
+ top-level API path family. Close via .close() or use as a context
6
+ manager.
7
+
8
+ Example:
9
+ from eolaswork import Client
10
+
11
+ client = Client(api_key="nxa_...")
12
+ me = client.account.whoami()
13
+ task = client.tasks.create(title="Q2 board prep")
14
+ run = client.runs.create(task_id=task.id, prompt="...", role="analyst")
15
+ final = client.runs.wait(run.id)
16
+ print(final.status, final.output)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+ from ._config import ClientConfig
24
+ from ._transport import SyncTransport
25
+ from .resources.account import Account
26
+ from .resources.api_keys import ApiKeys
27
+ from .resources.files import Files
28
+ from .resources.followups import Followups
29
+ from .resources.memory import Memory
30
+ from .resources.models import Models
31
+ from .resources.roles import Roles
32
+ from .resources.runs import Runs
33
+ from .resources.skills import Skills
34
+ from .resources.tasks import Tasks
35
+ from .resources.teams import Teams
36
+
37
+
38
+ class Client:
39
+ def __init__(
40
+ self,
41
+ api_key: str | None = None,
42
+ base_url: str | None = None,
43
+ timeout: float | None = None,
44
+ max_retries: int | None = None,
45
+ transport: Any = None,
46
+ ):
47
+ self._config = ClientConfig(
48
+ api_key=api_key,
49
+ base_url=base_url,
50
+ timeout=timeout,
51
+ max_retries=max_retries,
52
+ transport=transport,
53
+ )
54
+ self._transport = SyncTransport(self._config)
55
+ # All 11 resources hang off the client. Constructing them is
56
+ # cheap (one assignment); doing so up front means the dot-path
57
+ # surface is fully discoverable in any IDE / REPL.
58
+ self.account = Account(self._transport)
59
+ self.api_keys = ApiKeys(self._transport)
60
+ self.tasks = Tasks(self._transport)
61
+ self.runs = Runs(self._transport)
62
+ self.files = Files(self._transport)
63
+ self.roles = Roles(self._transport)
64
+ self.teams = Teams(self._transport)
65
+ self.skills = Skills(self._transport)
66
+ self.models = Models(self._transport)
67
+ self.followups = Followups(self._transport)
68
+ self.memory = Memory(self._transport)
69
+
70
+ def close(self) -> None:
71
+ self._transport.close()
72
+
73
+ def __enter__(self):
74
+ return self
75
+
76
+ def __exit__(self, *_):
77
+ self.close()