diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..70ea5bc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + pull_request: + push: + branches: [main, develop] + +jobs: + test: + name: test (py${{ matrix.python-version }}) + # Pinned: ubuntu-latest moved to 26.04, which has no Python 3.9 build for + # actions/setup-python, so the py3.9 job failed before running any tests. + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package and test deps + # `spatial_index` on PyPI has no wheels for Python 3.9+ and no + # source distribution, so `pip install .` cannot resolve it on the + # modern matrix. Install nexsciTAP with --no-deps and rely on the + # test fixture's spatial_index stub (tests/fixtures/stubs/) on + # PYTHONPATH for the constants TAP imports. configobj is the only + # actually-needed runtime dep and is in requirements-test.txt. + # ADQL is installed with --no-deps for the same reason. + run: | + pip install --upgrade pip + pip install -r requirements-test.txt + pip install --no-deps "ADQL==1.0.6" + pip install --no-deps . + + - name: Lint with ruff + run: ruff check . + + - name: Run tests + run: pytest tests/ -v + + wheel-platforms: + name: wheel build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Build wheel + run: | + pip install --upgrade pip build + python -m build --wheel + + - name: Verify wheel artifact + # See test job for spatial_index PyPI situation. We can't install + # the wheel's full deps on modern Python; just confirm the wheel + # was built and includes the C extension. + run: | + ls dist/ + python -c " + import zipfile, glob + wheel = glob.glob('dist/*.whl')[0] + with zipfile.ZipFile(wheel) as z: + names = z.namelist() + assert any('writerecs' in n and n.endswith('.so') for n in names), \ + f'C extension missing from wheel: {names}' + print(f'wheel OK: {wheel}') + print('contains C extension') + " diff --git a/.gitignore b/.gitignore index fcd07d6..035818a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,15 @@ *.[oa] *~ +*.so /Sphinx/_build /TAP/__pycache__ -/TAP/writerecs.cpython-36m-darwin.so /build /dist /final_dist /final_wheel /nexsciTAP.egg-info +__pycache__/ +.pytest_cache/ # Workflow artifacts (Claude Code planning/spec docs, not project documentation) /docs/superpowers/ diff --git a/HOTFIX-3.0.2.md b/HOTFIX-3.0.2.md deleted file mode 100644 index 742aca8..0000000 --- a/HOTFIX-3.0.2.md +++ /dev/null @@ -1,75 +0,0 @@ -# nexsciTAP 3.0.2 security hotfix - -This branch starts at the `v3.0.1` release commit -`6cf63cb8c282fb9fa7ca3800de85197b7b9e1734`. Its production changes are the -proprietary-access security fix, removal of credential logging, and the -3.0.2 version bump. It keeps the 3.0.1 configuration format and dependency -declarations. It does not include the 3.1 compatibility or async changes. - -## Behavior changes - -- The complete user WHERE condition is grouped before the access restriction - is added, so the restriction applies to every OR branch. -- Queries involving protected tables must use a single table without aliases, - joins, subqueries, set operations, or SQL comments. Unsupported queries - return HTTP 400. The guard is conservative and can also reject keywords or - parentheses inside string literals. -- Every referenced table is checked when choosing the protected-query path, - including protected tables inside a query whose outer table is public. -- Cookie user IDs are bound as parameters in the user lookup and access-list - query. Debug logging no longer dumps cookies, tokens, or database passwords. -- Operators enable request debugging with `TAP_DEBUG=1` in the server - environment; a request's `debug` parameter no longer enables it. - -## Deploy from the private advisory fork - -Use the reviewed commit on `security/propfilter-access-3.0.2` in -`Caltech-IPAC/nexsciTAP-ghsa-p9v9-r3q6-gxg6`. The separate -`security/propfilter-access` branch contains the 3.1 candidate. - -Build or install with the Python environment used by the TAP CGI/service. -For a checked-out copy of the reviewed commit, the installation command is: - -```sh -/path/to/service/venv/bin/python -m pip install --no-deps --no-build-isolation --upgrade . -``` - -The same command accepts the `nexscitap-3.0.2.tar.gz` source package in place -of `.`. The existing environment supplies its installed database driver, -ADQL, spatial_index, runtime dependencies, and packaging tools. Building from -source compiles the existing C extension for the deployment's Python and -platform; use that deployment's normal build process if it distributes wheels. - -Verify the imported package with the service's Python: - -```sh -/path/to/service/venv/bin/python -c 'import importlib.metadata, TAP.propfilter; print(importlib.metadata.version("nexsciTAP")); print(TAP.propfilter.__file__)' -``` - -The version must be `3.0.2` and the path must identify the service's installed -package. Reload persistent service workers through the team's normal deployment -procedure. Keep the existing TAP configuration. - -## Validation - -Local tests cover synthetic public and embargoed rows for KOA and NEID, -anonymous and authorized access, OR grouping, rejected query shapes, mixed -public/protected table routing, bound user IDs with apostrophes, and debug -credential handling. The complete release-branch suite contains 144 passing -tests, with no skips. The non-spatial test setup supplies only SpatialIndex -constants when the native spatial package is unavailable. - -Run the service's Oracle replica and frontend smoke checks before production -rollout. For an anonymous NEID check, these predicates must give the same count: - -```sql -SELECT COUNT(*) AS n FROM neidl2 WHERE obstype = 'Sci' -SELECT COUNT(*) AS n FROM neidl2 WHERE obstype = 'Sci' OR obstype = 'Sci' -SELECT COUNT(*) AS n FROM neidl2 WHERE (obstype = 'Sci' OR obstype = 'Sci') -``` - -Repeat with an authorized account and confirm its intended proprietary access. -Check the team's usual KOA queries and any UI queries that use table aliases, -joins, or subqueries against protected tables, since those now return HTTP 400. -The live Oracle and NEA production comparisons have not been run as part of -the local validation. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 3a395d0..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include HOTFIX-3.0.2.md -include tests/conftest.py diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..b673bf5 --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,74 @@ +# Migrating from the 1.x TAP.conf layout and NEA compatibility mode + +nexsciTAP 3.1 runs deployments that still use the 1.x `TAP.conf` layout +(`[webserver]`, a section named by `DBMS=`, top-level `ADQL_*` keys). A 1.x +config is translated to the 3.x layout when it's read, and it turns on +**compatibility mode**: the responses that deployment sent under nexsciTAP +1.2. Compatibility mode is temporary. While it's on, the service writes one +line a day to stderr (the web server's error log): + + nexsciTAP: compatibility mode active (...); see MIGRATING.md + +## Compatibility behaviors + +| Name | With it (1.2 behavior) | Without it (3.x behavior) | +|---|---|---| +| `nea-vosi-headers` | `/availability` and `/capabilities` also send `Connection: close`, and end with an extra CRLF | status line and `Content-type` only | +| `nea-errors` | errors are VOTable documents; a table not in TAP_SCHEMA returns 400 | plain text; 403 | +| `nea-tables` | `/tables` uses VODataService `` elements | per-column ``, ``, ``, ``, `` | +| `nea-votable` | VOTable results are `application/xml`, non-char FIELDs carry no DESCRIPTION (units are still written) | `text/xml`, with descriptions on every FIELD | +| `nea-uws` | job documents are `application/xml`, no `Z` on `destruction`; the 303 that starts a job (`PHASE=RUN`) carries no `Content-Type`, `Content-Length` or `Connection` lines, because the response ends when the CGI exits; `/async//executionDuration` (camelCase) is accepted as well as `/executionduration`; job URLs join `HTTP_URL` and `CGI_PGM` with `/` as written, so `CGI_PGM=/TAP` gives `//TAP` | `text/xml`, `Z`; the 303 carries `Content-Type: text/plain`, `Content-Length` and `Connection: close`; only `/executionduration` is a valid key; job URLs have one `/` between `HTTP_URL` and `CGI_PGM` | + +## Moving to the 3.x layout + +1. Translate the config, keeping every behavior: in `[WEB]`, list them all. + + | 1.x | 3.x | + |---|---| + | `[webserver]` `TAP_WORKDIR`, `TAP_WORKURL`, `HTTP_URL`, `HTTP_PORT`, `CGI_PGM`, `ArraySize`, `INFOMSG` | `[WEB]`, same keys | + | `[webserver]` `DBMS` | `[DBMS]` `DBMS` | + | the `[oracle]` / `[sqlite3]` section's keys | `[DBMS]`, same keys | + | `[webserver]` `COOKIENAME`, `RACOL`, `DECCOL` | `[DBMS]`, same keys | + | `ADQL_MODE`, `ADQL_LEVEL`, `ADQL_XCOL`, `ADQL_YCOL`, `ADQL_ZCOL`, `ADQL_COLNAME`, `ADQL_ENCODING` | `[SPTIND]` `MODE`, `LEVEL`, `XCOL`, `YCOL`, `ZCOL`, `COLNAME`, `ENCODING` | + + ```ini + [WEB] + COMPAT = nea-vosi-headers, nea-errors, nea-tables, nea-votable, nea-uws + ``` + Responses don't change. + +2. Remove names from `COMPAT` one at a time, each as an announced change for + that service's users. +3. When `COMPAT` is empty, remove the key. + +## Known differences from 1.2 + +Compatibility mode reproduces 1.2's responses, with these exceptions: + +- Errors raised before `TAP.conf` is read (a missing or broken `TAP.conf`, a + malformed request path) use the 3.x error format, since the mode isn't known + yet. +- The async job document escapes quotes in the query text as `'`; it + parses to the same XML. +- Both modes write the job document's duration as `executionDuration` (the + UWS name) and read either spelling, so jobs written by an earlier release + or under the other mode read correctly. +- Both modes follow UWS where 1.2 did not: a job can be deleted + (`POST ACTION=DELETE` or HTTP `DELETE`, answered with 303 to the job list), + an unknown job id is 404, `GET` on the job list returns an empty + `` instead of creating a job, and an unset job value (such as + `/destruction` before the job runs) is empty text instead of `None`. + +`COMPAT` is read only from `[WEB]`; placing it in any other section, including +a 1.x `[webserver]`, is a configuration error. + +## Release plan + +- **3.1.0**: compatibility mode, the 1.x reader, this guide. The default + job document now spells the duration `executionDuration`, as UWS + requires, which retires that part of `nea-uws`. +- **3.x**: where the shared behavior is to change (for example VOTable + errors, which IVOA standards require), the release notes say so before + any default changes. +- **4.0.0**: compatibility mode, the `COMPAT` key and the 1.x reader are + removed; a 1.x `TAP.conf` then fails at startup with a pointer here. diff --git a/Sphinx/conf.py b/Sphinx/conf.py index 446a46d..b0c89c9 100644 --- a/Sphinx/conf.py +++ b/Sphinx/conf.py @@ -24,7 +24,7 @@ author = 'John Good' # The full version, including alpha/beta/rc tags -release = '3.0.2' +release = '3.1.0' # -- General configuration --------------------------------------------------- diff --git a/TAP/compat.py b/TAP/compat.py new file mode 100644 index 0000000..96e83ea --- /dev/null +++ b/TAP/compat.py @@ -0,0 +1,270 @@ +"""NEA compatibility mode: NEA's pre-3.x responses on the shared code line. + +Compat mode is on when TAP.conf uses the 1.x layout ([webserver], no [WEB]), +which implies every behavior below, or when [WEB] lists COMPAT names. Each +function returns what the default code does unless its behavior is active, +so call sites hold no conditionals, and retiring compat means deleting this +module and compat_vositables.py. See MIGRATING.md for the plan. +""" +import datetime +import html +import os +import sys + +import configobj + +NAMES = ('nea-vosi-headers', 'nea-errors', 'nea-tables', 'nea-votable', 'nea-uws') +ALL = frozenset(NAMES) +NONE = frozenset() + +# 1.x [webserver] keys that 3.x reads from [WEB]. +_WEB_KEYS = ('TAP_WORKDIR', 'TAP_WORKURL', 'HTTP_URL', 'HTTP_PORT', 'CGI_PGM', + 'ArraySize', 'INFOMSG') +# 1.x ADQL_* keys and their 3.x [SPTIND] names. +_SPTIND_KEYS = {'ADQL_MODE': 'MODE', 'ADQL_LEVEL': 'LEVEL', 'ADQL_XCOL': 'XCOL', + 'ADQL_YCOL': 'YCOL', 'ADQL_ZCOL': 'ZCOL', + 'ADQL_COLNAME': 'COLNAME', 'ADQL_ENCODING': 'ENCODING'} + + +class CompatConfigError(Exception): + pass + + +def parse_names(value): + """A COMPAT value (configobj gives str or list) -> frozenset of names.""" + if value is None: + return NONE + items = [value] if isinstance(value, str) else list(value) + names = frozenset(v.strip() for v in items if v.strip()) + unknown = sorted(names - ALL) + if unknown: + raise CompatConfigError('unknown COMPAT name(s) %s; valid names: %s' + % (', '.join(unknown), ', '.join(NAMES))) + return names + + +def _find(conf, key): + """A 1.x key wherever configobj filed it: [webserver] first, then any section. + + Keys written after a section header belong to that section, so ADQL_* + lines placed after [oracle] land in [oracle]. + """ + if key in conf['webserver']: + return conf['webserver'][key] + for name in conf.sections: + if key in conf[name]: + return conf[name][key] + return None + + +def translate_legacy(conf): + """A 1.x TAP.conf ([webserver], [], ADQL_*) as an equivalent 3.x one.""" + web = conf['webserver'] + dbms = web.get('DBMS', '') + if dbms not in conf: + raise CompatConfigError('1.x TAP.conf names DBMS=%s but has no [%s] section' + % (dbms, dbms)) + new = configobj.ConfigObj() + new['WEB'] = {k: web[k] for k in _WEB_KEYS if k in web} + db = {'DBMS': dbms} + db.update({k: v for k, v in conf[dbms].items() if not k.startswith('ADQL_')}) + # Every other [webserver] key goes to the connection section, where 3.x + # reads COOKIENAME, RACOL, DECCOL and the proprietary-filter keys + # (PROPFILTER, ACCESS_TBL, USERS_TBL, FILEID, ACCESSID). Nothing is + # dropped: a lost PROPFILTER would serve proprietary rows to anyone. + for k, v in web.items(): + if k not in _WEB_KEYS and k != 'DBMS' and not k.startswith('ADQL_'): + db[k] = v + if dbms == 'sqlite3': + db.setdefault('TAP_SCHEMA_FILE', 'TAP_SCHEMA') # 3.x's SQLite ATTACH name + new['DBMS'] = db + sptind = {} + for old, current in _SPTIND_KEYS.items(): + value = _find(conf, old) + if value is not None: + sptind[current] = value + new['SPTIND'] = sptind + return new + + +def load_config(conf): + """(ConfigObj as read) -> (ConfigObj in 3.x layout, active compat names).""" + for name in conf.sections: + if name != 'WEB' and 'COMPAT' in conf[name]: + raise CompatConfigError('COMPAT belongs in [WEB], found in [%s]' % name) + if 'WEB' not in conf and 'webserver' in conf: + return translate_legacy(conf), ALL + web = conf['WEB'] if 'WEB' in conf else {} + return conf, parse_names(web.get('COMPAT')) + + +# --- nea-vosi-headers ------------------------------------------------------- + +def vosi_head(active): + """Status line and headers written before a VOSI document. + + nph- CGI: the script emits the whole HTTP response. The status line and + each header end in CRLF, and a bare CRLF closes the header block; nginx + and Cloudflare reject the response otherwise. NEA also sends + Connection: close. + """ + head = 'HTTP/1.1 200 OK\r\nContent-type: application/xml\r\n' + if 'nea-vosi-headers' in active: + head += 'Connection: close\r\n' + return head + '\r\n' + + +def vosi_tail(active): + """NEA ends its VOSI documents with one more CRLF.""" + return '\r\n' if 'nea-vosi-headers' in active else '' + + +# --- nea-errors ------------------------------------------------------------- + +def table_denied_status(active): + """Status for a table outside TAP_SCHEMA: NEA answers 400, the shared line 403.""" + return '400' if 'nea-errors' in active else '403' + + +def error_document(active, errmsg, errcode): + """NEA's error response: a VOTable error document whatever the format. + + Returns the whole response, or None when nea-errors is inactive. Markup + is escaped so the document stays well-formed; quotes are not, matching + NEA's bytes for ordinary messages. + """ + if 'nea-errors' not in active: + return None + return ('HTTP/1.1 %s ERROR\r\n' % errcode + + 'Content-type: application/xml\r\n\r\n' + + '\n' + + '\n' + + '\n' + + '\n' + + html.escape(str(errmsg), quote=False) + '\n' + + '\n' + + '\n' + + '\n') + + +# --- nea-votable ------------------------------------------------------------ + +def votable_content_type(active): + """Content type for VOTable results: NEA sends application/xml.""" + return 'application/xml' if 'nea-votable' in active else 'text/xml' + + +def field_description(active, desc, coltype): + """NEA's VOTables carry no DESCRIPTION for non-char columns. + + The C writer prints the plain form when a non-char column's + description is empty (writerecsmodule.c, non-char branch), so blanking it + here reproduces NEA's FIELDs without touching the C API. Char columns + already match. + """ + if 'nea-votable' in active and coltype != 'char': + return '' + return desc + + +# --- nea-uws ---------------------------------------------------------------- + +def service_url(active, httpurl, cgipgm): + """The service URL that job URLs extend. + + NEA's carry a double slash (HTTP_URL + '/' + CGI_PGM=/TAP); the shared + line joins the parts with one. + """ + if 'nea-uws' in active: + return httpurl + '/' + cgipgm + return httpurl.rstrip('/') + '/' + cgipgm.strip('/') + + +def uws_key(job, key): + """Element of a parsed status.xml that answers a job sub-resource key. + + The duration is spelled as the document spells it (see duration_value). + """ + if key == 'executionduration' and 'uws:executionDuration' in job: + return 'executionDuration' + return key + + +def uws_url_key(active, key): + """The job sub-resource key from the URL, as the code compares it. + + NEA's /async//executionDuration (UWS spelling) is accepted next to the + lowercase form pyvo uses; the shared line knows only the lowercase one. + """ + if 'nea-uws' in active and key == 'executionDuration': + return 'executionduration' + return key + + +def duration_value(job): + """The duration in a parsed status.xml, whichever spelling wrote it. + + A job written under one mode may be read under the other (jobs live up + to four days, and 3.1.0 and earlier wrote it lowercase), so reading + tolerates both; writing uses the UWS spelling. + """ + return job.get('uws:executionDuration', job.get('uws:executionduration')) + + +def destruction_suffix(active): + """NEA writes the destruction time without a trailing 'Z'.""" + return '' if 'nea-uws' in active else 'Z' + + +def uws_content_type(active): + """Content type of the job document: NEA sends application/xml.""" + return 'application/xml' if 'nea-uws' in active else 'text/xml' + + +def async_submit_headers(active, body): + """Framing headers of the 303 that starts a job (PHASE=RUN). + + NEA sends none: the response ends when the CGI exits, because the + forked worker holds no stdout. Otherwise: Content-Type, the length of + `body` in bytes, and Connection: close. + """ + if 'nea-uws' in active: + return '' + return ('Content-Type: text/plain\r\nContent-Length: %d\r\n' + 'Connection: close\r\n' % len(body.encode('utf-8'))) + + +# --- nea-tables ------------------------------------------------------------- + +def vosi_tables_class(active): + """The /tables writer: NEA's (prod's vositables.py, verbatim) or the shared one.""" + if 'nea-tables' in active: + from TAP.compat_vositables import vosiTables + else: + from TAP.vositables import vosiTables + return vosiTables + + +# --- deprecation ------------------------------------------------------------ + +def warn_once_per_day(active, workdir, today=None, stream=None): + """While compat is active, one stderr line (Apache's error_log) per day. + + A stamp file /TAP/.compat-warned-, created exclusively, + marks the day, so concurrent requests warn once. Any failure is + swallowed: the warning must never fail a request. + """ + if not active: + return False + try: + day = (today or datetime.date.today()).isoformat() + stamp = os.path.join(workdir, 'TAP', '.compat-warned-' + day) + os.makedirs(os.path.dirname(stamp), exist_ok=True) + with open(stamp, 'x'): + pass + (stream or sys.stderr).write( + 'nexsciTAP: compatibility mode active (%s); see MIGRATING.md\n' + % ', '.join(sorted(active))) + return True + except Exception: + return False diff --git a/TAP/compat_vositables.py b/TAP/compat_vositables.py new file mode 100644 index 0000000..64c57db --- /dev/null +++ b/TAP/compat_vositables.py @@ -0,0 +1,1699 @@ +# ruff: noqa +# NEA compatibility (nea-tables): NEA prod's vositables.py, kept verbatim from +# commit bbc1108 so /tables matches NEA byte for byte. Selected by +# compat.vosi_tables_class(); delete with compat.py (see MIGRATING.md). + +# Copyright (c) 2020, Caltech IPAC. +# This code is released with a BSD 3-clause license. License information is at +# https://github.com/Caltech-IPAC/nexsciTAP/blob/master/LICENSE + + +import sys +import os +import logging + +import datetime + +import argparse + + +class vosiTables: +# +# { class vosiTables +# + + pid = os.getpid() + + debug = 0 + + debugfname = './vosi.debug' + status = '' + msg = '' + returnMsg = "" + + arraysize = 10000 + + # + # DD columns + # + + dd = None + + conn = None + dbtable = '' + + sql = '' + racol = '' + deccol = '' + + nfetch = 1000 + + outpath = '' + + #ntot = 0 + + format = 'votable' + maxrec = -1 + coldesc = 0 + + nschema = 0 + + schema_namearr = [] + schema_descarr = [] + + + def __init__(self, **kwargs): + + # + # { vosiTables.init method + # + + """ + vosiTables class generates the VOSI compatible XML outputs for TAP + query with endpoint TAP/tables + + Required keyword input parameters: + + connectInfo (python dictionary): + a python dictionary containing info needed to make + a "connection" to a DBMS. + + ourpath(char): output table file path + + Usage: + + vosi = vosiTables (\ + dbms=dbms, + dbserver=dbserver, \ + userid=userid, \ + password=password, \ + outpath=outpath, \ + debug=debug) + """ + + self.dbms = '' + self.dbserver = '' + self.userid = '' + self.password = '' + self.outpath = '' + self.debug = 0 + + self.db = '' + self.tap_schema = '' + + if('debug' in kwargs): + self.debug = kwargs['debug'] + + if self.debug: + logging.debug('') + logging.debug('Enter vosiTables.init') + + if('dbms' in kwargs): + self.dbms = kwargs['dbms'] + + if('dbserver' in kwargs): + self.dbserver = kwargs['dbserver'] + + if('userid' in kwargs): + self.userid = kwargs['userid'] + + if('password' in kwargs): + self.password = kwargs['password'] + + if('outpath' in kwargs): + self.outpath = kwargs['outpath'] + + if('db' in kwargs): + self.db = kwargs['db'] + + if('tap_schema' in kwargs): + self.tap_schema = kwargs['tap_schema'] + + # + # Get keyword parameters + # + + if ((self.dbms is None) or (len(self.dbms) == 0)): + self.msg = 'Database name missing.' + self.status = 'error' + #self.__printVosiError__ (self.msg) + + raise Exception (self.msg) + + if self.debug: + logging.debug('') + logging.debug(f'dbms= {self.dbms:s}') + + + if (self.dbms.lower() == 'oracle'): + + if ((self.dbserver is None) or (len(self.dbserver) == 0)): + self.msg = 'Database server name missing.' + self.status = 'error' + #self.__printVosiError__ (self.msg) + raise Exception (self.msg) + + + if self.debug: + logging.debug('') + logging.debug(f'dbserver= {self.dbserver:s}') + + if ((self.userid is None) or (len(self.userid) == 0)): + self.msg = 'Database userid missing.' + self.status = 'error' + #self.__printVosiError__ (self.msg) + raise Exception (self.msg) + + + if self.debug: + logging.debug('') + logging.debug(f'userid= {self.userid:s}') + + if ((self.password is None) or (len(self.password) == 0)): + self.msg = 'Database password missing.' + self.status = 'error' + #self.__printVosiError__ (self.msg) + raise Exception (self.msg) + + + + elif (self.dbms.lower() == 'sqlite3'): + + if ((self.db is None) or (len(self.db) == 0)): + self.msg = 'Database name missing.' + self.status = 'error' + #self.__printVosiError__ (self.msg) + raise Exception (self.msg) + + + if self.debug: + logging.debug('') + logging.debug(f'db= {self.db:s}') + + if ((self.tap_schema is None) or (len(self.tap_schema) == 0)): + + self.msg = 'Database table name missing.' + self.status = 'error' + #self.__printVosiError__ (self.msg) + raise Exception (self.msg) + + + if self.debug: + logging.debug('') + logging.debug(f'tap_schema= {self.tap_schema:s}') + + + if ((len(self.outpath) == 0) or (self.outpath is None)): + self.msg = 'Required output path is missing.' + #self.__printVosiError__ (self.msg) + raise Exception (self.msg) + + + if self.debug: + logging.debug('') + logging.debug(f'outpath= {self.outpath:s}') + + # + # { Connect to DBMS + # + if(self.dbms.lower() == 'oracle'): + + import cx_Oracle + try: + self.conn = cx_Oracle.connect(self.userid, + self.password, + self.dbserver) + + if self.debug: + logging.debug('') + logging.debug('connected to Oracle, DB ' + self.dbserver) + + except Exception as e: + + self.status = 'error' + self.msg = 'Failed to connect to cx_Oracle' + + #self.__printVosiError__ (self.msg) + raise Exception (self.msg) + + + + elif (self.dbms.lower() == 'sqlite3'): + + import sqlite3 + + self.db = '' + if('db' in self.connectInfo): + self.db = self.connectInfo['db'] + + if(len(self.db) == 0): + self.msg = 'Failed to retrieve required input parameter'\ + ' [db]' + self.status = 'error' + #self.__printVosiError__ (self.msg) + raise Exception (self.msg) + + + + self.tap_schema = '' + if('tap_schema' in self.connectInfo): + self.tap_schema = self.connectInfo['tap_schema'] + + if(len(self.tap_schema) == 0): + self.msg = 'Failed to retrieve required input parameter'\ + ' [tap_schema]' + self.status = 'error' + #self.__printVosiError__ (self.msg) + raise Exception (self.msg) + + + if self.debug: + logging.debug('') + logging.debug(f'db= {self.db:s}') + logging.debug(f'tap_schema= {self.tap_schema:s}') + + try: + self.conn = sqlite3.connect(self.db) + + if self.debug: + logging.debug('') + logging.debug('connected to SQLite3, database ' + self.db) + + cmd = 'ATTACH DATABASE ? AS TAP_SCHEMA' + + dbspec = (self.tap_schema,) + + if self.debug: + logging.debug('') + logging.debug('cmd: ' + cmd + '(' + self.tap_schema + ')') + + cursor = self.conn.cursor() + + cursor.execute(cmd, dbspec) + + if self.debug: + logging.debug('') + logging.debug('TAP_SCHEMA attached') + + except Exception as e: + + self.status = 'error' + self.msg = 'Failed to connect to SQLite3 databases' + #self.__printVosiError__ (self.msg) + raise Exception (self.msg) + + + else: + self.msg = f'Invalid DBMS: {self.dbms:s}' + self.status = 'error' + #self.__printVosiError__ (self.msg) + raise Exception (self.msg) + + + # + # } end Connect to DBMS + # + + # + # start loop for constructing output: + # + # -- vosi header: tableset tag, + # -- query for schemas: schema tag, + # -- for each schema, query tables: table tag, + # -- for each table, query columns: column tag, + # -- end column tag, + # -- for each table, query foreignkey: foreignkey tag, + # -- end foreignkey tag + # -- end table tag, + # -- end schema tag + # -- end tableset tag + + # + # open ouput file in workdir for vosi table output + # + #vositblpath = self.workdir + '/vositable.xml' + vositblpath = self.outpath + + if self.debug: + logging.debug('') + logging.debug(f'vositblpath = {vositblpath:s}') + + + fp = None + try: + fp = open (vositblpath, 'w') + os.chmod(vositblpath, 0o664) + + except Exception as e: + + if self.debug: + logging.debug('') + logging.debug(f'open voditblpath exception: {str(e):s}') + + self.msg = 'Failed to open/create vositbl file.' + #self.__printVosiError__(self.msg) + raise Exception (self.msg) + + + # + # write vositable header + # + fp.write ("\n") + fp.write(" \n") + fp.flush() + + # + # Submit database query for TAP_SCHEMA.schemas + # + + self.sql = "select schema_name, description from TAP_SCHEMA.schemas order by schema_index" + + if self.debug: + logging.debug('') + logging.debug(f'sql = {self.sql:s}') + logging.debug('call execute sql') + + cursor = self.conn.cursor() + + try: + self.__executeSql__(cursor, self.sql) + + except Exception as e: + + if self.debug: + logging.debug('') + logging.debug(f'executeSql exception: {str(e):s}') + + #self.__printVosiError__ (str(e)) + raise Exception (str(e)) + + + if self.debug: + logging.debug('') + logging.debug('returned executeSql') + + + try: + if self.debug: + logging.debug('') + logging.debug('call getSchemaQueryArr__:') + + self.__getSchemaQueryArr__ (cursor, \ + debug=self.debug) + + if self.debug: + logging.debug('') + logging.debug('returned getSchemaDesc') + + except Exception as e: + + if self.debug: + logging.debug('') + logging.debug(f'writeResult exception: {str(e):s}') + + #self.__printVosiError__ (str(e)) + raise Exception (str(e)) + + + self.nschema = len (self.schema_namearr) + + if self.debug: + logging.debug('') + logging.debug(f'nschema= {self.nschema:d}') + logging.debug('') + logging.debug('schema name and desc') + + for i in range (0,self.nschema): + logging.debug(f'{i:d} schema_name={self.schema_namearr[i]:s}') + logging.debug(f'schema_description={self.schema_descarr[i]:s}') + + for ischema in range (0, self.nschema): + # + # { ischema loop + # + # for each schema: + # 1. write header block for each schema, + # + fp.write(" \n") + fp.write(" " + \ + self.schema_namearr[ischema] + "\n") + fp.write(" " + \ + self.schema_descarr[ischema] + "\n") + fp.flush() + + + # + # 2. execute table sql + # + self.sql = "select table_name, description, table_type " + \ + "from TAP_SCHEMA.tables where schema_name='" + \ + self.schema_namearr[ischema] + "' order by table_index" + + if self.debug: + logging.debug('') + logging.debug(f'ischema= {ischema:d}') + logging.debug(f'schema_name= {self.schema_namearr[i]:s}') + logging.debug('') + logging.debug(f'sql = {self.sql:s}') + logging.debug('call execute sql') + + cursor = self.conn.cursor() + + try: + self.__executeSql__(cursor, self.sql) + + except Exception as e: + + if self.debug: + logging.debug('') + logging.debug(f'executeSql exception: {str(e):s}') + + #self.__printVosiError__ (str(e)) + raise Exception (str(e)) + + + if self.debug: + logging.debug('') + logging.debug('returned executeSql') + logging.debug('') + logging.debug('call getTableQueryArr') + + # + # 3. getSqlresult of table sql + # re-initialize arrays + # + + table_namearr = [] + table_descarr = [] + table_typearr = [] + + ntable = 0 + try: + ntable = self.__getTableQueryArr__(cursor, table_namearr, \ + table_descarr, table_typearr, debug=1) + + except Exception as e: + + if self.debug: + logging.debug('') + logging.debug(f'getTableQueryArr exception: {str(e):s}') + + #self.__printVosiError__ (str(e)) + raise Exception (str(e)) + + + if self.debug: + logging.debug('') + logging.debug('returned getTableQueryArr') + logging.debug(f'ntable= {ntable:d}') + + + for itable in range (0, ntable): + logging.debug ('') + logging.debug (f'itable= {itable:d}') + logging.debug ( + f'table_name= {table_namearr[itable]:s}') + logging.debug ( + f'table_desc= {table_descarr[itable]:s}') + logging.debug ( + f'table_type= {table_typearr[itable]:s}') + + # + # for each table: + # + + for itable in range (0, ntable): + # + # { itable loop + # + # 1. write header block for each table, + # + fp.write(" \n") + fp.write(" " + \ + table_namearr[itable] + "\n") + fp.write(" " + \ + table_descarr[itable] + "\n") + + + # + # 2. execute column sql + # + self.sql = "select column_name, description, unit, ucd, " + \ + "datatype, principal, indexed from TAP_SCHEMA.columns " + \ + "where table_name='" + table_namearr[itable] + \ + "' order by column_index" + + if self.debug: + logging.debug('') + logging.debug(f'sql = {self.sql:s}') + logging.debug('call execute sql') + + cursor = self.conn.cursor() + + try: + self.__executeSql__(cursor, self.sql) + + except Exception as e: + + if self.debug: + logging.debug('') + logging.debug(f'executeSql exception: {str(e):s}') + + #self.__printVosiError__ (str(e)) + raise Exception (str(e)) + + + if self.debug: + logging.debug('') + logging.debug('returned executeSql') + + # + # 3. getSqlresult of column sql and write column result + # + try: + self.__writeOneTableResult__ (cursor, fp, **kwargs) + + except Exception as e: + + if self.debug: + logging.debug('') + logging.debug( + f'writeOneTableResult exception: {str(e):s}') + + #self.__printVosiError__ (str(e)) + raise Exception (str(e)) + + + if self.debug: + logging.debug('') + logging.debug('returned writeOneTableResult') + + # + # 4. execute foreignkey sq + # 5. getSqlresult of foreignkey sql + # -- write foreignkey result + + self.sql = "select key_id as foreignKey, " + \ + "target_table as targetTable, " + \ + "from_column as fromColumn, " + \ + "target_column as targetColumn, description " + \ + "from TAP_SCHEMA.keys " + \ + "NATURAL JOIN TAP_SCHEMA.key_columns " + \ + "where from_table='" + table_namearr[itable] + "'" + + if self.debug: + logging.debug('') + logging.debug(f'foreign key sql:'); + logging.debug(f'sql= {self.sql:s}'); + logging.debug('call execute sql') + + cursor = self.conn.cursor() + + try: + self.__executeSql__(cursor, self.sql) + + except Exception as e: + + if self.debug: + logging.debug('') + logging.debug(f'executeSql exception: {str(e):s}') + + #self.__printVosiError__ (str(e)) + raise Exception (str(e)) + + + if self.debug: + logging.debug('') + logging.debug('returned executeSql') + + # + # 5. getSqlresult of foreignkey sql and write foreignkey result + # + try: + self.__writeForeignKey__ (cursor, fp, **kwargs) + + except Exception as e: + + if self.debug: + logging.debug('') + logging.debug( + f'writeForeignKey exception: {str(e):s}') + + #self.__printVosiError__ (str(e)) + raise Exception (str(e)) + + + + if self.debug: + logging.debug('') + logging.debug('returned writeForeignKey') + + + # + # 6. write end block for each table + # + fp.write("
\n") + fp.flush() + + # + # } end itable loop + # + + # + # 2. write end block for each schema + # + fp.write("
\n") + fp.flush() + + # + # } end ischema loop and write end block for xml file + # + fp.write("\n") + fp.flush() + + # + # } end vosiTables.init + # + + + def __getTableQueryArr__ (self, cursor, table_namearr, table_descarr, \ + table_typearr, **kwargs): + # + # { vosiTable.getTableQueryArr + # + # this query contains only three char columns: + # table_name, description, and table_type; + # + + if self.debug: + logging.debug('') + logging.debug('Enter getTableQueryArr') + + size = 0 + + table_colname = [] + table_dbtype = [] + + nfetch = self.arraysize + cursor.arraysize = nfetch + + if self.debug: + logging.debug('') + logging.debug(f'nfetch = {nfetch:d}') + + + i = 0 + for col in cursor.description: + + # + # { For loop for cursor description: + # cursor description is a list with ncols tuples: + # each tuple + # contains each output colname's name, datatype, + # dbdatatype, size, etc... + # + + if self.debug: + logging.debug('') + logging.debug('get cursor description:') + logging.debug(f'i = {i:d} col = ' + str(col)) + + # + # Extract colname, dbdatatype, size, precision, scale from + # col array + # + colname = str(col[0]).lower() + table_colname.append(colname) + + if self.debug: + logging.debug('') + logging.debug(f'colname(lower) = {colname:s}') + + + dbtype = '' + size = None + + dbdatatypestr = str(col[1]) + + if self.debug: + logging.debug('') + logging.debug(f'dbdatatypestr = {dbdatatypestr:s}') + + ind = dbdatatypestr.find("VARCHAR") + if(ind != -1): + dbtype = 'STRING' + + ind = dbdatatypestr.find("STRING") + if(ind != -1): + dbtype = 'STRING' + + ind = dbdatatypestr.find("NUMBER") + if(ind != -1): + dbtype = 'NUMBER' + + if self.debug: + logging.debug(f'dbtype = {dbtype:s}') + + table_dbtype.append(dbtype) + + i = i + 1 + + # + # } end of for loop for analysing cursor description + # + + # + # Start fetching data lines + # + + if self.debug: + logging.debug('') + logging.debug('Start fetching data:') + + if self.debug: + logging.debug('') + logging.debug(f'nfetch= {nfetch:d}') + + irow = 0 + ntot = 0 + + while True: + + # + # { start of while loop for fetching data lines; + # max 10000 lines at a time + # + + rows = cursor.fetchmany() + + nrec = len(rows) + + if self.debug: + logging.debug(f'nrec = {nrec:d}') + logging.debug('') + + + for ll in range(0, nrec): + + # + # { Beginning ll loop: one row + # + + row = rows[ll] + + if self.debug: + logging.debug ('') + logging.debug (f'len(row)= {len(row):d}') + logging.debug ('') + logging.debug ('row:') + logging.debug(row) + + for i in range(0, len(row)): + + # + # { Beginning i loop: one col + # + data = '' + + if (table_dbtype[i] != 'STRING'): + + data = str(row[i]) + else: + data = row[i] + + if self.debug: + logging.debug (f'i= {i:d}, data= {data:s}') + logging.debug (f'table_colname= {table_colname[i]:s}') + + if (table_colname[i].lower() == 'table_name'): + table_namearr.append (data) + + elif (table_colname[i].lower() == 'description'): + table_descarr.append (data) + + elif (table_colname[i].lower() == 'table_type'): + table_typearr.append (data) + + # + # } end of i loop + # + + irow = irow + 1 + + # + # } end of ll loop + # + if self.debug: + logging.debug('') + logging.debug(table_namearr) + logging.debug(table_descarr) + logging.debug(table_typearr) + logging.debug('----------------------------------------') + + + ntot = ntot + nrec + + if(len(rows) < cursor.arraysize): + break + + if self.debug: + logging.debug ('') + logging.debug (f'ntot= {ntot:d}') + # + # } end while loop for fetching data lines + # + + return (ntot) + + # + # } end vosiTables.getTableQueryArr + # + + + + def __getSchemaQueryArr__ (self, cursor, **kwargs): + # + # { vosiTables.getSchemaQueryArr + # + # this query contains only two columns: + # schema_name and schema_description + # + if self.debug: + logging.debug('') + logging.debug(f'Enter __getSchemaQueryArr__\n') + + size = 0 + nfetch = self.arraysize + cursor.arraysize = nfetch + + if self.debug: + logging.debug('') + logging.debug(f'nfetch= {nfetch:d}') + + + i = 0 + for col in cursor.description: + + # + # { For loop for cursor description: + # cursor description is a list with ncols tuples: + # each tuple # contains each output colname's name, datatype, + # dbdatatype, size, etc... + # + + if self.debug: + logging.debug('') + logging.debug('----------------------------------------------') + logging.debug(f'i = {i:d} col = ' + str(col)) + + # + # Extract colname, dbdatatype, size, precision, scale from + # col array + # + + colname = str(col[0]).lower() + + if self.debug: + logging.debug('') + logging.debug(f'colname(lower) = {colname:s}') + + # + # { Extract dbdatatype, display_size, precision, scale from + # col_array: + # col[1], col[2], col[4], and col[5] + # + + dbdatatype = '' + size = None + precision = None + scale = None + + dbdatatypestr = str(col[1]) + + if self.debug: + logging.debug('') + logging.debug(f'dbdatatypestr = {dbdatatypestr:s}') + + ind = dbdatatypestr.find("VARCHAR") + if(ind != -1): + dbdatatype = 'VARCHAR' + + ind = dbdatatypestr.find("STRING") + if(ind != -1): + dbdatatype = 'STRING' + + size = None + if(col[2] is not None): + + size = col[2] + if(len(colname) > size): + size = len(colname) + + if self.debug: + logging.debug(f'size = {size:d}') + + # + # } end extract dbdatatype, display_size, precision, + # scale from + # col_array: col[1], col[2], col[4], and col[5] + + + dbtype = '' + datatype = '' + fmt = '' + width = 0 + + if(dbdatatype == 'STRING') or (dbdatatype == 'VARCHAR'): + + # + # { dbdatatype == string + # + + width = 0 + if(size is not None): + width = size + + if(len(colname) > width): + width = len(colname) + + datatype = 'char' + dbtype = dbdatatype + fmt = str(width) + 's' + + # + # } end dbdatatype == STRING + # + + else: + # + # { unknown type: default to char + # + + datatype = 'char' + dbtype = dbdatatype + + width = 200 + if(len(colname) > width): + width = len(colname) + fmt = str(width) + 's' + + # + # } end unknown type + # + + if self.debug: + logging.debug('') + logging.debug(f' width = {width:d}') + logging.debug(f' dbtype = {dbtype:s}') + logging.debug(f' datatype = {datatype:s}') + logging.debug(f' fmt = {fmt:s}') + + i = i + 1 + + # + # } end of for loop for analysing cursor description + # + + if self.debug: + logging.debug('') + logging.debug('Start fetching data') + logging.debug('----------------------------------------') + + # + # Start fetching data lines + # + + nfetch = self.arraysize + + cursor.arraysize = nfetch + + irow = 0 + ntot = 0 + + while True: + + # + # { start of while loop for fetching data lines; + # max 10000 lines at a time + # + + rows = cursor.fetchmany() + + nrec = len(rows) + + if self.debug: + logging.debug(f'nrec = {nrec:d}') + logging.debug('') + + + for ll in range(0, nrec): + + # + # { Beginning ll loop: one row + # + + row = rows[ll] + + if self.debug: + logging.debug(row) + logging.debug(f'row[0]= {row[0]:s}') + logging.debug(f'row[1]= {row[1]:s}') + + + self.schema_namearr.append(row[0]) + self.schema_descarr.append(row[1]) + + if self.debug: + logging.debug(row) + logging.debug(self.schema_namearr) + logging.debug(self.schema_descarr) + + irow = irow + 1 + + # + # } end of ll loop + # + + if self.debug: + logging.debug('----------------------------------------') + logging.debug(f'irow= {irow:d}') + + ntot = ntot + nrec + + if(len(rows) < cursor.arraysize): + break + + # + # } end while loop for fetching data lines + # + + return + + # + # } end vosiTables.getSchemaQueryArr + # + + + def __writeOneTableResult__ (self, cursor, fp, **kwargs): + + # + # {vosiTables.writeOneTableResult + # + if self.debug: + logging.debug('') + logging.debug('Enter __writeOneTableResult') + + cursor.arraysize = self.arraysize + + dbtype = [] + + i = 0 + for col in cursor.description: + + # + # { For loop for cursor description: + # cursor description is a list with ncols tuples: + # each tuple + # contains each output colname's name, datatype, + # dbdatatype, size, etc... + # + + if self.debug: + logging.debug('') + logging.debug(f'i = {i:d} col = ' + str(col)) + + # + # Extract colname, dbdatatype, size, precision, scale from + # col array + # + + colname = str(col[0]).lower() + + if self.debug: + logging.debug('') + logging.debug(f'colname(lower) = {colname:s}') + + # + # { Extract dbdatatype, display_size, precision, scale from + # col_array: + # col[1], col[2], col[4], and col[5] + # + + dbdatatype = '' + size = None + + dbdatatypestr = str(col[1]) + + if self.debug: + logging.debug('') + logging.debug(f'dbdatatypestr = {dbdatatypestr:s}') + + ind = dbdatatypestr.find("VARCHAR") + if(ind != -1): + dbdatatype = 'STRING' + + ind = dbdatatypestr.find("STRING") + if(ind != -1): + dbdatatype = 'STRING' + + if self.debug: + logging.debug(f'dbdatatype = {dbdatatype:s}') + + dbtype.append(dbdatatype) + # + # } end extract dbdatatype from col_array: col[1] + # + + i = i + 1 + + # + # } end of for loop for analysing dd's cursor description + # + + # + # Start fetching data lines + # + + nfetch = self.arraysize + cursor.arraysize = nfetch + + irow = 0 + ntot = 0 + + while True: + + # + # { start of while loop for fetching data lines; + # max 10000 lines at a time + # + + rows = cursor.fetchmany() + nrec = len(rows) + + if self.debug: + logging.debug(f'nrec = {nrec:d}') + logging.debug('') + + for ll in range(0, nrec): + + + # + # { Beginning ll loop: one row + # + + row = rows[ll] + + col_name = '' + col_desc = '' + col_unit = '' + col_ucd = '' + col_datatype = '' + col_principal = '' + col_indexed = '' + + data = '' + + for i in range(0, len(row)): + + if (row[i] is None): + data = 'None' + elif (dbtype[i] == 'STRING'): + data = row[i] + else: + data = str(row[i]) + + if self.debug: + logging.debug('') + logging.debug(f'i= {i:d}, data= {data:s}') + + if (i == 0): + col_name = data + elif (i == 1): + col_desc = data + elif (i == 2): + col_unit = data + elif (i == 3): + col_ucd = data + elif (i == 4): + col_datatype = data + elif (i == 5): + col_principal = data + elif (i == 6): + col_indexed = data + + irow = irow + 1 + + if col_datatype == 'timestamp': + col_datatype = 'char' + + + # + # for row, write column block + # + fp.write(" \n") + fp.write(" " + col_name + "\n") + fp.write(" " + \ + "\n") + + if (col_unit != 'None'): + fp.write(" " + col_unit + "\n") + + if (col_ucd != 'None'): + fp.write (" " + col_ucd + "\n") + + fp.write (" " + \ + "" + \ + col_datatype + "\n") + + if (col_principal != 'None' and col_principal != 0 \ + and col_principal != '0'): + fp.write(" " + \ + "principal\n") + + if (col_indexed != 'None' and col_indexed != 0 \ + and col_indexed != '0'): + fp.write(" " + "indexed\n") + + fp.write(" \n") + + + # + # } end of ll loop + # + + ntot = ntot + nrec + + if(len(rows) < cursor.arraysize): + break + + if self.debug: + logging.debug('') + logging.debug(f'ntot= {ntot:d}') + + # + # } end of while loop for fetching data lines + # + + return + + # + # }end vosiTables.writeOneTableResult + # + + + def __writeForeignKey__ (self, cursor, fp, **kwargs): + + # + # {vosiTables.writeForeignKey + # + if self.debug: + logging.debug('') + logging.debug('Enter __writeForeignKey') + + cursor.arraysize = self.arraysize + + dbtype = [] + + i = 0 + for col in cursor.description: + + # + # { For loop for cursor description: + # cursor description is a list with ncols tuples: + # each tuple + # contains each output colname's name, datatype, + # dbdatatype, size, etc... + # + + if self.debug: + logging.debug('') + logging.debug(f'i = {i:d} col = ' + str(col)) + + # + # Extract colname, dbdatatype, size, precision, scale from + # col array + # + + colname = str(col[0]).lower() + + if self.debug: + logging.debug('') + logging.debug(f'colname(lower) = {colname:s}') + + # + # { Extract dbdatatype, display_size, precision, scale from + # col_array: + # col[1], col[2], col[4], and col[5] + # + + dbdatatype = '' + size = None + + dbdatatypestr = str(col[1]) + + if self.debug: + logging.debug('') + logging.debug(f'dbdatatypestr = {dbdatatypestr:s}') + + ind = dbdatatypestr.find("VARCHAR") + if(ind != -1): + dbdatatype = 'STRING' + + ind = dbdatatypestr.find("STRING") + if(ind != -1): + dbdatatype = 'STRING' + + if self.debug: + logging.debug(f'dbdatatype = {dbdatatype:s}') + + dbtype.append(dbdatatype) + # + # } end extract dbdatatype from col_array: col[1] + # + + i = i + 1 + + # + # } end of for loop for analysing dd's cursor description + # + + # + # Start fetching data lines + # + + nfetch = self.arraysize + cursor.arraysize = nfetch + + irow = 0 + ntot = 0 + + while True: + + # + # { start of while loop for fetching data lines; + # max 10000 lines at a time + # + + rows = cursor.fetchmany() + nrec = len(rows) + + if self.debug: + logging.debug(f'nrec = {nrec:d}') + logging.debug('') + + for ll in range(0, nrec): + + + # + # { Beginning ll loop: one row + # + + row = rows[ll] + + keyid = '' + targettbl = '' + fromcol = '' + targetcol= '' + desc = '' + + data = '' + for i in range(0, len(row)): + + if (row[i] is None): + data = 'None' + elif (dbtype[i] == 'STRING'): + data = row[i] + else: + data = str(row[i]) + + if self.debug: + logging.debug('') + logging.debug(f'i= {i:d}, data= {data:s}') + + if (i == 0): + keyid = data + elif (i == 1): + targettbl = data + elif (i == 2): + fromcol = data + elif (i == 3): + targetcol = data + elif (i == 4): + desc = data + + irow = irow + 1 + + # + # for row, write column block + # + fp.write(" \n") + fp.write(" " + targettbl + \ + "\n") + + fp.write(" \n") + fp.write(" " + fromcol + \ + "\n") + fp.write(" " + targetcol + \ + "\n") + fp.write(" \n") + + fp.write(" " + \ + desc + "\n") + fp.write(" \n") + + # + # } end of ll loop + # + + ntot = ntot + nrec + + if(len(rows) < cursor.arraysize): + break + + if self.debug: + logging.debug('') + logging.debug(f'ntot= {ntot:d}') + + # + # } end of while loop for fetching data lines + # + + return + + # + # }end vosiTables.writeOneTableResult + # + + + + + + + + + + + def __executeSql__(self, cursor, sql, **kwargs): + # + # { vosiTables.executeSql + # + + debug = 0 + + if('debug' in kwargs): + debug = kwargs['debug'] + + if self.debug: + logging.debug('') + logging.debug('Enter executeSql') + logging.debug(f'sql:= {sql:s}') + + try: + cursor.execute(sql) + except Exception as e: + + msg = str(e).replace('"', "'") + + if self.debug: + logging.debug('') + logging.debug(f'create table exception: {str(msg):s}') + + # raise Exception(str(e)) + raise Exception(msg) + + # + # } end vosiTables.executeSql + # + + + def __printVosiError__(self, errmsg): + + # + # { + # + + httphdr = "HTTP/1.1 500 ERROR\r" + + #print("HTTP/1.1 200 OK\r") + + print(httphdr) + + print("Content-type: text/xml\r") + print("\r") + + print('') + print('') + print('') + print('') + + print(errmsg) + + print('') + print('') + print('') + + sys.stdout.flush() + sys.exit() + + # + # } end of printError + # + + +# +# } end class vosiTables: +# + +def main(): + + print ('Enter main') + + sys.tracebacklimit = 0 + + parser = argparse.ArgumentParser(description='vosiTables') + + parser.add_argument('--dbms', required=True, + help='database (e.g. oracle, sqlite, etc..) to use.') + + parser.add_argument('--dbserver', required=False, + help='Oracle database server (dbserver) to use.') + + parser.add_argument('--userid', required=False, + help='Oracle dbserver userid (userid) to use.') + + parser.add_argument('--password', required=False, + help='Oracle dbserver (password) to use.') + + parser.add_argument('--db', required=False, + help='Sqlite database name to use.') + + parser.add_argument('--tap_schema', required=False, + help='Sqlite database table name to use.') + + parser.add_argument('--outpath', required=True, + help='outpath for the result.') + + parser.add_argument('--debug', required=False, + help='debugflag: 1/0.') + + args = parser.parse_args() + + dbms = '' + dbserver = '' + userid = '' + password = '' + outpath = '' + debug = 0 + + db = '' + tap_schema = '' + + if (args.dbms is not None): + dbms = args.dbms + print (f'dbms= {dbms:s}') + + if (args.dbserver is not None): + dbserver = args.dbserver + print (f'dbserver= {dbserver:s}') + + if (args.userid is not None): + userid = args.userid + print (f'userid= {userid:s}') + + + if (args.password is not None): + password = args.password + print (f'password= {password:s}') + + + if (args.outpath is not None): + outpath = args.outpath + print (f'outpath= {outpath:s}') + + + if (args.debug is not None): + debug = args.debug + print (f'debug= {debug:s}') + + if (args.db is not None): + db = args.db + print (f'db= {db:s}') + + if (args.tap_schema is not None): + tap_schema = args.tap_schema + print (f'tap_schema= {tap_schema:s}') + + + if debug: + debugfname = './vosi.debug' + + logging.basicConfig(filename=debugfname, \ + level=logging.DEBUG) + + print (f'debug turned on') + + logging.debug('') + logging.debug('debug turned on') + logging.debug(f'dbms= {dbms:s}') + logging.debug(f'dbserver= {dbserver:s}') + logging.debug(f'userid= {userid:s}') + logging.debug(f'password= {password:s}') + logging.debug(f'outpath= {outpath:s}') + logging.debug(f'debug= {debug:s}') + + + vositbl = None + try: + if debug: + logging.debug('') + logging.debug('call vosiTables') + + vositbl = vosiTables (dbms=dbms, \ + dbserver=dbserver, \ + userid=userid, \ + password=password, \ + outpath=outpath, \ + debug=debug) + + if debug: + logging.debug('') + logging.debug('vosiTables initialized') + + print('vosiTables initialized') + + except Exception as e: + + if debug: + logging.debug('') + logging.debug(f'vosiTable.init failed: {str(e):s}') + + print(f'vosiTable.init failed: {str(e):s}') + + +if __name__ == "__main__": + main() diff --git a/TAP/configparam.py b/TAP/configparam.py index 4478fe0..ca079c5 100644 --- a/TAP/configparam.py +++ b/TAP/configparam.py @@ -3,11 +3,14 @@ # https://github.com/Caltech-IPAC/nexsciTAP/blob/master/LICENSE -import os import logging -import configobj +import os import pprint +import configobj + +from TAP import compat + class configParam: @@ -31,7 +34,7 @@ class configParam: def __init__(self, path, **kwargs): - pp = pprint.PrettyPrinter(indent=3) + pprint.PrettyPrinter(indent=3) @@ -70,12 +73,13 @@ def __init__(self, path, **kwargs): raise Exception(self.msg) confobj = configobj.ConfigObj(self.configpath) + confobj, self.compat = compat.load_config(confobj) if self.debug: logging.debug('') logging.debug('ConfigObj instantiated successfully') - + ### Web server config parameters ############ self.web = 'WEB' @@ -115,7 +119,7 @@ def __init__(self, path, **kwargs): # HTTP_URL (includingt HTTP_PORT) - self.httpurl = None + self.httpurl = None if ('HTTP_URL' in confobj[self.web]): self.httpurl = confobj[self.web]['HTTP_URL'] @@ -124,7 +128,7 @@ def __init__(self, path, **kwargs): self.msg = 'Failed to find HTTP_URL in config_file' raise Exception(self.msg) - self.port = None + self.port = None if('HTTP_PORT' in confobj[self.web]): self.port = confobj[self.web]['HTTP_PORT'] @@ -134,23 +138,23 @@ def __init__(self, path, **kwargs): # CGIPGM - self.cgipgm = None + self.cgipgm = None if ('CGI_PGM' in confobj[self.web]): self.cgipgm = confobj[self.web]['CGI_PGM'] # ARRAYSIZE - self.arraysize = 10000 + self.arraysize = 10000 if ('ArraySize' in confobj[self.web]): self.arraysize = confobj[self.web]['ArraySize'] # INFOMSG - self.infomsg = '' - if('INFOMSG' in confobj[self.web]): - self.infomsg = confobj[self.web]['INFOMSG'] + self.infomsg = '' + if('INFOMSG' in confobj[self.web]): + self.infomsg = confobj[self.web]['INFOMSG'] if self.debug: logging.debug('') @@ -160,10 +164,10 @@ def __init__(self, path, **kwargs): logging.debug('arraysize = %s', self.infomsg) - + ### Configuration ########################### - # If there was an input configuration 'instance', read 'db_connection' + # If there was an input configuration 'instance', read 'db_connection' # and 'sptind_config' section names from there. Otherwise use the # following defaults: @@ -181,7 +185,7 @@ def __init__(self, path, **kwargs): self.status = 'error' self.msg = 'Failed to find DB_CONNECTION in config_file' raise Exception(self.msg) - + self.sptind_config = None if('SPTIND_CONFIG' in confobj[self.instance]): @@ -217,9 +221,9 @@ def __init__(self, path, **kwargs): self.socket = None self.dbschema = None - self.cookiename = '' - self.accesstbl = '' - self.usertbl = '' + self.cookiename = '' + self.accesstbl = '' + self.usertbl = '' self.propfilter = '' self.fileid = '' self.accessid = '' @@ -242,7 +246,7 @@ def __init__(self, path, **kwargs): if self.debug: logging.debug('') logging.debug('dbms = %s', self.dbms) - + # ORACLE Connection @@ -304,15 +308,15 @@ def __init__(self, path, **kwargs): # KOA Proprietary Access parameters - self.cookiename = '' + self.cookiename = '' if('COOKIENAME' in confobj[self.db_connection]): self.cookiename = confobj[self.db_connection]['COOKIENAME'] - self.accesstbl = '' + self.accesstbl = '' if('ACCESS_TBL' in confobj[self.db_connection]): self.accesstbl = confobj[self.db_connection]['ACCESS_TBL'] - self.usertbl = '' + self.usertbl = '' if('USERS_TBL' in confobj[self.db_connection]): self.usertbl = confobj[self.db_connection]['USERS_TBL'] @@ -423,7 +427,7 @@ def __init__(self, path, **kwargs): self.msg = 'Failed to MySQL DBMS user ID in config_file' raise Exception(self.msg) - + # PASSWORD if ('Password' in confobj[self.db_connection]): @@ -485,7 +489,7 @@ def __init__(self, path, **kwargs): # DATABASE - + if 'DataBase' in confobj[self.db_connection]: self.database = confobj[self.db_connection]['DataBase'] @@ -506,7 +510,7 @@ def __init__(self, path, **kwargs): self.msg = 'Failed to find PostgreSQL username in config_file.' raise Exception(self.msg) - + # PASSWORD if 'Password' in confobj[self.db_connection]: @@ -640,6 +644,7 @@ def __init__(self, path, **kwargs): self.connectInfo['columns_table'] = self.columns_table self.connectInfo['keys_table'] = self.keys_table self.connectInfo['key_columns_table'] = self.key_columns_table + self.connectInfo['compat'] = self.compat self.connectInfo['port'] = self.dbport self.connectInfo['socket'] = self.socket self.connectInfo['dbschema'] = self.dbschema @@ -679,5 +684,5 @@ def __init__(self, path, **kwargs): logging.debug(' accessid = ' + str(self.accessid)) logging.debug(' racol = ' + str(self.racol)) logging.debug(' deccol = ' + str(self.deccol)) - - return + + return diff --git a/TAP/datadictionary.py b/TAP/datadictionary.py index 3f556d7..a11cc2d 100644 --- a/TAP/datadictionary.py +++ b/TAP/datadictionary.py @@ -3,9 +3,10 @@ # https://github.com/Caltech-IPAC/nexsciTAP/blob/master/LICENSE -import os import logging +import os import re + from astropy.io import ascii @@ -92,8 +93,8 @@ def __init__(self, conn, table, connectInfo, **kwargs): if self.debug: logging.debug(f'dbtable = {self.dbtable:s}') - logging.debug(f'ddtbl = ' + str(self.ddtbl)) - logging.debug(f'ddfile = ' + str(self.ddfile)) + logging.debug('ddtbl = ' + str(self.ddtbl)) + logging.debug('ddfile = ' + str(self.ddfile)) # @@ -111,20 +112,20 @@ def __init__(self, conn, table, connectInfo, **kwargs): if self.debug: logging.debug('colnames: ' + str(tcolnames)) - i = 0 + i = 0 for trow in tdata: - + dbname = '' desc = '' if 'name' in tcolnames: dbname = trow['name'].lower() desc = dbname - + type = '' if 'intype' in tcolnames: type = trow['intype'] - + unit = '' if 'units' in tcolnames: unit = trow['units'] @@ -147,7 +148,7 @@ def __init__(self, conn, table, connectInfo, **kwargs): if len(dbname) > width: width = len(dbname) - + self.colname[i] = dbname i = i+1 @@ -182,7 +183,14 @@ def __init__(self, conn, table, connectInfo, **kwargs): else: placeholder = '?' - sql = "select * from " + self.connectInfo["tap_schema"] + "." + self.connectInfo["columns_table"] + " where lower(table_name) = " \ + # For SQLite the schema is ATTACHed under a name held in + # `tap_schema_file`; `tap_schema` is the file path. For + # Oracle/Postgres/MySQL `tap_schema` is the schema name itself. + schema_name = (self.connectInfo.get("tap_schema_file") + if self.connectInfo.get("dbms") == "sqlite3" else None) \ + or self.connectInfo["tap_schema"] + + sql = "select * from " + schema_name + "." + self.connectInfo["columns_table"] + " where lower(table_name) = " \ + placeholder if self.debug: @@ -302,7 +310,7 @@ def __init__(self, conn, table, connectInfo, **kwargs): # # { while loop # - + #rows = cursor.fetchmany(self.nfetch) rows = cursor.fetchall() @@ -314,7 +322,7 @@ def __init__(self, conn, table, connectInfo, **kwargs): i = 0 for row in rows: - + # # { for loop: each row in the file represents # a column in data dictionary diff --git a/TAP/propfilter.py b/TAP/propfilter.py index f19cd64..03562ce 100644 --- a/TAP/propfilter.py +++ b/TAP/propfilter.py @@ -3,17 +3,15 @@ # https://github.com/Caltech-IPAC/nexsciTAP/blob/master/LICENSE -import os import logging +import os import re - -import datetime import time -from TAP.writeresult import writeResult from TAP.datadictionary import dataDictionary from TAP.tablenames import TableNames -from TAP.tablevalidator import TableValidator, TableValidationError +from TAP.tablevalidator import TableValidationError, TableValidator +from TAP.writeresult import writeResult def _filterable(query, wherestr): @@ -419,7 +417,7 @@ def __init__(self, **kwargs): try: self.maxrec = int(maxrecstr) - except Exception as e: + except Exception: self.msg = "Failed to convert input maxrec value [" + \ maxrecstr + "] to integer." @@ -467,7 +465,7 @@ def __init__(self, **kwargs): logging.debug('Connected to Oracle, database ' + self.dbserver) - except Exception as e: + except Exception: self.status = 'error' self.msg = 'Failed to connect to cx_Oracle' @@ -526,7 +524,7 @@ def __init__(self, **kwargs): logging.debug('') logging.debug('TAP_SCHEMA attached') - except Exception as e: + except Exception: self.status = 'error' self.msg = 'Failed to connect to SQLite3 databases' @@ -780,8 +778,8 @@ def __init__(self, **kwargs): " where " + self.fileid + " in(select " + self.fileid_allowed + \ " from " + self.tmp_fileidAlloweddbtbl + ")" - if(len(self.wherestr) > 0): - sql = sql + ' and (' + self.wherestr[6:] + ')' + if(len(self.wherestr) > 0): + sql = sql + ' and (' + self.wherestr[6:] + ')' if self.debug: logging.debug('') @@ -877,7 +875,7 @@ def __init__(self, **kwargs): coldesc=self.coldesc, racol=self.racol, deccol=self.deccol) - + #deccol=self.deccol, #debug=self.debug) @@ -894,7 +892,7 @@ def __init__(self, **kwargs): if self.debug: logging.debug('') - logging.debug(f'writeResult cursor closed') + logging.debug('writeResult cursor closed') self.outpath = wresult.outpath @@ -902,14 +900,14 @@ def __init__(self, **kwargs): # # Drop all tmp DB tables for oracle because oracle v.12.xxx's - # global temporary table is permanent, not really temporary + # global temporary table is permanent, not really temporary # if (self.dbms.lower() == 'oracle'): - + try: self.conn.close() - + if self.debug: logging.debug ('') logging.debug ('returned from oracle conn.close()') @@ -919,12 +917,12 @@ def __init__(self, **kwargs): if self.debug: logging.debug ('') logging.debug (f'conn.close exception: {str(e):s}') - + # # re-connect # - time.sleep (2.0) - + time.sleep (2.0) + userid = self.connectInfo['userid'] dbserver = self.connectInfo['dbserver'] password = self.connectInfo['password'] @@ -933,10 +931,10 @@ def __init__(self, **kwargs): if self.debug: logging.debug ('') - logging.debug (f'xxx0') + logging.debug ('xxx0') logging.debug (f'userid= {userid:s}') logging.debug (f'dbserver= {dbserver:s}') - + try: self.conn = cx_Oracle.connect ( \ userid, \ @@ -960,25 +958,25 @@ def __init__(self, **kwargs): logging.debug('') logging.debug('Failed to re-connected to Oracle ') logging.debug(f'e= {str(e):s}') - - pass - + + pass + try: self.__dropDbtbl__(self.tmp_fileidAlloweddbtbl) - + if self.debug: logging.debug('') logging.debug( \ 'returned dropDbtbl: tmp_fileidAlloweddbtbl') except Exception as e: - + if self.debug: logging.debug('') logging.debug( \ f'drop fileidAlloweddbtbl exception: {str(e):s}') pass - + if self.debug: logging.debug('') logging.debug('tmp_fileidAlloweddbtbl dropped') @@ -992,14 +990,14 @@ def __init__(self, **kwargs): try: self.__dropDbtbl__(self.tmp_accessiddbtbl) - + if self.debug: logging.debug('') logging.debug( \ 'returned dropDbtbl: tmp_accessiddbtbl') except Exception as e: - + if self.debug: logging.debug('') logging.debug( \ @@ -1325,7 +1323,7 @@ def __validateUser__(self, cookiename, cookiestr, propfilter, # if self.debug: - logging.debug(f'cookiename = ' + str(cookiename)) + logging.debug('cookiename = ' + str(cookiename)) msg = '' ind = cookiestr.find(cookiename) @@ -1354,14 +1352,14 @@ def __validateUser__(self, cookiename, cookiestr, propfilter, return arr = substr1.split('|') - narr = len(arr) + len(arr) self.userid = arr[0] self.encodedpass = arr[1] if self.debug: logging.debug('') - logging.debug(f'userid = ' + str(self.userid)) + logging.debug('userid = ' + str(self.userid)) if(self.userid == 'anon'): self.userid = '' @@ -1423,7 +1421,7 @@ def __validateUser__(self, cookiename, cookiestr, propfilter, if self.debug: logging.debug('') - logging.debug(f'password = [Not shown for security reasons.]') + logging.debug('password = [Not shown for security reasons.]') if(len(password) == 0): @@ -1606,7 +1604,7 @@ def __createTmpAccessiddb__(self, tmp_accessiddbtbl, userid, accessid, # { createTmpAccessiddb # # - # first drop tmp_accessiddbtbl in case table with the same name + # first drop tmp_accessiddbtbl in case table with the same name # might already exist # @@ -1618,8 +1616,8 @@ def __createTmpAccessiddb__(self, tmp_accessiddbtbl, userid, accessid, if self.debug: logging.debug('') logging.debug('returned dropDbtbl') - logging.debug(f'temp dbtbl dropped') - + logging.debug('temp dbtbl dropped') + except Exception as e: self.msg = 'Failed to create tmp_accessiddbtbl: ' + str(e) @@ -1661,7 +1659,7 @@ def __createTmpAccessiddb__(self, tmp_accessiddbtbl, userid, accessid, logging.debug('') logging.debug('tmp_accessiddbtbl created') - + # Insert into tmp_accessiddbtbl: select accessid allowed # by userid: accessidtbl @@ -1687,8 +1685,8 @@ def __createTmpAccessiddb__(self, tmp_accessiddbtbl, userid, accessid, raise Exception(self.msg) - # check rowcount in tmp_accessiddbtbl - + # check rowcount in tmp_accessiddbtbl + sql = "select * from " + tmp_accessiddbtbl if self.debug: @@ -1732,10 +1730,10 @@ def __createTmpFileiddb__(self, tmp_fileiddbtbl, fileid, fileid_allowed, if self.debug: logging.debug('') - logging.debug(f'Enter createTmpFileiddb') + logging.debug('Enter createTmpFileiddb') if (self.dbms.lower() == 'pgsql'): - + sql = "create temporary table " + tmp_fileiddbtbl + \ "(" + fileid_allowed + " varchar(35)) on commit preserve rows" else: @@ -1770,21 +1768,21 @@ def __createTmpFileiddb__(self, tmp_fileiddbtbl, fileid, fileid_allowed, if(self.propfilter == 'koa'): if(len(self.userid) > 0): - + if (self.dbms.lower() == 'oracle'): access_constraint = \ "((current_date > add_months(date_obs, propint))" + \ " or(lower(" + accessid + ") in(select " + \ accessid + " from " + tmp_accessiddbtbl + ")))" - + elif (self.dbms.lower() == 'pgsql'): access_constraint = "((current_date > " + \ "(date_obs + (propint * '1 month'::interval)))" + \ " or (lower(" + accessid + ") in (select " + \ accessid + " from " + tmp_accessiddbtbl + ")))" - + else: if (self.dbms.lower() == 'oracle'): @@ -1796,7 +1794,7 @@ def __createTmpFileiddb__(self, tmp_fileiddbtbl, fileid, fileid_allowed, access_constraint = "(current_date > " + \ "(date_obs + (propint * '1 month'::interval)))" - + if self.debug: logging.debug('') logging.debug( @@ -1819,20 +1817,20 @@ def __createTmpFileiddb__(self, tmp_fileiddbtbl, fileid, fileid_allowed, "add_months(obsdate, l0propint))" + \ " or(lower(" + accessid + ") in(select " + \ accessid + " from " + tmp_accessiddbtbl + ")))" - + elif (self.dbms.lower() == 'pgsql'): access_constraint = "((current_date > " + \ "(obsdate + (l0propint * '1 month'::interval)))" + \ " or (lower(" + accessid + ") in (select " + \ accessid + " from " + tmp_accessiddbtbl + ")))" - + else: if (self.dbms.lower() == 'oracle'): access_constraint = \ "(current_date > add_months(obsdate, l0propint))" - + elif (self.dbms.lower() == 'pgsql'): access_constraint = "(current_date > " + \ @@ -1849,20 +1847,20 @@ def __createTmpFileiddb__(self, tmp_fileiddbtbl, fileid, fileid_allowed, "add_months(obsdate, l1propint))" + \ " or(lower(" + accessid + ") in(select " + \ accessid + " from " + tmp_accessiddbtbl + ")))" - + elif (self.dbms.lower() == 'pgsql'): access_constraint = "((current_date > " + \ "(obsdate + (l1propint * '1 month'::interval)))" + \ " or (lower(" + accessid + ") in (select " + \ accessid + " from " + tmp_accessiddbtbl + ")))" - + else: if (self.dbms.lower() == 'oracle'): access_constraint = \ "(current_date > add_months(obsdate, l1propint))" - + elif (self.dbms.lower() == 'pgsql'): access_constraint = "(current_date > " + \ @@ -1878,20 +1876,20 @@ def __createTmpFileiddb__(self, tmp_fileiddbtbl, fileid, fileid_allowed, "add_months(obsdate, l2propint))" + \ " or(lower(" + accessid + ") in(select " + \ accessid + " from " + tmp_accessiddbtbl + ")))" - + elif (self.dbms.lower() == 'pgsql'): access_constraint = "((current_date > " + \ "(obsdate + (l2propint * '1 month'::interval)))" + \ " or (lower(" + accessid + ") in (select " + \ accessid + " from " + tmp_accessiddbtbl + ")))" - + else: if (self.dbms.lower() == 'oracle'): access_constraint = \ "(current_date > add_months(obsdate, l2propint))" - + elif (self.dbms.lower() == 'pgsql'): access_constraint = "(current_date > " + \ @@ -1934,7 +1932,7 @@ def __createTmpFileiddb__(self, tmp_fileiddbtbl, fileid, fileid_allowed, logging.debug(f'{self.msg:s}') raise Exception(self.msg) - + rowcnt_conditional = cursor.rowcount if self.debug: logging.debug('') @@ -2144,13 +2142,13 @@ def __dropDbtbl__(self, dbtable, **kwargs): logging.debug(f'table {dbtable:s} successfully dropped') except Exception as e: - + if self.debug: logging.debug('') logging.debug(f'drop table exception: {str(e):s}') - + pass - + return # diff --git a/TAP/tablenames.py b/TAP/tablenames.py index a3f230d..1ea9106 100644 --- a/TAP/tablenames.py +++ b/TAP/tablenames.py @@ -4,10 +4,10 @@ import itertools -import sqlparse -from sqlparse.sql import IdentifierList, Identifier -from sqlparse.tokens import Keyword, DML +import sqlparse +from sqlparse.sql import Identifier, IdentifierList +from sqlparse.tokens import DML, Keyword class TableNames: diff --git a/TAP/tablevalidator.py b/TAP/tablevalidator.py index e2d25ca..fa80940 100644 --- a/TAP/tablevalidator.py +++ b/TAP/tablevalidator.py @@ -32,7 +32,16 @@ def __init__(self, conn, connectInfo=None, debug=0): self.tables_table = 'tables' if connectInfo is not None: - if 'tap_schema' in connectInfo: + # `tap_schema_file` is the ATTACH-AS name for SQLite ATTACHed + # schemas (set in tapquery.py:395). For Oracle/Postgres/MySQL + # there's no ATTACH and `tap_schema` is the schema name itself. + # For SQLite, `tap_schema` is the FILE PATH and using it directly + # in a `SELECT ... FROM .tables` reference produces a SQL + # syntax error. So `tap_schema_file` is used only for SQLite + # (dbms == 'sqlite3'); other databases use `tap_schema`. + if connectInfo.get('dbms') == 'sqlite3' and 'tap_schema_file' in connectInfo: + self.tap_schema = connectInfo['tap_schema_file'] + elif 'tap_schema' in connectInfo: self.tap_schema = connectInfo['tap_schema'] if 'tables_table' in connectInfo: self.tables_table = connectInfo['tables_table'] diff --git a/TAP/tap.py b/TAP/tap.py index a2bb510..b0e4127 100755 --- a/TAP/tap.py +++ b/TAP/tap.py @@ -2,34 +2,31 @@ # This code is released with a BSD 3-clause license. License information is at # https://github.com/Caltech-IPAC/nexsciTAP/blob/master/LICENSE -import os -import sys +import cgi +import datetime import fcntl import html - import logging - -import datetime -import time +import math +import os +import re +import shutil import signal -import math - -import cgi +import sys import tempfile +import time import xmltodict -from bs4 import BeautifulSoup - from ADQL.adql import ADQL - +from bs4 import BeautifulSoup from spatial_index import SpatialIndex -from TAP.tapquery import tapQuery +from TAP import compat from TAP.configparam import configParam from TAP.propfilter import _require_filterable, propFilter from TAP.tablenames import TableNames -from TAP.vositables import vosiTables from TAP.tablevalidator import TableValidationError, TableValidator +from TAP.tapquery import tapQuery class Tap: @@ -148,6 +145,7 @@ class Tap: uwsheader = '' + compat = frozenset() # active compat names; set once TAP.conf loads def __init__(self, **kwargs): @@ -174,6 +172,13 @@ def __run__(self, **kwargs): # { tap.init() # + # A request with no form fields (an HTTP DELETE) has no list, and + # FieldStorage raises 'not indexable' on any lookup into it. + self.fields = self.form.keys() if self.form.list is not None else [] + self.request_method = os.environ.get('REQUEST_METHOD', '').upper() + self.action = '' + + # Only the server environment may turn on request debugging. self.debug = 1 if os.environ.get('TAP_DEBUG') == '1' else 0 if(self.debug): @@ -217,7 +222,7 @@ def __run__(self, **kwargs): if self.debug: logging.debug('') - logging.debug('nexsciTAP version 3.0.2\n\n') + logging.debug('nexsciTAP version 3.1.0\n\n') logging.debug('HTTP request keywords:\n') self.lang = 'ADQL' @@ -230,8 +235,8 @@ def __run__(self, **kwargs): self.uwsheader = '' - - for key in self.form: + + for key in self.fields: if self.debug: logging.debug(f' key: {key:<15}') @@ -244,7 +249,7 @@ def __run__(self, **kwargs): if(key.lower() == 'lang'): self.lang = self.form[key].value self.param['lang'] = self.form[key].value - + if(key.lower() == 'request'): self.param['request'] = self.form[key].value @@ -271,6 +276,9 @@ def __run__(self, **kwargs): if(key.lower() == 'token'): self.token = self.form[key].value + if(key.lower() == 'action'): + self.action = self.form[key].value.strip().upper() + self.nparam = len(self.param) if 'format' in self.param: @@ -305,7 +313,7 @@ def __run__(self, **kwargs): self.maxrec = int(maxrec_dbl) - except Exception as e: + except Exception: self.msg = "Failed to convert input maxrec value [" + \ self.param['maxrec'] + "] to integer." @@ -314,7 +322,7 @@ def __run__(self, **kwargs): if (len(self.lang) == 0): self.lang = 'ADQL' - self.param['lang'] = 'ADQL' + self.param['lang'] = 'ADQL' self.nparam = len(self.param) @@ -354,11 +362,11 @@ def __run__(self, **kwargs): self.configext = arr[0] arr = arr[1:] narr = len(arr) - + if self.debug: logging.debug('') logging.debug(f'id= {self.id:s} configext= {self.configext:s}') - + if(arr[0] == 'async'): self.tapcontext = 'async' @@ -370,7 +378,7 @@ def __run__(self, **kwargs): self.tapcontext = 'capabilities' elif (arr[0] == 'tables'): self.tapcontext = 'tables' - + if (len(self.tapcontext) == 0): self.msg = 'PATH_INFO: sync or async not found.' self.__printError__('votable', self.msg, errcode='404') @@ -382,17 +390,17 @@ def __run__(self, **kwargs): if(narr > 1 and len(arr[1]) > 0): # - #{ narr > 1: + #{ narr > 1: # input query contains more than TAP/(sync/async)/statuskey for - # retrive status.xml, we set getstatus=1; - # + # retrive status.xml, we set getstatus=1; + # self.getstatus = 1 self.id = arr[1] if self.debug: logging.debug('') logging.debug(f'id= {self.id:s} getstatus= {self.getstatus:d}') - + if (len(self.id) == 0): self.msg = 'Failed to find jobid for getStatus request.' self.__printError__('votable', self.msg, errcode='404') @@ -400,7 +408,7 @@ def __run__(self, **kwargs): len_id = len(self.id) ind = self.pathinfo.find(self.id) - + i = ind + len_id + 1 self.statuskey = self.pathinfo[i:] @@ -423,16 +431,16 @@ def __run__(self, **kwargs): logging.debug(f'setstatus = {self.setstatus:d}') logging.debug(f'id = {self.id:s}') # - #} end parsing pathinfo for narr > 1: + #} end parsing pathinfo for narr > 1: # input query contains more than TAP/(sync/async)/statuskey for - # retrive status.xml, we set getstatus=1; - # + # retrive status.xml, we set getstatus=1; + # self.cookiestr = os.getenv('HTTP_COOKIE', default='') if (len(self.cookiestr) == 0): - + self.cookiestr = self.token # @@ -464,10 +472,12 @@ def __run__(self, **kwargs): self.config = None try: self.config = configParam(self.configpath, instance=self.instance, debug=self.debug) - + self.compat = self.config.compat + compat.warn_once_per_day(self.compat, self.config.workdir) + if self.debug: logging.debug('') - logging.debug(f'returned configParam:') + logging.debug('returned configParam:') logging.debug('%s', self.config) except Exception as e: @@ -475,7 +485,7 @@ def __run__(self, **kwargs): if self.debug: logging.debug('') logging.debug(f'config exception: {str(e):s}') - + self.msg = 'Internal system error: config variables read error.' self.__printError__('votable', str(e), errcode='500') @@ -489,7 +499,7 @@ def __run__(self, **kwargs): logging.debug('') logging.debug(f'workdir = {self.workdir:s}') - + self.arraysize = self.config.arraysize self.cookiename = self.config.cookiename @@ -517,27 +527,6 @@ def __run__(self, **kwargs): - # Special case: HTTP DELETE. We only need - # the REQUEST_METHOD and PATH_INFO environment - # variable. The first must be DELETE and the - # second is the directory of th TAP job we wish - # to delete. - # - - # if("REQUEST_METHOD" in os.environ): - # self.request_method = os.environ["REQUEST_METHOD"] - # else: - # self.request_method = '' - # - # if self.request_method == 'DELETE': - # - # delete_dir = self.workdir + self.pathinfo - # - # if self.debug: - # logging.debug('') - # logging.debug(f'DELETE: {delete_dir:s}') - - # # Initialize statdict dict # @@ -559,11 +548,35 @@ def __run__(self, **kwargs): self.statdict['destruction'] = '' self.statdict['endtime'] = '' self.statdict['duration'] = 0 - + self.statdict['stime'] = None self.statdict['resulturl'] = '' + # + #{ availability and capabilities are static VOSI documents: they need + # no workspace, so answer them before workspace resolution. Going + # through it treated the empty jobid as a workspace name and failed + # with a 500 whenever /TAP did not already exist. + # + if (self.tapcontext == 'availability'): + self.__printVosiAvailability__ () + + if (self.tapcontext == 'capabilities'): + self.__printVosiCapability__ () + # + #} end VOSI static endpoints + # + + # + # GET on the job list must not create a job: clients follow the + # redirect that answers a job deletion with a GET here. + # + if ((self.tapcontext == 'async') and (self.getstatus == 0) and + (self.querykey == 0) and (len(self.param['phase']) == 0) and + (self.request_method != 'POST')): + self.__printJobList__() + # # sync or async without input workspace id: make workspace, # otherwise retrieve workspace from getstatus id @@ -577,7 +590,7 @@ def __run__(self, **kwargs): # # { Make workspace: - # make TAP subdir if it doesn't exist, + # make TAP subdir if it doesn't exist, # make a workspace name with unique id # @@ -628,10 +641,19 @@ def __run__(self, **kwargs): else: # - # { input jobid exists: + # { input jobid exists: # Retrieve workspace from id # + # + # A job id names a directory that tempfile.mkdtemp made under + # /TAP; anything else ('..', '.') would reach outside + # it, and a deletion removes what the id names. + # + if not re.fullmatch(r'tap_[A-Za-z0-9_]+', self.id): + self.__printError__('votable', 'Job not found: ' + self.id, + errcode='404') + self.workspace = self.id self.userWorkdir = self.workdir + '/TAP/' + self.workspace @@ -639,11 +661,18 @@ def __run__(self, **kwargs): # # check if workspace exists # - isExist = os.path.exists(self.userWorkdir) + isExist = os.path.isdir(self.userWorkdir) if (isExist == 0): - self.msg = 'work directory based on input jobid does not exist.' - self.__printError__('votable', self.msg, errcode='500') + self.__printError__('votable', 'Job not found: ' + self.id, + errcode='404') + + if ((self.tapcontext == 'async') and + (self.statuskey.strip('/') == '') and + ((self.request_method == 'DELETE') or + ((self.request_method == 'POST') and + (self.action == 'DELETE')))): + self.__deleteJob__() # # } end of retrieve workspace @@ -654,16 +683,6 @@ def __run__(self, **kwargs): logging.debug(f'workspace = {self.workspace:s}') logging.debug(f'userWorkdir = {self.userWorkdir:s}') - # - #{ if tapcontext is one of vosiEnpoint, take care of take care of VOSI - # output and return - # - if (self.tapcontext == 'availability'): - self.__printVosiAvailability__ () - - if (self.tapcontext == 'capabilities'): - self.__printVosiCapability__ () - # # vositable: make up vositbl filepath # @@ -675,11 +694,11 @@ def __run__(self, **kwargs): logging.debug(f'vosipath = {self.vosipath:s}') if (self.tapcontext == 'tables'): - + if self.debug: - + logging.debug('') - logging.debug(f'call printVosiTables:') + logging.debug('call printVosiTables:') logging.debug(f'statuspath = {self.statuspath:s}') self.__printVosiTables__ (self.config.connectInfo, \ @@ -691,14 +710,15 @@ def __run__(self, **kwargs): # #} end vosiEnpoint outputs # - + # # Make up status file name # self.statustbl = 'status.xml' self.statuspath = self.userWorkdir + '/' + self.statustbl - self.statusurl = self.httpurl + '/' + self.cgipgm + \ + self.statusurl = \ + compat.service_url(self.compat, self.httpurl, self.cgipgm) + \ '/' + self.tapcontext + '/' + self.workspace if self.debug: @@ -709,7 +729,7 @@ def __run__(self, **kwargs): # # If async and phase == PENDING: send 303 with statusurl and exit # - # If async and phase != RUN and != ABORT, i.e. bogus value: + # If async and phase != RUN and != ABORT, i.e. bogus value: # return with 400 client error immediately. # @@ -729,13 +749,13 @@ def __run__(self, **kwargs): if ((self.tapcontext == 'async') and \ (self.getstatus == 0)): - + if (len(self.param['phase']) == 0): # # { If phase not specified: set to PENDING and exit # - + if self.debug: logging.debug('') logging.debug('case: set to PENDING') @@ -760,23 +780,23 @@ def __run__(self, **kwargs): # # } end of PENDING case # - + elif (self.param['phase'].lower() == 'abort'): # # { If phase is abort # - + if self.debug: logging.debug('') logging.debug('case bad phase input: abort') self.msg = "There is no existing job to be aborted." - + self.statdict['phase'] = 'ABORTED' self.statdict['jobid'] = self.workspace self.statdict['errmsg'] = self.msg - + """ self.__printError__('votable', self.msg, errcode='400') @@ -800,14 +820,14 @@ def __run__(self, **kwargs): logging.debug('Return HTTP redirect status.xml and exit.') sys.exit() - + elif ((self.param['phase'].lower() != 'run') and \ (self.param['phase'].lower() != 'abort')): - + # # { If phase is bogus value # - + if self.debug: logging.debug('') logging.debug('case bad phase input:') @@ -817,7 +837,7 @@ def __run__(self, **kwargs): self.statdict['jobid'] = self.workspace self.statdict['errmsg'] = self.msg - + """ self.__printError__('votable', self.msg, errcode='400') @@ -827,7 +847,7 @@ def __run__(self, **kwargs): sys.exit() """ - + self.__writeStatusMsg__(self.statuspath, self.statdict, self.param) @@ -847,41 +867,42 @@ def __run__(self, **kwargs): # # } end of async and getstatus=0 case # - - + + if ((self.tapcontext == 'async') and (self.getstatus == 1)): # #{ async and getstatus=1 case: # - + if (len(self.param['phase']) == 0): # - #{ async getStatus case: tap query includes jobid but - # input phase is blank + #{ async getStatus case: tap query includes jobid but + # input phase is blank # if self.debug: logging.debug('') logging.debug('case: getStatus') - + try: - self.__getStatus__(self.workdir, self.id, self.statuskey, \ + self.__getStatus__(self.workdir, self.id, + compat.uws_url_key(self.compat, self.statuskey), \ self.param) except Exception as e: - + self.__printError__(self.format, str(e)) - + # # } end getStatus and printError will exit when done # - + else: - # - # {Tap input with jobid and phase: need to + # + # {Tap input with jobid and phase: need to # parse statuspath to retrieve input parameters # if self.debug: logging.debug('') - logging.debug (f'case: retrieve parameters from workspace') + logging.debug ('case: retrieve parameters from workspace') logging.debug (f'statuspath= {self.statuspath:s}') xmlstruct = None @@ -957,10 +978,10 @@ def __run__(self, **kwargs): doc = xmltodict.parse (xmlstruct) job = doc['uws:job'] - + if self.debug: logging.debug ('') - logging.debug (f'job= ') + logging.debug ('job= ') logging.debug (job) self.statdict['jobid'] = job['uws:jobId'] @@ -971,28 +992,28 @@ def __run__(self, **kwargs): self.statdict['starttime'] = job['uws:startTime'] self.statdict['endtime'] = job['uws:endTime'] self.statdict['destruction'] = job['uws:destruction'] - self.statdict['duration'] = job['uws:executionduration'] + self.statdict['duration'] = compat.duration_value(job) if self.debug: logging.debug ('') - logging.debug (f'param retrieved from status.xml:') - logging.debug (f'jobid: ') + logging.debug ('param retrieved from status.xml:') + logging.debug ('jobid: ') logging.debug (self.statdict["jobid"]) - logging.debug (f'process_id:') + logging.debug ('process_id:') logging.debug (self.statdict["process_id"]) - logging.debug (f'ownerId:') + logging.debug ('ownerId:') logging.debug (self.statdict["ownerId"]) - logging.debug (f'phase:') + logging.debug ('phase:') logging.debug (self.statdict["phase"]) - logging.debug (f'quote:') + logging.debug ('quote:') logging.debug (self.statdict["quote"]) - logging.debug (f'starttime:') + logging.debug ('starttime:') logging.debug (self.statdict["starttime"]) - logging.debug (f'endtime:') + logging.debug ('endtime:') logging.debug (self.statdict["endtime"]) - logging.debug (f'destruction:') + logging.debug ('destruction:') logging.debug (self.statdict["destruction"]) - logging.debug (f'duration:') + logging.debug ('duration:') logging.debug (self.statdict["duration"]) # @@ -1001,8 +1022,8 @@ def __run__(self, **kwargs): # #} end async and getstatus=1 case # - - + + # # Make result table names # @@ -1066,7 +1087,7 @@ def __run__(self, **kwargs): starttime = stime.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-4] destruction = \ - destructtime.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-4] + 'Z' + destructtime.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-4] + compat.destruction_suffix(self.compat) if self.debug: logging.debug('') @@ -1090,7 +1111,7 @@ def __run__(self, **kwargs): # self.__printAsyncResponse__(self.statusurl) - + if self.debug: logging.debug('') logging.debug(f'returned printAsyncResponse') @@ -1099,7 +1120,7 @@ def __run__(self, **kwargs): # # } end async RUN case # - + elif (self.param['phase'] == 'ABORT'): # # { async ABORT case @@ -1114,8 +1135,8 @@ def __run__(self, **kwargs): if self.debug: logging.debug('') - logging.debug(f'currently executing:') - + logging.debug('currently executing:') + pid = self.statdict['process_id'] if self.debug: logging.debug('') @@ -1124,8 +1145,8 @@ def __run__(self, **kwargs): try: os.kill (pid, signal.SIGKILL) - except Exception as e: - + except Exception: + if self.debug: logging.debug('') logging.debug(f'Error abort job: pid = {pid:d}') @@ -1141,97 +1162,97 @@ def __run__(self, **kwargs): destructtime = datetime.datetime.now() destruction = \ destructtime.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-4] - + etime = datetime.datetime.now() endtime = etime.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-4] if self.debug: logging.debug('') - logging.debug(f'got here0-3') - logging.debug(f'etime:') + logging.debug('got here0-3') + logging.debug('etime:') logging.debug(etime) - + starttimestr = self.statdict['starttime'] - + stime = datetime.datetime.strptime \ (starttimestr, '%Y-%m-%dT%H:%M:%S.%f') if self.debug: logging.debug('') - logging.debug(f'stime:') + logging.debug('stime:') logging.debug(stime) - - durationtime = etime - stime + + durationtime = etime - stime if self.debug: logging.debug('') - logging.debug(f'got here0-4') - logging.debug(f'durationtime:') + logging.debug('got here0-4') + logging.debug('durationtime:') logging.debug(durationtime) - + durationstr = str(durationtime.total_seconds())[:50] if self.debug: logging.debug('') - logging.debug(f'got here0-5') + logging.debug('got here0-5') logging.debug(f'durationstr= {durationstr:s}') - + duration_dbl = float(durationstr) if self.debug: logging.debug('') - logging.debug(f'got here0-6') - + logging.debug('got here0-6') + duration = math.ceil(duration_dbl) if self.debug: logging.debug('') - logging.debug(f'got here0-7') - + logging.debug('got here0-7') + self.statdict['endtime'] = endtime if self.debug: logging.debug('') - logging.debug(f'got here0-8') - + logging.debug('got here0-8') + self.statdict['duration'] = duration if self.debug: logging.debug('') - logging.debug(f'got here0-9') - + logging.debug('got here0-9') + self.statdict['destruction'] = destruction if self.debug: logging.debug('') - logging.debug(f'got here0-10') - + logging.debug('got here0-10') + self.statdict['phase'] = 'ABORTED' - self.statdict['errmsg'] = '' + self.statdict['errmsg'] = '' if self.debug: logging.debug('') - logging.debug(f'got here0-11') - + logging.debug('got here0-11') + """ self.__writeStatusMsg__(self.statuspath, self.statdict, self.param) self.__printAsyncResponse__(self.statusurl) - + if self.debug: logging.debug('') logging.debug(f'returned printAsyncResponse') """ - + elif (self.statdict['phase'] == 'PENDING'): errmsg = 'PENDING job aborted' if self.debug: logging.debug('') - logging.debug(f'call writeAsyncError:') + logging.debug('call writeAsyncError:') logging.debug(f'errmsg= {errmsg:s}') - - + + self.statdict['starttime'] = '' self.statdict['endtime'] = '' - self.statdict['duration'] = 0 - + self.statdict['duration'] = 0 + self.statdict['phase'] = 'ABORTED' self.statdict['errmsg'] = errmsg @@ -1240,7 +1261,7 @@ def __run__(self, **kwargs): self.param) self.__printAsyncResponse__(self.statusurl) - + if self.debug: logging.debug('') logging.debug(f'returned printAsyncResponse') @@ -1252,7 +1273,7 @@ def __run__(self, **kwargs): else: # - # { any other bogus values: this only occured in the initial + # { any other bogus values: this only occured in the initial # PENDING case; the other case that the client send in bogus # case first time around would have been returned by a HTTP # 400 error immediately. @@ -1262,56 +1283,74 @@ def __run__(self, **kwargs): logging.debug ('case bogus phase value') errmsg = "Unknown input job phase=" + self.param['phase'] - + self.statdict['starttime'] = '' self.statdict['endtime'] = '' - self.statdict['duration'] = 0 - + self.statdict['duration'] = 0 + self.statdict['phase'] = 'ERROR' self.statdict['errmsg'] = errmsg - + # # } end async bogus value case # - if self.debug: - logging.debug('') - logging.debug ('call writeStatusMsg') - logging.debug (f'statuspath= {self.statuspath:s}') + if (self.param['phase'] == 'RUN'): + # + # Publish the job, answer the client, and keep running + # the query in a detached child. + # - self.__writeStatusMsg__(self.statuspath, self.statdict, - self.param) + self.__respondAsyncAndDetach__() - if self.debug: - logging.debug('') - logging.debug ('call printAsyncResponse') + if self.debug: + logging.debug('') + logging.debug('async response sent; job running detached') - self.__printAsyncResponse__(self.statusurl) - - if self.debug: - logging.debug('') - logging.debug(f'returned printAsyncResponse') + else: + + # + # ABORT and unrecognized phases are terminal: the branch + # above has already decided the phase, so write it, + # answer the client and stop. Falling through to the + # query path below would run the job the client just + # aborted and overwrite ABORTED (or ERROR) with + # COMPLETED. + # + + if self.debug: + logging.debug('') + logging.debug ('terminal async phase= ' + f"{self.statdict['phase']:s}") + logging.debug (f'statuspath= {self.statuspath:s}') + + self.__writeStatusMsg__(self.statuspath, self.statdict, + self.param) + + self.__printAsyncResponse__(self.statusurl) + + sys.exit() # - # } end async submit case + # } end async submit case # # - # { Before running query -- both sync and async cases, check - # input parameters to reject obvious input errors - # - + # { Before running query -- both sync and async cases, check + # input parameters to reject obvious input errors + # + if self.debug: logging.debug('') - logging.debug(f'continue: check lang') + logging.debug('continue: check lang') if (self.param['lang'].lower() != 'adql' and \ self.param['lang'].lower() != 'adql-2.0'): - + self.msg = "Input parameter (lang=" + self.param['lang'] + \ - ") error: only lang=ADQL is implemented." - + ") error: only lang=ADQL is implemented." + if(self.tapcontext == 'async'): self.__writeAsyncError__(self.msg, self.statuspath, self.statdict, self.param) @@ -1322,10 +1361,10 @@ def __run__(self, **kwargs): if ((self.tapcontext == 'async') and \ (self.param['phase'].lower() != 'run') and\ (self.param['phase'].lower() != 'abort')): - + self.statdict['phase'] = 'ERROR' self.statdict['jobid'] = self.workspace - + self.msg = "Unknown input job phase=" + self.param['phase'] self.__writeAsyncError__(self.msg, self.statuspath, @@ -1335,7 +1374,7 @@ def __run__(self, **kwargs): if self.debug: logging.debug('') - logging.debug(f'continue: check format') + logging.debug('continue: check format') if ((self.format != 'votable') and \ (self.format != 'ipac') and \ @@ -1361,7 +1400,7 @@ def __run__(self, **kwargs): if self.debug: logging.debug('') - logging.debug(f'continue: check maxrec') + logging.debug('continue: check maxrec') logging.debug(f'self.maxrecstr= {self.maxrecstr:s}') @@ -1476,7 +1515,7 @@ def __run__(self, **kwargs): try: TableValidator.validate_statement(query_adql, debug=self.debug) except Exception as e: - errcode = '403' if isinstance(e, TableValidationError) else '400' + errcode = compat.table_denied_status(self.compat) if isinstance(e, TableValidationError) else '400' if(self.tapcontext == 'async'): self.phase = 'ERROR' self.__writeAsyncError__(str(e), self.statuspath, @@ -1530,7 +1569,7 @@ def __run__(self, **kwargs): if self.debug: logging.debug('') - logging.debug(f'ADQL initialized') + logging.debug('ADQL initialized') self.query = adql.sql(query_adql) @@ -1564,7 +1603,7 @@ def __run__(self, **kwargs): tables = tn.extract_tables(self.query) self.dbtable = tables[0] - except Exception as e: + except Exception: if self.debug: logging.debug('') logging.debug('TableName exception') @@ -1573,9 +1612,9 @@ def __run__(self, **kwargs): if len(self.dbtable) == 0: self.msg = 'No table name found in ADQL query.' - + if(self.tapcontext == 'async'): - + self.__writeAsyncError__(self.msg, self.statuspath, self.statdict, self.param) else: @@ -1706,7 +1745,7 @@ def __run__(self, **kwargs): if self.debug: logging.debug('') - logging.debug(f'tapQuery parameters:') + logging.debug('tapQuery parameters:') logging.debug(f'query = [{self.query:s}]') logging.debug(f'workdir = [{self.userWorkdir:s}]') logging.debug(f'format = [{self.format:s}]') @@ -1741,7 +1780,7 @@ def __run__(self, **kwargs): # TableValidationError means the query referenced a table # not in TAP_SCHEMA — access denied (403). All other errors # are bad requests (400). - errcode = '403' if isinstance(e, TableValidationError) else '400' + errcode = compat.table_denied_status(self.compat) if isinstance(e, TableValidationError) else '400' if(self.tapcontext == 'async'): @@ -1802,7 +1841,7 @@ def __run__(self, **kwargs): # TableValidationError means the query referenced a table # not in TAP_SCHEMA — access denied (403). All other errors # are bad requests (400). - errcode = '403' if isinstance(e, TableValidationError) else '400' + errcode = compat.table_denied_status(self.compat) if isinstance(e, TableValidationError) else '400' if(self.tapcontext == 'async'): @@ -1842,7 +1881,7 @@ def __run__(self, **kwargs): etime = datetime.datetime.now() durationtime = etime - self.statdict['stime'] - + if self.debug: logging.debug('') logging.debug('durationtime:') @@ -1857,7 +1896,7 @@ def __run__(self, **kwargs): if self.debug: logging.debug('') logging.debug(f'duration_dbl= {duration_dbl:f}') - + duration = math.ceil(duration_dbl) if self.debug: @@ -2032,7 +2071,7 @@ def __printStatus__(self, key, retval, outtype, **kwargs): # # Header # - + retvalstr = str(retval) if self.debug: logging.debug('') @@ -2046,16 +2085,16 @@ def __printStatus__(self, key, retval, outtype, **kwargs): uwsschema = ' xmlns:uws="http://www.ivoa.net/xml/UWS/v1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xsi="http://www.w3.org/2001/XMLSchema" xsi:schemaLocation="http://www.ivoa.net/xml/UWS/v1.0 http://www.ivoa.net/xml/UWS/v1.0">' - + if ((key == 'error') or (key == 'errorSummary') or (key == 'errmsg')): - + if(outtype == 'xml'): - self.__printError__ ('votable', retval) + self.__printError__ ('votable', retval) else: - self.__printError__ ('plain', retval) - + self.__printError__ ('plain', retval) + # # parameters and results vosi endpoints # @@ -2063,43 +2102,43 @@ def __printStatus__(self, key, retval, outtype, **kwargs): if(outtype == 'xml'): - print("Content-type: text/xml\r") + print("Content-type: %s\r" % compat.uws_content_type(self.compat)) print("\r") print('') - + if(key == 'parameters'): - + if self.debug: logging.debug('') logging.debug('key = parameters') - + ind = retvalstr.index('>') if self.debug: logging.debug('') logging.debug('ind=') logging.debug(ind) - + if self.debug: logging.debug('') logging.debug(f'retvalstr[0:ind]= {retvalstr[0:ind-1]:s}') logging.debug(f'retvalstr[ind+1]= {retvalstr[ind+1]:s}') - - val = retvalstr[0:ind] + uwsschema + retvalstr[ind+1:] + + val = retvalstr[0:ind] + uwsschema + retvalstr[ind+1:] if self.debug: logging.debug('') logging.debug(f'val= {val:s}') - + print(val) sys.stdout.flush() elif(key == 'results'): - + ind = retvalstr.index('>') - val = retvalstr[0:ind] + uwsschema + retvalstr[ind+1:] + val = retvalstr[0:ind] + uwsschema + retvalstr[ind+1:] if self.debug: logging.debug('') logging.debug(f'val= {val:s}') - + print(val) sys.stdout.flush() @@ -2109,10 +2148,10 @@ def __printStatus__(self, key, retval, outtype, **kwargs): print (retval) sys.stdout.flush() - + if self.debug: logging.debug('Write status to user and exit.') - + sys.exit() # # } end of printStatus @@ -2124,7 +2163,7 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): # # { initialize format to votable but after extracting format - # the output should follow the format + # the output should follow the format # if self.debug: logging.debug('') @@ -2132,7 +2171,7 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): logging.debug('key= ') logging.debug(key) - + isExist = os.path.exists(self.statuspath) if(isExist == 0): @@ -2165,7 +2204,7 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): logging.debug('Return status.xml to user and exit.') print("HTTP/1.1 200 OK\r") - print("Content-type: text/xml\r") + print("Content-type: %s\r" % compat.uws_content_type(self.compat)) print("\r") print(data) sys.exit() @@ -2231,13 +2270,13 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): logging.debug('') logging.debug('key == parameters') logging.debug('call __printStatus') - + self.__printStatus__('parameters', parameters, 'xml') # # } end key=parameters # - + elif ((key == 'phase') or (key == 'startTime') or (key == 'endTime') @@ -2258,12 +2297,12 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): if self.debug: logging.debug('') logging.debug(\ - f'key == phase, executionduration or destruction etc.') + 'key == phase, executionduration or destruction etc.') # # { Single value return # retval = 'None' - keystr = 'uws:' + key + keystr = 'uws:' + compat.uws_key(job, key) outstr = '' if((key == 'phase') @@ -2274,7 +2313,11 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): or (key == 'jobId') or (key == 'runId')): - retval = job[keystr] + # xmltodict reads an empty element as None and a nil one + # as a dict; both are an unset value, which is empty text. + retval = job.get(keystr) + if (retval is None) or isinstance(retval, dict): + retval = '' outstr = retval elif((key == 'ownerId') or (key == 'quote')): @@ -2308,8 +2351,8 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): if self.debug: logging.debug('') - logging.debug(f'key == error') - + logging.debug('key == error') + retval = '' errmsg = '' @@ -2317,11 +2360,11 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): if self.debug: logging.debug('') - logging.debug(f'key == error') + logging.debug('key == error') try: errmsg = job['uws:errorSummary']['uws:message'] - except Exception as e: + except Exception: self.printError (\ self.format, 'error extracting errorSummary') @@ -2330,17 +2373,17 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): logging.debug(f'errmsg: {errmsg:s}') if (self.format == 'votable'): - + if (len(errmsg) > 0): outstr = f' {errmsg:s}' else: - outstr = f' ' + outstr = ' ' self.__printStatus__('errorSummary', outstr, 'xml') - + else: if (len(errmsg) > 0): - outstr = errmsg + outstr = errmsg else: outstr = 'No error message' @@ -2363,7 +2406,7 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): logging.debug(\ 'case results or results/result or results/resulturl') - + if (key == 'results'): # # { if results @@ -2371,7 +2414,7 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): if self.debug: logging.debug('') logging.debug('key= results') - + if (phase.lower() == 'completed'): # # { if reulsts case and phase = completed @@ -2390,35 +2433,35 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): # logging.debug(result) #results = job['uws:results'] - + #if self.debug: # logging.debug('') # logging.debug(f'results: {results:s}') - + #outstr = results - except Exception as e: - + except Exception: + if self.debug: logging.debug('') logging.debug('error retrieving results') - + self.__printError__(self.format, \ 'Failed to extract results', errcode='400') - + if self.debug: logging.debug('') logging.debug('results:') logging.debug(results) logging.debug('result:') logging.debug(result) - + self.__printStatus__(key, results, 'xml') sys.exit() - + + # + # } end results case # - # } end results case - # else: # # { if reulsts case but phase NOT completed @@ -2431,13 +2474,13 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): # # } end if results # - else: + else: # # { if results/resulturl or 'results/result: # if (phase.lower() == 'completed'): # - # { if reulsts/resulturl or results/result cases + # { if reulsts/resulturl or results/result cases # and phase = completed # try: @@ -2445,12 +2488,12 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): resulturl = \ job['uws:results']['uws:result']['@xlink:href'] - except Exception as e: - + except Exception: + if self.debug: logging.debug('') logging.debug('error retrieving result') - + self.__printError__ (self.format, \ 'Error retrieving result or resulturl') @@ -2464,7 +2507,7 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): self.__printStatus__(key, resulturl, 'plain') sys.exit() - + elif (key == 'results/result'): indx = resulturl.find(workspace) @@ -2478,7 +2521,7 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): fp = None try: fp = open(resultpath, 'r') - except Exception as e: + except Exception: msg = 'Failed to open result file: ' + resultpath self.__printError__(format, msg, errcode='400') @@ -2487,7 +2530,7 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): if(format == 'json'): print("Content-type: application/json\r") elif(format == 'votable'): - print("Content-type: text/xml\r") + print("Content-type: %s\r" % compat.votable_content_type(self.compat)) else: print("Content-type: text/plain\r") print("\r") @@ -2508,31 +2551,31 @@ def __getStatus__(self, workdir, workspace, key, param, **kwargs): fp.close() sys.exit() - + # - # } end results/resulturl or results/result cases + # } end results/resulturl or results/result cases # and phase completed - # - + # + else: # - # { if reulsts/resulturl or results/result cases + # { if reulsts/resulturl or results/result cases # and phase NOT completed # self.__printError__ (self.format, \ 'No result or resulturl because phase is not COMPLETED') - + # - # } end results/resulturl or results/result cases + # } end results/resulturl or results/result cases # and phase NOT completed - # - + # + # # } if results/resulturl or 'results/result: # # - # } end results or results/resulturl or results/result cases - # + # } end results or results/resulturl or results/result cases + # else: msg = f'key {key:s} is not a valid key' @@ -2547,11 +2590,17 @@ def __printError__(self, fmt, errmsg, **kwargs): # # { # - - errcode = '200' + + errcode = '200' if ('errcode' in kwargs): errcode = kwargs['errcode'] + nea = compat.error_document(self.compat, errmsg, errcode) + if nea is not None: # nea-errors replaces the whole response + sys.stdout.write(nea) + sys.stdout.flush() + sys.exit() + httphdr = "HTTP/1.1 " + str(errcode) + " ERROR\r" print(httphdr) @@ -2574,7 +2623,7 @@ def __printError__(self, fmt, errmsg, **kwargs): if(self.infomsg[-1] == '?'): print(self.infomsg + 'dbtable=' + html.escape(self.dbtable)) else: - print(self.infomsg) + print(self.infomsg) print('') print('') @@ -2589,8 +2638,8 @@ def __printError__(self, fmt, errmsg, **kwargs): if(self.infomsg[-1] == '?'): print(self.infomsg + 'dbtable=' + self.dbtable) else: - print(self.infomsg) - + print(self.infomsg) + sys.stdout.flush() sys.exit() @@ -2615,22 +2664,22 @@ def __writeAsyncError__(self, errmsg, statuspath, statdict, param, **kwargs): # # Set status parameters # - + if self.debug: logging.debug('') - logging.debug(f'From writeAsyncError') - logging.debug(f'statdict["stime"]:') + logging.debug('From writeAsyncError') + logging.debug('statdict["stime"]:') logging.debug(statdict['stime']) - + if (statdict['stime'] is not None): - + etime = datetime.datetime.now() endtime = etime.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-4] durationtime = etime - statdict['stime'] durationstr = str(durationtime.total_seconds())[:10] duration_dbl = float(durationstr) - + duration = math.ceil(duration_dbl) statdict['endtime'] = endtime @@ -2639,8 +2688,8 @@ def __writeAsyncError__(self, errmsg, statuspath, statdict, param, **kwargs): else: statdict['starttime'] = '' statdict['endtime'] = '' - statdict['duration'] = 0 - + statdict['duration'] = 0 + statdict['phase'] = 'ERROR' statdict['errmsg'] = errmsg @@ -2651,8 +2700,8 @@ def __writeAsyncError__(self, errmsg, statuspath, statdict, param, **kwargs): # # } end of writeAsyncError # - - + + def __printSyncResult__(self, resultpath, format, **kwargs): @@ -2680,7 +2729,7 @@ def __printSyncResult__(self, resultpath, format, **kwargs): if(format == 'json'): print("Content-type: application/json\r") elif(format == 'votable'): - print("Content-type: text/xml\r") + print("Content-type: %s\r" % compat.votable_content_type(self.compat)) else: print("Content-type: text/plain\r") print("\r") @@ -2740,28 +2789,276 @@ def __printSyncResponse__(self, status, msg, resulturl, format, **kwargs): # + def __printJobList__(self, **kwargs): + + # + # UWS job list (GET /async). Jobs carry no owner, so the list + # shows none of them rather than everyone's. + # + + body = ('\n' + '\n') + + sys.stdout.write('HTTP/1.1 200 OK\r\n') + sys.stdout.write(f'Content-Type: {compat.uws_content_type(self.compat)}\r\n') + sys.stdout.write(f'Content-Length: {len(body.encode("utf-8"))}\r\n') + sys.stdout.write('Connection: close\r\n\r\n') + sys.stdout.write(body) + sys.stdout.flush() + sys.exit() + + + def __deleteJob__(self, **kwargs): + + # + # UWS job deletion (POST ACTION=DELETE, or HTTP DELETE): stop the + # job if it is running, remove its workspace, and send the client + # to the job list. The job id was checked against the workspace + # name pattern before userWorkdir was built from it. + # + + try: + with open(self.userWorkdir + '/status.xml', 'r') as fp: + job = xmltodict.parse(fp.read())['uws:job'] + + if (job.get('uws:phase') == 'EXECUTING'): + pid = int(job.get('uws:runId') or 0) + if (pid > 0) and (pid != os.getpid()): + os.kill(pid, signal.SIGKILL) + + except Exception as e: + + # A job whose status can't be read or whose process is gone + # is still deleted. + if self.debug: + logging.debug(f'deleteJob: {str(e):s}') + + try: + shutil.rmtree(self.userWorkdir) + + except OSError as e: + self.__printError__('votable', 'Failed to delete job ' + + self.workspace + ': ' + str(e), errcode='500') + + joblist = compat.service_url(self.compat, self.httpurl, self.cgipgm) + \ + '/async' + + sys.stdout.write('HTTP/1.1 303 See Other\r\n') + sys.stdout.write(f'Location: {joblist}\r\n') + sys.stdout.write('Content-Length: 0\r\n') + sys.stdout.write('Connection: close\r\n\r\n') + sys.stdout.flush() + sys.exit() + + def __printAsyncResponse__(self, statusurl, **kwargs): # - # async: return statusurl and kill the parent process + # async: point the client at the job's status URL. + # + # The body is one line, but it still needs a Content-Length. This + # is an nph- script, so nothing downstream supplies one, and a + # reverse proxy in front of the CGI has no other way to tell + # where the response ends. Earlier versions omitted it and let + # the connection dying stand in for the end of the message. # - print("HTTP/1.1 303 See Other\r") - print("Location: %s\r\n\r" % statusurl) - print("Redirect Location: %s" % statusurl) + body = 'Redirect Location: %s\n' % statusurl + + sys.stdout.write('HTTP/1.1 303 See Other\r\n') + sys.stdout.write('Location: %s\r\n' % statusurl) + sys.stdout.write(compat.async_submit_headers(self.compat, body)) + sys.stdout.write('\r\n') + sys.stdout.write(body) sys.stdout.flush() - time.sleep(2.0) + if self.debug: + logging.debug('') + logging.debug(f'async response sent: statusurl= {statusurl:s}') + + return + + def __respondAsyncAndDetach__(self, **kwargs): + + # + # { An async submit has to finish an HTTP response now and keep + # executing the query afterwards, and under CGI those two pull + # against each other: the web server completes the response + # when the script closes stdout, and mod_cgi terminates the + # script once the request is cleaned up. The process that runs + # the query can therefore be neither the one holding stdout nor + # part of the request. + # + # So fork. The parent names the child as the job's runId, + # writes the status document, sends the 303 and exits, which + # ends the response the way any other CGI would. The child + # leaves the request's process group, points its standard + # streams away from the server pipe, waits for the parent to + # confirm the job is published, and returns to run the query. # - # Shut down parent program + # Earlier versions sent the response and then SIGKILLed + # os.getppid(). Under CGI that parent is the web server child + # serving the request: killing it truncates the response, and + # behind a reverse proxy it leaves the proxy holding a dead + # upstream connection, which the proxy reports as 503 on the + # next request or two routed over it. # - os.kill(os.getppid(), signal.SIGKILL) + try: + readfd, writefd = os.pipe() + pid = os.fork() + + except OSError as e: + + # + # No fork available: answer the request from this process and + # run the job here. The response is still well formed; the + # job now lives and dies with the request. + # + + logging.error(f'Could not fork async worker: {str(e)}') + + self.__writeStatusMsg__(self.statuspath, self.statdict, + self.param) + + self.__printAsyncResponse__(self.statusurl) + + return + + if (pid > 0): + # + # { parent: publish the job, answer the client, exit + # + os.close(readfd) + + self.statdict['process_id'] = pid + + published = False + + try: + self.__writeStatusMsg__(self.statuspath, self.statdict, + self.param) + + published = True + + self.__printAsyncResponse__(self.statusurl) + + except Exception as e: + + # + # Most likely the client or the proxy hung up while the + # response was going out. Nothing can be sent about it + # now; the job's own fate is decided in the finally + # clause below. + # + + logging.error(f'Async response failed: {str(e)}') + + finally: + + # + # The child blocks on this byte, so a fast query cannot + # overwrite the status document written above. Once the + # job is published the child has to be released even if + # the response itself failed: a client that hung up does + # not make the job go away, and a child that exits here + # would leave the job stuck in EXECUTING forever. If the + # status write is what failed, the byte is withheld and + # the child exits, because there is no job document for + # it to update. + # + + if published: + try: + os.write(writefd, b'1') + + except OSError as e: + logging.error( + f'Could not release async worker: {str(e)}') + + os.close(writefd) + + sys.exit(0) + # + # } end parent + # + + # + # { child: detach from the request, then run the query + # + os.close(writefd) + + self.pid = os.getpid() + self.statdict['process_id'] = self.pid + + self.__detachFromServer__() + + published = b'' + + try: + published = os.read(readfd, 1) + + except OSError as e: + logging.error(f'Async worker handshake failed: {str(e)}') + + os.close(readfd) + + if (len(published) == 0): + + # + # The parent exited before it published the job, so there is + # no status document for this run to update. + # + + logging.error('Async parent exited before publishing the job') + + os._exit(1) if self.debug: logging.debug('') - logging.debug('parent process killed') + logging.debug(f'async worker detached: pid= {self.pid:d}') + + return + # + # } end child + # + + + def __detachFromServer__(self, **kwargs): + + # + # Give up the web server's request context: leave the request's + # process group so the server cannot reap this process along with + # the request, and replace the inherited stdio with /dev/null so + # the response the parent just sent is neither held open by nor + # corrupted from here. + # + + try: + os.setsid() + + except OSError as e: + logging.error(f'setsid failed in async worker: {str(e)}') + + try: + sys.stdout.flush() + sys.stderr.flush() + + except (OSError, ValueError) as e: + logging.error(f'Could not flush async worker stdio: {str(e)}') + + devnull = os.open(os.devnull, os.O_RDWR) + + try: + os.dup2(devnull, 0) + os.dup2(devnull, 1) + os.dup2(devnull, 2) + + finally: + if (devnull > 2): + os.close(devnull) return @@ -2770,9 +3067,9 @@ def __writeStatusMsg__(self, statuspath, statdict, param, **kwargs): if self.debug: logging.debug('') - logging.debug(f'Enter writeStatusMsg') + logging.debug('Enter writeStatusMsg') logging.debug(f'phase= {statdict["phase"]:s}') - + # # { TAP status result always written in XML format # input maxrecstr is stored in self.maxrecstr @@ -2781,7 +3078,7 @@ def __writeStatusMsg__(self, statuspath, statdict, param, **kwargs): format = param['format'].lower() maxrecstr = '' - + if (len(self.maxrecstr) == 0): maxrecstr = str(param['maxrec']) else: @@ -2791,13 +3088,13 @@ def __writeStatusMsg__(self, statuspath, statdict, param, **kwargs): logging.debug('') logging.debug(f'maxrecstr= {maxrecstr:s}') logging.debug(f"maxrec= {param['maxrec']:d}") - + fp = None try: fp = open(statuspath, 'w+') os.chmod(statuspath, 0o664) - except Exception as e: + except Exception: msg = 'Failed to open/create status file.' self.__printError__(format, msg) @@ -2827,7 +3124,7 @@ def __writeStatusMsg__(self, statuspath, statdict, param, **kwargs): errmsg = statdict['errmsg'] fp.write('\n') - + fp.write (self.uwsheader) fp.write ('\n') @@ -2850,7 +3147,8 @@ def __writeStatusMsg__(self, statuspath, statdict, param, **kwargs): fp.write(f" {statdict['endtime']:s}\n") - fp.write(f" {statdict['duration']:d}\n") + fp.write(f" {statdict['duration']:d}" + "\n") if (statdict['destruction'] is None): fp.write(' \n') @@ -2873,7 +3171,7 @@ def __writeStatusMsg__(self, statuspath, statdict, param, **kwargs): if self.debug: logging.debug('') - logging.debug(f'wrote job status file') + logging.debug('wrote job status file') # @@ -2906,7 +3204,7 @@ def __writeStatusMsg__(self, statuspath, statdict, param, **kwargs): if self.debug: logging.debug('') - logging.debug(f'done writeAsyncError') + logging.debug('done writeAsyncError') # # Note: closing file automatically released the lock @@ -2923,7 +3221,7 @@ def __getDatalevel__(self, dbtable, **kwargs): # # { getDataLevel # - + if self.debug: logging.debug('') logging.debug('Enter __getDatalevel__') @@ -2962,12 +3260,12 @@ def __getDatalevel__(self, dbtable, **kwargs): # } end getDataLevel # - + def __printVosiTables__ (self, connectInfo, vosipath, **kwargs): # # { printVosiTables # - + if self.debug: logging.debug('') logging.debug('Enter __printVosiTables__') @@ -2990,13 +3288,13 @@ def __printVosiTables__ (self, connectInfo, vosipath, **kwargs): logging.debug('') logging.debug('call vosiTables') - vositbl = vosiTables (connectInfo=self.config.connectInfo, dbms=dbms, \ + compat.vosi_tables_class(self.compat) (connectInfo=self.config.connectInfo, dbms=dbms, \ dbserver=dbserver, \ userid=userid, \ password=password, \ outpath=vosipath, \ debug=1) - + if self.debug: logging.debug('') logging.debug('returned vosiTables') @@ -3008,7 +3306,7 @@ def __printVosiTables__ (self, connectInfo, vosipath, **kwargs): logging.debug('VosiTables exception:') logging.debug(f'exception: {str(e):s}') - self.__printError__ ('votable', str(e)) + self.__printError__ ('votable', str(e)) # # print out vosi table in the workspace @@ -3017,7 +3315,7 @@ def __printVosiTables__ (self, connectInfo, vosipath, **kwargs): fp = None try: fp = open(vosipath, 'r') - except Exception as e: + except Exception: msg = 'Failed to open vositable path: ' + vosipath self.__printError__('votable', msg, errcode='400') @@ -3048,21 +3346,23 @@ def __printVosiTables__ (self, connectInfo, vosipath, **kwargs): def __printVosiAvailability__ (self, **kwargs): - + # # { printVosiAvailability # + sys.stdout.write(compat.vosi_head(self.compat)) print ('') print ('') print ('') print (' true') print (' TAP service available.') print ('') + sys.stdout.write(compat.vosi_tail(self.compat)) sys.exit() # @@ -3076,6 +3376,7 @@ def __printVosiCapability__ (self, **kwargs): # { printVosiCapability # + sys.stdout.write(compat.vosi_head(self.compat)) print ('') print ('') print ('') val = ' ' + self.httpurl + '/TAP' print (val) - + print (' ') print ('') print (' ') @@ -3148,10 +3449,10 @@ def __printVosiCapability__ (self, **kwargs): print (' ') print (' ') print (' ') - + val = ' ' + self.httpurl + '/TAP/tables' print (val) - + print (' GET') print (' application/xml') print (' ') @@ -3160,31 +3461,32 @@ def __printVosiCapability__ (self, **kwargs): print (' ') print (' ') print (' ') - + val = ' ' + self.httpurl + '/TAP/capabilities' print (val) - + print (' ') print (' ') print ('') print (' ') print (' ') print (' ') - + val = ' ' + self.httpurl + '/TAP/availability' print (val) - + print (' ') print (' ') print ('') - print ('') - + print ('') + + sys.stdout.write(compat.vosi_tail(self.compat)) sys.exit() - + # # } end printVosiCapability # - + # # } end tap class # diff --git a/TAP/tapquery.py b/TAP/tapquery.py index 954f20d..43c9173 100644 --- a/TAP/tapquery.py +++ b/TAP/tapquery.py @@ -2,25 +2,20 @@ # This code is released with a BSD 3-clause license. License information is at # https://github.com/Caltech-IPAC/nexsciTAP/blob/master/LICENSE -import sys -import os -import logging - -import datetime - import argparse -import configobj - -from TAP.datadictionary import dataDictionary -from TAP.writeresult import writeResult -from TAP.tablenames import TableNames -from TAP.tablevalidator import TableValidator, TableValidationError -from TAP.configparam import configParam +import logging +import os +import sys from ADQL.adql import ADQL - from spatial_index import SpatialIndex +from TAP.configparam import configParam +from TAP.datadictionary import dataDictionary +from TAP.tablenames import TableNames +from TAP.tablevalidator import TableValidationError, TableValidator +from TAP.writeresult import writeResult + class tapQuery: @@ -80,8 +75,8 @@ def __init__(self, **kwargs): conn: We will need the above connectInfo for it's formatting info but we may have already made the DBMS connection elsewhere (for more involved - processing scenarios). In that case we can - optionally pass in the connection itself + processing scenarios). In that case we can + optionally pass in the connection itself instead of creating it here. query(char): the sql query to be executed, workdir(char): user work directory @@ -101,8 +96,8 @@ def __init__(self, **kwargs): workdir=userworkdir, filename=filename, maxrec=maxrec, - ddtbl=ddtbl, - ddtbl=ddfile, + ddtbl=ddtbl, + ddtbl=ddfile, format=format, racol=racol, deccol=deccol) @@ -120,7 +115,7 @@ def __init__(self, **kwargs): self.ddfile = kwargs['ddfile'] if self.debug: - logging.debug(f'Enter tapQuery') + logging.debug('Enter tapQuery') logging.debug(f'self.debug = {self.debug:d}') @@ -149,15 +144,15 @@ def __init__(self, **kwargs): # Otherwise collect the info for making the connection and make it. else: - + # Collect DBMS-specific keyword parameters # # There are two use modes for getting these parameters. Most of the time we let this code do everything; # getting the connection paramters and making the actual connection ('conn' parameter) to whichever DBMS # we are using. But sometimes we have a more complicated scenario (like using and populating temporary - # tables as part of the processing). The we will have run tapUtil() to do this same setup and used the + # tables as part of the processing). The we will have run tapUtil() to do this same setup and used the # database connection for these other steps before we get here. The all of the code here and the section - # below where we connect to the database will have already been executed there. + # below where we connect to the database will have already been executed there. # ORACLE @@ -166,7 +161,7 @@ def __init__(self, **kwargs): import cx_Oracle - self.dbserver = None + self.dbserver = None if('dbserver' in self.connectInfo): self.dbserver = self.connectInfo['dbserver'] @@ -176,7 +171,7 @@ def __init__(self, **kwargs): self.status = 'error' raise Exception(self.msg) - self.userid = None + self.userid = None if ('userid' in self.connectInfo): self.userid = self.connectInfo['userid'] @@ -207,7 +202,7 @@ def __init__(self, **kwargs): import sqlite3 - self.db = None + self.db = None if ('db' in self.connectInfo): self.db = self.connectInfo['db'] @@ -217,7 +212,7 @@ def __init__(self, **kwargs): self.status = 'error' raise Exception(self.msg) - self.tap_schema = None + self.tap_schema = None if('tap_schema' in self.connectInfo): self.tap_schema = self.connectInfo['tap_schema'] @@ -231,20 +226,20 @@ def __init__(self, **kwargs): logging.debug(f'db= {self.db:s}') logging.debug(f'tap_schema= {self.tap_schema:s}') - + # MYSQL elif self.dbms.lower() == 'mysql': import mysql.connector - - self.dbserver = None + + self.dbserver = None self.port = 3306 self.socket = None self.db = None self.userid = None self.password = None - + if ('dbserver' in self.connectInfo): self.dbserver = self.connectInfo['dbserver'] @@ -259,10 +254,10 @@ def __init__(self, **kwargs): if ((self.dbserver is None) and \ (self.socket is None)): - + self.msg = 'Failed to retrieve required input DB server ' \ 'parameter [dbserver] or [socket]' - + self.status = 'error' raise Exception(self.msg) @@ -304,7 +299,7 @@ def __init__(self, **kwargs): import psycopg2 - self.hostname = None + self.hostname = None if('hostname' in self.connectInfo): self.hostname = self.connectInfo['hostname'] @@ -314,7 +309,7 @@ def __init__(self, **kwargs): self.status = 'error' raise Exception(self.msg) - self.database = None + self.database = None if('database' in self.connectInfo): self.database = self.connectInfo['database'] @@ -324,7 +319,7 @@ def __init__(self, **kwargs): self.status = 'error' raise Exception(self.msg) - self.username = None + self.username = None if('username' in self.connectInfo): self.username = self.connectInfo['username'] @@ -334,7 +329,7 @@ def __init__(self, **kwargs): self.status = 'error' raise Exception(self.msg) - self.password = None + self.password = None if('password' in self.connectInfo): self.password = self.connectInfo['password'] @@ -376,14 +371,14 @@ def __init__(self, **kwargs): if self.debug: logging.debug('connected to Oracle, DB ' + self.dbserver) - except Exception as e: + except Exception: self.status = 'error' self.msg = 'Failed to connect to cx_Oracle' raise Exception(self.msg) - + # SQLITE3 elif self.dbms.lower() == 'sqlite3': @@ -408,7 +403,7 @@ def __init__(self, **kwargs): if self.debug: logging.debug('TAP_SCHEMA attached') - except Exception as e: + except Exception: self.status = 'error' self.msg = 'Failed to connect to SQLite3 databases' @@ -419,7 +414,7 @@ def __init__(self, **kwargs): # MYSQL elif self.dbms.lower() == 'mysql': - + try: if (self.dbserver is not None): @@ -430,7 +425,7 @@ def __init__(self, **kwargs): port=self.port, \ db=self.db ) - + elif (self.socket is not None): self.conn = mysql.connector.connect ( @@ -439,7 +434,7 @@ def __init__(self, **kwargs): unix_socket=self.socket, \ db=self.db ) - + else: self.status = 'error' self.msg = 'Failed to connect to mysql databases' @@ -448,7 +443,7 @@ def __init__(self, **kwargs): if self.debug: logging.debug('mysql connected') - except Exception as e: + except Exception: self.status = 'error' self.msg = 'Failed to connect to mysql databases' @@ -457,7 +452,7 @@ def __init__(self, **kwargs): if self.debug: logging.debug('here0') - + # POSTGRESQL @@ -482,10 +477,10 @@ def __init__(self, **kwargs): raise Exception(self.msg) - + # Get the query and query processing parameters (format, workspace, coordinate columns, etc.) - self.sql = None + self.sql = None if('query' in kwargs): self.sql = kwargs['query'] @@ -511,7 +506,7 @@ def __init__(self, **kwargs): if('filename' in kwargs): logging.debug(f'filename= {self.filename:s}') else: - logging.debug(f'filename= None') + logging.debug('filename= None') logging.debug(f'sql= {self.sql:s}') @@ -537,7 +532,7 @@ def __init__(self, **kwargs): try: self.maxrec = int(maxrecstr) - except Exception as e: + except Exception: self.msg = "Failed to convert input maxrec value [" + \ maxrecstr + "] to integer." @@ -550,7 +545,7 @@ def __init__(self, **kwargs): # Extract DB table name from query - self.dbtable = None + self.dbtable = None tn = TableNames() tables = tn.extract_tables(self.sql) @@ -623,12 +618,12 @@ def __init__(self, **kwargs): if self.debug: logging.debug('DD successfully retrieved.') - except Exception as e: + except Exception: if self.debug: logging.debug('dataDictionary retrieval failure.') - self.msg = f'dataDictionary retrieval failure.' + self.msg = 'dataDictionary retrieval failure.' raise Exception(self.msg) @@ -640,19 +635,19 @@ def __init__(self, **kwargs): # cursor = self.conn.cursor() - + if self.debug: logging.debug(f'sql = {self.sql:s}') logging.debug('call execut sql') self.msg = self.__executeSql__(cursor, self.sql, debug=1) - + if self.debug: logging.debug('returned executeSql') if len(self.msg) > 0: raise Exception(self.msg) - + # # Call writeResult @@ -667,6 +662,7 @@ def __init__(self, **kwargs): maxrec=self.maxrec, arraysize=self.arraysize, coldesc=self.coldesc, + compat=self.connectInfo.get('compat', frozenset()), racol=self.racol, deccol=self.deccol, dbms=self.dbms, \ @@ -700,10 +696,9 @@ def __executeSql__(self, cursor, sql, **kwargs): # { # - debug = 0 if('debug' in kwargs): - debug = kwargs['debug'] + kwargs['debug'] if self.debug: logging.debug('Enter executeSql') @@ -722,7 +717,7 @@ def __executeSql__(self, cursor, sql, **kwargs): try: cursor.execute(sql) - + except Exception as e: return_message = str(e).replace('"', "'") @@ -731,10 +726,10 @@ def __executeSql__(self, cursor, sql, **kwargs): try: self.conn.rollback() - - except Exception as e: + + except Exception: pass - + return return_message # diff --git a/TAP/taputil.py b/TAP/taputil.py index 9ec1e55..86c4c82 100644 --- a/TAP/taputil.py +++ b/TAP/taputil.py @@ -2,17 +2,15 @@ # This code is released with a BSD 3-clause license. License information is at # https://github.com/Caltech-IPAC/nexsciTAP/blob/master/LICENSE -import sys -import os import logging +import os +import sys -import pprint - -import configobj +from ADQL.adql import ADQL +from spatial_index import SpatialIndex from TAP.configparam import configParam -from ADQL.adql import ADQL -from spatial_index import SpatialIndex + class tapUtil: @@ -20,7 +18,7 @@ def __init__(self, **kwargs): self.conn = None - pid = os.getpid() + os.getpid() sys.tracebacklimit = 0 @@ -102,7 +100,7 @@ def __init__(self, **kwargs): socket = None if ('socket' in connectInfo): socket = connectInfo['socket'] - + port = None if ('port' in connectInfo): port = connectInfo['port'] @@ -173,7 +171,7 @@ def __init__(self, **kwargs): logging.debug('') logging.debug('connected to Oracle, DB ' + dbserver) - except Exception as e: + except Exception: msg = 'Failed to connect to cx_Oracle' raise Exception(msg) @@ -233,7 +231,7 @@ def __init__(self, **kwargs): logging.debug('') logging.debug('TAP_SCHEMA attached') - except Exception as e: + except Exception: msg = 'Failed to connect to SQLite3 databases' raise Exception(msg) @@ -241,9 +239,8 @@ def __init__(self, **kwargs): # MYSQL elif (dbms.lower() == 'mysql'): - - import msql.connector - + + try: if (dbserver is not None): @@ -254,7 +251,7 @@ def __init__(self, **kwargs): port=port, \ db=db ) - + elif (socket is not None): self.conn = mysql.connector.connect ( @@ -263,7 +260,7 @@ def __init__(self, **kwargs): unix_socket=socket, \ db=db ) - + else: msg = 'Failed to connect to mysql databases' raise Exception(msg) @@ -272,14 +269,14 @@ def __init__(self, **kwargs): logging.debug('') logging.debug('mysql connected') - except Exception as e: + except Exception: msg = 'Failed to connect to mysql databases' raise Exception(msg) if debug: logging.debug('') logging.debug('here0') - + else: msg = 'Invalid DBMS' raise Exception(msg) diff --git a/TAP/vositables.py b/TAP/vositables.py index 6a2cbf5..c6c0702 100644 --- a/TAP/vositables.py +++ b/TAP/vositables.py @@ -3,13 +3,10 @@ # https://github.com/Caltech-IPAC/nexsciTAP/blob/master/LICENSE -import sys -import os -import logging - -import datetime - import argparse +import logging +import os +import sys class vosiTables: @@ -27,7 +24,7 @@ class vosiTables: returnMsg = "" arraysize = 10000 - + # # DD columns # @@ -52,28 +49,28 @@ class vosiTables: coldesc = 0 nschema = 0 - + schema_namearr = [] schema_descarr = [] - - + + def __init__(self, **kwargs): - + # # { vosiTables.init method # """ - vosiTables class generates the VOSI compatible XML outputs for TAP - query with endpoint TAP/tables + vosiTables class generates the VOSI compatible XML outputs for TAP + query with endpoint TAP/tables Required keyword input parameters: - connectInfo (python dictionary): - a python dictionary containing info needed to make - a "connection" to a DBMS. - - ourpath(char): output table file path + connectInfo (python dictionary): + a python dictionary containing info needed to make + a "connection" to a DBMS. + + ourpath(char): output table file path Usage: @@ -86,13 +83,13 @@ def __init__(self, **kwargs): debug=debug) """ - self.dbms = '' + self.dbms = '' self.dbserver = '' self.userid = '' self.password = '' self.outpath = '' - self.debug = 0 - + self.debug = 0 + self.db = '' self.tap_schema = '' @@ -127,19 +124,19 @@ def __init__(self, **kwargs): # # Get keyword parameters # - + if ((self.dbms is None) or (len(self.dbms) == 0)): self.msg = 'Database name missing.' self.status = 'error' #self.__printVosiError__ (self.msg) - + raise Exception (self.msg) if self.debug: logging.debug('') logging.debug(f'dbms= {self.dbms:s}') - - + + if (self.dbms.lower() == 'oracle'): if ((self.dbserver is None) or (len(self.dbserver) == 0)): @@ -148,29 +145,29 @@ def __init__(self, **kwargs): #self.__printVosiError__ (self.msg) raise Exception (self.msg) - + if self.debug: logging.debug('') logging.debug(f'dbserver= {self.dbserver:s}') - + if ((self.userid is None) or (len(self.userid) == 0)): self.msg = 'Database userid missing.' self.status = 'error' #self.__printVosiError__ (self.msg) raise Exception (self.msg) - + if self.debug: logging.debug('') logging.debug(f'userid= {self.userid:s}') - + if ((self.password is None) or (len(self.password) == 0)): self.msg = 'Database password missing.' self.status = 'error' #self.__printVosiError__ (self.msg) raise Exception (self.msg) - + elif (self.dbms.lower() == 'sqlite3'): if ((self.db is None) or (len(self.db) == 0)): @@ -179,30 +176,30 @@ def __init__(self, **kwargs): #self.__printVosiError__ (self.msg) raise Exception (self.msg) - + if self.debug: logging.debug('') logging.debug(f'db= {self.db:s}') - + if ((self.tap_schema is None) or (len(self.tap_schema) == 0)): - + self.msg = 'Database table name missing.' self.status = 'error' #self.__printVosiError__ (self.msg) raise Exception (self.msg) - + if self.debug: logging.debug('') logging.debug(f'tap_schema= {self.tap_schema:s}') - + if ((len(self.outpath) == 0) or (self.outpath is None)): self.msg = 'Required output path is missing.' #self.__printVosiError__ (self.msg) raise Exception (self.msg) - + if self.debug: logging.debug('') logging.debug(f'outpath= {self.outpath:s}') @@ -210,7 +207,7 @@ def __init__(self, **kwargs): # # { Connect to DBMS # - if('connectInfo' in kwargs): + if('connectInfo' in kwargs): self.connectInfo = kwargs['connectInfo'] else: self.msg = 'Required connectInfo dict is missing.' @@ -236,7 +233,7 @@ def __init__(self, **kwargs): logging.debug('') logging.debug('connected to Oracle, DB ' + self.dbserver) - except Exception as e: + except Exception: self.status = 'error' self.msg = 'Failed to connect to cx_Oracle' @@ -303,7 +300,7 @@ def __init__(self, **kwargs): logging.debug('') logging.debug('TAP_SCHEMA attached') - except Exception as e: + except Exception: self.status = 'error' self.msg = 'Failed to connect to SQLite3 databases' @@ -321,10 +318,10 @@ def __init__(self, **kwargs): # # } end Connect to DBMS # - + # # start loop for constructing output: - # + # # -- vosi header: tableset tag, # -- query for schemas: schema tag, # -- for each schema, query tables: table tag, @@ -335,25 +332,25 @@ def __init__(self, **kwargs): # -- end table tag, # -- end schema tag # -- end tableset tag - + # # open ouput file in workdir for vosi table output # #vositblpath = self.workdir + '/vositable.xml' vositblpath = self.outpath - + if self.debug: logging.debug('') logging.debug(f'vositblpath = {vositblpath:s}') - + fp = None try: fp = open (vositblpath, 'w') os.chmod(vositblpath, 0o664) except Exception as e: - + if self.debug: logging.debug('') logging.debug(f'open voditblpath exception: {str(e):s}') @@ -394,7 +391,7 @@ def __init__(self, **kwargs): if self.debug: logging.debug('') logging.debug(f'executeSql exception: {str(e):s}') - + #self.__printVosiError__ (str(e)) raise Exception (str(e)) @@ -411,7 +408,7 @@ def __init__(self, **kwargs): self.__getSchemaQueryArr__ (cursor, \ debug=self.debug) - + if self.debug: logging.debug('') logging.debug('returned getSchemaDesc') @@ -425,9 +422,9 @@ def __init__(self, **kwargs): #self.__printVosiError__ (str(e)) raise Exception (str(e)) - + self.nschema = len (self.schema_namearr) - + if self.debug: logging.debug('') logging.debug(f'nschema= {self.nschema:d}') @@ -438,7 +435,7 @@ def __init__(self, **kwargs): logging.debug(f'{i:d} schema_name={self.schema_namearr[i]:s}') logging.debug(f'schema_description={self.schema_descarr[i]:s}') - for ischema in range (0, self.nschema): + for ischema in range (0, self.nschema): # # { ischema loop # @@ -450,9 +447,9 @@ def __init__(self, **kwargs): self.schema_namearr[ischema] + "\n") fp.write(" " + \ self.schema_descarr[ischema] + "\n") - fp.flush() - - + fp.flush() + + # # 2. execute table sql # @@ -478,7 +475,7 @@ def __init__(self, **kwargs): if self.debug: logging.debug('') logging.debug(f'executeSql exception: {str(e):s}') - + #self.__printVosiError__ (str(e)) raise Exception (str(e)) @@ -489,15 +486,15 @@ def __init__(self, **kwargs): logging.debug('') logging.debug('call getTableQueryArr') - # - # 3. getSqlresult of table sql + # + # 3. getSqlresult of table sql # re-initialize arrays # table_namearr = [] table_descarr = [] table_typearr = [] - + ntable = 0 try: ntable = self.__getTableQueryArr__(cursor, table_namearr, \ @@ -508,7 +505,7 @@ def __init__(self, **kwargs): if self.debug: logging.debug('') logging.debug(f'getTableQueryArr exception: {str(e):s}') - + #self.__printVosiError__ (str(e)) raise Exception (str(e)) @@ -517,9 +514,9 @@ def __init__(self, **kwargs): logging.debug('') logging.debug('returned getTableQueryArr') logging.debug(f'ntable= {ntable:d}') - - - for itable in range (0, ntable): + + + for itable in range (0, ntable): logging.debug ('') logging.debug (f'itable= {itable:d}') logging.debug ( @@ -528,15 +525,15 @@ def __init__(self, **kwargs): f'table_desc= {table_descarr[itable]:s}') logging.debug ( f'table_type= {table_typearr[itable]:s}') - + # # for each table: # - for itable in range (0, ntable): + for itable in range (0, ntable): # # { itable loop - # + # # 1. write header block for each table, # fp.write(" \n") - fp.flush() - + fp.flush() + # # } end itable loop # - + # # 2. write end block for each schema # fp.write(" \n") - fp.flush() + fp.flush() # # } end ischema loop and write end block for xml file # fp.write("\n") - fp.flush() - + fp.flush() + # # } end vosiTables.init # @@ -704,7 +701,7 @@ def __getTableQueryArr__ (self, cursor, table_namearr, table_descarr, \ # table_name, description, and table_type; # debug = 0 - + if('debug' in kwargs): debug = kwargs['debug'] @@ -712,7 +709,6 @@ def __getTableQueryArr__ (self, cursor, table_namearr, table_descarr, \ logging.debug('') logging.debug('Enter getTableQueryArr') - size = 0 table_colname = [] table_dbtype = [] @@ -754,7 +750,6 @@ def __getTableQueryArr__ (self, cursor, table_namearr, table_descarr, \ dbtype = '' - size = None dbdatatypestr = str(col[1]) @@ -815,7 +810,7 @@ def __getTableQueryArr__ (self, cursor, table_namearr, table_descarr, \ logging.debug(f'nrec = {nrec:d}') logging.debug('') - + for ll in range(0, nrec): # @@ -823,7 +818,7 @@ def __getTableQueryArr__ (self, cursor, table_namearr, table_descarr, \ # row = rows[ll] - + if debug: logging.debug ('') logging.debug (f'len(row)= {len(row):d}') @@ -847,13 +842,13 @@ def __getTableQueryArr__ (self, cursor, table_namearr, table_descarr, \ if debug: logging.debug (f'i= {i:d}, data= {data:s}') logging.debug (f'table_colname= {table_colname[i]:s}') - + if (table_colname[i].lower() == 'table_name'): table_namearr.append (data) - + elif (table_colname[i].lower() == 'description'): table_descarr.append (data) - + elif (table_colname[i].lower() == 'table_type'): table_typearr.append (data) @@ -865,7 +860,7 @@ def __getTableQueryArr__ (self, cursor, table_namearr, table_descarr, \ # # } end of ll loop - # + # if debug: logging.debug('') logging.debug(table_namearr) @@ -873,7 +868,7 @@ def __getTableQueryArr__ (self, cursor, table_namearr, table_descarr, \ logging.debug(table_typearr) logging.debug('----------------------------------------') - + ntot = ntot + nrec if(len(rows) < cursor.arraysize): @@ -893,11 +888,11 @@ def __getTableQueryArr__ (self, cursor, table_namearr, table_descarr, \ # - + def __getSchemaQueryArr__ (self, cursor, **kwargs): # # { vosiTables.getSchemaQueryArr - # + # # this query contains only two columns: # schema_name and schema_description # @@ -908,12 +903,12 @@ def __getSchemaQueryArr__ (self, cursor, **kwargs): if self.debug: logging.debug('') - logging.debug(f'Enter __getSchemaQueryArr__\n') + logging.debug('Enter __getSchemaQueryArr__\n') size = 0 nfetch = self.arraysize cursor.arraysize = nfetch - + if debug: logging.debug('') logging.debug(f'nfetch= {nfetch:d}') @@ -940,7 +935,7 @@ def __getSchemaQueryArr__ (self, cursor, **kwargs): # colname = str(col[0]).lower() - + if debug: logging.debug('') logging.debug(f'colname(lower) = {colname:s}') @@ -953,8 +948,6 @@ def __getSchemaQueryArr__ (self, cursor, **kwargs): dbdatatype = '' size = None - precision = None - scale = None dbdatatypestr = str(col[1]) @@ -1081,7 +1074,7 @@ def __getSchemaQueryArr__ (self, cursor, **kwargs): # row = rows[ll] - + if debug: logging.debug(row) logging.debug(f'row[0]= {row[0]:s}') @@ -1090,7 +1083,7 @@ def __getSchemaQueryArr__ (self, cursor, **kwargs): self.schema_namearr.append(row[0]) self.schema_descarr.append(row[1]) - + if debug: logging.debug(row) logging.debug(self.schema_namearr) @@ -1169,7 +1162,6 @@ def __writeOneTableResult__ (self, cursor, fp, **kwargs): # dbdatatype = '' - size = None dbdatatypestr = str(col[1]) @@ -1193,7 +1185,7 @@ def __writeOneTableResult__ (self, cursor, fp, **kwargs): # # } end extract dbdatatype from col_array: col[1] # - + i = i + 1 # @@ -1236,13 +1228,13 @@ def __writeOneTableResult__ (self, cursor, fp, **kwargs): # row = rows[ll] - data = '' + data = '' fp.write(" \n") - + col_name = '' for i in range(0, len(row)): - + if (row[i] is None): data = 'None' elif (dbtype[i] == 'STRING'): @@ -1262,65 +1254,65 @@ def __writeOneTableResult__ (self, cursor, fp, **kwargs): if self.debug: logging.debug('') logging.debug(f'll= {ll:d} col_name= {col_name:s} data= {data:s}') - + if (colnamearr[i] == 'column_name'): fp.write(" " + data + "\n") - + elif (colnamearr[i] == 'description'): fp.write(" " + \ "\n") - + elif (colnamearr[i] == 'datatype'): fp.write (" " + \ "" + \ data + "\n") - + elif (colnamearr[i] == 'arraysize'): if (data != 'None'): fp.write (" " + data + \ "\n") - + elif (colnamearr[i] == 'xtype'): if (data != 'None'): fp.write (" " + data + \ "\n") - + elif (colnamearr[i] == 'utype'): if (data != 'None'): fp.write(" " + data + \ "\n") - + elif (colnamearr[i] == 'unit'): if (data != 'None'): fp.write(" " + data + \ "\n") - + elif (colnamearr[i] == 'ucd'): if (data != 'None'): fp.write (" " + data + \ "\n") - + elif (colnamearr[i] == 'indexed'): if (data != 'None'): fp.write (" " + data + \ "\n") - + elif (colnamearr[i] == 'principal'): if (data != 'None' and data != 0 and data != '0'): fp.write(" " + \ "" + data + "\n") - + elif (colnamearr[i] == 'std'): if (data != 'None'): fp.write (" " + data + \ "\n") - + elif (colnamearr[i] == 'column_index'): if (data != 'None' and data != 0 and data != '0'): fp.write(" " + "" + data + \ "\n") - + irow = irow + 1 #if col_datatype == 'timestamp': @@ -1329,7 +1321,7 @@ def __writeOneTableResult__ (self, cursor, fp, **kwargs): # col_datatype = 'char' fp.write(" \n") - + # # } end of ll loop @@ -1338,7 +1330,7 @@ def __writeOneTableResult__ (self, cursor, fp, **kwargs): ntot = ntot + nrec if(len(rows) < cursor.arraysize): - break + break if self.debug: logging.debug('') @@ -1356,12 +1348,12 @@ def __writeOneTableResult__ (self, cursor, fp, **kwargs): def __writeForeignKey__ (self, cursor, fp, **kwargs): - + # # {vosiTables.writeForeignKey # debug = 0 - + if('debug' in kwargs): debug = kwargs['debug'] @@ -1406,7 +1398,6 @@ def __writeForeignKey__ (self, cursor, fp, **kwargs): # dbdatatype = '' - size = None dbdatatypestr = str(col[1]) @@ -1429,7 +1420,7 @@ def __writeForeignKey__ (self, cursor, fp, **kwargs): # # } end extract dbdatatype from col_array: col[1] # - + i = i + 1 # @@ -1468,16 +1459,15 @@ def __writeForeignKey__ (self, cursor, fp, **kwargs): # row = rows[ll] - - keyid = '' + targettbl = '' fromcol = '' targetcol= '' desc = '' - data = '' + data = '' for i in range(0, len(row)): - + if (row[i] is None): data = 'None' elif (dbtype[i] == 'STRING'): @@ -1490,7 +1480,7 @@ def __writeForeignKey__ (self, cursor, fp, **kwargs): logging.debug(f'i= {i:d}, data= {data:s}') if (i == 0): - keyid = data + pass elif (i == 1): targettbl = data elif (i == 2): @@ -1501,14 +1491,14 @@ def __writeForeignKey__ (self, cursor, fp, **kwargs): desc = data irow = irow + 1 - + # # for row, write column block # fp.write(" \n") fp.write(" " + targettbl + \ "\n") - + fp.write(" \n") fp.write(" " + fromcol + \ "\n") @@ -1527,7 +1517,7 @@ def __writeForeignKey__ (self, cursor, fp, **kwargs): ntot = ntot + nrec if(len(rows) < cursor.arraysize): - break + break if debug: logging.debug('') @@ -1587,7 +1577,7 @@ def __printVosiError__(self, errmsg): httphdr = "HTTP/1.1 500 ERROR\r" #print("HTTP/1.1 200 OK\r") - + print(httphdr) print("Content-type: text/xml\r") @@ -1630,19 +1620,19 @@ def main(): parser.add_argument('--dbserver', required=False, help='Oracle database server (dbserver) to use.') - + parser.add_argument('--userid', required=False, help='Oracle dbserver userid (userid) to use.') - + parser.add_argument('--password', required=False, help='Oracle dbserver (password) to use.') - + parser.add_argument('--db', required=False, help='Sqlite database name to use.') - + parser.add_argument('--tap_schema', required=False, help='Sqlite database table name to use.') - + parser.add_argument('--outpath', required=True, help='outpath for the result.') @@ -1664,47 +1654,47 @@ def main(): if (args.dbms is not None): dbms = args.dbms print (f'dbms= {dbms:s}') - + if (args.dbserver is not None): dbserver = args.dbserver print (f'dbserver= {dbserver:s}') - + if (args.userid is not None): userid = args.userid print (f'userid= {userid:s}') - - + + if (args.password is not None): password = args.password print (f'password= {password:s}') - - + + if (args.outpath is not None): outpath = args.outpath print (f'outpath= {outpath:s}') - - + + if (args.debug is not None): debug = args.debug print (f'debug= {debug:s}') - + if (args.db is not None): db = args.db print (f'db= {db:s}') - + if (args.tap_schema is not None): tap_schema = args.tap_schema print (f'tap_schema= {tap_schema:s}') - - + + if debug: debugfname = './vosi.debug' logging.basicConfig(filename=debugfname, \ level=logging.DEBUG) - print (f'debug turned on') - + print ('debug turned on') + logging.debug('') logging.debug('debug turned on') logging.debug(f'dbms= {dbms:s}') @@ -1715,13 +1705,12 @@ def main(): logging.debug(f'debug= {debug:s}') - vositbl = None try: if debug: logging.debug('') logging.debug('call vosiTables') - - vositbl = vosiTables (dbms=dbms, \ + + vosiTables (dbms=dbms, \ dbserver=dbserver, \ userid=userid, \ password=password, \ diff --git a/TAP/writeresult.py b/TAP/writeresult.py index 3489556..09aa869 100644 --- a/TAP/writeresult.py +++ b/TAP/writeresult.py @@ -13,9 +13,8 @@ # import logging -import datetime +from TAP import compat, writerecs -from TAP import writerecs class writeResult: @@ -70,7 +69,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): """ cursor: oracle query returned cursor containing data stream, workdir: work directory for output files, - filename: output file name. + filename: output file name. dd: data dictionary for the output columns, coldes(0/1): indictes whether to include column descriptions in the output file header @@ -103,8 +102,8 @@ def __init__(self, cursor, workdir, dd, **kwargs): logging.debug('') logging.debug('Enter writeResult:') logging.debug(f'self.debug= {self.debug:d}') - - + + self.cursor = cursor self.workdir = workdir self.dd = dd @@ -116,7 +115,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): if self.debug: logging.debug('') logging.debug(f'ncols_dd= {self.ncols_dd:d}') - + if('racol' in kwargs): self.racol = kwargs['racol'] @@ -125,7 +124,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): if('deccol' in kwargs): self.deccol = kwargs['deccol'] - + if (self.dd != None): self.ind_deccol = self.__getDDIndex__(self.dd, self.deccol) @@ -134,12 +133,12 @@ def __init__(self, cursor, workdir, dd, **kwargs): if('dbms' in kwargs): self.dbms = kwargs['dbms'] - + if self.debug: logging.debug('') logging.debug(f'dbms= {self.dbms:s}') - - + + if('format' in kwargs): self.format = kwargs['format'] @@ -149,11 +148,13 @@ def __init__(self, cursor, workdir, dd, **kwargs): if self.debug: logging.debug('') logging.debug('here0-2') - + self.coldesc = 0 if('coldesc' in kwargs): self.coldesc = kwargs['coldesc'] + self.compat = kwargs.get('compat', compat.NONE) + self.arraysize = 10000 if('arraysize' in kwargs): self.arraysize = kwargs['arraysize'] @@ -172,7 +173,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): logging.debug(f' arraysize = {self.arraysize:d}') self.status = '' - + # # open querypath for output # @@ -191,9 +192,9 @@ def __init__(self, cursor, workdir, dd, **kwargs): resulttbl = 'result.tsv' elif(self.format == 'json'): resulttbl = 'result.json' - + self.outpath = self.workdir + '/' + resulttbl - + if self.debug: pathstr = str(self.outpath) logging.debug('') @@ -220,14 +221,14 @@ def __init__(self, cursor, workdir, dd, **kwargs): logging.debug('------------------------------------------------') for desc in self.cursor.description: - name = desc[0] + desc[0] coltype = desc[1] - dispsize = desc[2] - internalsz = desc[3] + desc[2] + desc[3] precision = desc[4] - scale = desc[5] - nullok = desc[6] - + scale = desc[5] + desc[6] + # @@ -255,9 +256,9 @@ def __init__(self, cursor, workdir, dd, **kwargs): # varchr, sting -- char, # int, integer, long, etc.. -- int (32bit), # - # oracle: dbtype == NUMBER, check first batch of data to see if - # it is int or float: if any number in this batch is float, - # consider the column dtype as double + # oracle: dbtype == NUMBER, check first batch of data to see if + # it is int or float: if any number in this batch is float, + # consider the column dtype as double # # pgsql: dbtype == decimal, assume it is double # @@ -268,19 +269,19 @@ def __init__(self, cursor, workdir, dd, **kwargs): dbdatatype = None - precision = None + precision = None scale = None - size = None + size = None i = 0 for col in self.cursor.description: # # { cursor description for loop: - # + # # cursor description is a list with ncols tuples: # each tuple contains each output colname's name, datatype, - # dbdatatype, size, precision, scale but most DBMSs' - # implementation are incomplete, only name and dbdatatype + # dbdatatype, size, precision, scale but most DBMSs' + # implementation are incomplete, only name and dbdatatype # are reliably implemented. # @@ -317,14 +318,14 @@ def __init__(self, cursor, workdir, dd, **kwargs): if self.debug: logging.debug('') - logging.debug(f'analyze description array:') + logging.debug('analyze description array:') logging.debug(f'dbms= {self.dbms:s}') if (self.dbms.lower() == 'oracle'): # # { oracle datatype from descriptor - # + # if self.debug: logging.debug('') logging.debug('oracle:') @@ -341,7 +342,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): ind = dbdatatypestr.find("VARCHAR") if(ind != -1): dbdatatype = 'VARCHAR' - + ind = dbdatatypestr.find("STRING") if(ind != -1): dbdatatype = 'STRING' @@ -358,6 +359,12 @@ def __init__(self, cursor, workdir, dd, **kwargs): if(ind != -1): dbdatatype = 'TIMESTAMP' + # cx_Oracle 8+ reports DATE columns as DB_TYPE_DATE, which only + # matches 'DATE' above. Oracle DATE values carry a time of day, + # so treat them as DATETIME, as cx_Oracle 7 did. + if dbdatatype == 'DATE': + dbdatatype = 'DATETIME' + ind = dbdatatypestr.find("NUMBER") if(ind != -1): dbdatatype = 'NUMBER' @@ -369,7 +376,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): ind = dbdatatypestr.find("FLOAT") if(ind != -1): dbdatatype = 'FLOAT' - + if self.debug: logging.debug('') logging.debug(f'dbdatatype = {dbdatatype:s}') @@ -381,7 +388,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): elif (self.dbms.lower() == 'pgsql'): # # { pgsql datatype from descriptor - # + # if self.debug: logging.debug('') logging.debug('pgsql:') @@ -439,18 +446,18 @@ def __init__(self, cursor, workdir, dd, **kwargs): elif (self.dbms.lower() == 'mysql'): # # { mysql datatype from descriptor - # + # if self.debug: logging.debug('') - logging.debug(f'mysql:') + logging.debug('mysql:') logging.debug('col[1]:') logging.debug(col[1]) coltype = int(col[1]) - + if (coltype == 253): dbdatatype = 'VARCHAR' - + if (coltype == 3): dbdatatype = 'LONG' @@ -479,10 +486,10 @@ def __init__(self, cursor, workdir, dd, **kwargs): elif (self.dbms.lower() == 'sqlite3'): # # { mysql datatype from descriptor - # + # if self.debug: logging.debug('') - logging.debug(f'sqlite3:') + logging.debug('sqlite3:') dbdatatype = '' # @@ -495,14 +502,14 @@ def __init__(self, cursor, workdir, dd, **kwargs): logging.debug(f'dbdatatype = {dbdatatype:s}') logging.debug(f'size = {size:d}') - except Exception as e: - logging.debug(f'dbdatatype not set.') + except Exception: + logging.debug('dbdatatype not set.') pass if(col[2] is not None): size = int(col[2]) - + if self.debug: logging.debug(f'size= {size:d}') @@ -562,7 +569,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): try: if len(units) <= 0: units = '' - except Exception as e: + except Exception: units = '' isddcolarr.append(1) @@ -582,10 +589,10 @@ def __init__(self, cursor, workdir, dd, **kwargs): if self.debug: logging.debug('') - logging.debug(f'char type col in dd') + logging.debug('char type col in dd') logging.debug('size=') logging.debug(size) - + if (len(colname) > width): width = len(colname) @@ -671,7 +678,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): width = 80 if ((size is not None) and (size > 0)): width = size - + if(len(colname) > width): width = len(colname) @@ -840,7 +847,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): # { dbdatatype == numeric: this is pgsql oid=1700 special dtype, # if no dd, then we assume it is a double number, although # this dbdatatype is often used to represent a large integer. - # + # coltype = 'double' dbtype = dbdatatype @@ -856,7 +863,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): fmt = str(width) + '.14e' # - # } end dbdatatype == numeric + # } end dbdatatype == numeric # else: @@ -883,12 +890,12 @@ def __init__(self, cursor, workdir, dd, **kwargs): # # - # Special cases: if colname == 'RA' or 'DEC' use + # Special cases: if colname == 'RA' or 'DEC' use # racol, decol in dd # if (dd is not None): - + if(colname.lower() == 'ra'): if((dbdatatype == 'NUMBER') and \ @@ -933,7 +940,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): fmtarr.append(fmt) widtharr.append(width) unitsarr.append(units) - descarr.append(desc) + descarr.append(compat.field_description(self.compat, desc, coltype)) i = i + 1 @@ -948,7 +955,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): # to avoid excess data transfer) # - len_arr = len(namearr) + len(namearr) ddlist.append(namearr) ddlist.append(typearr) @@ -984,14 +991,14 @@ def __init__(self, cursor, workdir, dd, **kwargs): try: if self.debug: logging.debug('') - logging.debug(f'Calling writerecs()[1]:') - logging.debug(f'format: ' + str(self.format)) - logging.debug(f'ddlist: ' + str(ddlist)) - logging.debug(f'rowslist: ' + str(rowslist)) - logging.debug(f'ishdr: ' + str(self.ishdr)) - logging.debug(f'coldesc: ' + str(self.coldesc)) - logging.debug(f'overflow: ' + str(self.overflow)) - logging.debug(f'istail: ' + str(self.istail)) + logging.debug('Calling writerecs()[1]:') + logging.debug('format: ' + str(self.format)) + logging.debug('ddlist: ' + str(ddlist)) + logging.debug('rowslist: ' + str(rowslist)) + logging.debug('ishdr: ' + str(self.ishdr)) + logging.debug('coldesc: ' + str(self.coldesc)) + logging.debug('overflow: ' + str(self.overflow)) + logging.debug('istail: ' + str(self.istail)) istatus = writerecs.writerecs(self.outpath, self.format, ddlist, rowslist, self.ishdr, @@ -1074,7 +1081,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): #if (self.dbms.lower() == 'mysql'): # rows = cursor.fetchmany (self.cursor.arraysize) - #else: + #else: # rows = cursor.fetchmany(self.cursor.arraysize) rowslist = [] @@ -1109,14 +1116,14 @@ def __init__(self, cursor, workdir, dd, **kwargs): if self.debug: logging.debug('') - logging.debug(f'Calling writerecs()[2]:') - logging.debug(f'format: ' + str(self.format)) - logging.debug(f'ddlist: ' + str(ddlist)) - logging.debug(f'rowslist: ' + str(rowslist)) - logging.debug(f'ishdr: ' + str(self.ishdr)) - logging.debug(f'coldesc: ' + str(self.coldesc)) - logging.debug(f'overflow: ' + str(self.overflow)) - logging.debug(f'istail: ' + str(self.istail)) + logging.debug('Calling writerecs()[2]:') + logging.debug('format: ' + str(self.format)) + logging.debug('ddlist: ' + str(ddlist)) + logging.debug('rowslist: ' + str(rowslist)) + logging.debug('ishdr: ' + str(self.ishdr)) + logging.debug('coldesc: ' + str(self.coldesc)) + logging.debug('overflow: ' + str(self.overflow)) + logging.debug('istail: ' + str(self.istail)) istatus = writerecs.writerecs(self.outpath, self.format, ddlist, rowslist, self.ishdr, @@ -1139,7 +1146,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): raise Exception(str(e)) if self.debug: - logging.debug (f'return') + logging.debug ('return') logging.debug('') return @@ -1152,12 +1159,12 @@ def __init__(self, cursor, workdir, dd, **kwargs): if self.debug: logging.debug ( \ f'nrec = {nrec:d} and ibatch = {ibatch:d}') - logging.debug (f'break to write last batch of data') + logging.debug ('break to write last batch of data') break # - # } end batch > 0 + # } end batch > 0 # # # } end nrec = 0 @@ -1167,11 +1174,11 @@ def __init__(self, cursor, workdir, dd, **kwargs): # the column "description" block returned by the DBMS # does not give any datatypes. For those cases where # we don't have a priori schema information, we have to - # wait for the first data return to determine types. + # wait for the first data return to determine types. # This is complicated by the possibility that for some # records some columns are sometimes NULL (value 'None'). - # So while we don't want to scan all the data, we have + # So while we don't want to scan all the data, we have # to scan until we find some value for every column. if ((ibatch == 0) and (self.dbms.lower() == 'sqlite')): @@ -1246,7 +1253,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): # # {if pgsql dbtype = 'numeric', dtype will be decimal, # make it integer or float depending on the python type - # of typearr value + # of typearr value # dtype = type (row[i]).__name__ @@ -1292,12 +1299,12 @@ def __init__(self, cursor, workdir, dd, **kwargs): # - # For DBMSs like Oracle that store numbers as generic + # For DBMSs like Oracle that store numbers as generic # NUMBER type, we need to try to distinguish between - # int and double by checking the output row[i]'s + # int and double by checking the output row[i]'s # python datatype -- which might not be completely - # accurate so we check the whole column to make - # summary determination + # accurate so we check the whole column to make + # summary determination # if ((self.dbms.lower() == 'oracle') and \ (ibatch == 0) and \ @@ -1349,7 +1356,7 @@ def __init__(self, cursor, workdir, dd, **kwargs): if self.debug: logging.debug('----------------------------------------') - + # # The following block determines the NUMBER type ORACLE arrays's # datatype -- whether the column should be double or int based @@ -1418,21 +1425,21 @@ def __init__(self, cursor, workdir, dd, **kwargs): if self.debug: logging.debug('') - logging.debug(f'Calling writerecs()[3]:') - logging.debug(f'format: ' + str(self.format)) - logging.debug(f'ddlist: ' + str(ddlist)) - logging.debug(f'rowslist: ' + str(rowslist)) - logging.debug(f'ishdr: ' + str(self.ishdr)) - logging.debug(f'coldesc: ' + str(self.coldesc)) - logging.debug(f'overflow: ' + str(self.overflow)) - logging.debug(f'istail: ' + str(self.istail)) - logging.debug(f'cursor.arraysize' + str(self.cursor.arraysize)) + logging.debug('Calling writerecs()[3]:') + logging.debug('format: ' + str(self.format)) + logging.debug('ddlist: ' + str(ddlist)) + logging.debug('rowslist: ' + str(rowslist)) + logging.debug('ishdr: ' + str(self.ishdr)) + logging.debug('coldesc: ' + str(self.coldesc)) + logging.debug('overflow: ' + str(self.overflow)) + logging.debug('istail: ' + str(self.istail)) + logging.debug('cursor.arraysize' + str(self.cursor.arraysize)) istatus = writerecs.writerecs(self.outpath, self.format, ddlist, rowslist, self.ishdr, self.coldesc, self.overflow, self.istail) - + if self.debug: logging.debug('returned writerecs.writerecs') diff --git a/ctest/ctap.py b/ctest/ctap.py index baed052..76e2f04 100755 --- a/ctest/ctap.py +++ b/ctest/ctap.py @@ -3,10 +3,9 @@ # This example is as much documentation as processing. In a real # application we would only need parts of it. -from TAP.tapstore import tapStore -from TAP.taputil import tapUtil -from TAP.tapquery import tapQuery - +from TAP.tapquery import tapQuery +from TAP.tapstore import tapStore +from TAP.taputil import tapUtil # We may run into problems while connecting configuratio information, # translating the ADQL query into SQL, or submitting the user query @@ -42,7 +41,7 @@ def tapc(catalog, select_stmt, result_tbl): cursor = conn.cursor() cursor.execute('select count(*) from ' + catalog) rowcount = cursor.fetchone()[0] - + # But the main purpose of TAP is to run ADQL queries, including spatial constraints # and output formatting (output file type, column width, and number formatting). @@ -58,7 +57,7 @@ def tapc(catalog, select_stmt, result_tbl): # The returned 'query' object has several internal parameters but the ones most - # useful for further processing are the return status, the output file name + # useful for further processing are the return status, the output file name # (though this is just the name we put in above), and the return record count. if status == 'OK': @@ -66,5 +65,5 @@ def tapc(catalog, select_stmt, result_tbl): else: return status, msg, 0, 0 - except Exception as e: + except Exception: return 'ERROR', msg, 0, 0 diff --git a/ctest/dbtap.py b/ctest/dbtap.py index 84f5777..2fcfa19 100644 --- a/ctest/dbtap.py +++ b/ctest/dbtap.py @@ -1,6 +1,6 @@ -from TAP.tapstore import tapStore -from TAP.taputil import tapUtil -from TAP.tapquery import tapQuery +from TAP.tapquery import tapQuery +from TAP.tapstore import tapStore +from TAP.taputil import tapUtil dbutils = None dbconfig = None @@ -20,9 +20,9 @@ def dbinit(): dbconn = dbstore.getConn() dbadql = dbstore.getADQL() - except Exception as e: + except Exception: return 0 - + return 1 @@ -38,13 +38,13 @@ def dbquery(adql_string, filename): try: format = 'ipac' - cursor = dbconn.cursor() - + dbconn.cursor() + sql_string = dbadql.sql(adql_string) dbQuery = tapQuery(connectInfo=dbconfig.connectInfo, - conn=dbconn, - query=sql_string, + conn=dbconn, + query=sql_string, filename=filename, format=format) @@ -59,6 +59,6 @@ def dbquery(adql_string, filename): else: return status, msg, 0 - except Exception as e: + except Exception: return status, msg, 0 diff --git a/load_schema/load_schema.py b/load_schema/load_schema.py index 2f1a879..b16d782 100644 --- a/load_schema/load_schema.py +++ b/load_schema/load_schema.py @@ -1,7 +1,7 @@ #!/bin/env python -import sqlite3 import pprint +import sqlite3 pp = pprint.PrettyPrinter() @@ -29,11 +29,11 @@ # worked out once; from here on if we were to add tables we would just need # to add one "tables" record and its "columns" records. -# Finally, there are two more TAP_SCHEMA tables describing how foreign keys +# Finally, there are two more TAP_SCHEMA tables describing how foreign keys # in our tables relate the tables to each other (i.e., allow JOINS). Our one # real data table has no such keys but all these TAP_SCHEMA tables themselves # do have such relationships (and so must be included). However, unless we add -# new data tables which can join with each other, we never need to touch these +# new data tables which can join with each other, we never need to touch these # two tables again. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c952242 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" + +[tool.ruff] +line-length = 120 +target-version = "py39" +exclude = [ + "build", + "dist", + "docs", + "Sphinx", # Sphinx config conventionally puts imports below comments + "Jupyter", + "tests/fixtures", +] + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = [ + # Existing code style choices we're not touching in this pass: + "E501", # line too long — many long log strings and SQL literals + "E701", # multiple statements on one line — common in if-on-one-line style + "E711", # comparison to None — code uses `== None` extensively + "E712", # comparison to True/False — code uses `== True` extensively + "E722", # bare except — code uses bare except in many places + "E741", # ambiguous variable name — `l` used in several loops + # Pre-existing real bugs to address in a follow-up; intentionally not + # fixed in the CI-setup PR to keep the diff focused. + # See: TAP/taputil.py:221,223,227,246,251,261 (SQLite + MySQL branches) + # TAP/tap.py:2402 (`result` typo in a debug log path) + "F821", # undefined-name +] diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..f8c7ea5 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,25 @@ +pytest>=7 +# Pinned: ruff's default rule behaviour changes between releases, and an +# unpinned `ruff>=0.1` turned CI red on untouched code (W291 in +# TAP/vositables.py) the moment a new ruff shipped. Bump deliberately. +ruff==0.15.2 +requests + +# Runtime deps, repeated from setup.py's install_requires: the CI install +# uses --no-deps for nexsciTAP itself (to avoid the spatial_index PyPI +# hole), so they are listed here too. tests/test_install_requires.py +# keeps setup.py complete. +configobj +sqlparse +astropy +beautifulsoup4 +# tap.py asks BeautifulSoup for the 'lxml' tree builder by name when it +# reads a job status document (TAP/tap.py:904, 2162), so bs4 alone is not +# enough for the async flow. +lxml +xmltodict + +# pyneid is not currently used — tests/test_pyneid_compat.py is skipped +# at module level until async-flow fixture support lands. Listed as a +# reminder; comment back in when re-enabling. +# pyneid diff --git a/setup.py b/setup.py index a15cda0..c31d093 100644 --- a/setup.py +++ b/setup.py @@ -1,18 +1,22 @@ -import os -from setuptools import setup from distutils.extension import Extension -setup(name='nexsciTAP', - version='3.0.2', - author='John Good', - author_email='jcg@caltech.edu', - license='LICENSE', - keywords='astronomy database ADQL web-service', +from setuptools import setup + +setup(name='nexsciTAP', + version='3.1.0', + author='John Good', + author_email='jcg@caltech.edu', + license='LICENSE', + keywords='astronomy database ADQL web-service', url = 'https://github.com/Caltech-IPAC/nexsciTAP', - description='NExScI VO Table Access Protocol (TAP) web service', + description='NExScI VO Table Access Protocol (TAP) web service', long_description=open('README.md').read(), ext_modules=[Extension('TAP/writerecs', ['TAP/writerecsmodule.c'])], - install_requires=['ADQL', 'spatial_index', 'configobj'], + # Database drivers (cx_Oracle, psycopg2, mysql-connector) are not listed: + # each deployment installs the one for its DBMS. lxml is not imported, + # but BeautifulSoup is asked for it by name to read UWS job documents. + install_requires=['ADQL', 'spatial_index', 'configobj', 'sqlparse', + 'astropy', 'beautifulsoup4', 'lxml', 'xmltodict'], packages=['TAP'] ) diff --git a/tests/_helpers.py b/tests/_helpers.py new file mode 100644 index 0000000..60c32c1 --- /dev/null +++ b/tests/_helpers.py @@ -0,0 +1,44 @@ +"""Shared test utility functions. Not auto-discovered by pytest (no `test_` prefix).""" +from __future__ import annotations + + +def tap_sync_url(server: str) -> str: + """The TAP sync endpoint URL relative to *server*.""" + return f"{server}/cgi-bin/TAP/nph-tap.py/sync" + + +def tap_async_url(server: str) -> str: + """The TAP async endpoint URL relative to *server*.""" + return f"{server}/cgi-bin/TAP/nph-tap.py/async" + + +def row_count_from_ipac(body: str) -> int: + """Count data rows in an IPAC ASCII table body. + + IPAC format has 4 header lines (names, types, units, nulls) followed by + data rows. Empty trailing lines are ignored. + """ + lines = [L for L in body.splitlines() if L.strip()] + return max(0, len(lines) - 4) + + +def raw_get(server: str, path: str) -> bytes: + """GET *path* and return the response bytes exactly as sent (no parsing). + + HTTP libraries reject or repair a response with no status line, which is + the very thing the VOSI tests need to see. + """ + import socket + from urllib.parse import urlsplit + + u = urlsplit(server) + with socket.create_connection((u.hostname, u.port), timeout=30) as s: + s.sendall((f"GET {path} HTTP/1.1\r\nHost: {u.netloc}\r\n" + f"Connection: close\r\n\r\n").encode()) + chunks = [] + while True: + b = s.recv(65536) + if not b: + break + chunks.append(b) + return b"".join(chunks) diff --git a/tests/conftest.py b/tests/conftest.py index 4ebded9..10f57cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,258 @@ -"""Test setup for non-spatial security regressions on the 3.0.1 release.""" -import importlib.util +"""Shared pytest fixtures. + +Spins up a per-session HTTP server backed by SQLite fixtures and the +production CGI shim, so tests can exercise the full HTTP→CGI→TAP→DB pipeline. + +Layout under the session tmpdir: + + / + tap_schema.db — populated TAP_SCHEMA + test_data.db — data_l0, data_l1, test_access, test_users + TAP.conf — substituted from fixtures/TAP.conf.template + workdir/ — TAP_WORKDIR (request artifacts) + cgi-bin/ + TAP/ + nph-tap.py — copy of fixtures/nph-tap.py +""" +from __future__ import annotations + +import http.server +import os +import shutil +import socket import sys -from types import ModuleType - -if importlib.util.find_spec("spatial_index") is None: - # These tests use no spatial queries. Supply only the constants needed by - # TAP/ADQL imports when the deployment's native spatial package is absent. - spatial = ModuleType("spatial_index") - spatial.SpatialIndex = type("SpatialIndex", (), { - "HTM": 1, "HPX": 2, "BASE4": 10, "BASE10": 16, - }) - sys.modules["spatial_index"] = spatial +import threading +import time +from pathlib import Path + +import pytest + +TESTS_DIR = Path(__file__).parent +FIXTURES = TESTS_DIR / "fixtures" +STUBS = FIXTURES / "stubs" +sys.path.insert(0, str(FIXTURES)) +sys.path.insert(0, str(TESTS_DIR)) +sys.path.insert(0, str(STUBS)) + +import build_test_db # noqa: E402 + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture(scope="session") +def fixture_root(tmp_path_factory) -> Path: + """Build all the per-session artifacts and return the tmpdir root.""" + root = tmp_path_factory.mktemp("nexscitap-ci") + + build_test_db.build_all(str(root)) + + # tap.py resolves a VOSI request's workspace as /TAP and + # fails with a 500 if it is missing; a deployed service always has it. + (root / "workdir" / "TAP").mkdir(parents=True) + cgi_dir = root / "cgi-bin" / "TAP" + cgi_dir.mkdir(parents=True) + shutil.copy(FIXTURES / "nph-tap.py", cgi_dir / "nph-tap.py") + (cgi_dir / "nph-tap.py").chmod(0o755) + + return root + + +@pytest.fixture(scope="session") +def tap_conf(fixture_root: Path) -> Path: + """Materialize TAP.conf with absolute paths substituted in.""" + port = _free_port() + template = (FIXTURES / "TAP.conf.template").read_text() + conf = template.format( + TEST_WORKDIR=str(fixture_root / "workdir"), + TEST_HTTP_HOST="127.0.0.1", + TEST_HTTP_PORT=str(port), + TEST_DB_PATH=str(fixture_root / "test_data.db"), + TEST_TAP_SCHEMA=str(fixture_root / "tap_schema.db"), + ) + conf_path = fixture_root / "TAP.conf" + conf_path.write_text(conf) + # Stash the port so tap_server can reuse it + (fixture_root / ".port").write_text(str(port)) + return conf_path + + +class _NphCGIHandler(http.server.BaseHTTPRequestHandler): + """Minimal nph-CGI runner. + + The TAP CGI script writes its own HTTP status line (it's an `nph-` / + non-parsed-headers script in Apache parlance — see TAP/tap.py:755, 2538, + etc.). Python's stdlib CGIHTTPRequestHandler is not nph-aware: it always + prepends a `HTTP/1.1 200 Script output follows` status line, so the + script's status line ends up looking like a header to the client. + + This handler runs the CGI subprocess and forwards its raw stdout to the + socket without modification — exactly what an Apache nph- script does. + """ + + nph_script_path = "" # set by the tap_server fixture before serve_forever + + def do_GET(self): + self._run_cgi() + + def do_POST(self): + self._run_cgi() + + def do_DELETE(self): + self._run_cgi() + + def _run_cgi(self): + # Parse PATH_INFO and QUERY_STRING from self.path. + from urllib.parse import urlsplit + + parts = urlsplit(self.path) + # Two URL patterns are supported, both mapping to nph-tap.py: + # /cgi-bin/TAP/nph-tap.py/ — direct CGI URL + # /TAP/ — short form pyNEID uses, + # mimicking the Apache + # rewrite KOA/NEID/NEA + # run in production. + path = parts.path + marker = "/nph-tap.py" + if marker in path: + path_info = path.split(marker, 1)[1] + elif path.startswith("/TAP/"): + path_info = path[len("/TAP"):] # keep leading slash + else: + path_info = "" + + # Read request body (if any) for the subprocess stdin. + content_length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(content_length) if content_length else b"" + + env = os.environ.copy() + env.update({ + "GATEWAY_INTERFACE": "CGI/1.1", + "SERVER_PROTOCOL": "HTTP/1.1", + "SERVER_SOFTWARE": "test-nph-cgi/1.0", + "REQUEST_METHOD": self.command, + "QUERY_STRING": parts.query, + "PATH_INFO": path_info, + "SCRIPT_NAME": "/cgi-bin/TAP/nph-tap.py", + "REMOTE_ADDR": self.client_address[0], + "CONTENT_TYPE": self.headers.get("Content-Type", ""), + "CONTENT_LENGTH": str(content_length), + "HTTP_HOST": self.headers.get("Host", ""), + }) + # A server may carry its own TAP.conf (legacy_tap_server does); + # otherwise the process-wide TAP_CONF set by tap_server applies. + if getattr(self.server, "tap_conf", None): + env["TAP_CONF"] = self.server.tap_conf + + import subprocess + proc = subprocess.Popen( + [sys.executable, self.server.nph_script_path], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + stdout, stderr = proc.communicate(input=body) + + # A CGI that dies has only stderr to say so, and swallowing it + # makes a 500 from the script indistinguishable from a 500 the + # script meant to send. Surface it on the test runner's stderr. + if stderr: + sys.stderr.write(stderr.decode("utf-8", "replace")) + + # Forward raw subprocess stdout to the socket. The script is + # responsible for emitting a valid HTTP status line + headers. + try: + self.wfile.write(stdout) + self.wfile.flush() + except BrokenPipeError: + pass + + # Quieter logging during test runs. + def log_message(self, format, *args): + return + + +@pytest.fixture(scope="session") +def tap_server(fixture_root: Path, tap_conf: Path): + """Start http.server with the CGI handler. Yields the base URL.""" + port = int((fixture_root / ".port").read_text()) + + os.environ["TAP_CONF"] = str(tap_conf) + os.environ["TAP_PORT"] = str(port) + + # CGIHTTPRequestHandler runs each request as a subprocess; it inherits + # the parent's environment but not the in-process sys.path. Add the + # repo root to PYTHONPATH so `from TAP import tap` works in the CGI + # subprocess even if the package isn't pip-installed (CI installs the + # package; local pytest may not). + repo_root = TESTS_DIR.parent + paths = [str(repo_root), str(STUBS)] + existing = os.environ.get("PYTHONPATH", "") + if existing: + paths.append(existing) + os.environ["PYTHONPATH"] = os.pathsep.join(paths) + + original_cwd = os.getcwd() + os.chdir(fixture_root) + + httpd = http.server.HTTPServer(("127.0.0.1", port), _NphCGIHandler) + httpd.allow_reuse_address = True + httpd.nph_script_path = str(fixture_root / "cgi-bin" / "TAP" / "nph-tap.py") + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + + # Brief wait for the server to come up + for _ in range(50): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + break + except OSError: + time.sleep(0.05) + + base_url = f"http://127.0.0.1:{port}" + try: + yield base_url + finally: + httpd.shutdown() + httpd.server_close() + os.chdir(original_cwd) + + +@pytest.fixture(scope="session") +def legacy_tap_server(fixture_root: Path, tap_server: str): + """The same SQLite data behind NEA's 1.x TAP.conf layout: compat mode on. + + Depends on tap_server for PYTHONPATH and the working directory it sets. + """ + port = _free_port() + workdir = fixture_root / "workdir-legacy" + (workdir / "TAP").mkdir(parents=True, exist_ok=True) # as in fixture_root + conf = (FIXTURES / "TAP.conf.legacy.template").read_text().format( + TEST_WORKDIR=str(workdir), + TEST_HTTP_HOST="127.0.0.1", + TEST_HTTP_PORT=str(port), + TEST_DB_PATH=str(fixture_root / "test_data.db"), + TEST_TAP_SCHEMA=str(fixture_root / "tap_schema.db"), + ) + conf_path = fixture_root / "TAP.legacy.conf" + conf_path.write_text(conf) + httpd = http.server.HTTPServer(("127.0.0.1", port), _NphCGIHandler) + httpd.allow_reuse_address = True + httpd.nph_script_path = str(fixture_root / "cgi-bin" / "TAP" / "nph-tap.py") + httpd.tap_conf = str(conf_path) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + for _ in range(50): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + break + except OSError: + time.sleep(0.05) + try: + yield f"http://127.0.0.1:{port}" + finally: + httpd.shutdown() diff --git a/tests/fixtures/TAP.conf.legacy.template b/tests/fixtures/TAP.conf.legacy.template new file mode 100644 index 0000000..8d414c3 --- /dev/null +++ b/tests/fixtures/TAP.conf.legacy.template @@ -0,0 +1,23 @@ +# NEA's 1.x TAP.conf layout ([webserver] + [sqlite3] + ADQL_*), for the +# compat-mode tests. Same SQLite data as TAP.conf.template; placeholders +# are filled by the legacy_tap_server fixture in tests/conftest.py. +[webserver] +DBMS=sqlite3 +TAP_WORKDIR={TEST_WORKDIR} +TAP_WORKURL=/workspace +HTTP_URL=http://{TEST_HTTP_HOST} +HTTP_PORT={TEST_HTTP_PORT} +CGI_PGM=TAP +RACOL=ra +DECCOL=dec +ADQL_MODE=HTM +ADQL_LEVEL=20 +ADQL_XCOL=x +ADQL_YCOL=y +ADQL_ZCOL=z +ADQL_COLNAME=sptind +ADQL_ENCODING=BASE10 + +[sqlite3] +DB={TEST_DB_PATH} +TAP_SCHEMA={TEST_TAP_SCHEMA} diff --git a/tests/fixtures/TAP.conf.template b/tests/fixtures/TAP.conf.template new file mode 100644 index 0000000..6c2c4a9 --- /dev/null +++ b/tests/fixtures/TAP.conf.template @@ -0,0 +1,50 @@ +# TAP.conf template for CI tests. +# +# The pytest `tap_conf` fixture in tests/conftest.py substitutes the +# placeholders below with absolute paths into the per-session tmpdir. +# +# Placeholders: +# {TEST_WORKDIR} — working dir for TAP request artifacts +# {TEST_HTTP_HOST} — 127.0.0.1 +# {TEST_HTTP_PORT} — fixture-allocated port +# {TEST_DB_PATH} — absolute path to test_data.db +# {TEST_TAP_SCHEMA} — absolute path to tap_schema.db + +[WEB] + TAP_WORKDIR={TEST_WORKDIR} + TAP_WORKURL=/workspace + + # configparam appends HTTP_PORT to HTTP_URL for any port other than + # 80/443, and that composed value is what the async status and result + # URLs are built from. Keep the two apart here or the job URLs come + # back with the port twice. + HTTP_URL=http://{TEST_HTTP_HOST} + HTTP_PORT={TEST_HTTP_PORT} + CGI_PGM=TAP + + +# Default configuration instance — no `instance=` HTTP parameter needed. +[DBMS] + DBMS=sqlite3 + DB={TEST_DB_PATH} + TAP_SCHEMA={TEST_TAP_SCHEMA} + TAP_SCHEMA_FILE=TAP_SCHEMA + + # Propfilter setup — exercises the propflag guard added in PR #17. + ACCESS_TBL=test_access + USERS_TBL=test_users + FILEID=l0filename + ACCESSID=program + PROPFILTER=neid + RACOL=ra + DECCOL=dec + + +[SPTIND] + MODE=HTM + LEVEL=20 + XCOL=x + YCOL=y + ZCOL=z + COLNAME=sptind + ENCODING=BASE10 diff --git a/tests/fixtures/build_test_db.py b/tests/fixtures/build_test_db.py new file mode 100644 index 0000000..caa40ab --- /dev/null +++ b/tests/fixtures/build_test_db.py @@ -0,0 +1,344 @@ +"""Build the SQLite test fixtures: tap_schema.db and test_data.db. + +Used by the pytest `test_db` session fixture in tests/conftest.py. Can also +be run standalone for local inspection: + + python tests/fixtures/build_test_db.py /tmp/fixture-out +""" +from __future__ import annotations + +import os +import sqlite3 +import sys +from datetime import datetime, timedelta + + +# ---------------------------------------------------------------- TAP_SCHEMA + +TAP_SCHEMA_DDL = [ + """ + CREATE TABLE IF NOT EXISTS schemas ( + schema_name TEXT NOT NULL, + utype TEXT, + description TEXT, + schema_index INTEGER, + PRIMARY KEY (schema_name) + ) + """, + """ + CREATE TABLE IF NOT EXISTS tables ( + schema_name TEXT NOT NULL, + table_name TEXT NOT NULL, + table_type TEXT, + utype TEXT, + description TEXT, + table_index INTEGER, + PRIMARY KEY (table_name) + ) + """, + """ + CREATE TABLE IF NOT EXISTS columns ( + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + datatype TEXT, + arraysize TEXT, + size INTEGER, + description TEXT, + utype TEXT, + unit TEXT, + ucd TEXT, + indexed INTEGER, + principal INTEGER, + std INTEGER, + column_index INTEGER, + PRIMARY KEY (table_name, column_name) + ) + """, + """ + CREATE TABLE IF NOT EXISTS keys ( + key_id TEXT NOT NULL, + from_table TEXT, + target_table TEXT, + description TEXT, + utype TEXT, + PRIMARY KEY (key_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS key_columns ( + key_id TEXT NOT NULL, + from_column TEXT, + target_column TEXT, + PRIMARY KEY (key_id, from_column) + ) + """, +] + +SCHEMAS = [ + ("test", "Test data for nexsciTAP CI", 1), + ("TAP_SCHEMA", "Standard TAP schema description.", 2), +] + +# Only data_l0 and data_l1 are reachable as query targets; test_access and +# test_users are propfilter-internal tables that the validator allows in +# subqueries by way of the ACCESS_TBL/USERS_TBL config entries. +TABLES = [ + # (schema, table, description) + ("test", "data_l0", "Public-data test table (propint=0)."), + ("test", "data_l1", "Proprietary test table (mix of embargoed and released)."), + ("test", "test_access", "Propfilter access table — internal."), + ("test", "test_users", "Propfilter users table — internal."), + ("TAP_SCHEMA", "schemas", "TAP_SCHEMA.schemas."), + ("TAP_SCHEMA", "tables", "TAP_SCHEMA.tables."), + ("TAP_SCHEMA", "columns", "TAP_SCHEMA.columns."), + ("TAP_SCHEMA", "keys", "TAP_SCHEMA.keys."), + ("TAP_SCHEMA", "key_columns", "TAP_SCHEMA.key_columns."), +] + +# Columns for data_l0 and data_l1 — shape mirrors NEID's neidl0/neidl1 enough +# to drive the propfilter code paths and the UI's CASE-WHEN download-link +# query pattern. Keep small; only the columns used by tests need to exist. +DATA_L0_COLUMNS = [ + ("data_l0", "obsdate", "char", "DATE", "Observation date."), + ("data_l0", "obsjd", "double", None, "Julian date of observation."), + ("data_l0", "program", "char", None, "Program ID."), + ("data_l0", "piname", "char", None, "PI name."), + ("data_l0", "obsmode", "char", None, "Observation mode."), + ("data_l0", "obstype", "char", None, "Sci or Cal."), + ("data_l0", "l0propint", "int", "months", "Proprietary interval (months)."), + ("data_l0", "l0filename", "char", None, "Filename."), + ("data_l0", "l0filehand", "char", None, "File handle."), + ("data_l0", "l0filepath", "char", None, "File path."), + ("data_l0", "object", "char", None, "Object name."), + ("data_l0", "qobject", "char", None, "Resolved object."), + ("data_l0", "qra", "char", "deg", "Query RA."), + ("data_l0", "qdec", "char", "deg", "Query Dec."), + ("data_l0", "ra", "double", "deg", "RA."), + ("data_l0", "dec", "double", "deg", "Dec."), +] +DATA_L1_COLUMNS = [(("data_l1",) + c[1:]) for c in DATA_L0_COLUMNS] +# Rename the l0-prefixed columns to l1-prefixed for data_l1 +DATA_L1_COLUMNS = [ + (t, name.replace("l0", "l1") if name.startswith("l0") else name, *rest) + for (t, name, *rest) in DATA_L1_COLUMNS +] + +# Minimal column metadata for the propfilter-internal and TAP_SCHEMA tables +# — TableValidator looks at table names not column names, but the data +# dictionary code path queries `select * from tap_schema.columns where +# table_name = ?` so the rows must exist. +ACCESS_COLUMNS = [ + ("test_access", "userid", "char", None, "User ID."), + ("test_access", "program", "char", None, "Program ID accessible to userid."), +] +USERS_COLUMNS = [ + ("test_users", "userid", "char", None, "User ID."), + ("test_users", "cookie", "char", None, "Session cookie."), +] +SELF_COLUMNS = [ + ("schemas", "schema_name", "char", None, ""), + ("tables", "schema_name", "char", None, ""), + ("tables", "table_name", "char", None, ""), + ("columns", "table_name", "char", None, ""), + ("columns", "column_name", "char", None, ""), + ("columns", "datatype", "char", None, ""), + ("columns", "unit", "char", None, ""), + ("columns", "description", "char", None, ""), +] + + +def build_tap_schema(path: str) -> None: + """Populate tap_schema.db at *path*.""" + if os.path.exists(path): + os.remove(path) + conn = sqlite3.connect(path) + cur = conn.cursor() + for ddl in TAP_SCHEMA_DDL: + cur.execute(ddl) + cur.executemany( + "INSERT INTO schemas (schema_name, description, schema_index) VALUES (?, ?, ?)", + SCHEMAS, + ) + # TableValidator expects table_name values to be schema-qualified + # (e.g. "TAP_SCHEMA.tables", not just "tables") so it can populate its + # allowed_schemas set from the dotted prefix. See tablevalidator.py:50-60. + qualified_tables = [ + (schema, f"{schema}.{table}", description) + for (schema, table, description) in TABLES + ] + cur.executemany( + "INSERT INTO tables (schema_name, table_name, description) VALUES (?, ?, ?)", + qualified_tables, + ) + all_cols = DATA_L0_COLUMNS + DATA_L1_COLUMNS + ACCESS_COLUMNS + USERS_COLUMNS + SELF_COLUMNS + cur.executemany( + """INSERT INTO columns (table_name, column_name, datatype, unit, description) + VALUES (?, ?, ?, ?, ?)""", + all_cols, + ) + conn.commit() + conn.close() + + +# ---------------------------------------------------------------- data tables + +def build_test_data(path: str) -> None: + """Populate test_data.db with the data_l0, data_l1, test_access, test_users tables.""" + if os.path.exists(path): + os.remove(path) + conn = sqlite3.connect(path) + cur = conn.cursor() + + # data_l0 — public (l0propint = 0). 6 rows across 2 programs. + cur.execute(""" + CREATE TABLE data_l0 ( + obsdate TEXT, + obsjd REAL, + program TEXT, + piname TEXT, + obsmode TEXT, + obstype TEXT, + l0propint INTEGER, + l0filename TEXT, + l0filehand TEXT, + l0filepath TEXT, + object TEXT, + qobject TEXT, + qra TEXT, + qdec TEXT, + ra REAL, + dec REAL + ) + """) + + now = datetime.now() + l0_rows = [] + for i in range(6): + d = now - timedelta(days=10 + i) + prog = "TEST-A" if i % 2 == 0 else "TEST-B" + l0_rows.append(( + d.strftime("%Y-%m-%d %H:%M:%S"), # obsdate + 2460000.0 + i, # obsjd + prog, # program + "Test PI", # piname + "hr", # obsmode + "Cal" if i < 2 else "Sci", # obstype + 0, # l0propint — PUBLIC + f"testL0_2026{i:04d}.fits", # l0filename + f"/handle/{i}", # l0filehand + f"/data/L0/testL0_2026{i:04d}.fits", # l0filepath + f"OBJ-{i}", # object + f"OBJ-{i}", # qobject + "10.0", # qra + "20.0", # qdec + 10.0, # ra + 20.0, # dec + )) + cur.executemany( + "INSERT INTO data_l0 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", l0_rows + ) + + # data_l1 — proprietary mix. Critical fixture invariant: at least some + # rows must be STILL EMBARGOED (today < add_months(obsdate, l1propint)) + # so propfilter has something to filter out. Without this, propflag=0 + # vs propflag=1 returns identical counts and the security test can't + # detect a regression. + cur.execute(""" + CREATE TABLE data_l1 ( + obsdate TEXT, + obsjd REAL, + program TEXT, + piname TEXT, + obsmode TEXT, + obstype TEXT, + l1propint INTEGER, + l1filename TEXT, + l1filehand TEXT, + l1filepath TEXT, + object TEXT, + qobject TEXT, + qra TEXT, + qdec TEXT, + ra REAL, + dec REAL + ) + """) + l1_rows = [] + # 3 public rows (propint=0) + for i in range(3): + d = now - timedelta(days=30 + i) + l1_rows.append(( + d.strftime("%Y-%m-%d %H:%M:%S"), 2460500.0 + i, + "TEST-PUBLIC", "PI Public", "hr", "Sci", 0, + f"testL1_public_{i:02d}.fits", f"/h/p/{i}", f"/data/L1/p{i}.fits", + "STAR", "STAR", "10.0", "20.0", 10.0, 20.0, + )) + # 3 expired-embargo rows (propint=12 months, obsdate > 2 years ago) + for i in range(3): + d = now - timedelta(days=900 + i) + l1_rows.append(( + d.strftime("%Y-%m-%d %H:%M:%S"), 2459500.0 + i, + "TEST-EXPIRED", "PI Expired", "hr", "Sci", 12, + f"testL1_expired_{i:02d}.fits", f"/h/e/{i}", f"/data/L1/e{i}.fits", + "STAR", "STAR", "10.0", "20.0", 10.0, 20.0, + )) + # 4 STILL-EMBARGOED rows (propint=24 months, obsdate < 6 months ago) + for i in range(4): + d = now - timedelta(days=60 + i * 10) + l1_rows.append(( + d.strftime("%Y-%m-%d %H:%M:%S"), 2461000.0 + i, + "TEST-PROPRIETARY", "PI Proprietary", "hr", "Sci", 24, + f"testL1_embargoed_{i:02d}.fits", f"/h/x/{i}", f"/data/L1/x{i}.fits", + "STAR", "STAR", "10.0", "20.0", 10.0, 20.0, + )) + cur.executemany( + "INSERT INTO data_l1 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", l1_rows + ) + + # test_access — propfilter access table. Maps userid -> program. + cur.execute(""" + CREATE TABLE test_access ( + userid TEXT, + program TEXT + ) + """) + cur.executemany( + "INSERT INTO test_access VALUES (?, ?)", + [ + ("testuser", "TEST-PROPRIETARY"), + ], + ) + + # test_users — propfilter users table. Holds session token mapping. + cur.execute(""" + CREATE TABLE test_users ( + userid TEXT, + cookie TEXT + ) + """) + cur.executemany( + "INSERT INTO test_users VALUES (?, ?)", + [ + ("testuser", "fake-test-cookie"), + ], + ) + + conn.commit() + conn.close() + + +def build_all(out_dir: str) -> dict: + """Build both fixtures under *out_dir*. Returns {'tap_schema': path, 'test_data': path}.""" + os.makedirs(out_dir, exist_ok=True) + tap_schema_path = os.path.join(out_dir, "tap_schema.db") + test_data_path = os.path.join(out_dir, "test_data.db") + build_tap_schema(tap_schema_path) + build_test_data(test_data_path) + return {"tap_schema": tap_schema_path, "test_data": test_data_path} + + +if __name__ == "__main__": + out = sys.argv[1] if len(sys.argv) > 1 else "." + paths = build_all(out) + for k, v in paths.items(): + print(f"{k}: {v}") diff --git a/tests/fixtures/nph-tap.py b/tests/fixtures/nph-tap.py new file mode 100644 index 0000000..3912999 --- /dev/null +++ b/tests/fixtures/nph-tap.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +"""CGI entry point for the CI test fixture. + +Matches the production CGI shim documented at Sphinx/install.rst. The pytest +`tap_server` fixture in tests/conftest.py serves this file via +http.server.CGIHTTPRequestHandler with TAP_CONF set to the fixture's TAP.conf. +""" +from TAP import tap + +tap.Tap() diff --git a/tests/fixtures/stubs/spatial_index.py b/tests/fixtures/stubs/spatial_index.py new file mode 100644 index 0000000..356c803 --- /dev/null +++ b/tests/fixtures/stubs/spatial_index.py @@ -0,0 +1,22 @@ +"""Minimal stub of the spatial_index package for CI use. + +The real `spatial_index` package on PyPI only ships wheels for Python ≤ 3.8, +so the CI matrix (3.9 / 3.10 / 3.11 / 3.12) can't install it. TAP imports +the package unconditionally for four module-level constants — that's all +the surface we exercise in tests, since no test query uses spatial +geometry functions. + +If/when a test starts exercising spatial queries (POINT, CIRCLE, CONTAINS, +DISTANCE), this stub will need to grow or the real package needs to be +installed. + +Tracking the upstream issue: spatial-index 1.1.0 on PyPI lacks a source +distribution and modern-Python wheels. +""" + + +class SpatialIndex: + HTM = 1 + HPX = 2 + BASE4 = 10 + BASE10 = 16 diff --git a/tests/test_async_flow.py b/tests/test_async_flow.py new file mode 100644 index 0000000..b8e2201 --- /dev/null +++ b/tests/test_async_flow.py @@ -0,0 +1,212 @@ +"""End-to-end coverage of the UWS async flow. + +An async TAP job takes three round trips, the same sequence pyvo and +pyNEID drive: + +1. POST to ``/async`` with the query. The server creates the job in + PENDING and answers 303 with ``Location: ``. +2. POST ``PHASE=RUN`` to ``/phase``. The server starts the + job and answers 303 to the same status URL. +3. GET ```` until the phase reaches COMPLETED, then read the + result named in the status document. + +None of this could be covered before the detach fix. ``tap.py`` ended +every async response by SIGKILLing ``os.getppid()`` — under this fixture +that is the process running the test suite, and in production it is the +web server child serving the request. Step 2 therefore either hung (the +CGI never closed the stdout pipe the server was reading) or took the +test runner down with it. +""" +from __future__ import annotations + +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +import requests +from _helpers import tap_async_url + +UWS = {"uws": "http://www.ivoa.net/xml/UWS/v1.0", + "xlink": "http://www.w3.org/1999/xlink"} + +QUERY = "select * from data_l0" + + +def _submit(server: str) -> str: + """Round trip 1. Returns the job's status URL.""" + resp = requests.post( + tap_async_url(server), + data={"query": QUERY, "format": "ipac"}, + allow_redirects=False, + timeout=30, + ) + assert resp.status_code == 303, resp.text[:500] + statusurl = resp.headers["Location"] + assert statusurl, "303 carried no Location header" + return statusurl + + +def _status(statusurl: str) -> ET.Element: + resp = requests.get(statusurl, timeout=30) + assert resp.status_code == 200, resp.text[:500] + return ET.fromstring(resp.text) + + +def _phase(statusurl: str) -> str: + return _status(statusurl).find("uws:phase", UWS).text + + +def test_async_submit_creates_pending_job(tap_server): + statusurl = _submit(tap_server) + assert _phase(statusurl) == "PENDING" + + +def test_async_run_starts_the_job(tap_server): + """Round trip 2 answers promptly and does not take the server with it.""" + statusurl = _submit(tap_server) + + resp = requests.post( + f"{statusurl}/phase", + data={"PHASE": "RUN"}, + allow_redirects=False, + timeout=30, + ) + + assert resp.status_code == 303, resp.text[:500] + assert resp.headers["Location"] == statusurl + + +def test_async_run_response_is_self_delimiting(tap_server): + """The 303 must be framed by its headers, not by the connection dying. + + A reverse proxy in front of the CGI needs a Content-Length (or a + chunked body) to relay the response. The old code emitted a body + with neither and relied on the connection being destroyed to mark + the end of the message. + """ + statusurl = _submit(tap_server) + + resp = requests.post( + f"{statusurl}/phase", + data={"PHASE": "RUN"}, + allow_redirects=False, + timeout=30, + ) + + assert "Content-Length" in resp.headers + assert int(resp.headers["Content-Length"]) == len(resp.content) + + +def test_async_job_runs_to_completion(tap_server, fixture_root: Path): + statusurl = _submit(tap_server) + requests.post( + f"{statusurl}/phase", + data={"PHASE": "RUN"}, + allow_redirects=False, + timeout=30, + ) + + deadline = time.time() + 30 + phase = "" + while time.time() < deadline: + phase = _phase(statusurl) + if phase in ("COMPLETED", "ERROR", "ABORTED"): + break + time.sleep(0.2) + + assert phase == "COMPLETED", f"job ended in phase {phase}" + + job = _status(statusurl) + href = job.find("uws:results/uws:result", UWS).get( + "{http://www.w3.org/1999/xlink}href") + assert href + + # The fixture server does not serve TAP_WORKURL (Apache does that in + # production), so read the result out of the workspace on disk. + # tap.py puts each job under /TAP/. + workspace = job.find("uws:jobId", UWS).text + results = list((fixture_root / "workdir" / "TAP" / workspace).glob("*")) + assert results, f"no result artifacts in workspace {workspace}" + written = [p for p in results if p.name != "status.xml" and p.stat().st_size] + assert written, f"job completed but wrote no result file: {results}" + + +def _post_phase(statusurl: str, phase: str): + return requests.post( + f"{statusurl}/phase", + data={"PHASE": phase}, + allow_redirects=False, + timeout=30, + ) + + +def _results(fixture_root: Path, workspace: str) -> list: + workdir = fixture_root / "workdir" / "TAP" / workspace + return [p for p in workdir.glob("*") if p.name != "status.xml"] + + +def test_async_abort_is_terminal(tap_server, fixture_root: Path): + """ABORT must end the job, not run it. + + Every phase shares one response path at the end of the async block, + and that path used to fall through into the query. So a POST of + PHASE=ABORT wrote ABORTED, answered the client, and then ran the + job anyway, overwriting the phase the client had just been told. + """ + statusurl = _submit(tap_server) + assert _phase(statusurl) == "PENDING" + + resp = _post_phase(statusurl, "ABORT") + assert resp.status_code == 303, resp.text[:500] + + job = _status(statusurl) + assert job.find("uws:phase", UWS).text == "ABORTED" + + workspace = job.find("uws:jobId", UWS).text + time.sleep(1.0) + assert _phase(statusurl) == "ABORTED", "the aborted job kept running" + assert not _results(fixture_root, workspace), \ + "the aborted job wrote a result" + + +def test_async_unknown_phase_is_terminal(tap_server, fixture_root: Path): + """An unrecognized phase reports ERROR and runs nothing.""" + statusurl = _submit(tap_server) + + resp = _post_phase(statusurl, "SPIN") + assert resp.status_code == 303, resp.text[:500] + + job = _status(statusurl) + assert job.find("uws:phase", UWS).text == "ERROR" + + workspace = job.find("uws:jobId", UWS).text + time.sleep(1.0) + assert _phase(statusurl) == "ERROR", "the rejected job kept running" + assert not _results(fixture_root, workspace), \ + "the rejected job wrote a result" + + +def test_async_detach_does_not_signal_the_web_server(): + """Regression guard against the SIGKILL-the-parent detach. + + Asserted on the source rather than on behavior on purpose: the + behavioral assertion is "the process running this suite is still + alive", which a suite that has been SIGKILLed cannot make. + """ + import tokenize + + from TAP import tap + + # Comments and strings are stripped before the check so the code can + # still explain in prose why it no longer signals its parent. + with open(tap.__file__, "rb") as fp: + src = "".join( + tok.string + for tok in tokenize.tokenize(fp.readline) + if tok.type not in (tokenize.COMMENT, tokenize.STRING)) + + assert "getppid" not in src, ( + "tap.py signals its parent process. Under CGI the parent is the " + "web server child serving the request; killing it drops the " + "response and, behind a proxy, poisons the upstream connection." + ) diff --git a/tests/test_compat_config.py b/tests/test_compat_config.py new file mode 100644 index 0000000..acc7ad5 --- /dev/null +++ b/tests/test_compat_config.py @@ -0,0 +1,160 @@ +"""Compat mode's config side: COMPAT names and the 1.x TAP.conf translator.""" +import pytest + +from TAP import compat +from TAP.configparam import configParam + +LEGACY_ORACLE = """[webserver] +DBMS=oracle +TAP_WORKDIR={work}/ +TAP_WORKURL=/workspace/ +HTTP_PORT=80 +COOKIENAME=tapcookie +CGI_PGM=/TAP +RACOL=ra +DECCOL=dec +# Spatial indexing settings +ADQL_MODE=HTM +ADQL_LEVEL=20 +ADQL_XCOL=x +ADQL_YCOL=y +ADQL_ZCOL=z +ADQL_COLNAME=htm20 +ADQL_ENCODING=BASE10 +HTTP_URL=https://exoplanetarchive.ipac.caltech.edu +[oracle] +ServerName=tapdb +UserID=exo_tap +Password=secret +""" + +CURRENT_ORACLE = """[WEB] +TAP_WORKDIR={work}/ +TAP_WORKURL=/workspace/ +HTTP_URL=https://exoplanetarchive.ipac.caltech.edu +HTTP_PORT=80 +CGI_PGM=/TAP +{compat} +[DBMS] +DBMS=oracle +ServerName=tapdb +UserID=exo_tap +Password=secret +COOKIENAME=tapcookie +RACOL=ra +DECCOL=dec +[SPTIND] +MODE=HTM +LEVEL=20 +XCOL=x +YCOL=y +ZCOL=z +COLNAME=htm20 +ENCODING=BASE10 +""" + + +def write(tmp_path, name, text, **fmt): + p = tmp_path / name + p.write_text(text.format(work=tmp_path, **fmt)) + return str(p) + + +def state(cp): + """Everything configParam derived, minus where it came from.""" + out = {k: v for k, v in vars(cp).items() if k not in ('configpath', 'compat', 'connectInfo')} + out['connectInfo'] = {k: v for k, v in cp.connectInfo.items() if k != 'compat'} + return out + + +def test_parse_names_accepts_str_and_list(): + assert compat.parse_names(None) == compat.NONE + assert compat.parse_names('nea-errors') == {'nea-errors'} + assert compat.parse_names(['nea-errors', ' nea-uws ']) == {'nea-errors', 'nea-uws'} + assert compat.parse_names(['', '']) == compat.NONE + + +def test_unknown_compat_name_is_an_error(tmp_path): + with pytest.raises(compat.CompatConfigError, match='nea-eror'): + compat.parse_names(['nea-eror']) + path = write(tmp_path, 'TAP.conf', CURRENT_ORACLE, compat='COMPAT = nea-eror') + with pytest.raises(compat.CompatConfigError): + configParam(path) + + +def test_legacy_oracle_reads_like_its_3x_equivalent(tmp_path): + legacy = configParam(write(tmp_path, 'legacy.conf', LEGACY_ORACLE)) + current = configParam(write(tmp_path, 'current.conf', CURRENT_ORACLE, compat='')) + assert state(legacy) == state(current) + assert legacy.compat == compat.ALL + assert legacy.connectInfo['compat'] == compat.ALL + assert current.compat == compat.NONE + assert current.connectInfo['compat'] == compat.NONE + + +PROPRIETARY_KEYS = ('ACCESS_TBL=access_tbl\nUSERS_TBL=users_tbl\nFILEID=fileid\n' + 'ACCESSID=accessid\nPROPFILTER=koa\n') + + +def test_legacy_proprietary_filter_keys_are_kept(tmp_path): + # A 1.x config keeps the proprietary-filter keys in [webserver]; 3.x reads + # them from the connection section. Dropping them serves proprietary rows. + legacy = configParam(write(tmp_path, 'legacy.conf', LEGACY_ORACLE.replace( + 'CGI_PGM=/TAP\n', 'CGI_PGM=/TAP\n' + PROPRIETARY_KEYS))) + current = configParam(write(tmp_path, 'current.conf', CURRENT_ORACLE.replace( + 'DECCOL=dec\n', 'DECCOL=dec\n' + PROPRIETARY_KEYS), compat='')) + assert legacy.propfilter == 'koa' + assert (legacy.accesstbl, legacy.usertbl, legacy.fileid, legacy.accessid) == \ + ('access_tbl', 'users_tbl', 'fileid', 'accessid') + assert state(legacy) == state(current) + + +def test_every_legacy_webserver_key_is_kept(tmp_path): + # Keys the translator has no list entry for still reach configParam. + text = LEGACY_ORACLE.replace('CGI_PGM=/TAP\n', 'CGI_PGM=/TAP\nSOME_FUTURE_KEY=x\n') + conf = compat.translate_legacy(__import__('configobj').ConfigObj( + write(tmp_path, 'TAP.conf', text))) + assert conf['DBMS']['SOME_FUTURE_KEY'] == 'x' + + +def test_explicit_compat_list(tmp_path): + cp = configParam(write(tmp_path, 'TAP.conf', CURRENT_ORACLE, + compat='COMPAT = nea-errors, nea-uws')) + assert cp.compat == {'nea-errors', 'nea-uws'} + + +def test_adql_keys_after_db_section_are_found(tmp_path): + # configobj files keys that follow [oracle] under [oracle]. + text = LEGACY_ORACLE.replace('ADQL_MODE=HTM\n', '').replace( + 'Password=secret\n', 'Password=secret\nADQL_MODE=HTM\n') + conf = compat.translate_legacy(__import__('configobj').ConfigObj( + write(tmp_path, 'TAP.conf', text))) + assert conf['SPTIND']['MODE'] == 'HTM' + assert 'ADQL_MODE' not in conf['DBMS'] + + +def test_legacy_sqlite_gets_an_attach_name(tmp_path): + text = '[webserver]\nDBMS=sqlite3\nTAP_WORKDIR={work}/\n[sqlite3]\nDB=/x.db\nTAP_SCHEMA=/ts.db\n' + conf = compat.translate_legacy(__import__('configobj').ConfigObj( + write(tmp_path, 'TAP.conf', text))) + assert dict(conf['DBMS']) == {'DBMS': 'sqlite3', 'DB': '/x.db', 'TAP_SCHEMA': '/ts.db', + 'TAP_SCHEMA_FILE': 'TAP_SCHEMA'} + + +def test_legacy_without_its_db_section_is_an_error(tmp_path): + text = '[webserver]\nDBMS=oracle\nTAP_WORKDIR={work}/\n' + with pytest.raises(compat.CompatConfigError, match=r'no \[oracle\] section'): + compat.translate_legacy(__import__('configobj').ConfigObj( + write(tmp_path, 'TAP.conf', text))) + + +def test_compat_outside_web_is_an_error(tmp_path): + text = CURRENT_ORACLE.replace('Password=secret\n', 'Password=secret\nCOMPAT = nea-uws\n') + with pytest.raises(compat.CompatConfigError, match=r'\[DBMS\]'): + configParam(write(tmp_path, 'TAP.conf', text, compat='')) + + +def test_compat_in_legacy_webserver_is_an_error(tmp_path): + text = LEGACY_ORACLE.replace('DBMS=oracle\n', 'DBMS=oracle\nCOMPAT=nea-uws\n', 1) + with pytest.raises(compat.CompatConfigError, match=r'\[webserver\]'): + configParam(write(tmp_path, 'TAP.conf', text)) diff --git a/tests/test_compat_http.py b/tests/test_compat_http.py new file mode 100644 index 0000000..29672f5 --- /dev/null +++ b/tests/test_compat_http.py @@ -0,0 +1,184 @@ +"""Compat mode end to end on the SQLite fixture: a 1.x TAP.conf gives NEA's +responses (legacy_tap_server); the 3.x config is unchanged (tap_server).""" +from __future__ import annotations + +import time +import xml.etree.ElementTree as ET + +import requests +from _helpers import raw_get, tap_async_url, tap_sync_url + +VOSI_HEAD = (b"HTTP/1.1 200 OK\r\nContent-type: application/xml\r\n" + b"Connection: close\r\n\r\n") +XML_DECL = b'\n' + + +def test_vosi_documents_carry_neas_headers(legacy_tap_server): + for ep in ("availability", "capabilities"): + raw = raw_get(legacy_tap_server, "/cgi-bin/TAP/nph-tap.py/" + ep) + assert raw.startswith(VOSI_HEAD + XML_DECL), raw[:200] + assert raw.endswith(b"\n\r\n"), raw[-80:] + + +def test_vosi_documents_in_default_mode_have_plain_headers(tap_server): + head = b"HTTP/1.1 200 OK\r\nContent-type: application/xml\r\n\r\n" + for ep in ("availability", "capabilities"): + raw = raw_get(tap_server, "/cgi-bin/TAP/nph-tap.py/" + ep) + assert raw.startswith(head + XML_DECL), raw[:200] + assert raw.endswith(b"\n"), raw[-80:] + + +DENIED = "Table 'not_a_table' is not available for querying." + + +def _sync(server, query, fmt="csv"): + return requests.post(tap_sync_url(server), data={"query": query, "format": fmt}, + timeout=60) + + +def test_unknown_table_is_neas_400_votable(legacy_tap_server): + r = _sync(legacy_tap_server, "select * from not_a_table") + assert r.status_code == 400 + assert r.headers["Content-Type"] == "application/xml" + assert '\n' + DENIED in r.text + + +def test_bad_adql_is_a_votable_error(legacy_tap_server): + r = _sync(legacy_tap_server, "selec x frm y") + assert r.status_code == 400 + assert r.headers["Content-Type"] == "application/xml" + assert r.text.startswith('\n" not in r.text + assert '' in r.text + + +def test_votable_unchanged_in_default_mode(tap_server): + r = _sync(tap_server, NONCHAR, fmt="votable") + assert r.status_code == 200, r.text[:300] + assert r.headers["Content-Type"] == "text/xml" + assert "Julian date of observation." in r.text + + +UWS = "{http://www.ivoa.net/xml/UWS/v1.0}" + + +def _completed_job(server): + """Submit, run and wait; returns (the 303 answering RUN, status URL). + + The 303 to the initial submit (job left PENDING) is written by a different + code path and carries no Content-Type in either mode; the one answering + PHASE=RUN is what ``async_submit_headers`` controls. + """ + sub = requests.post(tap_async_url(server), + data={"query": "select obsjd from data_l0", "format": "csv"}, + allow_redirects=False, timeout=60) + assert sub.status_code == 303, sub.text[:300] + url = sub.headers["Location"] + run = requests.post(url + "/phase", data={"PHASE": "RUN"}, + allow_redirects=False, timeout=60) + assert run.status_code == 303, run.text[:300] + deadline = time.time() + 30 + while time.time() < deadline: + if requests.get(url + "/phase", timeout=30).text.strip() in ("COMPLETED", "ERROR"): + break + time.sleep(0.2) + return run, url + + +def test_uws_is_neas_form(legacy_tap_server): + run, url = _completed_job(legacy_tap_server) + assert "Content-Type" not in run.headers + assert "Content-Length" not in run.headers + assert "Connection" not in run.headers + r = requests.get(url, timeout=30) + assert r.headers["Content-Type"] == "application/xml" + job = ET.fromstring(r.text) + assert job.find(UWS + "phase").text == "COMPLETED" + assert job.find(UWS + "executionDuration") is not None + assert job.find(UWS + "executionduration") is None + assert not job.find(UWS + "destruction").text.endswith("Z") + dur = requests.get(url + "/executionduration", timeout=30) + assert dur.status_code == 200 and dur.text.strip().isdigit(), dur.text[:200] + camel = requests.get(url + "/executionDuration", timeout=30) + assert camel.status_code == 200 and camel.text.strip().isdigit(), camel.text[:200] + assert camel.text == dur.text + + +def test_uws_in_default_mode(tap_server): + run, url = _completed_job(tap_server) + assert run.headers["Content-Type"] == "text/plain" + assert int(run.headers["Content-Length"]) == len(run.content) + assert run.headers["Connection"] == "close" + r = requests.get(url, timeout=30) + assert r.headers["Content-Type"] == "text/xml" + job = ET.fromstring(r.text) + assert job.find(UWS + "executionDuration") is not None # UWS spelling, as of 3.1.0 + assert job.find(UWS + "destruction").text.endswith("Z") + + +def test_camelcase_duration_key_unchanged_in_default_mode(tap_server): + run, url = _completed_job(tap_server) + r = requests.get(url + "/executionDuration", timeout=30) + assert r.status_code == 200 + assert r.headers["Content-Type"] == "text/plain" + assert r.text == "key executionDuration is not a valid key\n" + + +def _respell_duration(fixture_root, url, old, new): + """Rewrite one job's status.xml as an earlier release would have written it.""" + jobid = url.rstrip("/").rsplit("/", 1)[1] + paths = list(fixture_root.glob("workdir*/**/%s/status.xml" % jobid)) + assert len(paths) == 1, paths + text = paths[0].read_text() + assert "uws:" + old in text + paths[0].write_text(text.replace("uws:" + old, "uws:" + new)) + + +def _read_back(url): + whole = requests.get(url, timeout=30) + assert whole.status_code == 200 and "COMPLETED<" in whole.text, whole.text[:300] + dur = requests.get(url + "/executionduration", timeout=30) + assert dur.status_code == 200 and dur.text.strip().isdigit(), dur.text[:300] + phase = requests.get(url + "/phase", timeout=30) + assert phase.text.strip() == "COMPLETED", phase.text[:300] + + +# Jobs live four days, so a job written lowercase by an earlier release can +# be read after an upgrade, in either mode. + +def test_lowercase_status_reads_in_compat(legacy_tap_server, fixture_root): + _, url = _completed_job(legacy_tap_server) + _respell_duration(fixture_root, url, "executionDuration", "executionduration") + _read_back(url) + + +def test_lowercase_status_reads_in_default_mode(tap_server, fixture_root): + _, url = _completed_job(tap_server) + _respell_duration(fixture_root, url, "executionDuration", "executionduration") + _read_back(url) + + +def test_tables_is_neas_layout(legacy_tap_server): + r = requests.get(legacy_tap_server + "/cgi-bin/TAP/nph-tap.py/tables", timeout=60) + assert r.status_code == 200, r.text[:300] + + +def test_tables_unchanged_in_default_mode(tap_server): + r = requests.get(tap_server + "/cgi-bin/TAP/nph-tap.py/tables", timeout=60) + assert r.status_code == 200, r.text[:300] + assert "principal" not in r.text diff --git a/tests/test_compat_units.py b/tests/test_compat_units.py new file mode 100644 index 0000000..a7a94c8 --- /dev/null +++ b/tests/test_compat_units.py @@ -0,0 +1,95 @@ +"""compat.py's pure functions, active and inactive.""" +import datetime +import io + +from TAP import compat + + +def test_error_document_escapes_markup_not_quotes(): + doc = compat.error_document(compat.ALL, "a < b & 'c'", '400') + assert doc.startswith('HTTP/1.1 400 ERROR\r\nContent-type: application/xml\r\n\r\n' + '\n') + assert "\na < b & 'c'\n\n" in doc + assert doc.endswith('\n\n\n') + + +def test_error_document_inactive_is_none(): + assert compat.error_document(compat.NONE, 'x', '400') is None + + +def test_table_denied_status(): + assert compat.table_denied_status(compat.ALL) == '400' + assert compat.table_denied_status(compat.NONE) == '403' + + +def test_votable_content_type(): + assert compat.votable_content_type(compat.ALL) == 'application/xml' + assert compat.votable_content_type(compat.NONE) == 'text/xml' + + +def test_field_description_blanks_only_non_char_columns(): + assert compat.field_description(compat.ALL, 'Julian date', 'double') == '' + assert compat.field_description(compat.ALL, 'Program ID', 'char') == 'Program ID' + assert compat.field_description(compat.NONE, 'Julian date', 'double') == 'Julian date' + + +def test_uws_functions(): + assert compat.uws_key({'uws:executionDuration': '1'}, 'executionduration') == 'executionDuration' + assert compat.uws_key({'uws:executionduration': '1'}, 'executionduration') == 'executionduration' + assert compat.uws_key({'uws:executionDuration': '1'}, 'phase') == 'phase' + assert compat.uws_url_key(compat.ALL, 'executionDuration') == 'executionduration' + assert compat.uws_url_key(compat.ALL, 'executionduration') == 'executionduration' + assert compat.uws_url_key(compat.ALL, 'phase') == 'phase' + assert compat.uws_url_key(compat.NONE, 'executionDuration') == 'executionDuration' + assert compat.uws_url_key(compat.NONE, 'executionduration') == 'executionduration' + assert compat.destruction_suffix(compat.ALL) == '' + assert compat.destruction_suffix(compat.NONE) == 'Z' + assert compat.uws_content_type(compat.ALL) == 'application/xml' + assert compat.uws_content_type(compat.NONE) == 'text/xml' + body = 'Redirect Location: https://x/\u00e9\n' + assert len(body.encode('utf-8')) != len(body) + assert compat.async_submit_headers(compat.ALL, body) == '' + assert compat.async_submit_headers(compat.NONE, body) == ( + 'Content-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n' + % len(body.encode('utf-8'))) + + +def test_vosi_tables_class(): + from TAP import compat_vositables, vositables + assert compat.vosi_tables_class(compat.ALL) is compat_vositables.vosiTables + assert compat.vosi_tables_class(compat.NONE) is vositables.vosiTables + + +def test_warning_once_per_day(tmp_path): + out = io.StringIO() + day1, day2 = datetime.date(2026, 10, 6), datetime.date(2026, 10, 7) + assert compat.warn_once_per_day(compat.ALL, str(tmp_path), day1, out) is True + assert compat.warn_once_per_day(compat.ALL, str(tmp_path), day1, out) is False + assert compat.warn_once_per_day(compat.ALL, str(tmp_path), day2, out) is True + lines = out.getvalue().splitlines() + assert len(lines) == 2 + assert lines[0].startswith('nexsciTAP: compatibility mode active (nea-errors, ') + assert lines[0].endswith('see MIGRATING.md') + assert (tmp_path / 'TAP' / '.compat-warned-2026-10-06').exists() + + +def test_no_warning_without_compat(tmp_path): + out = io.StringIO() + assert compat.warn_once_per_day(compat.NONE, str(tmp_path), None, out) is False + assert out.getvalue() == '' and not (tmp_path / 'TAP').exists() + + +def test_warning_never_fails_a_request(tmp_path): + blocker = tmp_path / 'file' # a file where the work dir should be + blocker.write_text('x') + out = io.StringIO() + assert compat.warn_once_per_day(compat.ALL, str(blocker), None, out) is False + assert compat.warn_once_per_day(compat.ALL, None, None, out) is False + + +def test_duration_value_reads_either_spelling(): + lower = {'uws:executionduration': '30'} + camel = {'uws:executionDuration': '30'} + assert compat.duration_value(lower) == '30' + assert compat.duration_value(camel) == '30' + assert compat.duration_value({}) is None diff --git a/tests/test_http_endpoint.py b/tests/test_http_endpoint.py new file mode 100644 index 0000000..6fd9377 --- /dev/null +++ b/tests/test_http_endpoint.py @@ -0,0 +1,89 @@ +"""Smoke tests for the CI HTTP fixture. + +These don't probe security boundaries (see test_security_e2e.py in the next +PR). They just confirm the fixture itself works: the CGI launches, the SQLite +DBs are wired in, and a few representative queries make it through the full +HTTP→CGI→TAP→SQLite→IPAC pipeline. +""" +from __future__ import annotations + +import requests +from _helpers import row_count_from_ipac, tap_sync_url + + +def test_server_starts(tap_server: str): + """The fixture comes up and the CGI returns something for a trivial GET.""" + r = requests.get(tap_server) + # Root path is not a CGI script — we expect a 200 with the directory + # listing or a 404; either confirms the server itself is alive. + assert r.status_code in (200, 404) + + +def test_sync_select_l0(tap_server: str): + """A trivial select against data_l0 returns 6 rows in IPAC format.""" + r = requests.post( + tap_sync_url(tap_server), + data={ + "query": "select l0filename, program from data_l0", + "format": "ipac", + }, + ) + assert r.status_code == 200, r.text[:500] + assert row_count_from_ipac(r.text) == 6 + + +def test_sync_select_l1(tap_server: str): + """data_l1 has 10 rows total (3 public + 3 expired + 4 embargoed).""" + r = requests.post( + tap_sync_url(tap_server), + data={ + "query": "select l1filename from data_l1", + "format": "ipac", + # propflag=0 → tapQuery path → no filter → all rows + "propflag": "0", + }, + ) + assert r.status_code == 200, r.text[:500] + assert row_count_from_ipac(r.text) == 10 + + +def test_sync_get_method(tap_server: str): + """Sync TAP must also accept GET (not just POST).""" + r = requests.get( + tap_sync_url(tap_server), + params={ + "query": "select l0filename from data_l0", + "format": "ipac", + }, + ) + assert r.status_code == 200, r.text[:500] + assert row_count_from_ipac(r.text) == 6 + + +def test_tap_schema_query(tap_server: str): + """tap_schema queries take a separate code path; confirm it works.""" + r = requests.post( + tap_sync_url(tap_server), + data={ + "query": "select table_name from tap_schema.tables", + "format": "ipac", + }, + ) + assert r.status_code == 200, r.text[:500] + # Our fixture registers 4 user tables + 5 TAP_SCHEMA self-tables = 9. + assert row_count_from_ipac(r.text) >= 9 + + +def test_csv_format(tap_server: str): + """The CSV serializer works.""" + r = requests.post( + tap_sync_url(tap_server), + data={ + "query": "select l0filename from data_l0", + "format": "csv", + }, + ) + assert r.status_code == 200, r.text[:500] + lines = [L for L in r.text.splitlines() if L.strip()] + # 1 header line + 6 data lines + assert len(lines) == 7 diff --git a/tests/test_install_requires.py b/tests/test_install_requires.py new file mode 100644 index 0000000..2b22fa2 --- /dev/null +++ b/tests/test_install_requires.py @@ -0,0 +1,59 @@ +"""setup.py declares every package the TAP code needs at run time. + +CI installs nexsciTAP with --no-deps and gets its runtime packages from +requirements-test.txt, so a package missing from install_requires passes +CI and only fails on a real `pip install`. Missing lxml did exactly that: +sync queries worked and every async job returned 500. +""" +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Import name -> distribution name, where they differ. +DIST_NAME = {'bs4': 'beautifulsoup4'} + +# Database drivers are imported only for the DBMS a deployment configures. +OPTIONAL = {'cx_Oracle', 'psycopg2', 'mysql'} + +# Not imported, but named as a parser: BeautifulSoup(data, 'lxml'). +NAMED_ONLY = {'lxml'} + + +def _install_requires() -> set[str]: + tree = ast.parse((REPO_ROOT / 'setup.py').read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.keyword) and node.arg == 'install_requires': + reqs = ast.literal_eval(node.value) + return {re.split(r'[\s;\[<>=!~]', r, maxsplit=1)[0] for r in reqs} + raise AssertionError('setup.py has no install_requires') + + +def _third_party_imports() -> set[str]: + found = set() + for path in (REPO_ROOT / 'TAP').glob('*.py'): + for node in ast.walk(ast.parse(path.read_text())): + if isinstance(node, ast.Import): + names = [a.name for a in node.names] + elif isinstance(node, ast.ImportFrom) and node.level == 0: + names = [node.module or ''] + else: + continue + for name in names: + top = name.split('.')[0] + if top != 'TAP' and top not in sys.stdlib_module_names: + found.add(top) + return found + + +@pytest.mark.skipif(sys.version_info < (3, 10), reason='needs sys.stdlib_module_names') +def test_every_runtime_import_is_declared(): + needed = {DIST_NAME.get(m, m) for m in _third_party_imports() - OPTIONAL} | NAMED_ONLY + missing = needed - _install_requires() + assert not missing, f'add to install_requires in setup.py: {sorted(missing)}' diff --git a/tests/test_propfilter_security.py b/tests/test_propfilter_security.py index 64e48b4..b928503 100644 --- a/tests/test_propfilter_security.py +++ b/tests/test_propfilter_security.py @@ -16,6 +16,14 @@ from TAP.tapquery import tapQuery from TAP.taputil import tapUtil + +class _Form(dict): + """Stand-in for cgi.FieldStorage: a key lookup plus the .list Tap checks.""" + + @property + def list(self): + return list(self.values()) + ORACLE_CONFIG = """[WEB] TAP_WORKDIR={work}/ TAP_WORKURL=/workspace/ @@ -235,7 +243,7 @@ def test_request_cannot_enable_debug(monkeypatch, tmp_path, capsys, operator_deb monkeypatch.setenv("PATH_INFO", "/sync") monkeypatch.delenv("TAP_CONF", raising=False) app = Tap.__new__(Tap) - app.form = {"debug": SimpleNamespace(value="1")} if request_debug else {} + app.form = _Form({"debug": SimpleNamespace(value="1")} if request_debug else {}) app.debug, app.param, app.infomsg, app.dbtable = 0, {}, "", "" app.debugfname = str(tmp_path / "tap.debug") with pytest.raises(SystemExit): @@ -250,7 +258,7 @@ def test_operator_debug_does_not_log_cookies_or_tokens(monkeypatch, tmp_path, ca monkeypatch.setenv("HTTP_COOKIE", "tapcookie=user|cookie-secret-marker") monkeypatch.delenv("TAP_CONF", raising=False) app = Tap.__new__(Tap) - app.form = {"token": SimpleNamespace(value="tapcookie=user|token-secret-marker")} + app.form = _Form({"token": SimpleNamespace(value="tapcookie=user|token-secret-marker")}) app.debug, app.param, app.infomsg, app.dbtable = 1, {}, "", "" app.debugfname = str(tmp_path / "tap.debug") with caplog.at_level(logging.DEBUG), pytest.raises(SystemExit): @@ -280,8 +288,8 @@ def unavailable_connection(*args, **kwargs): monkeypatch.delenv("TAP_DEBUG", raising=False) monkeypatch.delenv("HTTP_COOKIE", raising=False) app = Tap.__new__(Tap) - app.form = {key: SimpleNamespace(value=value) for key, value in - {"query": query, "format": "votable"}.items()} + app.form = _Form({key: SimpleNamespace(value=value) for key, value in + {"query": query, "format": "votable"}.items()}) app.debug, app.propflag, app.param, app.statdict = 0, -1, {}, {} with pytest.raises(SystemExit): app.__init__() diff --git a/tests/test_pyneid_compat.py b/tests/test_pyneid_compat.py new file mode 100644 index 0000000..883a3b7 --- /dev/null +++ b/tests/test_pyneid_compat.py @@ -0,0 +1,40 @@ +"""pyNEID compatibility regression tests. + +Still skipped, but for a different reason than before. + +pyNEID's ``query_adql`` always uses the async TAP endpoint, which works +in three round trips: + +1. POST to /TAP/async with the ADQL query -> server returns 303 See Other + with a Location: header. +2. POST to /phase with PHASE=RUN -> server starts the job and + returns another 303 to the same status URL. +3. GET the status URL repeatedly until phase=COMPLETED, then GET the + result URL embedded in the status XML. + +This module used to say the fixture handler could not carry that +sequence, and recorded that "pyNEID hung at step 2 with no observable +error in the TAP debug log". The hang was not the fixture. Step 2 ended +by SIGKILLing os.getppid(), which under the fixture is the test runner +and in production is the web server child serving the request, so the +client was left waiting on a pipe nobody would close. That is fixed, and +the sequence above now has direct coverage in tests/test_async_flow.py. + +What is still missing here is pyNEID itself: it is not a test dependency +and is not installed in CI, so there is nothing to import. Adding it +means weighing a heavier test dependency (and its own network defaults) +against coverage that tests/test_async_flow.py already provides at the +HTTP level. Until that call is made, this module stays skipped. + +To wire it up later: add pyNEID to requirements-test.txt, write the +round-trip test against the fixture server, and drop the skip below. +""" +from __future__ import annotations + +import pytest + +pytest.skip( + "pyNEID is not a test dependency; the async flow it drives is covered " + "directly in tests/test_async_flow.py. See module docstring.", + allow_module_level=True, +) diff --git a/tests/test_security_e2e.py b/tests/test_security_e2e.py new file mode 100644 index 0000000..772ef38 --- /dev/null +++ b/tests/test_security_e2e.py @@ -0,0 +1,252 @@ +"""End-to-end security regression tests. + +Each test is named after the specific regression or security boundary it +guards. The intent is that any future change that re-opens one of these +holes turns the corresponding test red at PR time, making the regression +unmissable in code review. + +Actual TAP status-code behavior in v3.0.0: +- 403 only for `TableValidationError`. The validator raises this for + *system-catalog* references (e.g., sqlite_master, dba_users), and + the table whitelist raises it for tables not in TAP_SCHEMA.tables. +- 400 for everything else, including DML rejection, dangerous-function + rejection, semicolon-chained statements, and ordinary query errors. + This is inconsistent — DML/dangerous-func rejections are security + policy decisions and should arguably also be 403 — but the existing + code raises plain Exception for those, not TableValidationError. + Tests match the actual behavior; harmonizing the codes is a follow-up. +- 500 for startup failures. +""" +from __future__ import annotations + +import pytest +import requests +from _helpers import row_count_from_ipac, tap_sync_url + +# ---------------------------------------------------------------- helpers + +def _post(server: str, query: str, **params) -> requests.Response: + """POST a sync TAP query.""" + data = {"query": query, "format": "ipac"} + data.update(params) + return requests.post(tap_sync_url(server), data=data) + + +def _count(server: str, query: str, **params) -> int: + """Run a query and return the row count from the IPAC response. + + Asserts a 200 before counting — caller should expect success. + """ + r = _post(server, query, **params) + assert r.status_code == 200, f"expected 200 got {r.status_code}: {r.text[:300]}" + return row_count_from_ipac(r.text) + + +# ---------------------------------------------------------------- table whitelist + +def test_table_whitelist_rejects_system_catalog(tap_server: str): + """SQL injection class: system-catalog reads must be blocked. + + Regression marker: cd3d963 (initial table whitelist). + """ + r = _post(tap_server, "select * from sqlite_master") + assert r.status_code == 403, r.text[:300] + + +def test_table_whitelist_rejects_unregistered_table(tap_server: str): + """Tables not in TAP_SCHEMA.tables are 403, not 400 or 200.""" + r = _post(tap_server, "select * from not_in_tap_schema_at_all") + assert r.status_code == 403, r.text[:300] + + +def test_table_whitelist_allows_propfilter_internal_table_in_subquery(tap_server: str): + """The propfilter's access-control table is reachable from subqueries. + + Regression markers: bdbd2d7, de1941f. The fix excluded the configured + ACCESS_TBL / USERS_TBL from validation so the prop check could still + reference them in `WHERE program IN (SELECT program FROM test_access ...)`. + """ + q = ( + "select l1filename from data_l1 " + "where program in (select program from test_access where userid='') " + "or l1propint = 0" + ) + r = _post(tap_server, q, propflag="0") + assert r.status_code == 200, r.text[:300] + + +def test_order_by_not_treated_as_table(tap_server: str): + """ORDER BY columns must not be misidentified as table names. + + Regression marker: 1404e15. sqlparse tokenizes 'ORDER BY' as a single + keyword; the table extractor was treating columns after it as tables + until that fix. + """ + r = _post(tap_server, "select l0filename from data_l0 order by obsdate") + assert r.status_code == 200, r.text[:300] + assert row_count_from_ipac(r.text) == 6 + + +def test_unknown_statement_does_not_skip_validation(tap_server: str): + """Oracle-dialect queries using ROWNUM must still be validated. + + Regression marker: 867278c. sqlparse returns get_type() == 'UNKNOWN' + for ROWNUM-bearing queries; an earlier extractor skipped UNKNOWN + statements entirely, letting them through validation. The fix + extracts tables from UNKNOWN statements too. + """ + r = _post( + tap_server, + "select * from not_in_tap_schema where rownum < 10", + ) + assert r.status_code == 403, r.text[:300] + + +# ---------------------------------------------------------------- propflag + +def test_propflag_zero_honored_for_simple_l0(tap_server: str): + """L0 with propflag=0 must return all rows — the UI's L0 metadata + listing path. data_l0 has 6 rows total (all public, propint=0).""" + n = _count(tap_server, "select l0filename from data_l0", propflag="0") + assert n == 6 + + +@pytest.mark.skip( + reason="Cannot exercise the propflag guard with the SQLite fixture. " + "configparam.py only reads PROPFILTER/ACCESS_TBL/USERS_TBL " + "inside its `if self.dbms == 'oracle'` branch (~lines 255-345), " + "so on a SQLite config those keys are silently dropped and " + "self.config.propfilter ends up '' — neither the NEID nor the " + "KOA guard fires. This is a separate bug worth fixing (the " + "guard semantics should not be Oracle-only). End-to-end propflag " + "coverage will land with the deferred Oracle/Postgres/MySQL CI " + "matrix work." +) +def test_propflag_bypass_routes_l1_through_propfilter(tap_server: str): + """L1 with propflag=0 must route through propfilter, not bare tapQuery. + + Regression markers: PR #16 + PR #17. The guard in tap.py:1612-1638 + forces propflag=1 when datalevel is eng/l1/l2. Cannot be exercised + against the SQLite fixture (see skip reason).""" + # If/when configparam.py is fixed to read PROPFILTER for non-Oracle + # configs, this test will run and assert: + # - propflag=0 status_code != 200 (because propfilter then errors + # on add_months — fixing THAT is a separate concern) + # - propflag=0 status_code == propflag=1 status_code + raise AssertionError("Skipped — see decorator") + + +def test_propflag_whitelist_l0_complex_case_does_not_misfire(tap_server: str): + """The UI's L0 download-link query shape (CASE WHEN with a subquery + FROM nested in the SELECT list) with propflag=0 must succeed and + return rows. + + Regression marker: PR #16 introduced a bug where the propflag guard + used `datalevel != 'l0'`, which evaluates True for the empty + datalevel that __getDatalevel__ returns when the table-name parser + picks the subquery's table first. That bug routed the L0 query + through propfilter, which then constructed malformed prop-check SQL. + + PR #17 switched to an explicit `datalevel in ('eng','l1','l2')` + whitelist so the misfire stopped happening — when datalevel='', the + whitelist doesn't match, and the L0 query stays on the tapQuery path. + + Query structure is the regression trigger: a CASE WHEN containing a + `SELECT ... FROM test_access` subquery in the SELECT list. The + table-name parser yields `test_access` first; __getDatalevel__ on + 'test_access' returns ''. Production used `add_months(obsdate, + l0propint)` in the CASE condition; SQLite doesn't have add_months, + so this test uses a comparison the table-name parser still treats + identically. + """ + q = ( + "select (case when program in " + "(SELECT program from test_access where userid = '') " + "then l0filename else 'redacted' end) as fn, " + "l0filename, program from data_l0" + ) + r = _post(tap_server, q, propflag="0") + assert r.status_code == 200, r.text[:300] + assert row_count_from_ipac(r.text) == 6 + + +# ---------------------------------------------------------------- statement validator + +def test_statement_validator_rejects_dml(tap_server: str): + """INSERT/UPDATE/DELETE/DROP must all be rejected (status 400). + + `validate_statement` raises plain Exception (not TableValidationError) + for these, so the response is 400 rather than 403. Arguably they + should also be 403 — security policy rejection rather than malformed + query — but harmonizing the codes is a separate follow-up. + """ + for dml in ( + "insert into data_l0 (l0filename) values ('x')", + "update data_l0 set l0filename = 'x'", + "delete from data_l0", + "drop table data_l0", + ): + r = _post(tap_server, dml) + assert r.status_code == 400, f"{dml!r} -> {r.status_code}: {r.text[:200]}" + assert "not permitted" in r.text or "rejected" in r.text, r.text[:300] + + +def test_statement_validator_rejects_oracle_dangerous_functions(tap_server: str): + """utl_http, dbms_sql, sys_context etc. must be rejected (status 400). + + Same 400-vs-403 inconsistency as DML rejection — see that test. + """ + for sql in ( + "select utl_http.request('http://evil/x') from data_l0", + "select dbms_sql.parse(1) from data_l0", + "select sys_context('USERENV','IP_ADDRESS') from data_l0", + ): + r = _post(tap_server, sql) + assert r.status_code == 400, f"{sql!r} -> {r.status_code}: {r.text[:200]}" + assert "not permitted" in r.text, r.text[:300] + + +def test_statement_validator_rejects_semicolon_chained(tap_server: str): + """Semicolon-chained statements (classic SQL injection) are rejected. + + Same 400-vs-403 inconsistency as DML rejection. + """ + r = _post( + tap_server, + "select l0filename from data_l0; drop table data_l0", + ) + assert r.status_code == 400, r.text[:300] + assert "semicolon" in r.text.lower(), r.text[:300] + + +# ---------------------------------------------------------------- HTTP status codes + +def test_http_status_code_400_for_query_error(tap_server: str): + """A query referencing a missing column on a valid table → 400.""" + r = _post(tap_server, "select column_that_does_not_exist from data_l0") + # The column doesn't exist; SQLite/TAP returns this as a query error, + # which is 400 (not 403, since the table itself is valid). + assert r.status_code == 400, r.text[:300] + + +# ---------------------------------------------------------------- error message safety + +def test_table_reject_error_message_is_safe(tap_server: str): + """Error responses for rejected tables must not leak internal paths + or expose stack-trace internals. + + Regression marker: 4fa46ff (generic startup error messages) + + c202285 (don't leak raw Oracle errors). This test is a sanity check + that the error response for a table rejection stays clean. + """ + r = _post(tap_server, "select * from sqlite_master") + body = r.text + # No bare file paths (full /tmp/, /var/, /Users/ etc.) + forbidden_substrings = [ + "Traceback", + "Password=", + "/tmp/tap_", + "site-packages", + ] + for s in forbidden_substrings: + assert s not in body, f"Leaked {s!r} in error response:\n{body[:500]}" diff --git a/tests/test_tablevalidator.py b/tests/test_tablevalidator.py index 5faff1e..d91719f 100644 --- a/tests/test_tablevalidator.py +++ b/tests/test_tablevalidator.py @@ -3,7 +3,7 @@ import tempfile import unittest -from TAP.tablevalidator import TableValidator, TableValidationError +from TAP.tablevalidator import TableValidationError, TableValidator def _make_db(table_names, schema_name='TAP_SCHEMA', tables_table='tables'): diff --git a/tests/test_tap_schema_name.py b/tests/test_tap_schema_name.py new file mode 100644 index 0000000..87e34c0 --- /dev/null +++ b/tests/test_tap_schema_name.py @@ -0,0 +1,68 @@ +"""TAP_SCHEMA's name in SQL: the schema for Oracle, the ATTACH name for SQLite. + +configparam always passes tap_schema_file='TAP_SCHEMA.db' (SQLite's attach +name). Using it for Oracle produced 'FROM TAP_SCHEMA.db.tables' and +ORA-03048 on every query (found by comparing against a production deployment, 2026-10-05). +""" +import contextlib + +from TAP.datadictionary import dataDictionary +from TAP.tablevalidator import TableValidator + + +class _Cursor: + def __init__(self, log): + self.log = log + self.description = [('column_name',), ('datatype',), ('description',), ('unit',)] + self.arraysize = 100 + + def execute(self, sql, *params): + self.log.append(sql) + + def fetchall(self): + return [('ps',)] + + def fetchmany(self, *a): + return [] + + def close(self): + pass + + +class _Conn: + def __init__(self): + self.log = [] + + def cursor(self): + return _Cursor(self.log) + + +def info(dbms, file_name): + return {'dbms': dbms, 'tap_schema': 'TAP_SCHEMA', 'tap_schema_file': file_name, + 'tables_table': 'tables', 'columns_table': 'columns'} + + +def test_validator_uses_schema_name_for_oracle(): + conn = _Conn() + TableValidator(conn, info('oracle', 'TAP_SCHEMA.db')) + assert conn.log == ['SELECT table_name FROM TAP_SCHEMA.tables'] + + +def test_validator_uses_attach_name_for_sqlite(): + conn = _Conn() + TableValidator(conn, info('sqlite3', 'TS')) + assert conn.log == ['SELECT table_name FROM TS.tables'] + + +def test_data_dictionary_uses_schema_name_for_oracle(): + conn = _Conn() + with contextlib.suppress(Exception): # the fake has no rows; only the SQL matters + dataDictionary(conn, 'ps', info('oracle', 'TAP_SCHEMA.db')) + assert conn.log and conn.log[0].startswith('select * from TAP_SCHEMA.columns ') + + +def test_data_dictionary_uses_attach_name_for_sqlite(): + conn = _Conn() + with contextlib.suppress(Exception): + dataDictionary(conn, 'ps', info('sqlite3', 'TS')) + assert conn.log and conn.log[0].startswith('select * from TS.columns ') diff --git a/tests/test_uws_conformance.py b/tests/test_uws_conformance.py new file mode 100644 index 0000000..e8b3fda --- /dev/null +++ b/tests/test_uws_conformance.py @@ -0,0 +1,142 @@ +"""UWS behaviors taplint checks (E-UWS-DENO, -HTDE, -JDED, -JDDE) and what +deleting a job needs to be safe.""" +from __future__ import annotations + +import http.client +import os +import xml.etree.ElementTree as ET +from pathlib import Path +from urllib.parse import urlsplit + +import pytest +import requests +from _helpers import tap_async_url + +from TAP import compat + +UWS = "{http://www.ivoa.net/xml/UWS/v1.0}" +QUERY = "SELECT TOP 2 table_name FROM TAP_SCHEMA.tables" + + +def _submit(server): + r = requests.post(tap_async_url(server), data={"query": QUERY, "lang": "ADQL", "format": "csv"}, + allow_redirects=False, timeout=60) + assert r.status_code == 303, r.text + return r.headers["Location"] + + +def _job(url): + r = requests.get(url, timeout=60) + assert r.status_code == 200, r.text + return ET.fromstring(r.content) + + +def _text(url, key): + r = requests.get(f"{url}/{key}", timeout=60) + assert r.status_code == 200, r.text + return r.text.strip() + + +def _jobs_dir(fixture_root: Path) -> Path: + return fixture_root / "workdir" / "TAP" + + +@pytest.mark.parametrize("phase", ["PENDING", "COMPLETED"]) +def test_job_document_agrees_with_its_resources(tap_server, phase): + """JDED, JDDE: the job document and /executionduration, /destruction match.""" + url = _submit(tap_server) + if phase == "COMPLETED": + requests.post(url + "/phase", data={"PHASE": "RUN"}, allow_redirects=False, timeout=60) + for _ in range(60): + if _text(url, "phase") == "COMPLETED": + break + job = _job(url) + duration = job.find(UWS + "executionDuration") + assert duration is not None, "UWS names the element executionDuration" + assert (duration.text or "") == _text(url, "executionduration") + assert (job.find(UWS + "destruction").text or "") == _text(url, "destruction") + + +@pytest.mark.parametrize("how", ["POST ACTION=DELETE", "HTTP DELETE"]) +def test_deleted_job_is_gone(tap_server, fixture_root, how): + """DENO, HTDE: deleting answers 303 to the job list; the job is then 404.""" + url = _submit(tap_server) + jobid = url.rstrip("/").rsplit("/", 1)[1] + assert (_jobs_dir(fixture_root) / jobid).is_dir() + if how == "HTTP DELETE": + r = requests.delete(url, allow_redirects=False, timeout=60) + else: + r = requests.post(url, data={"ACTION": "DELETE"}, allow_redirects=False, timeout=60) + assert r.status_code == 303, r.text + assert r.headers["Location"].rstrip("/").endswith("/async") + assert requests.get(url, timeout=60).status_code == 404 + assert not (_jobs_dir(fixture_root) / jobid).exists() + + +def test_completed_job_can_be_deleted(tap_server): + url = _submit(tap_server) + requests.post(url + "/phase", data={"PHASE": "RUN"}, allow_redirects=False, timeout=60) + for _ in range(60): + if _text(url, "phase") == "COMPLETED": + break + r = requests.delete(url, allow_redirects=False, timeout=60) + assert r.status_code == 303, r.text + assert requests.get(url, timeout=60).status_code == 404 + + +def test_unknown_job_is_404(tap_server): + url = tap_async_url(tap_server) + "/tap_doesnotexist" + assert requests.get(url, timeout=60).status_code == 404 + assert requests.delete(url, allow_redirects=False, timeout=60).status_code == 404 + + +def _raw_status(server, method, path, body=b""): + """Send path as is: requests would drop '.' and '..' segments first.""" + parts = urlsplit(server) + conn = http.client.HTTPConnection(parts.hostname, parts.port, timeout=60) + headers = {"Content-Type": "application/x-www-form-urlencoded"} if body else {} + conn.request(method, path, body=body, headers=headers) + status = conn.getresponse().status + conn.close() + return status + + +@pytest.mark.parametrize("jobid", ["..", ".", "TAP", "tap_x.y", "notajob"]) +def test_job_ids_outside_the_job_namespace_are_404(tap_server, fixture_root, jobid): + """A job id names a directory under /TAP; nothing else may be reached.""" + before = sorted(os.listdir(fixture_root / "workdir")) + path = urlsplit(tap_async_url(tap_server)).path + "/" + jobid + assert _raw_status(tap_server, "DELETE", path) == 404 + assert _raw_status(tap_server, "POST", path, b"ACTION=DELETE") == 404 + assert _raw_status(tap_server, "GET", path) == 404 + assert sorted(os.listdir(fixture_root / "workdir")) == before + assert _jobs_dir(fixture_root).is_dir() + + +def test_job_list_does_not_create_a_job(tap_server, fixture_root): + """Clients follow the delete redirect with a GET on the job list.""" + before = set(os.listdir(_jobs_dir(fixture_root))) + r = requests.get(tap_async_url(tap_server), allow_redirects=False, timeout=60) + assert r.status_code == 200, r.text + assert ET.fromstring(r.content).tag == UWS + "jobs" + assert set(os.listdir(_jobs_dir(fixture_root))) == before + + +@pytest.mark.parametrize("httpurl, cgipgm", [ + ("https://host.org", "/TAP"), ("https://host.org/", "TAP"), ("https://host.org", "TAP"), + ("https://host.org/", "/TAP/"), +]) +def test_job_urls_have_one_slash_between_parts(httpurl, cgipgm): + assert compat.service_url(compat.NONE, httpurl, cgipgm) == "https://host.org/TAP" + + +def test_nea_keeps_its_job_url_form(): + # NEA's job URLs carry the double slash today (HTTP_URL + '/' + CGI_PGM=/TAP). + assert compat.service_url(compat.ALL, "https://host.org", "/TAP") == "https://host.org//TAP" + + +def test_delete_works_in_compat_mode(legacy_tap_server, fixture_root): + url = _submit(legacy_tap_server) + r = requests.delete(url, allow_redirects=False, timeout=60) + assert r.status_code == 303, r.text + assert requests.get(url, timeout=60).status_code == 404 diff --git a/tests/test_vosi_http_response.py b/tests/test_vosi_http_response.py new file mode 100644 index 0000000..76e59ec --- /dev/null +++ b/tests/test_vosi_http_response.py @@ -0,0 +1,153 @@ +"""Wire-level checks for the VOSI /capabilities and /availability endpoints. + +These are nph- (non-parsed-headers) CGI endpoints: the script is responsible +for emitting the entire HTTP response itself, status line included. Anything +short of a well-formed `HTTP/1.1 200 OK\\r\\n` start-line followed by CRLF +headers and a blank CRLF line is rejected as an upstream protocol error by +nginx and Cloudflare, so these tests assert on raw bytes rather than going +through a forgiving client library. +""" +from __future__ import annotations + +import os +import socket +import subprocess +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +FIXTURES = Path(__file__).parent / "fixtures" +STUBS = FIXTURES / "stubs" + + +def _raw_get(server: str, path: str) -> bytes: + """Issue a GET and return the unparsed response bytes off the socket.""" + parts = urlsplit(server) + with socket.create_connection((parts.hostname, parts.port), timeout=30) as sock: + request = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {parts.hostname}:{parts.port}\r\n" + "Connection: close\r\n" + "\r\n" + ) + sock.sendall(request.encode("ascii")) + chunks = [] + while True: + chunk = sock.recv(65536) + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks) + + +VOSI_PATHS = ["/TAP/capabilities", "/TAP/availability"] + + +@pytest.mark.parametrize("path", VOSI_PATHS) +def test_vosi_status_line_is_crlf_terminated(tap_server: str, path: str): + """The response opens with a CRLF-terminated 200 status line.""" + raw = _raw_get(tap_server, path) + assert raw.startswith(b"HTTP/1.1 200 OK\r\n"), ( + f"{path} must begin with a CRLF-terminated status line; " + f"got {raw[:60]!r}" + ) + + +@pytest.mark.parametrize("path", VOSI_PATHS) +def test_vosi_headers_are_crlf_terminated(tap_server: str, path: str): + """Every header line uses CRLF, and CRLFCRLF separates headers from body.""" + raw = _raw_get(tap_server, path) + assert b"\r\n\r\n" in raw, f"{path} has no CRLF blank line ending the headers" + + head = raw.split(b"\r\n\r\n", 1)[0] + for line in head.split(b"\r\n"): + assert b"\n" not in line, ( + f"{path} header line {line!r} contains a bare LF" + ) + assert b"\r" not in line, ( + f"{path} header line {line!r} contains a bare CR" + ) + + +@pytest.mark.parametrize("path", VOSI_PATHS) +def test_vosi_declares_xml_content_type(tap_server: str, path: str): + """A Content-type header is present and announces XML.""" + raw = _raw_get(tap_server, path) + head = raw.split(b"\r\n\r\n", 1)[0].lower() + assert b"content-type: application/xml" in head, ( + f"{path} is missing an XML Content-type header; got {head!r}" + ) + + +@pytest.mark.parametrize( + "path,root_element", + [ + ("/TAP/capabilities", b"') + assert root_element in body + + +def _run_cgi_with_workdir(tmp_path, fixture_root, path_info: str) -> bytes: + """Run the CGI directly against a *pristine* TAP_WORKDIR. + + The session `tap_server` fixture shares one workdir across the whole + suite, so by the time these tests run `/TAP` has already been + created by earlier sync requests. That hides the fresh-deployment case, + which is exactly where the workspace-resolution bug bit. Point the CGI at + an empty workdir so the no-workspace path is exercised deterministically. + """ + workdir = tmp_path / "workdir" + workdir.mkdir() + conf = tmp_path / "TAP.conf" + conf.write_text( + (FIXTURES / "TAP.conf.template").read_text().format( + TEST_WORKDIR=str(workdir), + TEST_HTTP_HOST="127.0.0.1", + TEST_HTTP_PORT="8099", + TEST_DB_PATH=str(fixture_root / "test_data.db"), + TEST_TAP_SCHEMA=str(fixture_root / "tap_schema.db"), + ) + ) + + env = os.environ.copy() + env.update({ + "TAP_CONF": str(conf), + "PATH_INFO": path_info, + "REQUEST_METHOD": "GET", + "QUERY_STRING": "", + "PYTHONPATH": os.pathsep.join([str(REPO_ROOT), str(STUBS)]), + }) + proc = subprocess.run( + [sys.executable, str(fixture_root / "cgi-bin" / "TAP" / "nph-tap.py")], + env=env, capture_output=True, timeout=60, + ) + assert (workdir / "TAP").exists() is False, ( + "the VOSI endpoints must not create a workspace" + ) + return proc.stdout + + +@pytest.mark.parametrize("path", VOSI_PATHS) +def test_vosi_works_without_existing_workspace(tmp_path, fixture_root, path): + """A fresh deployment, where /TAP does not yet exist, still works. + + These endpoints previously fell through to the "retrieve workspace from + jobid" branch with an empty jobid, resolving to /TAP and + returning a 500 whenever that directory was absent. + """ + raw = _run_cgi_with_workdir(tmp_path, fixture_root, path.replace("/TAP", "", 1)) + assert raw.startswith(b"HTTP/1.1 200 OK\r\n"), ( + f"{path} on a pristine workdir must return a CRLF-terminated 200; " + f"got {raw[:120]!r}" + ) + assert b"500" not in raw.split(b"\r\n", 1)[0]