graphique 1.4__tar.gz → 1.6__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.
- {graphique-1.4 → graphique-1.6}/PKG-INFO +3 -4
- {graphique-1.4 → graphique-1.6}/README.md +0 -1
- {graphique-1.4 → graphique-1.6}/graphique/__init__.py +1 -1
- {graphique-1.4 → graphique-1.6}/graphique/core.py +19 -14
- {graphique-1.4 → graphique-1.6}/graphique/inputs.py +3 -2
- {graphique-1.4 → graphique-1.6}/graphique/interface.py +12 -9
- {graphique-1.4 → graphique-1.6}/graphique/middleware.py +4 -3
- {graphique-1.4 → graphique-1.6}/graphique/models.py +6 -6
- graphique-1.6/graphique/scalars.py +105 -0
- {graphique-1.4 → graphique-1.6}/graphique/service.py +2 -1
- {graphique-1.4 → graphique-1.6}/graphique/shell.py +27 -25
- {graphique-1.4 → graphique-1.6}/graphique.egg-info/PKG-INFO +3 -4
- {graphique-1.4 → graphique-1.6}/graphique.egg-info/SOURCES.txt +1 -0
- graphique-1.6/graphique.egg-info/requires.txt +8 -0
- {graphique-1.4 → graphique-1.6}/pyproject.toml +8 -7
- graphique-1.6/requirements.in +2 -0
- {graphique-1.4 → graphique-1.6}/tests/test_core.py +18 -0
- {graphique-1.4 → graphique-1.6}/tests/test_dataset.py +38 -59
- {graphique-1.4 → graphique-1.6}/tests/test_models.py +78 -119
- {graphique-1.4 → graphique-1.6}/tests/test_service.py +91 -123
- graphique-1.4/graphique/scalars.py +0 -64
- graphique-1.4/graphique.egg-info/requires.txt +0 -8
- {graphique-1.4 → graphique-1.6}/LICENSE.txt +0 -0
- {graphique-1.4 → graphique-1.6}/graphique/py.typed +0 -0
- {graphique-1.4 → graphique-1.6}/graphique.egg-info/dependency_links.txt +0 -0
- {graphique-1.4 → graphique-1.6}/graphique.egg-info/top_level.txt +0 -0
- {graphique-1.4 → graphique-1.6}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: graphique
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6
|
|
4
4
|
Summary: GraphQL service for arrow tables and parquet data sets.
|
|
5
5
|
Author-email: Aric Coady <aric.coady@gmail.com>
|
|
6
6
|
License: Copyright 2022 Aric Coady
|
|
@@ -40,8 +40,8 @@ Classifier: Typing :: Typed
|
|
|
40
40
|
Requires-Python: >=3.9
|
|
41
41
|
Description-Content-Type: text/markdown
|
|
42
42
|
License-File: LICENSE.txt
|
|
43
|
-
Requires-Dist: pyarrow>=
|
|
44
|
-
Requires-Dist: strawberry-graphql[asgi,cli]>=0.
|
|
43
|
+
Requires-Dist: pyarrow>=16
|
|
44
|
+
Requires-Dist: strawberry-graphql[asgi,cli]>=0.221
|
|
45
45
|
Provides-Extra: server
|
|
46
46
|
Requires-Dist: uvicorn[standard]; extra == "server"
|
|
47
47
|
Provides-Extra: cli
|
|
@@ -54,7 +54,6 @@ Requires-Dist: tqdm; extra == "cli"
|
|
|
54
54
|
[](https://github.com/coady/graphique/actions)
|
|
55
55
|
[](https://codecov.io/gh/coady/graphique/)
|
|
56
56
|
[](https://github.com/coady/graphique/security/code-scanning)
|
|
57
|
-
[](https://pypi.org/project/black/)
|
|
58
57
|
[](https://github.com/astral-sh/ruff)
|
|
59
58
|
[](https://mypy-lang.org/)
|
|
60
59
|
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
[](https://github.com/coady/graphique/actions)
|
|
6
6
|
[](https://codecov.io/gh/coady/graphique/)
|
|
7
7
|
[](https://github.com/coady/graphique/security/code-scanning)
|
|
8
|
-
[](https://pypi.org/project/black/)
|
|
9
8
|
[](https://github.com/astral-sh/ruff)
|
|
10
9
|
[](https://mypy-lang.org/)
|
|
11
10
|
|
|
@@ -4,6 +4,7 @@ Core utilities that add pandas-esque features to arrow arrays and tables.
|
|
|
4
4
|
Arrow forbids subclassing, so the classes are for logical grouping.
|
|
5
5
|
Their methods are called as functions.
|
|
6
6
|
"""
|
|
7
|
+
|
|
7
8
|
import bisect
|
|
8
9
|
import contextlib
|
|
9
10
|
import functools
|
|
@@ -14,7 +15,7 @@ import json
|
|
|
14
15
|
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
15
16
|
from dataclasses import dataclass
|
|
16
17
|
from typing import Optional, Union, get_type_hints
|
|
17
|
-
import numpy as np
|
|
18
|
+
import numpy as np
|
|
18
19
|
import pyarrow as pa
|
|
19
20
|
import pyarrow.acero as ac
|
|
20
21
|
import pyarrow.compute as pc
|
|
@@ -106,7 +107,10 @@ def register(func: Callable, kind: str = 'scalar') -> pc.Function:
|
|
|
106
107
|
|
|
107
108
|
@register
|
|
108
109
|
def digitize(
|
|
109
|
-
ctx,
|
|
110
|
+
ctx,
|
|
111
|
+
array: pa.float64(), # type: ignore
|
|
112
|
+
bins: pa.list_(pa.float64()), # type: ignore
|
|
113
|
+
right: pa.bool_(), # type: ignore
|
|
110
114
|
) -> pa.int64(): # type: ignore
|
|
111
115
|
"""Return the indices of the bins to which each value in input array belongs."""
|
|
112
116
|
return pa.array(np.digitize(array, bins.values, right.as_py()))
|
|
@@ -347,7 +351,7 @@ class Table(pa.Table):
|
|
|
347
351
|
"""Return table with union of columns."""
|
|
348
352
|
columns: dict = {}
|
|
349
353
|
for table in tables:
|
|
350
|
-
columns
|
|
354
|
+
columns |= Table.columns(table)
|
|
351
355
|
return type(tables[0]).from_pydict(columns)
|
|
352
356
|
|
|
353
357
|
def range(self, name: str, lower=None, upper=None, **includes) -> pa.Table:
|
|
@@ -398,10 +402,10 @@ class Table(pa.Table):
|
|
|
398
402
|
if the predicate has args, it will be called on the differences
|
|
399
403
|
"""
|
|
400
404
|
offsets = pa.chunked_array(
|
|
401
|
-
Column.run_offsets(self[name], *predicates.get(name, (
|
|
405
|
+
Column.run_offsets(self[name], *predicates.get(name, ()))
|
|
402
406
|
for name in names + tuple(predicates)
|
|
403
|
-
)
|
|
404
|
-
offsets = offsets.
|
|
407
|
+
)
|
|
408
|
+
offsets = offsets.unique().sort()
|
|
405
409
|
scalars = self.select(names).take(offsets[:-1])
|
|
406
410
|
lists = self.select(set(self.schema.names) - set(names))
|
|
407
411
|
table = Table.union(scalars, Table.from_offsets(lists, offsets))
|
|
@@ -419,19 +423,20 @@ class Table(pa.Table):
|
|
|
419
423
|
self = self.unify_dictionaries()
|
|
420
424
|
prefix = 'hash_' if names else ''
|
|
421
425
|
aggs = [agg.astuple(prefix + func) for func in funcs for agg in funcs[func]]
|
|
426
|
+
columns = list(names) + [agg[0] for agg in aggs]
|
|
422
427
|
if counts:
|
|
423
428
|
aggs.append(([], 'hash_count_all', None, counts))
|
|
424
|
-
return Declarations(self).aggregate(aggs, names).to_table()
|
|
429
|
+
return Declarations(self, columns).aggregate(aggs, names).to_table()
|
|
425
430
|
|
|
426
431
|
def aggregate(self, counts: str = '', **funcs: Sequence[Agg]) -> dict:
|
|
427
432
|
"""Return aggregated scalars as a row of data."""
|
|
428
433
|
row = {counts: len(self)} if counts else {}
|
|
429
434
|
for key in ('one', 'list', 'distinct'): # hash only functions
|
|
430
435
|
func, aggs = getattr(Agg, key), funcs.pop(key, [])
|
|
431
|
-
row
|
|
436
|
+
row |= {agg.alias: func(self[agg.name], **agg.options) for agg in aggs}
|
|
432
437
|
if funcs:
|
|
433
438
|
table = Table.group(self, **funcs) # type: ignore
|
|
434
|
-
row
|
|
439
|
+
row |= {name: table[name][0] for name in table.schema.names}
|
|
435
440
|
for name, value in row.items():
|
|
436
441
|
if isinstance(value, pa.ChunkedArray):
|
|
437
442
|
row[name] = value.combine_chunks()
|
|
@@ -464,7 +469,7 @@ class Table(pa.Table):
|
|
|
464
469
|
func = functools.partial(pc.sort_indices, null_placement=null_placement)
|
|
465
470
|
if length is not None and length < len(self):
|
|
466
471
|
func = functools.partial(pc.select_k_unstable, k=length)
|
|
467
|
-
keys = dict(map(sort_key, names))
|
|
472
|
+
keys = dict(map(sort_key, names))
|
|
468
473
|
table = pa.table({name: Column.sort_values(self[name]) for name in keys})
|
|
469
474
|
return func(table, sort_keys=keys.items()) if table else pa.array([], 'int64')
|
|
470
475
|
|
|
@@ -556,7 +561,7 @@ class Table(pa.Table):
|
|
|
556
561
|
dense: use dense rank; false indicates sorting
|
|
557
562
|
"""
|
|
558
563
|
schema = set(Table.fragment_keys(self))
|
|
559
|
-
keys = dict(itertools.takewhile(lambda key: key[0] in schema, map(sort_key, names)))
|
|
564
|
+
keys = dict(itertools.takewhile(lambda key: key[0] in schema, map(sort_key, names)))
|
|
560
565
|
if not keys:
|
|
561
566
|
return None, names
|
|
562
567
|
parts = []
|
|
@@ -614,7 +619,7 @@ class Table(pa.Table):
|
|
|
614
619
|
yield None
|
|
615
620
|
else:
|
|
616
621
|
row = {name: pa.repeat(self[name][index], count) for name in scalars}
|
|
617
|
-
row
|
|
622
|
+
row |= {name: self[name][index].values for name in lists}
|
|
618
623
|
yield pa.RecordBatch.from_pydict(row)
|
|
619
624
|
|
|
620
625
|
def size(self) -> str:
|
|
@@ -638,10 +643,10 @@ class Declarations(list):
|
|
|
638
643
|
'hashjoin': ac.HashJoinNodeOptions,
|
|
639
644
|
}
|
|
640
645
|
|
|
641
|
-
def __init__(self, source: Union[pa.Table, ds.Dataset]):
|
|
646
|
+
def __init__(self, source: Union[pa.Table, ds.Dataset], columns=None):
|
|
642
647
|
if isinstance(source, ds.Dataset):
|
|
643
648
|
expr = source._scan_options.get('filter')
|
|
644
|
-
self.scan(source, filter=expr)
|
|
649
|
+
self.scan(source, filter=expr, columns=columns)
|
|
645
650
|
if expr is not None:
|
|
646
651
|
self.filter(expr)
|
|
647
652
|
else:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
GraphQL input types.
|
|
3
3
|
"""
|
|
4
|
+
|
|
4
5
|
import functools
|
|
5
6
|
import inspect
|
|
6
7
|
import operator
|
|
@@ -376,11 +377,11 @@ class Expression:
|
|
|
376
377
|
fields.append(field)
|
|
377
378
|
for group in operator.attrgetter(*self.groups)(self):
|
|
378
379
|
if group is not UNSET:
|
|
379
|
-
fields += group.to_fields()
|
|
380
|
+
fields += group.to_fields()
|
|
380
381
|
for op in self.unaries:
|
|
381
382
|
expr = getattr(self, op)
|
|
382
383
|
if expr is not UNSET:
|
|
383
|
-
fields.append(self.getfunc(op)(expr.to_arrow()))
|
|
384
|
+
fields.append(self.getfunc(op)(expr.to_arrow()))
|
|
384
385
|
if not fields:
|
|
385
386
|
return None
|
|
386
387
|
if len(fields) > 1:
|
|
@@ -3,6 +3,7 @@ Primary Dataset interface.
|
|
|
3
3
|
|
|
4
4
|
Doesn't require knowledge of the schema.
|
|
5
5
|
"""
|
|
6
|
+
|
|
6
7
|
# mypy: disable-error-code=valid-type
|
|
7
8
|
import collections
|
|
8
9
|
import inspect
|
|
@@ -14,8 +15,8 @@ import pyarrow as pa
|
|
|
14
15
|
import pyarrow.compute as pc
|
|
15
16
|
import pyarrow.dataset as ds
|
|
16
17
|
import strawberry.asgi
|
|
18
|
+
from strawberry import Info
|
|
17
19
|
from strawberry.extensions.utils import get_path_from_info
|
|
18
|
-
from strawberry.types import Info
|
|
19
20
|
from typing_extensions import Self
|
|
20
21
|
from .core import Batch, Column as C, ListChunk, Table as T
|
|
21
22
|
from .inputs import CountAggregate, Cumulative, Diff, Expression, Field, Filter
|
|
@@ -234,7 +235,11 @@ class Dataset:
|
|
|
234
235
|
aggregate="aggregation functions applied to other columns",
|
|
235
236
|
)
|
|
236
237
|
def group(
|
|
237
|
-
self,
|
|
238
|
+
self,
|
|
239
|
+
info: Info,
|
|
240
|
+
by: list[str] = [],
|
|
241
|
+
counts: str = '',
|
|
242
|
+
aggregate: HashAggregates = {}, # type: ignore
|
|
238
243
|
) -> Self:
|
|
239
244
|
"""Return table grouped by columns.
|
|
240
245
|
|
|
@@ -277,11 +282,11 @@ class Dataset:
|
|
|
277
282
|
row = ds.get_partition_keys(fragment.partition_expression)
|
|
278
283
|
if projection:
|
|
279
284
|
table = fragment.to_table(columns=projection)
|
|
280
|
-
row
|
|
285
|
+
row |= T.aggregate(table, counts=counts, **aggs)
|
|
281
286
|
elif counts:
|
|
282
287
|
row[counts] = fragment.count_rows()
|
|
283
288
|
arrays = {name: value for name, value in row.items() if isinstance(value, pa.Array)}
|
|
284
|
-
row
|
|
289
|
+
row |= T.columns(pa.RecordBatch.from_pydict(arrays))
|
|
285
290
|
for name in row:
|
|
286
291
|
columns[name].append(row[name])
|
|
287
292
|
for name, values in columns.items():
|
|
@@ -289,7 +294,7 @@ class Dataset:
|
|
|
289
294
|
columns[name] = C.from_scalars(values)
|
|
290
295
|
elif isinstance(values[0], pa.Array):
|
|
291
296
|
columns[name] = ListChunk.from_scalars(values)
|
|
292
|
-
columns
|
|
297
|
+
columns |= {field.name: pa.array(columns[field.name], field.type) for field in schema}
|
|
293
298
|
return self.add_metric(info, pa.table(columns), mode='fragment')
|
|
294
299
|
|
|
295
300
|
@doc_field(
|
|
@@ -482,7 +487,7 @@ class Dataset:
|
|
|
482
487
|
def project(self, info: Info, columns: list[Projection]) -> dict:
|
|
483
488
|
"""Return projected columns, including all references from below fields."""
|
|
484
489
|
projection = {name: pc.field(name) for name in self.references(info, level=1)}
|
|
485
|
-
projection
|
|
490
|
+
projection |= {col.alias or '.'.join(col.name): col.to_arrow() for col in columns}
|
|
486
491
|
if '' in projection:
|
|
487
492
|
raise ValueError(f"projected columns need a name or alias: {projection['']}")
|
|
488
493
|
return projection
|
|
@@ -497,9 +502,7 @@ class Dataset:
|
|
|
497
502
|
return scanner
|
|
498
503
|
|
|
499
504
|
@doc_field(filter="selected rows", columns="projected columns")
|
|
500
|
-
def scan(
|
|
501
|
-
self, info: Info, filter: Expression = {}, columns: list[Projection] = [] # type: ignore
|
|
502
|
-
) -> Self:
|
|
505
|
+
def scan(self, info: Info, filter: Expression = {}, columns: list[Projection] = []) -> Self: # type: ignore
|
|
503
506
|
"""Select rows and project columns without memory usage."""
|
|
504
507
|
expr = filter.to_arrow()
|
|
505
508
|
if expr is not None and not columns and isinstance(self.table, ds.Dataset):
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
"""
|
|
2
2
|
ASGI GraphQL utilities.
|
|
3
3
|
"""
|
|
4
|
+
|
|
4
5
|
import warnings
|
|
5
6
|
from collections.abc import Iterable, Mapping
|
|
6
7
|
from datetime import timedelta
|
|
8
|
+
from keyword import iskeyword
|
|
7
9
|
from typing import Optional
|
|
8
10
|
import pyarrow.dataset as ds
|
|
9
11
|
import strawberry.asgi
|
|
10
|
-
from strawberry import UNSET
|
|
12
|
+
from strawberry import Info, UNSET
|
|
11
13
|
from strawberry.extensions import tracing
|
|
12
14
|
from strawberry.utils.str_converters import to_camel_case
|
|
13
|
-
from strawberry.types import Info
|
|
14
15
|
from .core import Column as C
|
|
15
16
|
from .inputs import Filter
|
|
16
17
|
from .interface import Dataset, Root
|
|
@@ -94,7 +95,7 @@ def implemented(root: Root, name: str = '', keys: Iterable = ()):
|
|
|
94
95
|
"""Return type which extends the Dataset interface with knowledge of the schema."""
|
|
95
96
|
schema = root.projected_schema if isinstance(root, ds.Scanner) else root.schema
|
|
96
97
|
types = {field.name: type_map[C.scalar_type(field).id] for field in schema}
|
|
97
|
-
types = {name: types[name] for name in types if name.isidentifier()}
|
|
98
|
+
types = {name: types[name] for name in types if name.isidentifier() and not iskeyword(name)}
|
|
98
99
|
if invalid := set(schema.names) - set(types):
|
|
99
100
|
warnings.warn(f'invalid field names: {invalid}')
|
|
100
101
|
prefix = to_camel_case(name.title())
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
GraphQL output types and resolvers.
|
|
3
3
|
"""
|
|
4
|
+
|
|
4
5
|
import collections
|
|
5
6
|
import functools
|
|
6
7
|
import inspect
|
|
@@ -12,11 +13,11 @@ from typing import Annotated, Generic, Optional, TypeVar, TYPE_CHECKING, get_arg
|
|
|
12
13
|
import pyarrow as pa
|
|
13
14
|
import pyarrow.compute as pc
|
|
14
15
|
import strawberry
|
|
16
|
+
from strawberry import Info
|
|
15
17
|
from strawberry.field import StrawberryField
|
|
16
|
-
from strawberry.types import Info
|
|
17
18
|
from .core import Column as C
|
|
18
19
|
from .inputs import links
|
|
19
|
-
from .scalars import
|
|
20
|
+
from .scalars import Long, scalar_map, type_map
|
|
20
21
|
|
|
21
22
|
if TYPE_CHECKING: # pragma: no cover
|
|
22
23
|
from .interface import Dataset
|
|
@@ -67,7 +68,7 @@ class Column:
|
|
|
67
68
|
if cls is Column:
|
|
68
69
|
return lambda cls: cls.register(*scalars) or cls
|
|
69
70
|
# strawberry#1921: scalar python names are prepended to column name
|
|
70
|
-
generic = cls
|
|
71
|
+
generic = issubclass(cls, Generic)
|
|
71
72
|
for scalar in scalars:
|
|
72
73
|
cls.registry[scalar] = cls[scalar_map.get(scalar, scalar)] if generic else cls
|
|
73
74
|
|
|
@@ -119,7 +120,7 @@ class Set(Generic[T]):
|
|
|
119
120
|
return self.array.to_pylist()
|
|
120
121
|
|
|
121
122
|
|
|
122
|
-
@Column.register(timedelta,
|
|
123
|
+
@Column.register(timedelta, pa.MonthDayNano)
|
|
123
124
|
@strawberry.type(name='Column', description="column of elapsed times")
|
|
124
125
|
class NominalColumn(Generic[T], Column):
|
|
125
126
|
values = doc_field(Set.values)
|
|
@@ -174,8 +175,7 @@ class OrdinalColumn(NominalColumn[T]):
|
|
|
174
175
|
|
|
175
176
|
@Column.register(str)
|
|
176
177
|
@strawberry.type(name='ingColumn', description="column of strings")
|
|
177
|
-
class StringColumn(OrdinalColumn[T]):
|
|
178
|
-
...
|
|
178
|
+
class StringColumn(OrdinalColumn[T]): ...
|
|
179
179
|
|
|
180
180
|
|
|
181
181
|
@strawberry.type
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GraphQL scalars.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import re
|
|
7
|
+
from datetime import date, datetime, time, timedelta
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import Union, no_type_check
|
|
10
|
+
import pyarrow as pa
|
|
11
|
+
import strawberry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_long(value) -> int:
|
|
15
|
+
if isinstance(value, int):
|
|
16
|
+
return value
|
|
17
|
+
raise TypeError(f"Long cannot represent value: {value}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@no_type_check
|
|
21
|
+
def parse_duration(value: str):
|
|
22
|
+
months = days = seconds = 0
|
|
23
|
+
d_val, _, t_val = value.partition('T')
|
|
24
|
+
parts = re.split(r'(-?\d+\.?\d*)', d_val.lower() + t_val)
|
|
25
|
+
if parts.pop(0) != 'p':
|
|
26
|
+
raise ValueError("Duration format must start with `P`")
|
|
27
|
+
for num, key in zip(parts[::2], parts[1::2]):
|
|
28
|
+
if n := float(num) if '.' in num else int(num):
|
|
29
|
+
if key in 'ym':
|
|
30
|
+
months += n * 12 if key == 'y' else n
|
|
31
|
+
elif key in 'wd':
|
|
32
|
+
days += n * 7 if key == 'w' else n
|
|
33
|
+
elif key in 'HMS':
|
|
34
|
+
seconds += n * {'H': 3600, 'M': 60, 'S': 1}[key]
|
|
35
|
+
else:
|
|
36
|
+
raise ValueError(f"Invalid duration field: {key.upper()}")
|
|
37
|
+
if months:
|
|
38
|
+
return pa.MonthDayNano([months, days, int(seconds * 1_000_000_000)])
|
|
39
|
+
return timedelta(days, seconds)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@functools.singledispatch
|
|
43
|
+
def duration_isoformat(td: timedelta) -> str:
|
|
44
|
+
days = f'{td.days}D' if td.days else ''
|
|
45
|
+
fraction = f'.{td.microseconds:06}' if td.microseconds else ''
|
|
46
|
+
return f'P{days}T{td.seconds}{fraction}S'
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@duration_isoformat.register
|
|
50
|
+
def _(mdn: pa.MonthDayNano) -> str:
|
|
51
|
+
months = f'{mdn.months}M' if mdn.months else ''
|
|
52
|
+
days = f'{mdn.days}D' if mdn.days else ''
|
|
53
|
+
seconds, nanoseconds = divmod(mdn.nanoseconds, 1_000_000_000)
|
|
54
|
+
fraction = f'.{nanoseconds:09}' if nanoseconds else ''
|
|
55
|
+
return f'P{months}{days}T{seconds}{fraction}S'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
Long = strawberry.scalar(int, name='Long', description="64-bit int", parse_value=parse_long)
|
|
59
|
+
Duration = strawberry.scalar(
|
|
60
|
+
Union[timedelta, pa.MonthDayNano],
|
|
61
|
+
name='Duration',
|
|
62
|
+
description="Duration (isoformat)",
|
|
63
|
+
specified_by_url="https://en.wikipedia.org/wiki/ISO_8601#Durations",
|
|
64
|
+
serialize=duration_isoformat,
|
|
65
|
+
parse_value=parse_duration,
|
|
66
|
+
)
|
|
67
|
+
scalar_map = {
|
|
68
|
+
bytes: strawberry.scalars.Base64,
|
|
69
|
+
dict: strawberry.scalars.JSON,
|
|
70
|
+
timedelta: Duration,
|
|
71
|
+
pa.MonthDayNano: Duration,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type_map = {
|
|
75
|
+
pa.lib.Type_BOOL: bool,
|
|
76
|
+
pa.lib.Type_UINT8: int,
|
|
77
|
+
pa.lib.Type_INT8: int,
|
|
78
|
+
pa.lib.Type_UINT16: int,
|
|
79
|
+
pa.lib.Type_INT16: int,
|
|
80
|
+
pa.lib.Type_UINT32: Long,
|
|
81
|
+
pa.lib.Type_INT32: int,
|
|
82
|
+
pa.lib.Type_UINT64: Long,
|
|
83
|
+
pa.lib.Type_INT64: Long,
|
|
84
|
+
pa.lib.Type_HALF_FLOAT: float,
|
|
85
|
+
pa.lib.Type_FLOAT: float,
|
|
86
|
+
pa.lib.Type_DOUBLE: float,
|
|
87
|
+
pa.lib.Type_DECIMAL128: Decimal,
|
|
88
|
+
pa.lib.Type_DECIMAL256: Decimal,
|
|
89
|
+
pa.lib.Type_DATE32: date,
|
|
90
|
+
pa.lib.Type_DATE64: date,
|
|
91
|
+
pa.lib.Type_TIMESTAMP: datetime,
|
|
92
|
+
pa.lib.Type_TIME32: time,
|
|
93
|
+
pa.lib.Type_TIME64: time,
|
|
94
|
+
pa.lib.Type_DURATION: timedelta,
|
|
95
|
+
pa.lib.Type_INTERVAL_MONTH_DAY_NANO: pa.MonthDayNano,
|
|
96
|
+
pa.lib.Type_BINARY: bytes,
|
|
97
|
+
pa.lib.Type_FIXED_SIZE_BINARY: bytes,
|
|
98
|
+
pa.lib.Type_LARGE_BINARY: bytes,
|
|
99
|
+
pa.lib.Type_STRING: str,
|
|
100
|
+
pa.lib.Type_LARGE_STRING: str,
|
|
101
|
+
pa.lib.Type_LIST: list,
|
|
102
|
+
pa.lib.Type_FIXED_SIZE_LIST: list,
|
|
103
|
+
pa.lib.Type_LARGE_LIST: list,
|
|
104
|
+
pa.lib.Type_STRUCT: dict,
|
|
105
|
+
}
|
|
@@ -6,6 +6,7 @@ Copy and customize as needed. Demonstrates:
|
|
|
6
6
|
* datasets, scanners, and tables
|
|
7
7
|
* filtering and projection
|
|
8
8
|
"""
|
|
9
|
+
|
|
9
10
|
import json
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
import pyarrow.dataset as ds
|
|
@@ -13,7 +14,7 @@ from starlette.config import Config
|
|
|
13
14
|
from graphique.inputs import Expression
|
|
14
15
|
from graphique import GraphQL
|
|
15
16
|
|
|
16
|
-
config = Config('.env')
|
|
17
|
+
config = Config('.env' if Path('.env').is_file() else None)
|
|
17
18
|
PARQUET_PATH = Path(config('PARQUET_PATH')).resolve()
|
|
18
19
|
FEDERATED = config('FEDERATED', default='')
|
|
19
20
|
DEBUG = config('DEBUG', cast=bool, default=False)
|
|
@@ -8,21 +8,24 @@ Second, the partitioned dataset is rewritten to merge parts. Often the built-in
|
|
|
8
8
|
sufficient once partitioned, but there is a `fragments` option to optimize for memory or show
|
|
9
9
|
progress on the second pass.
|
|
10
10
|
"""
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
import shutil
|
|
13
13
|
from pathlib import Path
|
|
14
|
+
from typing import Annotated
|
|
14
15
|
import pyarrow.dataset as ds
|
|
16
|
+
import typer # type: ignore
|
|
15
17
|
from tqdm import tqdm # type: ignore
|
|
16
18
|
|
|
17
19
|
|
|
20
|
+
def sort_key(name: str) -> tuple:
|
|
21
|
+
"""Parse sort order."""
|
|
22
|
+
return name.lstrip('-'), ('descending' if name.startswith('-') else 'ascending')
|
|
23
|
+
|
|
24
|
+
|
|
18
25
|
def write_batches(scanner: ds.Scanner, base_dir: str, *partitioning: str, **options):
|
|
19
26
|
"""Partition dataset by batches."""
|
|
20
|
-
options.update(
|
|
21
|
-
|
|
22
|
-
partitioning=partitioning,
|
|
23
|
-
partitioning_flavor=options.get('partitioning_flavor', 'hive'),
|
|
24
|
-
existing_data_behavior='overwrite_or_ignore',
|
|
25
|
-
)
|
|
27
|
+
options.update(format='parquet', partitioning=partitioning)
|
|
28
|
+
options.update(partitioning_flavor='hive', existing_data_behavior='overwrite_or_ignore')
|
|
26
29
|
with tqdm(total=scanner.count_rows(), desc="Batches") as pbar:
|
|
27
30
|
for index, batch in enumerate(scanner.to_batches()):
|
|
28
31
|
options['basename_template'] = f'part-{index}-{{i}}.parquet'
|
|
@@ -30,38 +33,37 @@ def write_batches(scanner: ds.Scanner, base_dir: str, *partitioning: str, **opti
|
|
|
30
33
|
pbar.update(len(batch))
|
|
31
34
|
|
|
32
35
|
|
|
33
|
-
def write_fragments(dataset: ds.Dataset, base_dir: str, **options):
|
|
36
|
+
def write_fragments(dataset: ds.Dataset, base_dir: str, sorting=(), **options):
|
|
34
37
|
"""Rewrite partition files by fragment to consolidate."""
|
|
38
|
+
options['format'] = 'parquet'
|
|
35
39
|
exprs = {Path(frag.path).parent: frag.partition_expression for frag in dataset.get_fragments()}
|
|
36
40
|
offset = len(dataset.partitioning.schema)
|
|
37
41
|
for path in tqdm(exprs, desc="Fragments"):
|
|
38
42
|
part_dir = Path(base_dir, *path.parts[-offset:])
|
|
39
|
-
|
|
43
|
+
part = dataset.filter(exprs[path])
|
|
44
|
+
ds.write_dataset(part.sort_by(sorting) if sorting else part, part_dir, **options)
|
|
40
45
|
|
|
41
46
|
|
|
42
47
|
def partition(
|
|
43
|
-
|
|
48
|
+
src: Annotated[str, typer.Argument(help="source path")],
|
|
49
|
+
dest: Annotated[str, typer.Argument(help="destination path")],
|
|
50
|
+
partitioning: Annotated[list[str], typer.Argument(help="partition keys")],
|
|
51
|
+
fragments: Annotated[bool, typer.Option(help="iterate over fragments")] = False,
|
|
52
|
+
sort: Annotated[list[str], typer.Option(help="sort keys; will load fragments")] = [],
|
|
44
53
|
):
|
|
45
54
|
"""Partition dataset by keys."""
|
|
46
|
-
temp = Path(
|
|
47
|
-
write_batches(
|
|
55
|
+
temp = Path(dest) / 'temp'
|
|
56
|
+
write_batches(ds.dataset(src, partitioning='hive'), str(temp), *partitioning)
|
|
48
57
|
dataset = ds.dataset(temp, partitioning='hive')
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
options = dict(partitioning_flavor='hive', existing_data_behavior='overwrite_or_ignore')
|
|
59
|
+
if fragments or sort:
|
|
60
|
+
write_fragments(dataset, dest, tuple(map(sort_key, sort)))
|
|
51
61
|
else:
|
|
52
|
-
options.update(partitioning_flavor='hive', existing_data_behavior='overwrite_or_ignore')
|
|
53
62
|
with tqdm(desc="Partitions"):
|
|
54
|
-
ds.write_dataset(dataset,
|
|
63
|
+
ds.write_dataset(dataset, dest, partitioning=partitioning, **options)
|
|
55
64
|
shutil.rmtree(temp)
|
|
56
65
|
|
|
57
66
|
|
|
58
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
59
|
-
parser.add_argument('src', help="source path")
|
|
60
|
-
parser.add_argument('dest', help="destination path")
|
|
61
|
-
parser.add_argument('partitioning', nargs='+', help="partition keys")
|
|
62
|
-
parser.add_argument('--fragments', action='store_true', help="iterate over fragments")
|
|
63
|
-
|
|
64
67
|
if __name__ == '__main__':
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
partition(dataset, args.dest, *args.partitioning, fragments=args.fragments)
|
|
68
|
+
partition.__doc__ = __doc__
|
|
69
|
+
typer.run(partition)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: graphique
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6
|
|
4
4
|
Summary: GraphQL service for arrow tables and parquet data sets.
|
|
5
5
|
Author-email: Aric Coady <aric.coady@gmail.com>
|
|
6
6
|
License: Copyright 2022 Aric Coady
|
|
@@ -40,8 +40,8 @@ Classifier: Typing :: Typed
|
|
|
40
40
|
Requires-Python: >=3.9
|
|
41
41
|
Description-Content-Type: text/markdown
|
|
42
42
|
License-File: LICENSE.txt
|
|
43
|
-
Requires-Dist: pyarrow>=
|
|
44
|
-
Requires-Dist: strawberry-graphql[asgi,cli]>=0.
|
|
43
|
+
Requires-Dist: pyarrow>=16
|
|
44
|
+
Requires-Dist: strawberry-graphql[asgi,cli]>=0.221
|
|
45
45
|
Provides-Extra: server
|
|
46
46
|
Requires-Dist: uvicorn[standard]; extra == "server"
|
|
47
47
|
Provides-Extra: cli
|
|
@@ -54,7 +54,6 @@ Requires-Dist: tqdm; extra == "cli"
|
|
|
54
54
|
[](https://github.com/coady/graphique/actions)
|
|
55
55
|
[](https://codecov.io/gh/coady/graphique/)
|
|
56
56
|
[](https://github.com/coady/graphique/security/code-scanning)
|
|
57
|
-
[](https://pypi.org/project/black/)
|
|
58
57
|
[](https://github.com/astral-sh/ruff)
|
|
59
58
|
[](https://mypy-lang.org/)
|
|
60
59
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphique"
|
|
7
|
-
dynamic = ["version"]
|
|
7
|
+
dynamic = ["version", "dependencies"]
|
|
8
8
|
description = "GraphQL service for arrow tables and parquet data sets."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -28,7 +28,6 @@ classifiers = [
|
|
|
28
28
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
29
|
"Typing :: Typed",
|
|
30
30
|
]
|
|
31
|
-
dependencies = ["pyarrow>=14", "strawberry-graphql[asgi,cli]>=0.198"]
|
|
32
31
|
|
|
33
32
|
[project.urls]
|
|
34
33
|
Homepage = "https://github.com/coady/graphique"
|
|
@@ -45,19 +44,21 @@ packages = ["graphique"]
|
|
|
45
44
|
|
|
46
45
|
[tool.setuptools.dynamic]
|
|
47
46
|
version = {attr = "graphique.__version__"}
|
|
47
|
+
dependencies = {file = "requirements.in"}
|
|
48
48
|
|
|
49
49
|
[tool.setuptools.package-data]
|
|
50
50
|
graphique = ["py.typed"]
|
|
51
51
|
|
|
52
|
-
[tool.
|
|
52
|
+
[tool.ruff]
|
|
53
53
|
line-length = 100
|
|
54
|
-
|
|
54
|
+
extend-include = ["*.ipynb"]
|
|
55
55
|
|
|
56
|
-
[tool.ruff]
|
|
57
|
-
|
|
56
|
+
[tool.ruff.format]
|
|
57
|
+
preview = true
|
|
58
|
+
quote-style = "preserve"
|
|
58
59
|
|
|
59
60
|
[[tool.mypy.overrides]]
|
|
60
|
-
module = ["pyarrow.*", "strawberry.*", "starlette.*"]
|
|
61
|
+
module = ["numpy.*", "pyarrow.*", "strawberry.*", "starlette.*"]
|
|
61
62
|
ignore_missing_imports = true
|
|
62
63
|
|
|
63
64
|
[tool.coverage.run]
|
|
@@ -2,6 +2,24 @@ import pyarrow as pa
|
|
|
2
2
|
import pyarrow.compute as pc
|
|
3
3
|
import pytest
|
|
4
4
|
from graphique.core import Agg, ListChunk, Column as C, Table as T
|
|
5
|
+
from graphique.scalars import parse_duration, duration_isoformat
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_duration():
|
|
9
|
+
assert duration_isoformat(parse_duration('P1Y1M1DT1H1M1.1S')) == 'P13M1DT3661.100000000S'
|
|
10
|
+
assert duration_isoformat(parse_duration('P1M1DT1H1M1.1S')) == 'P1M1DT3661.100000000S'
|
|
11
|
+
assert duration_isoformat(parse_duration('P1DT1H1M1.1S')) == 'P1DT3661.100000S'
|
|
12
|
+
assert duration_isoformat(parse_duration('PT1H1M1.1S')) == 'PT3661.100000S'
|
|
13
|
+
assert duration_isoformat(parse_duration('PT1M1.1S')) == 'PT61.100000S'
|
|
14
|
+
assert duration_isoformat(parse_duration('PT1.1S')) == 'PT1.100000S'
|
|
15
|
+
assert duration_isoformat(parse_duration('PT1S')) == 'PT1S'
|
|
16
|
+
assert duration_isoformat(parse_duration('P0D')) == 'PT0S'
|
|
17
|
+
assert duration_isoformat(parse_duration('PT0S')) == 'PT0S'
|
|
18
|
+
assert duration_isoformat(parse_duration('P-1DT-1H')) == 'P-2DT82800S'
|
|
19
|
+
with pytest.raises(ValueError):
|
|
20
|
+
duration_isoformat(parse_duration('T1H'))
|
|
21
|
+
with pytest.raises(ValueError):
|
|
22
|
+
duration_isoformat(parse_duration('P1H'))
|
|
5
23
|
|
|
6
24
|
|
|
7
25
|
def test_dictionary(table):
|