From 3600d788695b6c0147f8ed50771e5e6b8d7f51a8 Mon Sep 17 00:00:00 2001 From: Brian Goins <340396+briangoins@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:54:23 -0500 Subject: [PATCH 1/6] CASSANDRA-17684 bundling CQL.html as Python package resource --- .gitignore | 2 + build.xml | 15 ++- pylib/cqlshlib/cqlshmain.py | 59 +++++++++-- pylib/cqlshlib/resources/__init__.py | 24 +++++ pylib/cqlshlib/test/test_docspath.py | 140 +++++++++++++++++++++++++++ pylib/setup.py | 6 +- 6 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 pylib/cqlshlib/resources/__init__.py create mode 100644 pylib/cqlshlib/test/test_docspath.py diff --git a/.gitignore b/.gitignore index c8cd6ac29a86..a1722e9e6ab3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ data/ conf/hotspot_compiler doc/cql3/CQL.html doc/build/ +pylib/cqlshlib/resources/CQL.html +pylib/cqlshlib/resources/CQL.css lib/ pylib/src/ **/cqlshlib.xml diff --git a/build.xml b/build.xml index 832a9d86bb3f..78f54c03797d 100644 --- a/build.xml +++ b/build.xml @@ -65,6 +65,7 @@ + @@ -458,6 +459,8 @@ + + @@ -506,6 +509,16 @@ + + + + + + + + + @@ -586,7 +599,7 @@ - diff --git a/pylib/cqlshlib/cqlshmain.py b/pylib/cqlshlib/cqlshmain.py index 8b26e3307884..2bd2cc9303b0 100755 --- a/pylib/cqlshlib/cqlshmain.py +++ b/pylib/cqlshlib/cqlshmain.py @@ -2125,14 +2125,57 @@ def read_options(cmdlineargs, parser, config_file, cql_dir, environment=os.envir def get_docspath(path): - cqldocs_url = Shell.DEFAULT_CQLDOCS_URL - if os.path.exists(path + '/doc/cql3/CQL.html'): - # default location of local CQL.html - cqldocs_url = 'file://' + path + '/doc/cql3/CQL.html' - elif os.path.exists('/usr/share/doc/cassandra/CQL.html'): - # fallback to package file - cqldocs_url = 'file:///usr/share/doc/cassandra/CQL.html' - return cqldocs_url + """ + Determine the URL for CQL documentation. + + Priority order: + 1. Local development/tarball path: {path}/doc/cql3/CQL.html + 2. Linux package path: /usr/share/doc/cassandra/CQL.html + 3. macOS path: /usr/local/share/doc/cassandra/CQL.html + 4. Bundled package resource (for pip installs, etc.) + 5. Online documentation URL (fallback) + """ + # Check local dev/tarball path + local_path = os.path.join(path, 'doc', 'cql3', 'CQL.html') + if os.path.exists(local_path): + return 'file://' + os.path.abspath(local_path) + + # Check system package installation paths + for system_path in ['/usr/share/doc/cassandra/CQL.html', + '/usr/local/share/doc/cassandra/CQL.html']: + if os.path.exists(system_path): + return 'file://' + system_path + + # Try to load from package resources + resource_url = _get_docs_from_package_resource() + if resource_url: + return resource_url + + # Fall back to online documentation + return Shell.DEFAULT_CQLDOCS_URL + + +def _get_docs_from_package_resource(): + """ + Attempt to load CQL documentation from package resources. + Returns a file:// URL to the resource, or None if unavailable. + """ + try: + if sys.version_info >= (3, 9): + from importlib.resources import files, as_file + resource = files('cqlshlib.resources').joinpath('CQL.html') + with as_file(resource) as path: + if path.exists(): + return 'file://' + str(path.resolve()) + else: + # Python 3.8 compatibility + from importlib.resources import path as resource_path + with resource_path('cqlshlib.resources', 'CQL.html') as path: + if path.exists(): + return 'file://' + str(path.resolve()) + except Exception: + pass + return None def insert_driver_hooks(): diff --git a/pylib/cqlshlib/resources/__init__.py b/pylib/cqlshlib/resources/__init__.py new file mode 100644 index 000000000000..d4cd24963b28 --- /dev/null +++ b/pylib/cqlshlib/resources/__init__.py @@ -0,0 +1,24 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Bundled resources for cqlshlib. + +This package contains static resources (like CQL documentation) that are +bundled with cqlshlib for distribution as a Python package. These resources +are used as fallbacks when the documentation cannot be found in the standard +installation paths. +""" diff --git a/pylib/cqlshlib/test/test_docspath.py b/pylib/cqlshlib/test/test_docspath.py new file mode 100644 index 000000000000..e4cca349e257 --- /dev/null +++ b/pylib/cqlshlib/test/test_docspath.py @@ -0,0 +1,140 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import tempfile +from unittest.mock import patch + +from .basecase import BaseTestCase +from cqlshlib.cqlshmain import get_docspath, _get_docs_from_package_resource, Shell + + +class TestGetDocspath(BaseTestCase): + """ + Tests for the get_docspath() function. + + Verifies that CQL documentation paths are resolved according to the + function's priority logic. + """ + + def test_local_dev_path(self): + """Local doc/cql3/CQL.html takes precedence over all other paths.""" + with tempfile.TemporaryDirectory() as tmpdir: + docs_dir = os.path.join(tmpdir, 'doc', 'cql3') + os.makedirs(docs_dir) + docs_file = os.path.join(docs_dir, 'CQL.html') + with open(docs_file, 'w') as f: + f.write('') + + result = get_docspath(tmpdir) + + self.assertTrue(result.startswith('file://')) + self.assertIn('doc/cql3/CQL.html', result) + self.assertEqual(result, 'file://' + os.path.abspath(docs_file)) + + def test_linux_package_path(self): + """Linux package path when local path doesn't exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch('os.path.exists') as mock_exists: + def exists_side_effect(path): + if path == os.path.join(tmpdir, 'doc', 'cql3', 'CQL.html'): + return False + if path == '/usr/share/doc/cassandra/CQL.html': + return True + return False + + mock_exists.side_effect = exists_side_effect + + result = get_docspath(tmpdir) + + self.assertEqual(result, 'file:///usr/share/doc/cassandra/CQL.html') + + def test_macos_path(self): + """macOS path when local and Linux paths don't exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch('os.path.exists') as mock_exists: + def exists_side_effect(path): + if path == os.path.join(tmpdir, 'doc', 'cql3', 'CQL.html'): + return False + if path == '/usr/share/doc/cassandra/CQL.html': + return False + if path == '/usr/local/share/doc/cassandra/CQL.html': + return True + return False + + mock_exists.side_effect = exists_side_effect + + result = get_docspath(tmpdir) + + self.assertEqual(result, 'file:///usr/local/share/doc/cassandra/CQL.html') + + def test_package_resource(self): + """Package resource when filesystem paths don't exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch('os.path.exists', return_value=False): + with patch('cqlshlib.cqlshmain._get_docs_from_package_resource') as mock_resource: + mock_resource.return_value = 'file:///some/resource/path/CQL.html' + + result = get_docspath(tmpdir) + + self.assertEqual(result, 'file:///some/resource/path/CQL.html') + mock_resource.assert_called_once() + + def test_online_url_fallback(self): + """Online documentation URL when all local paths fail.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch('os.path.exists', return_value=False): + with patch('cqlshlib.cqlshmain._get_docs_from_package_resource', return_value=None): + result = get_docspath(tmpdir) + + self.assertEqual(result, Shell.DEFAULT_CQLDOCS_URL) + + +class TestGetDocsFromPackageResource(BaseTestCase): + """Tests for the _get_docs_from_package_resource() function.""" + + def test_returns_none_on_import_error(self): + """Should return None if importlib.resources is not available.""" + with patch.dict('sys.modules', {'importlib.resources': None}): + with patch('cqlshlib.cqlshmain.sys.version_info', (3, 9)): + with patch('builtins.__import__', side_effect=ImportError): + result = _get_docs_from_package_resource() + self.assertIsNone(result) + + def test_returns_none_when_resource_not_found(self): + """Should return None if the resource file doesn't exist.""" + from unittest.mock import MagicMock + from contextlib import contextmanager + + @contextmanager + def mock_as_file(resource): + mock_path = MagicMock() + mock_path.exists.return_value = False + yield mock_path + + with patch('cqlshlib.cqlshmain.sys.version_info', (3, 9)): + with patch('importlib.resources.files') as mock_files: + with patch('importlib.resources.as_file', mock_as_file): + mock_files.return_value.joinpath.return_value = MagicMock() + result = _get_docs_from_package_resource() + self.assertIsNone(result) + + def test_exception_handling(self): + """Should handle exceptions gracefully and return None.""" + with patch('cqlshlib.cqlshmain.sys.version_info', (3, 9)): + with patch('importlib.resources.files', side_effect=Exception("Test error")): + result = _get_docs_from_package_resource() + self.assertIsNone(result) diff --git a/pylib/setup.py b/pylib/setup.py index 1dfd8cdc89a0..c04827fde782 100755 --- a/pylib/setup.py +++ b/pylib/setup.py @@ -30,6 +30,10 @@ def get_extensions(): setup( name="cassandra-pylib", description="Cassandra Python Libraries", - packages=["cqlshlib"], + packages=["cqlshlib", "cqlshlib.resources"], + package_data={ + "cqlshlib.resources": ["CQL.html", "CQL.css"], + }, + include_package_data=True, ext_modules=get_extensions(), ) From b954e0cd461dec6596823432b265fb958d7a94c6 Mon Sep 17 00:00:00 2001 From: Brian Goins <340396+briangoins@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:26:49 -0500 Subject: [PATCH 2/6] test python 3.8 compatibility code for get_docspath --- pylib/cqlshlib/test/test_docspath.py | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/pylib/cqlshlib/test/test_docspath.py b/pylib/cqlshlib/test/test_docspath.py index e4cca349e257..8f363fac0ee0 100644 --- a/pylib/cqlshlib/test/test_docspath.py +++ b/pylib/cqlshlib/test/test_docspath.py @@ -138,3 +138,52 @@ def test_exception_handling(self): with patch('importlib.resources.files', side_effect=Exception("Test error")): result = _get_docs_from_package_resource() self.assertIsNone(result) + + def test_python38_returns_none_on_import_error(self): + """Should return None if importlib.resources is not available on Python 3.8.""" + with patch.dict('sys.modules', {'importlib.resources': None}): + with patch('cqlshlib.cqlshmain.sys.version_info', (3, 8)): + with patch('builtins.__import__', side_effect=ImportError): + result = _get_docs_from_package_resource() + self.assertIsNone(result) + + def test_python38_returns_none_when_resource_not_found(self): + """Should return None if the resource file doesn't exist on Python 3.8.""" + from unittest.mock import MagicMock + from contextlib import contextmanager + + @contextmanager + def mock_resource_path(package, resource): + mock_path = MagicMock() + mock_path.exists.return_value = False + yield mock_path + + with patch('cqlshlib.cqlshmain.sys.version_info', (3, 8)): + with patch('importlib.resources.path', mock_resource_path): + result = _get_docs_from_package_resource() + self.assertIsNone(result) + + def test_python38_exception_handling(self): + """Should handle exceptions gracefully and return None on Python 3.8.""" + with patch('cqlshlib.cqlshmain.sys.version_info', (3, 8)): + with patch('importlib.resources.path', side_effect=Exception("Test error")): + result = _get_docs_from_package_resource() + self.assertIsNone(result) + + def test_python38_returns_file_url_when_resource_exists(self): + """Should return file:// URL when resource exists on Python 3.8.""" + from unittest.mock import MagicMock + from contextlib import contextmanager + from pathlib import Path + + @contextmanager + def mock_resource_path(package, resource): + mock_path = MagicMock(spec=Path) + mock_path.exists.return_value = True + mock_path.resolve.return_value = Path('/fake/path/CQL.html') + yield mock_path + + with patch('cqlshlib.cqlshmain.sys.version_info', (3, 8)): + with patch('importlib.resources.path', mock_resource_path): + result = _get_docs_from_package_resource() + self.assertEqual(result, 'file:///fake/path/CQL.html') From 4576fa9ca5e8f048dcf6df76e28cb2e195ecf89e Mon Sep 17 00:00:00 2001 From: Brian Goins <340396+briangoins@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:27:37 -0500 Subject: [PATCH 3/6] remove redundant package directive --- pylib/setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pylib/setup.py b/pylib/setup.py index c04827fde782..c23b13c708b4 100755 --- a/pylib/setup.py +++ b/pylib/setup.py @@ -34,6 +34,5 @@ def get_extensions(): package_data={ "cqlshlib.resources": ["CQL.html", "CQL.css"], }, - include_package_data=True, ext_modules=get_extensions(), ) From 92131e157404da74e0517b26c21e2a12338b99c7 Mon Sep 17 00:00:00 2001 From: Brian Goins <340396+briangoins@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:31:11 -0500 Subject: [PATCH 4/6] mkdir on `copy-cql-docs-to-pylib` task --- build.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/build.xml b/build.xml index 78f54c03797d..03aec6ad1da8 100644 --- a/build.xml +++ b/build.xml @@ -511,6 +511,7 @@ + From 01d604e98d78dae12d98757a532f271265554fe0 Mon Sep 17 00:00:00 2001 From: Brian Goins <340396+briangoins@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:42:51 -0500 Subject: [PATCH 5/6] fix context manager pattern --- pylib/cqlshlib/cqlshmain.py | 26 +++++---- pylib/cqlshlib/test/test_docspath.py | 81 +++++++++++++++------------- 2 files changed, 61 insertions(+), 46 deletions(-) diff --git a/pylib/cqlshlib/cqlshmain.py b/pylib/cqlshlib/cqlshmain.py index 2bd2cc9303b0..5f8ccff509d8 100755 --- a/pylib/cqlshlib/cqlshmain.py +++ b/pylib/cqlshlib/cqlshmain.py @@ -2159,20 +2159,28 @@ def _get_docs_from_package_resource(): """ Attempt to load CQL documentation from package resources. Returns a file:// URL to the resource, or None if unavailable. + + Note: This only works for packages installed on the filesystem. + For zipped packages, returns None and the caller falls back to online docs. """ try: + from pathlib import Path if sys.version_info >= (3, 9): - from importlib.resources import files, as_file + from importlib.resources import files resource = files('cqlshlib.resources').joinpath('CQL.html') - with as_file(resource) as path: - if path.exists(): - return 'file://' + str(path.resolve()) + # Convert to path and check if it exists on the real filesystem. + # For zipped packages, this path won't exist, so we fall back to online docs. + resource_path = Path(str(resource)) + if resource_path.is_file(): + return 'file://' + str(resource_path.resolve()) else: - # Python 3.8 compatibility - from importlib.resources import path as resource_path - with resource_path('cqlshlib.resources', 'CQL.html') as path: - if path.exists(): - return 'file://' + str(path.resolve()) + # Python 3.8 compatibility: locate the package directory directly + import importlib.util + spec = importlib.util.find_spec('cqlshlib.resources') + if spec and spec.origin: + resource_path = Path(spec.origin).parent / 'CQL.html' + if resource_path.is_file(): + return 'file://' + str(resource_path.resolve()) except Exception: pass return None diff --git a/pylib/cqlshlib/test/test_docspath.py b/pylib/cqlshlib/test/test_docspath.py index 8f363fac0ee0..7d465f40942a 100644 --- a/pylib/cqlshlib/test/test_docspath.py +++ b/pylib/cqlshlib/test/test_docspath.py @@ -115,22 +115,27 @@ def test_returns_none_on_import_error(self): self.assertIsNone(result) def test_returns_none_when_resource_not_found(self): - """Should return None if the resource file doesn't exist.""" + """Should return None if the resource file doesn't exist on filesystem.""" from unittest.mock import MagicMock - from contextlib import contextmanager - - @contextmanager - def mock_as_file(resource): - mock_path = MagicMock() - mock_path.exists.return_value = False - yield mock_path with patch('cqlshlib.cqlshmain.sys.version_info', (3, 9)): with patch('importlib.resources.files') as mock_files: - with patch('importlib.resources.as_file', mock_as_file): - mock_files.return_value.joinpath.return_value = MagicMock() + mock_files.return_value.joinpath.return_value = '/wrong/path/CQL.html' + result = _get_docs_from_package_resource() + self.assertIsNone(result) + + def test_returns_file_url_when_resource_exists(self): + """Should return file:// URL when resource exists on filesystem.""" + with tempfile.TemporaryDirectory() as tmpdir: + resource_file = os.path.join(tmpdir, 'CQL.html') + with open(resource_file, 'w') as f: + f.write('') + + with patch('cqlshlib.cqlshmain.sys.version_info', (3, 9)): + with patch('importlib.resources.files') as mock_files: + mock_files.return_value.joinpath.return_value = resource_file result = _get_docs_from_package_resource() - self.assertIsNone(result) + self.assertEqual(result, 'file://' + os.path.realpath(resource_file)) def test_exception_handling(self): """Should handle exceptions gracefully and return None.""" @@ -140,50 +145,52 @@ def test_exception_handling(self): self.assertIsNone(result) def test_python38_returns_none_on_import_error(self): - """Should return None if importlib.resources is not available on Python 3.8.""" - with patch.dict('sys.modules', {'importlib.resources': None}): + """Should return None if importlib.util is not available on Python 3.8.""" + with patch.dict('sys.modules', {'importlib.util': None}): with patch('cqlshlib.cqlshmain.sys.version_info', (3, 8)): with patch('builtins.__import__', side_effect=ImportError): result = _get_docs_from_package_resource() self.assertIsNone(result) + def test_python38_returns_none_when_spec_not_found(self): + """Should return None if package spec is not found on Python 3.8.""" + with patch('cqlshlib.cqlshmain.sys.version_info', (3, 8)): + with patch('importlib.util.find_spec', return_value=None): + result = _get_docs_from_package_resource() + self.assertIsNone(result) + def test_python38_returns_none_when_resource_not_found(self): """Should return None if the resource file doesn't exist on Python 3.8.""" from unittest.mock import MagicMock - from contextlib import contextmanager - @contextmanager - def mock_resource_path(package, resource): - mock_path = MagicMock() - mock_path.exists.return_value = False - yield mock_path + mock_spec = MagicMock() + mock_spec.origin = '/wrong/package/__init__.py' with patch('cqlshlib.cqlshmain.sys.version_info', (3, 8)): - with patch('importlib.resources.path', mock_resource_path): - result = _get_docs_from_package_resource() - self.assertIsNone(result) - - def test_python38_exception_handling(self): - """Should handle exceptions gracefully and return None on Python 3.8.""" - with patch('cqlshlib.cqlshmain.sys.version_info', (3, 8)): - with patch('importlib.resources.path', side_effect=Exception("Test error")): + with patch('importlib.util.find_spec', return_value=mock_spec): result = _get_docs_from_package_resource() self.assertIsNone(result) def test_python38_returns_file_url_when_resource_exists(self): """Should return file:// URL when resource exists on Python 3.8.""" from unittest.mock import MagicMock - from contextlib import contextmanager - from pathlib import Path - @contextmanager - def mock_resource_path(package, resource): - mock_path = MagicMock(spec=Path) - mock_path.exists.return_value = True - mock_path.resolve.return_value = Path('/fake/path/CQL.html') - yield mock_path + with tempfile.TemporaryDirectory() as tmpdir: + resource_file = os.path.join(tmpdir, 'CQL.html') + with open(resource_file, 'w') as f: + f.write('') + + mock_spec = MagicMock() + mock_spec.origin = os.path.join(tmpdir, '__init__.py') + + with patch('cqlshlib.cqlshmain.sys.version_info', (3, 8)): + with patch('importlib.util.find_spec', return_value=mock_spec): + result = _get_docs_from_package_resource() + self.assertEqual(result, 'file://' + os.path.realpath(resource_file)) + def test_python38_exception_handling(self): + """Should handle exceptions gracefully and return None on Python 3.8.""" with patch('cqlshlib.cqlshmain.sys.version_info', (3, 8)): - with patch('importlib.resources.path', mock_resource_path): + with patch('importlib.util.find_spec', side_effect=Exception("Test error")): result = _get_docs_from_package_resource() - self.assertEqual(result, 'file:///fake/path/CQL.html') + self.assertIsNone(result) From 37e5ab759f7f8ddbfe0a3170d1efff55e37e2772 Mon Sep 17 00:00:00 2001 From: Brian Goins <340396+briangoins@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:43:02 -0500 Subject: [PATCH 6/6] explanatory comment for exception --- pylib/cqlshlib/cqlshmain.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pylib/cqlshlib/cqlshmain.py b/pylib/cqlshlib/cqlshmain.py index 5f8ccff509d8..e8412995e5d7 100755 --- a/pylib/cqlshlib/cqlshmain.py +++ b/pylib/cqlshlib/cqlshmain.py @@ -2182,6 +2182,8 @@ def _get_docs_from_package_resource(): if resource_path.is_file(): return 'file://' + str(resource_path.resolve()) except Exception: + # Any error while loading the bundled CQL docs is non-fatal; + # pass to fall back to other locations. pass return None