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(),
)