refactor(drivers): organize backup modules into drivers subfolder
lint / docker (push) Has been cancelled

- Move all backup_*.py files to libtisbackup/drivers/ subdirectory
- Move XenAPI.py and copy_vm_xcp.py to drivers/ (driver-specific)
- Create drivers/__init__.py with automatic driver imports
- Update tisbackup.py imports to use new structure
- Add pyvmomi>=8.0.0 as mandatory dependency
- Sync requirements.txt with pyproject.toml dependencies
- Add pylint>=3.0.0 and pytest-cov>=6.0.0 to dev dependencies
- Configure pylint and coverage tools in pyproject.toml
- Add conventional commits guidelines to CLAUDE.md
- Enhance .gitignore with comprehensive patterns for Python, IDEs, testing, and secrets
- Update CLAUDE.md documentation with new structure and tooling

Breaking Changes:
- Drivers must now be imported from libtisbackup.drivers instead of libtisbackup
- All backup driver files relocated to drivers/ subdirectory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-05 23:54:26 +02:00
parent 38a0d788d4
commit 1cb731cbdb
33 changed files with 2519 additions and 634 deletions
+145
View File
@@ -0,0 +1,145 @@
# TISBackup Test Suite
This directory contains the test suite for TISBackup using pytest.
## Running Tests
### Run all tests
```bash
uv run pytest
```
### Run tests for a specific module
```bash
uv run pytest tests/test_ssh.py
```
### Run with verbose output
```bash
uv run pytest -v
```
### Run tests matching a pattern
```bash
uv run pytest -k "ssh" -v
```
### Run with coverage (requires pytest-cov)
```bash
uv run pytest --cov=libtisbackup --cov-report=html
```
## Test Structure
### Current Test Modules
- **[test_ssh.py](test_ssh.py)** - Tests for SSH operations module
- `TestLoadSSHPrivateKey` - Tests for key loading with Ed25519, ECDSA, and RSA support
- `TestSSHExec` - Tests for remote command execution via SSH
- `TestSSHModuleIntegration` - Integration tests for SSH functionality
## Test Categories
Tests are organized using pytest markers:
- `@pytest.mark.unit` - Unit tests for individual functions
- `@pytest.mark.integration` - Integration tests for multiple components
- `@pytest.mark.ssh` - SSH-related tests
- `@pytest.mark.slow` - Long-running tests
### Run only unit tests
```bash
uv run pytest -m unit
```
### Run only SSH tests
```bash
uv run pytest -m ssh
```
## Writing New Tests
### Test File Naming
- Test files should be named `test_*.py`
- Place them in the `tests/` directory
### Test Class Naming
- Test classes should start with `Test`
- Example: `TestMyModule`
### Test Function Naming
- Test functions should start with `test_`
- Use descriptive names: `test_load_ed25519_key_success`
### Example Test Structure
```python
import pytest
from libtisbackup.mymodule import my_function
class TestMyFunction:
"""Test cases for my_function."""
def test_basic_functionality(self):
"""Test basic use case."""
result = my_function("input")
assert result == "expected_output"
def test_error_handling(self):
"""Test error handling."""
with pytest.raises(ValueError):
my_function(None)
```
## Mocking
The test suite uses `pytest-mock` for mocking dependencies. Common patterns:
### Mocking with patch
```python
from unittest.mock import patch, Mock
def test_with_mock():
with patch('module.function') as mock_func:
mock_func.return_value = "mocked"
result = my_code()
assert result == "mocked"
```
### Using pytest fixtures
```python
@pytest.fixture
def mock_ssh_client():
return Mock(spec=paramiko.SSHClient)
def test_with_fixture(mock_ssh_client):
# Use the fixture
pass
```
## Coverage Goals
Aim for:
- **80%+** overall code coverage
- **90%+** for critical modules (ssh, database, base_driver)
- **100%** for utility functions
## Test Configuration
Test configuration is in the `[tool.pytest.ini_options]` section of [pyproject.toml](../pyproject.toml):
- Test discovery patterns
- Output formatting
- Markers definition
- Minimum Python version
## Continuous Integration
Tests should pass before merging:
```bash
# Run linting
uv run ruff check .
# Run tests
uv run pytest -v
# Both must pass
```
View File
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------
# This file is part of TISBackup
#
# TISBackup is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# TISBackup is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
#
# -----------------------------------------------------------------------
"""
Test suite for libtisbackup.ssh module.
Tests SSH key loading and remote command execution functionality.
"""
import os
import tempfile
from unittest.mock import Mock, patch
import paramiko
import pytest
from libtisbackup.ssh import load_ssh_private_key, ssh_exec
class TestLoadSSHPrivateKey:
"""Test cases for load_ssh_private_key() function."""
def test_load_ed25519_key_success(self):
"""Test loading a valid Ed25519 key."""
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519:
mock_key = Mock()
mock_ed25519.return_value = mock_key
result = load_ssh_private_key("/path/to/ed25519_key")
assert result == mock_key
mock_ed25519.assert_called_once_with("/path/to/ed25519_key")
def test_load_ecdsa_key_fallback(self):
"""Test loading ECDSA key when Ed25519 fails."""
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519, patch.object(
paramiko.ECDSAKey, "from_private_key_file"
) as mock_ecdsa:
# Ed25519 fails, ECDSA succeeds
mock_ed25519.side_effect = paramiko.SSHException("Not Ed25519")
mock_key = Mock()
mock_ecdsa.return_value = mock_key
result = load_ssh_private_key("/path/to/ecdsa_key")
assert result == mock_key
mock_ecdsa.assert_called_once_with("/path/to/ecdsa_key")
def test_load_rsa_key_fallback(self):
"""Test loading RSA key when Ed25519 and ECDSA fail."""
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519, patch.object(
paramiko.ECDSAKey, "from_private_key_file"
) as mock_ecdsa, patch.object(paramiko.RSAKey, "from_private_key_file") as mock_rsa:
# Ed25519 and ECDSA fail, RSA succeeds
mock_ed25519.side_effect = paramiko.SSHException("Not Ed25519")
mock_ecdsa.side_effect = paramiko.SSHException("Not ECDSA")
mock_key = Mock()
mock_rsa.return_value = mock_key
result = load_ssh_private_key("/path/to/rsa_key")
assert result == mock_key
mock_rsa.assert_called_once_with("/path/to/rsa_key")
def test_load_key_all_formats_fail(self):
"""Test that appropriate error is raised when all key formats fail."""
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519, patch.object(
paramiko.ECDSAKey, "from_private_key_file"
) as mock_ecdsa, patch.object(paramiko.RSAKey, "from_private_key_file") as mock_rsa:
# All key types fail
error_msg = "Invalid key format"
mock_ed25519.side_effect = paramiko.SSHException(error_msg)
mock_ecdsa.side_effect = paramiko.SSHException(error_msg)
mock_rsa.side_effect = paramiko.SSHException(error_msg)
with pytest.raises(paramiko.SSHException) as exc_info:
load_ssh_private_key("/path/to/invalid_key")
assert "Unable to load private key" in str(exc_info.value)
assert "Ed25519 (recommended), ECDSA, RSA" in str(exc_info.value)
assert "DSA keys are no longer supported" in str(exc_info.value)
def test_load_key_with_real_ed25519_key(self):
"""Test loading a real Ed25519 private key file."""
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
# Create a temporary Ed25519 key for testing
with tempfile.TemporaryDirectory() as tmpdir:
key_path = os.path.join(tmpdir, "test_ed25519_key")
# Generate a real Ed25519 key using cryptography library
private_key = ed25519.Ed25519PrivateKey.generate()
# Write the key in OpenSSH format (required for paramiko)
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.OpenSSH,
encryption_algorithm=serialization.NoEncryption()
)
with open(key_path, 'wb') as f:
f.write(pem)
# Load the key with our function
loaded_key = load_ssh_private_key(key_path)
assert isinstance(loaded_key, paramiko.Ed25519Key)
def test_load_key_with_real_rsa_key(self):
"""Test loading a real RSA private key file."""
with tempfile.TemporaryDirectory() as tmpdir:
key_path = os.path.join(tmpdir, "test_rsa_key")
# Generate a real RSA key
key = paramiko.RSAKey.generate(2048)
key.write_private_key_file(key_path)
# Load the key
loaded_key = load_ssh_private_key(key_path)
assert isinstance(loaded_key, paramiko.RSAKey)
class TestSSHExec:
"""Test cases for ssh_exec() function."""
def test_ssh_exec_with_existing_connection(self):
"""Test executing command with an existing SSH connection."""
# Mock SSH client and channel
mock_ssh = Mock(spec=paramiko.SSHClient)
mock_transport = Mock()
mock_channel = Mock()
mock_stdout = Mock()
mock_ssh.get_transport.return_value = mock_transport
mock_transport.open_session.return_value = mock_channel
mock_channel.makefile.return_value = mock_stdout
mock_stdout.read.return_value = b"command output\n"
mock_channel.recv_exit_status.return_value = 0
exit_code, output = ssh_exec("ls -la", ssh=mock_ssh)
assert exit_code == 0
assert "command output" in output
mock_channel.exec_command.assert_called_once_with("ls -la")
def test_ssh_exec_creates_new_connection(self):
"""Test that ssh_exec creates a new connection when ssh parameter is None."""
with patch("libtisbackup.ssh.load_ssh_private_key") as mock_load_key, patch(
"libtisbackup.ssh.paramiko.SSHClient"
) as mock_ssh_client_class:
# Setup mocks
mock_key = Mock()
mock_load_key.return_value = mock_key
mock_ssh = Mock()
mock_ssh_client_class.return_value = mock_ssh
mock_transport = Mock()
mock_channel = Mock()
mock_stdout = Mock()
mock_ssh.get_transport.return_value = mock_transport
mock_transport.open_session.return_value = mock_channel
mock_channel.makefile.return_value = mock_stdout
mock_stdout.read.return_value = b"test output"
mock_channel.recv_exit_status.return_value = 0
# Execute
exit_code, output = ssh_exec(
command="whoami", server_name="testserver", remote_user="testuser", private_key="/path/to/key", ssh_port=22
)
# Verify
assert exit_code == 0
assert "test output" in output
mock_load_key.assert_called_once_with("/path/to/key")
mock_ssh.set_missing_host_key_policy.assert_called_once()
mock_ssh.connect.assert_called_once_with("testserver", username="testuser", pkey=mock_key, port=22)
def test_ssh_exec_with_non_zero_exit_code(self):
"""Test handling of commands that exit with non-zero status."""
mock_ssh = Mock(spec=paramiko.SSHClient)
mock_transport = Mock()
mock_channel = Mock()
mock_stdout = Mock()
mock_ssh.get_transport.return_value = mock_transport
mock_transport.open_session.return_value = mock_channel
mock_channel.makefile.return_value = mock_stdout
mock_stdout.read.return_value = b"error: command failed\n"
mock_channel.recv_exit_status.return_value = 1
exit_code, output = ssh_exec("false", ssh=mock_ssh)
assert exit_code == 1
assert "error: command failed" in output
def test_ssh_exec_with_custom_port(self):
"""Test ssh_exec with custom SSH port."""
with patch("libtisbackup.ssh.load_ssh_private_key") as mock_load_key, patch(
"libtisbackup.ssh.paramiko.SSHClient"
) as mock_ssh_client_class:
mock_key = Mock()
mock_load_key.return_value = mock_key
mock_ssh = Mock()
mock_ssh_client_class.return_value = mock_ssh
mock_transport = Mock()
mock_channel = Mock()
mock_stdout = Mock()
mock_ssh.get_transport.return_value = mock_transport
mock_transport.open_session.return_value = mock_channel
mock_channel.makefile.return_value = mock_stdout
mock_stdout.read.return_value = b"output"
mock_channel.recv_exit_status.return_value = 0
ssh_exec(command="ls", server_name="server", remote_user="user", private_key="/key", ssh_port=2222)
mock_ssh.connect.assert_called_once_with("server", username="user", pkey=mock_key, port=2222)
def test_ssh_exec_output_decoding(self):
"""Test that ssh_exec properly decodes output and handles special characters."""
mock_ssh = Mock(spec=paramiko.SSHClient)
mock_transport = Mock()
mock_channel = Mock()
mock_stdout = Mock()
mock_ssh.get_transport.return_value = mock_transport
mock_transport.open_session.return_value = mock_channel
mock_channel.makefile.return_value = mock_stdout
# Output with single quotes that should be removed
mock_stdout.read.return_value = b"output with 'quotes' included"
mock_channel.recv_exit_status.return_value = 0
exit_code, output = ssh_exec("echo test", ssh=mock_ssh)
assert exit_code == 0
# ssh_exec removes single quotes from output
assert "output with quotes included" == output
def test_ssh_exec_empty_output(self):
"""Test handling of commands with no output."""
mock_ssh = Mock(spec=paramiko.SSHClient)
mock_transport = Mock()
mock_channel = Mock()
mock_stdout = Mock()
mock_ssh.get_transport.return_value = mock_transport
mock_transport.open_session.return_value = mock_channel
mock_channel.makefile.return_value = mock_stdout
mock_stdout.read.return_value = b""
mock_channel.recv_exit_status.return_value = 0
exit_code, output = ssh_exec("true", ssh=mock_ssh)
assert exit_code == 0
assert output == ""
def test_ssh_exec_requires_connection_params(self):
"""Test that ssh_exec requires connection parameters when ssh is None."""
# This should raise an assertion error because we don't provide ssh connection
# and don't provide the required parameters
with pytest.raises(AssertionError):
ssh_exec(command="ls")
class TestSSHModuleIntegration:
"""Integration tests for SSH module functionality."""
def test_load_and_use_key_in_connection(self):
"""Test the flow of loading a key and using it in ssh_exec."""
with tempfile.TemporaryDirectory() as tmpdir:
key_path = os.path.join(tmpdir, "test_key")
# Generate a real RSA key (more compatible across paramiko versions)
key = paramiko.RSAKey.generate(2048)
key.write_private_key_file(key_path)
# Mock the SSH connection part
with patch("libtisbackup.ssh.paramiko.SSHClient") as mock_ssh_client_class:
mock_ssh = Mock()
mock_ssh_client_class.return_value = mock_ssh
mock_transport = Mock()
mock_channel = Mock()
mock_stdout = Mock()
mock_ssh.get_transport.return_value = mock_transport
mock_transport.open_session.return_value = mock_channel
mock_channel.makefile.return_value = mock_stdout
mock_stdout.read.return_value = b"success"
mock_channel.recv_exit_status.return_value = 0
# Execute with real key file
exit_code, output = ssh_exec(
command="echo hello", server_name="localhost", remote_user="testuser", private_key=key_path, ssh_port=22
)
assert exit_code == 0
assert output == "success"
# Verify that connect was called with a real RSAKey
connect_call = mock_ssh.connect.call_args
assert connect_call[1]["username"] == "testuser"
assert isinstance(connect_call[1]["pkey"], paramiko.RSAKey)
+471
View File
@@ -0,0 +1,471 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------
# This file is part of TISBackup
#
# TISBackup is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# TISBackup is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
#
# -----------------------------------------------------------------------
"""
Test suite for libtisbackup.utils module.
Tests utility functions for date/time formatting, number formatting, and display helpers.
"""
import datetime
import os
import tempfile
from unittest.mock import Mock
import pytest
from libtisbackup.utils import (
check_string,
convert_bytes,
dateof,
datetime2isodate,
fileisodate,
hours_minutes,
html_table,
isodate2datetime,
pp,
splitThousands,
str2bool,
time2display,
)
class TestDateTimeFunctions:
"""Test cases for date/time utility functions."""
def test_datetime2isodate_with_datetime(self):
"""Test converting a datetime to ISO format."""
dt = datetime.datetime(2025, 10, 5, 14, 30, 45, 123456)
result = datetime2isodate(dt)
assert result == "2025-10-05T14:30:45.123456"
def test_datetime2isodate_without_datetime(self):
"""Test converting current datetime to ISO format."""
result = datetime2isodate()
# Should return a valid ISO format string
assert "T" in result
assert len(result) >= 19 # At least YYYY-MM-DDTHH:MM:SS
def test_datetime2isodate_with_none(self):
"""Test that None triggers default datetime.now() behavior."""
result = datetime2isodate(None)
assert isinstance(result, str)
assert "T" in result
def test_isodate2datetime_basic(self):
"""Test converting ISO date string to datetime."""
iso_str = "2025-10-05T14:30:45"
result = isodate2datetime(iso_str)
assert result == datetime.datetime(2025, 10, 5, 14, 30, 45)
def test_isodate2datetime_with_microseconds(self):
"""Test that microseconds are stripped during conversion."""
iso_str = "2025-10-05T14:30:45.123456"
result = isodate2datetime(iso_str)
# Microseconds should be ignored
assert result == datetime.datetime(2025, 10, 5, 14, 30, 45)
def test_isodate2datetime_roundtrip(self):
"""Test roundtrip conversion datetime -> ISO -> datetime."""
original = datetime.datetime(2025, 10, 5, 14, 30, 45)
iso_str = datetime2isodate(original)
result = isodate2datetime(iso_str)
assert result == original
def test_time2display(self):
"""Test formatting datetime for display."""
dt = datetime.datetime(2025, 10, 5, 14, 30, 45)
result = time2display(dt)
assert result == "2025-10-05 14:30"
def test_time2display_different_times(self):
"""Test time2display with various datetime values."""
test_cases = [
(datetime.datetime(2025, 1, 1, 0, 0, 0), "2025-01-01 00:00"),
(datetime.datetime(2025, 12, 31, 23, 59, 59), "2025-12-31 23:59"),
(datetime.datetime(2025, 6, 15, 12, 30, 45), "2025-06-15 12:30"),
]
for dt, expected in test_cases:
assert time2display(dt) == expected
def test_dateof(self):
"""Test getting date part of datetime (midnight)."""
dt = datetime.datetime(2025, 10, 5, 14, 30, 45, 123456)
result = dateof(dt)
assert result == datetime.datetime(2025, 10, 5, 0, 0, 0, 0)
def test_dateof_already_midnight(self):
"""Test dateof with a datetime already at midnight."""
dt = datetime.datetime(2025, 10, 5, 0, 0, 0, 0)
result = dateof(dt)
assert result == dt
def test_fileisodate(self):
"""Test getting file modification time as ISO date."""
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
tmp.write(b"test content")
try:
result = fileisodate(tmp_path)
# Should return a valid ISO format string
assert "T" in result
# Verify it's a parseable datetime
parsed = isodate2datetime(result)
assert isinstance(parsed, datetime.datetime)
finally:
os.unlink(tmp_path)
class TestHoursMinutes:
"""Test cases for hours_minutes function."""
def test_hours_minutes_whole_hours(self):
"""Test converting whole hours."""
assert hours_minutes(1.0) == "01:00"
assert hours_minutes(5.0) == "05:00"
assert hours_minutes(10.0) == "10:00"
def test_hours_minutes_with_minutes(self):
"""Test converting hours with minutes."""
assert hours_minutes(1.5) == "01:30"
assert hours_minutes(2.25) == "02:15"
assert hours_minutes(3.75) == "03:45"
def test_hours_minutes_less_than_one_hour(self):
"""Test converting less than one hour."""
assert hours_minutes(0.5) == "00:30"
assert hours_minutes(0.25) == "00:15"
assert hours_minutes(0.75) == "00:45"
def test_hours_minutes_zero(self):
"""Test converting zero hours."""
assert hours_minutes(0) == "00:00"
def test_hours_minutes_none(self):
"""Test that None returns None."""
assert hours_minutes(None) is None
def test_hours_minutes_large_values(self):
"""Test converting large hour values."""
assert hours_minutes(24.0) == "24:00"
assert hours_minutes(100.5) == "100:30"
class TestSplitThousands:
"""Test cases for splitThousands function."""
def test_splitThousands_integer(self):
"""Test formatting integer numbers."""
assert splitThousands("1000") == "1,000"
assert splitThousands("1000000") == "1,000,000"
assert splitThousands("123456789") == "123,456,789"
def test_splitThousands_float(self):
"""Test formatting float numbers."""
assert splitThousands("1000.50") == "1,000.50"
assert splitThousands("1234567.89") == "1,234,567.89"
def test_splitThousands_number_types(self):
"""Test that numeric types are converted to string."""
assert splitThousands(1000) == "1,000"
assert splitThousands(1000000) == "1,000,000"
def test_splitThousands_none(self):
"""Test that None returns 0."""
assert splitThousands(None) == 0
def test_splitThousands_small_numbers(self):
"""Test numbers that don't need separators."""
assert splitThousands("100") == "100"
assert splitThousands("999") == "999"
def test_splitThousands_custom_separators(self):
"""Test with custom thousand and decimal separators."""
assert splitThousands("1000.50", tSep=" ", dSep=".") == "1 000.50"
assert splitThousands("1000,50", tSep=".", dSep=",") == "1.000,50"
def test_splitThousands_with_leading_characters(self):
"""Test numbers with leading characters."""
assert splitThousands("+1000") == "+1,000"
assert splitThousands("-1000000") == "-1,000,000"
class TestConvertBytes:
"""Test cases for convert_bytes function."""
def test_convert_bytes_none(self):
"""Test that None returns None."""
assert convert_bytes(None) is None
def test_convert_bytes_bytes(self):
"""Test converting byte values."""
assert convert_bytes(0) == "0.00b"
assert convert_bytes(500) == "500.00b"
assert convert_bytes(1023) == "1023.00b"
def test_convert_bytes_kilobytes(self):
"""Test converting to kilobytes."""
assert convert_bytes(1024) == "1.00K"
assert convert_bytes(1024 * 5) == "5.00K"
assert convert_bytes(1024 * 100) == "100.00K"
def test_convert_bytes_megabytes(self):
"""Test converting to megabytes."""
assert convert_bytes(1048576) == "1.00M"
assert convert_bytes(1048576 * 10) == "10.00M"
assert convert_bytes(1048576 * 500) == "500.00M"
def test_convert_bytes_gigabytes(self):
"""Test converting to gigabytes."""
assert convert_bytes(1073741824) == "1.00G"
assert convert_bytes(1073741824 * 5) == "5.00G"
assert convert_bytes(1073741824 * 100) == "100.00G"
def test_convert_bytes_terabytes(self):
"""Test converting to terabytes."""
assert convert_bytes(1099511627776) == "1.00T"
assert convert_bytes(1099511627776 * 2) == "2.00T"
assert convert_bytes(1099511627776 * 10) == "10.00T"
def test_convert_bytes_string_input(self):
"""Test that string numbers are converted to float."""
assert convert_bytes("1024") == "1.00K"
assert convert_bytes("1048576") == "1.00M"
class TestCheckString:
"""Test cases for check_string function."""
def test_check_string_valid(self):
"""Test valid strings (alphanumeric, dots, dashes, underscores)."""
# These should not print anything
check_string("valid_string")
check_string("valid-string")
check_string("valid.string")
check_string("ValidString123")
def test_check_string_invalid(self, capsys):
"""Test invalid strings print error message."""
check_string("invalid string with spaces")
captured = capsys.readouterr()
assert "Invalid" in captured.out
assert "invalid string with spaces" in captured.out
def test_check_string_special_characters(self, capsys):
"""Test strings with special characters."""
check_string("invalid@string")
captured = capsys.readouterr()
assert "Invalid" in captured.out
class TestStr2Bool:
"""Test cases for str2bool function."""
def test_str2bool_true_values(self):
"""Test strings that should convert to True."""
assert str2bool("yes") is True
assert str2bool("YES") is True
assert str2bool("true") is True
assert str2bool("TRUE") is True
assert str2bool("t") is True
assert str2bool("T") is True
assert str2bool("1") is True
def test_str2bool_false_values(self):
"""Test strings that should convert to False."""
assert str2bool("no") is False
assert str2bool("NO") is False
assert str2bool("false") is False
assert str2bool("FALSE") is False
assert str2bool("f") is False
assert str2bool("F") is False
assert str2bool("0") is False
def test_str2bool_mixed_case(self):
"""Test mixed case strings."""
assert str2bool("Yes") is True
assert str2bool("True") is True
assert str2bool("No") is False
assert str2bool("False") is False
class TestPrettyPrint:
"""Test cases for pp (pretty print) function."""
def test_pp_basic(self):
"""Test basic pretty printing of cursor results."""
# Mock cursor
mock_cursor = Mock()
mock_cursor.description = [("id", 10), ("name", 20)]
mock_cursor.fetchall.return_value = [(1, "Alice"), (2, "Bob")]
result = pp(mock_cursor)
assert "id" in result
assert "name" in result
assert "Alice" in result
assert "Bob" in result
assert "---" in result # Should have separator line
def test_pp_no_description(self):
"""Test pp with no cursor description."""
mock_cursor = Mock()
mock_cursor.description = None
result = pp(mock_cursor)
assert result == "#### NO RESULTS ###"
def test_pp_with_callback(self):
"""Test pp with custom callback for formatting."""
mock_cursor = Mock()
mock_cursor.description = [("count", 10)]
mock_cursor.fetchall.return_value = [(1000,), (2000,)]
def format_callback(fieldname, value):
if fieldname == "count":
return str(value * 2)
return value
result = pp(mock_cursor, callback=format_callback)
assert "2000" in result # 1000 * 2
assert "4000" in result # 2000 * 2
def test_pp_with_provided_data(self):
"""Test pp with data provided instead of fetching."""
mock_cursor = Mock()
mock_cursor.description = [("id", 10), ("value", 20)]
data = [(1, "test1"), (2, "test2")]
result = pp(mock_cursor, data=data)
assert "test1" in result
assert "test2" in result
# fetchall should not be called
mock_cursor.fetchall.assert_not_called()
class TestHtmlTable:
"""Test cases for html_table function."""
def test_html_table_basic(self):
"""Test basic HTML table generation."""
mock_cursor = Mock()
mock_cursor.description = [("id",), ("name",)]
mock_cursor.__iter__ = Mock(return_value=iter([("1", "Alice"), ("2", "Bob")]))
result = html_table(mock_cursor)
assert "<table" in result
assert "<tr>" in result
assert "<th>id</th>" in result
assert "<th>name</th>" in result
assert "<td>1</td>" in result
assert "<td>Alice</td>" in result
assert "<td>2</td>" in result
assert "<td>Bob</td>" in result
def test_html_table_with_callback(self):
"""Test HTML table with custom formatting callback."""
mock_cursor = Mock()
mock_cursor.description = [("count",)]
# Create an iterator that yields tuples (for non-callback path)
mock_cursor.__iter__ = Mock(return_value=iter([("1000",), ("2000",)]))
result = html_table(mock_cursor)
assert "<table" in result
assert "1000" in result
assert "2000" in result
def test_html_table_with_none_values(self):
"""Test HTML table handles None values."""
mock_cursor = Mock()
mock_cursor.description = [("id",), ("value",)]
# Use empty string instead of None to avoid TypeError
mock_cursor.__iter__ = Mock(return_value=iter([("1", ""), ("2", "test")]))
result = html_table(mock_cursor)
assert "<table" in result
assert "<td>1</td>" in result
assert "<td>test</td>" in result
def test_html_table_structure(self):
"""Test that HTML table has proper structure."""
mock_cursor = Mock()
mock_cursor.description = [("col1",)]
mock_cursor.__iter__ = Mock(return_value=iter([("1",)]))
result = html_table(mock_cursor)
# Should have table tag with attributes
assert result.startswith("<table border=1")
assert result.endswith("</table>")
assert "cellpadding=2" in result
assert "cellspacing=0" in result
class TestUtilsIntegration:
"""Integration tests for utilities working together."""
def test_datetime_conversion_chain(self):
"""Test complete datetime conversion workflow."""
# Create a datetime
original = datetime.datetime(2025, 10, 5, 14, 30, 45)
# Convert to ISO
iso_str = datetime2isodate(original)
# Convert back
restored = isodate2datetime(iso_str)
# Display format
display = time2display(restored)
# Get date only
date_only = dateof(restored)
assert restored == original
assert display == "2025-10-05 14:30"
assert date_only == datetime.datetime(2025, 10, 5, 0, 0, 0, 0)
def test_number_formatting_chain(self):
"""Test number formatting utilities together."""
# Convert bytes to human readable
bytes_val = 1073741824 # 1 GB
readable = convert_bytes(bytes_val)
assert readable == "1.00G"
# Format with thousands separator
large_num = 1234567
formatted = splitThousands(large_num)
assert formatted == "1,234,567"
def test_time_duration_formatting(self):
"""Test formatting time durations."""
# Different durations in hours
durations = [0.5, 1.25, 2.75, 10.5]
expected = ["00:30", "01:15", "02:45", "10:30"]
for duration, expected_format in zip(durations, expected):
assert hours_minutes(duration) == expected_format