garf-executors 0.2.3__py3-none-any.whl → 1.1.3__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.
Files changed (49) hide show
  1. garf/executors/__init__.py +25 -0
  2. garf/executors/api_executor.py +228 -0
  3. garf/executors/bq_executor.py +179 -0
  4. garf/executors/config.py +52 -0
  5. garf/executors/entrypoints/__init__.py +0 -0
  6. garf/executors/entrypoints/cli.py +164 -0
  7. {garf_executors → garf/executors}/entrypoints/grpc_server.py +22 -9
  8. garf/executors/entrypoints/server.py +174 -0
  9. garf/executors/entrypoints/tracer.py +82 -0
  10. garf/executors/entrypoints/utils.py +140 -0
  11. garf/executors/exceptions.py +17 -0
  12. garf/executors/execution_context.py +117 -0
  13. garf/executors/executor.py +124 -0
  14. garf/executors/fetchers.py +128 -0
  15. garf/executors/garf_pb2.py +51 -0
  16. {garf_executors → garf/executors}/garf_pb2_grpc.py +45 -2
  17. garf/executors/query_processor.py +79 -0
  18. garf/executors/setup.py +58 -0
  19. garf/executors/sql_executor.py +144 -0
  20. garf/executors/telemetry.py +20 -0
  21. garf/executors/workflows/__init__.py +0 -0
  22. garf/executors/workflows/gcp_workflow.yaml +49 -0
  23. garf/executors/workflows/workflow.py +164 -0
  24. garf/executors/workflows/workflow_runner.py +172 -0
  25. garf_executors/__init__.py +9 -44
  26. garf_executors/api_executor.py +9 -121
  27. garf_executors/bq_executor.py +9 -161
  28. garf_executors/config.py +9 -37
  29. garf_executors/entrypoints/__init__.py +25 -0
  30. garf_executors/entrypoints/cli.py +9 -148
  31. garf_executors/entrypoints/grcp_server.py +25 -0
  32. garf_executors/entrypoints/server.py +9 -102
  33. garf_executors/entrypoints/tracer.py +8 -40
  34. garf_executors/entrypoints/utils.py +9 -124
  35. garf_executors/exceptions.py +11 -3
  36. garf_executors/execution_context.py +9 -100
  37. garf_executors/executor.py +9 -108
  38. garf_executors/fetchers.py +9 -63
  39. garf_executors/sql_executor.py +9 -125
  40. garf_executors/telemetry.py +10 -5
  41. garf_executors/workflow.py +8 -79
  42. {garf_executors-0.2.3.dist-info → garf_executors-1.1.3.dist-info}/METADATA +18 -5
  43. garf_executors-1.1.3.dist-info/RECORD +46 -0
  44. {garf_executors-0.2.3.dist-info → garf_executors-1.1.3.dist-info}/WHEEL +1 -1
  45. garf_executors-1.1.3.dist-info/entry_points.txt +2 -0
  46. {garf_executors-0.2.3.dist-info → garf_executors-1.1.3.dist-info}/top_level.txt +1 -0
  47. garf_executors/garf_pb2.py +0 -45
  48. garf_executors-0.2.3.dist-info/RECORD +0 -24
  49. garf_executors-0.2.3.dist-info/entry_points.txt +0 -2
@@ -19,29 +19,42 @@ import logging
19
19
  from concurrent import futures
20
20
 
21
21
  import grpc
22
+ from garf.executors import execution_context, garf_pb2, garf_pb2_grpc, setup
23
+ from garf.executors.entrypoints.tracer import initialize_tracer
22
24
  from google.protobuf.json_format import MessageToDict
23
25
  from grpc_reflection.v1alpha import reflection
24
26
 
25
- import garf_executors
26
- from garf_executors import garf_pb2, garf_pb2_grpc
27
- from garf_executors.entrypoints.tracer import initialize_tracer
28
-
29
27
 
30
28
  class GarfService(garf_pb2_grpc.GarfService):
31
29
  def Execute(self, request, context):
32
- query_executor = garf_executors.setup_executor(
30
+ query_executor = setup.setup_executor(
33
31
  request.source, request.context.fetcher_parameters
34
32
  )
35
- execution_context = garf_executors.execution_context.ExecutionContext(
36
- **MessageToDict(request.context, preserving_proto_field_name=True)
37
- )
38
33
  result = query_executor.execute(
39
34
  query=request.query,
40
35
  title=request.title,
41
- context=execution_context,
36
+ context=execution_context.ExecutionContext(
37
+ **MessageToDict(request.context, preserving_proto_field_name=True)
38
+ ),
42
39
  )
43
40
  return garf_pb2.ExecuteResponse(results=[result])
44
41
 
42
+ def Fetch(self, request, context):
43
+ query_executor = setup.setup_executor(
44
+ request.source, request.context.fetcher_parameters
45
+ )
46
+ query_args = execution_context.ExecutionContext(
47
+ **MessageToDict(request.context, preserving_proto_field_name=True)
48
+ ).query_parameters
49
+ result = query_executor.fetcher.fetch(
50
+ query_specification=request.query,
51
+ title=request.title,
52
+ args=query_args,
53
+ )
54
+ return garf_pb2.FetchResponse(
55
+ columns=result.column_names, rows=result.to_list(row_type='dict')
56
+ )
57
+
45
58
 
46
59
  if __name__ == '__main__':
47
60
  parser = argparse.ArgumentParser()
@@ -0,0 +1,174 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """FastAPI endpoint for executing queries."""
16
+
17
+ from typing import Optional, Union
18
+
19
+ import fastapi
20
+ import garf.executors
21
+ import pydantic
22
+ import typer
23
+ import uvicorn
24
+ from garf.executors import exceptions, setup
25
+ from garf.executors.entrypoints import utils
26
+ from garf.executors.entrypoints.tracer import (
27
+ initialize_meter,
28
+ initialize_tracer,
29
+ )
30
+ from garf.executors.workflows import workflow_runner
31
+ from garf.io import reader
32
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
33
+ from pydantic_settings import BaseSettings, SettingsConfigDict
34
+ from typing_extensions import Annotated
35
+
36
+ initialize_tracer()
37
+ initialize_meter()
38
+ app = fastapi.FastAPI()
39
+ FastAPIInstrumentor.instrument_app(app)
40
+ typer_app = typer.Typer()
41
+
42
+
43
+ class GarfSettings(BaseSettings):
44
+ """Specifies environmental variables for garf.
45
+
46
+ Ensure that mandatory variables are exposed via
47
+ export ENV_VARIABLE_NAME=VALUE.
48
+
49
+ Attributes:
50
+ loglevel: Level of logging.
51
+ log_name: Name of log.
52
+ logger_type: Type of logger.
53
+ """
54
+
55
+ model_config = SettingsConfigDict(env_prefix='garf_')
56
+
57
+ loglevel: str = 'INFO'
58
+ log_name: str = 'garf'
59
+ logger_type: str = 'local'
60
+
61
+
62
+ class GarfDependencies:
63
+ def __init__(self) -> None:
64
+ """Initializes GarfDependencies."""
65
+ settings = GarfSettings()
66
+ self.logger = utils.init_logging(
67
+ loglevel=settings.loglevel,
68
+ logger_type=settings.logger_type,
69
+ name=settings.log_name,
70
+ )
71
+
72
+
73
+ class ApiExecutorRequest(pydantic.BaseModel):
74
+ """Request for executing a query.
75
+
76
+ Attributes:
77
+ source: Type of API to interact with.
78
+ title: Name of the query used as an output for writing.
79
+ query: Query to execute.
80
+ query_path: Local or remote path to query.
81
+ context: Execution context.
82
+ """
83
+
84
+ source: str
85
+ title: Optional[str] = None
86
+ query: Optional[str] = None
87
+ query_path: Optional[Union[str, list[str]]] = None
88
+ context: garf.executors.api_executor.ApiExecutionContext
89
+
90
+ @pydantic.model_validator(mode='after')
91
+ def check_query_specified(self):
92
+ if not self.query_path and not self.query:
93
+ raise exceptions.GarfExecutorError(
94
+ 'Missing one of required parameters: query, query_path'
95
+ )
96
+ return self
97
+
98
+ def model_post_init(self, __context__) -> None:
99
+ if self.query_path and isinstance(self.query_path, str):
100
+ self.query = reader.FileReader().read(self.query_path)
101
+ if not self.title:
102
+ self.title = str(self.query_path)
103
+
104
+
105
+ class ApiExecutorResponse(pydantic.BaseModel):
106
+ """Response after executing a query.
107
+
108
+ Attributes:
109
+ results: Results of query execution.
110
+ """
111
+
112
+ results: list[str]
113
+
114
+
115
+ @app.get('/api/version')
116
+ async def version() -> str:
117
+ return garf.executors.__version__
118
+
119
+
120
+ @app.get('/api/fetchers')
121
+ async def get_fetchers(
122
+ dependencies: Annotated[GarfDependencies, fastapi.Depends(GarfDependencies)],
123
+ ) -> list[str]:
124
+ """Shows all available API sources."""
125
+ return list(garf.executors.fetchers.find_fetchers())
126
+
127
+
128
+ @app.post('/api/execute')
129
+ def execute(
130
+ request: ApiExecutorRequest,
131
+ dependencies: Annotated[GarfDependencies, fastapi.Depends(GarfDependencies)],
132
+ ) -> ApiExecutorResponse:
133
+ query_executor = setup.setup_executor(
134
+ request.source, request.context.fetcher_parameters
135
+ )
136
+ result = query_executor.execute(request.query, request.title, request.context)
137
+ return ApiExecutorResponse(results=[result])
138
+
139
+
140
+ @app.post('/api/execute:batch')
141
+ def execute_batch(
142
+ request: ApiExecutorRequest,
143
+ dependencies: Annotated[GarfDependencies, fastapi.Depends(GarfDependencies)],
144
+ ) -> ApiExecutorResponse:
145
+ query_executor = setup.setup_executor(
146
+ request.source, request.context.fetcher_parameters
147
+ )
148
+ reader_client = reader.FileReader()
149
+ batch = {query: reader_client.read(query) for query in request.query_path}
150
+ results = query_executor.execute_batch(batch, request.context)
151
+ return ApiExecutorResponse(results=results)
152
+
153
+
154
+ @app.post('/api/execute:workflow')
155
+ def execute_workflow(
156
+ workflow_file: str,
157
+ dependencies: Annotated[GarfDependencies, fastapi.Depends(GarfDependencies)],
158
+ enable_cache: bool = False,
159
+ cache_ttl_seconds: int = 3600,
160
+ ) -> list[str]:
161
+ return workflow_runner.WorkflowRunner.from_file(workflow_file).run(
162
+ enable_cache=enable_cache, cache_ttl_seconds=cache_ttl_seconds
163
+ )
164
+
165
+
166
+ @typer_app.command()
167
+ def main(
168
+ port: Annotated[int, typer.Option(help='Port to start the server')] = 8000,
169
+ ):
170
+ uvicorn.run(app, port=port)
171
+
172
+
173
+ if __name__ == '__main__':
174
+ typer_app()
@@ -0,0 +1,82 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+
17
+ from opentelemetry import metrics, trace
18
+ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
19
+ OTLPMetricExporter,
20
+ )
21
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
22
+ OTLPSpanExporter,
23
+ )
24
+ from opentelemetry.sdk.metrics import MeterProvider
25
+ from opentelemetry.sdk.metrics.export import (
26
+ PeriodicExportingMetricReader,
27
+ )
28
+ from opentelemetry.sdk.resources import Resource
29
+ from opentelemetry.sdk.trace import TracerProvider
30
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
31
+
32
+ DEFAULT_SERVICE_NAME = 'garf'
33
+
34
+
35
+ def initialize_tracer():
36
+ resource = Resource.create(
37
+ {'service.name': os.getenv('OTLP_SERVICE_NAME', DEFAULT_SERVICE_NAME)}
38
+ )
39
+
40
+ tracer_provider = TracerProvider(resource=resource)
41
+
42
+ if otel_endpoint := os.getenv('OTEL_EXPORTER_OTLP_ENDPOINT'):
43
+ if gcp_project_id := os.getenv('OTEL_EXPORTER_GCP_PROJECT_ID'):
44
+ try:
45
+ from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter
46
+ except ImportError as e:
47
+ raise ImportError(
48
+ 'Please install garf-executors with GCP support '
49
+ '- `pip install garf-executors[gcp]`'
50
+ ) from e
51
+
52
+ cloud_span_processor = BatchSpanProcessor(
53
+ CloudTraceSpanExporter(project_id=gcp_project_id)
54
+ )
55
+ tracer_provider.add_span_processor(cloud_span_processor)
56
+ else:
57
+ otlp_processor = BatchSpanProcessor(
58
+ OTLPSpanExporter(endpoint=otel_endpoint, insecure=True)
59
+ )
60
+ tracer_provider.add_span_processor(otlp_processor)
61
+
62
+ trace.set_tracer_provider(tracer_provider)
63
+
64
+
65
+ def initialize_meter():
66
+ resource = Resource.create(
67
+ {'service.name': os.getenv('OTLP_SERVICE_NAME', DEFAULT_SERVICE_NAME)}
68
+ )
69
+ meter_provider = MeterProvider(resource=resource)
70
+
71
+ if otel_endpoint := os.getenv('OTEL_EXPORTER_OTLP_ENDPOINT'):
72
+ otlp_metric_exporter = OTLPMetricExporter(
73
+ endpoint=f'{otel_endpoint}/v1/metrics'
74
+ )
75
+ metric_reader = PeriodicExportingMetricReader(otlp_metric_exporter)
76
+ meter_provider = MeterProvider(
77
+ resource=resource, metric_readers=[metric_reader]
78
+ )
79
+ else:
80
+ meter_provider = MeterProvider(resource=resource)
81
+ metrics.set_meter_provider(meter_provider)
82
+ return meter_provider
@@ -0,0 +1,140 @@
1
+ # Copyright 2022 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Module for various helpers for executing Garf as CLI tool."""
15
+
16
+ from __future__ import annotations
17
+
18
+ import enum
19
+ import logging
20
+ import sys
21
+ from collections.abc import Sequence
22
+ from typing import Any
23
+
24
+ from rich import logging as rich_logging
25
+
26
+
27
+ class ParamsParser:
28
+ def __init__(self, identifiers: Sequence[str]) -> None:
29
+ self.identifiers = identifiers
30
+
31
+ def parse(self, params: Sequence) -> dict[str, dict | None]:
32
+ return {
33
+ identifier: self._parse_params(identifier, params)
34
+ for identifier in self.identifiers
35
+ }
36
+
37
+ def _parse_params(self, identifier: str, params: Sequence[Any]) -> dict:
38
+ parsed_params = {}
39
+ if params:
40
+ raw_params = [param.split('=', maxsplit=1) for param in params]
41
+ for param in raw_params:
42
+ param_pair = self._identify_param_pair(identifier, param)
43
+ if param_pair:
44
+ parsed_params.update(param_pair)
45
+ return parsed_params
46
+
47
+ def _identify_param_pair(
48
+ self, identifier: str, param: Sequence[str]
49
+ ) -> dict[str, Any] | None:
50
+ key = param[0]
51
+ if not identifier or identifier not in key:
52
+ return None
53
+ provided_identifier, *keys = key.split('.')
54
+ if not keys:
55
+ return None
56
+ if len(keys) > 1:
57
+ raise GarfParamsException(
58
+ f'{key} is invalid format,'
59
+ f'`--{identifier}.key=value` or `--{identifier}.key` '
60
+ 'are the correct formats'
61
+ )
62
+ provided_identifier = provided_identifier.replace('--', '')
63
+ if provided_identifier not in self.identifiers:
64
+ supported_arguments = ', '.join(self.identifiers)
65
+ raise GarfParamsException(
66
+ f'CLI argument {provided_identifier} is not supported'
67
+ f', supported arguments {supported_arguments}'
68
+ )
69
+ if provided_identifier != identifier:
70
+ return None
71
+ key = keys[0].replace('-', '_')
72
+ if not key:
73
+ raise GarfParamsException(
74
+ f'{identifier} {key} is invalid,'
75
+ f'`--{identifier}.key=value` or `--{identifier}.key` '
76
+ 'are the correct formats'
77
+ )
78
+ if len(param) == 2:
79
+ return {key: param[1]}
80
+ if len(param) == 1:
81
+ return {key: True}
82
+ raise GarfParamsException(
83
+ f'{identifier} {key} is invalid,'
84
+ f'`--{identifier}.key=value` or `--{identifier}.key` '
85
+ 'are the correct formats'
86
+ )
87
+
88
+
89
+ class GarfParamsException(Exception):
90
+ """Defines exception for incorrect parameters."""
91
+
92
+
93
+ class LoggerEnum(str, enum.Enum):
94
+ local = 'local'
95
+ rich = 'rich'
96
+ gcloud = 'gcloud'
97
+
98
+
99
+ def init_logging(
100
+ loglevel: str = 'INFO',
101
+ logger_type: str | LoggerEnum = 'local',
102
+ name: str = __name__,
103
+ ) -> logging.Logger:
104
+ loglevel = getattr(logging, loglevel)
105
+ if logger_type == 'rich':
106
+ logging.basicConfig(
107
+ format='%(message)s',
108
+ level=loglevel,
109
+ datefmt='%Y-%m-%d %H:%M:%S',
110
+ handlers=[
111
+ rich_logging.RichHandler(rich_tracebacks=True),
112
+ ],
113
+ )
114
+ elif logger_type == 'gcloud':
115
+ try:
116
+ import google.cloud.logging as glogging
117
+ except ImportError as e:
118
+ raise ImportError(
119
+ 'Please install garf-executors with Cloud logging support - '
120
+ '`pip install garf-executors[bq]`'
121
+ ) from e
122
+
123
+ client = glogging.Client()
124
+ handler = glogging.handlers.CloudLoggingHandler(client, name=name)
125
+ handler.close()
126
+ glogging.handlers.setup_logging(handler, log_level=loglevel)
127
+ logging.basicConfig(
128
+ level=loglevel,
129
+ handlers=[handler],
130
+ )
131
+ else:
132
+ logging.basicConfig(
133
+ format='[%(asctime)s][%(name)s][%(levelname)s] %(message)s',
134
+ stream=sys.stdout,
135
+ level=loglevel,
136
+ datefmt='%Y-%m-%d %H:%M:%S',
137
+ )
138
+ logging.getLogger('smart_open.smart_open_lib').setLevel(logging.WARNING)
139
+ logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING)
140
+ return logging.getLogger(name)
@@ -0,0 +1,17 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+
16
+ class GarfExecutorError(Exception):
17
+ """Base class for garf executor exceptions."""
@@ -0,0 +1,117 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # pylint: disable=C0330, g-bad-import-order, g-multiple-import
16
+
17
+ """Captures parameters for fetching data from APIs."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import pathlib
23
+ from typing import Any
24
+
25
+ import pydantic
26
+ import smart_open
27
+ import yaml
28
+ from garf.core import query_editor
29
+ from garf.io import writer
30
+ from garf.io.writers import abs_writer
31
+
32
+
33
+ class ExecutionContext(pydantic.BaseModel):
34
+ """Common context for executing one or more queries.
35
+
36
+ Attributes:
37
+ query_parameters: Parameters to dynamically change query text.
38
+ fetcher_parameters: Parameters to specify fetching setup.
39
+ writer: Type of writer to use. Can be a single writer string or list of writers.
40
+ writer_parameters: Optional parameters to setup writer.
41
+ """
42
+
43
+ query_parameters: query_editor.GarfQueryParameters | None = pydantic.Field(
44
+ default_factory=dict
45
+ )
46
+ fetcher_parameters: dict[str, Any] | None = pydantic.Field(
47
+ default_factory=dict
48
+ )
49
+ writer: str | list[str] | None = None
50
+ writer_parameters: dict[str, str] | None = pydantic.Field(
51
+ default_factory=dict
52
+ )
53
+
54
+ def model_post_init(self, __context__) -> None:
55
+ if self.fetcher_parameters is None:
56
+ self.fetcher_parameters = {}
57
+ if self.writer_parameters is None:
58
+ self.writer_parameters = {}
59
+ if not self.query_parameters:
60
+ self.query_parameters = query_editor.GarfQueryParameters()
61
+
62
+ @classmethod
63
+ def from_file(
64
+ cls, path: str | pathlib.Path | os.PathLike[str]
65
+ ) -> ExecutionContext:
66
+ """Builds context from local or remote yaml file."""
67
+ with smart_open.open(path, 'r', encoding='utf-8') as f:
68
+ data = yaml.safe_load(f)
69
+ return ExecutionContext(**data)
70
+
71
+ def save(self, path: str | pathlib.Path | os.PathLike[str]) -> str:
72
+ """Saves context to local or remote yaml file."""
73
+ with smart_open.open(path, 'w', encoding='utf-8') as f:
74
+ yaml.dump(self.model_dump(), f, encoding='utf-8')
75
+ return f'ExecutionContext is saved to {str(path)}'
76
+
77
+ @property
78
+ def writer_client(self) -> abs_writer.AbsWriter:
79
+ """Returns single writer client."""
80
+ if isinstance(self.writer, list) and len(self.writer) > 0:
81
+ writer_type = self.writer[0]
82
+ else:
83
+ writer_type = self.writer
84
+
85
+ writer_params = self.writer_parameters or {}
86
+
87
+ if not writer_type:
88
+ raise ValueError('No writer specified')
89
+
90
+ writer_client = writer.create_writer(writer_type, **writer_params)
91
+ if writer_type == 'bq':
92
+ _ = writer_client.create_or_get_dataset()
93
+ if writer_type == 'sheet':
94
+ writer_client.init_client()
95
+ return writer_client
96
+
97
+ @property
98
+ def writer_clients(self) -> list[abs_writer.AbsWriter]:
99
+ """Returns list of writer clients."""
100
+ if not self.writer:
101
+ return []
102
+
103
+ # Convert single writer to list for uniform processing
104
+ writers_to_use = (
105
+ self.writer if isinstance(self.writer, list) else [self.writer]
106
+ )
107
+ writer_params = self.writer_parameters or {}
108
+
109
+ clients = []
110
+ for writer_type in writers_to_use:
111
+ writer_client = writer.create_writer(writer_type, **writer_params)
112
+ if writer_type == 'bq':
113
+ _ = writer_client.create_or_get_dataset()
114
+ if writer_type == 'sheet':
115
+ writer_client.init_client()
116
+ clients.append(writer_client)
117
+ return clients