spot-sdk-python 2.0.0b12__tar.gz → 2.0.0b13__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.
Files changed (38) hide show
  1. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/PKG-INFO +12 -14
  2. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/README.md +1 -1
  3. spot_sdk_python-2.0.0b13/pyproject.toml +52 -0
  4. spot_sdk_python-2.0.0b13/pyproject.toml.orig +43 -0
  5. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/knowledge.py +3 -2
  6. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/mime.py +139 -15
  7. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/workflow.py +9 -1
  8. spot_sdk_python-2.0.0b12/pyproject.toml +0 -38
  9. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/__init__.py +0 -0
  10. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/_json.py +0 -0
  11. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/app.py +0 -0
  12. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/artifacts.py +0 -0
  13. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/clients.py +0 -0
  14. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/config.py +0 -0
  15. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/correlation.py +0 -0
  16. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/email.py +0 -0
  17. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/errors.py +0 -0
  18. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/knowledge_tags.py +0 -0
  19. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/llm.py +0 -0
  20. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/logging.py +0 -0
  21. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/manifest.py +0 -0
  22. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/model_input.py +0 -0
  23. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/orchestrator.py +0 -0
  24. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/proxy.py +0 -0
  25. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/py.typed +0 -0
  26. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/retriever.py +0 -0
  27. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/settings.py +0 -0
  28. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/signals.py +0 -0
  29. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/README.md +0 -0
  30. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/__init__.py +0 -0
  31. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/contract.py +0 -0
  32. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/fake_knowledge_client.py +0 -0
  33. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/fake_llm.py +0 -0
  34. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/fake_spot.py +0 -0
  35. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/settings.py +0 -0
  36. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/views.py +0 -0
  37. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/urls.py +0 -0
  38. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.0.0b13}/spot_sdk/verdict.py +0 -0
@@ -1,25 +1,24 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spot-sdk-python
3
- Version: 2.0.0b12
3
+ Version: 2.0.0b13
4
4
  Summary: SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers
5
- License: Apache-2.0
6
5
  Author: SPOT Project
7
- Author-email: spot@sonn.lu
8
- Requires-Python: >=3.11,<3.16
9
- Classifier: License :: OSI Approved :: Apache Software License
6
+ Author-email: SPOT Project <spot@sonn.lu>
7
+ License-Expression: Apache-2.0
10
8
  Classifier: Programming Language :: Python :: 3
11
9
  Classifier: Programming Language :: Python :: 3.11
12
10
  Classifier: Programming Language :: Python :: 3.12
13
11
  Classifier: Programming Language :: Python :: 3.13
14
12
  Classifier: Programming Language :: Python :: 3.14
15
13
  Classifier: Programming Language :: Python :: 3.15
16
- Requires-Dist: fastapi (>=0.133.0,<1)
17
- Requires-Dist: httpx2 (>=2.13.1,<3)
18
- Requires-Dist: idna (>=3.11,<4)
19
- Requires-Dist: pydantic (>=2.10,<3.0)
20
- Requires-Dist: pydantic-settings (>=2.0.0,<3.0.0)
21
- Requires-Dist: selectolax (>=1.0,<1.1)
22
- Requires-Dist: starlette (>=1.7.0,<2)
14
+ Requires-Dist: pydantic>=2.10,<3
15
+ Requires-Dist: pydantic-settings>=2.0.0,<3
16
+ Requires-Dist: httpx2>=2.13.1,<3
17
+ Requires-Dist: selectolax>=1.0,<1.1
18
+ Requires-Dist: idna>=3.11,<4
19
+ Requires-Dist: fastapi>=0.133.0,<1
20
+ Requires-Dist: starlette>=1.7.0,<2
21
+ Requires-Python: >=3.11, <3.16
23
22
  Project-URL: Homepage, https://sonn.lu
24
23
  Description-Content-Type: text/markdown
25
24
 
@@ -51,7 +50,7 @@ plugins share:
51
50
  ## Install
52
51
 
53
52
  ```bash
54
- pip install spot-sdk-python==2.0.0b12
53
+ pip install spot-sdk-python==2.0.0b13
55
54
  ```
56
55
 
57
56
  Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
@@ -116,4 +115,3 @@ token=...)`.
116
115
  ## License
117
116
 
118
117
  Apache License 2.0.
119
-
@@ -26,7 +26,7 @@ plugins share:
26
26
  ## Install
27
27
 
28
28
  ```bash
29
- pip install spot-sdk-python==2.0.0b12
29
+ pip install spot-sdk-python==2.0.0b13
30
30
  ```
31
31
 
32
32
  Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.22,<0.13"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "spot-sdk-python"
7
+ version = "2.0.0b13"
8
+ description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.11,<3.16"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Programming Language :: Python :: 3.15",
19
+ ]
20
+ dependencies = [
21
+ "pydantic>=2.10,<3",
22
+ "pydantic-settings>=2.0.0,<3",
23
+ "httpx2>=2.13.1,<3",
24
+ "selectolax>=1.0,<1.1",
25
+ "idna>=3.11,<4",
26
+ "fastapi>=0.133.0,<1",
27
+ "starlette>=1.7.0,<2",
28
+ ]
29
+
30
+ [[project.authors]]
31
+ name = "SPOT Project"
32
+ email = "spot@sonn.lu"
33
+
34
+ [project.urls]
35
+ Homepage = "https://sonn.lu"
36
+
37
+ [tool.uv.build-backend]
38
+ module-name = "spot_sdk"
39
+ module-root = ""
40
+
41
+ [tool.ruff]
42
+ target-version = "py311"
43
+
44
+ [tool.ruff.lint]
45
+ select = [
46
+ "E",
47
+ "F",
48
+ "I",
49
+ "N",
50
+ "W",
51
+ ]
52
+ ignore = ["E501"]
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.22,<0.13"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "spot-sdk-python"
7
+ version = "2.0.0b13"
8
+ description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ authors = [{ name = "SPOT Project", email = "spot@sonn.lu" }]
12
+ requires-python = ">=3.11,<3.16"
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Programming Language :: Python :: 3.15",
20
+ ]
21
+ dependencies = [
22
+ "pydantic>=2.10,<3",
23
+ "pydantic-settings>=2.0.0,<3",
24
+ "httpx2>=2.13.1,<3",
25
+ "selectolax>=1.0,<1.1",
26
+ "idna>=3.11,<4",
27
+ "fastapi>=0.133.0,<1",
28
+ "starlette>=1.7.0,<2",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://sonn.lu"
33
+
34
+ [tool.uv.build-backend]
35
+ module-name = "spot_sdk"
36
+ module-root = ""
37
+
38
+ [tool.ruff]
39
+ target-version = "py311"
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "N", "W"]
43
+ ignore = ["E501"] # Line too long
@@ -64,8 +64,9 @@ class KnowledgeDocument(BaseModel):
64
64
  id: str = Field(
65
65
  ...,
66
66
  description=(
67
- "Stable id, unique within its source (e.g. 'employee:alice@co.com'). "
68
- "Upsert is idempotent on (source, id)."
67
+ "Stable id, unique within its source, e.g. a key of the source's own or a "
68
+ "keyed hash. It appears in URL paths and logs, so it never holds personal "
69
+ "data such as an email address. Upsert is idempotent on (source, id)."
69
70
  ),
70
71
  )
71
72
  content: str = Field(
@@ -38,7 +38,9 @@ import base64
38
38
  import binascii
39
39
  import codecs
40
40
  import email
41
+ import functools
41
42
  import hashlib
43
+ import ipaddress
42
44
  import json
43
45
  import math
44
46
  import re
@@ -54,8 +56,8 @@ from email.utils import decode_params, getaddresses, parsedate_to_datetime, unqu
54
56
  from html import unescape as unescape_html
55
57
  from itertools import chain
56
58
  from typing import Any, Literal, NamedTuple, TypeAlias, TypeVar, cast
59
+ from urllib.parse import SplitResult
57
60
  from urllib.parse import unquote as unquote_url
58
- from urllib.parse import urlsplit
59
61
  from uuid import UUID
60
62
 
61
63
  import idna
@@ -519,6 +521,38 @@ _MAX_LABEL = 4 * 63
519
521
  _MAX_A_LABEL = 63
520
522
  # The most characters idna maps a call (from 3.17).
521
523
  _IDNA_INPUT = 1024
524
+ # NFC reorders a run of combining marks (of a canonical combining class
525
+ # other than 0) in time quadratic in it. A label with more marks in a
526
+ # row than an A-label holds characters maps to no DNS label: NFC composes at
527
+ # most 3 of them into the character before (U+1F8F), and the 61 or more left
528
+ # are code points an A-label spells with a character each at least, after its
529
+ # "xn--": 65 characters.
530
+ _MAX_MARKS = _MAX_A_LABEL
531
+ _MARK_RUN = re.compile(rb"(?<![^\x00])[^\x00]{%d}" % (_MAX_MARKS + 1))
532
+ # The characters UTS 46 maps to other marks than their own class counts (idna
533
+ # 3.20, Unicode 14.0 and 15.1): to two, to marks though they are of class 0,
534
+ # or to an iota, no mark.
535
+ _MARK_READINGS = str.maketrans(
536
+ {
537
+ "\u0344": "\u0308\u0301",
538
+ "\u0345": "\u03b9",
539
+ "\u0f73": "\u0f71\u0f72",
540
+ "\u0f75": "\u0f71\u0f74",
541
+ "\u0f81": "\u0f71\u0f80",
542
+ "\uff9e": "\u3099",
543
+ "\uff9f": "\u309a",
544
+ }
545
+ )
546
+ # Python's urlsplit reads a URL without its leading C0 controls and spaces,
547
+ # and without its tabs and line breaks. Its netloc follows "//", after the
548
+ # scheme if any, up to the first of "/?#".
549
+ _URL_LEADING = "".join(map(chr, range(0x21)))
550
+ _URL_REMOVED = "\t\r\n"
551
+ _NETLOC = re.compile(r"(?:[A-Za-z][A-Za-z0-9+.-]*:)?//([^/?#]*)")
552
+ # What urlsplit refuses NFKC to make of a netloc's characters.
553
+ _URL_DELIMITERS = frozenset("/?#@:")
554
+ # An IPvFuture address, in brackets (RFC 3986).
555
+ _IPV_FUTURE = re.compile(r"v[a-fA-F0-9]+\..+")
522
556
 
523
557
 
524
558
  # A parameter as email.utils decodes it: a value, or an RFC 2231
@@ -1532,11 +1566,26 @@ def _domain(host: str) -> str:
1532
1566
  A host that can be no DNS name (``_can_be_dns_name``) reaches no
1533
1567
  resolver: it is only lower-cased, neither mapped nor decoded. That bounds
1534
1568
  the work a host costs: CPython reorders combining marks for NFC in time
1535
- quadratic in their run, and a run ends at a dot.
1569
+ quadratic in their run, and a longer run than ``_MAX_MARKS`` makes no DNS
1570
+ name.
1536
1571
 
1537
1572
  It reads the host, and does not decide whether a browser would accept it:
1538
1573
  a label against the Bidi rule, or with a joiner out of context, is read
1539
- all the same."""
1574
+ all the same.
1575
+
1576
+ A host of at most ``_MAX_DOMAIN`` characters is read once while it stays
1577
+ among the last 256 the process read: a hostile message repeats one host
1578
+ up to MAX_URLS times (sdk#27). UTS 46 maps a character to at most 18, so
1579
+ the cache holds 19 MiB at most. A longer host is read each time: it is a
1580
+ DNS name only once its invisible characters or its trailing dots are
1581
+ removed."""
1582
+ if len(host) > _MAX_DOMAIN:
1583
+ return _read_domain(host)
1584
+ return _cached_domain(host)
1585
+
1586
+
1587
+ def _read_domain(host: str) -> str:
1588
+ """``host`` read as ``_domain`` reads it, each time."""
1540
1589
  host = host.rstrip(_DOT_CHARACTERS)
1541
1590
  if not _can_be_dns_name(host):
1542
1591
  return host.lower()
@@ -1549,19 +1598,37 @@ def _domain(host: str) -> str:
1549
1598
  return ".".join(map(_decoded, labels))
1550
1599
 
1551
1600
 
1601
+ _cached_domain = functools.lru_cache(maxsize=256)(_read_domain)
1602
+
1603
+
1552
1604
  def _can_be_dns_name(host: str) -> bool:
1553
1605
  """Whether ``host`` may map to a DNS name, counted on what a resolver could
1554
1606
  see: without its invisible characters (``visible``, which holds all those
1555
1607
  UTS 46 removes) and its trailing dots, it keeps at most ``_MAX_DOMAIN``
1556
- characters, and each label at most ``_MAX_LABEL``. UTS 46 maps any other
1608
+ characters, each label at most ``_MAX_LABEL``, and at most ``_MAX_MARKS``
1609
+ combining marks in a row as UTS 46 maps them. UTS 46 maps any other
1557
1610
  character to one or more, so the count never exceeds the mapped host's
1558
- before NFC. It only counts: the host is read as UTS 46 maps it."""
1611
+ before NFC. The joiners, the only invisible characters UTS 46 keeps, may
1612
+ split a run counted whole: no ASCII either, they leave the label as long
1613
+ in its A-label. It only counts: the host is read as UTS 46 maps it."""
1559
1614
  counted = visible(host).rstrip(_DOT_CHARACTERS)
1560
- return len(counted) <= _MAX_DOMAIN and all(
1561
- len(label) <= _MAX_LABEL for label in _DOTS.split(counted)
1615
+ return (
1616
+ len(counted) <= _MAX_DOMAIN
1617
+ and all(len(label) <= _MAX_LABEL for label in _DOTS.split(counted))
1618
+ and not _too_many_marks(counted)
1562
1619
  )
1563
1620
 
1564
1621
 
1622
+ def _too_many_marks(text: str) -> bool:
1623
+ """Whether ``text`` holds more than ``_MAX_MARKS`` combining marks in a row,
1624
+ as UTS 46 maps them (``_MARK_READINGS``). In linear time: the classes, all
1625
+ under 256, are matched as bytes, from the start of each run."""
1626
+ if text.isascii():
1627
+ return False
1628
+ classes = bytes(map(unicodedata.combining, text.translate(_MARK_READINGS)))
1629
+ return _MARK_RUN.search(classes) is not None
1630
+
1631
+
1565
1632
  def _mapped(label: str) -> str:
1566
1633
  """A label mapped as UTS 46 maps it; as written, lower-cased, when it does
1567
1634
  not map."""
@@ -1878,19 +1945,76 @@ def url_host(text: str) -> str:
1878
1945
  dropped. A label UTS 46 does not map stays as written, lower-cased, a
1879
1946
  host browsers refuse (a space, a slash, a label against the Bidi rule) is
1880
1947
  kept as read, and a host that can be no DNS name is only lower-cased:
1881
- never an exception. That is a host that keeps over 1 012 characters, or a
1882
- label over 252, once its invisible characters are removed: a DNS name
1883
- holds 253, a label 63, and NFC composes at most 4 code points into one.
1884
- An A-label over 63 characters stays encoded.
1948
+ never an exception. That is a host that keeps over 1 012 characters, a
1949
+ label over 252, or over 63 combining marks in a row, once its invisible
1950
+ characters are removed: a DNS name holds 253, a label 63, NFC composes at
1951
+ most 4 code points into one, and an A-label spells each code point that is
1952
+ not ASCII with a character at least. An A-label over 63 characters stays
1953
+ encoded.
1954
+
1955
+ The host is found as CPython 3.11's urlsplit reads it (3.13 also accepts
1956
+ an IPvFuture ``V``), its ``.hostname``, and none where urlsplit refuses the
1957
+ URL, but in linear time: urlsplit puts the netloc in NFKC to refuse a
1958
+ character NFKC turns into a delimiter (U+2100 is ``a/c``), in time
1959
+ quadratic in a run of combining marks.
1885
1960
  """
1886
1961
  if text[:4].lower() == "www.":
1887
1962
  text = "http://" + text
1963
+ # Browsers read a backslash as a slash in web URLs.
1964
+ host = _hostname(text.replace("\\", "/"))
1965
+ return _domain(_clean(unquote_url(host))) if host else ""
1966
+
1967
+
1968
+ def _hostname(url: str) -> str | None:
1969
+ """``urlsplit(url).hostname``, as CPython 3.11's urlsplit reads it (3.13
1970
+ also accepts an IPvFuture ``V``); None where urlsplit refuses ``url``: a
1971
+ bracket without its pair, brackets misplaced or around no IPv6 or IPvFuture
1972
+ address (``_accepted_brackets``), or a character NFKC turns into a
1973
+ delimiter. That last is checked a character at a time: NFKC composes no
1974
+ delimiter, and the netloc's own ``@`` and ``:`` do not count."""
1975
+ netloc = _netloc(url)
1976
+ if ("[" in netloc) != ("]" in netloc):
1977
+ return None
1978
+ if "[" in netloc and not _accepted_brackets(netloc.rpartition("@")[2]):
1979
+ return None
1980
+ if any(
1981
+ not _URL_DELIMITERS.isdisjoint(unicodedata.normalize("NFKD", character))
1982
+ for character in set(netloc)
1983
+ if not character.isascii()
1984
+ ):
1985
+ return None
1986
+ return SplitResult("", netloc, "", "", "").hostname
1987
+
1988
+
1989
+ def _netloc(url: str) -> str:
1990
+ """The netloc urlsplit reads in ``url``, unchecked; "" when it has none."""
1991
+ url = url.lstrip(_URL_LEADING)
1992
+ for removed in _URL_REMOVED:
1993
+ url = url.replace(removed, "")
1994
+ found = _NETLOC.match(url)
1995
+ return found.group(1) if found else ""
1996
+
1997
+
1998
+ def _accepted_brackets(hostinfo: str) -> bool:
1999
+ """Whether urlsplit accepts the brackets of a netloc whose ``hostinfo``
2000
+ follows its last ``@``: nothing before them, only a port after them, and an
2001
+ IPv6 or IPvFuture address in them. With the brackets in the userinfo, the
2002
+ host itself must be such an address."""
2003
+ before, bracket, bracketed = hostinfo.partition("[")
2004
+ if not bracket:
2005
+ host = before.partition(":")[0]
2006
+ elif before:
2007
+ return False
2008
+ else:
2009
+ host, _, port = bracketed.partition("]")
2010
+ if port and not port.startswith(":"):
2011
+ return False
2012
+ if host.startswith("v"):
2013
+ return _IPV_FUTURE.fullmatch(host) is not None
1888
2014
  try:
1889
- # Browsers read a backslash as a slash in web URLs.
1890
- host = urlsplit(text.replace("\\", "/")).hostname
2015
+ return ipaddress.ip_address(host).version == 6
1891
2016
  except ValueError:
1892
- return ""
1893
- return _domain(_clean(unquote_url(host))) if host else ""
2017
+ return False
1894
2018
 
1895
2019
 
1896
2020
  def _url(
@@ -15,6 +15,10 @@ ResponseMode = Literal["observe", "enforce"]
15
15
  DECISIVE_SIGNAL_PATTERN = r"^[a-z0-9_]+(\.[a-z0-9_]+)*\.([a-z0-9_]+|\*)$"
16
16
  """A signal name, or a prefix ending in ``.*``."""
17
17
 
18
+ # A workflow id goes into URLs, log lines and the commit messages of the config
19
+ # history: letters, digits, dots, underscores and hyphens only (sdk#26)
20
+ WORKFLOW_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
21
+
18
22
 
19
23
  class FailureStrategy(StrEnum):
20
24
  """Strategy for handling analyzer failures."""
@@ -163,7 +167,11 @@ class VerdictPolicy(BaseModel):
163
167
  class Workflow(BaseModel):
164
168
  """Complete workflow configuration for email analysis."""
165
169
 
166
- id: str = Field(..., description="Workflow ID")
170
+ id: str = Field(
171
+ ...,
172
+ pattern=WORKFLOW_ID_PATTERN,
173
+ description="Workflow ID: a letter or digit, then up to 127 letters, digits, dots, underscores or hyphens",
174
+ )
167
175
  name: str = Field(..., description="Workflow name")
168
176
  version: int = Field(default=1, ge=1, description="Workflow version")
169
177
  description: str = Field(default="", description="Workflow description")
@@ -1,38 +0,0 @@
1
- [build-system]
2
- requires = ["poetry-core"]
3
- build-backend = "poetry.core.masonry.api"
4
-
5
- [tool.poetry]
6
- name = "spot-sdk-python"
7
- version = "2.0.0b12"
8
- description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
- authors = ["SPOT Project <spot@sonn.lu>"]
10
- license = "Apache-2.0"
11
- homepage = "https://sonn.lu"
12
- readme = "README.md"
13
- packages = [
14
- {include = "spot_sdk"}
15
- ]
16
-
17
- [tool.poetry.dependencies]
18
- python = ">=3.11,<3.16"
19
- pydantic = "^2.10"
20
- pydantic-settings = "^2.0.0"
21
- httpx2 = ">=2.13.1,<3"
22
- selectolax = ">=1.0,<1.1"
23
- idna = ">=3.11,<4"
24
- fastapi = ">=0.133.0,<1"
25
- starlette = ">=1.7.0,<2"
26
-
27
- [tool.poetry.group.dev.dependencies]
28
- pytest = "^9.0.3"
29
- pytest-asyncio = "^1.3.0"
30
- pytest-cov = "^4.1.0"
31
- mypy = "^1.5.0"
32
-
33
- [tool.ruff]
34
- target-version = "py311"
35
-
36
- [tool.ruff.lint]
37
- select = ["E", "F", "I", "N", "W"]
38
- ignore = ["E501"] # Line too long