floe-python 0.8.6__py3-none-any.whl → 0.8.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
floe/__init__.py CHANGED
@@ -46,6 +46,7 @@ from floe.run.events import (
46
46
  )
47
47
  from floe.state import dump_state, read_state, reset
48
48
  from floe.storage.paths import normalize_local_path
49
+ from floe.storage.resolve import clear_storage_clients
49
50
  from floe.storage.types import StorageClient
50
51
 
51
52
  _observer: Callable[[RunEvent], None] | None = None
@@ -281,6 +282,7 @@ def extract_config_env_vars(config_path: str) -> dict[str, str]:
281
282
 
282
283
 
283
284
  def _state(config_path: str, entity_name: str) -> tuple[str, str, str, StorageClient]:
285
+ clear_storage_clients()
284
286
  path, root = _load(config_path)
285
287
  [entity] = _run.select_entities(root, [entity_name])
286
288
  uri, client = state_uri(entity, normalize_local_path(path.parent), root.storages)
floe/checks/normalize.py CHANGED
@@ -115,4 +115,6 @@ def normalize_columns(df: pl.DataFrame, strategy: str | None) -> pl.DataFrame:
115
115
  f"normalized input column collision: {seen[new]} and {old} -> {new}"
116
116
  )
117
117
  seen[new] = old
118
- return df.rename(names)
118
+ renamed = df.clone()
119
+ renamed.columns = list(names.values()) # a rename is a query plan, this is not
120
+ return renamed
floe/checks/rows.py CHANGED
@@ -109,6 +109,10 @@ def split_rows(
109
109
  sources: Mapping[str, str] | None = None,
110
110
  ) -> tuple[pl.DataFrame, pl.DataFrame]:
111
111
  """`(accepted, rejected)`; rejected adds `__floe_row_index` (UInt64, row position in `df`) and `__floe_errors`."""
112
+ # a null mask passes the row, as in `error_column`
113
+ if not any(c.mask.any() for c in checks):
114
+ none = [pl.Series(ROW_INDEX, [], pl.UInt64), pl.Series(ERRORS, [], pl.String)]
115
+ return df, df.clear().with_columns(none) # replaces a clashing column, as below
112
116
  errors = error_column(df.height, checks, formatter, sources)
113
117
  rejected = df.with_columns(
114
118
  pl.int_range(pl.len(), dtype=pl.UInt64).alias(ROW_INDEX), errors
floe/iceberg/catalog.py CHANGED
@@ -4,6 +4,7 @@ import os
4
4
  import re
5
5
  import shutil
6
6
  import tempfile
7
+ import threading
7
8
  import weakref
8
9
  from collections.abc import Generator, Sequence
9
10
  from contextlib import contextmanager
@@ -83,8 +84,34 @@ def _credential_props(catalog: str, credential: str) -> dict[str, str]:
83
84
  return {"credential": credential.removeprefix("client_credentials:")}
84
85
 
85
86
 
87
+ _CATALOGS: dict[tuple[CatalogDefinition, StorageDefinition | None], Catalog] = {}
88
+ _CATALOGS_LOCK = threading.Lock()
89
+
90
+
91
+ def clear_catalogs() -> None:
92
+ """Called when a run starts: a catalog is reused within a run only, never across runs (#1101)."""
93
+ with _CATALOGS_LOCK:
94
+ _CATALOGS.clear()
95
+
96
+
86
97
  def open_catalog(
87
98
  definition: CatalogDefinition, storage: StorageDefinition | None = None
99
+ ) -> Catalog:
100
+ """One catalog per definition and storage until `clear_catalogs`, so the REST init (config, OAuth token) happens once.
101
+
102
+ Safe to call from several threads (one init per key). PyIceberg 0.12 refetches an expired OAuth token
103
+ itself: its REST calls retry once on a 401 after a new client-credentials request; a static `token:` has
104
+ nothing to refetch, as with a freshly opened catalog. A failed init is not kept.
105
+ """
106
+ key = (definition, storage)
107
+ with _CATALOGS_LOCK:
108
+ if key not in _CATALOGS:
109
+ _CATALOGS[key] = _open_catalog(definition, storage)
110
+ return _CATALOGS[key]
111
+
112
+
113
+ def _open_catalog(
114
+ definition: CatalogDefinition, storage: StorageDefinition | None
88
115
  ) -> Catalog:
89
116
  """Credentials for S3 and Glue come from the environment; the catalog never vends them (as in Rust)."""
90
117
  props = {
floe/read/csv.py CHANGED
@@ -79,15 +79,16 @@ def _scan_args(options: SourceOptions) -> _Args:
79
79
 
80
80
  def _columns(path: Path, args: _Args, declared: Sequence[str]) -> list[str]:
81
81
  try:
82
- probe = args.scan(path, n_rows=0 if args.has_header else 1).collect()
82
+ probe = args.scan(path, n_rows=0 if args.has_header else 1)
83
+ if args.has_header: # the schema is the header: no plan to run
84
+ return probe.collect_schema().names()
85
+ width = probe.collect().width
83
86
  except (pl.exceptions.PolarsError, OSError) as err:
84
87
  # Rust's polars prints the error kind: `no data: empty CSV`
85
88
  kind = "no data: " if isinstance(err, pl.exceptions.NoDataError) else ""
86
89
  raise FloeIoError(f"csv header read failed: {kind}{err}") from err
87
- if args.has_header:
88
- return probe.columns
89
- names = list(declared[: probe.width])
90
- return names + [f"extra_column_{i + 1}" for i in range(len(declared), probe.width)]
90
+ names = list(declared[:width])
91
+ return names + [f"extra_column_{i + 1}" for i in range(len(declared), width)]
91
92
 
92
93
 
93
94
  def _field_counts(path: Path, args: _Args, skip: int) -> Iterator[int]:
floe/run/__init__.py CHANGED
@@ -11,6 +11,7 @@ from floe.checks.rows import Formatter
11
11
  from floe.config.project import RootConfig
12
12
  from floe.config.types import EntityConfig
13
13
  from floe.errors import FloeConfigError, FloeError
14
+ from floe.iceberg.catalog import clear_catalogs
14
15
  from floe.report.summary import (
15
16
  build_summary,
16
17
  id_path,
@@ -24,7 +25,7 @@ from floe.run.entity import EntityRun, list_source, resolve_source, run_entity
24
25
  from floe.run.events import Emitter, Observer
25
26
  from floe.storage.inputs import ResolvedInputs
26
27
  from floe.storage.paths import normalize_local_path
27
- from floe.storage.resolve import resolve_path, storage_client
28
+ from floe.storage.resolve import clear_storage_clients, resolve_path, storage_client
28
29
 
29
30
 
30
31
  @dataclass(frozen=True)
@@ -104,7 +105,9 @@ def run(
104
105
  config_uri: str | None = None,
105
106
  ) -> RunOutcome:
106
107
  """Run the selected entities in config order, writing each entity's report as it finishes. A failing entity raises: no summary, as Rust."""
108
+ clear_storage_clients()
107
109
  started, timer = _rfc3339(), time.monotonic()
110
+ clear_catalogs()
108
111
  selected = select_entities(root, entities)
109
112
  if full_refresh:
110
113
  selected = [_refresh(e) for e in selected]
floe/run/entity.py CHANGED
@@ -5,7 +5,7 @@ import tempfile
5
5
  import threading
6
6
  import time
7
7
  from collections.abc import Callable, Generator, Iterable, Iterator
8
- from contextlib import suppress
8
+ from contextlib import closing, suppress
9
9
  from dataclasses import dataclass, field, replace
10
10
  from datetime import datetime
11
11
  from functools import partial
@@ -85,7 +85,6 @@ from floe.storage.inputs import (
85
85
  InputFile,
86
86
  NoInputsError,
87
87
  ResolvedInputs,
88
- localize_input,
89
88
  resolve_inputs,
90
89
  suffixes_for_format,
91
90
  )
@@ -99,6 +98,7 @@ from floe.storage.partitioning import (
99
98
  window,
100
99
  )
101
100
  from floe.storage.paths import normalize_local_path
101
+ from floe.storage.prefetch import localized_inputs
102
102
  from floe.storage.resolve import resolve_path, storage_client
103
103
  from floe.storage.s3 import S3Client
104
104
  from floe.storage.types import StorageClient
@@ -335,27 +335,31 @@ def _typed(
335
335
  ) -> tuple[pl.DataFrame, list[RowCheck]]:
336
336
  entity, src = plan.entity, plan.entity.source
337
337
  # D28 (#913): the derived columns join after the file's own columns are normalised and matched
338
- df = apply_mismatch(normalize_columns(raw, plan.strategy), mismatch).with_columns(
339
- derived
340
- )
341
- df = df.select(
342
- pl.col(n) if n in df.columns else pl.lit(None, pl.String).alias(n)
343
- for n in plan.names
344
- )
338
+ df = apply_mismatch(normalize_columns(raw, plan.strategy), mismatch)
339
+ if derived:
340
+ df = df.with_columns(derived)
341
+ if df.columns != plan.names:
342
+ df = df.select(
343
+ pl.col(n) if n in df.columns else pl.lit(None, pl.String).alias(n)
344
+ for n in plan.names
345
+ )
345
346
  columns = entity.schema_.columns
347
+ tag = "_e_cast_" # the mask columns' prefix: no declared column may start with it
348
+ while any(n.startswith(tag) for n in plan.names):
349
+ tag = f"_{tag}"
346
350
  try:
347
351
  casts = [
348
352
  cast_column(n, c.column_type, src.cast_mode, df.schema[n])
349
353
  for n, c in zip(plan.names, columns, strict=True)
350
354
  ]
351
- typed = df.with_columns(t for t, _ in casts)
352
- masks = df.with_columns(
353
- m for _, m in casts
354
- ) # unlike select, broadcasts constant masks
355
+ # one plan for both; unlike select, with_columns broadcasts the constant masks
356
+ masks = [m.alias(f"{tag}{n}") for n, (_, m) in zip(plan.names, casts, strict=True)] # fmt: skip
357
+ both = df.with_columns([t for t, _ in casts] + masks)
358
+ typed = both[plan.names]
355
359
  except pl.exceptions.InvalidOperationError as err:
356
360
  raise FloeRunError(f"entity.name={entity.name} cast failed: {err}") from err
357
361
  cast_checks = [
358
- RowCheck("cast_error", n, CAST_ERROR_MESSAGE, masks[f"_e_cast_{n}"])
362
+ RowCheck("cast_error", n, CAST_ERROR_MESSAGE, both[f"{tag}{n}"])
359
363
  for n, c in zip(plan.names, columns, strict=True)
360
364
  if c.column_type != "string"
361
365
  ] # fmt: skip
@@ -940,14 +944,18 @@ def _run_entity(
940
944
  def accepted_frames() -> Generator[pl.DataFrame]:
941
945
  """Files are read as the sink pulls (#1056); `files` and `complete` are final once it is exhausted."""
942
946
  try:
943
- # Rust `localize_input`: a remote input is read from a local copy
944
- with tempfile.TemporaryDirectory(prefix="floe-") as temp_dir:
947
+ queue = pending[::-1] if latest_first else pending
948
+ # Rust `localize_input`: a remote input is read from a local copy; #1102: downloaded ahead, and closed before the temp dir goes
949
+ with (
950
+ tempfile.TemporaryDirectory(prefix="floe-") as temp_dir,
951
+ closing(localized_inputs(queue, downloader, Path(temp_dir))) as inputs,
952
+ ):
945
953
  if pending and sink.write_mode == "append" and plan.tracker.keys and (target is None or plan.table is not None): # fmt: skip
946
954
  plan.history = _key_cache(plan, accepted_client, base, Path(temp_dir)) # fmt: skip
947
- for f in pending[::-1] if latest_first else pending:
955
+ for f in queue:
948
956
  emit("file_started", entity=entity.name, input=f.source_uri)
949
957
  timer = time.monotonic()
950
- local = localize_input(f, downloader, Path(temp_dir))
958
+ local = next(inputs)
951
959
  report, kept = _run_file(plan, f, local.local_path, Path(temp_dir))
952
960
  if local.is_ephemeral:
953
961
  local.local_path.unlink()
@@ -0,0 +1,46 @@
1
+ """Download the inputs of one entity a few files ahead of the one being read (#1102)."""
2
+
3
+ from collections import deque
4
+ from collections.abc import Generator, Iterable
5
+ from concurrent.futures import Future, ThreadPoolExecutor
6
+ from pathlib import Path
7
+
8
+ from floe.storage.inputs import InputFile, LocalInputFile, localize_input
9
+ from floe.storage.types import Downloader
10
+
11
+ # ponytail: a fixed window of 8 files and 512 MiB (unknown size counts as 0; the next file is always allowed) and one pool per entity; a config knob or a run-wide pool once a bucket needs more
12
+ PREFETCH_WINDOW = 8
13
+ PREFETCH_BYTES = 512 * 1024 * 1024
14
+
15
+
16
+ def localized_inputs(
17
+ files: Iterable[InputFile], client: Downloader | None, temp_dir: Path
18
+ ) -> Generator[LocalInputFile]:
19
+ """`localize_input` for each file, in order. Close it to stop: pending downloads are cancelled and the running ones awaited."""
20
+ if client is None: # local inputs are used in place; no pool
21
+ for f in files:
22
+ yield localize_input(f, None, temp_dir)
23
+ return
24
+ todo = deque(files)
25
+ ahead: deque[tuple[Future[LocalInputFile], int]] = deque()
26
+ pool = ThreadPoolExecutor(PREFETCH_WINDOW, thread_name_prefix="floe-prefetch")
27
+
28
+ def refill() -> None:
29
+ held = sum(size for _, size in ahead)
30
+ while todo and len(ahead) < PREFETCH_WINDOW:
31
+ size = todo[0].source_size or 0
32
+ if ahead and held + size > PREFETCH_BYTES:
33
+ break
34
+ held += size
35
+ ahead.append(
36
+ (pool.submit(localize_input, todo.popleft(), client, temp_dir), size)
37
+ )
38
+
39
+ try:
40
+ refill()
41
+ while ahead:
42
+ local = ahead.popleft()[0].result()
43
+ refill()
44
+ yield local
45
+ finally:
46
+ pool.shutdown(cancel_futures=True)
floe/storage/resolve.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Storage reference resolution: name, else `storages.default`, else local (Rust `StorageResolver::resolve_path`)."""
2
2
 
3
3
  from pathlib import Path
4
+ from threading import Lock
4
5
 
5
6
  from floe.config.storage import StorageDefinition, StoragesConfig
6
7
  from floe.errors import FloeConfigError, FloeRunError
@@ -48,8 +49,23 @@ def resolve_path(
48
49
  return f"s3://{definition.bucket}/{key}" if key else f"s3://{definition.bucket}", definition # fmt: skip
49
50
 
50
51
 
52
+ _clients: dict[StorageDefinition, S3Client] = {}
53
+ _clients_lock = Lock()
54
+
55
+
56
+ def clear_storage_clients() -> None:
57
+ """Start of a run: the clients below read credentials and `AWS_*` env when built."""
58
+ with _clients_lock:
59
+ _clients.clear()
60
+
61
+
51
62
  def storage_client(definition: StorageDefinition | None) -> LocalClient | S3Client:
52
- """S3 credentials come from the environment, as in Rust."""
63
+ """S3 credentials come from the environment, as in Rust. One shared client per definition between `clear_storage_clients` calls."""
53
64
  if definition is None or definition.type == "local":
54
65
  return LocalClient()
55
- return S3Client(definition.bucket or "", definition.region, definition.endpoint, definition.path_style_access) # fmt: skip
66
+ with (
67
+ _clients_lock
68
+ ): # boto3 clients are thread-safe; the lock only stops a duplicate build
69
+ if definition not in _clients:
70
+ _clients[definition] = S3Client(definition.bucket or "", definition.region, definition.endpoint, definition.path_style_access) # fmt: skip
71
+ return _clients[definition]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: floe-python
3
- Version: 0.8.6
3
+ Version: 0.8.7
4
4
  Summary: Floe: a data-contract runtime for file ingestion
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,4 +1,4 @@
1
- floe/__init__.py,sha256=0X9pm8uWiwoDslKZyyjsEAYd6g1mlvIWhkWgE5uhGng,13279
1
+ floe/__init__.py,sha256=BMJil7rW2sI2OGGcEPUcz_mkZpyKe77sbdjGh7kjPAo,13362
2
2
  floe/__main__.py,sha256=Nzz6wrGXRpJiO59qlX7WEJRTlb-XcRFswmP0TA2KjyM,57
3
3
  floe/_cli.py,sha256=hwast3uMX0iUtkIpcChSIVHYkoYi4fR4jwCwQT53lXE,14101
4
4
  floe/_cli_output.py,sha256=VLxWQr-EPtIwklo9xr2CnjMD17U-7JrMUWdTtAP3jrY,8809
@@ -13,8 +13,8 @@ floe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  floe/state.py,sha256=XFHF6zVxbdGg1bkONZDLryTD4noDF9IgaimaEI-3Nlg,11104
14
14
  floe/checks/cast.py,sha256=lM40xtKEggWI8FW3jijM3T2JFQtaXs8FhsFXo3vy5oM,6788
15
15
  floe/checks/mismatch.py,sha256=2Gz-tiWKxy-iehSjAlsGzPLtYeNIfBWvi4yQPlASVYQ,5309
16
- floe/checks/normalize.py,sha256=4g-Mw-UOtLnitavD3lPWUUYweGqGMUx_8dhq2AJU8oc,4331
17
- floe/checks/rows.py,sha256=E-YUZUvBUyOB7zat2dtbPWFM-0T1-01lYKuQU1A6wto,4156
16
+ floe/checks/normalize.py,sha256=9fdRYFjWGXBAEw1Po71N5tsUWG8eekmSWIQktPduOig,4431
17
+ floe/checks/rows.py,sha256=-yPieSus0-7ZewLv2vR8waHqQ9DGMbVvmJh1_hK4eDs,4433
18
18
  floe/checks/unique.py,sha256=qoZVqS_uG_mWZlV7-LdxT6_dMVQRlklEvMutO_V0qaY,12396
19
19
  floe/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  floe/config/entity.py,sha256=CLXLXzkUbQhxenD_LwjBt0eoPreaYaxiBVEsdmnFIco,15883
@@ -27,7 +27,7 @@ floe/config/types.py,sha256=4eveoOlyGmNxyZpI1PSjli9lnXLPc1-jYj4fL8qHN_8,5038
27
27
  floe/config/validate.py,sha256=7tyofwoTBL-BBCNxnmkXeSOybwmMNngeEcj8zviRyLs,36176
28
28
  floe/config/vars.py,sha256=7Bq0IU0KrFeRlyA8W9-F0gDSDKfXCfFpBrX08H8Flvw,4577
29
29
  floe/config/yaml.py,sha256=HeFGhKFxYkiqQOwljYOgUD5naJyxL_ZIX6hUIS36tzU,10071
30
- floe/iceberg/catalog.py,sha256=n2NlgqRgnTe3Etdyra70Ib2aYtpR7zXWrajKl7spyYg,15340
30
+ floe/iceberg/catalog.py,sha256=xjRCn6MFZCzleeM9DYkc8f6_In1qEQX_HQpDj1QSFWk,16380
31
31
  floe/iceberg/compact.py,sha256=5O372vAIsvgRc2PGihryAsrQ0otn0F30AQu-cetRCP0,7123
32
32
  floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
33
33
  floe/iceberg/sink.py,sha256=WyT6yrp5JQyfaqWES96NWh3V-EzgTD4Y-IjB84mZ9Sg,11842
@@ -37,7 +37,7 @@ floe/lineage/events.py,sha256=4txd8bGUwKBZd-8Aw_ebTurhlxAYk8SVMgH8vSjC3bs,6682
37
37
  floe/lineage/observer.py,sha256=dDYfWvPpFGjdqvFqQDZ2pTjj7uDfqji2-BTID9hzg90,10075
38
38
  floe/read/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
39
  floe/read/avro.py,sha256=rhGYlrWGFnqt_tyLIph57qV2TH91ULjoJ5qUlpW_6jI,1419
40
- floe/read/csv.py,sha256=5nXUGBa2s2Tt790UvOcrvfDym906JYjyhgunV6xPzr4,5153
40
+ floe/read/csv.py,sha256=1lNLh6MGrInWi3TADERA--rOG1iOVcgGdo7JPtt5LdI,5237
41
41
  floe/read/json.py,sha256=dPUXNlQUmMeYId3lef8ssEQeHi0tIOoEblvMQDmNVt4,8284
42
42
  floe/read/orc.py,sha256=TWgdlLBR2NF_oVZ5zEmRKNXoTkq5QfF2BOFzKyzSjhY,1553
43
43
  floe/read/parquet.py,sha256=JwfuWaHj2TxBKJeaKignq8p0Op_pUfsOdVh7dDC3wcE,1491
@@ -45,8 +45,8 @@ floe/read/xlsx.py,sha256=dengSY8HuNh9ahyR_LR8beqXTZQubqQ1Jx8qEak5jrY,3009
45
45
  floe/read/xml.py,sha256=7m6DtoU8iExuLROavyojysqOUE_Bd_N0uwFaLAzjI_Y,8004
46
46
  floe/report/entity.py,sha256=FRwLtXrlJXDNXrOKscitm-NCNJWQXd0vr_JIf_cUp20,9867
47
47
  floe/report/summary.py,sha256=tb547VRkM5WGWHQ-4vZB2DUW4VTWgv9FUoym-NpuWE4,4452
48
- floe/run/__init__.py,sha256=Nm1OVIJF3p3DrkH3MCbBOzKcRFuFlMDSxhhdGyrM5N8,8189
49
- floe/run/entity.py,sha256=xw9RPspLM9ZrY5CmQfk5qBJXcxUvO5aaosLs5UdOiPs,48710
48
+ floe/run/__init__.py,sha256=0QGSqgVTCcEH_Upp7A-Df376aN-pQc4G3F2N6uVebMw,8309
49
+ floe/run/entity.py,sha256=lSQYCAP-d-1O9R44wJF8kgO3U2B60d_PM37NmxLJJfs,49269
50
50
  floe/run/events.py,sha256=OE7zYkt0Dz2xrvgNavT1nHfZJ-97IIJ8LpHcameUJZU,2859
51
51
  floe/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
52
  floe/storage/archive.py,sha256=1gPImm9PDSJFCpWjg37ax4ubb0wwc6FZm38quQXhORg,1101
@@ -54,13 +54,14 @@ floe/storage/inputs.py,sha256=nmgEo2ub3hPUghLUXHU1wnKzpL4aYZbHb6S0XPYvMew,10410
54
54
  floe/storage/local.py,sha256=UiGFYEY33j45CQspWzRyJBW0HC2Nk9_zG--wL-aUuJI,6388
55
55
  floe/storage/partitioning.py,sha256=MG8RJDrwgZhucTswlXC2RwCmzrYIoOE6SH_aBM11KW8,6178
56
56
  floe/storage/paths.py,sha256=Z0fry3Gp0Tu2DpUeKqNXdqYP6n_KcKzK2llGx0TGHB8,2343
57
- floe/storage/resolve.py,sha256=pFAuVytTfnSEcTAVlJjFTj7S_Vipjry1ormWZP3Egmc,2593
57
+ floe/storage/prefetch.py,sha256=b-_be13xkJIw3LVndOb6SouTd1Pf8uZwG6Wy0Nk1pr8,1801
58
+ floe/storage/resolve.py,sha256=0hGSdU_p10j9vzET25Kcnd-QF6H7uN-hzpo_SC-DV_c,3154
58
59
  floe/storage/s3.py,sha256=XcG1qJTEY3KLSMLT950mUdrAxuCA8-Gx3fpNSzyV_Xo,7508
59
60
  floe/storage/types.py,sha256=FvAEXWMdVdopRK276JyETRYGOva2-uR8pZvsWj6Xvy0,993
60
61
  floe/write/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
61
62
  floe/write/outputs.py,sha256=cXxGUIJnZ38XPaqEI6a83GFUsapBRdY52XQm3GlUPcI,6937
62
- floe_python-0.8.6.dist-info/METADATA,sha256=eUy15Jwu4CdMPA1EJDxZYVGwz3gRcJN02PyqJW-5Qjo,442
63
- floe_python-0.8.6.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
64
- floe_python-0.8.6.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
65
- floe_python-0.8.6.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
66
- floe_python-0.8.6.dist-info/RECORD,,
63
+ floe_python-0.8.7.dist-info/METADATA,sha256=xk8WIhLuQqvT4lPVlzSISa5FRfsyhIpL3TjcfVUGGoo,442
64
+ floe_python-0.8.7.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
65
+ floe_python-0.8.7.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
66
+ floe_python-0.8.7.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
67
+ floe_python-0.8.7.dist-info/RECORD,,