graphty 0.1.0__tar.gz → 0.2.0__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.
graphty-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.3
2
+ Name: graphty
3
+ Version: 0.2.0
4
+ Summary: Add your description here
5
+ Author: Lukas Plank
6
+ Author-email: Lukas Plank <lupl@tuta.io>
7
+ Requires-Dist: polars>=1.39.3
8
+ Requires-Dist: pydantic>=2.12.5
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+
12
+ <img src="graphty.svg" width="50%" height="50%" />
13
+
14
+ #
15
+ ![tests](https://github.com/lu-pl/graphty/actions/workflows/tests.yaml/badge.svg)
16
+ [![coverage](https://coveralls.io/repos/github/lu-pl/graphty/badge.svg?branch=lupl/actions)](https://coveralls.io/github/lu-pl/graphty?branch=lupl/actions)
17
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
18
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
19
+
20
+
21
+ Typed object graph materializer over relational binding sets.
22
+
23
+ > WARNING: This project is in an early stage of development and should be used with caution.
24
+
25
+ ## Introduction
26
+
27
+ [Intro]
28
+
29
+ ## Installation
30
+
31
+ [Installation]
@@ -0,0 +1,20 @@
1
+ <img src="graphty.svg" width="50%" height="50%" />
2
+
3
+ #
4
+ ![tests](https://github.com/lu-pl/graphty/actions/workflows/tests.yaml/badge.svg)
5
+ [![coverage](https://coveralls.io/repos/github/lu-pl/graphty/badge.svg?branch=lupl/actions)](https://coveralls.io/github/lu-pl/graphty?branch=lupl/actions)
6
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
7
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
8
+
9
+
10
+ Typed object graph materializer over relational binding sets.
11
+
12
+ > WARNING: This project is in an early stage of development and should be used with caution.
13
+
14
+ ## Introduction
15
+
16
+ [Intro]
17
+
18
+ ## Installation
19
+
20
+ [Installation]
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "graphty"
3
+ version = "0.2.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Lukas Plank", email = "lupl@tuta.io" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "polars>=1.39.3",
12
+ "pydantic>=2.12.5",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.9.30,<0.10.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=9.0.3",
22
+ "pytest-cov>=7.1.0",
23
+ "pytest-randomly>=4.1.0",
24
+ "ruff>=0.15.17",
25
+ ]
26
+
27
+ [tool.pytest.ini_options]
28
+ addopts = [
29
+ "--import-mode=importlib",
30
+ ]
@@ -0,0 +1,2 @@
1
+ # Automatically created by ruff.
2
+ *
@@ -0,0 +1 @@
1
+ Signature: 8a477f597d28d172789f06886806bc55
@@ -0,0 +1,323 @@
1
+ from collections.abc import Callable, Iterable, Iterator
2
+ from dataclasses import dataclass
3
+ from functools import cached_property, reduce
4
+ from itertools import chain
5
+ from types import UnionType
6
+ from typing import Annotated, cast, get_args, get_origin
7
+
8
+ import polars as pl
9
+ from pydantic import BaseModel, Discriminator, Tag
10
+ from pydantic import ConfigDict as PydanticConfigDict
11
+ from pydantic.fields import FieldInfo
12
+ from typing_extensions import TypeForm, get_annotations
13
+
14
+ from graphty.utils.type_utils import (
15
+ de_annotate,
16
+ is_parametrized_list_static_type,
17
+ is_pydantic_model_static_type,
18
+ is_pydantic_model_union_static_type,
19
+ is_structured_field_static_type,
20
+ )
21
+
22
+
23
+ def get_model_projection(model: type[BaseModel]) -> set[str]:
24
+ return {
25
+ field_name
26
+ for field_name, field_info in model.model_fields.items()
27
+ if not is_structured_field_static_type(field_info.annotation)
28
+ }
29
+
30
+
31
+ class ConfigDict(PydanticConfigDict):
32
+ group_by: str
33
+
34
+
35
+ @dataclass
36
+ class Agg:
37
+ """Configuration class for aggregation fields.
38
+
39
+ The class allows to configure aggregation behavior for
40
+ model fields with aggretation targets:
41
+
42
+ E.g. `field: typing.Annotated[list[int], Agg(unique=False)]`
43
+ will aggregate non-unique values of a given partition into `field`.
44
+
45
+ Note: This is a draft and likely subject of breaking API changes.
46
+ """
47
+
48
+ unique: bool = True
49
+ drop_nulls: bool = True
50
+
51
+ def __iter__(self) -> Iterator[Callable[[pl.Expr], pl.Expr]]:
52
+ if self.unique:
53
+ yield lambda expr: expr.unique()
54
+ if self.drop_nulls:
55
+ yield lambda expr: expr.drop_nulls()
56
+
57
+ def apply_to(self, expr: pl.Expr) -> pl.Expr:
58
+ return reduce(lambda x, y: y(x), self, expr)
59
+
60
+
61
+ class ModelUnionDispatch:
62
+ def __init__(
63
+ self,
64
+ type_form: TypeForm,
65
+ base_cols: set[str],
66
+ discriminator: str | Callable | None,
67
+ ) -> None:
68
+ self.type_form = type_form
69
+ self.base_cols = base_cols
70
+ self.discriminator = discriminator
71
+
72
+ self.model_members: list[type[BaseModel]] = [
73
+ member
74
+ for member in get_args(de_annotate(self.type_form))
75
+ if is_pydantic_model_static_type(member)
76
+ ]
77
+ self.model_union_members: list[UnionType] = [
78
+ member
79
+ for member in get_args(de_annotate(self.type_form))
80
+ if is_pydantic_model_union_static_type(member)
81
+ ]
82
+
83
+ def compute_model_expr(self) -> pl.Expr:
84
+ match self.model_members, self.model_union_members:
85
+ case [model], []:
86
+ # TODO: this duplicates Exprs._struct_expr and should be abstracted
87
+ model_projection: set[str] = get_model_projection(model=model)
88
+ return pl.struct(model_projection).struct.with_fields(
89
+ *Exprs(model=model, base_cols=self.base_cols),
90
+ )
91
+
92
+ whens = self._compute_whens()
93
+ when, *rest_whens = whens
94
+
95
+ return reduce(
96
+ lambda x, y: y.otherwise(x),
97
+ rest_whens,
98
+ when.otherwise(None),
99
+ )
100
+
101
+ def _compute_whens(self) -> list["pl.When"]:
102
+ return [
103
+ *self._compute_model_whens(),
104
+ *self._compute_model_union_whens(),
105
+ ]
106
+
107
+ def _compute_model_whens(self) -> list["pl.When"]:
108
+ if not self.model_members:
109
+ return []
110
+
111
+ union_projection: set[str] = reduce(
112
+ set.union,
113
+ [get_model_projection(member) for member in self.model_members],
114
+ )
115
+
116
+ discriminator: Discriminator = self._resolve_discriminator()
117
+ discriminator_value: str | Callable = discriminator.discriminator
118
+
119
+ match discriminator_value:
120
+ case str():
121
+ discriminator_mapping: dict[tuple[str, ...], type[BaseModel]] = {
122
+ get_args(model.model_fields[discriminator_value].annotation): model
123
+ for model in self.model_members
124
+ }
125
+
126
+ return [
127
+ pl.when(pl.col(discriminator_value).is_in(list(k))).then(
128
+ pl.struct(union_projection).struct.with_fields(
129
+ *Exprs(model=v, base_cols=self.base_cols),
130
+ )
131
+ )
132
+ for k, v in discriminator_mapping.items()
133
+ ]
134
+
135
+ case Callable():
136
+ tag_mapping: dict[str, type[BaseModel]] = self._get_tag_mapping()
137
+
138
+ discriminator_expression = pl.struct(union_projection).map_elements(
139
+ function=discriminator_value,
140
+ return_dtype=pl.String,
141
+ )
142
+
143
+ return [
144
+ pl.when(discriminator_expression == k).then(
145
+ pl.struct(union_projection).struct.with_fields(
146
+ *Exprs(model=v, base_cols=self.base_cols),
147
+ )
148
+ )
149
+ for k, v in tag_mapping.items()
150
+ ]
151
+
152
+ case _:
153
+ assert False, "Expected discriminator to be of type str | Callable."
154
+
155
+ def _compute_model_union_whens(self) -> list["pl.When"]:
156
+ return list(
157
+ chain.from_iterable(
158
+ ModelUnionDispatch(
159
+ type_form=cast(TypeForm, type_form),
160
+ base_cols=self.base_cols,
161
+ discriminator=None,
162
+ )._compute_whens()
163
+ for type_form in self.model_union_members
164
+ )
165
+ )
166
+
167
+ def _resolve_discriminator(self) -> Discriminator:
168
+ if self.discriminator is not None:
169
+ return Discriminator(discriminator=self.discriminator)
170
+
171
+ args = (
172
+ get_args(self.type_form) if get_origin(self.type_form) is Annotated else []
173
+ )
174
+
175
+ for arg in args:
176
+ match arg:
177
+ case FieldInfo(discriminator=discriminator):
178
+ assert discriminator is not None, (
179
+ "Expected discriminator to be non-None."
180
+ )
181
+ return (
182
+ discriminator
183
+ if isinstance(discriminator, Discriminator)
184
+ else Discriminator(discriminator=discriminator)
185
+ )
186
+
187
+ case Discriminator():
188
+ return arg
189
+
190
+ msg = (
191
+ "Multi-Model unions must be discriminated unions. "
192
+ f"Unable to extract discriminator for type form '{self.type_form}'."
193
+ )
194
+ raise ValueError(msg)
195
+
196
+ # TODO: this needs to be more defensive and raise a clear exception in case Tags cannot be retrieved
197
+ def _get_tag_mapping(self):
198
+ """Prototype; this needs proper abstraction."""
199
+
200
+ def _generate():
201
+ for type_form in get_args(de_annotate(self.type_form)):
202
+ model, *rest = get_args(type_form)
203
+ tag = next(member for member in rest if isinstance(member, Tag))
204
+
205
+ yield tag.tag, model
206
+
207
+ return dict(_generate())
208
+
209
+
210
+ class Exprs(Iterable[pl.Expr]):
211
+ def __init__(
212
+ self, model: type[BaseModel], base_cols: set[str], group_context: bool = False
213
+ ) -> None:
214
+ self.model = model
215
+ self.base_cols = base_cols
216
+ self.group_context = group_context
217
+
218
+ def __iter__(self) -> Iterator[pl.Expr]:
219
+ for field_name, field_info in self.model.model_fields.items():
220
+ annotation = cast(TypeForm, field_info.annotation)
221
+
222
+ if is_pydantic_model_static_type(annotation):
223
+ expr: pl.Expr = self._struct_expr(model=annotation).alias(field_name)
224
+ yield (expr.first() if self.group_context else expr)
225
+
226
+ elif is_pydantic_model_union_static_type(annotation):
227
+ expr: pl.Expr = (
228
+ ModelUnionDispatch(
229
+ type_form=get_annotations(self.model)[
230
+ field_name
231
+ ], # pass full TypeForm for discriminator resolution
232
+ base_cols=self.base_cols,
233
+ discriminator=field_info.discriminator,
234
+ )
235
+ .compute_model_expr()
236
+ .alias(field_name)
237
+ )
238
+ yield (expr.first() if self.group_context else expr)
239
+
240
+ elif is_parametrized_list_static_type(annotation):
241
+ (item_annotation,) = get_args(annotation)
242
+
243
+ partition_value: str = self._get_partition_value(model=self.model)
244
+ agg: Agg = self._get_agg(field_info)
245
+
246
+ if is_pydantic_model_static_type(item_annotation):
247
+ inner: pl.Expr = self._struct_expr(model=item_annotation).alias(
248
+ field_name
249
+ )
250
+ elif is_pydantic_model_union_static_type(item_annotation):
251
+ inner = (
252
+ ModelUnionDispatch(
253
+ type_form=item_annotation, # pyright: ignore
254
+ base_cols=self.base_cols,
255
+ discriminator=field_info.discriminator,
256
+ )
257
+ .compute_model_expr()
258
+ .alias(field_name)
259
+ )
260
+ else:
261
+ inner: pl.Expr = pl.col(field_name)
262
+
263
+ expr: pl.Expr = agg.apply_to(inner)
264
+ yield (
265
+ expr
266
+ if self.group_context
267
+ else expr.implode().over(partition_by=partition_value)
268
+ )
269
+
270
+ def _struct_expr(self, model: type[BaseModel]) -> pl.Expr:
271
+ model_projection: set[str] = get_model_projection(model=model)
272
+ exprs: chain[pl.Expr] = chain(
273
+ [pl.col(member) for member in model_projection],
274
+ Exprs(model=model, base_cols=self.base_cols),
275
+ )
276
+ return pl.struct(*exprs)
277
+
278
+ @staticmethod
279
+ def _get_partition_value(model: type[BaseModel]) -> str:
280
+ try:
281
+ partition_value = model.model_config["group_by"] # type: ignore
282
+ except KeyError:
283
+ raise Exception(
284
+ f"Model '{model.__name__}' with aggregation target "
285
+ "does not specify ConfigDict.group_by."
286
+ )
287
+ else:
288
+ return partition_value
289
+
290
+ @staticmethod
291
+ def _get_agg(field_info: FieldInfo) -> Agg:
292
+ agg: Agg | None = next(
293
+ (entry for entry in field_info.metadata if isinstance(entry, Agg)), None
294
+ )
295
+ return agg or Agg()
296
+
297
+
298
+ class LazyFramePlanner:
299
+ def __init__(
300
+ self, model: type[BaseModel], data: pl._typing.FrameInitTypes | pl.LazyFrame
301
+ ) -> None:
302
+ self.model = model
303
+ self.lazy_frame: pl.LazyFrame = (
304
+ data if isinstance(data, pl.LazyFrame) else pl.LazyFrame(data=data)
305
+ )
306
+
307
+ def run(self) -> pl.LazyFrame:
308
+ group_by: str | None = self.model.model_config.get("group_by")
309
+ model_projection: set[str] = get_model_projection(self.model)
310
+
311
+ if group_by is None:
312
+ return self.lazy_frame.with_columns(
313
+ *Exprs(model=self.model, base_cols=self._base_cols)
314
+ ).drop(self._base_cols.difference(model_projection))
315
+
316
+ return self.lazy_frame.group_by(group_by, maintain_order=True).agg(
317
+ *[pl.col(col).first() for col in model_projection.difference({group_by})],
318
+ *Exprs(model=self.model, base_cols=self._base_cols, group_context=True),
319
+ )
320
+
321
+ @cached_property
322
+ def _base_cols(self) -> set[str]:
323
+ return set(self.lazy_frame.collect_schema().names())
File without changes
@@ -0,0 +1,2 @@
1
+ # Automatically created by ruff.
2
+ *
@@ -0,0 +1 @@
1
+ Signature: 8a477f597d28d172789f06886806bc55
@@ -0,0 +1,135 @@
1
+ from collections import UserDict
2
+ from collections.abc import Callable, Iterator
3
+ from types import resolve_bases
4
+
5
+ from pydantic import AliasChoices, BaseModel
6
+ from pydantic.fields import FieldInfo
7
+
8
+
9
+ class AliasMap(UserDict):
10
+ """Custom mapping for resolving Pydantic field validation aliases.
11
+
12
+ E.g. for a given model
13
+
14
+ class Point(BaseModel):
15
+ a: int = Field(alias="x")
16
+ y: int
17
+
18
+ the mapping would compute to {"a": "x", "y": y}.
19
+
20
+ Alias choices, alias generators and alias priority are supported;
21
+ alias choices are resolved against a projection.
22
+
23
+ Note that AliasPath is currently not supported.
24
+ """
25
+
26
+ def __init__(self, model: type[BaseModel], projection: set[str]) -> None:
27
+ self.model = model
28
+ self.projection = projection
29
+
30
+ self.data = dict(self._generate_alias_map())
31
+
32
+ def _generate_alias_map(self) -> Iterator[tuple[str, str]]:
33
+ """Generate an alias mapping.
34
+
35
+ For every field in a given model, the generator resolves aliases
36
+ and yields 2-tuples representing key/value pairs.
37
+ """
38
+
39
+ alias_resolver: Callable[[str, FieldInfo], list[str]] = (
40
+ self._get_alias_resolver()
41
+ )
42
+
43
+ for k, v in self.model.model_fields.items():
44
+ aliases: list[str] = alias_resolver(k, v)
45
+
46
+ match aliases:
47
+ case []:
48
+ yield k, k
49
+ case [alias]:
50
+ yield (k, alias)
51
+ case [_alias, *_aliases] as aliases:
52
+ alias: str | None = next(
53
+ (i for i in aliases if i in self.projection), None
54
+ )
55
+
56
+ if alias is None:
57
+ msg = (
58
+ f"Unable to resolve AliasChoice for field '{k}' of model '{self.model.__name__}'.\n"
59
+ f"None of computed aliases '{aliases}' in projection '{self.projection}'."
60
+ )
61
+ raise ValueError(msg)
62
+
63
+ yield (k, alias)
64
+ case _: # pragma: no cover; unreachable
65
+ assert False, "This should never happen."
66
+
67
+ def _get_alias_resolver(self) -> Callable[[str, FieldInfo], list[str]]:
68
+ """Helper for acquiring an alias resolver.
69
+
70
+ An alias resolver computes alias candidates given a field name and
71
+ FieldInfo object and according to validate_by_name/validate_by_alias flags.
72
+
73
+ Note that the deprecated populate_by_name flag is not supported.
74
+ """
75
+
76
+ model_config = self.model.model_config
77
+
78
+ if model_config.get("populate_by_name") is not None:
79
+ msg = (
80
+ "Config option 'populate_by_name' is not supported. "
81
+ "Use Pydantic >=2.11 flags 'validate_by_name'/'validate_by_alias' instead."
82
+ )
83
+ raise ValueError(msg)
84
+
85
+ validate_by_name, validate_by_alias = (
86
+ model_config.get("validate_by_name", False),
87
+ model_config.get("validate_by_alias", True),
88
+ )
89
+
90
+ match validate_by_name, validate_by_alias:
91
+ case True, False: # TODO: trigger in tests
92
+ return lambda field_name, _: [field_name]
93
+ case False, True:
94
+ return lambda _, field_info: self._compute_alias_candidates(
95
+ field_info=field_info
96
+ )
97
+ case True, True: # TODO: trigger in tests
98
+ return lambda field_name, field_info: [
99
+ field_name,
100
+ *self._compute_alias_candidates(field_info=field_info),
101
+ ]
102
+ case (
103
+ False,
104
+ False,
105
+ ): # pragma: no cover; False, False raises error in Pydantic
106
+ msg = "Invalid config: validate_by_name=False, validate_by_alias=False."
107
+ raise ValueError(msg)
108
+ case _: # pragma: no cover
109
+ assert False, "This should never happen."
110
+
111
+ def _compute_alias_candidates(self, field_info: FieldInfo) -> list[str]:
112
+ if (validation_alias := field_info.validation_alias):
113
+ return self._resolve_alias(validation_alias)
114
+ return []
115
+
116
+ @staticmethod
117
+ def _resolve_alias(alias: str | AliasChoices) -> list[str]:
118
+ """Helper for resolving alias/validation_alias values in FieldInfo objects.
119
+
120
+ Note that pydantic.AliasPath is not meaningful in the context of flat relational binding mappings;
121
+ the method therefore raises an Exception for AliasPath and AliasChoices/AliasPath objects.
122
+ """
123
+ match alias:
124
+ case str():
125
+ return [alias]
126
+ case AliasChoices(choices=choices) if all(
127
+ isinstance(choice, str) for choice in choices
128
+ ):
129
+ return choices
130
+ case _:
131
+ msg = (
132
+ "Unable to resolve alias. "
133
+ f"Expected str or AliasChoices of str, got '{alias}'."
134
+ )
135
+ raise ValueError(msg)
@@ -0,0 +1,64 @@
1
+ import types
2
+ import typing
3
+ from typing import Annotated, TypeGuard, get_args, get_origin
4
+
5
+ from pydantic import BaseModel
6
+ from typing_extensions import TypeForm
7
+
8
+
9
+ def de_annotate(type_form: TypeForm) -> TypeForm:
10
+ """Unwrap potentially nested Annotated wrappers."""
11
+
12
+ if get_origin(type_form) is Annotated:
13
+ type_form, *_ = get_args(type_form)
14
+ return de_annotate(type_form)
15
+ return type_form
16
+
17
+
18
+ def is_pydantic_model_static_type(type_form: TypeForm) -> TypeGuard[type[BaseModel]]:
19
+ """Check if type_form denotes a Pydantic model type."""
20
+ type_form = de_annotate(type_form)
21
+
22
+ return (
23
+ isinstance(type_form, type)
24
+ and issubclass(type_form, BaseModel)
25
+ and (type_form is not BaseModel)
26
+ )
27
+
28
+
29
+ def is_parametrized_list_static_type(type_form: TypeForm) -> TypeGuard[type[list]]:
30
+ """Check if type_form denotes a parametrized list type."""
31
+ type_form = de_annotate(type_form)
32
+ return get_origin(type_form) is list
33
+
34
+
35
+ def is_pydantic_model_union_static_type(
36
+ type_form: TypeForm,
37
+ ) -> TypeGuard[types.UnionType]:
38
+ """Check if type_form denotes a union type of a Pydantic model."""
39
+
40
+ type_form = de_annotate(type_form)
41
+
42
+ is_union_type: bool = get_origin(type_form) in (types.UnionType, typing.Union)
43
+ has_any_model: bool = any(
44
+ is_pydantic_model_static_type(obj) or is_pydantic_model_union_static_type(obj)
45
+ for obj in get_args(type_form)
46
+ )
47
+
48
+ return is_union_type and has_any_model
49
+
50
+
51
+ def is_structured_field_static_type(type_form: TypeForm) -> bool:
52
+ """Check if type_form denotes a structured field type.
53
+
54
+ A structured field type is a type that triggers a recursion
55
+ and/or aggregation code path in the GraphTy planner.
56
+ """
57
+ return any(
58
+ predicate(type_form)
59
+ for predicate in [
60
+ is_pydantic_model_static_type,
61
+ is_pydantic_model_union_static_type,
62
+ is_parametrized_list_static_type,
63
+ ]
64
+ )
graphty-0.1.0/.gitignore DELETED
@@ -1,10 +0,0 @@
1
- # Python-generated files
2
- __pycache__/
3
- *.py[oc]
4
- build/
5
- dist/
6
- wheels/
7
- *.egg-info
8
-
9
- # Virtual environments
10
- .venv
@@ -1 +0,0 @@
1
- 3.13
graphty-0.1.0/PKG-INFO DELETED
@@ -1,11 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: graphty
3
- Version: 0.1.0
4
- Summary: Add your description here
5
- Author-email: Lukas Plank <lupl@tuta.io>
6
- Requires-Python: >=3.13
7
- Description-Content-Type: text/markdown
8
-
9
- # GraphTy
10
-
11
- Typed Views over RDF Graph data.
graphty-0.1.0/README.md DELETED
@@ -1,3 +0,0 @@
1
- # GraphTy
2
-
3
- Typed Views over RDF Graph data.
@@ -1,14 +0,0 @@
1
- [project]
2
- name = "graphty"
3
- version = "0.1.0"
4
- description = "Add your description here"
5
- readme = "README.md"
6
- authors = [
7
- { name = "Lukas Plank", email = "lupl@tuta.io" }
8
- ]
9
- requires-python = ">=3.13"
10
- dependencies = []
11
-
12
- [build-system]
13
- requires = ["hatchling"]
14
- build-backend = "hatchling.build"
@@ -1,2 +0,0 @@
1
- def hello() -> str:
2
- return "Hello from graphty!"