law4devs 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.
law4devs/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ from ._version import __version__
2
+ from .client import Law4DevsClient
3
+ from .exceptions import (
4
+ Law4DevsError,
5
+ NotFoundError,
6
+ ValidationError,
7
+ RateLimitError,
8
+ ServerError,
9
+ )
10
+ from .models import (
11
+ Framework,
12
+ FrameworkDetail,
13
+ Article,
14
+ ArticleSummary,
15
+ ArticleParagraph,
16
+ Recital,
17
+ Requirement,
18
+ Tag,
19
+ ComplianceDeadline,
20
+ Annex,
21
+ AnnexSummary,
22
+ SearchResult,
23
+ )
24
+
25
+
26
+ __all__ = [
27
+ "Law4DevsClient",
28
+ "__version__",
29
+ # Exceptions
30
+ "Law4DevsError",
31
+ "NotFoundError",
32
+ "ValidationError",
33
+ "RateLimitError",
34
+ "ServerError",
35
+ # Models
36
+ "Framework",
37
+ "FrameworkDetail",
38
+ "Article",
39
+ "ArticleSummary",
40
+ "ArticleParagraph",
41
+ "Recital",
42
+ "Requirement",
43
+ "Tag",
44
+ "ComplianceDeadline",
45
+ "Annex",
46
+ "AnnexSummary",
47
+ "SearchResult",
48
+ ]
law4devs/_http.py ADDED
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import time
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ from typing import Any
8
+
9
+ from ._version import __version__
10
+ from .exceptions import Law4DevsError, NotFoundError, RateLimitError, ServerError, ValidationError
11
+
12
+ DEFAULT_BASE_URL = "https://api.law4devs.eu/api/v1"
13
+
14
+
15
+ class HTTPClient:
16
+ def __init__(
17
+ self,
18
+ base_url: str = DEFAULT_BASE_URL,
19
+ api_key: str | None = None,
20
+ timeout: int = 30,
21
+ max_retries: int = 3,
22
+ ):
23
+ self.base_url = base_url.rstrip("/")
24
+ self.timeout = timeout
25
+ self.max_retries = max_retries
26
+ self._headers: dict[str, str] = {
27
+ "Accept": "application/json",
28
+ "User-Agent": f"law4devs-python/{__version__}",
29
+ }
30
+ if api_key:
31
+ self._headers["X-API-Key"] = api_key
32
+
33
+ def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
34
+ """Make a GET request with automatic retry on 429/5xx."""
35
+ url = self.base_url + path
36
+ if params:
37
+ clean = {k: str(v) for k, v in params.items() if v is not None}
38
+ if clean:
39
+ url += "?" + urllib.parse.urlencode(clean)
40
+
41
+ last_exc: Exception | None = None
42
+ for attempt in range(self.max_retries):
43
+ try:
44
+ req = urllib.request.Request(url, headers=self._headers)
45
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
46
+ return json.loads(resp.read())
47
+ except urllib.error.HTTPError as exc:
48
+ body: dict = {}
49
+ try:
50
+ body = json.loads(exc.read())
51
+ except Exception:
52
+ pass
53
+ msg = body.get("error", {}).get("message", str(exc))
54
+ status = exc.code
55
+ if status == 404:
56
+ raise NotFoundError(msg, status) from exc
57
+ if status == 400:
58
+ raise ValidationError(msg, status) from exc
59
+ if status == 429:
60
+ if attempt < self.max_retries - 1:
61
+ time.sleep(2 ** attempt)
62
+ last_exc = exc
63
+ continue
64
+ raise RateLimitError(msg, status) from exc
65
+ if status >= 500:
66
+ if attempt < self.max_retries - 1:
67
+ time.sleep(2 ** attempt)
68
+ last_exc = exc
69
+ continue
70
+ raise ServerError(msg, status) from exc
71
+ raise Law4DevsError(msg, status) from exc
72
+ except urllib.error.URLError as exc:
73
+ if attempt < self.max_retries - 1:
74
+ time.sleep(2 ** attempt)
75
+ last_exc = exc
76
+ continue
77
+ raise Law4DevsError(f"Request failed: {exc}") from exc
78
+
79
+ raise Law4DevsError("Max retries exceeded") from last_exc
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Generic, Iterator, List, TypeVar
4
+
5
+ T = TypeVar("T")
6
+
7
+ @dataclass
8
+ class PageMeta:
9
+ api_version: str
10
+ total: int
11
+ page: int
12
+ per_page: int
13
+ pages: int
14
+
15
+ @dataclass
16
+ class PageLinks:
17
+ next: str | None
18
+ prev: str | None
19
+
20
+ @dataclass
21
+ class Page(Generic[T]):
22
+ data: List[T]
23
+ meta: PageMeta
24
+ links: PageLinks
25
+
26
+ def __iter__(self) -> Iterator[T]:
27
+ return iter(self.data)
28
+
29
+ def __len__(self) -> int:
30
+ return len(self.data)
law4devs/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
law4devs/client.py ADDED
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+ from ._http import HTTPClient, DEFAULT_BASE_URL
3
+ from .resources.frameworks import FrameworksResource
4
+ from .resources.articles import ArticlesResource
5
+ from .resources.recitals import RecitalsResource
6
+ from .resources.requirements import RequirementsResource
7
+ from .resources.tags import TagsResource
8
+ from .resources.compliance import ComplianceResource
9
+ from .resources.annexes import AnnexesResource
10
+ from .resources.search import SearchResource
11
+
12
+
13
+ class Law4DevsClient:
14
+ """Official Python client for the Law4Devs EU Regulatory Compliance API.
15
+
16
+ Usage::
17
+
18
+ from law4devs import Law4DevsClient
19
+
20
+ client = Law4DevsClient()
21
+ frameworks = client.frameworks.list()
22
+ cra = client.frameworks.get("cra")
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ base_url: str = DEFAULT_BASE_URL,
29
+ api_key: str | None = None,
30
+ timeout: int = 30,
31
+ max_retries: int = 3,
32
+ ) -> None:
33
+ self._http = HTTPClient(
34
+ base_url=base_url,
35
+ api_key=api_key,
36
+ timeout=timeout,
37
+ max_retries=max_retries,
38
+ )
39
+ self.frameworks = FrameworksResource(self._http)
40
+ self.articles = ArticlesResource(self._http)
41
+ self.recitals = RecitalsResource(self._http)
42
+ self.requirements = RequirementsResource(self._http)
43
+ self.tags = TagsResource(self._http)
44
+ self.compliance = ComplianceResource(self._http)
45
+ self.annexes = AnnexesResource(self._http)
46
+ self.search = SearchResource(self._http)
law4devs/exceptions.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ class Law4DevsError(Exception):
4
+ """Base exception for all SDK errors."""
5
+ def __init__(self, message: str, status_code: int | None = None):
6
+ super().__init__(message)
7
+ self.status_code = status_code
8
+
9
+ class NotFoundError(Law4DevsError):
10
+ """Raised when a resource is not found (404)."""
11
+
12
+ class ValidationError(Law4DevsError):
13
+ """Raised for invalid request parameters (400)."""
14
+
15
+ class RateLimitError(Law4DevsError):
16
+ """Raised when rate limit is exceeded (429)."""
17
+
18
+ class ServerError(Law4DevsError):
19
+ """Raised for server-side errors (5xx)."""
@@ -0,0 +1,19 @@
1
+ from .framework import Framework, FrameworkDetail
2
+ from .article import Article, ArticleSummary, ArticleParagraph
3
+ from .recital import Recital
4
+ from .requirement import Requirement
5
+ from .tag import Tag
6
+ from .compliance import ComplianceDeadline
7
+ from .annex import Annex, AnnexSummary
8
+ from .search import SearchResult
9
+
10
+ __all__ = [
11
+ "Framework", "FrameworkDetail",
12
+ "Article", "ArticleSummary", "ArticleParagraph",
13
+ "Recital",
14
+ "Requirement",
15
+ "Tag",
16
+ "ComplianceDeadline",
17
+ "Annex", "AnnexSummary",
18
+ "SearchResult",
19
+ ]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class AnnexSummary:
7
+ id: int
8
+ framework_slug: str
9
+ annex_number: str
10
+ title: str
11
+
12
+ @classmethod
13
+ def _from_dict(cls, d: dict) -> AnnexSummary:
14
+ return cls(
15
+ id=d["id"],
16
+ framework_slug=d.get("framework_slug", d.get("framework", {}).get("slug", "")),
17
+ annex_number=str(d.get("annex_number", "")),
18
+ title=d.get("title", ""),
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class Annex(AnnexSummary):
24
+ content: str = ""
25
+
26
+ @classmethod
27
+ def _from_dict(cls, d: dict) -> Annex:
28
+ return cls(
29
+ id=d["id"],
30
+ framework_slug=d.get("framework_slug", d.get("framework", {}).get("slug", "")),
31
+ annex_number=str(d.get("annex_number", "")),
32
+ title=d.get("title", ""),
33
+ content=d.get("content", ""),
34
+ )
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import List
4
+ from .tag import Tag
5
+
6
+
7
+ @dataclass
8
+ class ArticleParagraph:
9
+ paragraph_ref: str
10
+ content: str
11
+ position: int
12
+
13
+ @classmethod
14
+ def _from_dict(cls, d: dict) -> ArticleParagraph:
15
+ return cls(
16
+ paragraph_ref=d.get("paragraph_ref", ""),
17
+ content=d.get("content", ""),
18
+ position=d.get("position", 0),
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class ArticleSummary:
24
+ id: int
25
+ framework_slug: str
26
+ article_number: int
27
+ title: str
28
+ position: int
29
+ paragraph_count: int
30
+ tags: List[Tag]
31
+
32
+ @classmethod
33
+ def _from_dict(cls, d: dict) -> ArticleSummary:
34
+ return cls(
35
+ id=d["id"],
36
+ framework_slug=d.get("framework_slug", d.get("framework", {}).get("slug", "")),
37
+ article_number=int(d.get("article_number", 0)),
38
+ title=d.get("title", ""),
39
+ position=d.get("position", 0),
40
+ paragraph_count=d.get("paragraph_count", 0),
41
+ tags=[Tag._from_dict(t) if isinstance(t, dict) else t for t in d.get("tags", [])],
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class Article(ArticleSummary):
47
+ content: str = ""
48
+ paragraphs: List[ArticleParagraph] = field(default_factory=list)
49
+
50
+ @classmethod
51
+ def _from_dict(cls, d: dict) -> Article:
52
+ return cls(
53
+ id=d["id"],
54
+ framework_slug=d.get("framework_slug", d.get("framework", {}).get("slug", "")),
55
+ article_number=int(d.get("article_number", 0)),
56
+ title=d.get("title", ""),
57
+ position=d.get("position", 0),
58
+ paragraph_count=d.get("paragraph_count", 0),
59
+ tags=[Tag._from_dict(t) if isinstance(t, dict) else t for t in d.get("tags", [])],
60
+ content=d.get("content", ""),
61
+ paragraphs=[ArticleParagraph._from_dict(p) for p in d.get("paragraphs", [])],
62
+ )
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class ComplianceDeadline:
7
+ id: int
8
+ framework_slug: str
9
+ article_number: str | None
10
+ paragraph_ref: str | None
11
+ deadline_date: str
12
+ deadline_type: str
13
+ description: str | None
14
+
15
+ @classmethod
16
+ def _from_dict(cls, d: dict) -> ComplianceDeadline:
17
+ return cls(
18
+ id=d["id"],
19
+ framework_slug=d.get("framework_slug", d.get("framework", {}).get("slug", "")),
20
+ article_number=d.get("article_number"),
21
+ paragraph_ref=d.get("paragraph_ref"),
22
+ deadline_date=d.get("deadline_date", ""),
23
+ deadline_type=d.get("deadline_type", ""),
24
+ description=d.get("description"),
25
+ )
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class Framework:
7
+ id: int
8
+ slug: str
9
+ name: str
10
+ short_name: str
11
+ celex_number: str
12
+ description: str | None
13
+ is_active: bool
14
+ status: str
15
+ expected_articles: int
16
+ expected_recitals: int
17
+ last_synced_at: str | None
18
+ created_at: str
19
+ article_count: int
20
+ recital_count: int
21
+
22
+ @classmethod
23
+ def _from_dict(cls, d: dict) -> Framework:
24
+ return cls(
25
+ id=d["id"],
26
+ slug=d["slug"],
27
+ name=d["name"],
28
+ short_name=d.get("short_name", ""),
29
+ celex_number=d.get("celex_number", ""),
30
+ description=d.get("description"),
31
+ is_active=d.get("is_active", True),
32
+ status=d.get("status", "active"),
33
+ expected_articles=d.get("expected_articles", 0),
34
+ expected_recitals=d.get("expected_recitals", 0),
35
+ last_synced_at=d.get("last_synced_at"),
36
+ created_at=d.get("created_at", ""),
37
+ article_count=d.get("article_count", 0),
38
+ recital_count=d.get("recital_count", 0),
39
+ )
40
+
41
+
42
+ @dataclass
43
+ class FrameworkDetail(Framework):
44
+ eurlex_url: str = ""
45
+ requirement_count: int = 0
46
+ annex_count: int = 0
47
+ tag_count: int = 0
48
+ coverage: dict | None = None
49
+
50
+ @classmethod
51
+ def _from_dict(cls, d: dict) -> FrameworkDetail:
52
+ return cls(
53
+ id=d["id"],
54
+ slug=d["slug"],
55
+ name=d["name"],
56
+ short_name=d.get("short_name", ""),
57
+ celex_number=d.get("celex_number", ""),
58
+ description=d.get("description"),
59
+ is_active=d.get("is_active", True),
60
+ status=d.get("status", "active"),
61
+ expected_articles=d.get("expected_articles", 0),
62
+ expected_recitals=d.get("expected_recitals", 0),
63
+ last_synced_at=d.get("last_synced_at"),
64
+ created_at=d.get("created_at", ""),
65
+ article_count=d.get("article_count", 0),
66
+ recital_count=d.get("recital_count", 0),
67
+ eurlex_url=d.get("eurlex_url", ""),
68
+ requirement_count=d.get("requirement_count", 0),
69
+ annex_count=d.get("annex_count", 0),
70
+ tag_count=d.get("tag_count", 0),
71
+ coverage=d.get("coverage"),
72
+ )
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class Recital:
7
+ id: int
8
+ framework_slug: str
9
+ recital_number: int
10
+ content: str
11
+ position: int
12
+
13
+ @classmethod
14
+ def _from_dict(cls, d: dict) -> Recital:
15
+ return cls(
16
+ id=d["id"],
17
+ framework_slug=d.get("framework_slug", d.get("framework", {}).get("slug", "")),
18
+ recital_number=int(d.get("recital_number", 0)),
19
+ content=d.get("content", ""),
20
+ position=d.get("position", 0),
21
+ )
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import List
4
+ from .tag import Tag
5
+
6
+
7
+ @dataclass
8
+ class Requirement:
9
+ id: int
10
+ framework_slug: str
11
+ article_number: int | None
12
+ paragraph_ref: str | None
13
+ paragraph_content: str | None
14
+ requirement_text: str
15
+ requirement_type: str
16
+ compliance_deadline: str | None
17
+ linked_article_numbers: List[int]
18
+ stakeholder_roles: List[str]
19
+ tags: List[Tag]
20
+ created_at: str
21
+
22
+ @classmethod
23
+ def _from_dict(cls, d: dict) -> Requirement:
24
+ return cls(
25
+ id=d["id"],
26
+ framework_slug=d.get("framework_slug", d.get("framework", {}).get("slug", "")),
27
+ article_number=d.get("article_number"),
28
+ paragraph_ref=d.get("paragraph_ref"),
29
+ paragraph_content=d.get("paragraph_content"),
30
+ requirement_text=d.get("requirement_text", ""),
31
+ requirement_type=d.get("requirement_type", "general"),
32
+ compliance_deadline=d.get("compliance_deadline"),
33
+ linked_article_numbers=d.get("linked_article_numbers", []),
34
+ stakeholder_roles=d.get("stakeholder_roles", []),
35
+ tags=[Tag._from_dict(t) if isinstance(t, dict) else t for t in d.get("tags", [])],
36
+ created_at=d.get("created_at", ""),
37
+ )
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class SearchResult:
7
+ type: str
8
+ framework_slug: str
9
+ framework_name: str
10
+ article_number: int | None
11
+ recital_number: int | None
12
+ paragraph_ref: str | None
13
+ title: str | None
14
+ requirement_type: str | None
15
+ match_context: str
16
+ url: str
17
+
18
+ @classmethod
19
+ def _from_dict(cls, d: dict) -> SearchResult:
20
+ fw = d.get("framework", {})
21
+ return cls(
22
+ type=d.get("type", ""),
23
+ framework_slug=d.get("framework_slug", fw.get("slug", "")),
24
+ framework_name=d.get("framework_name", fw.get("name", "")),
25
+ article_number=d.get("article_number"),
26
+ recital_number=d.get("recital_number"),
27
+ paragraph_ref=d.get("paragraph_ref"),
28
+ title=d.get("title"),
29
+ requirement_type=d.get("requirement_type"),
30
+ match_context=d.get("match_context", ""),
31
+ url=d.get("url", ""),
32
+ )
law4devs/models/tag.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import List
4
+
5
+
6
+ @dataclass
7
+ class Tag:
8
+ id: int
9
+ slug: str
10
+ name: str
11
+ description: str | None
12
+ keywords: List[str]
13
+ color: str
14
+ created_at: str
15
+
16
+ @classmethod
17
+ def _from_dict(cls, d: dict) -> Tag:
18
+ return cls(
19
+ id=d["id"],
20
+ slug=d["slug"],
21
+ name=d["name"],
22
+ description=d.get("description"),
23
+ keywords=d.get("keywords", []),
24
+ color=d.get("color", ""),
25
+ created_at=d.get("created_at", ""),
26
+ )
law4devs/py.typed ADDED
File without changes
@@ -0,0 +1,21 @@
1
+ from ._base import BaseResource
2
+ from .frameworks import FrameworksResource
3
+ from .articles import ArticlesResource
4
+ from .recitals import RecitalsResource
5
+ from .requirements import RequirementsResource
6
+ from .tags import TagsResource
7
+ from .compliance import ComplianceResource
8
+ from .annexes import AnnexesResource
9
+ from .search import SearchResource
10
+
11
+ __all__ = [
12
+ "BaseResource",
13
+ "FrameworksResource",
14
+ "ArticlesResource",
15
+ "RecitalsResource",
16
+ "RequirementsResource",
17
+ "TagsResource",
18
+ "ComplianceResource",
19
+ "AnnexesResource",
20
+ "SearchResource",
21
+ ]
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, Any, Iterator, TypeVar
3
+
4
+ if TYPE_CHECKING:
5
+ from law4devs._http import HTTPClient
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ class BaseResource:
11
+ def __init__(self, http: HTTPClient) -> None:
12
+ self._http = http
13
+
14
+ def _page_params(self, page: int | None, per_page: int | None) -> dict[str, Any]:
15
+ params: dict[str, Any] = {}
16
+ if page is not None:
17
+ params["page"] = page
18
+ if per_page is not None:
19
+ params["per_page"] = per_page
20
+ return params
21
+
22
+ def _parse_page(self, raw: dict, item_factory) -> Any:
23
+ from law4devs._pagination import Page, PageMeta, PageLinks
24
+ meta_raw = raw.get("meta", {})
25
+ links_raw = raw.get("links", {})
26
+ return Page(
27
+ data=[item_factory(item) for item in raw.get("data", [])],
28
+ meta=PageMeta(
29
+ api_version=meta_raw.get("api_version", "1.0"),
30
+ total=meta_raw.get("total", 0),
31
+ page=meta_raw.get("page", 1),
32
+ per_page=meta_raw.get("per_page", 20),
33
+ pages=meta_raw.get("pages", 0),
34
+ ),
35
+ links=PageLinks(
36
+ next=links_raw.get("next"),
37
+ prev=links_raw.get("prev"),
38
+ ),
39
+ )
40
+
41
+ def _iter_pages(self, list_fn, *args, per_page: int = 20, **kwargs) -> Iterator:
42
+ """Auto-paginate through all pages and yield individual items."""
43
+ page = 1
44
+ while True:
45
+ result = list_fn(*args, page=page, per_page=per_page, **kwargs)
46
+ yield from result.data
47
+ if not result.links.next:
48
+ break
49
+ page += 1
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+ from typing import Iterator
3
+ from ._base import BaseResource
4
+ from ..models.annex import Annex, AnnexSummary
5
+ from .._pagination import Page
6
+
7
+
8
+ class AnnexesResource(BaseResource):
9
+ def list(self, framework_slug: str, *, page: int | None = None, per_page: int | None = None) -> Page[AnnexSummary]:
10
+ raw = self._http.get(f"/frameworks/{framework_slug}/annexes", params=self._page_params(page, per_page))
11
+ return self._parse_page(raw, AnnexSummary._from_dict)
12
+
13
+ def iter(self, framework_slug: str, *, per_page: int = 20) -> Iterator[AnnexSummary]:
14
+ """Iterate over all annexes for a framework across all pages."""
15
+ return self._iter_pages(self.list, framework_slug, per_page=per_page)
16
+
17
+ def get(self, framework_slug: str, annex_number: int | str) -> Annex:
18
+ raw = self._http.get(f"/frameworks/{framework_slug}/annexes/{annex_number}")
19
+ return Annex._from_dict(raw.get("data", raw))
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+ from typing import Iterator
3
+ from ._base import BaseResource
4
+ from ..models.article import Article, ArticleSummary
5
+ from .._pagination import Page
6
+
7
+
8
+ class ArticlesResource(BaseResource):
9
+ def list(self, framework_slug: str, *, page: int | None = None, per_page: int | None = None) -> Page[ArticleSummary]:
10
+ raw = self._http.get(f"/frameworks/{framework_slug}/articles", params=self._page_params(page, per_page))
11
+ return self._parse_page(raw, ArticleSummary._from_dict)
12
+
13
+ def iter(self, framework_slug: str, *, per_page: int = 20) -> Iterator[ArticleSummary]:
14
+ """Iterate over all articles for a framework across all pages."""
15
+ return self._iter_pages(self.list, framework_slug, per_page=per_page)
16
+
17
+ def get(self, framework_slug: str, article_number: int | str) -> Article:
18
+ raw = self._http.get(f"/frameworks/{framework_slug}/articles/{article_number}")
19
+ return Article._from_dict(raw.get("data", raw))
20
+
21
+ def related(self, framework_slug: str, article_number: int | str, *, page: int | None = None, per_page: int | None = None) -> Page[ArticleSummary]:
22
+ raw = self._http.get(f"/frameworks/{framework_slug}/articles/{article_number}/related", params=self._page_params(page, per_page))
23
+ return self._parse_page(raw, ArticleSummary._from_dict)
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+ from typing import Iterator
3
+ from ._base import BaseResource
4
+ from ..models.compliance import ComplianceDeadline
5
+ from .._pagination import Page
6
+
7
+
8
+ class ComplianceResource(BaseResource):
9
+ def deadlines(self, *, framework_slug: str | None = None, page: int | None = None, per_page: int | None = None) -> Page[ComplianceDeadline]:
10
+ if framework_slug:
11
+ path = f"/frameworks/{framework_slug}/compliance/deadlines"
12
+ else:
13
+ path = "/compliance/deadlines"
14
+ params = self._page_params(page, per_page)
15
+ raw = self._http.get(path, params=params)
16
+ return self._parse_page(raw, ComplianceDeadline._from_dict)
17
+
18
+ def iter_deadlines(self, *, framework_slug: str | None = None, per_page: int = 20) -> Iterator[ComplianceDeadline]:
19
+ """Iterate over all compliance deadlines across all pages."""
20
+ return self._iter_pages(self.deadlines, framework_slug=framework_slug, per_page=per_page)
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Iterator
3
+ from ._base import BaseResource
4
+ from ..models.framework import Framework, FrameworkDetail
5
+ from .._pagination import Page
6
+
7
+
8
+ class FrameworksResource(BaseResource):
9
+ def list(self, *, page: int | None = None, per_page: int | None = None) -> Page[Framework]:
10
+ raw = self._http.get("/frameworks", params=self._page_params(page, per_page))
11
+ return self._parse_page(raw, Framework._from_dict)
12
+
13
+ def iter(self, *, per_page: int = 20) -> Iterator[Framework]:
14
+ """Iterate over all frameworks across all pages."""
15
+ return self._iter_pages(self.list, per_page=per_page)
16
+
17
+ def get(self, slug: str) -> FrameworkDetail:
18
+ raw = self._http.get(f"/frameworks/{slug}")
19
+ return FrameworkDetail._from_dict(raw.get("data", raw))
20
+
21
+ def stats(self, slug: str) -> dict:
22
+ return self._http.get(f"/frameworks/{slug}/stats")
23
+
24
+ def changelog(self, slug: str, *, page: int | None = None, per_page: int | None = None) -> dict:
25
+ return self._http.get(f"/frameworks/{slug}/changelog", params=self._page_params(page, per_page))
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+ from typing import Iterator
3
+ from ._base import BaseResource
4
+ from ..models.recital import Recital
5
+ from .._pagination import Page
6
+
7
+
8
+ class RecitalsResource(BaseResource):
9
+ def list(self, framework_slug: str, *, page: int | None = None, per_page: int | None = None) -> Page[Recital]:
10
+ raw = self._http.get(f"/frameworks/{framework_slug}/recitals", params=self._page_params(page, per_page))
11
+ return self._parse_page(raw, Recital._from_dict)
12
+
13
+ def iter(self, framework_slug: str, *, per_page: int = 20) -> Iterator[Recital]:
14
+ """Iterate over all recitals for a framework across all pages."""
15
+ return self._iter_pages(self.list, framework_slug, per_page=per_page)
16
+
17
+ def get(self, framework_slug: str, recital_number: int | str) -> Recital:
18
+ raw = self._http.get(f"/frameworks/{framework_slug}/recitals/{recital_number}")
19
+ return Recital._from_dict(raw.get("data", raw))
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+ from typing import Iterator
3
+ from ._base import BaseResource
4
+ from ..models.requirement import Requirement
5
+ from .._pagination import Page
6
+
7
+
8
+ class RequirementsResource(BaseResource):
9
+ def list(self, *, framework_slug: str | None = None, page: int | None = None, per_page: int | None = None) -> Page[Requirement]:
10
+ if framework_slug:
11
+ path = f"/frameworks/{framework_slug}/requirements"
12
+ else:
13
+ path = "/requirements"
14
+ params = self._page_params(page, per_page)
15
+ raw = self._http.get(path, params=params)
16
+ return self._parse_page(raw, Requirement._from_dict)
17
+
18
+ def iter(self, *, framework_slug: str | None = None, per_page: int = 20) -> Iterator[Requirement]:
19
+ """Iterate over all requirements across all pages."""
20
+ return self._iter_pages(self.list, framework_slug=framework_slug, per_page=per_page)
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+ from ._base import BaseResource
3
+ from ..models.search import SearchResult
4
+ from .._pagination import Page
5
+
6
+
7
+ class SearchResource(BaseResource):
8
+ def query(self, q: str, *, framework: str | None = None, result_type: str | None = None, page: int | None = None, per_page: int | None = None) -> Page[SearchResult]:
9
+ params: dict = {"q": q}
10
+ if framework:
11
+ params["framework"] = framework
12
+ if result_type:
13
+ params["type"] = result_type
14
+ params.update(self._page_params(page, per_page))
15
+ raw = self._http.get("/search", params=params)
16
+ return self._parse_page(raw, SearchResult._from_dict)
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+ from typing import Iterator
3
+ from ._base import BaseResource
4
+ from ..models.tag import Tag
5
+ from .._pagination import Page
6
+
7
+
8
+ class TagsResource(BaseResource):
9
+ def list(self, *, page: int | None = None, per_page: int | None = None) -> Page[Tag]:
10
+ raw = self._http.get("/tags", params=self._page_params(page, per_page))
11
+ return self._parse_page(raw, Tag._from_dict)
12
+
13
+ def iter(self, *, per_page: int = 20) -> Iterator[Tag]:
14
+ """Iterate over all tags across all pages."""
15
+ return self._iter_pages(self.list, per_page=per_page)
16
+
17
+ def get(self, slug: str) -> Tag:
18
+ raw = self._http.get(f"/tags/{slug}")
19
+ return Tag._from_dict(raw.get("data", raw))
@@ -0,0 +1,230 @@
1
+ Metadata-Version: 2.4
2
+ Name: law4devs
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Law4Devs EU Regulatory Compliance API
5
+ Project-URL: Homepage, https://law4devs.eu
6
+ Project-URL: Documentation, https://docs.law4devs.eu/sdks/python
7
+ Author-email: law4devs <sdk@law4devs.eu>
8
+ Maintainer-email: Sofiane Hamlaoui <contact@law4devs.eu>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-act,compliance,cra,eu,gdpr,law4devs,nis2,regulatory
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+
24
+ # law4devs
25
+
26
+ Official Python SDK for the [Law4Devs](https://law4devs.eu) EU Regulatory Compliance API.
27
+
28
+ Access structured, developer-friendly data for EU regulations — GDPR, Cyber Resilience Act, NIS2, AI Act, and more.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install law4devs
34
+ ```
35
+
36
+ Python 3.9+ required. Zero runtime dependencies (stdlib urllib only).
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ from law4devs import Law4DevsClient
42
+
43
+ client = Law4DevsClient()
44
+
45
+ # List all frameworks
46
+ page = client.frameworks.list()
47
+ for fw in page:
48
+ print(fw.slug, fw.name)
49
+
50
+ # Get framework detail
51
+ cra = client.frameworks.get("cra")
52
+ print(cra.name, cra.requirement_count)
53
+
54
+ # Fetch a specific article
55
+ article = client.articles.get("cra", 13)
56
+ print(article.title, article.content)
57
+ ```
58
+
59
+ ## Authentication
60
+
61
+ The public API tier does not require authentication. Authenticated tiers provide higher rate limits:
62
+
63
+ ```python
64
+ client = Law4DevsClient(api_key="your-api-key")
65
+ ```
66
+
67
+ ## Resources
68
+
69
+ ### Frameworks
70
+
71
+ ```python
72
+ page = client.frameworks.list(page=1, per_page=10)
73
+ print(page.meta.total, page.meta.pages)
74
+
75
+ cra = client.frameworks.get("cra")
76
+
77
+ for fw in client.frameworks.iter():
78
+ print(fw.slug)
79
+ ```
80
+
81
+ ### Articles
82
+
83
+ ```python
84
+ page = client.articles.list("cra", per_page=20)
85
+ art = client.articles.get("cra", 13)
86
+ related = client.articles.related("cra", 13)
87
+
88
+ for art in client.articles.iter("cra"):
89
+ print(art.article_number, art.title)
90
+ ```
91
+
92
+ ### Recitals
93
+
94
+ ```python
95
+ page = client.recitals.list("cra")
96
+ recital = client.recitals.get("cra", 10)
97
+
98
+ for recital in client.recitals.iter("cra"):
99
+ print(recital.recital_number, recital.content[:80])
100
+ ```
101
+
102
+ ### Requirements
103
+
104
+ ```python
105
+ page = client.requirements.list()
106
+ page = client.requirements.list(framework_slug="cra")
107
+
108
+ for req in client.requirements.iter(framework_slug="cra"):
109
+ print(req.requirement_type, req.requirement_text[:60])
110
+ ```
111
+
112
+ ### Compliance Deadlines
113
+
114
+ ```python
115
+ page = client.compliance.deadlines()
116
+ page = client.compliance.deadlines(framework_slug="nis2")
117
+
118
+ for d in client.compliance.iter_deadlines(framework_slug="nis2"):
119
+ print(d.deadline_date, d.description)
120
+ ```
121
+
122
+ ### Tags
123
+
124
+ ```python
125
+ page = client.tags.list()
126
+ tag = client.tags.get("security")
127
+
128
+ for tag in client.tags.iter():
129
+ print(tag.slug, tag.name)
130
+ ```
131
+
132
+ ### Annexes
133
+
134
+ ```python
135
+ page = client.annexes.list("cra")
136
+ annex = client.annexes.get("cra", "I")
137
+
138
+ for annex in client.annexes.iter("cra"):
139
+ print(annex.annex_number, annex.title)
140
+ ```
141
+
142
+ ### Search
143
+
144
+ ```python
145
+ results = client.search.query("vulnerability reporting")
146
+ results = client.search.query("audit", framework="gdpr")
147
+ results = client.search.query("encryption", result_type="requirement")
148
+ ```
149
+
150
+ ## Auto-Pagination
151
+
152
+ All list resources support `iter()` which automatically fetches all pages:
153
+
154
+ ```python
155
+ all_articles = list(client.articles.iter("cra"))
156
+
157
+ for art in client.articles.iter("cra", per_page=50):
158
+ process(art)
159
+ ```
160
+
161
+ ## Error Handling
162
+
163
+ ```python
164
+ from law4devs import Law4DevsClient, NotFoundError, RateLimitError, Law4DevsError
165
+
166
+ client = Law4DevsClient()
167
+
168
+ try:
169
+ fw = client.frameworks.get("nonexistent")
170
+ except NotFoundError as e:
171
+ print(f"Not found: {e} (status {e.status_code})")
172
+ except RateLimitError:
173
+ print("Rate limited — slow down requests")
174
+ except Law4DevsError as e:
175
+ print(f"API error: {e}")
176
+ ```
177
+
178
+ ### Exception Hierarchy
179
+
180
+ | Exception | HTTP Status | Description |
181
+ |-----------|-------------|-------------|
182
+ | `Law4DevsError` | any | Base exception |
183
+ | `NotFoundError` | 404 | Resource not found |
184
+ | `ValidationError` | 400 | Invalid request parameters |
185
+ | `RateLimitError` | 429 | Rate limit exceeded |
186
+ | `ServerError` | 5xx | Server-side error |
187
+
188
+ ## Configuration
189
+
190
+ | Parameter | Default | Description |
191
+ |-----------|---------|-------------|
192
+ | `base_url` | `https://api.law4devs.eu/api/v1` | API base URL |
193
+ | `api_key` | `None` | API key for authenticated tiers |
194
+ | `timeout` | `30` | Request timeout in seconds |
195
+ | `max_retries` | `3` | Retries on 429/5xx with exponential backoff |
196
+
197
+ ## Available Frameworks
198
+
199
+ | Slug | Name | CELEX | Status |
200
+ |------|------|-------|--------|
201
+ | `cra` | Cyber Resilience Act | 32024R2847 | active |
202
+ | `nis2` | NIS2 Directive | 32022L2555 | active |
203
+ | `dora` | Digital Operational Resilience Act | 32022R2554 | active |
204
+ | `gdpr` | General Data Protection Regulation | 32016R0679 | active |
205
+ | `ai_act` | AI Act | 32024R1689 | active |
206
+ | `eidas` | eIDAS Regulation | 32014R0910 | active |
207
+ | `dsa` | Digital Services Act | 32022R2065 | active |
208
+ | `dma` | Digital Markets Act | 32022R1925 | active |
209
+ | `data_act` | Data Act | 32023R2854 | active |
210
+ | `dga` | Data Governance Act | 32022R0868 | active |
211
+ | `eidas2` | European Digital Identity Regulation | 32024R1183 | active |
212
+ | `cer` | Critical Entities Resilience Directive | 32022L2557 | active |
213
+ | `psd2` | Payment Services Directive 2 | 32015L2366 | active |
214
+ | `mica` | Markets in Crypto-Assets Regulation | 32023R1114 | active |
215
+ | `cybersecurity_act` | Cybersecurity Act | 32019R0881 | active |
216
+ | `eprivacy` | ePrivacy Directive | 32002L0058 | active |
217
+ | `red` | Radio Equipment Directive | 32014L0053 | active |
218
+ | `csrd` | Corporate Sustainability Reporting Dir. | 32022L2464 | active |
219
+ | `nis1` | NIS Directive (Original) | 32016L1148 | superseded |
220
+
221
+
222
+ ## License
223
+
224
+ MIT License. See [LICENSE](LICENSE) for details.
225
+
226
+ ## Links
227
+
228
+ - [Law4Devs API](https://law4devs.eu)
229
+ - [API Reference](https://docs.law4devs.eu)
230
+ - [PyPI](https://pypi.org/project/law4devs/)
@@ -0,0 +1,30 @@
1
+ law4devs/__init__.py,sha256=PBIDVDEHgF3osGzmDwHNLlnFIi96ElT5OPUEFVJwC7w,836
2
+ law4devs/_http.py,sha256=V4EwXVJeP_igZIjiljZDBcue8qAP55_mwlVT5-t6_kE,2985
3
+ law4devs/_pagination.py,sha256=OAjssCGmvQxVJbFZVksJwZ2fqB_DCWViOV0p1whQauE,547
4
+ law4devs/_version.py,sha256=QTYqXqSTHFRkM9TEgpDFcHvwLbvqHDqvqfQ9EiXkcAM,23
5
+ law4devs/client.py,sha256=tvVgfVFyTOYcyMM26jtCfINKqk81SqON39313qHnkHg,1557
6
+ law4devs/exceptions.py,sha256=f02RtKJokKVICCN_yJ6YwHX_sMJ5aA2AFMUzZQemyMg,612
7
+ law4devs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ law4devs/models/__init__.py,sha256=_xdC3j4pzYeKbuRWsiDM1pCWRPaXdrC2SIktHUL8OwU,539
9
+ law4devs/models/annex.py,sha256=PBYaEvdpgis7glfncKvP0P-Y_P0waMExPgiQXvrRjvg,890
10
+ law4devs/models/article.py,sha256=4mL99Mi2ExNn_Me6cCRottVK5UXsXs5xeTjkf5zFgRE,1908
11
+ law4devs/models/compliance.py,sha256=a5w0ly10JNnyQxTs317D4GLT-k8XlYIzylZL1XCJ0GY,755
12
+ law4devs/models/framework.py,sha256=QKMA1vHxxVQrTBPI1ov2JKvFDHPy4rahQGa-Ml1Bywg,2315
13
+ law4devs/models/recital.py,sha256=nGqNQXFrQORFLHe2dC5CPYB6PV94_MCgrw8ln_Dl8iA,546
14
+ law4devs/models/requirement.py,sha256=uc9qscBf1UbA6JPg9LcmzJFCMLUa_q2S4TV4DbRvnPs,1315
15
+ law4devs/models/search.py,sha256=I9zWSiNReN3Uwq8Frgx6wBCIuhKons2aiXNE4d1dY0E,993
16
+ law4devs/models/tag.py,sha256=7SJ1xKIg_a25lk8NIcFuFXdF8OB3ddkUMbChxlBOrHs,589
17
+ law4devs/resources/__init__.py,sha256=nuiyaPvQGu0lYa7kxhtwVyvxiKbZ-mSmKH-DpdiVxKs,574
18
+ law4devs/resources/_base.py,sha256=1CVy4WXptVZ4CGdld03SUrN5mkHsQXLAQ0amfAM6pQ4,1686
19
+ law4devs/resources/annexes.py,sha256=6jOZRZ6_Cnl7j5hh5un_XQN8qaD__vM-REgMxHfPo8U,962
20
+ law4devs/resources/articles.py,sha256=mARXWBAFNJ5-v66P42OICm6-ngNeRUnt5AKHA5iZ6Oo,1341
21
+ law4devs/resources/compliance.py,sha256=O0Rn-IsTVtahfuimoP27T_8Uc5ECdhBAssCh6S-e4gw,978
22
+ law4devs/resources/frameworks.py,sha256=POaJvYVF-j56ypYEn8-ql_kkAb2knyqT7j7KW2WOr1o,1134
23
+ law4devs/resources/recitals.py,sha256=AOQDmmTgOiNjK2PunIzDbZ6PynHm0fVo0nSefe_bwkc,949
24
+ law4devs/resources/requirements.py,sha256=PSCaBK85KkIPswYw9NdB6KKdxuvCz4O4uLoYGWZvZqE,909
25
+ law4devs/resources/search.py,sha256=3Y1rbCbIxeKNKLgU84AnIO3hAtLAe4haVa515Byiq14,684
26
+ law4devs/resources/tags.py,sha256=XlfAHFyxtZi2GLf_0CBRDGQPolvlGIQD53blGCHztNA,727
27
+ law4devs-0.1.0.dist-info/METADATA,sha256=5BlmWYwYX0DiJv8z3EqmfV4pW4cFN7wv3PvCnJFoHzY,6345
28
+ law4devs-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
29
+ law4devs-0.1.0.dist-info/licenses/LICENSE,sha256=p8gNxtKvHogeIY8HF6FZY28sIeFIDnxkYlI_NRNDQnk,1086
30
+ law4devs-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 law4devs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.