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 b970240df4ec..12272a711e48 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(), )