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.
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/CHANGELOG.md +6 -0
- {gitdata_lib-0.0.18/gitdata_lib.egg-info → gitdata_lib-0.0.19}/PKG-INFO +37 -17
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/README.md +36 -16
- gitdata_lib-0.0.19/gitdata/__version__.py +1 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/__init__.py +54 -1
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/gitdata_ls.py +3 -2
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/gitdata_scan.py +9 -2
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/common.py +4 -0
- gitdata_lib-0.0.19/gitdata/connectors/oracle.py +415 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/tabular.py +36 -2
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19/gitdata_lib.egg-info}/PKG-INFO +37 -17
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata_lib.egg-info/SOURCES.txt +1 -0
- gitdata_lib-0.0.18/gitdata/__version__.py +0 -1
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/LICENSE +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/MANIFEST.in +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/__init__.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/assets/README.md +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/buckets.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/gitdata_get.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/gitdata_init.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/cli/gitdata_secrets.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/config.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/__init__.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/fake.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/gitlab.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/http.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/connectors/local.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/__init__.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/common.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/mysql.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/mysql_setup.sql +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/mysql_setup_test_database.sql +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/oracle.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/postgresql.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/sqlite3.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/sqlite3_setup.sql +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/database/sqlite3_setup_test_data.sql +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/digester.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/encryption.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/ext/__init__.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/ext/connectors/__init__.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/graphs.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/inspection.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/json.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/queues.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/repositories.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/secrets.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/solutions.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/sql.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/stores/__init__.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/stores/common.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/stores/entities.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/stores/facts.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/stores/tables.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata/utils.py +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata_lib.egg-info/dependency_links.txt +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata_lib.egg-info/entry_points.txt +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata_lib.egg-info/requires.txt +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/gitdata_lib.egg-info/top_level.txt +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/requirements.txt +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/setup.cfg +0 -0
- {gitdata_lib-0.0.18 → gitdata_lib-0.0.19}/setup.py +0 -0
- {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.
|
|
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,
|
|
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
|
|
209
|
+
## Oracle
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
Install the optional Oracle driver, then keep the password in an environment
|
|
212
|
+
variable:
|
|
212
213
|
|
|
213
|
-
```
|
|
214
|
-
|
|
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
|
-
|
|
228
|
+
# Show table counts and sizes
|
|
229
|
+
gitdata ls -l "$DATABASE"
|
|
217
230
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
162
|
+
## Oracle
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
Install the optional Oracle driver, then keep the password in an environment
|
|
165
|
+
variable:
|
|
165
166
|
|
|
166
|
-
```
|
|
167
|
-
|
|
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
|
-
|
|
181
|
+
# Show table counts and sizes
|
|
182
|
+
gitdata ls -l "$DATABASE"
|
|
170
183
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
|
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'],
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
|
209
|
+
## Oracle
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
Install the optional Oracle driver, then keep the password in an environment
|
|
212
|
+
variable:
|
|
212
213
|
|
|
213
|
-
```
|
|
214
|
-
|
|
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
|
-
|
|
228
|
+
# Show table counts and sizes
|
|
229
|
+
gitdata ls -l "$DATABASE"
|
|
217
230
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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`).
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '0.0.18'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|