mishmar 0.2.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.
mishmar/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """The Mishmar client: sign and submit proposals, read decisions, answer the signer channel."""
2
+
3
+ from .agentkey import AgentKey, request_signing_bytes, verify, verify_request
4
+ from .canon import canonical_json
5
+ from .client import DEFAULT_RETRIES, Client, EngineError, Retries
6
+ from .proposal import Proposal, VenueMessage, is_decimal, new_nonce, rfc3339
7
+ from .signer import Signer, download_binary, find_binary
8
+
9
+ __all__ = [
10
+ "DEFAULT_RETRIES",
11
+ "AgentKey",
12
+ "Client",
13
+ "EngineError",
14
+ "Proposal",
15
+ "Retries",
16
+ "Signer",
17
+ "VenueMessage",
18
+ "canonical_json",
19
+ "download_binary",
20
+ "find_binary",
21
+ "is_decimal",
22
+ "new_nonce",
23
+ "request_signing_bytes",
24
+ "rfc3339",
25
+ "verify",
26
+ "verify_request",
27
+ ]
mishmar/_time.py ADDED
@@ -0,0 +1,31 @@
1
+ """Times as the contract writes them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+
9
+ def rfc3339(dt: datetime) -> str:
10
+ """Format a time as the contract wants it: UTC, Z suffix, fraction trimmed."""
11
+ if dt.tzinfo is None:
12
+ raise ValueError("times must be timezone-aware")
13
+ dt = dt.astimezone(UTC)
14
+ text = dt.strftime("%Y-%m-%dT%H:%M:%S")
15
+ if dt.microsecond:
16
+ text += "." + f"{dt.microsecond:06d}".rstrip("0")
17
+ return text + "Z"
18
+
19
+
20
+ def parse_time(value: Any) -> datetime:
21
+ """A timezone-aware datetime from a datetime or an RFC 3339 string."""
22
+ if isinstance(value, datetime):
23
+ if value.tzinfo is None:
24
+ raise ValueError("times must be timezone-aware")
25
+ return value
26
+ if isinstance(value, str):
27
+ dt = datetime.fromisoformat(value)
28
+ if dt.tzinfo is None:
29
+ raise ValueError(f"time {value!r} has no offset")
30
+ return dt
31
+ raise TypeError(f"not a time: {value!r}")
mishmar/agentkey.py ADDED
@@ -0,0 +1,188 @@
1
+ """The agent's identity: an Ed25519 key whose public half the engine stores.
2
+
3
+ The engine shows the secret once, as ``msk_<prefix>_<seed>``: a label, then
4
+ the 32-byte seed in base64url without padding. The key id (``key_…``) is
5
+ separate and public.
6
+
7
+ The key signs two things: proposals (see proposal.py) and, under the
8
+ contract's agentAuth scheme, every other agent-facing request: four headers
9
+ carry the key id, the time, a nonce and an Ed25519 signature over the canonical
10
+ JSON of ``{bodySha256, keyId, method, nonce, path, query, signedAt}``. The bytes
11
+ are pinned in spec/vectors/agent-request-signing.json.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import secrets
19
+ from datetime import UTC, datetime
20
+
21
+ from cryptography.exceptions import InvalidSignature
22
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
23
+ Ed25519PrivateKey,
24
+ Ed25519PublicKey,
25
+ )
26
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
27
+
28
+ from ._time import rfc3339
29
+ from .canon import canonical_json
30
+
31
+ SEED_SIZE = 32
32
+
33
+ HEADER_KEY_ID = "X-Mishmar-Key-Id"
34
+ HEADER_SIGNED_AT = "X-Mishmar-Signed-At"
35
+ HEADER_NONCE = "X-Mishmar-Nonce"
36
+ HEADER_SIGNATURE = "X-Mishmar-Signature"
37
+
38
+
39
+ def _b64url_decode(s: str) -> bytes:
40
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
41
+
42
+
43
+ def new_nonce() -> str:
44
+ """A fresh nonce: 16 random bytes, hex."""
45
+ return secrets.token_hex(16)
46
+
47
+
48
+ def request_signing_bytes(
49
+ *,
50
+ key_id: str,
51
+ method: str,
52
+ path: str,
53
+ query: str = "",
54
+ body: bytes = b"",
55
+ nonce: str,
56
+ signed_at: str,
57
+ ) -> bytes:
58
+ """What the agent signs for a request. ``path`` is without the server
59
+ prefix (``/agent/decisions``), ``query`` the raw query string without the
60
+ ``?``, ``signed_at`` exactly as the header carries it."""
61
+ fields: dict[str, str] = {
62
+ "keyId": key_id,
63
+ "method": method.upper(),
64
+ "nonce": nonce,
65
+ "path": path,
66
+ "signedAt": signed_at,
67
+ }
68
+ if query:
69
+ fields["query"] = query
70
+ if body:
71
+ fields["bodySha256"] = hashlib.sha256(body).hexdigest()
72
+ return canonical_json(fields)
73
+
74
+
75
+ class AgentKey:
76
+ """The agent's private key."""
77
+
78
+ def __init__(self, private: Ed25519PrivateKey):
79
+ self._private = private
80
+
81
+ @classmethod
82
+ def parse(cls, secret: str) -> AgentKey:
83
+ """Read the secret the engine showed when the key was issued."""
84
+ # msk_<prefix>_<seed>: the seed is base64url and may itself contain
85
+ # '_', so split on the first two underscores only.
86
+ parts = secret.strip().split("_", 2)
87
+ if len(parts) != 3 or parts[0] != "msk":
88
+ raise ValueError("malformed agent secret: expected msk_<prefix>_<seed>")
89
+ try:
90
+ seed = _b64url_decode(parts[2])
91
+ except ValueError as err:
92
+ raise ValueError(
93
+ "malformed agent secret: the seed is not base64url"
94
+ ) from err
95
+ if len(seed) != SEED_SIZE:
96
+ raise ValueError("malformed agent secret: the seed is not 32 bytes")
97
+ return cls.from_seed(seed)
98
+
99
+ @classmethod
100
+ def from_seed(cls, seed: bytes) -> AgentKey:
101
+ return cls(Ed25519PrivateKey.from_private_bytes(seed))
102
+
103
+ def sign(self, message: bytes) -> str:
104
+ """The base64 (standard, padded) Ed25519 signature of message."""
105
+ return base64.b64encode(self._private.sign(message)).decode("ascii")
106
+
107
+ def sign_request(
108
+ self,
109
+ key_id: str,
110
+ method: str,
111
+ path: str,
112
+ *,
113
+ query: str = "",
114
+ body: bytes = b"",
115
+ nonce: str | None = None,
116
+ signed_at: datetime | None = None,
117
+ ) -> dict[str, str]:
118
+ """The four agentAuth headers for a request; a nonce and the time are
119
+ made here when not given."""
120
+ nonce = nonce or new_nonce()
121
+ signed_at_text = rfc3339(signed_at or datetime.now(UTC))
122
+ message = request_signing_bytes(
123
+ key_id=key_id,
124
+ method=method,
125
+ path=path,
126
+ query=query,
127
+ body=body,
128
+ nonce=nonce,
129
+ signed_at=signed_at_text,
130
+ )
131
+ return {
132
+ HEADER_KEY_ID: key_id,
133
+ HEADER_SIGNED_AT: signed_at_text,
134
+ HEADER_NONCE: nonce,
135
+ HEADER_SIGNATURE: self.sign(message),
136
+ }
137
+
138
+ @property
139
+ def public_key(self) -> bytes:
140
+ return self._private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
141
+
142
+ @property
143
+ def public_key_b64(self) -> str:
144
+ """The public half as the API stores and returns it."""
145
+ return base64.b64encode(self.public_key).decode("ascii")
146
+
147
+
148
+ def secret(prefix: str, seed: bytes) -> str:
149
+ """Render a seed the way the engine shows it."""
150
+ return prefix + "_" + base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
151
+
152
+
153
+ def verify(public_key_b64: str, message: bytes, signature_b64: str) -> bool:
154
+ """Check a base64 signature over message with a public key in the API's form."""
155
+ try:
156
+ public = Ed25519PublicKey.from_public_bytes(
157
+ base64.b64decode(public_key_b64, validate=True)
158
+ )
159
+ public.verify(base64.b64decode(signature_b64, validate=True), message)
160
+ return True
161
+ except (ValueError, InvalidSignature):
162
+ return False
163
+
164
+
165
+ def verify_request(
166
+ public_key_b64: str,
167
+ *,
168
+ key_id: str,
169
+ method: str,
170
+ path: str,
171
+ query: str,
172
+ body: bytes,
173
+ nonce: str,
174
+ signed_at: str,
175
+ signature_b64: str,
176
+ ) -> bool:
177
+ """The engine's side of agentAuth: check the headers against the request.
178
+ Freshness and nonce replay are the caller's checks."""
179
+ message = request_signing_bytes(
180
+ key_id=key_id,
181
+ method=method,
182
+ path=path,
183
+ query=query,
184
+ body=body,
185
+ nonce=nonce,
186
+ signed_at=signed_at,
187
+ )
188
+ return verify(public_key_b64, message, signature_b64)
mishmar/canon.py ADDED
@@ -0,0 +1,46 @@
1
+ """Canonical JSON: the bytes an agent signs and the engine verifies.
2
+
3
+ The rules match Go's encoding/json, which the engine uses (see canon/ in the
4
+ repo root and spec/vectors): one object, keys sorted, no whitespace, keys
5
+ whose value is None left out at every depth, strings with Go's escapes.
6
+ Python's json module agrees on everything but the HTML-safe escapes, which
7
+ are added here. Numbers never appear in what Mishmar signs, so they are
8
+ refused rather than formatted differently from Go.
9
+ """
10
+
11
+ import json
12
+ from typing import Any
13
+
14
+ # What Go escapes beyond the JSON minimum: HTML-significant characters and
15
+ # the two line separators. Python with ensure_ascii=False writes them raw.
16
+ _GO_ESCAPES = {
17
+ "<": "\\u003c",
18
+ ">": "\\u003e",
19
+ "&": "\\u0026",
20
+ "
": "\\u2028",
21
+ "
": "\\u2029",
22
+ }
23
+
24
+
25
+ def _strip(value: Any) -> Any:
26
+ if isinstance(value, bool) or value is None or isinstance(value, str):
27
+ return value
28
+ if isinstance(value, dict):
29
+ return {k: _strip(v) for k, v in value.items() if v is not None}
30
+ if isinstance(value, (list, tuple)):
31
+ return [_strip(v) for v in value]
32
+ if isinstance(value, (int, float)):
33
+ raise TypeError(
34
+ f"canonical_json: numbers are not signed by Mishmar; send {value!r} as a decimal string"
35
+ )
36
+ raise TypeError(f"canonical_json: cannot encode {type(value).__name__}")
37
+
38
+
39
+ def canonical_json(value: Any) -> bytes:
40
+ """Return the canonical UTF-8 encoding of value."""
41
+ text = json.dumps(
42
+ _strip(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False
43
+ )
44
+ for raw, escaped in _GO_ESCAPES.items():
45
+ text = text.replace(raw, escaped)
46
+ return text.encode("utf-8")
mishmar/client.py ADDED
@@ -0,0 +1,328 @@
1
+ """A small HTTP client for the engine, on the standard library only.
2
+
3
+ Proposals go to POST /proposals with the signature in the body. Everything
4
+ else an agent may call (its own decisions, approvals, positions, PnL, trading accounts,
5
+ and the signer channel) is under /agent and signed with the agentAuth
6
+ headers, so a bot can function without an operator token and cannot act on
7
+ the account.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import random
14
+ import time
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from email.utils import parsedate_to_datetime
21
+ from typing import Any
22
+
23
+ from .agentkey import AgentKey
24
+ from .proposal import Proposal
25
+
26
+ RESOLVED_APPROVAL = ("approved", "rejected", "expired")
27
+
28
+ #: The answers that mean the request did nothing and may go again: 503 while
29
+ #: the engine restarts, 429 when this caller sent too many.
30
+ RETRYABLE = (503, 429)
31
+
32
+
33
+ class EngineError(Exception):
34
+ """The engine answered with an error: ``status`` (HTTP), ``code`` and ``message`` from its body."""
35
+
36
+ def __init__(self, status: int, code: str, message: str):
37
+ super().__init__(f"{status} {code}: {message}")
38
+ self.status, self.code, self.message = status, code, message
39
+ #: The ``Retry-After`` header of a 429, when the engine sent one.
40
+ self.retry_after: str | None = None
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Retries:
45
+ """How a call the engine did nothing with goes again: 503 ``unavailable``
46
+ while the engine restarts (its drain is 28 s) and 429 ``rate_limited``.
47
+ The same request is sent again after the seconds in ``Retry-After`` when
48
+ the engine names them, otherwise after a wait that doubles from
49
+ ``min_wait`` to ``max_wait`` with jitter, until ``budget`` seconds have
50
+ passed since the first attempt; then the last answer is raised. Nothing
51
+ else is retried: a 413 ``too_large`` would come back the same, and any
52
+ other answer means the request was taken. The defaults cover a restart.
53
+ """
54
+
55
+ budget: float = 45.0
56
+ min_wait: float = 0.25
57
+ max_wait: float = 8.0
58
+
59
+ def wait(
60
+ self, attempt: int, status: int, retry_after: str | None, elapsed: float
61
+ ) -> float | None:
62
+ """Seconds to wait before attempt ``attempt + 1`` (from 0), or ``None``
63
+ to stop and raise the answer."""
64
+ if status not in RETRYABLE:
65
+ return None
66
+ wait = _parse_retry_after(retry_after)
67
+ if wait is None:
68
+ step = min(self.min_wait * (2 ** min(attempt, 20)), self.max_wait)
69
+ wait = step / 2 + random.uniform(0, step / 2)
70
+ if elapsed + wait > self.budget:
71
+ return None
72
+ return wait
73
+
74
+
75
+ def _parse_retry_after(value: str | None) -> float | None:
76
+ if not value:
77
+ return None
78
+ try:
79
+ secs = int(value)
80
+ return float(secs) if secs >= 0 else None
81
+ except ValueError:
82
+ pass
83
+ try:
84
+ when = parsedate_to_datetime(value)
85
+ except (TypeError, ValueError):
86
+ return None
87
+ return max(when.timestamp() - time.time(), 0.0)
88
+
89
+
90
+ #: The default policy; see :class:`Retries`.
91
+ DEFAULT_RETRIES = Retries()
92
+
93
+
94
+ class Client:
95
+ """Talks to one engine as one agent. The trading key's share lives in the
96
+ agent's signer, never here: this client proposes, reads, and relays the
97
+ signer channel.
98
+
99
+ ``retries`` is how a call the engine did nothing with goes again
100
+ (:class:`Retries`, on by default); ``None`` turns retries off."""
101
+
102
+ def __init__(
103
+ self,
104
+ engine_url: str,
105
+ agent_key: AgentKey,
106
+ key_id: str,
107
+ *,
108
+ timeout: float = 10.0,
109
+ retries: Retries | None = DEFAULT_RETRIES,
110
+ ):
111
+ self.engine_url = engine_url.rstrip("/")
112
+ self.agent_key = agent_key
113
+ self.key_id = key_id
114
+ self.timeout = timeout
115
+ self.retries = retries
116
+ self._sleep: Callable[[float], None] = time.sleep
117
+
118
+ # Proposals
119
+
120
+ def propose(self, proposal: Proposal) -> dict[str, Any]:
121
+ """Sign the proposal with the agent's key (unless already signed) and
122
+ submit it. Returns the decision as the engine recorded it."""
123
+ if not proposal.key_id:
124
+ proposal.key_id = self.key_id
125
+ if proposal.signature is None:
126
+ proposal.sign(self.agent_key)
127
+ return self._call("POST", "/proposals", body=proposal.to_wire())
128
+
129
+ # Reads, scoped to this agent
130
+
131
+ def decisions(
132
+ self,
133
+ *,
134
+ trading_account_id: str | None = None,
135
+ verdict: str | None = None,
136
+ since: str | None = None,
137
+ until: str | None = None,
138
+ cursor: str | None = None,
139
+ limit: int | None = None,
140
+ ) -> dict[str, Any]:
141
+ """A page of this agent's decisions, newest first."""
142
+ params = {
143
+ "tradingAccountId": trading_account_id,
144
+ "verdict": verdict,
145
+ "from": since,
146
+ "to": until,
147
+ "cursor": cursor,
148
+ "limit": limit,
149
+ }
150
+ return self._agent_call("GET", "/agent/decisions", query=params)
151
+
152
+ def decision(self, decision_id: str) -> dict[str, Any]:
153
+ return self._agent_call(
154
+ "GET", f"/agent/decisions/{urllib.parse.quote(decision_id, safe='')}"
155
+ )
156
+
157
+ def approvals(self, status: str | None = None) -> list[dict[str, Any]]:
158
+ return self._agent_call("GET", "/agent/approvals", query={"status": status})
159
+
160
+ def positions(self) -> list[dict[str, Any]]:
161
+ return self._agent_call("GET", "/agent/positions")
162
+
163
+ def pnl(self, start: str | None = None, end: str | None = None) -> dict[str, Any]:
164
+ """Daily PnL of this agent; ``start`` and ``end`` are dates (YYYY-MM-DD)."""
165
+ return self._agent_call("GET", "/agent/pnl", query={"from": start, "to": end})
166
+
167
+ def trading_account(self, trading_account_id: str) -> dict[str, Any]:
168
+ """A trading account as this agent may see it, its setup without any secret."""
169
+ return self._agent_call(
170
+ "GET",
171
+ f"/agent/trading-accounts/{urllib.parse.quote(trading_account_id, safe='')}",
172
+ )
173
+
174
+ def venue_rules(
175
+ self, trading_account_id: str, symbol: str | None = None
176
+ ) -> dict[str, Any]:
177
+ """What the trading account's venue accepts per symbol (tick, step,
178
+ minimums), as the engine last read it; ``symbol`` narrows it to one."""
179
+ return self._agent_call(
180
+ "GET",
181
+ f"/agent/trading-accounts/{urllib.parse.quote(trading_account_id, safe='')}/venue-rules",
182
+ query={"symbol": symbol},
183
+ )
184
+
185
+ def wait(
186
+ self, decision_id: str, timeout: float = 60.0, interval: float = 1.0
187
+ ) -> dict[str, Any]:
188
+ """Poll a decision until it is no longer waiting for a person, or
189
+ until timeout; returns the last decision read either way."""
190
+ deadline = time.monotonic() + timeout
191
+ while True:
192
+ d = self.decision(decision_id)
193
+ if resolved(d) or time.monotonic() >= deadline:
194
+ return d
195
+ time.sleep(min(interval, max(0.0, deadline - time.monotonic())))
196
+
197
+ # The signer channel
198
+
199
+ def signer_poll(self, wait_seconds: int = 25) -> list[dict[str, Any]]:
200
+ """Wait up to wait_seconds for requests this agent's signer must sign."""
201
+ return self._agent_call(
202
+ "POST",
203
+ "/agent/signer/poll",
204
+ body={"waitSeconds": wait_seconds},
205
+ timeout=self.timeout + wait_seconds,
206
+ )
207
+
208
+ def signer_commitments(
209
+ self, venue_key_id: str, count: int | None = None
210
+ ) -> dict[str, Any]:
211
+ """Take up to ``count`` of the engine's single-use FROST commitments for
212
+ one-shot signing with a venue key (CommitmentBatch); by default as many
213
+ as the account allows."""
214
+ body: dict[str, Any] = {"venueKeyId": venue_key_id}
215
+ if count is not None:
216
+ body["count"] = count
217
+ return self._agent_call("POST", "/agent/signer/commitments", body=body)
218
+
219
+ def signer_answer(self, request_id: str, answer: dict[str, Any]) -> None:
220
+ """Answer a sign request: a share, a signature, a venue response or a refusal (SignerAnswer)."""
221
+ self._agent_call(
222
+ "POST",
223
+ f"/agent/signer/requests/{urllib.parse.quote(request_id, safe='')}/answer",
224
+ body=answer,
225
+ )
226
+
227
+ # Plumbing
228
+
229
+ def _agent_call(
230
+ self,
231
+ method: str,
232
+ path: str,
233
+ *,
234
+ query: dict[str, Any] | None = None,
235
+ body: Any = None,
236
+ timeout: float | None = None,
237
+ ) -> Any:
238
+ params = {k: v for k, v in (query or {}).items() if v is not None}
239
+ query_text = urllib.parse.urlencode(params)
240
+ data = b"" if body is None else json.dumps(body).encode("utf-8")
241
+
242
+ # Each attempt is signed anew, with its own nonce and time, so a
243
+ # retry is never a replay.
244
+ def sign() -> dict[str, str]:
245
+ return self.agent_key.sign_request(
246
+ self.key_id, method, path, query=query_text, body=data
247
+ )
248
+
249
+ return self._call(
250
+ method,
251
+ path + ("?" + query_text if query_text else ""),
252
+ body=body,
253
+ sign=sign,
254
+ timeout=timeout,
255
+ )
256
+
257
+ def _call(
258
+ self,
259
+ method: str,
260
+ path: str,
261
+ *,
262
+ body: Any = None,
263
+ sign: Callable[[], dict[str, str]] | None = None,
264
+ timeout: float | None = None,
265
+ ) -> Any:
266
+ """Send the request, again after an answer that did nothing (``retries``),
267
+ and return the final answer decoded; ``sign`` makes each attempt's headers."""
268
+ data = None if body is None else json.dumps(body).encode("utf-8")
269
+ start = time.monotonic()
270
+ attempt = 0
271
+ while True:
272
+ try:
273
+ return self._once(method, path, data, sign() if sign else {}, timeout)
274
+ except EngineError as err:
275
+ wait = (
276
+ self.retries.wait(
277
+ attempt, err.status, err.retry_after, time.monotonic() - start
278
+ )
279
+ if self.retries is not None
280
+ else None
281
+ )
282
+ if wait is None:
283
+ raise
284
+ self._sleep(wait)
285
+ attempt += 1
286
+
287
+ def _once(
288
+ self,
289
+ method: str,
290
+ path: str,
291
+ data: bytes | None,
292
+ headers: dict[str, str],
293
+ timeout: float | None,
294
+ ) -> Any:
295
+ req = urllib.request.Request(self.engine_url + path, data=data, method=method)
296
+ req.add_header("Accept", "application/json")
297
+ if data is not None:
298
+ req.add_header("Content-Type", "application/json")
299
+ for k, v in headers.items():
300
+ req.add_header(k, v)
301
+ try:
302
+ with urllib.request.urlopen(req, timeout=timeout or self.timeout) as res:
303
+ raw = res.read()
304
+ except urllib.error.HTTPError as err:
305
+ raw = err.read()
306
+ code, message = "http_error", err.reason or ""
307
+ try:
308
+ parsed = json.loads(raw)
309
+ code = parsed.get("code", code)
310
+ message = parsed.get("message", message)
311
+ except (ValueError, AttributeError):
312
+ if raw:
313
+ message = raw.decode("utf-8", "replace")[:200]
314
+ failure = EngineError(err.code, str(code), str(message))
315
+ failure.retry_after = (
316
+ err.headers.get("Retry-After") if err.headers else None
317
+ )
318
+ raise failure from None
319
+ return json.loads(raw) if raw else None
320
+
321
+
322
+ def resolved(decision: dict[str, Any]) -> bool:
323
+ """Whether the decision no longer waits for a person: anything but
324
+ require_approval, or an approval outcome that has been decided."""
325
+ if decision.get("verdict") != "require_approval":
326
+ return True
327
+ approval = decision.get("approval") or {}
328
+ return approval.get("status") in RESOLVED_APPROVAL