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.
Files changed (27) hide show
  1. {graphique-1.4 → graphique-1.6}/PKG-INFO +3 -4
  2. {graphique-1.4 → graphique-1.6}/README.md +0 -1
  3. {graphique-1.4 → graphique-1.6}/graphique/__init__.py +1 -1
  4. {graphique-1.4 → graphique-1.6}/graphique/core.py +19 -14
  5. {graphique-1.4 → graphique-1.6}/graphique/inputs.py +3 -2
  6. {graphique-1.4 → graphique-1.6}/graphique/interface.py +12 -9
  7. {graphique-1.4 → graphique-1.6}/graphique/middleware.py +4 -3
  8. {graphique-1.4 → graphique-1.6}/graphique/models.py +6 -6
  9. graphique-1.6/graphique/scalars.py +105 -0
  10. {graphique-1.4 → graphique-1.6}/graphique/service.py +2 -1
  11. {graphique-1.4 → graphique-1.6}/graphique/shell.py +27 -25
  12. {graphique-1.4 → graphique-1.6}/graphique.egg-info/PKG-INFO +3 -4
  13. {graphique-1.4 → graphique-1.6}/graphique.egg-info/SOURCES.txt +1 -0
  14. graphique-1.6/graphique.egg-info/requires.txt +8 -0
  15. {graphique-1.4 → graphique-1.6}/pyproject.toml +8 -7
  16. graphique-1.6/requirements.in +2 -0
  17. {graphique-1.4 → graphique-1.6}/tests/test_core.py +18 -0
  18. {graphique-1.4 → graphique-1.6}/tests/test_dataset.py +38 -59
  19. {graphique-1.4 → graphique-1.6}/tests/test_models.py +78 -119
  20. {graphique-1.4 → graphique-1.6}/tests/test_service.py +91 -123
  21. graphique-1.4/graphique/scalars.py +0 -64
  22. graphique-1.4/graphique.egg-info/requires.txt +0 -8
  23. {graphique-1.4 → graphique-1.6}/LICENSE.txt +0 -0
  24. {graphique-1.4 → graphique-1.6}/graphique/py.typed +0 -0
  25. {graphique-1.4 → graphique-1.6}/graphique.egg-info/dependency_links.txt +0 -0
  26. {graphique-1.4 → graphique-1.6}/graphique.egg-info/top_level.txt +0 -0
  27. {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.4
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>=14
44
- Requires-Dist: strawberry-graphql[asgi,cli]>=0.198
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
  [![image](https://github.com/coady/graphique/workflows/build/badge.svg)](https://github.com/coady/graphique/actions)
55
55
  [![image](https://codecov.io/gh/coady/graphique/branch/main/graph/badge.svg)](https://codecov.io/gh/coady/graphique/)
56
56
  [![image](https://github.com/coady/graphique/workflows/codeql/badge.svg)](https://github.com/coady/graphique/security/code-scanning)
57
- [![image](https://img.shields.io/badge/code%20style-black-000000.svg)](https://pypi.org/project/black/)
58
57
  [![image](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
59
58
  [![image](https://mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
60
59
 
@@ -5,7 +5,6 @@
5
5
  [![image](https://github.com/coady/graphique/workflows/build/badge.svg)](https://github.com/coady/graphique/actions)
6
6
  [![image](https://codecov.io/gh/coady/graphique/branch/main/graph/badge.svg)](https://codecov.io/gh/coady/graphique/)
7
7
  [![image](https://github.com/coady/graphique/workflows/codeql/badge.svg)](https://github.com/coady/graphique/security/code-scanning)
8
- [![image](https://img.shields.io/badge/code%20style-black-000000.svg)](https://pypi.org/project/black/)
9
8
  [![image](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
10
9
  [![image](https://mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
11
10
 
@@ -1,3 +1,3 @@
1
1
  from .middleware import GraphQL # noqa: F401
2
2
 
3
- __version__ = '1.4'
3
+ __version__ = '1.6'
@@ -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 # type: ignore
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, array: pa.float64(), bins: pa.list_(pa.float64()), right: pa.bool_() # type: ignore
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.update(Table.columns(table))
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, (pc.not_equal,)))
405
+ Column.run_offsets(self[name], *predicates.get(name, ()))
402
406
  for name in names + tuple(predicates)
403
- ).unique()
404
- offsets = offsets.take(pc.sort_indices(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.update({agg.alias: func(self[agg.name], **agg.options) for agg in aggs})
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.update({name: table[name][0] for name in table.schema.names})
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)) # type: ignore
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))) # type: ignore
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.update({name: self[name][index].values for name in lists})
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() # type: ignore
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())) # type: ignore
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, info: Info, by: list[str] = [], counts: str = '', aggregate: HashAggregates = {} # type: ignore
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.update(T.aggregate(table, counts=counts, **aggs))
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.update(T.columns(pa.RecordBatch.from_pydict(arrays)))
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.update({field.name: pa.array(columns[field.name], field.type) for field in schema})
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.update({col.alias or '.'.join(col.name): col.to_arrow() for col in columns})
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 Interval, Long, scalar_map, type_map
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.__strawberry_definition__.is_generic
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, Interval)
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
- import argparse
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
- format=options.get('format', 'parquet'),
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
- ds.write_dataset(dataset.filter(exprs[path]), part_dir, **options)
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
- scanner: ds.Scanner, base_dir: str, *partitioning: str, fragments: bool = False, **options
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(base_dir) / 'temp'
47
- write_batches(scanner, str(temp), *partitioning)
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
- if fragments:
50
- write_fragments(dataset, base_dir, **options)
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, base_dir, partitioning=partitioning, **options)
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
- args = parser.parse_args()
66
- dataset = ds.dataset(args.src, partitioning='hive')
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.4
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>=14
44
- Requires-Dist: strawberry-graphql[asgi,cli]>=0.198
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
  [![image](https://github.com/coady/graphique/workflows/build/badge.svg)](https://github.com/coady/graphique/actions)
55
55
  [![image](https://codecov.io/gh/coady/graphique/branch/main/graph/badge.svg)](https://codecov.io/gh/coady/graphique/)
56
56
  [![image](https://github.com/coady/graphique/workflows/codeql/badge.svg)](https://github.com/coady/graphique/security/code-scanning)
57
- [![image](https://img.shields.io/badge/code%20style-black-000000.svg)](https://pypi.org/project/black/)
58
57
  [![image](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
59
58
  [![image](https://mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
60
59
 
@@ -1,6 +1,7 @@
1
1
  LICENSE.txt
2
2
  README.md
3
3
  pyproject.toml
4
+ requirements.in
4
5
  graphique/__init__.py
5
6
  graphique/core.py
6
7
  graphique/inputs.py
@@ -0,0 +1,8 @@
1
+ pyarrow>=16
2
+ strawberry-graphql[asgi,cli]>=0.221
3
+
4
+ [cli]
5
+ tqdm
6
+
7
+ [server]
8
+ uvicorn[standard]
@@ -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.black]
52
+ [tool.ruff]
53
53
  line-length = 100
54
- skip-string-normalization = true
54
+ extend-include = ["*.ipynb"]
55
55
 
56
- [tool.ruff]
57
- ignore = ["E501"]
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]
@@ -0,0 +1,2 @@
1
+ pyarrow>=16
2
+ strawberry-graphql[asgi,cli]>=0.221
@@ -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):