kumoai 2.15.0.dev202601121731__cp313-cp313-macosx_11_0_arm64.whl → 2.15.0.dev202601181732__cp313-cp313-macosx_11_0_arm64.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.
kumoai/__init__.py CHANGED
@@ -277,7 +277,7 @@ __all__ = [
277
277
  ]
278
278
 
279
279
 
280
- def in_snowflake_notebook() -> bool:
280
+ def in_streamlit_notebook() -> bool:
281
281
  try:
282
282
  from snowflake.snowpark.context import get_active_session
283
283
  import streamlit # noqa: F401
@@ -287,9 +287,7 @@ def in_snowflake_notebook() -> bool:
287
287
  return False
288
288
 
289
289
 
290
- def in_notebook() -> bool:
291
- if in_snowflake_notebook():
292
- return True
290
+ def in_jupyter_notebook() -> bool:
293
291
  try:
294
292
  from IPython import get_ipython
295
293
  shell = get_ipython()
@@ -298,3 +296,16 @@ def in_notebook() -> bool:
298
296
  return shell.__class__.__name__ == 'ZMQInteractiveShell'
299
297
  except Exception:
300
298
  return False
299
+
300
+
301
+ def in_vnext_notebook() -> bool:
302
+ try:
303
+ from snowflake.snowpark.context import get_active_session
304
+ get_active_session()
305
+ return in_jupyter_notebook()
306
+ except Exception:
307
+ return False
308
+
309
+
310
+ def in_notebook() -> bool:
311
+ return in_streamlit_notebook() or in_jupyter_notebook()
kumoai/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '2.15.0.dev202601121731'
1
+ __version__ = '2.15.0.dev202601181732'
@@ -1,7 +1,8 @@
1
1
  import json
2
+ import math
2
3
  from collections.abc import Iterator
3
4
  from contextlib import contextmanager
4
- from typing import TYPE_CHECKING
5
+ from typing import TYPE_CHECKING, cast
5
6
 
6
7
  import numpy as np
7
8
  import pandas as pd
@@ -11,7 +12,7 @@ from kumoapi.pquery import ValidatedPredictiveQuery
11
12
  from kumoai.experimental.rfm.backend.snow import Connection, SnowTable
12
13
  from kumoai.experimental.rfm.base import SQLSampler, Table
13
14
  from kumoai.experimental.rfm.pquery import PQueryPandasExecutor
14
- from kumoai.utils import ProgressLogger
15
+ from kumoai.utils import ProgressLogger, quote_ident
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from kumoai.experimental.rfm import Graph
@@ -37,6 +38,15 @@ class SnowSampler(SQLSampler):
37
38
  assert isinstance(table, SnowTable)
38
39
  self._connection = table._connection
39
40
 
41
+ self._num_rows_dict: dict[str, int] = {
42
+ table.name: cast(int, table._num_rows)
43
+ for table in graph.tables.values()
44
+ }
45
+
46
+ @property
47
+ def num_rows_dict(self) -> dict[str, int]:
48
+ return self._num_rows_dict
49
+
40
50
  def _get_min_max_time_dict(
41
51
  self,
42
52
  table_names: list[str],
@@ -45,8 +55,9 @@ class SnowSampler(SQLSampler):
45
55
  for table_name in table_names:
46
56
  column = self.time_column_dict[table_name]
47
57
  column_ref = self.table_column_ref_dict[table_name][column]
58
+ ident = quote_ident(table_name, char="'")
48
59
  select = (f"SELECT\n"
49
- f" ? as table_name,\n"
60
+ f" {ident} as table_name,\n"
50
61
  f" MIN({column_ref}) as min_date,\n"
51
62
  f" MAX({column_ref}) as max_date\n"
52
63
  f"FROM {self.source_name_dict[table_name]}")
@@ -54,14 +65,13 @@ class SnowSampler(SQLSampler):
54
65
  sql = "\nUNION ALL\n".join(selects)
55
66
 
56
67
  out_dict: dict[str, tuple[pd.Timestamp, pd.Timestamp]] = {}
57
- with paramstyle(self._connection), self._connection.cursor() as cursor:
58
- cursor.execute(sql, table_names)
59
- rows = cursor.fetchall()
60
- for table_name, _min, _max in rows:
61
- out_dict[table_name] = (
62
- pd.Timestamp.max if _min is None else pd.Timestamp(_min),
63
- pd.Timestamp.min if _max is None else pd.Timestamp(_max),
64
- )
68
+ with self._connection.cursor() as cursor:
69
+ cursor.execute(sql)
70
+ for table_name, _min, _max in cursor.fetchall():
71
+ out_dict[table_name] = (
72
+ pd.Timestamp.max if _min is None else pd.Timestamp(_min),
73
+ pd.Timestamp.min if _max is None else pd.Timestamp(_max),
74
+ )
65
75
 
66
76
  return out_dict
67
77
 
@@ -239,9 +249,30 @@ class SnowSampler(SQLSampler):
239
249
  ) -> tuple[pd.DataFrame, np.ndarray]:
240
250
  time_column = self.time_column_dict.get(table_name)
241
251
 
252
+ end_time: pd.Series | None = None
253
+ start_time: pd.Series | None = None
242
254
  if time_column is not None and anchor_time is not None:
243
- anchor_time = anchor_time.dt.strftime("%Y-%m-%d %H:%M:%S")
244
- payload = json.dumps(list(zip(index, anchor_time)))
255
+ # In order to avoid a full table scan, we limit foreign key
256
+ # sampling to a certain time range, approximated by the number of
257
+ # rows, timestamp ranges and `num_neighbors` value.
258
+ # Downstream, this helps Snowflake to apply partition pruning:
259
+ dst_table_name = [
260
+ dst_table
261
+ for key, dst_table in self.foreign_key_dict[table_name]
262
+ if key == foreign_key
263
+ ][0]
264
+ num_facts = self.num_rows_dict[table_name]
265
+ num_entities = self.num_rows_dict[dst_table_name]
266
+ min_time = self.get_min_time([table_name])
267
+ max_time = self.get_max_time([table_name])
268
+ freq = num_facts / num_entities
269
+ freq = freq / max((max_time - min_time).total_seconds(), 1)
270
+ offset = pd.Timedelta(seconds=math.ceil(5 * num_neighbors / freq))
271
+
272
+ end_time = anchor_time.dt.strftime("%Y-%m-%d %H:%M:%S")
273
+ start_time = anchor_time - offset
274
+ start_time = start_time.dt.strftime("%Y-%m-%d %H:%M:%S")
275
+ payload = json.dumps(list(zip(index, end_time, start_time)))
245
276
  else:
246
277
  payload = json.dumps(list(zip(index)))
247
278
 
@@ -260,9 +291,10 @@ class SnowSampler(SQLSampler):
260
291
  sql += " f.value[0]::FLOAT as __KUMO_ID__"
261
292
  else:
262
293
  sql += " f.value[0]::VARCHAR as __KUMO_ID__"
263
- if time_column is not None and anchor_time is not None:
294
+ if end_time is not None and start_time is not None:
264
295
  sql += (",\n"
265
- " f.value[1]::TIMESTAMP_NTZ as __KUMO_TIME__")
296
+ " f.value[1]::TIMESTAMP_NTZ as __KUMO_END_TIME__,\n"
297
+ " f.value[2]::TIMESTAMP_NTZ as __KUMO_START_TIME__")
266
298
  sql += (f"\n"
267
299
  f" FROM TABLE(FLATTEN(INPUT => PARSE_JSON(?))) f\n"
268
300
  f")\n"
@@ -272,9 +304,13 @@ class SnowSampler(SQLSampler):
272
304
  f"FROM TMP\n"
273
305
  f"JOIN {self.source_name_dict[table_name]}\n"
274
306
  f" ON {key_ref} = TMP.__KUMO_ID__\n")
275
- if time_column is not None and anchor_time is not None:
307
+ if end_time is not None and start_time is not None:
308
+ assert time_column is not None
276
309
  time_ref = self.table_column_ref_dict[table_name][time_column]
277
- sql += f" AND {time_ref} <= TMP.__KUMO_TIME__\n"
310
+ sql += (f" AND {time_ref} <= TMP.__KUMO_END_TIME__\n"
311
+ f" AND {time_ref} > TMP.__KUMO_START_TIME__\n"
312
+ f"WHERE {time_ref} <= '{end_time.max()}'\n"
313
+ f" AND {time_ref} > '{start_time.min()}'\n")
278
314
  sql += ("QUALIFY ROW_NUMBER() OVER (\n"
279
315
  " PARTITION BY TMP.__KUMO_BATCH__\n")
280
316
  if time_column is not None:
@@ -76,21 +76,13 @@ class SnowTable(Table):
76
76
 
77
77
  @property
78
78
  def source_name(self) -> str:
79
- names: list[str] = []
80
- if self._database is not None:
81
- names.append(self._database)
82
- if self._schema is not None:
83
- names.append(self._schema)
84
- return '.'.join(names + [self._source_name])
79
+ names = [self._database, self._schema, self._source_name]
80
+ return '.'.join(names)
85
81
 
86
82
  @property
87
83
  def _quoted_source_name(self) -> str:
88
- names: list[str] = []
89
- if self._database is not None:
90
- names.append(quote_ident(self._database))
91
- if self._schema is not None:
92
- names.append(quote_ident(self._schema))
93
- return '.'.join(names + [quote_ident(self._source_name)])
84
+ names = [self._database, self._schema, self._source_name]
85
+ return '.'.join([quote_ident(name) for name in names])
94
86
 
95
87
  @property
96
88
  def backend(self) -> DataBackend:
@@ -159,7 +151,18 @@ class SnowTable(Table):
159
151
  )
160
152
 
161
153
  def _get_num_rows(self) -> int | None:
162
- return None
154
+ with self._connection.cursor() as cursor:
155
+ quoted_source_name = quote_ident(self._source_name, char="'")
156
+ sql = (f"SHOW TABLES LIKE {quoted_source_name} "
157
+ f"IN SCHEMA {quote_ident(self._database)}."
158
+ f"{quote_ident(self._schema)}")
159
+ cursor.execute(sql)
160
+ num_rows = cursor.fetchone()[7]
161
+
162
+ if num_rows == 0:
163
+ raise RuntimeError("Table '{self.source_name}' is empty")
164
+
165
+ return num_rows
163
166
 
164
167
  def _get_expr_sample_df(
165
168
  self,
@@ -121,8 +121,9 @@ class SQLiteSampler(SQLSampler):
121
121
  for table_name in table_names:
122
122
  column = self.time_column_dict[table_name]
123
123
  column_ref = self.table_column_ref_dict[table_name][column]
124
+ ident = quote_ident(table_name, char="'")
124
125
  select = (f"SELECT\n"
125
- f" ? as table_name,\n"
126
+ f" {ident} as table_name,\n"
126
127
  f" MIN({column_ref}) as min_date,\n"
127
128
  f" MAX({column_ref}) as max_date\n"
128
129
  f"FROM {self.source_name_dict[table_name]}")
@@ -131,12 +132,13 @@ class SQLiteSampler(SQLSampler):
131
132
 
132
133
  out_dict: dict[str, tuple[pd.Timestamp, pd.Timestamp]] = {}
133
134
  with self._connection.cursor() as cursor:
134
- cursor.execute(sql, table_names)
135
+ cursor.execute(sql)
135
136
  for table_name, _min, _max in cursor.fetchall():
136
137
  out_dict[table_name] = (
137
138
  pd.Timestamp.max if _min is None else pd.Timestamp(_min),
138
139
  pd.Timestamp.min if _max is None else pd.Timestamp(_max),
139
140
  )
141
+
140
142
  return out_dict
141
143
 
142
144
  def _sample_entity_table(
@@ -4,11 +4,22 @@ import pandas as pd
4
4
  import pyarrow as pa
5
5
 
6
6
 
7
+ def is_datetime(ser: pd.Series) -> bool:
8
+ r"""Check whether a :class:`pandas.Series` holds datetime values."""
9
+ if isinstance(ser.dtype, pd.ArrowDtype):
10
+ dtype = ser.dtype.pyarrow_dtype
11
+ return (pa.types.is_timestamp(dtype) or pa.types.is_date(dtype)
12
+ or pa.types.is_time(dtype))
13
+
14
+ return pd.api.types.is_datetime64_any_dtype(ser)
15
+
16
+
7
17
  def to_datetime(ser: pd.Series) -> pd.Series:
8
- """Converts a :class:`panads.Series` to ``datetime64[ns]`` format."""
9
- if (not pd.api.types.is_datetime64_any_dtype(ser)
10
- and not (isinstance(ser.dtype, pd.ArrowDtype)
11
- and pa.types.is_timestamp(ser.dtype.pyarrow_dtype))):
18
+ """Converts a :class:`pandas.Series` to ``datetime64[ns]`` format."""
19
+ if isinstance(ser.dtype, pd.ArrowDtype):
20
+ ser = pd.Series(ser.to_numpy(), index=ser.index, name=ser.name)
21
+
22
+ if not pd.api.types.is_datetime64_any_dtype(ser):
12
23
  with warnings.catch_warnings():
13
24
  warnings.filterwarnings(
14
25
  'ignore',
@@ -16,9 +27,7 @@ def to_datetime(ser: pd.Series) -> pd.Series:
16
27
  )
17
28
  ser = pd.to_datetime(ser, errors='coerce')
18
29
 
19
- if (isinstance(ser.dtype, pd.DatetimeTZDtype)
20
- or (isinstance(ser.dtype, pd.ArrowDtype)
21
- and ser.dtype.pyarrow_dtype.tz is not None)):
30
+ if isinstance(ser.dtype, pd.DatetimeTZDtype):
22
31
  ser = ser.dt.tz_localize(None)
23
32
 
24
33
  if ser.dtype != 'datetime64[ns]':
@@ -17,8 +17,9 @@ from kumoapi.table import TableDefinition
17
17
  from kumoapi.typing import Stype
18
18
  from typing_extensions import Self
19
19
 
20
- from kumoai import in_notebook, in_snowflake_notebook
20
+ from kumoai import in_jupyter_notebook, in_streamlit_notebook
21
21
  from kumoai.experimental.rfm.base import ColumnSpec, DataBackend, Table
22
+ from kumoai.experimental.rfm.infer import infer_time_column
22
23
  from kumoai.graph import Edge
23
24
  from kumoai.mixin import CastMixin
24
25
  from kumoai.utils import display
@@ -415,8 +416,9 @@ class Graph:
415
416
  assert isinstance(connection, Connection)
416
417
 
417
418
  with connection.cursor() as cursor:
418
- cursor.execute(f"SELECT SYSTEM$READ_YAML_FROM_SEMANTIC_VIEW("
419
- f"'{semantic_view_name}')")
419
+ sql = (f"SELECT SYSTEM$READ_YAML_FROM_SEMANTIC_VIEW("
420
+ f"'{semantic_view_name}')")
421
+ cursor.execute(sql)
420
422
  cfg = yaml.safe_load(cursor.fetchone()[0])
421
423
 
422
424
  graph = cls(tables=[])
@@ -492,7 +494,17 @@ class Graph:
492
494
  )
493
495
 
494
496
  # TODO Add a way to register time columns without heuristic usage.
495
- table.infer_time_column(verbose=False)
497
+ time_candidates = [
498
+ column_cfg['name']
499
+ for column_cfg in table_cfg.get('time_dimensions', [])
500
+ if table.has_column(column_cfg['name'])
501
+ and table[column_cfg['name']].stype == Stype.timestamp
502
+ ]
503
+ if time_column := infer_time_column(
504
+ df=table._get_sample_df(),
505
+ candidates=time_candidates,
506
+ ):
507
+ table.time_column = time_column
496
508
 
497
509
  graph.add_table(table)
498
510
 
@@ -1071,7 +1083,7 @@ class Graph:
1071
1083
  raise ImportError("The 'graphviz' package is required for "
1072
1084
  "visualization") from e
1073
1085
 
1074
- if not in_snowflake_notebook() and not has_graphviz_executables():
1086
+ if not in_streamlit_notebook() and not has_graphviz_executables():
1075
1087
  raise RuntimeError("Could not visualize graph as 'graphviz' "
1076
1088
  "executables are not installed. These "
1077
1089
  "dependencies are required in addition to the "
@@ -1161,10 +1173,10 @@ class Graph:
1161
1173
  graph.render(path, cleanup=True)
1162
1174
  elif isinstance(path, io.BytesIO):
1163
1175
  path.write(graph.pipe())
1164
- elif in_snowflake_notebook():
1176
+ elif in_streamlit_notebook():
1165
1177
  import streamlit as st
1166
1178
  st.graphviz_chart(graph)
1167
- elif in_notebook():
1179
+ elif in_jupyter_notebook():
1168
1180
  from IPython.display import display
1169
1181
  display(graph)
1170
1182
  else:
@@ -3,6 +3,8 @@ import pandas as pd
3
3
  import pyarrow as pa
4
4
  from kumoapi.typing import Dtype
5
5
 
6
+ from kumoai.experimental.rfm.base.utils import is_datetime
7
+
6
8
  PANDAS_TO_DTYPE: dict[str, Dtype] = {
7
9
  'bool': Dtype.bool,
8
10
  'boolean': Dtype.bool,
@@ -34,7 +36,7 @@ def infer_dtype(ser: pd.Series) -> Dtype:
34
36
  Returns:
35
37
  The data type.
36
38
  """
37
- if pd.api.types.is_datetime64_any_dtype(ser.dtype):
39
+ if is_datetime(ser):
38
40
  return Dtype.date
39
41
  if pd.api.types.is_timedelta64_dtype(ser.dtype):
40
42
  return Dtype.timedelta
@@ -610,7 +610,7 @@ class KumoRFM:
610
610
 
611
611
  if start == 0 and task.num_prediction_examples > batch_size:
612
612
  num = math.ceil(task.num_prediction_examples / batch_size)
613
- verbose.init_progress(total=num, description='Predicting')
613
+ verbose.init_progress(msg='Predicting', total=num)
614
614
 
615
615
  for attempt in range(self._num_retries + 1):
616
616
  try:
@@ -643,7 +643,7 @@ class KumoRFM:
643
643
  df['ANCHOR_TIMESTAMP'] = pd.to_datetime(
644
644
  ser, errors='coerce', unit=unit)
645
645
 
646
- predictions.append(df)
646
+ predictions.append(df.reset_index(drop=True))
647
647
 
648
648
  if task.num_prediction_examples > batch_size:
649
649
  verbose.step()
kumoai/utils/display.py CHANGED
@@ -6,14 +6,19 @@ from rich.console import Console
6
6
  from rich.table import Table
7
7
  from rich.text import Text
8
8
 
9
- from kumoai import in_notebook, in_snowflake_notebook
9
+ from kumoai import (
10
+ in_jupyter_notebook,
11
+ in_notebook,
12
+ in_streamlit_notebook,
13
+ in_vnext_notebook,
14
+ )
10
15
 
11
16
 
12
17
  def message(msg: str) -> None:
13
- if in_snowflake_notebook():
18
+ if in_streamlit_notebook():
14
19
  import streamlit as st
15
20
  st.markdown(msg)
16
- elif in_notebook():
21
+ elif in_jupyter_notebook():
17
22
  from IPython.display import Markdown, display
18
23
  display(Markdown(msg))
19
24
  else:
@@ -54,10 +59,13 @@ def unordered_list(items: Sequence[str]) -> None:
54
59
 
55
60
 
56
61
  def dataframe(df: pd.DataFrame) -> None:
57
- if in_snowflake_notebook():
62
+ if in_streamlit_notebook():
58
63
  import streamlit as st
59
64
  st.dataframe(df, hide_index=True)
60
- elif in_notebook():
65
+ elif in_vnext_notebook():
66
+ from IPython.display import display
67
+ display(df.reset_index(drop=True))
68
+ elif in_jupyter_notebook():
61
69
  from IPython.display import display
62
70
  try:
63
71
  if hasattr(df.style, 'hide'):
@@ -1,6 +1,7 @@
1
1
  import re
2
2
  import sys
3
3
  import time
4
+ from abc import ABC, abstractmethod
4
5
  from typing import Any
5
6
 
6
7
  from rich.console import Console, ConsoleOptions, RenderResult
@@ -20,52 +21,179 @@ from rich.text import Text
20
21
  from typing_extensions import Self
21
22
 
22
23
 
23
- class ProgressLogger:
24
+ class ProgressLogger(ABC):
25
+ r"""An abstract base class for logging progress updates."""
24
26
  def __init__(self, msg: str, verbose: bool = True) -> None:
25
- self.msg = msg
26
- self.verbose = verbose
27
- self.depth = 0
27
+ self.msg: str = msg
28
+ self.verbose: bool = verbose
28
29
 
29
30
  self.logs: list[str] = []
30
31
 
31
32
  self.start_time: float | None = None
32
33
  self.end_time: float | None = None
33
34
 
35
+ # Handle nested loggers gracefully:
36
+ self._depth: int = 0
37
+
38
+ # Internal progress bar cache:
39
+ self._progress_bar_msg: str | None = None
40
+ self._total: int = 0
41
+ self._current: int = 0
42
+
43
+ def __repr__(self) -> str:
44
+ return f'{self.__class__.__name__}()'
45
+
34
46
  @classmethod
35
47
  def default(cls, msg: str, verbose: bool = True) -> 'ProgressLogger':
36
- from kumoai import in_snowflake_notebook
48
+ r"""The default progress logger for the current environment."""
49
+ from kumoai import in_streamlit_notebook, in_vnext_notebook
37
50
 
38
- if in_snowflake_notebook():
51
+ if in_streamlit_notebook():
39
52
  return StreamlitProgressLogger(msg, verbose)
53
+ if in_vnext_notebook():
54
+ return PlainProgressLogger(msg, verbose)
40
55
  return RichProgressLogger(msg, verbose)
41
56
 
42
57
  @property
43
58
  def duration(self) -> float:
59
+ r"""The current/final duration."""
44
60
  assert self.start_time is not None
45
61
  if self.end_time is not None:
46
62
  return self.end_time - self.start_time
47
63
  return time.perf_counter() - self.start_time
48
64
 
65
+ def __enter__(self) -> Self:
66
+ from kumoai import in_notebook
67
+
68
+ self._depth += 1
69
+ if self._depth == 1:
70
+ self.start_time = time.perf_counter()
71
+ if self._depth == 1 and not in_notebook(): # Show progress bar in TUI.
72
+ sys.stdout.write("\x1b]9;4;3\x07")
73
+ sys.stdout.flush()
74
+ if self._depth == 1 and self.verbose:
75
+ self.on_enter()
76
+ return self
77
+
78
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
79
+ from kumoai import in_notebook
80
+
81
+ self._depth -= 1
82
+ if self._depth == 0:
83
+ self.end_time = time.perf_counter()
84
+ if self._depth == 0 and self.verbose:
85
+ self.on_exit(error=exc_val is not None)
86
+ if self._depth == 0 and not in_notebook(): # Stop progress bar in TUI.
87
+ sys.stdout.write("\x1b]9;4;0\x07")
88
+ sys.stdout.flush()
89
+
49
90
  def log(self, msg: str) -> None:
91
+ r"""Logs a new message."""
50
92
  self.logs.append(msg)
93
+ if self.verbose:
94
+ self.on_log(msg)
51
95
 
52
- def init_progress(self, total: int, description: str) -> None:
53
- pass
96
+ def init_progress(self, msg: str, total: int) -> None:
97
+ r"""Initializes a progress bar."""
98
+ if self._progress_bar_msg is not None:
99
+ raise RuntimeError("Current progress not yet finished")
100
+ self._progress_bar_msg = msg
101
+ self._current = 0
102
+ self._total = total
103
+ if self.verbose:
104
+ self.on_init_progress(msg, total)
54
105
 
55
106
  def step(self) -> None:
107
+ r"""Increments an active progress bar."""
108
+ assert self._progress_bar_msg is not None
109
+ self._current += 1
110
+ if self.verbose:
111
+ self.on_step(self._progress_bar_msg, self._current, self._total)
112
+ if self._current >= self._total:
113
+ self._progress_bar_msg = None
114
+ self._current = self._total = 0
115
+
116
+ @abstractmethod
117
+ def on_enter(self) -> None:
56
118
  pass
57
119
 
58
- def __enter__(self) -> Self:
59
- self.depth += 1
60
- self.start_time = time.perf_counter()
61
- return self
120
+ @abstractmethod
121
+ def on_exit(self, error: bool) -> None:
122
+ pass
62
123
 
63
- def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
64
- self.depth -= 1
65
- self.end_time = time.perf_counter()
124
+ @abstractmethod
125
+ def on_log(self, msg: str) -> None:
126
+ pass
66
127
 
67
- def __repr__(self) -> str:
68
- return f'{self.__class__.__name__}({self.msg})'
128
+ @abstractmethod
129
+ def on_init_progress(self, msg: str, total: int) -> None:
130
+ pass
131
+
132
+ @abstractmethod
133
+ def on_step(self, msg: str, current: int, total: int) -> None:
134
+ pass
135
+
136
+
137
+ class PlainProgressLogger(ProgressLogger):
138
+ RESET: str = '\x1b[0m'
139
+ BOLD: str = '\x1b[1m'
140
+ DIM: str = '\x1b[2m'
141
+ RED: str = '\x1b[31m'
142
+ GREEN: str = '\x1b[32m'
143
+ CYAN: str = '\x1b[36m'
144
+
145
+ def on_enter(self) -> None:
146
+ from kumoai import in_vnext_notebook
147
+
148
+ msg = self.msg.replace('[bold]', self.BOLD)
149
+ msg = msg.replace('[/bold]', self.RESET + self.CYAN)
150
+ msg = self.CYAN + msg + self.RESET
151
+ print(msg, end='\n' if in_vnext_notebook() else '', flush=True)
152
+
153
+ def on_exit(self, error: bool) -> None:
154
+ from kumoai import in_vnext_notebook
155
+
156
+ if error:
157
+ msg = f"❌ {self.RED}({self.duration:.2f}s){self.RESET}"
158
+ else:
159
+ msg = f"✅ {self.GREEN}({self.duration:.2f}s){self.RESET}"
160
+
161
+ if in_vnext_notebook():
162
+ print(f"{self.DIM}↳{self.RESET} {msg}", flush=True)
163
+ else:
164
+ print(f" {msg}", flush=True)
165
+
166
+ def on_log(self, msg: str) -> None:
167
+ from kumoai import in_vnext_notebook
168
+
169
+ msg = f"{self.DIM}↳ {msg}{self.RESET}"
170
+
171
+ if in_vnext_notebook():
172
+ print(msg, flush=True)
173
+ else:
174
+ print(f"\n{msg}", end='', flush=True)
175
+
176
+ def on_init_progress(self, msg: str, total: int) -> None:
177
+ from kumoai import in_vnext_notebook
178
+
179
+ msg = f"{self.DIM}↳ {msg}{self.RESET}"
180
+
181
+ if in_vnext_notebook():
182
+ print(msg, flush=True)
183
+ else:
184
+ print(f"\n{msg} {self.DIM}[{self.RESET}", end='', flush=True)
185
+
186
+ def on_step(self, msg: str, current: int, total: int) -> None:
187
+ from kumoai import in_vnext_notebook
188
+
189
+ if in_vnext_notebook():
190
+ return
191
+
192
+ msg = f"{self.DIM}#{self.RESET}"
193
+ if current == total:
194
+ msg += f"{self.DIM}]{self.RESET}"
195
+
196
+ print(msg, end='', flush=True)
69
197
 
70
198
 
71
199
  class ColoredMofNCompleteColumn(MofNCompleteColumn):
@@ -103,71 +231,51 @@ class RichProgressLogger(ProgressLogger):
103
231
  self._live: Live | None = None
104
232
  self._exception: bool = False
105
233
 
106
- def init_progress(self, total: int, description: str) -> None:
107
- assert self._progress is None
108
- if self.verbose:
109
- self._progress = Progress(
110
- TextColumn(f' ↳ {description}', style='dim'),
111
- BarColumn(bar_width=None),
112
- ColoredMofNCompleteColumn(style='dim'),
113
- TextColumn('•', style='dim'),
114
- ColoredTimeRemainingColumn(style='dim'),
115
- )
116
- self._task = self._progress.add_task("Progress", total=total)
117
-
118
- def step(self) -> None:
119
- if self.verbose:
120
- assert self._progress is not None
121
- assert self._task is not None
122
- self._progress.update(self._task, advance=1) # type: ignore
123
-
124
- def __enter__(self) -> Self:
125
- from kumoai import in_notebook
126
-
127
- super().__enter__()
128
-
129
- if self.depth > 1:
130
- return self
234
+ def on_enter(self) -> None:
235
+ self._live = Live(
236
+ self,
237
+ refresh_per_second=self.refresh_per_second,
238
+ vertical_overflow='visible',
239
+ )
240
+ self._live.start()
131
241
 
132
- if not in_notebook(): # Render progress bar in TUI.
133
- sys.stdout.write("\x1b]9;4;3\x07")
134
- sys.stdout.flush()
242
+ def on_exit(self, error: bool) -> None:
243
+ self._exception = error
135
244
 
136
- if self.verbose:
137
- self._live = Live(
138
- self,
139
- refresh_per_second=self.refresh_per_second,
140
- vertical_overflow='visible',
141
- )
142
- self._live.start()
245
+ if self._progress is not None:
246
+ self._progress.stop()
143
247
 
144
- return self
248
+ if self._live is not None:
249
+ self._live.update(self, refresh=True)
250
+ self._live.stop()
145
251
 
146
- def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
147
- from kumoai import in_notebook
252
+ self._progress = None
253
+ self._task = None
254
+ self._live = None
148
255
 
149
- super().__exit__(exc_type, exc_val, exc_tb)
256
+ def on_log(self, msg: str) -> None:
257
+ pass
150
258
 
151
- if self.depth > 1:
152
- return
259
+ def on_init_progress(self, msg: str, total: int) -> None:
260
+ self._progress = Progress(
261
+ TextColumn(f' ↳ {msg}', style='dim'),
262
+ BarColumn(bar_width=None),
263
+ ColoredMofNCompleteColumn(style='dim'),
264
+ TextColumn('•', style='dim'),
265
+ ColoredTimeRemainingColumn(style='dim'),
266
+ )
267
+ self._task = self._progress.add_task("Progress", total=total)
153
268
 
154
- if exc_type is not None:
155
- self._exception = True
269
+ def on_step(self, msg: str, current: int, total: int) -> None:
270
+ assert self._progress is not None
271
+ assert self._task is not None
272
+ self._progress.update(self._task, advance=1) # type: ignore
156
273
 
157
- if self._progress is not None:
274
+ if current == total:
158
275
  self._progress.stop()
159
276
  self._progress = None
160
277
  self._task = None
161
278
 
162
- if self._live is not None:
163
- self._live.update(self, refresh=True)
164
- self._live.stop()
165
- self._live = None
166
-
167
- if not in_notebook():
168
- sys.stdout.write("\x1b]9;4;0\x07")
169
- sys.stdout.flush()
170
-
171
279
  def __rich_console__(
172
280
  self,
173
281
  console: Console,
@@ -198,7 +306,7 @@ class RichProgressLogger(ProgressLogger):
198
306
 
199
307
  yield table
200
308
 
201
- if self.verbose and self._progress is not None:
309
+ if self._progress is not None:
202
310
  yield self._progress.get_renderable()
203
311
 
204
312
 
@@ -211,82 +319,50 @@ class StreamlitProgressLogger(ProgressLogger):
211
319
  super().__init__(msg=msg, verbose=verbose)
212
320
 
213
321
  self._status: Any = None
214
-
215
- self._total = 0
216
- self._current = 0
217
- self._description: str = ''
218
322
  self._progress: Any = None
219
323
 
220
- def __enter__(self) -> Self:
221
- super().__enter__()
324
+ @staticmethod
325
+ def _sanitize_text(msg: str) -> str:
326
+ return re.sub(r'\[/?bold\]', '**', msg)
222
327
 
328
+ def on_enter(self) -> None:
223
329
  import streamlit as st
224
330
 
225
- if self.depth > 1:
226
- return self
227
-
228
331
  # Adjust layout for prettier output:
229
332
  st.markdown(STREAMLIT_CSS, unsafe_allow_html=True)
230
333
 
231
- if self.verbose:
232
- self._status = st.status(
233
- f':blue[{self._sanitize_text(self.msg)}]',
334
+ self._status = st.status(
335
+ f':blue[{self._sanitize_text(self.msg)}]',
336
+ expanded=True,
337
+ )
338
+
339
+ def on_exit(self, error: bool) -> None:
340
+ if self._status is not None:
341
+ label = f'{self._sanitize_text(self.msg)} ({self.duration:.2f}s)'
342
+ self._status.update(
343
+ label=f':red[{label}]' if error else f':green[{label}]',
344
+ state='error' if error else 'complete',
234
345
  expanded=True,
235
346
  )
236
347
 
237
- return self
348
+ def on_log(self, msg: str) -> None:
349
+ if self._status is not None:
350
+ self._status.write(msg)
238
351
 
239
- def log(self, msg: str) -> None:
240
- super().log(msg)
241
- if self.verbose and self._status is not None:
242
- self._status.write(self._sanitize_text(msg))
243
-
244
- def init_progress(self, total: int, description: str) -> None:
245
- if self.verbose and self._status is not None:
246
- self._total = total
247
- self._current = 0
248
- self._description = self._sanitize_text(description)
249
- percent = min(self._current / self._total, 1.0)
352
+ def on_init_progress(self, msg: str, total: int) -> None:
353
+ if self._status is not None:
250
354
  self._progress = self._status.progress(
251
- value=percent,
252
- text=f'{self._description} [{self._current}/{self._total}]',
355
+ value=0.0,
356
+ text=f'{msg} [{0}/{total}]',
253
357
  )
254
358
 
255
- def step(self) -> None:
256
- self._current += 1
257
-
258
- if self.verbose and self._progress is not None:
259
- percent = min(self._current / self._total, 1.0)
359
+ def on_step(self, msg: str, current: int, total: int) -> None:
360
+ if self._progress is not None:
260
361
  self._progress.progress(
261
- value=percent,
262
- text=f'{self._description} [{self._current}/{self._total}]',
362
+ value=min(current / total, 1.0),
363
+ text=f'{msg} [{current}/{total}]',
263
364
  )
264
365
 
265
- def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
266
- super().__exit__(exc_type, exc_val, exc_tb)
267
-
268
- if not self.verbose or self._status is None or self.depth > 1:
269
- return
270
-
271
- label = f'{self._sanitize_text(self.msg)} ({self.duration:.2f}s)'
272
-
273
- if exc_type is not None:
274
- self._status.update(
275
- label=f':red[{label}]',
276
- state='error',
277
- expanded=True,
278
- )
279
- else:
280
- self._status.update(
281
- label=f':green[{label}]',
282
- state='complete',
283
- expanded=True,
284
- )
285
-
286
- @staticmethod
287
- def _sanitize_text(msg: str) -> str:
288
- return re.sub(r'\[/?bold\]', '**', msg)
289
-
290
366
 
291
367
  STREAMLIT_CSS = """
292
368
  <style>
kumoai/utils/sql.py CHANGED
@@ -1,3 +1,3 @@
1
- def quote_ident(name: str) -> str:
1
+ def quote_ident(ident: str, char: str = '"') -> str:
2
2
  r"""Quotes a SQL identifier."""
3
- return '"' + name.replace('"', '""') + '"'
3
+ return char + ident.replace(char, char + char) + char
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kumoai
3
- Version: 2.15.0.dev202601121731
3
+ Version: 2.15.0.dev202601181732
4
4
  Summary: AI on the Modern Data Stack
5
5
  Author-email: "Kumo.AI" <hello@kumo.ai>
6
6
  License-Expression: MIT
@@ -1,8 +1,8 @@
1
1
  kumoai/kumolib.cpython-313-darwin.so,sha256=waBv-DiZ3WcasxiCQ-OM9EbSTgTtCfBTZIibXAK-JiQ,232816
2
2
  kumoai/_logging.py,sha256=U2_5ROdyk92P4xO4H2WJV8EC7dr6YxmmnM-b7QX9M7I,886
3
3
  kumoai/mixin.py,sha256=MP413xzuCqWhxAPUHmloLA3j4ZyF1tEtfi516b_hOXQ,812
4
- kumoai/_version.py,sha256=Xo4G3EKaSBfPJqe6ahgplEBLNj9WpK6dS9XukJO3Dlk,39
5
- kumoai/__init__.py,sha256=x6Emn6VesHQz0wR7ZnbddPRYO9A5-0JTHDkzJ3Ocq6w,10907
4
+ kumoai/_version.py,sha256=2ksd2GuX-AZRJGtcuDxCIRV0etIpcKZFRxJlf6Of638,39
5
+ kumoai/__init__.py,sha256=n2Mi2n5S_WKpxpCInQKfGEmsIWVwrX86nGnYn5HwtIE,11171
6
6
  kumoai/formatting.py,sha256=jA_rLDCGKZI8WWCha-vtuLenVKTZvli99Tqpurz1H84,953
7
7
  kumoai/futures.py,sha256=oJFIfdCM_3nWIqQteBKYMY4fPhoYlYWE_JA2o6tx-ng,3737
8
8
  kumoai/jobs.py,sha256=NrdLEFNo7oeCYSy-kj2nAvCFrz9BZ_xrhkqHFHk5ksY,2496
@@ -12,23 +12,23 @@ kumoai/spcs.py,sha256=N31d7rLa-bgYh8e2J4YzX1ScxGLqiVXrqJnCl1y4Mts,4139
12
12
  kumoai/_singleton.py,sha256=UTwrbDkoZSGB8ZelorvprPDDv9uZkUi1q_SrmsyngpQ,836
13
13
  kumoai/experimental/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  kumoai/experimental/rfm/relbench.py,sha256=cVsxxV3TIL3PLEoYb-8tAVW3GSef6NQAd3rxdHJL63I,2276
15
- kumoai/experimental/rfm/graph.py,sha256=JtpnP-NIowKgtEggif_MzgXjbc6mi3tUyBGi1WuzsI0,46346
15
+ kumoai/experimental/rfm/graph.py,sha256=4Jo17oYSoZouzvNQT2-Ai9GOX-bIdefPcxj_gcoM3dI,46873
16
16
  kumoai/experimental/rfm/__init__.py,sha256=bW2XyYtkbdiu_iICYFF2Fu1Fx5fyGbqne6m_6c1P-fY,7016
17
17
  kumoai/experimental/rfm/sagemaker.py,sha256=6fyXO1Jd_scq-DH7kcv6JcV8QPyTbh4ceqwQDPADlZ0,4963
18
- kumoai/experimental/rfm/rfm.py,sha256=dCDHR-yNhtdH2Ja1yasbwSYYstDxlEkVOUNCUEOCTLM,60002
18
+ kumoai/experimental/rfm/rfm.py,sha256=XsxwiDIvlZ_js7rvvffrOiXFsLX15-C7N0T9M-aptCw,60017
19
19
  kumoai/experimental/rfm/authenticate.py,sha256=G2RkRWznMVQUzvhvbKhn0bMCY7VmoNYxluz3THRqSdE,18851
20
20
  kumoai/experimental/rfm/task_table.py,sha256=n_gZNQlCqHOiAkbeaa18nnQ-amt1oWKA9riO2rkrZuw,9847
21
21
  kumoai/experimental/rfm/backend/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  kumoai/experimental/rfm/backend/sqlite/__init__.py,sha256=jl-DBbhsqQ-dUXyWhyQTM1AU2qNAtXCmi1mokdhtBTg,902
23
23
  kumoai/experimental/rfm/backend/sqlite/table.py,sha256=WqYtd_rwlawItRMXZUfv14qdyU6huQmODuFjDo483dI,6683
24
- kumoai/experimental/rfm/backend/sqlite/sampler.py,sha256=G5INoEAvoPlg8pSN_QlJIOy2B-2D40eTDPZJxU0Dr0g,18651
24
+ kumoai/experimental/rfm/backend/sqlite/sampler.py,sha256=I-zaSMd5XLg0qaJOoCR8arFBauUfhW_ZMl7gI97ress,18699
25
25
  kumoai/experimental/rfm/backend/local/__init__.py,sha256=2s9sSA-E-8pfkkzCH4XPuaSxSznEURMfMgwEIfYYPsg,1014
26
26
  kumoai/experimental/rfm/backend/local/table.py,sha256=GKeYGcu52ztCU8EBMqp5UVj85E145Ug41xiCPiTCXq4,3489
27
27
  kumoai/experimental/rfm/backend/local/graph_store.py,sha256=RHhkI13KpdPxqb4vXkwEwuFiX5DkrEsfZsOLywNnrvU,11294
28
28
  kumoai/experimental/rfm/backend/local/sampler.py,sha256=UKxTjsYs00sYuV_LAlDuZOvQq0BZzPCzZK1Fki2Fd70,10726
29
29
  kumoai/experimental/rfm/backend/snow/__init__.py,sha256=BYfsiuJ4Ee30GjG9EuUtitMHXnRfvVKi85zNlIwldV4,993
30
- kumoai/experimental/rfm/backend/snow/table.py,sha256=9N7TOcXX8hhAjCawnhuvQCArBFTCdng3gBakunUxg90,8892
31
- kumoai/experimental/rfm/backend/snow/sampler.py,sha256=qst_9nRuiAT-rJecq9ZX3DbNFgIK4MZxeK9_HP8i5NM,14602
30
+ kumoai/experimental/rfm/backend/snow/table.py,sha256=1RXpPiTxawTTOFprXvu7jDLG0ZGio_vE9lSfB6wqbWM,9078
31
+ kumoai/experimental/rfm/backend/snow/sampler.py,sha256=tDOEiPTFFG6pWDcuuTvaOBAsMJLsxu4PzqryIgH1Kb4,16322
32
32
  kumoai/experimental/rfm/pquery/__init__.py,sha256=X0O3EIq5SMfBEE-ii5Cq6iDhR3s3XMXB52Cx5htoePw,152
33
33
  kumoai/experimental/rfm/pquery/pandas_executor.py,sha256=MwSvFRwLq-z19LEdF0G0AT7Gj9tCqu-XLEA7mNbqXwc,18454
34
34
  kumoai/experimental/rfm/pquery/executor.py,sha256=gs5AVNaA50ci8zXOBD3qt5szdTReSwTs4BGuEyx4BEE,2728
@@ -37,14 +37,14 @@ kumoai/experimental/rfm/infer/categorical.py,sha256=VwNaKwKbRYkTxEJ1R6gziffC8dGs
37
37
  kumoai/experimental/rfm/infer/time_col.py,sha256=iw_aUcHD2bHr7uRa3E7uDC30kU37aLIRTVAFdQEpt68,1818
38
38
  kumoai/experimental/rfm/infer/pkey.py,sha256=IaJI5GHK8ds_a3AOr3YYVgUlSmYYEgr4Nu92s2RyBV4,4412
39
39
  kumoai/experimental/rfm/infer/id.py,sha256=ZIO0DWIoiEoS_8MVc5lkqBfkTWWQ0yGCgjkwLdaYa_Q,908
40
- kumoai/experimental/rfm/infer/dtype.py,sha256=FyAqvtrOWQC9hGrhQ7sC4BAI6c9k6ew-fo8ClS1sewM,2782
40
+ kumoai/experimental/rfm/infer/dtype.py,sha256=fbRRyyKSzO4riqX3RlhvBK7DhnjhwTgZVUjQ9inVPYI,2811
41
41
  kumoai/experimental/rfm/infer/__init__.py,sha256=8GDxQKd0pxZULdk7mpwl3CsOpL4v2HPuPEsbi2t_vzc,519
42
42
  kumoai/experimental/rfm/infer/timestamp.py,sha256=vM9--7eStzaGG13Y-oLYlpNJyhL6f9dp17HDXwtl_DM,1094
43
43
  kumoai/experimental/rfm/infer/stype.py,sha256=fu4zsOB-C7jNeMnq6dsK4bOZSewe7PtZe_AkohSRLoM,894
44
44
  kumoai/experimental/rfm/base/sql_sampler.py,sha256=_go8TnH7AHki-0gg_pB7xd228VYhogQh10OkxT7PEnI,15682
45
45
  kumoai/experimental/rfm/base/mapper.py,sha256=WbWXSF8Vkdeud7UeQ2JgSX7z4d27b_b6o7nR4zET1aw,2420
46
46
  kumoai/experimental/rfm/base/__init__.py,sha256=rjmMux5lG8srw1bjQGcFQFv6zET9e5riP81nPkw28Jg,724
47
- kumoai/experimental/rfm/base/utils.py,sha256=MODr8v9aeIxwwdO6N2V8mzdjpOkBgFFcxvfFsXYfNm8,892
47
+ kumoai/experimental/rfm/base/utils.py,sha256=Easg1bvjPLR8oZIoxIQCtCyl92pp2dUskdnSv1eayxQ,1133
48
48
  kumoai/experimental/rfm/base/table.py,sha256=eJuOUM64VWDkHaslNgeR5A_FZjlPF_4czC8OfFGR62E,26015
49
49
  kumoai/experimental/rfm/base/sampler.py,sha256=2G6VmgAGV1mSQWHK4wUgf5Ngr8nnH8Hg6_D3sPZZx1A,31951
50
50
  kumoai/experimental/rfm/base/expression.py,sha256=Y7NtLTnKlx6euG_N3fLTcrFKheB6P5KS_jhCfoXV9DE,1252
@@ -60,9 +60,9 @@ kumoai/artifact_export/job.py,sha256=GEisSwvcjK_35RgOfsLXGgxMTXIWm765B_BW_Kgs-V0
60
60
  kumoai/artifact_export/__init__.py,sha256=BsfDrc3mCHpO9-BqvqKm8qrXDIwfdaoH5UIoG4eQkc4,238
61
61
  kumoai/utils/datasets.py,sha256=ptKIUoBONVD55pTVNdRCkQT3NWdN_r9UAUu4xewPa3U,2928
62
62
  kumoai/utils/__init__.py,sha256=6S-UtwjeLpnCYRCCIEWhkitPYGaqOGXC1ChE13DzXiU,256
63
- kumoai/utils/display.py,sha256=gnQR8QO0QQYfusefr7lObVEwZ3xajsv0XhhjAqOlz1A,2432
64
- kumoai/utils/progress_logger.py,sha256=rRcfWnfV6uHuvb7cD0mIIfUz3JvnSae0U4SesncODU8,9505
65
- kumoai/utils/sql.py,sha256=f6lR6rBEW7Dtk0NdM26dOZXUHDizEHb1WPlBCJrwoq0,118
63
+ kumoai/utils/display.py,sha256=QmgeQQT7SzoC1CK2A0ftWbfkEuVN4KQfrKoPCrCDaGc,2626
64
+ kumoai/utils/progress_logger.py,sha256=1PtXxfMteg2nyQAfTGx6qnljiZMZvhwDTndQ9_4_nCE,12161
65
+ kumoai/utils/sql.py,sha256=CNKa-M56QiWoCSe9WLuumahsu3_ugQGr2YoTbveFHq0,147
66
66
  kumoai/utils/forecasting.py,sha256=-nDS6ucKNfQhTQOfebjefj0wwWH3-KYNslIomxwwMBM,7415
67
67
  kumoai/codegen/generate.py,sha256=SvfWWa71xSAOjH9645yQvgoEM-o4BYjupM_EpUxqB_E,7331
68
68
  kumoai/codegen/naming.py,sha256=_XVQGxHfuub4bhvyuBKjltD5Lm_oPpibvP_LZteCGk0,3021
@@ -117,8 +117,8 @@ kumoai/trainer/__init__.py,sha256=zUdFl-f-sBWmm2x8R-rdVzPBeU2FaMzUY5mkcgoTa1k,93
117
117
  kumoai/trainer/online_serving.py,sha256=9cddb5paeZaCgbUeceQdAOxysCtV5XP-KcsgFz_XR5w,9566
118
118
  kumoai/trainer/distilled_trainer.py,sha256=2pPs5clakNxkLfaak7uqPJOrpTWe1RVVM7ztDSqQZvU,6484
119
119
  kumoai/trainer/trainer.py,sha256=hBXO7gwpo3t59zKFTeIkK65B8QRmWCwO33sbDuEAPlY,20133
120
- kumoai-2.15.0.dev202601121731.dist-info/RECORD,,
121
- kumoai-2.15.0.dev202601121731.dist-info/WHEEL,sha256=oqGJCpG61FZJmvyZ3C_0aCv-2mdfcY9e3fXvyUNmWfM,136
122
- kumoai-2.15.0.dev202601121731.dist-info/top_level.txt,sha256=YjU6UcmomoDx30vEXLsOU784ED7VztQOsFApk1SFwvs,7
123
- kumoai-2.15.0.dev202601121731.dist-info/METADATA,sha256=hIe7QHOe9wHsuE1EZQjEoBNGi6W8Pr4GNT5tIAmVLUI,2564
124
- kumoai-2.15.0.dev202601121731.dist-info/licenses/LICENSE,sha256=TbWlyqRmhq9PEzCaTI0H0nWLQCCOywQM8wYH8MbjfLo,1102
120
+ kumoai-2.15.0.dev202601181732.dist-info/RECORD,,
121
+ kumoai-2.15.0.dev202601181732.dist-info/WHEEL,sha256=oqGJCpG61FZJmvyZ3C_0aCv-2mdfcY9e3fXvyUNmWfM,136
122
+ kumoai-2.15.0.dev202601181732.dist-info/top_level.txt,sha256=YjU6UcmomoDx30vEXLsOU784ED7VztQOsFApk1SFwvs,7
123
+ kumoai-2.15.0.dev202601181732.dist-info/METADATA,sha256=ETr8-9Zfq1pM_Smk8KOWZZm14cRdtNl9vcd_hqSQBKg,2564
124
+ kumoai-2.15.0.dev202601181732.dist-info/licenses/LICENSE,sha256=TbWlyqRmhq9PEzCaTI0H0nWLQCCOywQM8wYH8MbjfLo,1102