Skip to content

Commit c9547e2

Browse files
MT255026tallamohan
andauthored
dagster-teradata : Added QueryBand and BTEQ Operator support (#210)
* initial_commit * optimized * added test cases * Added timeout * bteq_operator for dagster-teradata * Added expected_return_code * Accepts multiple expected return codes * updated code * Added test cases * Delete ddl.py * Removed creds * Corrected test cases * Added queryband support and required comments * Updated README and ruff * Update bteq_util.py * Addressed Review Comments * Corrected ruff changes * fix pyright issues * Revert "fix pyright issues" This reverts commit b8a9e5c. * Reapply "fix pyright issues" This reverts commit e40b7d6. * Fix pytest --------- Co-authored-by: Mohan Talla <tallamohan@gmail.com>
1 parent 955fb45 commit c9547e2

14 files changed

Lines changed: 2645 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,4 @@ cython_debug/
166166

167167
# NodeJS
168168
node_modules/
169+
/.idea

libraries/dagster-teradata/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,72 @@ def test_create_teradata_compute_cluster(tmp_path):
122122
}
123123
)
124124
```
125+
## BTEQ Operator
126+
127+
The bteq_operator method enables execution of SQL statements or BTEQ (Basic Teradata Query) scripts using the Teradata BTEQ utility.
128+
It supports running commands either on the local machine or on a remote machine over SSH — in both cases, the BTEQ utility must be installed on the target system.
129+
130+
### Key Features
131+
132+
- Executes SQL provided as a string or from a script file (only one can be used at a time).
133+
- Supports custom encoding for the script or session.
134+
- Configurable timeout and return code handling.
135+
- Remote execution supports authentication using a password or an SSH key.
136+
- Works in both local and remote setups, provided the BTEQ tool is installed on the system where execution takes place.
137+
138+
> Ensure that the Teradata BTEQ utility is installed on the machine where the SQL statements or scripts will be executed.
139+
>
140+
> This could be:
141+
> * The local machine where Dagster runs the task, for local execution.
142+
> * The remote host accessed via SSH, for remote execution.
143+
> * If executing remotely, also ensure that an SSH server (e.g., sshd) is running and accessible on the remote machine.
144+
145+
### Parameters
146+
147+
- `sql`: SQL statement(s) to be executed using BTEQ. (optional, mutually exclusive with `file_path`)
148+
- `file_path`: If provided, this file will be used instead of the `sql` content. This path represents remote file path when executing remotely via SSH, or local file path when executing locally. (optional, mutually exclusive with `sql`)
149+
- `remote_host`: Hostname or IP address for remote execution. If not provided, execution is assumed to be local. *(optional)*
150+
- `remote_user`: Username used for SSH authentication on the remote host. Required if `remote_host` is specified.
151+
- `remote_password`: Password for SSH authentication. Optional, and used as an alternative to `ssh_key_path`.
152+
- `ssh_key_path`: Path to the SSH private key used for authentication. Optional, and used as an alternative to `remote_password`.
153+
- `remote_port`: SSH port number for the remote host. Defaults to `22` if not specified. *(optional)*
154+
- `remote_working_dir`: Temporary directory location on the remote host (via SSH) where the BTEQ script will be transferred and executed. Defaults to `/tmp` if not specified. This is only applicable when `ssh_conn_id` is provided.
155+
- `bteq_script_encoding`: Character encoding for the BTEQ script file. Defaults to ASCII if not specified.
156+
- `bteq_session_encoding`: Character set encoding for the BTEQ session. Defaults to ASCII if not specified.
157+
- `bteq_quit_rc`: Accepts a single integer, list, or tuple of return codes. Specifies which BTEQ return codes should be treated as successful, allowing subsequent tasks to continue execution.
158+
- `timeout`: Timeout (in seconds) for executing the BTEQ command. Default is 600 seconds (10 minutes).
159+
- `timeout_rc`: Return code to use if the BTEQ execution fails due to a timeout. To allow Ops execution to continue after a timeout, include this value in `bteq_quit_rc`. If not specified, a timeout will raise an exception and stop the Ops.
160+
161+
### Returns
162+
163+
- Output of the BTEQ execution, or `None` if no output was produced.
164+
165+
### Raises
166+
167+
- `ValueError`: For invalid input or configuration
168+
- `DagsterError`: If BTEQ execution fails or times out
169+
170+
### Notes
171+
172+
- Either `sql` or `file_path` must be provided, but not both.
173+
- For remote execution, provide either `remote_password` or `ssh_key_path` (not both).
174+
- Encoding and timeout handling are customizable.
175+
- Validates remote port and authentication parameters.
176+
177+
### Example Usage
178+
179+
```python
180+
# Local execution with direct SQL
181+
output = bteq_operator(sql="SELECT * FROM table;")
182+
183+
# Remote execution with file
184+
output = bteq_operator(
185+
file_path="script.sql",
186+
remote_host="example.com",
187+
remote_user="user",
188+
ssh_key_path="/path/to/key.pem"
189+
)
190+
```
125191

126192
## Development
127193

libraries/dagster-teradata/__init__.py

Whitespace-only changes.

libraries/dagster-teradata/dagster_teradata/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
teradata_resource as teradata_resource,
88
)
99

10+
from dagster_teradata.teradata_compute_cluster_manager import (
11+
TeradataComputeClusterManager as TeradataComputeClusterManager,
12+
)
13+
14+
from dagster_teradata.ttu.bteq import Bteq as Bteq
15+
1016
__version__ = "0.0.3"
1117

1218
DagsterLibraryRegistry.register(

libraries/dagster-teradata/dagster_teradata/resources.py

Lines changed: 211 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from contextlib import closing, contextmanager
22
from datetime import datetime
33
from textwrap import dedent
4-
from typing import Any, List, Mapping, Optional, Sequence, Union, TYPE_CHECKING
4+
from typing import Any, List, Mapping, Optional, Sequence, Union, TYPE_CHECKING, Literal
55

6+
import re
67
import dagster._check as check
78
import teradatasql
89
from dagster import (
@@ -16,12 +17,51 @@
1617
from dagster._utils.cached_method import cached_method
1718
from pydantic import Field
1819

19-
from dagster_teradata import constants
20+
from . import constants
21+
from dagster_teradata.ttu.bteq import Bteq
2022
from dagster_teradata.teradata_compute_cluster_manager import (
2123
TeradataComputeClusterManager,
2224
)
2325

2426

27+
def _handle_user_query_band_text(self, query_band_text) -> str:
28+
"""Validate given query_band and append if required values missed in query_band."""
29+
# Ensures 'appname=dagster' and 'org=teradata-internal-telem' are in query_band_text.
30+
if query_band_text is not None:
31+
# checking org doesn't exist in query_band, appending 'org=teradata-internal-telem'
32+
# If it exists, user might have set some value of their own, so doing nothing in that case
33+
pattern = r"org\s*=\s*([^;]*)"
34+
match = re.search(pattern, query_band_text)
35+
if not match:
36+
if not query_band_text.endswith(";"):
37+
query_band_text += ";"
38+
query_band_text += "org=teradata-internal-telem;"
39+
# Making sure appname in query_band contains 'dagster'
40+
pattern = r"appname\s*=\s*([^;]*)"
41+
# Search for the pattern in the query_band_text
42+
match = re.search(pattern, query_band_text)
43+
if match:
44+
appname_value = match.group(1).strip()
45+
# if appname exists and dagster not exists in appname then appending 'dagster' to existing
46+
# appname value
47+
if "dagster" not in appname_value.lower():
48+
new_appname_value = appname_value + "_dagster"
49+
# Optionally, you can replace the original value in the query_band_text
50+
updated_query_band_text = re.sub(
51+
pattern, f"appname={new_appname_value}", query_band_text
52+
)
53+
query_band_text = updated_query_band_text
54+
else:
55+
# if appname doesn't exist in query_band, adding 'appname=dagster'
56+
if len(query_band_text.strip()) > 0 and not query_band_text.endswith(";"):
57+
query_band_text += ";"
58+
query_band_text += "appname=dagster;"
59+
else:
60+
query_band_text = "org=teradata-internal-telem;appname=dagster;"
61+
62+
return query_band_text
63+
64+
2565
class TeradataResource(ConfigurableResource, IAttachDifferentObjectToOpContext):
2666
host: Optional[str] = Field(description="Teradata Database Hostname")
2767
user: Optional[str] = Field(description="User login name.")
@@ -30,12 +70,25 @@ class TeradataResource(ConfigurableResource, IAttachDifferentObjectToOpContext):
3070
default=None,
3171
description=("Name of the default database to use."),
3272
)
73+
query_band: Optional[str] = None
3374
port: Optional[str] = None
3475
tmode: Optional[str] = "ANSI"
3576
logmech: Optional[str] = None
3677
browser: Optional[str] = None
3778
browser_tab_timeout: Optional[int] = None
3879
browser_timeout: Optional[int] = None
80+
console_output_encoding: Optional[str] = Field(
81+
default="utf-8", description="Console output encoding."
82+
)
83+
bteq_session_encoding: Optional[str] = Field(
84+
default="ASCII", description="BTEQ session encoding."
85+
)
86+
bteq_output_width: Optional[int] = Field(
87+
default=65531, description="BTEQ output width."
88+
)
89+
bteq_quit_zero: Optional[bool] = Field(
90+
default=False, description="BTEQ quit zero flag."
91+
)
3992
http_proxy: Optional[str] = None
4093
http_proxy_user: Optional[str] = None
4194
http_proxy_password: Optional[str] = None
@@ -63,6 +116,7 @@ def _connection_args(self) -> Mapping[str, Any]:
63116
"user",
64117
"password",
65118
"database",
119+
"query_band",
66120
"port",
67121
"tmode",
68122
"logmech",
@@ -158,9 +212,19 @@ def get_connection(self):
158212
connection_params["oidc_sslmode"] = self.oidc_sslmode
159213

160214
teradata_conn = teradatasql.connect(**connection_params)
161-
215+
self.set_query_band(self.query_band, teradata_conn)
162216
yield teradata_conn
163217

218+
def set_query_band(self, query_band_text, teradata_conn):
219+
"""Set SESSION Query Band for each connection session."""
220+
try:
221+
query_band_text = _handle_user_query_band_text(self, query_band_text)
222+
set_query_band_sql = f"SET QUERY_BAND='{query_band_text}' FOR SESSION"
223+
with teradata_conn.cursor() as cur:
224+
cur.execute(set_query_band_sql)
225+
except Exception:
226+
pass
227+
164228
def get_object_to_set_on_execution_context(self) -> Any:
165229
# Directly create a TeradataDagsterConnection here for backcompat since the TeradataDagsterConnection
166230
# has methods this resource does not have
@@ -188,6 +252,7 @@ def __init__(
188252
):
189253
self.teradata_connection_resource = teradata_connection_resource
190254
self.compute_cluster_manager = TeradataComputeClusterManager(self, log)
255+
self.bteq = Bteq(self, teradata_connection_resource, log)
191256
self.log = log
192257

193258
@public
@@ -283,6 +348,149 @@ def create_fresh_database(teradata: TeradataResource):
283348
results = results.append(cursor.fetchall()) # type: ignore
284349
return results
285350

351+
def bteq_operator(
352+
self,
353+
sql: Optional[str] = None,
354+
file_path: Optional[str] = None,
355+
remote_host: Optional[str] = None,
356+
remote_user: Optional[str] = None,
357+
remote_password: Optional[str] = None,
358+
ssh_key_path: Optional[str] = None,
359+
remote_port: int = 22,
360+
remote_working_dir: str = "/tmp",
361+
bteq_script_encoding: Optional[str] = "utf-8",
362+
bteq_session_encoding: Optional[str] = "ASCII",
363+
bteq_quit_rc: Union[int, List[int]] = 0,
364+
timeout: int | Literal[600] = 600, # Default to 10 minutes
365+
timeout_rc: int | None = None,
366+
) -> Optional[int]:
367+
"""
368+
Execute BTEQ commands either locally or on a remote machine via SSH.
369+
370+
This method provides a unified interface to run BTEQ operations with various configurations:
371+
- Local or remote execution
372+
- Direct SQL input or file-based input
373+
- Custom encoding settings
374+
- Timeout controls
375+
- Authentication via password or SSH key
376+
377+
Args:
378+
sql (Optional[str]): SQL commands to execute directly. Mutually exclusive with file_path.
379+
file_path (Optional[str]): [Optional] Path to an existing SQL or BTEQ script file.
380+
remote_host (Optional[str]): Hostname or IP for remote execution. None for local execution.
381+
remote_user (Optional[str]): Username for remote authentication. Required if remote_host specified.
382+
remote_password (Optional[str]): Password for remote authentication. Alternative to ssh_key_path.
383+
ssh_key_path (Optional[str]): Path to SSH private key for authentication. Alternative to remote_password.
384+
remote_port (int): SSH port for remote connection. Defaults to 22.
385+
remote_working_dir (str): Working directory on remote machine. Defaults to '/tmp'.
386+
bteq_script_encoding (Optional[str]): Encoding for BTEQ script file. Defaults to 'utf-8'.
387+
bteq_session_encoding (Optional[str]): Encoding for BTEQ session. Defaults to 'ASCII'.
388+
bteq_quit_rc (Union[int, List[int]]): Acceptable return codes for BTEQ execution. Defaults to 0.
389+
timeout (int | Literal[600]): Maximum execution time in seconds. Defaults to 600 (10 minutes).
390+
timeout_rc (int | None): Specific return code to use for timeout cases. Defaults to None.
391+
392+
Returns:
393+
Optional[str]: The output of the BTEQ execution, or None if no output was produced.
394+
395+
Raises:
396+
ValueError: If input validation fails, including:
397+
- Missing both sql and file_path
398+
- Providing both sql and file_path
399+
- Missing required remote authentication parameters
400+
- Invalid remote port specification
401+
DagsterError: If BTEQ execution fails or times out
402+
403+
Note:
404+
- For remote execution, either remote_password or ssh_key_path must be provided (but not both)
405+
- Encoding handling follows these rules:
406+
* ASCII session encoding: Uses default system encoding
407+
* UTF8/UTF16 session encoding: Forces corresponding script encoding
408+
- Timeout handling:
409+
* MAXREQTIME parameter sets maximum execution time per request
410+
* EXITONDELAY forces exit if timeout occurs
411+
* Timeout return code can be customized
412+
413+
Example:
414+
# Local execution with direct SQL
415+
>>> output = bteq_operator(sql="SELECT * FROM table;")
416+
417+
# Remote execution with file
418+
>>> output = bteq_operator(
419+
... file_path="script.sql",
420+
... remote_host="example.com",
421+
... remote_user="user",
422+
... ssh_key_path="/path/to/key.pem"
423+
... )
424+
"""
425+
# Validate input parameters
426+
if not sql and not file_path:
427+
raise ValueError(
428+
"BteqOperator requires either the 'sql' or 'file_path' parameter. Both are missing."
429+
)
430+
431+
if sum(bool(x) for x in [sql, file_path]) > 1:
432+
raise ValueError(
433+
"BteqOperator requires either the 'sql' or 'file_path' parameter but not both."
434+
)
435+
436+
# Validate remote execution parameters if needed
437+
if remote_host:
438+
if not remote_user:
439+
raise ValueError("remote_user must be provided for remote execution")
440+
if not ssh_key_path and not remote_password:
441+
raise ValueError(
442+
"Either ssh_key_path or remote_password must be provided for remote execution"
443+
)
444+
if remote_password and ssh_key_path:
445+
raise ValueError(
446+
"Cannot specify both remote_password and ssh_key_path for remote execution"
447+
)
448+
449+
# Validate network port
450+
if remote_port < 1 or remote_port > 65535:
451+
raise ValueError("remote_port must be a valid port number (1-65535)")
452+
453+
# Handle encoding configuration
454+
temp_file_read_encoding = "UTF-8"
455+
if not bteq_session_encoding or bteq_session_encoding == "ASCII":
456+
bteq_session_encoding = ""
457+
if bteq_script_encoding == "UTF8":
458+
temp_file_read_encoding = "UTF-8"
459+
elif bteq_script_encoding == "UTF16":
460+
temp_file_read_encoding = "UTF-16"
461+
bteq_script_encoding = ""
462+
elif bteq_session_encoding == "UTF8" and (
463+
not bteq_script_encoding or bteq_script_encoding == "ASCII"
464+
):
465+
bteq_script_encoding = "UTF8"
466+
elif bteq_session_encoding == "UTF16":
467+
if not bteq_script_encoding or bteq_script_encoding == "ASCII":
468+
bteq_script_encoding = "UTF8"
469+
470+
# Map BTEQ encoding to Python file reading encoding
471+
if bteq_script_encoding == "UTF8":
472+
temp_file_read_encoding = "UTF-8"
473+
elif bteq_script_encoding == "UTF16":
474+
temp_file_read_encoding = "UTF-16"
475+
476+
# Delegate execution to the underlying BTEQ implementation
477+
return self.bteq.bteq_operator(
478+
sql=sql,
479+
file_path=file_path,
480+
remote_host=remote_host,
481+
remote_user=remote_user,
482+
remote_password=remote_password,
483+
ssh_key_path=ssh_key_path,
484+
remote_port=remote_port,
485+
remote_working_dir=remote_working_dir,
486+
bteq_script_encoding=bteq_script_encoding,
487+
bteq_session_encoding=bteq_session_encoding,
488+
bteq_quit_rc=bteq_quit_rc,
489+
timeout=timeout,
490+
timeout_rc=timeout_rc,
491+
temp_file_read_encoding=temp_file_read_encoding,
492+
)
493+
286494
def drop_database(self, databases: Union[str, Sequence[str]]) -> None:
287495
"""
288496
Drop one or more databases in Teradata.

libraries/dagster-teradata/dagster_teradata/ttu/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)