Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion build.xml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
<property name="build.classes.main" value="${build.classes}/main" />
<property name="javadoc.dir" value="${build.dir}/javadoc"/>
<property name="interface.dir" value="${basedir}/interface"/>
<property name="pylib.resources" value="${basedir}/pylib/cqlshlib/resources"/>
<property name="test.dir" value="${basedir}/test"/>
<property name="test.resources" value="${test.dir}/resources"/>
<property name="test.lib" value="${build.dir}/test/lib"/>
Expand Down Expand Up @@ -458,6 +459,8 @@
<delete dir="${version.properties.dir}" />
<delete dir="${jacoco.export.dir}" />
<delete dir="${jacoco.partials.dir}"/>
<delete file="${pylib.resources}/CQL.html"/>
<delete file="${pylib.resources}/CQL.css"/>
</target>
<target depends="clean" name="cleanall"/>

Expand Down Expand Up @@ -506,6 +509,17 @@
</wikitext-to-html>
</target>

<target name="copy-cql-docs-to-pylib" depends="generate-cql-html"
description="Copy CQL documentation to pylib resources for packaging">
<mkdir dir="${pylib.resources}"/>
<copy todir="${pylib.resources}" failonerror="true">
<fileset dir="doc/cql3">
<include name="CQL.html"/>
<include name="CQL.css"/>
</fileset>
</copy>
</target>

<target name="gen-asciidoc" description="Generate dynamic asciidoc pages" depends="jar" unless="ant.gen-doc.skip">
<exec executable="make" osfamily="unix" dir="${doc.dir}" failonerror="true">
<arg value="gen-asciidoc"/>
Expand Down Expand Up @@ -586,7 +600,7 @@
</javac>
</target>

<target depends="init,gen-cql3-grammar,generate-cql-html,generate-jflex-java"
<target depends="init,gen-cql3-grammar,copy-cql-docs-to-pylib,generate-jflex-java"
name="build-project">
<echo message="${ant.project.name}: ${ant.file}"/>
<!-- Order matters! -->
Expand Down
69 changes: 61 additions & 8 deletions pylib/cqlshlib/cqlshmain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Suggested change
except Exception:
except Exception:
# Any error while loading the bundled CQL docs is non-fatal; fall back to other locations.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added explanatory comment: briangoins@37e5ab7

# 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():
Expand Down
24 changes: 24 additions & 0 deletions pylib/cqlshlib/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
196 changes: 196 additions & 0 deletions pylib/cqlshlib/test/test_docspath.py
Original file line number Diff line number Diff line change
@@ -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('<html></html>')

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('<html></html>')

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('<html></html>')

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)
5 changes: 4 additions & 1 deletion pylib/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ def get_extensions():
setup(
name="cassandra-pylib",
description="Cassandra Python Libraries",
packages=["cqlshlib"],
packages=["cqlshlib", "cqlshlib.resources"],
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bschoening this will be one package installed, cqlshlib, with a resources subpackage

package_data={
"cqlshlib.resources": ["CQL.html", "CQL.css"],
},
ext_modules=get_extensions(),
)