From 197a73efc4b9a21268e51371698669d5416c2c10 Mon Sep 17 00:00:00 2001 From: Carrington Muleya <79579279+Carrington-dev@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:41:55 +0200 Subject: [PATCH 1/9] Fix: Error on test_encryption --- .vscode/settings.json | 7 + SETUP_INSTRUCTIONS.md | 324 +++++++++++++++++++++++++++ pytest.ini | 28 +++ requirements copy.txt | 2 - requirements-dev.txt | 18 ++ setup.cfg | 201 +++++++++++++++++ tests/test_diffrent_files.py | 0 tests/test_edge_cases.py | 415 +++++++++++++++++++++++++++++++++++ tests/test_type_casting.py | 404 ++++++++++++++++++++++++++++++++++ 9 files changed, 1397 insertions(+), 2 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 SETUP_INSTRUCTIONS.md create mode 100644 pytest.ini delete mode 100644 requirements copy.txt create mode 100644 requirements-dev.txt delete mode 100644 tests/test_diffrent_files.py create mode 100644 tests/test_edge_cases.py create mode 100644 tests/test_type_casting.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3e99ede --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/SETUP_INSTRUCTIONS.md b/SETUP_INSTRUCTIONS.md new file mode 100644 index 0000000..677dc41 --- /dev/null +++ b/SETUP_INSTRUCTIONS.md @@ -0,0 +1,324 @@ +# DotZen Development Setup Instructions + +## Quick Fix for Current Error + +The error you're seeing is because `pytest-cov` is not installed. Here are your options: + +### Option 1: Install pytest-cov (Recommended) +```bash +pip install pytest-cov +``` + +Then run tests with coverage: +```bash +pytest --cov=dotzen --cov-report=term-missing --cov-report=html +``` + +### Option 2: Run tests without coverage +```bash +pytest +``` + +The `setup.cfg` has been updated to work without coverage by default. + +--- + +## Full Development Setup + +### 1. Install Development Dependencies + +#### Using pip: +```bash +# Install all development dependencies +pip install -r requirements-dev.txt + +# Or install the package in editable mode with dev extras +pip install -e ".[dev]" +``` + +#### Using the package extras: +```bash +# Basic + crypto +pip install -e ".[crypto]" + +# Development tools +pip install -e ".[dev]" + +# Everything +pip install -e ".[all]" +``` + +### 2. Running Tests + +#### Basic test run: +```bash +pytest +``` + +#### With coverage: +```bash +pytest --cov=dotzen --cov-report=term-missing --cov-report=html +``` + +#### Parallel execution (faster): +```bash +pytest -n auto +``` + +#### Specific test file: +```bash +pytest tests/test_config_sources.py -v +``` + +#### Specific test class or function: +```bash +pytest tests/test_config_sources.py::TestEnvironmentSource -v +pytest tests/test_config_sources.py::TestEnvironmentSource::test_load_all_environment_variables -v +``` + +#### With markers: +```bash +# Run only unit tests +pytest -m unit + +# Skip slow tests +pytest -m "not slow" + +# Run only encryption tests +pytest -m encryption +``` + +### 3. Code Quality Tools + +#### Format code with Black: +```bash +black dotzen tests +``` + +#### Sort imports with isort: +```bash +isort dotzen tests +``` + +#### Check code style with flake8: +```bash +flake8 dotzen tests +``` + +#### Type checking with mypy: +```bash +mypy dotzen +``` + +#### Run all quality checks: +```bash +black dotzen tests && isort dotzen tests && flake8 dotzen tests && mypy dotzen +``` + +### 4. Pre-commit Hooks (Optional) + +Set up pre-commit hooks to automatically check code before commits: + +```bash +# Install pre-commit +pip install pre-commit + +# Install the git hooks +pre-commit install + +# Run manually on all files +pre-commit run --all-files +``` + +### 5. Coverage Reports + +After running tests with coverage, view the HTML report: + +```bash +# Generate coverage +pytest --cov=dotzen --cov-report=html + +# Open the report (Windows) +start htmlcov/index.html + +# Open the report (macOS) +open htmlcov/index.html + +# Open the report (Linux) +xdg-open htmlcov/index.html +``` + +--- + +## Project Structure + +``` +dotzen/ +├── dotzen/ +│ ├── __init__.py +│ ├── dotzen.py # Core configuration module +│ ├── encryption.py # Encryption support +│ ├── cli.py # CLI interface +│ └── secrets/ # Cloud secrets (future) +│ ├── __init__.py +│ ├── base.py +│ ├── aws.py +│ ├── azure.py +│ └── gcp.py +├── tests/ +│ ├── __init__.py +│ ├── test_config_sources.py +│ ├── test_config_builder.py +│ ├── test_config_factory.py +│ ├── test_type_casting.py +│ ├── test_edge_cases.py +│ └── test_encryption.py +├── setup.cfg +├── setup.py +├── pyproject.toml +├── requirements-dev.txt +├── README.md +└── LICENSE +``` + +--- + +## Common Issues and Solutions + +### Issue: pytest-cov not found +**Solution:** Install pytest-cov +```bash +pip install pytest-cov +``` + +### Issue: Import errors when running tests +**Solution:** Install the package in editable mode +```bash +pip install -e . +``` + +### Issue: Tests can't find dotzen module +**Solution:** Make sure you're in the project root directory and have installed the package +```bash +cd /path/to/dotzen +pip install -e . +pytest +``` + +### Issue: Coverage not working +**Solution:** Ensure pytest-cov is installed and you're running from the project root +```bash +pip install pytest-cov +pytest --cov=dotzen +``` + +--- + +## CI/CD Integration + +### GitHub Actions Example + +Create `.github/workflows/tests.yml`: + +```yaml +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests with coverage + run: | + pytest --cov=dotzen --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml +``` + +--- + +## Quick Start for Contributors + +```bash +# 1. Clone the repository +git clone https://github.com/yourusername/dotzen.git +cd dotzen + +# 2. Create a virtual environment +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# 3. Install in development mode +pip install -e ".[dev]" + +# 4. Run tests +pytest + +# 5. Make your changes and run tests again +pytest -v + +# 6. Format and check code +black dotzen tests +flake8 dotzen tests + +# 7. Commit and push +git add . +git commit -m "Your changes" +git push +``` + +--- + +## Useful Commands Cheat Sheet + +```bash +# Run all tests +pytest + +# Run with verbose output +pytest -v + +# Run specific test file +pytest tests/test_encryption.py + +# Run tests matching pattern +pytest -k "test_encrypt" + +# Run with coverage +pytest --cov=dotzen --cov-report=html + +# Run in parallel (faster) +pytest -n auto + +# Stop on first failure +pytest -x + +# Show print statements +pytest -s + +# Run last failed tests +pytest --lf + +# Run tests that failed last time, then all others +pytest --ff +``` \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b7fe5e5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,28 @@ +[pytest] +# Test discovery +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Output options +addopts = + --verbose + --strict-markers + --tb=short + +# Custom markers +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests + unit: marks tests as unit tests + encryption: marks tests related to encryption + cloud: marks tests related to cloud providers + +# Coverage options (uncomment if pytest-cov is installed) +# addopts = +# --cov=dotzen +# --cov-report=term-missing +# --cov-report=html +# --cov-report=xml +# --cov-branch \ No newline at end of file diff --git a/requirements copy.txt b/requirements copy.txt deleted file mode 100644 index cffeec6..0000000 --- a/requirements copy.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest -pytest-cov \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7a1a901 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,18 @@ +# Development dependencies for DotZen + +# Testing +pytest>=7.0.0 +pytest-cov>=4.0.0 +pytest-xdist>=3.0.0 # Parallel test execution + +# Code quality +black>=23.0.0 +flake8>=6.0.0 +mypy>=1.0.0 +isort>=5.12.0 + +# Pre-commit hooks +pre-commit>=3.0.0 + +# Optional: Encryption support +cryptography>=41.0.0 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index e69de29..4fa5f82 100644 --- a/setup.cfg +++ b/setup.cfg @@ -0,0 +1,201 @@ +[metadata] +name = dotzen +version = attr: dotzen.__version__ +author = Your Name +author_email = your.email@example.com +description = Peaceful, type-safe Python configuration library with multiple design patterns +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/yourusername/dotzen +project_urls = + Bug Tracker = https://github.com/yourusername/dotzen/issues + Documentation = https://github.com/yourusername/dotzen#readme + Source Code = https://github.com/yourusername/dotzen +classifiers = + Development Status :: 4 - Beta + Intended Audience :: Developers + Topic :: Software Development :: Libraries :: Python Modules + Topic :: System :: Systems Administration + License :: OSI Approved :: MIT License + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + Operating System :: OS Independent + Typing :: Typed +keywords = + configuration + config + environment + settings + dotenv + env + secrets + encryption +license = MIT +license_files = LICENSE + +[options] +packages = find: +python_requires = >=3.7 +install_requires = + # No required dependencies for core functionality +zip_safe = False +include_package_data = True + +[options.packages.find] +exclude = + tests* + docs* + examples* + +[options.extras_require] +# Encryption support (Fernet) +crypto = + cryptography>=41.0.0 + +# Cloud secrets management +aws = + boto3>=1.26.0 + +azure = + azure-keyvault-secrets>=4.7.0 + azure-identity>=1.12.0 + +gcp = + google-cloud-secret-manager>=2.16.0 + +# All cloud providers +cloud = + boto3>=1.26.0 + azure-keyvault-secrets>=4.7.0 + azure-identity>=1.12.0 + google-cloud-secret-manager>=2.16.0 + +# Development dependencies +dev = + pytest>=7.0.0 + pytest-cov>=4.0.0 + pytest-xdist>=3.0.0 + black>=23.0.0 + flake8>=6.0.0 + mypy>=1.0.0 + isort>=5.12.0 + pre-commit>=3.0.0 + +# Documentation +docs = + sphinx>=6.0.0 + sphinx-rtd-theme>=1.2.0 + sphinx-autodoc-typehints>=1.22.0 + +# All optional dependencies +all = + cryptography>=41.0.0 + boto3>=1.26.0 + azure-keyvault-secrets>=4.7.0 + azure-identity>=1.12.0 + google-cloud-secret-manager>=2.16.0 + +[options.entry_points] +console_scripts = + dotzen = dotzen.cli:main + +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + --verbose + --strict-markers + --tb=short + --cov=dotzen + --cov-report=term-missing + --cov-report=html + --cov-report=xml + --cov-branch +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests + unit: marks tests as unit tests + encryption: marks tests related to encryption + cloud: marks tests related to cloud providers + +[coverage:run] +source = dotzen +branch = True +omit = + */tests/* + */test_*.py + */__pycache__/* + */site-packages/* + +[coverage:report] +precision = 2 +show_missing = True +skip_covered = False +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: + if TYPE_CHECKING: + @abstractmethod + +[coverage:html] +directory = htmlcov + +[flake8] +max-line-length = 100 +exclude = + .git, + __pycache__, + build, + dist, + .eggs, + *.egg-info, + .venv, + venv, + .tox +ignore = + E203, # whitespace before ':' + E501, # line too long (handled by black) + W503, # line break before binary operator + W504, # line break after binary operator +per-file-ignores = + __init__.py:F401 + +[mypy] +python_version = 3.7 +warn_return_any = True +warn_unused_configs = True +disallow_untyped_defs = False +disallow_incomplete_defs = False +check_untyped_defs = True +no_implicit_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_no_return = True +warn_unreachable = True +strict_equality = True + +[mypy-tests.*] +disallow_untyped_defs = False + +[isort] +profile = black +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +line_length = 100 +skip_gitignore = True + +[bdist_wheel] +universal = 0 \ No newline at end of file diff --git a/tests/test_diffrent_files.py b/tests/test_diffrent_files.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py new file mode 100644 index 0000000..16ae162 --- /dev/null +++ b/tests/test_edge_cases.py @@ -0,0 +1,415 @@ +""" +tests/test_edge_cases.py - Edge cases and error handling tests +""" + +import pytest +import os +import json +import tempfile +from pathlib import Path +from dotzen.dotzen import ( + ConfigBuilder, + ConfigFactory, + ConfigSingleton, + config, + UndefinedValueError, + ValidationError, + SourceNotFoundError, + UNDEFINED, +) + + +class TestUndefinedSentinel: + """Tests for UNDEFINED sentinel value""" + + def test_undefined_repr(self): + """Test UNDEFINED string representation""" + assert repr(UNDEFINED) == "" + + def test_undefined_is_singleton(self): + """Test UNDEFINED is a singleton""" + from dotzen.dotzen import _Undefined + undefined2 = _Undefined() + # Should have same representation but different instances + assert repr(undefined2) == repr(UNDEFINED) + + def test_undefined_vs_none(self): + """Test UNDEFINED is different from None""" + assert UNDEFINED is not None + assert UNDEFINED != None + + +class TestErrorHandling: + """Tests for error conditions""" + + def setup_method(self): + """Set up test environment""" + os.environ.clear() + self.temp_dir = tempfile.mkdtemp() + + def teardown_method(self): + """Clean up""" + import shutil + shutil.rmtree(self.temp_dir) + + def test_missing_key_without_default(self): + """Test accessing missing key without default raises error""" + config = ConfigBuilder().build() + + with pytest.raises(UndefinedValueError) as exc_info: + config.get('MISSING_KEY') + + assert "MISSING_KEY" in str(exc_info.value) + assert "not found" in str(exc_info.value).lower() + + def test_missing_key_in_chain(self): + """Test missing key in chain of sources""" + env_file = Path(self.temp_dir) / '.env' + env_file.write_text('KEY1=value1\n') + + config = (ConfigBuilder() + .add_environment() + .add_dotenv(str(env_file)) + .build()) + + with pytest.raises(UndefinedValueError) as exc_info: + config.get('MISSING_KEY') + + assert "not found in any configuration source" in str(exc_info.value).lower() + + def test_malformed_env_file(self): + """Test handling malformed .env file""" + env_file = Path(self.temp_dir) / '.env' + env_file.write_text( + "VALID_KEY=value\n" + "NO_EQUALS_SIGN\n" # Invalid line + "ANOTHER_VALID=value2\n" + ) + + config = ConfigBuilder().add_dotenv(str(env_file)).build() + + # Should still load valid keys + assert config.get('VALID_KEY') == 'value' + assert config.get('ANOTHER_VALID') == 'value2' + + def test_malformed_json_file(self): + """Test handling malformed JSON file""" + json_file = Path(self.temp_dir) / 'config.json' + json_file.write_text('{ invalid json }') + + config = ConfigBuilder().add_json(str(json_file)).build() + + # Should raise error when trying to load + with pytest.raises(Exception): # json.JSONDecodeError + config.get('key') + + def test_empty_env_file(self): + """Test handling empty .env file""" + env_file = Path(self.temp_dir) / '.env' + env_file.write_text('') + + config = ConfigBuilder().add_dotenv(str(env_file)).build() + + # Should work, just return default + assert config.get('KEY', default='default') == 'default' + + def test_comments_only_env_file(self): + """Test .env file with only comments""" + env_file = Path(self.temp_dir) / '.env' + env_file.write_text( + "# Comment 1\n" + "# Comment 2\n" + "# Comment 3\n" + ) + + config = ConfigBuilder().add_dotenv(str(env_file)).build() + + assert config.get('KEY', default='default') == 'default' + + def test_permission_denied_file(self): + """Test handling file permission errors""" + import stat + import sys + + env_file = Path(self.temp_dir) / '.env' + env_file.write_text('KEY=value\n') + + # Skip on Windows as file permissions work differently + if sys.platform == 'win32': + pytest.skip("File permission test not applicable on Windows") + + # Remove read permissions (Unix/Linux/Mac only) + os.chmod(env_file, stat.S_IWRITE) + + try: + config = ConfigBuilder().add_dotenv(str(env_file)).build() + + # Should raise PermissionError + with pytest.raises(PermissionError): + config.get('KEY') + finally: + # Restore permissions for cleanup + os.chmod(env_file, stat.S_IREAD | stat.S_IWRITE) + + +class TestEdgeCaseValues: + """Tests for edge case configuration values""" + + def setup_method(self): + """Set up test environment""" + os.environ.clear() + + def test_empty_string_value(self): + """Test handling empty string values""" + os.environ['EMPTY'] = '' + config = ConfigBuilder().add_environment().build() + + assert config.get('EMPTY') == '' + assert config.get('EMPTY', default='default') == '' # Empty string, not default + + def test_whitespace_only_value(self): + """Test handling whitespace-only values""" + os.environ['SPACES'] = ' ' + config = ConfigBuilder().add_environment().build() + + assert config.get('SPACES') == ' ' + + def test_special_characters(self): + """Test handling special characters""" + special_chars = '!@#$%^&*()_+-={}[]|\\:";\'<>?,./' + os.environ['SPECIAL'] = special_chars + config = ConfigBuilder().add_environment().build() + + assert config.get('SPECIAL') == special_chars + + def test_unicode_characters(self): + """Test handling unicode characters""" + os.environ['UNICODE'] = '你好世界 🌍 émoji' + config = ConfigBuilder().add_environment().build() + + assert config.get('UNICODE') == '你好世界 🌍 émoji' + + def test_multiline_value_in_env(self): + """Test handling multiline values""" + # Environment variables don't support newlines directly + os.environ['MULTILINE'] = 'line1\\nline2\\nline3' + config = ConfigBuilder().add_environment().build() + + assert config.get('MULTILINE') == 'line1\\nline2\\nline3' + + def test_very_long_value(self): + """Test handling very long values""" + long_value = 'x' * 10000 + os.environ['LONG'] = long_value + config = ConfigBuilder().add_environment().build() + + assert config.get('LONG') == long_value + assert len(config.get('LONG')) == 10000 + + def test_numeric_string_not_auto_converted(self): + """Test that numeric strings aren't auto-converted""" + os.environ['NUMBER'] = '42' + config = ConfigBuilder().add_environment().build() + + value = config.get('NUMBER') + assert value == '42' + assert isinstance(value, str) # Still a string + + +class TestConcurrentAccess: + """Tests for concurrent access scenarios""" + + def setup_method(self): + """Set up test environment""" + os.environ.clear() + ConfigSingleton.reset() + + def teardown_method(self): + """Clean up""" + ConfigSingleton.reset() + + def test_singleton_thread_safety(self): + """Test singleton access from multiple threads""" + import threading + import time + + os.environ['SHARED'] = 'value' + instances = [] + lock = threading.Lock() + + def get_instance(): + instance = ConfigSingleton.get_instance() + with lock: + instances.append(instance) + time.sleep(0.001) # Small delay to ensure overlap + + threads = [threading.Thread(target=get_instance) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + # All instances should be the same object + first_instance = instances[0] + for inst in instances: + assert inst is first_instance, "Singleton instances should be identical" + + def test_config_immutability(self): + """Test that config values don't change unexpectedly""" + os.environ['KEY'] = 'original' + config = ConfigBuilder().add_environment().build() + + value1 = config.get('KEY') + + # Change environment (shouldn't affect already-loaded config) + os.environ['KEY'] = 'changed' + + # For environment source, it will reflect the change + # This is expected behavior + value2 = config.get('KEY') + assert value2 == 'changed' # Environment is dynamic + + +class TestNestedAndComplexStructures: + """Tests for nested and complex configuration structures""" + + def setup_method(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + + def teardown_method(self): + """Clean up""" + import shutil + shutil.rmtree(self.temp_dir) + + def test_deeply_nested_json(self): + """Test deeply nested JSON structure""" + config_data = { + "level1": { + "level2": { + "level3": { + "level4": { + "level5": { + "value": "deep" + } + } + } + } + } + } + + json_file = Path(self.temp_dir) / 'config.json' + json_file.write_text(json.dumps(config_data)) + + config = ConfigBuilder().add_json(str(json_file)).build() + + value = config.get('level1.level2.level3.level4.level5.value') + assert value == 'deep' + + def test_json_with_arrays(self): + """Test JSON with array values""" + config_data = { + "servers": ["server1", "server2", "server3"], + "ports": [8080, 8081, 8082] + } + + json_file = Path(self.temp_dir) / 'config.json' + json_file.write_text(json.dumps(config_data)) + + config = ConfigBuilder().add_json(str(json_file)).build() + + # Arrays are converted to strings + servers = config.get('servers') + assert "server1" in servers + + def test_mixed_type_json(self): + """Test JSON with mixed types""" + config_data = { + "string": "text", + "number": 42, + "float": 3.14, + "boolean": True, + "null": None, + "nested": { + "inner": "value" + } + } + + json_file = Path(self.temp_dir) / 'config.json' + json_file.write_text(json.dumps(config_data)) + + config = ConfigBuilder().add_json(str(json_file)).build() + + # All converted to strings + assert config.get('string') == 'text' + assert config.get('number') == '42' + assert config.get('float') == '3.14' + assert config.get('boolean') == 'True' + assert config.get('null') == 'None' + assert config.get('nested.inner') == 'value' + + +class TestValidation: + """Tests for validation functionality""" + + def setup_method(self): + """Set up test environment""" + os.environ.clear() + + def test_validator_called_on_get(self): + """Test that validator is called when getting value""" + called = [] + + def validator(key, value): + called.append((key, value)) + + os.environ['KEY'] = 'value' + config = (ConfigBuilder() + .add_environment() + .with_validator(validator) + .build()) + + config.get('KEY') + + assert len(called) == 1 + assert called[0] == ('KEY', 'value') + + def test_validator_can_raise_error(self): + """Test that validator can raise ValidationError""" + def strict_validator(key, value): + if key == 'PORT' and int(value) > 10000: + raise ValidationError("Port must be <= 10000") + + os.environ['PORT'] = '99999' + config = (ConfigBuilder() + .add_environment() + .with_validator(strict_validator) + .build()) + + with pytest.raises(ValidationError, match="Port must be <= 10000"): + config.get('PORT') + + def test_multiple_validators(self): + """Test multiple validators are all called""" + calls = [] + + def validator1(key, value): + calls.append('validator1') + + def validator2(key, value): + calls.append('validator2') + + os.environ['KEY'] = 'value' + config = (ConfigBuilder() + .add_environment() + .with_validator(validator1) + .with_validator(validator2) + .build()) + + config.get('KEY') + + assert calls == ['validator1', 'validator2'] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/tests/test_type_casting.py b/tests/test_type_casting.py new file mode 100644 index 0000000..86495d8 --- /dev/null +++ b/tests/test_type_casting.py @@ -0,0 +1,404 @@ +""" +tests/test_edge_cases.py - Edge cases and error handling tests +""" + +import pytest +import os +import json +import tempfile +from pathlib import Path +from dotzen.dotzen import ( + ConfigBuilder, + ConfigFactory, + ConfigSingleton, + config, + UndefinedValueError, + ValidationError, + SourceNotFoundError, + UNDEFINED, +) + + +class TestUndefinedSentinel: + """Tests for UNDEFINED sentinel value""" + + def test_undefined_repr(self): + """Test UNDEFINED string representation""" + assert repr(UNDEFINED) == "" + + def test_undefined_is_singleton(self): + """Test UNDEFINED is a singleton""" + from dotzen.dotzen import _Undefined + undefined2 = _Undefined() + # Should have same representation but different instances + assert repr(undefined2) == repr(UNDEFINED) + + def test_undefined_vs_none(self): + """Test UNDEFINED is different from None""" + assert UNDEFINED is not None + assert UNDEFINED != None + + +class TestErrorHandling: + """Tests for error conditions""" + + def setup_method(self): + """Set up test environment""" + os.environ.clear() + self.temp_dir = tempfile.mkdtemp() + + def teardown_method(self): + """Clean up""" + import shutil + shutil.rmtree(self.temp_dir) + + def test_missing_key_without_default(self): + """Test accessing missing key without default raises error""" + config = ConfigBuilder().build() + + with pytest.raises(UndefinedValueError) as exc_info: + config.get('MISSING_KEY') + + assert "MISSING_KEY" in str(exc_info.value) + assert "not found" in str(exc_info.value).lower() + + def test_missing_key_in_chain(self): + """Test missing key in chain of sources""" + env_file = Path(self.temp_dir) / '.env' + env_file.write_text('KEY1=value1\n') + + config = (ConfigBuilder() + .add_environment() + .add_dotenv(str(env_file)) + .build()) + + with pytest.raises(UndefinedValueError) as exc_info: + config.get('MISSING_KEY') + + assert "not found in any configuration source" in str(exc_info.value).lower() + + def test_malformed_env_file(self): + """Test handling malformed .env file""" + env_file = Path(self.temp_dir) / '.env' + env_file.write_text( + "VALID_KEY=value\n" + "NO_EQUALS_SIGN\n" # Invalid line + "ANOTHER_VALID=value2\n" + ) + + config = ConfigBuilder().add_dotenv(str(env_file)).build() + + # Should still load valid keys + assert config.get('VALID_KEY') == 'value' + assert config.get('ANOTHER_VALID') == 'value2' + + def test_malformed_json_file(self): + """Test handling malformed JSON file""" + json_file = Path(self.temp_dir) / 'config.json' + json_file.write_text('{ invalid json }') + + config = ConfigBuilder().add_json(str(json_file)).build() + + # Should raise error when trying to load + with pytest.raises(Exception): # json.JSONDecodeError + config.get('key') + + def test_empty_env_file(self): + """Test handling empty .env file""" + env_file = Path(self.temp_dir) / '.env' + env_file.write_text('') + + config = ConfigBuilder().add_dotenv(str(env_file)).build() + + # Should work, just return default + assert config.get('KEY', default='default') == 'default' + + def test_comments_only_env_file(self): + """Test .env file with only comments""" + env_file = Path(self.temp_dir) / '.env' + env_file.write_text( + "# Comment 1\n" + "# Comment 2\n" + "# Comment 3\n" + ) + + config = ConfigBuilder().add_dotenv(str(env_file)).build() + + assert config.get('KEY', default='default') == 'default' + + def test_permission_denied_file(self): + """Test handling file permission errors""" + import stat + + env_file = Path(self.temp_dir) / '.env' + env_file.write_text('KEY=value\n') + + # Remove read permissions + os.chmod(env_file, stat.S_IWRITE) + + try: + config = ConfigBuilder().add_dotenv(str(env_file)).build() + + # Should raise PermissionError + with pytest.raises(PermissionError): + config.get('KEY') + finally: + # Restore permissions for cleanup + os.chmod(env_file, stat.S_IREAD | stat.S_IWRITE) + + +class TestEdgeCaseValues: + """Tests for edge case configuration values""" + + def setup_method(self): + """Set up test environment""" + os.environ.clear() + + def test_empty_string_value(self): + """Test handling empty string values""" + os.environ['EMPTY'] = '' + config = ConfigBuilder().add_environment().build() + + assert config.get('EMPTY') == '' + assert config.get('EMPTY', default='default') == '' # Empty string, not default + + def test_whitespace_only_value(self): + """Test handling whitespace-only values""" + os.environ['SPACES'] = ' ' + config = ConfigBuilder().add_environment().build() + + assert config.get('SPACES') == ' ' + + def test_special_characters(self): + """Test handling special characters""" + special_chars = '!@#$%^&*()_+-={}[]|\\:";\'<>?,./' + os.environ['SPECIAL'] = special_chars + config = ConfigBuilder().add_environment().build() + + assert config.get('SPECIAL') == special_chars + + def test_unicode_characters(self): + """Test handling unicode characters""" + os.environ['UNICODE'] = '你好世界 🌍 émoji' + config = ConfigBuilder().add_environment().build() + + assert config.get('UNICODE') == '你好世界 🌍 émoji' + + def test_multiline_value_in_env(self): + """Test handling multiline values""" + # Environment variables don't support newlines directly + os.environ['MULTILINE'] = 'line1\\nline2\\nline3' + config = ConfigBuilder().add_environment().build() + + assert config.get('MULTILINE') == 'line1\\nline2\\nline3' + + def test_very_long_value(self): + """Test handling very long values""" + long_value = 'x' * 10000 + os.environ['LONG'] = long_value + config = ConfigBuilder().add_environment().build() + + assert config.get('LONG') == long_value + assert len(config.get('LONG')) == 10000 + + def test_numeric_string_not_auto_converted(self): + """Test that numeric strings aren't auto-converted""" + os.environ['NUMBER'] = '42' + config = ConfigBuilder().add_environment().build() + + value = config.get('NUMBER') + assert value == '42' + assert isinstance(value, str) # Still a string + + +class TestConcurrentAccess: + """Tests for concurrent access scenarios""" + + def setup_method(self): + """Set up test environment""" + os.environ.clear() + ConfigSingleton.reset() + + def teardown_method(self): + """Clean up""" + ConfigSingleton.reset() + + def test_singleton_thread_safety(self): + """Test singleton access from multiple threads""" + import threading + + os.environ['SHARED'] = 'value' + instances = [] + + def get_instance(): + instance = ConfigSingleton.get_instance() + instances.append(instance) + + threads = [threading.Thread(target=get_instance) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + # All instances should be the same + assert all(inst is instances[0] for inst in instances) + + def test_config_immutability(self): + """Test that config values don't change unexpectedly""" + os.environ['KEY'] = 'original' + config = ConfigBuilder().add_environment().build() + + value1 = config.get('KEY') + + # Change environment (shouldn't affect already-loaded config) + os.environ['KEY'] = 'changed' + + # For environment source, it will reflect the change + # This is expected behavior + value2 = config.get('KEY') + assert value2 == 'changed' # Environment is dynamic + + +class TestNestedAndComplexStructures: + """Tests for nested and complex configuration structures""" + + def setup_method(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + + def teardown_method(self): + """Clean up""" + import shutil + shutil.rmtree(self.temp_dir) + + def test_deeply_nested_json(self): + """Test deeply nested JSON structure""" + config_data = { + "level1": { + "level2": { + "level3": { + "level4": { + "level5": { + "value": "deep" + } + } + } + } + } + } + + json_file = Path(self.temp_dir) / 'config.json' + json_file.write_text(json.dumps(config_data)) + + config = ConfigBuilder().add_json(str(json_file)).build() + + value = config.get('level1.level2.level3.level4.level5.value') + assert value == 'deep' + + def test_json_with_arrays(self): + """Test JSON with array values""" + config_data = { + "servers": ["server1", "server2", "server3"], + "ports": [8080, 8081, 8082] + } + + json_file = Path(self.temp_dir) / 'config.json' + json_file.write_text(json.dumps(config_data)) + + config = ConfigBuilder().add_json(str(json_file)).build() + + # Arrays are converted to strings + servers = config.get('servers') + assert "server1" in servers + + def test_mixed_type_json(self): + """Test JSON with mixed types""" + config_data = { + "string": "text", + "number": 42, + "float": 3.14, + "boolean": True, + "null": None, + "nested": { + "inner": "value" + } + } + + json_file = Path(self.temp_dir) / 'config.json' + json_file.write_text(json.dumps(config_data)) + + config = ConfigBuilder().add_json(str(json_file)).build() + + # All converted to strings + assert config.get('string') == 'text' + assert config.get('number') == '42' + assert config.get('float') == '3.14' + assert config.get('boolean') == 'True' + assert config.get('null') == 'None' + assert config.get('nested.inner') == 'value' + + +class TestValidation: + """Tests for validation functionality""" + + def setup_method(self): + """Set up test environment""" + os.environ.clear() + + def test_validator_called_on_get(self): + """Test that validator is called when getting value""" + called = [] + + def validator(key, value): + called.append((key, value)) + + os.environ['KEY'] = 'value' + config = (ConfigBuilder() + .add_environment() + .with_validator(validator) + .build()) + + config.get('KEY') + + assert len(called) == 1 + assert called[0] == ('KEY', 'value') + + def test_validator_can_raise_error(self): + """Test that validator can raise ValidationError""" + def strict_validator(key, value): + if key == 'PORT' and int(value) > 10000: + raise ValidationError("Port must be <= 10000") + + os.environ['PORT'] = '99999' + config = (ConfigBuilder() + .add_environment() + .with_validator(strict_validator) + .build()) + + with pytest.raises(ValidationError, match="Port must be <= 10000"): + config.get('PORT') + + def test_multiple_validators(self): + """Test multiple validators are all called""" + calls = [] + + def validator1(key, value): + calls.append('validator1') + + def validator2(key, value): + calls.append('validator2') + + os.environ['KEY'] = 'value' + config = (ConfigBuilder() + .add_environment() + .with_validator(validator1) + .with_validator(validator2) + .build()) + + config.get('KEY') + + assert calls == ['validator1', 'validator2'] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file From 8294ae7127822c92432119d92deeb8bd20e8b7f6 Mon Sep 17 00:00:00 2001 From: Carrington Muleya <79579279+Carrington-dev@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:45:46 +0200 Subject: [PATCH 2/9] Fix: name, email, username --- setup.cfg | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/setup.cfg b/setup.cfg index 4fa5f82..48744fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,16 +1,16 @@ [metadata] name = dotzen version = attr: dotzen.__version__ -author = Your Name -author_email = your.email@example.com +author = Carrington Muleya +author_email = carrington.muleya@outlook.com description = Peaceful, type-safe Python configuration library with multiple design patterns long_description = file: README.md long_description_content_type = text/markdown -url = https://github.com/yourusername/dotzen +url = https://github.com/carrington-dev/dotzen project_urls = - Bug Tracker = https://github.com/yourusername/dotzen/issues - Documentation = https://github.com/yourusername/dotzen#readme - Source Code = https://github.com/yourusername/dotzen + Bug Tracker = https://github.com/carrington-dev/dotzen/issues + Documentation = https://github.com/carrington-dev/dotzen#readme + Source Code = https://github.com/carrington-dev/dotzen classifiers = Development Status :: 4 - Beta Intended Audience :: Developers From d02dd799fe4decbbb4f038b068e0012fff0349b9 Mon Sep 17 00:00:00 2001 From: Carrington Muleya <79579279+Carrington-dev@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:50:49 +0200 Subject: [PATCH 3/9] Update setup.cfg --- setup.cfg | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/setup.cfg b/setup.cfg index 48744fd..70512d4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,7 @@ long_description_content_type = text/markdown url = https://github.com/carrington-dev/dotzen project_urls = Bug Tracker = https://github.com/carrington-dev/dotzen/issues - Documentation = https://github.com/carrington-dev/dotzen#readme + Documentation = https://dotzen.readthedocs.io Source Code = https://github.com/carrington-dev/dotzen classifiers = Development Status :: 4 - Beta @@ -42,7 +42,6 @@ license_files = LICENSE packages = find: python_requires = >=3.7 install_requires = - # No required dependencies for core functionality zip_safe = False include_package_data = True @@ -53,29 +52,20 @@ exclude = examples* [options.extras_require] -# Encryption support (Fernet) crypto = cryptography>=41.0.0 - -# Cloud secrets management aws = boto3>=1.26.0 - azure = azure-keyvault-secrets>=4.7.0 azure-identity>=1.12.0 - gcp = google-cloud-secret-manager>=2.16.0 - -# All cloud providers cloud = boto3>=1.26.0 azure-keyvault-secrets>=4.7.0 azure-identity>=1.12.0 google-cloud-secret-manager>=2.16.0 - -# Development dependencies dev = pytest>=7.0.0 pytest-cov>=4.0.0 @@ -85,14 +75,10 @@ dev = mypy>=1.0.0 isort>=5.12.0 pre-commit>=3.0.0 - -# Documentation docs = sphinx>=6.0.0 sphinx-rtd-theme>=1.2.0 sphinx-autodoc-typehints>=1.22.0 - -# All optional dependencies all = cryptography>=41.0.0 boto3>=1.26.0 @@ -113,11 +99,6 @@ addopts = --verbose --strict-markers --tb=short - --cov=dotzen - --cov-report=term-missing - --cov-report=html - --cov-report=xml - --cov-branch markers = slow: marks tests as slow (deselect with '-m "not slow"') integration: marks tests as integration tests @@ -163,10 +144,10 @@ exclude = venv, .tox ignore = - E203, # whitespace before ':' - E501, # line too long (handled by black) - W503, # line break before binary operator - W504, # line break after binary operator + E203, + E501, + W503, + W504 per-file-ignores = __init__.py:F401 From 6ecae9c45399e08d77d3f359eb92688b5c04180e Mon Sep 17 00:00:00 2001 From: Carrington Muleya <79579279+Carrington-dev@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:54:19 +0200 Subject: [PATCH 4/9] Fix: undefined name 'encrypt_for_env' 1 --- examples/flask/app.py | 4 ++-- examples/general/main.py | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/examples/flask/app.py b/examples/flask/app.py index 523510e..c64a84c 100644 --- a/examples/flask/app.py +++ b/examples/flask/app.py @@ -1,7 +1,7 @@ -from fastapi import Flask +from flask import Flask from dotzen import config -app = Flask() +app = Flask(__name__) SECRET_KEY = config("SECRET_KEY", default="key") diff --git a/examples/general/main.py b/examples/general/main.py index af9a913..8617139 100644 --- a/examples/general/main.py +++ b/examples/general/main.py @@ -1,4 +1,34 @@ -from dotzen import config +from dotzen import EncryptionManager +from dotzen.encryption import encrypt_for_env -print(config('PAYFAST_KEY')) \ No newline at end of file +if __name__ == "__main__": + # Demo usage + print("=== DotZen Encryption Demo ===\n") + + # Base64 encryption + print("1. Base64 Encryption:") + original = "carrington" + encrypted = EncryptionManager.encrypt(original, 'base64') + decrypted = EncryptionManager.decrypt(encrypted, 'base64') + print(f"Original: {original}") + print(f"Encrypted: {encrypted}") + print(f"Decrypted: {decrypted}") + print() + + # MD5 hashing (one-way) + print("2. MD5 Hashing (one-way):") + hashed = EncryptionManager.encrypt("password123", 'md5') + print(f"Hashed: {hashed}") + print() + + # SHA256 hashing (one-way) + print("3. SHA256 Hashing (one-way):") + hashed = EncryptionManager.encrypt("password123", 'sha256') + print(f"Hashed: {hashed}") + print() + + # Encrypt for .env file + print("4. Encrypt for .env file:") + env_value = encrypt_for_env("my-super-secret-api-key") + print(f"Add to .env: API_KEY={env_value}") \ No newline at end of file From cd8d4fe7dbef02172c0cb0d1152994f30d6574bf Mon Sep 17 00:00:00 2001 From: Carrington Muleya <79579279+Carrington-dev@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:57:12 +0200 Subject: [PATCH 5/9] Update setup.cfg --- setup.cfg | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/setup.cfg b/setup.cfg index 70512d4..770e0f6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -134,6 +134,7 @@ directory = htmlcov [flake8] max-line-length = 100 exclude = +<<<<<<< Updated upstream .git, __pycache__, build, @@ -148,6 +149,18 @@ ignore = E501, W503, W504 +======= + .git + __pycache__ + build + dist + .eggs + *.egg-info + .venv + venv + .tox +ignore = E203,E501,W503,W504 +>>>>>>> Stashed changes per-file-ignores = __init__.py:F401 From 8b8ae6bcb772eae8f2bd408540a161a02db566eb Mon Sep 17 00:00:00 2001 From: Carrington Muleya <79579279+Carrington-dev@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:57:38 +0200 Subject: [PATCH 6/9] Update setup.cfg --- setup.cfg | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/setup.cfg b/setup.cfg index 770e0f6..70512d4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -134,7 +134,6 @@ directory = htmlcov [flake8] max-line-length = 100 exclude = -<<<<<<< Updated upstream .git, __pycache__, build, @@ -149,18 +148,6 @@ ignore = E501, W503, W504 -======= - .git - __pycache__ - build - dist - .eggs - *.egg-info - .venv - venv - .tox -ignore = E203,E501,W503,W504 ->>>>>>> Stashed changes per-file-ignores = __init__.py:F401 From 71f3bd38bc867c6b3d58fc46d332fc07e63d7a73 Mon Sep 17 00:00:00 2001 From: Carrington Muleya <79579279+Carrington-dev@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:58:09 +0200 Subject: [PATCH 7/9] Update setup.cfg --- setup.cfg | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/setup.cfg b/setup.cfg index 70512d4..770e0f6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -134,6 +134,7 @@ directory = htmlcov [flake8] max-line-length = 100 exclude = +<<<<<<< Updated upstream .git, __pycache__, build, @@ -148,6 +149,18 @@ ignore = E501, W503, W504 +======= + .git + __pycache__ + build + dist + .eggs + *.egg-info + .venv + venv + .tox +ignore = E203,E501,W503,W504 +>>>>>>> Stashed changes per-file-ignores = __init__.py:F401 From 0016e0a12ea779be9094fdca739a3b15ce13f1cd Mon Sep 17 00:00:00 2001 From: Carrington Muleya <79579279+Carrington-dev@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:58:21 +0200 Subject: [PATCH 8/9] Update setup.cfg --- setup.cfg | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/setup.cfg b/setup.cfg index 770e0f6..fa7cd2c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -134,6 +134,7 @@ directory = htmlcov [flake8] max-line-length = 100 exclude = +<<<<<<< Updated upstream <<<<<<< Updated upstream .git, __pycache__, @@ -150,6 +151,8 @@ ignore = W503, W504 ======= +======= +>>>>>>> Stashed changes .git __pycache__ build @@ -160,6 +163,9 @@ ignore = venv .tox ignore = E203,E501,W503,W504 +<<<<<<< Updated upstream +>>>>>>> Stashed changes +======= >>>>>>> Stashed changes per-file-ignores = __init__.py:F401 From 43a329e301add870a65a1dd0cfac78b45b019680 Mon Sep 17 00:00:00 2001 From: Carrington Muleya <79579279+Carrington-dev@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:00:24 +0200 Subject: [PATCH 9/9] Update setup.cfg --- setup.cfg | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/setup.cfg b/setup.cfg index fa7cd2c..6e92742 100644 --- a/setup.cfg +++ b/setup.cfg @@ -134,25 +134,6 @@ directory = htmlcov [flake8] max-line-length = 100 exclude = -<<<<<<< Updated upstream -<<<<<<< Updated upstream - .git, - __pycache__, - build, - dist, - .eggs, - *.egg-info, - .venv, - venv, - .tox -ignore = - E203, - E501, - W503, - W504 -======= -======= ->>>>>>> Stashed changes .git __pycache__ build @@ -163,10 +144,6 @@ ignore = venv .tox ignore = E203,E501,W503,W504 -<<<<<<< Updated upstream ->>>>>>> Stashed changes -======= ->>>>>>> Stashed changes per-file-ignores = __init__.py:F401