ecosemantic-relay 0.1.14__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ *.egg-info/
6
+ dist/
7
+ .cache/
8
+ build/
9
+ node_modules/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+ .coverage
14
+ coverage.json
15
+ htmlcov/
16
+
17
+ .env
18
+ .env.*
19
+ !.env.example
20
+
21
+ .DS_Store
22
+ graphify-out
23
+ knowledge/
24
+ .okf-cache.json
25
+ **/ecosemantic/view/static/
26
+
27
+ installer/windows/defines.iss
28
+
29
+ /payload/
@@ -0,0 +1,73 @@
1
+ # PolyForm Noncommercial License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
4
+
5
+ ## Acceptance
6
+
7
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
12
+
13
+ ## Distribution License
14
+
15
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
16
+
17
+ ## Notices
18
+
19
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
20
+
21
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
22
+
23
+ ## Changes and New Works License
24
+
25
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
26
+
27
+ ## Patent License
28
+
29
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
30
+
31
+ ## Noncommercial Purposes
32
+
33
+ Any noncommercial purpose is a permitted purpose.
34
+
35
+ ## Personal Uses
36
+
37
+ Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
38
+
39
+ ## Noncommercial Organizations
40
+
41
+ Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
42
+
43
+ ## Fair Use
44
+
45
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
46
+
47
+ ## No Other Rights
48
+
49
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
50
+
51
+ ## Patent Defense
52
+
53
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
54
+
55
+ ## Violations
56
+
57
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
58
+
59
+ ## No Liability
60
+
61
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
62
+
63
+ ## Definitions
64
+
65
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
66
+
67
+ **You** refers to the individual or entity agreeing to these terms.
68
+
69
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
70
+
71
+ **Your licenses** are all the licenses granted to you for the software under these terms.
72
+
73
+ **Use** means anything you do with the software requiring one of your licenses.
@@ -0,0 +1,13 @@
1
+ EcoSemantic
2
+ Required Notice: Copyright 2026 Carlos Gaete-Morales (https://github.com/cdgaete/ecosemantic)
3
+
4
+ EcoSemantic is licensed under the PolyForm Noncommercial License 1.0.0, in LICENSE
5
+ and at https://polyformproject.org/licenses/noncommercial/1.0.0. Any noncommercial
6
+ purpose is permitted. Commercial use needs a separate licence from Carlos
7
+ Gaete-Morales: cdgaete@gmail.com.
8
+
9
+ The page that ecosemantic-view carries bundles third-party software under its
10
+ own licences; their notices are in ecosemantic/view/static/THIRD_PARTY_NOTICES.txt
11
+ inside that distribution. Components of the page adapt shadcn/ui, Copyright (c)
12
+ 2023 shadcn, under the MIT licence, as page/PROVENANCE.md in the source
13
+ repository records.
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.5
2
+ Name: ecosemantic-relay
3
+ Version: 0.1.14
4
+ Summary: The EcoSemantic bridge tunnel: its messages, its frames, the bridge's discovery document and the Ed25519 proof a bridged request carries.
5
+ Project-URL: Homepage, https://github.com/cdgaete/ecosemantic
6
+ Project-URL: Repository, https://github.com/cdgaete/ecosemantic
7
+ Project-URL: Issues, https://github.com/cdgaete/ecosemantic/issues
8
+ Project-URL: Changelog, https://github.com/cdgaete/ecosemantic/blob/main/CHANGELOG.md
9
+ Author-email: Carlos Gaete-Morales <cdgaete@gmail.com>
10
+ License-Expression: PolyForm-Noncommercial-1.0.0
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Keywords: Ed25519,LCA,bridge,life cycle assessment,tunnel
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: cryptography<51,>=44
25
+ Requires-Dist: pydantic<3,>=2.7
26
+ Requires-Dist: pyjwt[crypto]<3,>=2.10
27
+ Description-Content-Type: text/markdown
28
+
29
+ `ecosemantic-relay` holds the tunnel between a hosted bridge and an engine: the messages its text frames carry — the engine's hello, the bridge's welcome, a request's and a response's start, and a stream's reset — the binary frames that carry a stream's body in chunks of at most 64 KiB, the document the bridge publishes at its well-known address with what an engine registers and is registered as, and the Ed25519 proof a bridged request carries with the JWKs the bridge publishes to check it. The bridge service imports it for both sides of the tunnel, and `ecosemantic` depends on it for `ecosemantic serve --bridge`.
@@ -0,0 +1 @@
1
+ `ecosemantic-relay` holds the tunnel between a hosted bridge and an engine: the messages its text frames carry — the engine's hello, the bridge's welcome, a request's and a response's start, and a stream's reset — the binary frames that carry a stream's body in chunks of at most 64 KiB, the document the bridge publishes at its well-known address with what an engine registers and is registered as, and the Ed25519 proof a bridged request carries with the JWKs the bridge publishes to check it. The bridge service imports it for both sides of the tunnel, and `ecosemantic` depends on it for `ecosemantic serve --bridge`.
@@ -0,0 +1,3 @@
1
+ """The tunnel's messages, frames, discovery document and proof."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,50 @@
1
+ """What the bridge publishes at its well-known address, and what an engine registers as and is
2
+ registered with."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from uuid import UUID
7
+
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+ WELL_KNOWN = "/.well-known/ecosemantic-bridge"
11
+
12
+
13
+ class _Document(BaseModel):
14
+ """One document of the bridge's discovery, frozen: what the bridge answers and what an engine
15
+ sends it."""
16
+
17
+ model_config = ConfigDict(frozen=True)
18
+
19
+
20
+ class BridgeConfiguration(_Document):
21
+ """The bridge's discovery document: the issuer, the browser's and the device's client ids, the
22
+ tunnel's resource, scope and address, and the connector's and the registration's addresses."""
23
+
24
+ issuer: str
25
+ browser_client_id: str
26
+ device_client_id: str
27
+ tunnel_resource: str
28
+ tunnel_scope: str
29
+ tunnel_url: str
30
+ connector_url: str
31
+ registration_url: str
32
+
33
+
34
+ class Registration(_Document):
35
+ """What an engine registers: itself when it has an id, the name it answers to and its
36
+ version."""
37
+
38
+ engine: UUID | None
39
+ name: str
40
+ version: str
41
+
42
+
43
+ class Registered(_Document):
44
+ """What the bridge answers a registration with: the engine, its account, its handle and the
45
+ page's address."""
46
+
47
+ engine: UUID
48
+ account: str
49
+ handle: str
50
+ page_url: str
@@ -0,0 +1,11 @@
1
+ """The relay's typed errors: text, a frame or a proof it cannot accept."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RelayError(ValueError):
7
+ """The tunnel carries something the protocol does not define, or not the thing it defines."""
8
+
9
+
10
+ class ProofRefused(RelayError):
11
+ """A bridged request's proof does not admit the request; the message names the fault."""
@@ -0,0 +1,45 @@
1
+ """The tunnel's binary frames: a stream's body in chunks of at most 64 KiB, each frame carrying its
2
+ stream and whether it ends it."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import struct
7
+ from dataclasses import dataclass
8
+
9
+ from ecosemantic.relay.errors import RelayError
10
+
11
+ CHUNK_BYTES = 65536
12
+ _HEADER = struct.Struct(">IB")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Chunk:
17
+ """One frame of a stream's body: its stream, its bytes, and whether it is the last."""
18
+
19
+ stream: int
20
+ data: bytes
21
+ end: bool
22
+
23
+
24
+ def pack(chunk: Chunk) -> bytes:
25
+ """The binary frame that carries the chunk: its stream, the bit that ends it, then its body."""
26
+ return _HEADER.pack(chunk.stream, 1 if chunk.end else 0) + chunk.data
27
+
28
+
29
+ def unpack(frame: bytes) -> Chunk:
30
+ """The chunk a binary frame carries, refused when the frame is shorter than its header."""
31
+ if len(frame) < _HEADER.size:
32
+ raise RelayError(
33
+ f"a frame of {len(frame)} bytes is shorter than its {_HEADER.size}-byte header"
34
+ )
35
+ stream, flags = _HEADER.unpack_from(frame)
36
+ return Chunk(stream=stream, data=frame[_HEADER.size :], end=bool(flags & 1))
37
+
38
+
39
+ def chunks(stream: int, data: bytes, end: bool) -> list[Chunk]:
40
+ """A stream's body split at 64 KiB, the last chunk alone carrying whether it ends the stream."""
41
+ pieces = [data[at : at + CHUNK_BYTES] for at in range(0, len(data), CHUNK_BYTES)] or [b""]
42
+ last = len(pieces) - 1
43
+ return [
44
+ Chunk(stream=stream, data=piece, end=end and at == last) for at, piece in enumerate(pieces)
45
+ ]
@@ -0,0 +1,102 @@
1
+ """The tunnel's text messages: the opening exchange, a request's and a response's start, and a
2
+ stream's reset, each carried as one JSON document."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from datetime import datetime
7
+ from typing import Annotated, Literal
8
+ from uuid import UUID
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
11
+
12
+ from ecosemantic.relay.errors import RelayError
13
+
14
+ PROTOCOL = 1
15
+ Door = Literal["mcp", "page"]
16
+ ResetReason = Literal["cancelled", "failed"]
17
+ Header = tuple[str, str]
18
+
19
+
20
+ class _Message(BaseModel):
21
+ """One message of the tunnel, frozen: what a text frame carries."""
22
+
23
+ model_config = ConfigDict(frozen=True)
24
+
25
+
26
+ class Hello(_Message):
27
+ """The engine opens the tunnel: the protocol it speaks, itself and the version it runs."""
28
+
29
+ type: Literal["hello"] = "hello"
30
+ protocol: int
31
+ engine: UUID
32
+ version: str
33
+
34
+
35
+ class Welcome(_Message):
36
+ """The bridge answers: the protocol, the engine and its account, the handle it answers to, the
37
+ moment of the exchange and the keys a bridged request's proof is checked against."""
38
+
39
+ type: Literal["welcome"] = "welcome"
40
+ protocol: int
41
+ engine: UUID
42
+ account: str
43
+ handle: str
44
+ now: datetime
45
+ keys: list[dict[str, str]]
46
+
47
+
48
+ class RequestStart(_Message):
49
+ """A request opens a stream: its door, its method, path and query, its headers and the proof
50
+ that admits it."""
51
+
52
+ type: Literal["request"] = "request"
53
+ stream: int = Field(ge=1)
54
+ door: Door
55
+ method: str
56
+ path: str
57
+ query: str
58
+ headers: list[Header]
59
+ proof: str
60
+
61
+
62
+ class ResponseStart(_Message):
63
+ """The engine answers a stream: the status and headers of the response its body follows."""
64
+
65
+ type: Literal["response"] = "response"
66
+ stream: int
67
+ status: int
68
+ headers: list[Header]
69
+
70
+
71
+ class Reset(_Message):
72
+ """A stream ends without its answer: the reason it was cancelled or failed."""
73
+
74
+ type: Literal["reset"] = "reset"
75
+ stream: int
76
+ reason: ResetReason
77
+
78
+
79
+ Message = Annotated[
80
+ Hello | Welcome | RequestStart | ResponseStart | Reset, Field(discriminator="type")
81
+ ]
82
+
83
+ _ADAPTER: TypeAdapter[Message] = TypeAdapter(Message)
84
+
85
+
86
+ def encode(message: Message) -> str:
87
+ """The text frame that carries the message."""
88
+ return _ADAPTER.dump_json(message).decode("utf-8")
89
+
90
+
91
+ def decode(text: str) -> Message:
92
+ """The message a text frame carries, refused when the text is no message of this protocol."""
93
+ try:
94
+ return _ADAPTER.validate_json(text)
95
+ except ValidationError as error:
96
+ raise RelayError(f"no message of protocol {PROTOCOL} in {text!r}: {error}") from error
97
+
98
+
99
+ def door_of(path: str) -> Door:
100
+ """The door a request's path belongs to: the chats' for `/mcp` and its subpaths, the page's for
101
+ every other path."""
102
+ return "mcp" if path == "/mcp" or path.startswith("/mcp/") else "page"
@@ -0,0 +1,154 @@
1
+ """The Ed25519 proof a bridged request carries, and the JWKs the bridge's welcome publishes to check
2
+ it."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from uuid import UUID, uuid4
11
+
12
+ import jwt
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
14
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
15
+ from jwt.types import Options
16
+
17
+ from ecosemantic.relay.errors import ProofRefused
18
+ from ecosemantic.relay.messages import Door
19
+
20
+ PROOF_SECONDS = 60
21
+ _ALGORITHM = "EdDSA"
22
+ _CLAIMS: Options = {
23
+ "verify_exp": False,
24
+ "verify_aud": False,
25
+ "verify_iss": False,
26
+ "verify_jti": False,
27
+ }
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Proof:
32
+ """What a verified proof admits: the account, the engine, the door and its expiry."""
33
+
34
+ account: str
35
+ engine: UUID
36
+ door: Door
37
+ expires: datetime
38
+
39
+
40
+ def public_jwk(key: Ed25519PublicKey, kid: str) -> dict[str, str]:
41
+ """The JWK a welcome carries for this key: its raw public bytes under the key's id."""
42
+ raw = key.public_bytes(encoding=Encoding.Raw, format=PublicFormat.Raw)
43
+ return {
44
+ "kty": "OKP",
45
+ "crv": "Ed25519",
46
+ "x": base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii"),
47
+ "kid": kid,
48
+ "alg": _ALGORITHM,
49
+ "use": "sig",
50
+ }
51
+
52
+
53
+ def mint_proof(
54
+ key: Ed25519PrivateKey,
55
+ kid: str,
56
+ issuer: str,
57
+ account: str,
58
+ engine: UUID,
59
+ door: Door,
60
+ now: datetime,
61
+ ) -> str:
62
+ """The token that admits one bridged request: the account, the engine and the door it names,
63
+ its own id, expiring 60 seconds after `now`."""
64
+ issued = int(now.timestamp())
65
+ return jwt.encode(
66
+ {
67
+ "iss": issuer,
68
+ "sub": account,
69
+ "aud": str(engine),
70
+ "door": door,
71
+ "iat": issued,
72
+ "exp": issued + PROOF_SECONDS,
73
+ "jti": uuid4().hex,
74
+ },
75
+ key,
76
+ algorithm=_ALGORITHM,
77
+ headers={"kid": kid},
78
+ )
79
+
80
+
81
+ def verify_proof(
82
+ token: str,
83
+ keys: Sequence[Mapping[str, str]],
84
+ issuer: str,
85
+ account: str,
86
+ engine: UUID,
87
+ door: Door,
88
+ now: datetime,
89
+ ) -> Proof:
90
+ """What the proof admits. Refused when no key of the welcome carries the id it names, when its
91
+ signature is not that key's, when it names another issuer, account, engine or door, when it
92
+ carries no id of its own, or once `now` reaches its expiry."""
93
+ kid = _header(token).get("kid")
94
+ key = next((candidate for candidate in keys if candidate.get("kid") == kid), None)
95
+ if key is None:
96
+ raise ProofRefused(f"no key of the welcome carries the id {kid!r}")
97
+ claims = _claims(token, _public(key))
98
+ if claims.get("iss") != issuer:
99
+ raise ProofRefused(f"the proof is issued by {claims.get('iss')!r}, not {issuer!r}")
100
+ if claims.get("sub") != account:
101
+ raise ProofRefused(f"the proof names the account {claims.get('sub')!r}, not {account!r}")
102
+ if claims.get("aud") != str(engine):
103
+ raise ProofRefused(f"the proof names the engine {claims.get('aud')!r}, not {engine}")
104
+ if claims.get("door") != door:
105
+ raise ProofRefused(f"the proof names the door {claims.get('door')!r}, not {door!r}")
106
+ jti = claims.get("jti")
107
+ if not isinstance(jti, str) or not jti:
108
+ raise ProofRefused(f"the proof carries no jti, only {jti!r}")
109
+ expires = _moment(claims, "exp")
110
+ if expires <= now:
111
+ raise ProofRefused(
112
+ f"the proof expires at {expires.isoformat()}, not after {now.isoformat()}"
113
+ )
114
+ return Proof(account=account, engine=engine, door=door, expires=expires)
115
+
116
+
117
+ def _header(token: str) -> Mapping[str, object]:
118
+ """The unverified header of a token, refused when the text is no token."""
119
+ try:
120
+ return jwt.get_unverified_header(token)
121
+ except jwt.InvalidTokenError as error:
122
+ raise ProofRefused(f"the proof is no token: {error}") from error
123
+
124
+
125
+ def _public(key: Mapping[str, str]) -> Ed25519PublicKey:
126
+ """The Ed25519 key a JWK of the welcome carries, refused when it carries none."""
127
+ try:
128
+ return Ed25519PublicKey.from_public_bytes(_raw(key["x"]))
129
+ except (KeyError, ValueError) as error:
130
+ raise ProofRefused(f"the key {key.get('kid')!r} is no Ed25519 public key") from error
131
+
132
+
133
+ def _raw(encoded: str) -> bytes:
134
+ """The bytes a base64url value of a JWK stands for."""
135
+ return base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
136
+
137
+
138
+ def _claims(token: str, key: Ed25519PublicKey) -> Mapping[str, object]:
139
+ """What a token claims once its signature is the key's; the clock and the claims it names are
140
+ checked by the caller."""
141
+ try:
142
+ return jwt.decode(token, key, algorithms=[_ALGORITHM], options=_CLAIMS)
143
+ except jwt.InvalidSignatureError as error:
144
+ raise ProofRefused("the signature is not the key's") from error
145
+ except jwt.InvalidTokenError as error:
146
+ raise ProofRefused(f"the proof cannot be read: {error}") from error
147
+
148
+
149
+ def _moment(claims: Mapping[str, object], name: str) -> datetime:
150
+ """The moment a claim carries, in seconds, refused when the claim carries none."""
151
+ value = claims.get(name)
152
+ if not isinstance(value, int | float) or isinstance(value, bool):
153
+ raise ProofRefused(f"the proof carries no numeric {name}")
154
+ return datetime.fromtimestamp(value, UTC)
File without changes
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "ecosemantic-relay"
3
+ version = "0.1.14"
4
+ description = "The EcoSemantic bridge tunnel: its messages, its frames, the bridge's discovery document and the Ed25519 proof a bridged request carries."
5
+ requires-python = ">=3.12"
6
+ license = "PolyForm-Noncommercial-1.0.0"
7
+ license-files = ["LICENSE", "NOTICE"]
8
+ readme = "README.md"
9
+ authors = [{ name = "Carlos Gaete-Morales", email = "cdgaete@gmail.com" }]
10
+ keywords = ["life cycle assessment", "LCA", "bridge", "tunnel", "Ed25519"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Science/Research",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Scientific/Engineering",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "pydantic>=2.7,<3",
24
+ "pyjwt[crypto]>=2.10,<3",
25
+ "cryptography>=44,<51",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/cdgaete/ecosemantic"
30
+ Repository = "https://github.com/cdgaete/ecosemantic"
31
+ Issues = "https://github.com/cdgaete/ecosemantic/issues"
32
+ Changelog = "https://github.com/cdgaete/ecosemantic/blob/main/CHANGELOG.md"
33
+
34
+ [build-system]
35
+ requires = ["hatchling>=1.27"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["ecosemantic"]