moose-lib 0.6.95__tar.gz → 0.6.97__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.

Potentially problematic release.


This version of moose-lib might be problematic. Click here for more details.

Files changed (48) hide show
  1. {moose_lib-0.6.95 → moose_lib-0.6.97}/PKG-INFO +1 -1
  2. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/commons.py +83 -1
  3. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/config/config_file.py +37 -1
  4. moose_lib-0.6.97/moose_lib/config/runtime.py +198 -0
  5. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/ingest_pipeline.py +28 -5
  6. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/stream.py +116 -0
  7. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/streaming/streaming_function_runner.py +25 -39
  8. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib.egg-info/PKG-INFO +1 -1
  9. moose_lib-0.6.95/moose_lib/config/runtime.py +0 -110
  10. {moose_lib-0.6.95 → moose_lib-0.6.97}/README.md +0 -0
  11. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/__init__.py +0 -0
  12. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/blocks.py +0 -0
  13. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/clients/__init__.py +0 -0
  14. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/clients/redis_client.py +0 -0
  15. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/config/__init__.py +0 -0
  16. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/data_models.py +0 -0
  17. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/__init__.py +0 -0
  18. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/_registry.py +0 -0
  19. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/consumption.py +0 -0
  20. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/ingest_api.py +0 -0
  21. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/life_cycle.py +0 -0
  22. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/materialized_view.py +0 -0
  23. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/olap_table.py +0 -0
  24. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/registry.py +0 -0
  25. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/sql_resource.py +0 -0
  26. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/types.py +0 -0
  27. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/view.py +0 -0
  28. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2/workflow.py +0 -0
  29. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/dmv2_serializer.py +0 -0
  30. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/internal.py +0 -0
  31. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/main.py +0 -0
  32. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/query_builder.py +0 -0
  33. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/query_param.py +0 -0
  34. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/streaming/__init__.py +0 -0
  35. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/utilities/__init__.py +0 -0
  36. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib/utilities/sql.py +0 -0
  37. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib.egg-info/SOURCES.txt +0 -0
  38. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib.egg-info/dependency_links.txt +0 -0
  39. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib.egg-info/requires.txt +0 -0
  40. {moose_lib-0.6.95 → moose_lib-0.6.97}/moose_lib.egg-info/top_level.txt +0 -0
  41. {moose_lib-0.6.95 → moose_lib-0.6.97}/setup.cfg +0 -0
  42. {moose_lib-0.6.95 → moose_lib-0.6.97}/setup.py +0 -0
  43. {moose_lib-0.6.95 → moose_lib-0.6.97}/tests/__init__.py +0 -0
  44. {moose_lib-0.6.95 → moose_lib-0.6.97}/tests/conftest.py +0 -0
  45. {moose_lib-0.6.95 → moose_lib-0.6.97}/tests/test_moose.py +0 -0
  46. {moose_lib-0.6.95 → moose_lib-0.6.97}/tests/test_query_builder.py +0 -0
  47. {moose_lib-0.6.95 → moose_lib-0.6.97}/tests/test_redis_client.py +0 -0
  48. {moose_lib-0.6.95 → moose_lib-0.6.97}/tests/test_s3queue_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moose_lib
3
- Version: 0.6.95
3
+ Version: 0.6.97
4
4
  Home-page: https://www.fiveonefour.com/moose
5
5
  Author: Fiveonefour Labs Inc.
6
6
  Author-email: support@fiveonefour.com
@@ -4,8 +4,9 @@ from datetime import datetime, timezone
4
4
 
5
5
  import requests
6
6
  import json
7
- from typing import Optional, Literal
7
+ from typing import Optional, Literal, Any, Union, Callable
8
8
  import os
9
+ from kafka import KafkaConsumer, KafkaProducer
9
10
 
10
11
 
11
12
  class CliLogData:
@@ -100,3 +101,84 @@ class EnhancedJSONEncoder(json.JSONEncoder):
100
101
  if dataclasses.is_dataclass(o):
101
102
  return dataclasses.asdict(o)
102
103
  return super().default(o)
104
+
105
+
106
+ def _build_kafka_kwargs(
107
+ broker: Union[str, list[str]],
108
+ sasl_username: Optional[str] = None,
109
+ sasl_password: Optional[str] = None,
110
+ sasl_mechanism: Optional[str] = None,
111
+ security_protocol: Optional[str] = None,
112
+ ) -> dict[str, Any]:
113
+ """Builds common Kafka client kwargs from provided parameters."""
114
+ kwargs: dict[str, Any] = {
115
+ "bootstrap_servers": broker,
116
+ }
117
+ if sasl_mechanism:
118
+ kwargs["sasl_mechanism"] = sasl_mechanism
119
+ if sasl_username is not None:
120
+ kwargs["sasl_plain_username"] = sasl_username
121
+ if sasl_password is not None:
122
+ kwargs["sasl_plain_password"] = sasl_password
123
+ if security_protocol is not None:
124
+ kwargs["security_protocol"] = security_protocol
125
+ return kwargs
126
+
127
+
128
+ def get_kafka_consumer(
129
+ *,
130
+ broker: Union[str, list[str]],
131
+ client_id: str,
132
+ group_id: str,
133
+ sasl_username: Optional[str] = None,
134
+ sasl_password: Optional[str] = None,
135
+ sasl_mechanism: Optional[str] = None,
136
+ security_protocol: Optional[str] = None,
137
+ value_deserializer=lambda m: json.loads(m.decode("utf-8")),
138
+ **extra_kwargs: Any,
139
+ ) -> KafkaConsumer:
140
+ """Creates a configured KafkaConsumer with optional SASL/security settings."""
141
+ kwargs = _build_kafka_kwargs(
142
+ broker,
143
+ sasl_username=sasl_username,
144
+ sasl_password=sasl_password,
145
+ sasl_mechanism=sasl_mechanism,
146
+ security_protocol=security_protocol,
147
+ )
148
+ return KafkaConsumer(
149
+ client_id=client_id,
150
+ group_id=group_id,
151
+ value_deserializer=value_deserializer,
152
+ **kwargs,
153
+ **extra_kwargs,
154
+ )
155
+
156
+
157
+ def get_kafka_producer(
158
+ *,
159
+ broker: Union[str, list[str]],
160
+ sasl_username: Optional[str] = None,
161
+ sasl_password: Optional[str] = None,
162
+ sasl_mechanism: Optional[str] = None,
163
+ security_protocol: Optional[str] = None,
164
+ max_request_size: Optional[int] = None,
165
+ value_serializer: Optional[Callable[[Any], bytes]] = None,
166
+ **extra_kwargs: Any,
167
+ ) -> KafkaProducer:
168
+ """Creates a configured KafkaProducer with optional SASL/security settings.
169
+ """
170
+ kwargs = _build_kafka_kwargs(
171
+ broker,
172
+ sasl_username=sasl_username,
173
+ sasl_password=sasl_password,
174
+ sasl_mechanism=sasl_mechanism,
175
+ security_protocol=security_protocol,
176
+ )
177
+ if max_request_size is not None:
178
+ kwargs["max_request_size"] = max_request_size
179
+ kwargs["max_in_flight_requests_per_connection"] = 1
180
+ if value_serializer is not None:
181
+ kwargs["value_serializer"] = value_serializer
182
+ # Allow callers to pass through additional Kafka configs like linger_ms, acks, retries, etc.
183
+ kwargs.update(extra_kwargs)
184
+ return KafkaProducer(**kwargs)
@@ -9,6 +9,7 @@ import tomllib
9
9
  from dataclasses import dataclass
10
10
  from typing import Optional
11
11
 
12
+
12
13
  @dataclass
13
14
  class ClickHouseConfig:
14
15
  """ClickHouse configuration settings from moose.config.toml."""
@@ -20,11 +21,26 @@ class ClickHouseConfig:
20
21
  use_ssl: bool = False
21
22
  native_port: Optional[int] = None
22
23
 
24
+
25
+ @dataclass
26
+ class KafkaConfig:
27
+ """Redpanda/Kafka configuration settings from moose.config.toml."""
28
+ broker: str
29
+ message_timeout_ms: int
30
+ sasl_username: Optional[str] = None
31
+ sasl_password: Optional[str] = None
32
+ sasl_mechanism: Optional[str] = None
33
+ security_protocol: Optional[str] = None
34
+ namespace: Optional[str] = None
35
+
36
+
23
37
  @dataclass
24
38
  class ProjectConfig:
25
39
  """Project configuration from moose.config.toml."""
26
40
  language: str
27
41
  clickhouse_config: ClickHouseConfig
42
+ kafka_config: Optional[KafkaConfig] = None
43
+
28
44
 
29
45
  def find_config_file(start_dir: str = os.getcwd()) -> Optional[str]:
30
46
  """Find moose.config.toml by walking up directory tree.
@@ -48,6 +64,7 @@ def find_config_file(start_dir: str = os.getcwd()) -> Optional[str]:
48
64
  current_dir = parent_dir
49
65
  return None
50
66
 
67
+
51
68
  def read_project_config() -> ProjectConfig:
52
69
  """Read and parse moose.config.toml.
53
70
 
@@ -74,9 +91,28 @@ def read_project_config() -> ProjectConfig:
74
91
  native_port=config_data["clickhouse_config"].get("native_port")
75
92
  )
76
93
 
94
+ def _parse_kafka(section_name: str) -> Optional[KafkaConfig]:
95
+ sec = config_data.get(section_name)
96
+ if sec is None:
97
+ return None
98
+ return KafkaConfig(
99
+ broker=sec["broker"],
100
+ message_timeout_ms=sec.get("message_timeout_ms", 1000),
101
+ sasl_username=sec.get("sasl_username"),
102
+ sasl_password=sec.get("sasl_password"),
103
+ sasl_mechanism=sec.get("sasl_mechanism"),
104
+ security_protocol=sec.get("security_protocol"),
105
+ namespace=sec.get("namespace"),
106
+ )
107
+
108
+ kafka_cfg = _parse_kafka("kafka_config")
109
+ if kafka_cfg is None:
110
+ kafka_cfg = _parse_kafka("redpanda_config")
111
+
77
112
  return ProjectConfig(
78
113
  language=config_data["language"],
79
- clickhouse_config=clickhouse_config
114
+ clickhouse_config=clickhouse_config,
115
+ kafka_config=kafka_cfg,
80
116
  )
81
117
  except Exception as e:
82
118
  raise RuntimeError(f"Failed to parse moose.config.toml: {e}")
@@ -0,0 +1,198 @@
1
+ """
2
+ Runtime configuration management for Moose.
3
+
4
+ This module provides a singleton registry for managing runtime configuration settings,
5
+ particularly for ClickHouse connections.
6
+ """
7
+ import os
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class RuntimeClickHouseConfig:
14
+ """Runtime ClickHouse configuration settings."""
15
+ host: str
16
+ port: str
17
+ username: str
18
+ password: str
19
+ database: str
20
+ use_ssl: bool
21
+
22
+
23
+ @dataclass
24
+ class RuntimeKafkaConfig:
25
+ """Runtime Kafka configuration settings."""
26
+ broker: str
27
+ message_timeout_ms: int
28
+ sasl_username: Optional[str]
29
+ sasl_password: Optional[str]
30
+ sasl_mechanism: Optional[str]
31
+ security_protocol: Optional[str]
32
+ namespace: Optional[str]
33
+
34
+
35
+ class ConfigurationRegistry:
36
+ """Singleton registry for managing runtime configuration.
37
+
38
+ This class provides a centralized way to manage and access runtime configuration
39
+ settings, with fallback to file-based configuration when runtime settings are not set.
40
+ """
41
+ _instance: Optional['ConfigurationRegistry'] = None
42
+ _clickhouse_config: Optional[RuntimeClickHouseConfig] = None
43
+ _kafka_config: Optional[RuntimeKafkaConfig] = None
44
+
45
+ @classmethod
46
+ def get_instance(cls) -> 'ConfigurationRegistry':
47
+ """Get the singleton instance of ConfigurationRegistry.
48
+
49
+ Returns:
50
+ The singleton ConfigurationRegistry instance.
51
+ """
52
+ if not cls._instance:
53
+ cls._instance = cls()
54
+ return cls._instance
55
+
56
+ def set_clickhouse_config(self, config: RuntimeClickHouseConfig) -> None:
57
+ """Set the runtime ClickHouse configuration.
58
+
59
+ Args:
60
+ config: The ClickHouse configuration to use.
61
+ """
62
+ self._clickhouse_config = config
63
+
64
+ def set_kafka_config(self, config: 'RuntimeKafkaConfig') -> None:
65
+ """Set the runtime Kafka configuration.
66
+
67
+ Args:
68
+ config: The Kafka configuration to use.
69
+ """
70
+ self._kafka_config = config
71
+
72
+ def get_clickhouse_config(self) -> RuntimeClickHouseConfig:
73
+ """Get the current ClickHouse configuration.
74
+
75
+ If runtime configuration is not set, falls back to reading from moose.config.toml.
76
+
77
+ Returns:
78
+ The current ClickHouse configuration.
79
+ """
80
+ if self._clickhouse_config:
81
+ return self._clickhouse_config
82
+
83
+ # Fallback to reading from config file
84
+ from .config_file import read_project_config
85
+
86
+ def _env(name: str) -> Optional[str]:
87
+ val = os.environ.get(name)
88
+ if val is None:
89
+ return None
90
+ trimmed = val.strip()
91
+ return trimmed if trimmed else None
92
+
93
+ def _parse_bool(val: Optional[str]) -> Optional[bool]:
94
+ if val is None:
95
+ return None
96
+ v = val.strip().lower()
97
+ if v in ("1", "true", "yes", "on"):
98
+ return True
99
+ if v in ("0", "false", "no", "off"):
100
+ return False
101
+ return None
102
+
103
+ try:
104
+ config = read_project_config()
105
+
106
+ env_host = _env("MOOSE_CLICKHOUSE_CONFIG__HOST")
107
+ env_port = _env("MOOSE_CLICKHOUSE_CONFIG__HOST_PORT")
108
+ env_user = _env("MOOSE_CLICKHOUSE_CONFIG__USER")
109
+ env_password = _env("MOOSE_CLICKHOUSE_CONFIG__PASSWORD")
110
+ env_db = _env("MOOSE_CLICKHOUSE_CONFIG__DB_NAME")
111
+ env_use_ssl = _parse_bool(_env("MOOSE_CLICKHOUSE_CONFIG__USE_SSL"))
112
+
113
+ return RuntimeClickHouseConfig(
114
+ host=env_host or config.clickhouse_config.host,
115
+ port=(env_port or str(config.clickhouse_config.host_port)),
116
+ username=env_user or config.clickhouse_config.user,
117
+ password=env_password or config.clickhouse_config.password,
118
+ database=env_db or config.clickhouse_config.db_name,
119
+ use_ssl=(env_use_ssl if env_use_ssl is not None else config.clickhouse_config.use_ssl),
120
+ )
121
+ except Exception as e:
122
+ raise RuntimeError(f"Failed to get ClickHouse configuration: {e}")
123
+
124
+ def get_kafka_config(self) -> 'RuntimeKafkaConfig':
125
+ """Get the current Kafka configuration.
126
+
127
+ If runtime configuration is not set, falls back to reading from moose.config.toml
128
+ and environment variables (Redpanda- and Kafka-prefixed).
129
+
130
+ Returns:
131
+ The current Kafka configuration.
132
+ """
133
+ if self._kafka_config:
134
+ return self._kafka_config
135
+
136
+ from .config_file import read_project_config
137
+
138
+ def _env(name: str) -> Optional[str]:
139
+ val = os.environ.get(name)
140
+ if val is None:
141
+ return None
142
+ trimmed = val.strip()
143
+ return trimmed if trimmed else None
144
+
145
+ try:
146
+ config = read_project_config()
147
+
148
+ # Prefer Redpanda-prefixed env vars; fallback to Kafka-prefixed
149
+ broker = _env("MOOSE_REDPANDA_CONFIG__BROKER") or \
150
+ _env("MOOSE_KAFKA_CONFIG__BROKER")
151
+ message_timeout_ms = _env("MOOSE_REDPANDA_CONFIG__MESSAGE_TIMEOUT_MS") or \
152
+ _env("MOOSE_KAFKA_CONFIG__MESSAGE_TIMEOUT_MS")
153
+ sasl_username = _env("MOOSE_REDPANDA_CONFIG__SASL_USERNAME") or \
154
+ _env("MOOSE_KAFKA_CONFIG__SASL_USERNAME")
155
+ sasl_password = _env("MOOSE_REDPANDA_CONFIG__SASL_PASSWORD") or \
156
+ _env("MOOSE_KAFKA_CONFIG__SASL_PASSWORD")
157
+ sasl_mechanism = _env("MOOSE_REDPANDA_CONFIG__SASL_MECHANISM") or \
158
+ _env("MOOSE_KAFKA_CONFIG__SASL_MECHANISM")
159
+ security_protocol = _env("MOOSE_REDPANDA_CONFIG__SECURITY_PROTOCOL") or \
160
+ _env("MOOSE_KAFKA_CONFIG__SECURITY_PROTOCOL")
161
+ namespace = _env("MOOSE_REDPANDA_CONFIG__NAMESPACE") or \
162
+ _env("MOOSE_KAFKA_CONFIG__NAMESPACE")
163
+
164
+ file_kafka = config.kafka_config
165
+
166
+ def _to_int(value: Optional[str], fallback: int) -> int:
167
+ try:
168
+ return int(value) if value is not None else fallback
169
+ except Exception:
170
+ return fallback
171
+
172
+ return RuntimeKafkaConfig(
173
+ broker=broker or (file_kafka.broker if file_kafka else "localhost:19092"),
174
+ message_timeout_ms=_to_int(message_timeout_ms, file_kafka.message_timeout_ms if file_kafka else 1000),
175
+ sasl_username=sasl_username if sasl_username is not None else (
176
+ file_kafka.sasl_username if file_kafka else None),
177
+ sasl_password=sasl_password if sasl_password is not None else (
178
+ file_kafka.sasl_password if file_kafka else None),
179
+ sasl_mechanism=sasl_mechanism if sasl_mechanism is not None else (
180
+ file_kafka.sasl_mechanism if file_kafka else None),
181
+ security_protocol=security_protocol if security_protocol is not None else (
182
+ file_kafka.security_protocol if file_kafka else None),
183
+ namespace=namespace if namespace is not None else (file_kafka.namespace if file_kafka else None),
184
+ )
185
+ except Exception as e:
186
+ raise RuntimeError(f"Failed to get Kafka configuration: {e}")
187
+
188
+ def has_runtime_config(self) -> bool:
189
+ """Check if runtime configuration is set.
190
+
191
+ Returns:
192
+ True if either runtime clickhouse or kafka configuration is set, False otherwise.
193
+ """
194
+ return self._clickhouse_config is not None or self._kafka_config is not None
195
+
196
+
197
+ # Create singleton instance
198
+ config_registry = ConfigurationRegistry.get_instance()
@@ -4,8 +4,9 @@ Ingestion Pipeline definitions for Moose Data Model v2 (dmv2).
4
4
  This module provides classes for defining and configuring complete ingestion pipelines,
5
5
  which combine tables, streams, and ingestion APIs into a single cohesive unit.
6
6
  """
7
+ import warnings
7
8
  from typing import Any, Optional, Generic, TypeVar
8
- from pydantic import BaseModel
9
+ from pydantic import BaseModel, model_validator
9
10
 
10
11
  from .types import TypedMooseResource, T
11
12
  from .olap_table import OlapTable, OlapConfig
@@ -23,7 +24,7 @@ class IngestPipelineConfig(BaseModel):
23
24
  Attributes:
24
25
  table: Configuration for the OLAP table component.
25
26
  stream: Configuration for the stream component.
26
- ingest: Configuration for the ingest API component.
27
+ ingest_api: Configuration for the ingest API component.
27
28
  dead_letter_queue: Configuration for the dead letter queue.
28
29
  version: Optional version string applied to all created components.
29
30
  path: Optional custom path for the ingestion API endpoint.
@@ -32,12 +33,34 @@ class IngestPipelineConfig(BaseModel):
32
33
  """
33
34
  table: bool | OlapConfig = True
34
35
  stream: bool | StreamConfig = True
35
- ingest: bool | IngestConfig = True
36
+ ingest_api: bool | IngestConfig = True
36
37
  dead_letter_queue: bool | StreamConfig = True
37
38
  version: Optional[str] = None
38
39
  path: Optional[str] = None
39
40
  metadata: Optional[dict] = None
40
41
  life_cycle: Optional[LifeCycle] = None
42
+
43
+ # Legacy support - will be removed in future version
44
+ ingest: Optional[bool | IngestConfig] = None
45
+
46
+ @model_validator(mode='before')
47
+ @classmethod
48
+ def handle_legacy_ingest_param(cls, data):
49
+ """Handle backwards compatibility for the deprecated 'ingest' parameter."""
50
+ if isinstance(data, dict) and 'ingest' in data:
51
+ warnings.warn(
52
+ "The 'ingest' parameter is deprecated and will be removed in a future version. "
53
+ "Please use 'ingest_api' instead.",
54
+ DeprecationWarning,
55
+ stacklevel=3
56
+ )
57
+ # If ingest_api is not explicitly set, use the ingest value
58
+ if 'ingest_api' not in data:
59
+ data['ingest_api'] = data['ingest']
60
+ # Remove the legacy parameter
61
+ data = data.copy()
62
+ del data['ingest']
63
+ return data
41
64
 
42
65
  class IngestPipeline(TypedMooseResource, Generic[T]):
43
66
  """Creates and configures a linked Table, Stream, and Ingest API pipeline.
@@ -150,11 +173,11 @@ class IngestPipeline(TypedMooseResource, Generic[T]):
150
173
  stream_config.version = config.version
151
174
  stream_config.metadata = stream_metadata
152
175
  self.stream = Stream(name, stream_config, t=self._t)
153
- if config.ingest:
176
+ if config.ingest_api:
154
177
  if self.stream is None:
155
178
  raise ValueError("Ingest API needs a stream to write to.")
156
179
  ingest_config_dict = (
157
- IngestConfig() if config.ingest is True else config.ingest
180
+ IngestConfig() if config.ingest_api is True else config.ingest_api
158
181
  ).model_dump()
159
182
  ingest_config_dict["destination"] = self.stream
160
183
  if config.version:
@@ -6,14 +6,18 @@ including stream transformations, consumers, and dead letter queues.
6
6
  """
7
7
  import dataclasses
8
8
  import datetime
9
+ import json
9
10
  from typing import Any, Optional, Callable, Union, Literal, Generic
10
11
  from pydantic import BaseModel, ConfigDict, AliasGenerator
11
12
  from pydantic.alias_generators import to_camel
13
+ from kafka import KafkaProducer
12
14
 
13
15
  from .types import TypedMooseResource, ZeroOrMany, T, U
14
16
  from .olap_table import OlapTable
15
17
  from ._registry import _streams
16
18
  from .life_cycle import LifeCycle
19
+ from ..config.runtime import config_registry, RuntimeKafkaConfig
20
+ from ..commons import get_kafka_producer
17
21
 
18
22
 
19
23
  class StreamConfig(BaseModel):
@@ -110,6 +114,8 @@ class Stream(TypedMooseResource, Generic[T]):
110
114
  consumers: list[ConsumerEntry[T]]
111
115
  _multipleTransformations: Optional[Callable[[T], list[_RoutedMessage]]] = None
112
116
  default_dead_letter_queue: "Optional[DeadLetterQueue[T]]" = None
117
+ _memoized_producer: Optional[KafkaProducer] = None
118
+ _kafka_config_hash: Optional[str] = None
113
119
 
114
120
  def __init__(self, name: str, config: "StreamConfig" = None, **kwargs):
115
121
  super().__init__()
@@ -216,6 +222,116 @@ class Stream(TypedMooseResource, Generic[T]):
216
222
  """
217
223
  self._multipleTransformations = transformation
218
224
 
225
+ def _build_full_topic_name(self, namespace: Optional[str]) -> str:
226
+ """Build full topic name with optional namespace and version suffix."""
227
+ version_suffix = f"_{self.config.version.replace('.', '_')}" if self.config.version else ""
228
+ base = f"{self.name}{version_suffix}"
229
+ return f"{namespace}.{base}" if namespace else base
230
+
231
+ def _create_kafka_config_hash(self, cfg: RuntimeKafkaConfig) -> str:
232
+ import hashlib
233
+ config_string = ":".join(
234
+ str(x)
235
+ for x in (
236
+ cfg.broker,
237
+ cfg.message_timeout_ms,
238
+ cfg.sasl_username,
239
+ cfg.sasl_password,
240
+ cfg.sasl_mechanism,
241
+ cfg.security_protocol,
242
+ cfg.namespace,
243
+ )
244
+ )
245
+ return hashlib.sha256(config_string.encode()).hexdigest()[:16]
246
+
247
+ def _parse_brokers(self, broker_string: str) -> list[str]:
248
+ if not broker_string:
249
+ return []
250
+ return [b.strip() for b in broker_string.split(",") if b.strip()]
251
+
252
+ def _get_memoized_producer(self) -> tuple[KafkaProducer, Any]:
253
+ """Create or reuse a KafkaProducer using runtime configuration."""
254
+ cfg = config_registry.get_kafka_config()
255
+ current_hash = self._create_kafka_config_hash(cfg)
256
+
257
+ if self._memoized_producer is not None and self._kafka_config_hash == current_hash:
258
+ return self._memoized_producer, cfg
259
+
260
+ # Close previous producer if config changed
261
+ if self._memoized_producer is not None and self._kafka_config_hash != current_hash:
262
+ try:
263
+ self._memoized_producer.flush()
264
+ self._memoized_producer.close()
265
+ except Exception:
266
+ pass
267
+ finally:
268
+ self._memoized_producer = None
269
+
270
+ brokers = self._parse_brokers(getattr(cfg, "broker", "localhost:19092"))
271
+ if not brokers:
272
+ raise RuntimeError(f"No valid broker addresses found in: '{getattr(cfg, 'broker', '')}'")
273
+
274
+ producer = get_kafka_producer(
275
+ broker=brokers,
276
+ sasl_username=getattr(cfg, "sasl_username", None),
277
+ sasl_password=getattr(cfg, "sasl_password", None),
278
+ sasl_mechanism=getattr(cfg, "sasl_mechanism", None),
279
+ security_protocol=getattr(cfg, "security_protocol", None),
280
+ value_serializer=lambda v: v.model_dump_json().encode('utf-8'),
281
+ acks="all",
282
+ )
283
+
284
+ self._memoized_producer = producer
285
+ self._kafka_config_hash = current_hash
286
+ return producer, cfg
287
+
288
+ def close_producer(self) -> None:
289
+ """Closes the memoized Kafka producer if it exists."""
290
+ if self._memoized_producer is not None:
291
+ try:
292
+ self._memoized_producer.flush()
293
+ self._memoized_producer.close()
294
+ except Exception:
295
+ pass
296
+ finally:
297
+ self._memoized_producer = None
298
+ self._kafka_config_hash = None
299
+
300
+ def send(self, values: ZeroOrMany[T]) -> None:
301
+ """Send one or more records to this stream's Kafka topic.
302
+
303
+ Values are JSON-serialized using EnhancedJSONEncoder.
304
+ """
305
+ # Normalize inputs to a flat list of records
306
+ filtered: list[T] = []
307
+ if isinstance(values, list):
308
+ for v in values:
309
+ if v is None:
310
+ continue
311
+ else:
312
+ filtered.append(v)
313
+ elif values is not None:
314
+ filtered.append(values) # type: ignore[arg-type]
315
+
316
+ if len(filtered) == 0:
317
+ return
318
+
319
+ # ensure all records are instances of the stream's model type
320
+ model_type = self._t
321
+ for rec in filtered:
322
+ if not isinstance(rec, model_type):
323
+ raise TypeError(
324
+ f"Stream '{self.name}' expects instances of {model_type.__name__}, "
325
+ f"got {type(rec).__name__}"
326
+ )
327
+
328
+ producer, cfg = self._get_memoized_producer()
329
+ topic = self._build_full_topic_name(getattr(cfg, "namespace", None))
330
+
331
+ for rec in filtered:
332
+ producer.send(topic, value=rec)
333
+ producer.flush()
334
+
219
335
 
220
336
  class DeadLetterModel(BaseModel, Generic[T]):
221
337
  """Model for dead letter queue messages.
@@ -30,7 +30,12 @@ from typing import Optional, Callable, Tuple, Any
30
30
 
31
31
  from moose_lib.dmv2 import get_streams, DeadLetterModel
32
32
  from moose_lib import cli_log, CliLogData, DeadLetterQueue
33
- from moose_lib.commons import EnhancedJSONEncoder, moose_management_port
33
+ from moose_lib.commons import (
34
+ EnhancedJSONEncoder,
35
+ moose_management_port,
36
+ get_kafka_consumer,
37
+ get_kafka_producer,
38
+ )
34
39
 
35
40
  # Force stdout to be unbuffered
36
41
  sys.stdout = io.TextIOWrapper(
@@ -294,29 +299,18 @@ def create_consumer() -> KafkaConsumer:
294
299
  Returns:
295
300
  Configured KafkaConsumer instance
296
301
  """
297
- if sasl_config['mechanism'] is not None:
298
- return KafkaConsumer(
299
- source_topic.name,
300
- client_id="python_streaming_function_consumer",
301
- group_id=streaming_function_id,
302
- bootstrap_servers=broker,
303
- sasl_plain_username=sasl_config['username'],
304
- sasl_plain_password=sasl_config['password'],
305
- sasl_mechanism=sasl_config['mechanism'],
306
- security_protocol=args.security_protocol,
307
- # consumer_timeout_ms=10000,
308
- value_deserializer=lambda m: json.loads(m.decode('utf-8'))
309
- )
310
- else:
311
- log("No sasl mechanism specified. Using default consumer.")
312
- return KafkaConsumer(
313
- source_topic.name,
314
- client_id="python_streaming_function_consumer",
315
- group_id=streaming_function_id,
316
- bootstrap_servers=broker,
317
- # consumer_timeout_ms=10000,
318
- value_deserializer=lambda m: json.loads(m.decode('utf-8'))
319
- )
302
+ kwargs = dict(
303
+ broker=broker,
304
+ client_id="python_streaming_function_consumer",
305
+ group_id=streaming_function_id,
306
+ value_deserializer=lambda m: json.loads(m.decode("utf-8")),
307
+ sasl_username=sasl_config.get("username"),
308
+ sasl_password=sasl_config.get("password"),
309
+ sasl_mechanism=sasl_config.get("mechanism"),
310
+ security_protocol=args.security_protocol,
311
+ )
312
+ consumer = get_kafka_consumer(**kwargs)
313
+ return consumer
320
314
 
321
315
 
322
316
  def create_producer() -> Optional[KafkaProducer]:
@@ -330,20 +324,13 @@ def create_producer() -> Optional[KafkaProducer]:
330
324
  """
331
325
  max_request_size = KafkaProducer.DEFAULT_CONFIG['max_request_size'] if target_topic is None \
332
326
  else target_topic.max_message_bytes
333
- if sasl_config['mechanism'] is not None:
334
- return KafkaProducer(
335
- bootstrap_servers=broker,
336
- sasl_plain_username=sasl_config['username'],
337
- sasl_plain_password=sasl_config['password'],
338
- sasl_mechanism=sasl_config['mechanism'],
339
- security_protocol=args.security_protocol,
340
- max_request_size=max_request_size
341
- )
342
- log("No sasl mechanism specified. Using default producer.")
343
- return KafkaProducer(
344
- bootstrap_servers=broker,
345
- max_in_flight_requests_per_connection=1,
346
- max_request_size=max_request_size
327
+ return get_kafka_producer(
328
+ broker=broker,
329
+ sasl_username=sasl_config.get("username"),
330
+ sasl_password=sasl_config.get("password"),
331
+ sasl_mechanism=sasl_config.get("mechanism"),
332
+ security_protocol=args.security_protocol,
333
+ max_request_size=max_request_size,
347
334
  )
348
335
 
349
336
 
@@ -416,7 +403,6 @@ def main():
416
403
  kafka_refs['consumer'] = consumer
417
404
  kafka_refs['producer'] = producer
418
405
 
419
- # Subscribe to topic
420
406
  consumer.subscribe([source_topic.name])
421
407
 
422
408
  log("Kafka consumer and producer initialized in processing thread")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moose_lib
3
- Version: 0.6.95
3
+ Version: 0.6.97
4
4
  Home-page: https://www.fiveonefour.com/moose
5
5
  Author: Fiveonefour Labs Inc.
6
6
  Author-email: support@fiveonefour.com
@@ -1,110 +0,0 @@
1
- """
2
- Runtime configuration management for Moose.
3
-
4
- This module provides a singleton registry for managing runtime configuration settings,
5
- particularly for ClickHouse connections.
6
- """
7
- import os
8
- from dataclasses import dataclass
9
- from typing import Optional
10
-
11
- @dataclass
12
- class RuntimeClickHouseConfig:
13
- """Runtime ClickHouse configuration settings."""
14
- host: str
15
- port: str
16
- username: str
17
- password: str
18
- database: str
19
- use_ssl: bool
20
-
21
- class ConfigurationRegistry:
22
- """Singleton registry for managing runtime configuration.
23
-
24
- This class provides a centralized way to manage and access runtime configuration
25
- settings, with fallback to file-based configuration when runtime settings are not set.
26
- """
27
- _instance: Optional['ConfigurationRegistry'] = None
28
- _clickhouse_config: Optional[RuntimeClickHouseConfig] = None
29
-
30
- @classmethod
31
- def get_instance(cls) -> 'ConfigurationRegistry':
32
- """Get the singleton instance of ConfigurationRegistry.
33
-
34
- Returns:
35
- The singleton ConfigurationRegistry instance.
36
- """
37
- if not cls._instance:
38
- cls._instance = cls()
39
- return cls._instance
40
-
41
- def set_clickhouse_config(self, config: RuntimeClickHouseConfig) -> None:
42
- """Set the runtime ClickHouse configuration.
43
-
44
- Args:
45
- config: The ClickHouse configuration to use.
46
- """
47
- self._clickhouse_config = config
48
-
49
- def get_clickhouse_config(self) -> RuntimeClickHouseConfig:
50
- """Get the current ClickHouse configuration.
51
-
52
- If runtime configuration is not set, falls back to reading from moose.config.toml.
53
-
54
- Returns:
55
- The current ClickHouse configuration.
56
- """
57
- if self._clickhouse_config:
58
- return self._clickhouse_config
59
-
60
- # Fallback to reading from config file
61
- from .config_file import read_project_config
62
-
63
- def _env(name: str) -> Optional[str]:
64
- val = os.environ.get(name)
65
- if val is None:
66
- return None
67
- trimmed = val.strip()
68
- return trimmed if trimmed else None
69
-
70
- def _parse_bool(val: Optional[str]) -> Optional[bool]:
71
- if val is None:
72
- return None
73
- v = val.strip().lower()
74
- if v in ("1", "true", "yes", "on"):
75
- return True
76
- if v in ("0", "false", "no", "off"):
77
- return False
78
- return None
79
-
80
- try:
81
- config = read_project_config()
82
-
83
- env_host = _env("MOOSE_CLICKHOUSE_CONFIG__HOST")
84
- env_port = _env("MOOSE_CLICKHOUSE_CONFIG__HOST_PORT")
85
- env_user = _env("MOOSE_CLICKHOUSE_CONFIG__USER")
86
- env_password = _env("MOOSE_CLICKHOUSE_CONFIG__PASSWORD")
87
- env_db = _env("MOOSE_CLICKHOUSE_CONFIG__DB_NAME")
88
- env_use_ssl = _parse_bool(_env("MOOSE_CLICKHOUSE_CONFIG__USE_SSL"))
89
-
90
- return RuntimeClickHouseConfig(
91
- host=env_host or config.clickhouse_config.host,
92
- port=(env_port or str(config.clickhouse_config.host_port)),
93
- username=env_user or config.clickhouse_config.user,
94
- password=env_password or config.clickhouse_config.password,
95
- database=env_db or config.clickhouse_config.db_name,
96
- use_ssl=(env_use_ssl if env_use_ssl is not None else config.clickhouse_config.use_ssl),
97
- )
98
- except Exception as e:
99
- raise RuntimeError(f"Failed to get ClickHouse configuration: {e}")
100
-
101
- def has_runtime_config(self) -> bool:
102
- """Check if runtime configuration is set.
103
-
104
- Returns:
105
- True if runtime configuration is set, False otherwise.
106
- """
107
- return self._clickhouse_config is not None
108
-
109
- # Create singleton instance
110
- config_registry = ConfigurationRegistry.get_instance()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes