gitdata-lib 0.0.18__tar.gz → 0.0.19__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.
Files changed (63) hide show
  1. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/CHANGELOG.md +6 -0
  2. {gitdata_lib-0.0.18/gitdata_lib.egg-info → gitdata_lib-0.0.19}/PKG-INFO +37 -17
  3. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/README.md +36 -16
  4. gitdata_lib-0.0.19/gitdata/__version__.py +1 -0
  5. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/__init__.py +54 -1
  6. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/gitdata_ls.py +3 -2
  7. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/gitdata_scan.py +9 -2
  8. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/common.py +4 -0
  9. gitdata_lib-0.0.19/gitdata/connectors/oracle.py +415 -0
  10. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/tabular.py +36 -2
  11. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19/gitdata_lib.egg-info}/PKG-INFO +37 -17
  12. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata_lib.egg-info/SOURCES.txt +1 -0
  13. gitdata_lib-0.0.18/gitdata/__version__.py +0 -1
  14. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/LICENSE +0 -0
  15. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/MANIFEST.in +0 -0
  16. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/__init__.py +0 -0
  17. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/assets/README.md +0 -0
  18. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/buckets.py +0 -0
  19. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/gitdata_get.py +0 -0
  20. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/gitdata_init.py +0 -0
  21. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/gitdata_secrets.py +0 -0
  22. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/config.py +0 -0
  23. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/__init__.py +0 -0
  24. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/fake.py +0 -0
  25. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/gitlab.py +0 -0
  26. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/http.py +0 -0
  27. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/local.py +0 -0
  28. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/__init__.py +0 -0
  29. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/common.py +0 -0
  30. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/mysql.py +0 -0
  31. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/mysql_setup.sql +0 -0
  32. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/mysql_setup_test_database.sql +0 -0
  33. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/oracle.py +0 -0
  34. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/postgresql.py +0 -0
  35. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/sqlite3.py +0 -0
  36. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/sqlite3_setup.sql +0 -0
  37. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/sqlite3_setup_test_data.sql +0 -0
  38. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/digester.py +0 -0
  39. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/encryption.py +0 -0
  40. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/ext/__init__.py +0 -0
  41. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/ext/connectors/__init__.py +0 -0
  42. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/graphs.py +0 -0
  43. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/inspection.py +0 -0
  44. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/json.py +0 -0
  45. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/queues.py +0 -0
  46. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/repositories.py +0 -0
  47. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/secrets.py +0 -0
  48. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/solutions.py +0 -0
  49. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/sql.py +0 -0
  50. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/stores/__init__.py +0 -0
  51. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/stores/common.py +0 -0
  52. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/stores/entities.py +0 -0
  53. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/stores/facts.py +0 -0
  54. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/stores/tables.py +0 -0
  55. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/utils.py +0 -0
  56. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata_lib.egg-info/dependency_links.txt +0 -0
  57. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata_lib.egg-info/entry_points.txt +0 -0
  58. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata_lib.egg-info/requires.txt +0 -0
  59. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata_lib.egg-info/top_level.txt +0 -0
  60. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/requirements.txt +0 -0
  61. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/setup.cfg +0 -0
  62. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/setup.py +0 -0
  63. {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/tests/test_blob.py +0 -0
@@ -6,6 +6,12 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [v0.0.19] - 2026-09-28
10
+ - add direct `gitdata ls` and `gitdata scan` support for Oracle services and tables
11
+ - show actionable CLI errors for Oracle connection, authentication, service, table, and query failures
12
+ - redact inline URL passwords from debug output and escape terminal control characters in CLI listings
13
+ - show actionable CLI errors for MariaDB/MySQL connection, authentication, and database-selection failures instead of tracebacks
14
+
9
15
  ## [v0.0.18] - 2026-09-28
10
16
  - support and test Python 3.9 through 3.12
11
17
  - replace removed `collections` aliases with `collections.abc`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitdata-lib
3
- Version: 0.0.18
3
+ Version: 0.0.19
4
4
  Summary: Data extraction and analysis library
5
5
  Home-page: https://github.com/gitdata/gitdata-lib
6
6
  Author: DSI Labs
@@ -67,8 +67,8 @@ python3 -m pip install 'gitdata-lib[oracle]'
67
67
 
68
68
  ## Explore a data source
69
69
 
70
- The same two commands work across CSV files, SQLite databases, and
71
- MariaDB/MySQL databases:
70
+ The same two commands work across CSV files, SQLite databases,
71
+ MariaDB/MySQL databases, and Oracle databases:
72
72
 
73
73
  ```bash
74
74
  gitdata ls <source>
@@ -206,23 +206,43 @@ gitdata ls --help
206
206
  gitdata scan --help
207
207
  ```
208
208
 
209
- ## Oracle from Python
209
+ ## Oracle
210
210
 
211
- Use the optional Oracle driver through the Python API:
211
+ Install the optional Oracle driver, then keep the password in an environment
212
+ variable:
212
213
 
213
- ```python
214
- import os
214
+ ```bash
215
+ python3 -m pip install 'gitdata-lib[oracle]'
216
+ export ORACLE_PASSWORD='your-password'
217
+
218
+ DATABASE='oracle://app@db.example.com:1521/FREEPDB1?password_env=ORACLE_PASSWORD'
219
+ TABLE="${DATABASE}#orders"
220
+ ```
221
+
222
+ Explore the service and its tables with the same commands:
223
+
224
+ ```bash
225
+ # List tables owned by the connected user
226
+ gitdata ls "$DATABASE"
215
227
 
216
- from gitdata.database import connect
228
+ # Show table counts and sizes
229
+ gitdata ls -l "$DATABASE"
217
230
 
218
- db = connect(
219
- 'oracle',
220
- host='127.0.0.1',
221
- port=1521,
222
- service_name='FREEPDB1',
223
- user='app',
224
- password=os.environ['ORACLE_PASSWORD'],
225
- )
231
+ # Show column details
232
+ gitdata ls -l "$TABLE"
233
+
234
+ # Preview rows
235
+ gitdata scan --limit 10 "$TABLE"
236
+ ```
237
+
238
+ Oracle references follow this shape:
226
239
 
227
- rows = list(db('SELECT 1 FROM dual'))
240
+ ```text
241
+ oracle://user@host:port/service?password_env=ENVIRONMENT_VARIABLE
242
+ oracle://user@host:port/service?password_env=ENVIRONMENT_VARIABLE#table
228
243
  ```
244
+
245
+ Inline passwords are not accepted. Ordinary Oracle table and column names are
246
+ shown in lowercase. Quoted identifiers are shown with their double quotes so
247
+ names that differ only by Oracle quoting remain distinct; use the displayed
248
+ spelling after `#` when selecting one (the quotes may be URL-encoded as `%22`).
@@ -20,8 +20,8 @@ python3 -m pip install 'gitdata-lib[oracle]'
20
20
 
21
21
  ## Explore a data source
22
22
 
23
- The same two commands work across CSV files, SQLite databases, and
24
- MariaDB/MySQL databases:
23
+ The same two commands work across CSV files, SQLite databases,
24
+ MariaDB/MySQL databases, and Oracle databases:
25
25
 
26
26
  ```bash
27
27
  gitdata ls <source>
@@ -159,23 +159,43 @@ gitdata ls --help
159
159
  gitdata scan --help
160
160
  ```
161
161
 
162
- ## Oracle from Python
162
+ ## Oracle
163
163
 
164
- Use the optional Oracle driver through the Python API:
164
+ Install the optional Oracle driver, then keep the password in an environment
165
+ variable:
165
166
 
166
- ```python
167
- import os
167
+ ```bash
168
+ python3 -m pip install 'gitdata-lib[oracle]'
169
+ export ORACLE_PASSWORD='your-password'
170
+
171
+ DATABASE='oracle://app@db.example.com:1521/FREEPDB1?password_env=ORACLE_PASSWORD'
172
+ TABLE="${DATABASE}#orders"
173
+ ```
174
+
175
+ Explore the service and its tables with the same commands:
176
+
177
+ ```bash
178
+ # List tables owned by the connected user
179
+ gitdata ls "$DATABASE"
168
180
 
169
- from gitdata.database import connect
181
+ # Show table counts and sizes
182
+ gitdata ls -l "$DATABASE"
170
183
 
171
- db = connect(
172
- 'oracle',
173
- host='127.0.0.1',
174
- port=1521,
175
- service_name='FREEPDB1',
176
- user='app',
177
- password=os.environ['ORACLE_PASSWORD'],
178
- )
184
+ # Show column details
185
+ gitdata ls -l "$TABLE"
186
+
187
+ # Preview rows
188
+ gitdata scan --limit 10 "$TABLE"
189
+ ```
190
+
191
+ Oracle references follow this shape:
179
192
 
180
- rows = list(db('SELECT 1 FROM dual'))
193
+ ```text
194
+ oracle://user@host:port/service?password_env=ENVIRONMENT_VARIABLE
195
+ oracle://user@host:port/service?password_env=ENVIRONMENT_VARIABLE#table
181
196
  ```
197
+
198
+ Inline passwords are not accepted. Ordinary Oracle table and column names are
199
+ shown in lowercase. Quoted identifiers are shown with their double quotes so
200
+ names that differ only by Oracle quoting remain distinct; use the displayed
201
+ spelling after `#` when selecting one (the quotes may be URL-encoded as `%22`).
@@ -0,0 +1 @@
1
+ __version__ = '0.0.19'
@@ -24,12 +24,62 @@ import sys
24
24
  from docopt import docopt
25
25
 
26
26
  import gitdata
27
+ from gitdata.connectors.common import ConnectorError
27
28
  from gitdata.utils import trim
28
29
 
29
30
 
30
31
  root_logger = logging.getLogger()
31
32
 
32
33
 
34
+ def _redact_inline_url_password(value):
35
+ scheme_end = value.find('://')
36
+ if scheme_end < 1:
37
+ return value
38
+ authority_start = scheme_end + 3
39
+ separators = [
40
+ position for position in (
41
+ value.find('/', authority_start),
42
+ value.find('?', authority_start),
43
+ value.find('#', authority_start),
44
+ )
45
+ if position >= 0
46
+ ]
47
+ authority_end = min(separators) if separators else len(value)
48
+ authority = value[authority_start:authority_end]
49
+ at = authority.rfind('@')
50
+ colon = authority.find(':')
51
+ if at < 0 or colon < 0 or colon > at:
52
+ return value
53
+ redacted = authority[:colon + 1] + '***' + authority[at:]
54
+ return value[:authority_start] + redacted + value[authority_end:]
55
+
56
+
57
+ def _redact_debug_value(value):
58
+ """Remove URL passwords before rendering parsed debug arguments."""
59
+ if isinstance(value, str):
60
+ return _redact_inline_url_password(value)
61
+ if isinstance(value, list):
62
+ return [_redact_debug_value(item) for item in value]
63
+ if isinstance(value, tuple):
64
+ return tuple(_redact_debug_value(item) for item in value)
65
+ if isinstance(value, dict):
66
+ return {
67
+ key: _redact_debug_value(item)
68
+ for key, item in value.items()
69
+ }
70
+ return value
71
+
72
+
73
+ def terminal_text(value):
74
+ """Render text without terminal control characters."""
75
+ return ''.join(
76
+ character
77
+ if character.isprintable()
78
+ else character.encode('unicode_escape').decode('ascii')
79
+ for character in str(value)
80
+ )
81
+
82
+
33
83
  def print_help(doc):
34
84
  """Print help text"""
35
85
  print(trim(doc))
@@ -53,6 +103,9 @@ def main():
53
103
  except KeyboardInterrupt:
54
104
  print('\nInterrupted.', file=sys.stderr)
55
105
  raise SystemExit(130)
106
+ except ConnectorError as error:
107
+ print('gitdata: {}'.format(error), file=sys.stderr)
108
+ raise SystemExit(1)
56
109
 
57
110
 
58
111
  def _main():
@@ -69,7 +122,7 @@ def _main():
69
122
  )
70
123
 
71
124
  if args['--debug']:
72
- print(args)
125
+ print(_redact_debug_value(args))
73
126
  root_logger.setLevel(logging.DEBUG)
74
127
 
75
128
  argv = [args['<command>']] + args['<args>']
@@ -7,6 +7,7 @@ options:
7
7
  -h, --help show help
8
8
  """
9
9
 
10
+ from gitdata.cli import terminal_text
10
11
  from gitdata.connectors import fetch
11
12
  from gitdata.inspection import list_items
12
13
 
@@ -35,7 +36,7 @@ def _display(value):
35
36
  return '-'
36
37
  if isinstance(value, int):
37
38
  return '{:,}'.format(value)
38
- return str(value)
39
+ return terminal_text(value)
39
40
 
40
41
 
41
42
  def _row(values, widths):
@@ -81,4 +82,4 @@ def ls_to_console(args):
81
82
  _print_verbose(root, items)
82
83
  return
83
84
  for item in items:
84
- print(item['name'])
85
+ print(terminal_text(item['name']))
@@ -12,6 +12,7 @@ from decimal import Decimal
12
12
  import shutil
13
13
  import sys
14
14
 
15
+ from gitdata.cli import terminal_text
15
16
  from gitdata.connectors import fetch
16
17
  from gitdata.inspection import scan
17
18
 
@@ -31,7 +32,12 @@ def _title(report):
31
32
  )
32
33
  row_label = 'row' if row_label_count == 1 else 'rows'
33
34
  return '{}: {:,} {}, {} {}{}'.format(
34
- report['name'], columns, column_label, rows, row_label, sampled
35
+ terminal_text(report['name']),
36
+ columns,
37
+ column_label,
38
+ rows,
39
+ row_label,
40
+ sampled,
35
41
  )
36
42
 
37
43
 
@@ -48,6 +54,7 @@ def _format_value(value):
48
54
  text = '{:%Y-%m-%d}'.format(value)
49
55
  else:
50
56
  text = str(value)
57
+ text = terminal_text(text)
51
58
  if len(text) > 12:
52
59
  return text[:12] + '...'
53
60
  return text
@@ -81,7 +88,7 @@ def _column_widths():
81
88
 
82
89
 
83
90
  def _cell(value, width, right=False):
84
- text = str(value)
91
+ text = terminal_text(value)
85
92
  if len(text) > width:
86
93
  half = round(width / 2)
87
94
  text = (text[:half - 2] + ' ... ' + text[-half + 3:])[:width]
@@ -98,6 +98,10 @@ def explore(node, destination):
98
98
  # yield a, b
99
99
 
100
100
 
101
+ class ConnectorError(Exception):
102
+ """Raised when a Connector cannot complete an expected operation."""
103
+
104
+
101
105
  class UnsupportedRef(ValueError):
102
106
  """Raised when no Connector can resolve a ref."""
103
107
 
@@ -0,0 +1,415 @@
1
+ """Oracle connector for direct CLI inspection."""
2
+
3
+ from contextlib import contextmanager
4
+ import os
5
+ import sys
6
+ from urllib.parse import parse_qs, unquote, urlsplit
7
+
8
+ from gitdata.connectors.common import BaseConnector, ConnectorError
9
+
10
+
11
+ class OracleRefError(ConnectorError, ValueError):
12
+ """Raised when an Oracle ref cannot be used safely."""
13
+
14
+
15
+ class OracleConnector(BaseConnector):
16
+ """Resolve Oracle URLs into directly inspectable roots."""
17
+
18
+ @staticmethod
19
+ def _normalize_identifier(name):
20
+ if name.isupper():
21
+ return name.lower()
22
+ return '"{}"'.format(name.replace('"', '""'))
23
+
24
+ @staticmethod
25
+ def _data_type(schema_row):
26
+ data_type = schema_row[1]
27
+ if len(schema_row) <= 7:
28
+ return data_type
29
+ precision, scale, char_length, char_used = schema_row[7:11]
30
+ if data_type == 'NUMBER' and precision is not None:
31
+ if scale:
32
+ return 'NUMBER({},{})'.format(precision, scale)
33
+ return 'NUMBER({})'.format(precision)
34
+ if char_length is not None and data_type in (
35
+ 'CHAR', 'NCHAR', 'NVARCHAR2', 'VARCHAR2'):
36
+ semantics = ' CHAR' if char_used == 'C' else ''
37
+ return '{}({}{})'.format(data_type, char_length, semantics)
38
+ return data_type
39
+
40
+ def accepts(self, ref):
41
+ return (
42
+ isinstance(ref, str)
43
+ and ref.partition(':')[0].lower() == 'oracle'
44
+ )
45
+
46
+ def _parts(self, ref):
47
+ try:
48
+ parts = urlsplit(ref)
49
+ except ValueError:
50
+ raise OracleRefError('Oracle ref is malformed') from None
51
+ query = parse_qs(parts.query, keep_blank_values=True)
52
+ unexpected = set(query) - {'password_env'}
53
+ if unexpected:
54
+ raise OracleRefError(
55
+ 'Unsupported Oracle ref option(s): {}'.format(
56
+ ', '.join(
57
+ self._safe_component(option)
58
+ for option in sorted(unexpected)
59
+ )
60
+ )
61
+ )
62
+ raw_service_name = parts.path.lstrip('/')
63
+ service_name = unquote(raw_service_name)
64
+ if (
65
+ not parts.username
66
+ or not parts.hostname
67
+ or not service_name
68
+ or '/' in service_name):
69
+ raise OracleRefError(
70
+ 'Oracle refs require a user, host, and service name'
71
+ )
72
+ try:
73
+ port = parts.port or 1521
74
+ except ValueError:
75
+ raise OracleRefError('Oracle refs require a valid port') from None
76
+ if parts.password is not None:
77
+ raise OracleRefError(
78
+ 'Inline Oracle passwords are not supported; use password_env'
79
+ )
80
+ password = None
81
+ password_env = query.get('password_env', ())
82
+ if password_env and (
83
+ len(password_env) != 1 or not password_env[0]):
84
+ raise OracleRefError(
85
+ 'password_env must name exactly one environment variable'
86
+ )
87
+ if password_env:
88
+ variable = password_env[0]
89
+ try:
90
+ password = os.environ[variable]
91
+ except KeyError:
92
+ raise OracleRefError(
93
+ 'Oracle password environment variable {!r} is not set'
94
+ .format(variable)
95
+ )
96
+ config = {
97
+ 'host': parts.hostname,
98
+ 'port': port,
99
+ 'service_name': service_name,
100
+ 'user': unquote(parts.username),
101
+ 'password': password,
102
+ }
103
+ table = unquote(parts.fragment) if parts.fragment else None
104
+ return config, table
105
+
106
+ @staticmethod
107
+ def _safe_component(value):
108
+ return str(value).encode('unicode_escape').decode('ascii')
109
+
110
+ @classmethod
111
+ def _error_code(cls, detail):
112
+ full_code = getattr(detail, 'full_code', None)
113
+ if full_code:
114
+ return cls._safe_component(full_code)
115
+ code = getattr(detail, 'code', None)
116
+ if isinstance(code, int) and code > 0:
117
+ return 'ORA-{:05d}'.format(code)
118
+ return None
119
+
120
+ @classmethod
121
+ def _connection_error(cls, config, error):
122
+ detail = error.args[0] if error.args else None
123
+ full_code = cls._error_code(detail)
124
+ host = cls._safe_component(config['host'])
125
+ service = cls._safe_component(config['service_name'])
126
+ server = '{}:{}'.format(host, config['port'])
127
+ address = '{}/{}'.format(server, service)
128
+ network_errors = (
129
+ 'DPY-6000', 'DPY-6005', 'ORA-12170', 'ORA-12541',
130
+ 'ORA-12543', 'ORA-12545',
131
+ )
132
+ if full_code in network_errors:
133
+ message = (
134
+ 'could not connect to Oracle at {}. Check the server name, '
135
+ 'confirm the Oracle listener is running, and verify network '
136
+ 'access.'.format(address)
137
+ )
138
+ elif full_code == 'ORA-01017':
139
+ message = (
140
+ 'could not authenticate Oracle user {!r} at {}. Check the '
141
+ 'user name and password.'.format(config['user'], address)
142
+ )
143
+ elif full_code in ('ORA-28000', 'ORA-28001'):
144
+ message = (
145
+ 'Oracle account {!r} is locked or expired. Contact the '
146
+ 'database administrator.'.format(config['user'])
147
+ )
148
+ elif full_code == 'DPY-4001':
149
+ message = (
150
+ 'Oracle credentials were not provided for user {!r}. Set '
151
+ 'password_env in the Oracle ref.'.format(config['user'])
152
+ )
153
+ elif full_code in ('ORA-12514', 'DPY-6001'):
154
+ message = (
155
+ 'Oracle service {!r} was not found at {}. Check the service '
156
+ 'name and listener registration.'.format(
157
+ config['service_name'], server
158
+ )
159
+ )
160
+ else:
161
+ code = cls._safe_component(full_code or 'unknown')
162
+ message = (
163
+ 'Oracle operation failed at {} (error {}). Check the '
164
+ 'connection settings and access permissions.'.format(
165
+ address, code
166
+ )
167
+ )
168
+ return ConnectorError(message)
169
+
170
+ @classmethod
171
+ @contextmanager
172
+ def _translate_errors(cls, config):
173
+ import oracledb
174
+
175
+ try:
176
+ yield
177
+ except oracledb.Error as error:
178
+ raise cls._connection_error(config, error) from None
179
+
180
+ @classmethod
181
+ def _close(cls, resource, config):
182
+ """Close a driver resource without exposing driver exceptions."""
183
+ import oracledb
184
+
185
+ active_error = sys.exc_info()[0] is not None
186
+ try:
187
+ resource.close()
188
+ except oracledb.Error as error:
189
+ if not active_error:
190
+ raise cls._connection_error(config, error) from None
191
+
192
+ @classmethod
193
+ def _connect(cls, config):
194
+ try:
195
+ import oracledb
196
+ except ModuleNotFoundError as error:
197
+ if error.name != 'oracledb':
198
+ raise
199
+ raise ConnectorError(
200
+ 'Oracle support requires the optional dependency; install it '
201
+ "with 'pip install gitdata-lib[oracle]'."
202
+ ) from None
203
+
204
+ try:
205
+ dsn = oracledb.makedsn(
206
+ config['host'],
207
+ config['port'],
208
+ service_name=config['service_name'],
209
+ )
210
+ return oracledb.connect(
211
+ user=config['user'],
212
+ password=config['password'],
213
+ dsn=dsn,
214
+ )
215
+ except oracledb.Error as error:
216
+ raise cls._connection_error(config, error) from None
217
+ except OSError:
218
+ host = cls._safe_component(config['host'])
219
+ service = cls._safe_component(config['service_name'])
220
+ address = '{}:{}/{}'.format(host, config['port'], service)
221
+ raise ConnectorError(
222
+ 'could not connect to Oracle at {}. Check the server name, '
223
+ 'confirm the Oracle listener is running, and verify network '
224
+ 'access.'.format(address)
225
+ ) from None
226
+
227
+ @staticmethod
228
+ def _quoted(name):
229
+ return '"{}"'.format(name.replace('"', '""'))
230
+
231
+ @staticmethod
232
+ def _selector_candidates(name):
233
+ if len(name) >= 2 and name.startswith('"') and name.endswith('"'):
234
+ return (name[1:-1].replace('""', '"'),)
235
+ if name.isupper():
236
+ return (name,)
237
+ return (name.upper(), name)
238
+
239
+ def _table(self, config, name, count_rows, resolved=False):
240
+ connection = self._connect(config)
241
+ try:
242
+ with self._translate_errors(config):
243
+ with connection.cursor() as cursor:
244
+ query = (
245
+ 'select c.column_name, c.data_type, c.nullable, '
246
+ 'c.data_default, c.identity_column, c.virtual_column, '
247
+ "case when exists (select 1 from user_constraints k "
248
+ 'join user_cons_columns kc on kc.constraint_name = '
249
+ "k.constraint_name where k.constraint_type = 'P' and "
250
+ 'k.table_name = c.table_name and kc.table_name = '
251
+ 'c.table_name and kc.column_name = c.column_name) '
252
+ "then 'PRI' else '-' end, c.data_precision, "
253
+ 'c.data_scale, c.char_length, c.char_used '
254
+ 'from user_tab_cols c where '
255
+ "c.user_generated = 'YES' and "
256
+ 'c.table_name = :table_name order by c.column_id'
257
+ )
258
+ candidates = (
259
+ (name,) if resolved else self._selector_candidates(name)
260
+ )
261
+ schema = ()
262
+ actual_name = name
263
+ for candidate in candidates:
264
+ cursor.execute(query, {'table_name': candidate})
265
+ schema = tuple(cursor.fetchall())
266
+ if schema:
267
+ actual_name = candidate
268
+ break
269
+ finally:
270
+ self._close(connection, config)
271
+
272
+ if not schema:
273
+ raise ConnectorError(
274
+ 'Oracle table {!r} was not found in service {!r}. Check the '
275
+ 'table name.'.format(name, config['service_name'])
276
+ )
277
+
278
+ columns = tuple(
279
+ self._normalize_identifier(row[0]) for row in schema
280
+ )
281
+ quoted = self._quoted(actual_name)
282
+
283
+ def eager_count():
284
+ count_connection = self._connect(config)
285
+ try:
286
+ with self._translate_errors(config):
287
+ with count_connection.cursor() as cursor:
288
+ cursor.execute('select count(*) from {}'.format(quoted))
289
+ return cursor.fetchone()[0]
290
+ finally:
291
+ self._close(count_connection, config)
292
+
293
+ row_count = eager_count() if count_rows else None
294
+
295
+ def rows():
296
+ import oracledb
297
+
298
+ lob_type = getattr(oracledb, 'LOB', ())
299
+ row_connection = self._connect(config)
300
+ cursor = None
301
+ try:
302
+ with self._translate_errors(config):
303
+ cursor = row_connection.cursor()
304
+ cursor.execute('select * from {}'.format(quoted))
305
+ for row in cursor:
306
+ yield tuple(
307
+ value.read(1, 4096)
308
+ if isinstance(value, lob_type) else value
309
+ for value in row
310
+ )
311
+ finally:
312
+ try:
313
+ if cursor is not None:
314
+ self._close(cursor, config)
315
+ finally:
316
+ self._close(row_connection, config)
317
+
318
+ def column_summaries():
319
+ for row, column_name in zip(schema, columns):
320
+ extra = []
321
+ if row[4] == 'YES':
322
+ extra.append('IDENTITY')
323
+ if row[5] == 'YES':
324
+ extra.append('VIRTUAL')
325
+ yield {
326
+ 'name': column_name,
327
+ 'type': 'column',
328
+ 'data_type': self._data_type(row),
329
+ 'null': 'YES' if row[2] == 'Y' else 'NO',
330
+ 'key': row[6] if len(row) > 6 else '-',
331
+ 'default': (
332
+ row[3].strip()
333
+ if isinstance(row[3], str) and row[3].strip()
334
+ else '-'
335
+ ),
336
+ 'extra': ', '.join(extra) or '-',
337
+ }
338
+
339
+ return {
340
+ 'type': 'table',
341
+ 'name': self._normalize_identifier(actual_name),
342
+ 'columns': columns,
343
+ 'row_count': row_count,
344
+ 'rows': rows(),
345
+ 'column_summaries': column_summaries(),
346
+ }
347
+
348
+ def fetch(self, ref, count_rows=True):
349
+ if not self.accepts(ref):
350
+ return None
351
+ config, selected_table = self._parts(ref)
352
+ if selected_table:
353
+ return self._table(config, selected_table, count_rows)
354
+
355
+ connection = self._connect(config)
356
+ try:
357
+ with self._translate_errors(config):
358
+ with connection.cursor() as cursor:
359
+ cursor.execute(
360
+ 'select table_name from user_tables order by table_name'
361
+ )
362
+ table_pairs = tuple(
363
+ (self._normalize_identifier(row[0]), row[0])
364
+ for row in cursor
365
+ )
366
+ finally:
367
+ self._close(connection, config)
368
+
369
+ names = tuple(display for display, _ in table_pairs)
370
+
371
+ def tables():
372
+ for _, actual_name in table_pairs:
373
+ yield self._table(
374
+ config, actual_name, count_rows, resolved=True
375
+ )
376
+
377
+ def table_summaries():
378
+ stats_connection = self._connect(config)
379
+ try:
380
+ with self._translate_errors(config):
381
+ with stats_connection.cursor() as cursor:
382
+ cursor.execute(
383
+ 'select t.table_name, count(c.column_name), '
384
+ 't.num_rows, round(nvl(s.bytes, 0) / 1024 / 1024) '
385
+ 'from user_tables t left join user_tab_columns c '
386
+ 'on c.table_name = t.table_name left join '
387
+ '(select segment_name, sum(bytes) bytes from '
388
+ 'user_segments where segment_type in '
389
+ "('TABLE', 'TABLE PARTITION') group by segment_name) s "
390
+ 'on s.segment_name = t.table_name group by '
391
+ 't.table_name, t.num_rows, s.bytes order by '
392
+ 't.table_name'
393
+ )
394
+ for row in cursor:
395
+ yield {
396
+ 'name': self._normalize_identifier(row[0]),
397
+ 'type': 'table',
398
+ 'columns': int(row[1]),
399
+ 'rows': (
400
+ int(row[2]) if row[2] is not None else '-'
401
+ ),
402
+ 'size': (
403
+ int(row[3]) if row[3] is not None else '-'
404
+ ),
405
+ }
406
+ finally:
407
+ self._close(stats_connection, config)
408
+
409
+ return {
410
+ 'type': 'database',
411
+ 'name': config['service_name'],
412
+ 'table_names': names,
413
+ 'tables': tables(),
414
+ 'table_summaries': table_summaries(),
415
+ }
@@ -10,7 +10,7 @@ from urllib.parse import parse_qs, unquote, urlsplit
10
10
 
11
11
  import pymysql
12
12
 
13
- from gitdata.connectors.common import BaseConnector
13
+ from gitdata.connectors.common import BaseConnector, ConnectorError
14
14
 
15
15
 
16
16
  class CsvConnector(BaseConnector):
@@ -152,7 +152,41 @@ class MariaDBConnector(BaseConnector):
152
152
 
153
153
  @staticmethod
154
154
  def _connect(config):
155
- database = pymysql.connect(**config)
155
+ try:
156
+ database = pymysql.connect(**config)
157
+ except pymysql.OperationalError as error:
158
+ code = error.args[0] if error.args else None
159
+ host = config['host'].encode('unicode_escape').decode('ascii')
160
+ address = '{}:{}'.format(host, config['port'])
161
+ if code == 2003:
162
+ message = (
163
+ 'could not connect to MySQL server at {}. '
164
+ 'Check the server name, confirm MySQL is running, and '
165
+ 'verify network access.'.format(address)
166
+ )
167
+ elif code == 1045:
168
+ message = (
169
+ 'could not authenticate MySQL user {!r} at {}. '
170
+ 'Check the user name and password.'.format(
171
+ config['user'], address
172
+ )
173
+ )
174
+ elif code == 1049:
175
+ message = (
176
+ 'MySQL database {!r} was not found at {}. '
177
+ 'Check the database name.'.format(
178
+ config['database'], address
179
+ )
180
+ )
181
+ else:
182
+ code = code if isinstance(code, int) else 'unknown'
183
+ message = (
184
+ 'MySQL could not open a connection to {} (error {}). '
185
+ 'Check the server settings and access permissions.'.format(
186
+ address, code
187
+ )
188
+ )
189
+ raise ConnectorError(message) from None
156
190
  try:
157
191
  with database.cursor() as cursor:
158
192
  cursor.execute('set session transaction read only')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitdata-lib
3
- Version: 0.0.18
3
+ Version: 0.0.19
4
4
  Summary: Data extraction and analysis library
5
5
  Home-page: https://github.com/gitdata/gitdata-lib
6
6
  Author: DSI Labs
@@ -67,8 +67,8 @@ python3 -m pip install 'gitdata-lib[oracle]'
67
67
 
68
68
  ## Explore a data source
69
69
 
70
- The same two commands work across CSV files, SQLite databases, and
71
- MariaDB/MySQL databases:
70
+ The same two commands work across CSV files, SQLite databases,
71
+ MariaDB/MySQL databases, and Oracle databases:
72
72
 
73
73
  ```bash
74
74
  gitdata ls <source>
@@ -206,23 +206,43 @@ gitdata ls --help
206
206
  gitdata scan --help
207
207
  ```
208
208
 
209
- ## Oracle from Python
209
+ ## Oracle
210
210
 
211
- Use the optional Oracle driver through the Python API:
211
+ Install the optional Oracle driver, then keep the password in an environment
212
+ variable:
212
213
 
213
- ```python
214
- import os
214
+ ```bash
215
+ python3 -m pip install 'gitdata-lib[oracle]'
216
+ export ORACLE_PASSWORD='your-password'
217
+
218
+ DATABASE='oracle://app@db.example.com:1521/FREEPDB1?password_env=ORACLE_PASSWORD'
219
+ TABLE="${DATABASE}#orders"
220
+ ```
221
+
222
+ Explore the service and its tables with the same commands:
223
+
224
+ ```bash
225
+ # List tables owned by the connected user
226
+ gitdata ls "$DATABASE"
215
227
 
216
- from gitdata.database import connect
228
+ # Show table counts and sizes
229
+ gitdata ls -l "$DATABASE"
217
230
 
218
- db = connect(
219
- 'oracle',
220
- host='127.0.0.1',
221
- port=1521,
222
- service_name='FREEPDB1',
223
- user='app',
224
- password=os.environ['ORACLE_PASSWORD'],
225
- )
231
+ # Show column details
232
+ gitdata ls -l "$TABLE"
233
+
234
+ # Preview rows
235
+ gitdata scan --limit 10 "$TABLE"
236
+ ```
237
+
238
+ Oracle references follow this shape:
226
239
 
227
- rows = list(db('SELECT 1 FROM dual'))
240
+ ```text
241
+ oracle://user@host:port/service?password_env=ENVIRONMENT_VARIABLE
242
+ oracle://user@host:port/service?password_env=ENVIRONMENT_VARIABLE#table
228
243
  ```
244
+
245
+ Inline passwords are not accepted. Ordinary Oracle table and column names are
246
+ shown in lowercase. Quoted identifiers are shown with their double quotes so
247
+ names that differ only by Oracle quoting remain distinct; use the displayed
248
+ spelling after `#` when selecting one (the quotes may be URL-encoded as `%22`).
@@ -32,6 +32,7 @@ gitdata/connectors/fake.py
32
32
  gitdata/connectors/gitlab.py
33
33
  gitdata/connectors/http.py
34
34
  gitdata/connectors/local.py
35
+ gitdata/connectors/oracle.py
35
36
  gitdata/connectors/tabular.py
36
37
  gitdata/database/__init__.py
37
38
  gitdata/database/common.py
@@ -1 +0,0 @@
1
- __version__ = '0.0.18'
File without changes
File without changes
File without changes
File without changes