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..03aec6ad1da8 100644 --- a/build.xml +++ b/build.xml @@ -65,6 +65,7 @@ + @@ -458,6 +459,8 @@ + + @@ -506,6 +509,17 @@ + + + + + + + + + + @@ -586,7 +600,7 @@ - diff --git a/pylib/cqlshlib/cqlshmain.py b/pylib/cqlshlib/cqlshmain.py index 8b26e3307884..e8412995e5d7 100755 --- a/pylib/cqlshlib/cqlshmain.py +++ b/pylib/cqlshlib/cqlshmain.py @@ -2125,14 +2125,67 @@ 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. + + 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 + resource = files('cqlshlib.resources').joinpath('CQL.html') + # 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: 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: + # Any error while loading the bundled CQL docs is non-fatal; + # pass to fall back to other locations. + 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..7d465f40942a --- /dev/null +++ b/pylib/cqlshlib/test/test_docspath.py @@ -0,0 +1,196 @@ +# 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 on filesystem.""" + from unittest.mock import MagicMock + + with patch('cqlshlib.cqlshmain.sys.version_info', (3, 9)): + with patch('importlib.resources.files') as mock_files: + 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.assertEqual(result, 'file://' + os.path.realpath(resource_file)) + + 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) + + def test_python38_returns_none_on_import_error(self): + """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 + + mock_spec = MagicMock() + mock_spec.origin = '/wrong/package/__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.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 + + 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.util.find_spec', 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..c23b13c708b4 100755 --- a/pylib/setup.py +++ b/pylib/setup.py @@ -30,6 +30,9 @@ 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"], + }, ext_modules=get_extensions(), )