Spaces:
Sleeping
Sleeping
feat: Add comprehensive test suite and tools implementation
Browse files- Add test suite with high coverage for tools\n- Implement weather tool with caching and error handling\n- Add health check system with resource monitoring\n- Implement circuit breaker for API resilience\n- Add timezone conversion utilities\n- Add usage statistics collection\n- Add system monitoring capabilities\n- Add cache management with TTL support\n- Add comprehensive documentation\n- Add development tooling and configuration
This view is limited to 50 files because it contains too many changes.
See raw diff
- .flake8 +65 -0
- .github/ISSUE_TEMPLATE/bug_report.md +42 -0
- .github/ISSUE_TEMPLATE/config.yml +14 -0
- .github/ISSUE_TEMPLATE/documentation.md +36 -0
- .github/ISSUE_TEMPLATE/feature_request.md +42 -0
- .github/PULL_REQUEST_TEMPLATE.md +62 -0
- .github/workflows/deploy.yml +145 -0
- .github/workflows/lint.yml +98 -0
- .github/workflows/test.yml +102 -0
- .pre-commit-config.yaml +170 -0
- CHANGELOG.md +69 -0
- CONTRIBUTING.md +223 -0
- LICENSE +21 -0
- README.md +672 -0
- SECURITY.md +283 -0
- STYLE_GUIDE.md +220 -0
- agent.json +128 -24
- app.py +572 -93
- config.py +451 -0
- docs/api.md +385 -0
- docs/api_reference.md +143 -0
- docs/error_handling.md +201 -0
- docs/errors.md +434 -0
- docs/getting_started.md +202 -0
- docs/logging.md +231 -0
- docs/security_quickstart.md +130 -0
- docs/user_guide.md +813 -0
- prompts.yaml +15 -0
- pyproject.toml +107 -0
- pytest.ini +45 -0
- rate_limiter.py +32 -0
- requirements-dev.txt +0 -0
- requirements.txt +47 -7
- scripts/run_tests.py +96 -0
- setup.py +148 -0
- test_app.py +124 -0
- tests/__pycache__/test_gradio_ui.cpython-312-pytest-8.3.5.pyc +0 -0
- tests/__pycache__/test_security.cpython-312-pytest-8.3.5.pyc +0 -0
- tests/__pycache__/test_tools.cpython-312-pytest-8.3.5.pyc +0 -0
- tests/integration/test_assistant.py +402 -0
- tests/test_api_logging.py +220 -0
- tests/test_api_optimization.py +209 -0
- tests/test_cache.py +197 -0
- tests/test_cache_invalidation.py +221 -0
- tests/test_error_recovery.py +212 -0
- tests/test_errors.py +204 -0
- tests/test_gradio_ui.py +92 -0
- tests/test_health.py +136 -0
- tests/test_logging.py +126 -0
- tests/test_monitoring.py +160 -0
.flake8
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[flake8]
|
| 2 |
+
max-line-length = 100
|
| 3 |
+
max-complexity = 10
|
| 4 |
+
extend-ignore =
|
| 5 |
+
E203, # Whitespace before ':'
|
| 6 |
+
W503, # Line break before binary operator
|
| 7 |
+
D100, # Missing docstring in public module
|
| 8 |
+
D104, # Missing docstring in public package
|
| 9 |
+
D107, # Missing docstring in __init__
|
| 10 |
+
B008 # Do not perform function calls in argument defaults
|
| 11 |
+
extend-select =
|
| 12 |
+
B950, # Line too long
|
| 13 |
+
B901, # Return statements in finally blocks
|
| 14 |
+
B902, # Invalid first argument names for methods
|
| 15 |
+
B903 # __slots__ efficiency
|
| 16 |
+
|
| 17 |
+
exclude =
|
| 18 |
+
.git,
|
| 19 |
+
__pycache__,
|
| 20 |
+
build,
|
| 21 |
+
dist,
|
| 22 |
+
*.egg-info,
|
| 23 |
+
venv,
|
| 24 |
+
.env,
|
| 25 |
+
.venv,
|
| 26 |
+
.mypy_cache,
|
| 27 |
+
.pytest_cache
|
| 28 |
+
|
| 29 |
+
per-file-ignores =
|
| 30 |
+
__init__.py: F401,F403
|
| 31 |
+
tests/*: S101,D103,ANN
|
| 32 |
+
conftest.py: D103
|
| 33 |
+
|
| 34 |
+
# Plugin settings
|
| 35 |
+
docstring-convention = google
|
| 36 |
+
ignore-decorators = property|classmethod|staticmethod|validator|root_validator
|
| 37 |
+
|
| 38 |
+
# Complexity settings
|
| 39 |
+
max-annotations-complexity = 4
|
| 40 |
+
max-expression-complexity = 7
|
| 41 |
+
max-cognitive-complexity = 12
|
| 42 |
+
|
| 43 |
+
# Additional settings
|
| 44 |
+
max-line-doc-length = 100
|
| 45 |
+
max-doc-length = 100
|
| 46 |
+
statistics = True
|
| 47 |
+
count = True
|
| 48 |
+
show-source = True
|
| 49 |
+
|
| 50 |
+
# Error format
|
| 51 |
+
format = %(path)s:%(row)d:%(col)d: %(code)s %(text)s
|
| 52 |
+
|
| 53 |
+
# Plugin configurations
|
| 54 |
+
[flake8:local-plugins]
|
| 55 |
+
extension =
|
| 56 |
+
B = flake8_bugbear:BugBearChecker
|
| 57 |
+
C = flake8_comprehensions:ComprehensionChecker
|
| 58 |
+
ANN = flake8_annotations:AnnotationChecker
|
| 59 |
+
CCR = flake8_cognitive_complexity:CognitiveComplexityChecker
|
| 60 |
+
ECE = flake8_expression_complexity:ExpressionComplexityChecker
|
| 61 |
+
DAR = flake8_darglint:DarglintChecker
|
| 62 |
+
|
| 63 |
+
# Darglint configuration
|
| 64 |
+
strictness = short
|
| 65 |
+
docstring_style = google
|
.github/ISSUE_TEMPLATE/bug_report.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Bug Report
|
| 3 |
+
about: Create a report to help us improve
|
| 4 |
+
title: '[BUG] '
|
| 5 |
+
labels: bug
|
| 6 |
+
assignees: ''
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Bug Description
|
| 10 |
+
<!-- A clear and concise description of what the bug is -->
|
| 11 |
+
|
| 12 |
+
## Steps to Reproduce
|
| 13 |
+
1.
|
| 14 |
+
2.
|
| 15 |
+
3.
|
| 16 |
+
|
| 17 |
+
## Expected Behavior
|
| 18 |
+
<!-- A clear and concise description of what you expected to happen -->
|
| 19 |
+
|
| 20 |
+
## Actual Behavior
|
| 21 |
+
<!-- What actually happened -->
|
| 22 |
+
|
| 23 |
+
## Screenshots/Logs
|
| 24 |
+
<!-- If applicable, add screenshots or logs to help explain your problem -->
|
| 25 |
+
|
| 26 |
+
## Environment
|
| 27 |
+
- OS: [e.g., Ubuntu 22.04, Windows 11]
|
| 28 |
+
- Python Version: [e.g., 3.9.12]
|
| 29 |
+
- Package Version: [e.g., 0.1.0]
|
| 30 |
+
- Dependencies Versions:
|
| 31 |
+
```
|
| 32 |
+
Output of: pip freeze | grep -i ai-assistant
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
## Additional Context
|
| 36 |
+
<!-- Add any other context about the problem here -->
|
| 37 |
+
|
| 38 |
+
## Possible Solution
|
| 39 |
+
<!-- Optional: suggest a fix or reason for the bug -->
|
| 40 |
+
|
| 41 |
+
## Related Issues/PRs
|
| 42 |
+
<!-- Optional: link to related issues or PRs -->
|
.github/ISSUE_TEMPLATE/config.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
blank_issues_enabled: false
|
| 2 |
+
contact_links:
|
| 3 |
+
- name: Question or Discussion
|
| 4 |
+
url: https://github.com/yourusername/ai-assistant/discussions
|
| 5 |
+
about: Ask questions and discuss with other community members
|
| 6 |
+
- name: Documentation
|
| 7 |
+
url: https://yourusername.github.io/ai-assistant
|
| 8 |
+
about: Check out our documentation for answers
|
| 9 |
+
- name: Security Issue
|
| 10 |
+
url: https://github.com/yourusername/ai-assistant/security/policy
|
| 11 |
+
about: Please report security vulnerabilities here
|
| 12 |
+
|
| 13 |
+
# Configure issue template chooser
|
| 14 |
+
# https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository#configuring-the-template-chooser
|
.github/ISSUE_TEMPLATE/documentation.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Documentation
|
| 3 |
+
about: Report an issue or suggest an improvement in documentation
|
| 4 |
+
title: '[DOCS] '
|
| 5 |
+
labels: documentation
|
| 6 |
+
assignees: ''
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Documentation Location
|
| 10 |
+
<!-- Specify where the documentation issue is (e.g., README, API docs, code comments) -->
|
| 11 |
+
|
| 12 |
+
## Current Documentation
|
| 13 |
+
<!-- What does the current documentation say? -->
|
| 14 |
+
|
| 15 |
+
## Issue/Suggested Improvement
|
| 16 |
+
<!-- Describe what's wrong or what could be better -->
|
| 17 |
+
|
| 18 |
+
## Proposed Changes
|
| 19 |
+
<!-- Suggest specific changes or improvements -->
|
| 20 |
+
|
| 21 |
+
### Example
|
| 22 |
+
<!-- If applicable, provide an example of the improved documentation -->
|
| 23 |
+
```markdown
|
| 24 |
+
# Suggested documentation changes
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
## Additional Context
|
| 28 |
+
<!-- Add any other context about the documentation issue here -->
|
| 29 |
+
|
| 30 |
+
## Checklist
|
| 31 |
+
- [ ] I have checked that this is not already documented elsewhere
|
| 32 |
+
- [ ] I have checked the latest documentation in the main branch
|
| 33 |
+
- [ ] I have checked related issues/PRs
|
| 34 |
+
|
| 35 |
+
## Related Issues/PRs
|
| 36 |
+
<!-- Optional: link to related issues or PRs -->
|
.github/ISSUE_TEMPLATE/feature_request.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Feature Request
|
| 3 |
+
about: Suggest an idea for this project
|
| 4 |
+
title: '[FEATURE] '
|
| 5 |
+
labels: enhancement
|
| 6 |
+
assignees: ''
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Problem Statement
|
| 10 |
+
<!-- A clear and concise description of what problem this feature would solve -->
|
| 11 |
+
|
| 12 |
+
## Proposed Solution
|
| 13 |
+
<!-- A clear and concise description of what you want to happen -->
|
| 14 |
+
|
| 15 |
+
## Alternative Solutions
|
| 16 |
+
<!-- A clear and concise description of any alternative solutions or features you've considered -->
|
| 17 |
+
|
| 18 |
+
## Example Use Case
|
| 19 |
+
<!-- Provide an example of how this feature would be used -->
|
| 20 |
+
```python
|
| 21 |
+
# Example code showing how the feature might work
|
| 22 |
+
from ai_assistant import SomeFeature
|
| 23 |
+
|
| 24 |
+
# Usage example
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
## Implementation Details
|
| 28 |
+
<!-- Optional: If you have ideas about how to implement this feature -->
|
| 29 |
+
|
| 30 |
+
### Required Changes
|
| 31 |
+
<!-- List the components that would need to be modified -->
|
| 32 |
+
- [ ] Component 1
|
| 33 |
+
- [ ] Component 2
|
| 34 |
+
|
| 35 |
+
### Potential Challenges
|
| 36 |
+
<!-- List any challenges or considerations for implementing this feature -->
|
| 37 |
+
|
| 38 |
+
## Additional Context
|
| 39 |
+
<!-- Add any other context or screenshots about the feature request here -->
|
| 40 |
+
|
| 41 |
+
## Related Issues/PRs
|
| 42 |
+
<!-- Optional: link to related issues or PRs -->
|
.github/PULL_REQUEST_TEMPLATE.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Description
|
| 2 |
+
<!-- Provide a brief description of the changes in this PR -->
|
| 3 |
+
|
| 4 |
+
## Type of Change
|
| 5 |
+
<!-- Mark the appropriate option with an [x] -->
|
| 6 |
+
- [ ] Bug fix (non-breaking change that fixes an issue)
|
| 7 |
+
- [ ] New feature (non-breaking change that adds functionality)
|
| 8 |
+
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
| 9 |
+
- [ ] Documentation update
|
| 10 |
+
- [ ] Performance improvement
|
| 11 |
+
- [ ] Code cleanup or refactor
|
| 12 |
+
- [ ] Dependency update
|
| 13 |
+
- [ ] Other (please describe):
|
| 14 |
+
|
| 15 |
+
## Motivation and Context
|
| 16 |
+
<!-- Why is this change required? What problem does it solve? -->
|
| 17 |
+
|
| 18 |
+
## Implementation Details
|
| 19 |
+
<!-- Describe the changes in detail -->
|
| 20 |
+
|
| 21 |
+
## How Has This Been Tested?
|
| 22 |
+
<!-- Describe the tests you ran and how -->
|
| 23 |
+
- [ ] Unit tests added/updated
|
| 24 |
+
- [ ] Integration tests added/updated
|
| 25 |
+
- [ ] Manual testing performed
|
| 26 |
+
|
| 27 |
+
### Test Configuration
|
| 28 |
+
- Python version:
|
| 29 |
+
- OS:
|
| 30 |
+
- Dependencies:
|
| 31 |
+
|
| 32 |
+
## Breaking Changes
|
| 33 |
+
<!-- List any breaking changes and migration instructions if applicable -->
|
| 34 |
+
|
| 35 |
+
## Checklist
|
| 36 |
+
<!-- Mark completed items with an [x] -->
|
| 37 |
+
- [ ] My code follows the project's style guidelines
|
| 38 |
+
- [ ] I have performed a self-review of my own code
|
| 39 |
+
- [ ] I have commented my code, particularly in hard-to-understand areas
|
| 40 |
+
- [ ] I have made corresponding changes to the documentation
|
| 41 |
+
- [ ] My changes generate no new warnings
|
| 42 |
+
- [ ] I have added tests that prove my fix is effective or that my feature works
|
| 43 |
+
- [ ] New and existing unit tests pass locally with my changes
|
| 44 |
+
- [ ] Any dependent changes have been merged and published
|
| 45 |
+
- [ ] I have updated the version number if necessary
|
| 46 |
+
- [ ] I have updated the changelog if necessary
|
| 47 |
+
|
| 48 |
+
## Screenshots/Recordings
|
| 49 |
+
<!-- If applicable, add screenshots or recordings to demonstrate the changes -->
|
| 50 |
+
|
| 51 |
+
## Related Issues
|
| 52 |
+
<!-- Link to any related issues using #issue-number -->
|
| 53 |
+
Closes #
|
| 54 |
+
|
| 55 |
+
## Additional Notes
|
| 56 |
+
<!-- Add any additional notes or context about the PR here -->
|
| 57 |
+
|
| 58 |
+
## Review Guidelines
|
| 59 |
+
<!-- Specify any particular areas of the code that need careful review -->
|
| 60 |
+
|
| 61 |
+
## Deployment Notes
|
| 62 |
+
<!-- Any special considerations for deploying these changes -->
|
.github/workflows/deploy.yml
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
tags:
|
| 6 |
+
- 'v*'
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
inputs:
|
| 9 |
+
environment:
|
| 10 |
+
description: 'Environment to deploy to'
|
| 11 |
+
required: true
|
| 12 |
+
default: 'staging'
|
| 13 |
+
type: choice
|
| 14 |
+
options:
|
| 15 |
+
- staging
|
| 16 |
+
- production
|
| 17 |
+
|
| 18 |
+
jobs:
|
| 19 |
+
build:
|
| 20 |
+
runs-on: ubuntu-latest
|
| 21 |
+
steps:
|
| 22 |
+
- uses: actions/checkout@v4
|
| 23 |
+
|
| 24 |
+
- name: Set up Python
|
| 25 |
+
uses: actions/setup-python@v4
|
| 26 |
+
with:
|
| 27 |
+
python-version: "3.12"
|
| 28 |
+
cache: 'pip'
|
| 29 |
+
|
| 30 |
+
- name: Install dependencies
|
| 31 |
+
run: |
|
| 32 |
+
python -m pip install --upgrade pip
|
| 33 |
+
pip install build twine
|
| 34 |
+
|
| 35 |
+
- name: Build package
|
| 36 |
+
run: |
|
| 37 |
+
python -m build
|
| 38 |
+
|
| 39 |
+
- name: Upload dist artifact
|
| 40 |
+
uses: actions/upload-artifact@v3
|
| 41 |
+
with:
|
| 42 |
+
name: dist
|
| 43 |
+
path: dist/
|
| 44 |
+
if-no-files-found: error
|
| 45 |
+
|
| 46 |
+
test-package:
|
| 47 |
+
needs: build
|
| 48 |
+
runs-on: ubuntu-latest
|
| 49 |
+
strategy:
|
| 50 |
+
matrix:
|
| 51 |
+
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
| 52 |
+
steps:
|
| 53 |
+
- uses: actions/checkout@v4
|
| 54 |
+
|
| 55 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 56 |
+
uses: actions/setup-python@v4
|
| 57 |
+
with:
|
| 58 |
+
python-version: ${{ matrix.python-version }}
|
| 59 |
+
cache: 'pip'
|
| 60 |
+
|
| 61 |
+
- name: Download dist
|
| 62 |
+
uses: actions/download-artifact@v3
|
| 63 |
+
with:
|
| 64 |
+
name: dist
|
| 65 |
+
path: dist/
|
| 66 |
+
|
| 67 |
+
- name: Install package
|
| 68 |
+
run: |
|
| 69 |
+
python -m pip install --upgrade pip
|
| 70 |
+
pip install dist/*.whl
|
| 71 |
+
|
| 72 |
+
- name: Test import
|
| 73 |
+
run: |
|
| 74 |
+
python -c "import tools; print(tools.__version__)"
|
| 75 |
+
|
| 76 |
+
deploy-staging:
|
| 77 |
+
needs: [build, test-package]
|
| 78 |
+
runs-on: ubuntu-latest
|
| 79 |
+
if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'staging'
|
| 80 |
+
environment:
|
| 81 |
+
name: staging
|
| 82 |
+
url: ${{ steps.deploy.outputs.url }}
|
| 83 |
+
|
| 84 |
+
steps:
|
| 85 |
+
- name: Download dist
|
| 86 |
+
uses: actions/download-artifact@v3
|
| 87 |
+
with:
|
| 88 |
+
name: dist
|
| 89 |
+
path: dist/
|
| 90 |
+
|
| 91 |
+
- name: Deploy to Test PyPI
|
| 92 |
+
env:
|
| 93 |
+
TWINE_USERNAME: __token__
|
| 94 |
+
TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }}
|
| 95 |
+
run: |
|
| 96 |
+
python -m twine upload --repository testpypi dist/*
|
| 97 |
+
|
| 98 |
+
- name: Set output URL
|
| 99 |
+
id: deploy
|
| 100 |
+
run: echo "url=https://test.pypi.org/project/ai-assistant/" >> $GITHUB_OUTPUT
|
| 101 |
+
|
| 102 |
+
deploy-production:
|
| 103 |
+
needs: [build, test-package]
|
| 104 |
+
runs-on: ubuntu-latest
|
| 105 |
+
if: |
|
| 106 |
+
(github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production') ||
|
| 107 |
+
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
|
| 108 |
+
environment:
|
| 109 |
+
name: production
|
| 110 |
+
url: ${{ steps.deploy.outputs.url }}
|
| 111 |
+
|
| 112 |
+
steps:
|
| 113 |
+
- name: Download dist
|
| 114 |
+
uses: actions/download-artifact@v3
|
| 115 |
+
with:
|
| 116 |
+
name: dist
|
| 117 |
+
path: dist/
|
| 118 |
+
|
| 119 |
+
- name: Deploy to PyPI
|
| 120 |
+
env:
|
| 121 |
+
TWINE_USERNAME: __token__
|
| 122 |
+
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
| 123 |
+
run: |
|
| 124 |
+
python -m twine upload dist/*
|
| 125 |
+
|
| 126 |
+
- name: Set output URL
|
| 127 |
+
id: deploy
|
| 128 |
+
run: echo "url=https://pypi.org/project/ai-assistant/" >> $GITHUB_OUTPUT
|
| 129 |
+
|
| 130 |
+
- name: Create GitHub Release
|
| 131 |
+
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
| 132 |
+
uses: softprops/action-gh-release@v1
|
| 133 |
+
with:
|
| 134 |
+
files: dist/*
|
| 135 |
+
generate_release_notes: true
|
| 136 |
+
|
| 137 |
+
cleanup:
|
| 138 |
+
needs: [deploy-staging, deploy-production]
|
| 139 |
+
if: always()
|
| 140 |
+
runs-on: ubuntu-latest
|
| 141 |
+
steps:
|
| 142 |
+
- name: Delete dist artifact
|
| 143 |
+
uses: geekyeggo/delete-artifact@v2
|
| 144 |
+
with:
|
| 145 |
+
name: dist
|
.github/workflows/lint.yml
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Lint
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main, develop ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ main, develop ]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
lint:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
strategy:
|
| 13 |
+
matrix:
|
| 14 |
+
python-version: ["3.9"]
|
| 15 |
+
|
| 16 |
+
steps:
|
| 17 |
+
- uses: actions/checkout@v4
|
| 18 |
+
|
| 19 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 20 |
+
uses: actions/setup-python@v5
|
| 21 |
+
with:
|
| 22 |
+
python-version: ${{ matrix.python-version }}
|
| 23 |
+
cache: 'pip'
|
| 24 |
+
|
| 25 |
+
- name: Install dependencies
|
| 26 |
+
run: |
|
| 27 |
+
python -m pip install --upgrade pip
|
| 28 |
+
pip install -r requirements-dev.txt
|
| 29 |
+
|
| 30 |
+
- name: Check formatting with black
|
| 31 |
+
run: black . --check --diff
|
| 32 |
+
|
| 33 |
+
- name: Check import sorting with isort
|
| 34 |
+
run: isort . --check --diff
|
| 35 |
+
|
| 36 |
+
- name: Run flake8
|
| 37 |
+
run: flake8 .
|
| 38 |
+
|
| 39 |
+
- name: Run pylint
|
| 40 |
+
run: pylint tools tests
|
| 41 |
+
|
| 42 |
+
- name: Run mypy type checking
|
| 43 |
+
run: mypy .
|
| 44 |
+
|
| 45 |
+
- name: Run bandit security checks
|
| 46 |
+
run: bandit -r . -c pyproject.toml
|
| 47 |
+
|
| 48 |
+
- name: Run pyupgrade checks
|
| 49 |
+
run: |
|
| 50 |
+
find . -type f -name "*.py" -not -path "./venv/*" -not -path "./.env/*" -exec pyupgrade --py39-plus {} \;
|
| 51 |
+
|
| 52 |
+
- name: Verify pre-commit hooks
|
| 53 |
+
run: |
|
| 54 |
+
pre-commit install
|
| 55 |
+
pre-commit run --all-files
|
| 56 |
+
|
| 57 |
+
security:
|
| 58 |
+
runs-on: ubuntu-latest
|
| 59 |
+
steps:
|
| 60 |
+
- uses: actions/checkout@v4
|
| 61 |
+
|
| 62 |
+
- name: Set up Python
|
| 63 |
+
uses: actions/setup-python@v4
|
| 64 |
+
with:
|
| 65 |
+
python-version: "3.12"
|
| 66 |
+
cache: 'pip'
|
| 67 |
+
|
| 68 |
+
- name: Install dependencies
|
| 69 |
+
run: |
|
| 70 |
+
python -m pip install --upgrade pip
|
| 71 |
+
pip install bandit safety
|
| 72 |
+
|
| 73 |
+
- name: Run bandit
|
| 74 |
+
run: |
|
| 75 |
+
bandit -r tools -ll -ii
|
| 76 |
+
|
| 77 |
+
- name: Run safety check
|
| 78 |
+
run: |
|
| 79 |
+
safety check
|
| 80 |
+
|
| 81 |
+
dependency-review:
|
| 82 |
+
runs-on: ubuntu-latest
|
| 83 |
+
if: github.event_name == 'pull_request'
|
| 84 |
+
steps:
|
| 85 |
+
- uses: actions/checkout@v4
|
| 86 |
+
|
| 87 |
+
- name: Dependency Review
|
| 88 |
+
uses: actions/dependency-review-action@v3
|
| 89 |
+
with:
|
| 90 |
+
fail-on-severity: moderate
|
| 91 |
+
deny-licenses: |
|
| 92 |
+
GPL-1.0
|
| 93 |
+
LGPL-2.0
|
| 94 |
+
BSD-2-Clause
|
| 95 |
+
allow-licenses: |
|
| 96 |
+
MIT
|
| 97 |
+
Apache-2.0
|
| 98 |
+
BSD-3-Clause
|
.github/workflows/test.yml
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Tests
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ main ]
|
| 8 |
+
workflow_dispatch:
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
test:
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
strategy:
|
| 14 |
+
matrix:
|
| 15 |
+
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
| 16 |
+
fail-fast: false
|
| 17 |
+
|
| 18 |
+
steps:
|
| 19 |
+
- uses: actions/checkout@v4
|
| 20 |
+
|
| 21 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 22 |
+
uses: actions/setup-python@v4
|
| 23 |
+
with:
|
| 24 |
+
python-version: ${{ matrix.python-version }}
|
| 25 |
+
cache: 'pip'
|
| 26 |
+
|
| 27 |
+
- name: Install dependencies
|
| 28 |
+
run: |
|
| 29 |
+
python -m pip install --upgrade pip
|
| 30 |
+
pip install -r requirements.txt
|
| 31 |
+
pip install -r requirements-dev.txt
|
| 32 |
+
|
| 33 |
+
- name: Run tests with coverage
|
| 34 |
+
run: |
|
| 35 |
+
python scripts/run_tests.py
|
| 36 |
+
env:
|
| 37 |
+
PYTHONPATH: ${{ github.workspace }}
|
| 38 |
+
|
| 39 |
+
- name: Upload coverage reports
|
| 40 |
+
uses: codecov/codecov-action@v3
|
| 41 |
+
with:
|
| 42 |
+
file: ./coverage.xml
|
| 43 |
+
flags: unittests
|
| 44 |
+
name: codecov-umbrella
|
| 45 |
+
fail_ci_if_error: true
|
| 46 |
+
if: success()
|
| 47 |
+
|
| 48 |
+
- name: Upload coverage HTML report
|
| 49 |
+
uses: actions/upload-artifact@v3
|
| 50 |
+
with:
|
| 51 |
+
name: coverage-report-${{ matrix.python-version }}
|
| 52 |
+
path: coverage_html/
|
| 53 |
+
if-no-files-found: error
|
| 54 |
+
if: success()
|
| 55 |
+
|
| 56 |
+
- name: Upload test results
|
| 57 |
+
uses: actions/upload-artifact@v3
|
| 58 |
+
with:
|
| 59 |
+
name: pytest-results-${{ matrix.python-version }}
|
| 60 |
+
path: |
|
| 61 |
+
.coverage
|
| 62 |
+
coverage.xml
|
| 63 |
+
junit.xml
|
| 64 |
+
if-no-files-found: error
|
| 65 |
+
if: always()
|
| 66 |
+
|
| 67 |
+
coverage-badge:
|
| 68 |
+
needs: test
|
| 69 |
+
runs-on: ubuntu-latest
|
| 70 |
+
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
| 71 |
+
|
| 72 |
+
steps:
|
| 73 |
+
- uses: actions/checkout@v4
|
| 74 |
+
|
| 75 |
+
- name: Download coverage data
|
| 76 |
+
uses: actions/download-artifact@v3
|
| 77 |
+
with:
|
| 78 |
+
name: coverage-report-3.12
|
| 79 |
+
path: coverage_html/
|
| 80 |
+
|
| 81 |
+
- name: Extract coverage percentage
|
| 82 |
+
run: |
|
| 83 |
+
COVERAGE=$(grep -o '[0-9]\+%' coverage_html/index.html | head -1 | tr -d '%')
|
| 84 |
+
echo "COVERAGE=$COVERAGE" >> $GITHUB_ENV
|
| 85 |
+
if [ "$COVERAGE" -ge 90 ]; then
|
| 86 |
+
echo "COVERAGE_COLOR=green" >> $GITHUB_ENV
|
| 87 |
+
elif [ "$COVERAGE" -ge 80 ]; then
|
| 88 |
+
echo "COVERAGE_COLOR=yellow" >> $GITHUB_ENV
|
| 89 |
+
else
|
| 90 |
+
echo "COVERAGE_COLOR=red" >> $GITHUB_ENV
|
| 91 |
+
fi
|
| 92 |
+
|
| 93 |
+
- name: Create coverage badge
|
| 94 |
+
uses: schneegans/dynamic-badges-action@v1.6.0
|
| 95 |
+
with:
|
| 96 |
+
auth: ${{ secrets.GIST_SECRET }}
|
| 97 |
+
gistID: ${{ secrets.COVERAGE_GIST_ID }}
|
| 98 |
+
filename: coverage.json
|
| 99 |
+
label: coverage
|
| 100 |
+
message: ${{ env.COVERAGE }}%
|
| 101 |
+
color: ${{ env.COVERAGE_COLOR }}
|
| 102 |
+
namedLogo: python
|
.pre-commit-config.yaml
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
repos:
|
| 2 |
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
| 3 |
+
rev: v4.5.0
|
| 4 |
+
hooks:
|
| 5 |
+
- id: check-added-large-files
|
| 6 |
+
args: ['--maxkb=1024']
|
| 7 |
+
- id: check-ast
|
| 8 |
+
- id: check-case-conflict
|
| 9 |
+
- id: check-docstring-first
|
| 10 |
+
- id: check-executables-have-shebangs
|
| 11 |
+
- id: check-json
|
| 12 |
+
- id: check-merge-conflict
|
| 13 |
+
- id: check-toml
|
| 14 |
+
- id: check-yaml
|
| 15 |
+
- id: debug-statements
|
| 16 |
+
- id: detect-private-key
|
| 17 |
+
- id: end-of-file-fixer
|
| 18 |
+
- id: trailing-whitespace
|
| 19 |
+
- id: mixed-line-ending
|
| 20 |
+
args: ['--fix=lf']
|
| 21 |
+
|
| 22 |
+
- repo: https://github.com/psf/black
|
| 23 |
+
rev: 24.2.0
|
| 24 |
+
hooks:
|
| 25 |
+
- id: black
|
| 26 |
+
language_version: python3
|
| 27 |
+
args: ['--config=pyproject.toml']
|
| 28 |
+
|
| 29 |
+
- repo: https://github.com/pycqa/isort
|
| 30 |
+
rev: 5.13.2
|
| 31 |
+
hooks:
|
| 32 |
+
- id: isort
|
| 33 |
+
args: ['--settings-path=pyproject.toml']
|
| 34 |
+
|
| 35 |
+
- repo: https://github.com/pycqa/flake8
|
| 36 |
+
rev: 7.0.0
|
| 37 |
+
hooks:
|
| 38 |
+
- id: flake8
|
| 39 |
+
additional_dependencies:
|
| 40 |
+
- flake8-docstrings
|
| 41 |
+
- flake8-bugbear
|
| 42 |
+
- flake8-comprehensions
|
| 43 |
+
- flake8-simplify
|
| 44 |
+
- flake8-pytest-style
|
| 45 |
+
- flake8-typing-imports
|
| 46 |
+
- pep8-naming
|
| 47 |
+
- flake8-cognitive-complexity
|
| 48 |
+
- flake8-expression-complexity
|
| 49 |
+
- mccabe
|
| 50 |
+
- flake8-functions
|
| 51 |
+
- flake8-annotations-complexity
|
| 52 |
+
|
| 53 |
+
- repo: https://github.com/pre-commit/mirrors-mypy
|
| 54 |
+
rev: v1.8.0
|
| 55 |
+
hooks:
|
| 56 |
+
- id: mypy
|
| 57 |
+
additional_dependencies:
|
| 58 |
+
- types-requests
|
| 59 |
+
- types-PyYAML
|
| 60 |
+
- pydantic
|
| 61 |
+
- types-setuptools
|
| 62 |
+
- types-python-dateutil
|
| 63 |
+
- types-pytz
|
| 64 |
+
- types-urllib3
|
| 65 |
+
- types-six
|
| 66 |
+
- types-protobuf
|
| 67 |
+
- types-mock
|
| 68 |
+
- types-toml
|
| 69 |
+
args: [
|
| 70 |
+
'--config-file=pyproject.toml',
|
| 71 |
+
'--strict',
|
| 72 |
+
'--ignore-missing-imports',
|
| 73 |
+
'--python-version=3.9',
|
| 74 |
+
'--show-error-codes',
|
| 75 |
+
'--pretty',
|
| 76 |
+
'--warn-unused-ignores',
|
| 77 |
+
'--disallow-any-generics',
|
| 78 |
+
'--disallow-untyped-decorators',
|
| 79 |
+
'--no-implicit-optional'
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
- repo: https://github.com/microsoft/pyright
|
| 83 |
+
rev: 1.1.350
|
| 84 |
+
hooks:
|
| 85 |
+
- id: pyright
|
| 86 |
+
additional_dependencies:
|
| 87 |
+
- pydantic
|
| 88 |
+
- requests
|
| 89 |
+
- PyYAML
|
| 90 |
+
args: [
|
| 91 |
+
'--warnings',
|
| 92 |
+
'--pythonversion', '3.9',
|
| 93 |
+
'--typeCheckingMode', 'strict'
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
- repo: https://github.com/RobertCraigie/pyright-python
|
| 97 |
+
rev: v1.1.350
|
| 98 |
+
hooks:
|
| 99 |
+
- id: pyright-python
|
| 100 |
+
name: pyright-strict
|
| 101 |
+
args: [
|
| 102 |
+
'--warnings',
|
| 103 |
+
'--pythonversion', '3.9',
|
| 104 |
+
'--typeCheckingMode', 'strict',
|
| 105 |
+
'--ignoreexternal'
|
| 106 |
+
]
|
| 107 |
+
|
| 108 |
+
- repo: https://github.com/PyCQA/bandit
|
| 109 |
+
rev: 1.7.7
|
| 110 |
+
hooks:
|
| 111 |
+
- id: bandit
|
| 112 |
+
args: ['-c', 'pyproject.toml']
|
| 113 |
+
|
| 114 |
+
- repo: https://github.com/asottile/pyupgrade
|
| 115 |
+
rev: v3.15.1
|
| 116 |
+
hooks:
|
| 117 |
+
- id: pyupgrade
|
| 118 |
+
args: ['--py39-plus']
|
| 119 |
+
|
| 120 |
+
- repo: https://github.com/PyCQA/doc8
|
| 121 |
+
rev: v1.1.1
|
| 122 |
+
hooks:
|
| 123 |
+
- id: doc8
|
| 124 |
+
args: ['--max-line-length=120']
|
| 125 |
+
|
| 126 |
+
- repo: https://github.com/pypa/safety
|
| 127 |
+
rev: 2.3.5
|
| 128 |
+
hooks:
|
| 129 |
+
- id: safety
|
| 130 |
+
args: ['check']
|
| 131 |
+
|
| 132 |
+
- repo: local
|
| 133 |
+
hooks:
|
| 134 |
+
- id: pylint
|
| 135 |
+
name: pylint
|
| 136 |
+
entry: pylint
|
| 137 |
+
language: system
|
| 138 |
+
types: [python]
|
| 139 |
+
args:
|
| 140 |
+
- --rcfile=pyproject.toml
|
| 141 |
+
- --score=no
|
| 142 |
+
- --output-format=colorized
|
| 143 |
+
|
| 144 |
+
- id: pytest-check
|
| 145 |
+
name: pytest-check
|
| 146 |
+
entry: pytest
|
| 147 |
+
language: system
|
| 148 |
+
pass_filenames: false
|
| 149 |
+
always_run: true
|
| 150 |
+
args:
|
| 151 |
+
- --cov
|
| 152 |
+
- --cov-report=term-missing
|
| 153 |
+
- -v
|
| 154 |
+
|
| 155 |
+
- repo: https://github.com/rubik/radon
|
| 156 |
+
rev: v6.0.1
|
| 157 |
+
hooks:
|
| 158 |
+
- id: radon
|
| 159 |
+
name: radon-complexity
|
| 160 |
+
entry: radon cc
|
| 161 |
+
args: [--min=C, --total-average, --show-complexity]
|
| 162 |
+
language: python
|
| 163 |
+
types: [python]
|
| 164 |
+
|
| 165 |
+
- id: radon-maintainability
|
| 166 |
+
name: radon-maintainability
|
| 167 |
+
entry: radon mi
|
| 168 |
+
args: [--min=B, --show]
|
| 169 |
+
language: python
|
| 170 |
+
types: [python]
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable changes to this project will be documented in this file.
|
| 4 |
+
|
| 5 |
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
| 6 |
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
| 7 |
+
|
| 8 |
+
## [Unreleased]
|
| 9 |
+
|
| 10 |
+
### Added
|
| 11 |
+
- Initial project structure and setup
|
| 12 |
+
- Comprehensive code formatting standards
|
| 13 |
+
- Black configuration with 100 character line length
|
| 14 |
+
- isort for import sorting
|
| 15 |
+
- EditorConfig for consistent formatting
|
| 16 |
+
- Static type checking configuration
|
| 17 |
+
- MyPy with strict type checking
|
| 18 |
+
- Pyright integration
|
| 19 |
+
- Type stubs for common dependencies
|
| 20 |
+
- Code quality tools
|
| 21 |
+
- Flake8 with multiple plugins
|
| 22 |
+
- Radon for complexity checks
|
| 23 |
+
- Bandit for security scanning
|
| 24 |
+
- Testing framework
|
| 25 |
+
- Pytest configuration
|
| 26 |
+
- Coverage reporting setup
|
| 27 |
+
- Test helpers and fixtures
|
| 28 |
+
- Documentation
|
| 29 |
+
- Project README
|
| 30 |
+
- Contributing guidelines
|
| 31 |
+
- Code style guide
|
| 32 |
+
- Type checking guide
|
| 33 |
+
- CI/CD Pipeline
|
| 34 |
+
- GitHub Actions workflow
|
| 35 |
+
- Pre-commit hooks configuration
|
| 36 |
+
- Automated testing and linting
|
| 37 |
+
- Development tools
|
| 38 |
+
- Development requirements
|
| 39 |
+
- Virtual environment setup
|
| 40 |
+
- Local development guide
|
| 41 |
+
|
| 42 |
+
### Changed
|
| 43 |
+
- None (initial release)
|
| 44 |
+
|
| 45 |
+
### Deprecated
|
| 46 |
+
- None
|
| 47 |
+
|
| 48 |
+
### Removed
|
| 49 |
+
- None
|
| 50 |
+
|
| 51 |
+
### Fixed
|
| 52 |
+
- None
|
| 53 |
+
|
| 54 |
+
### Security
|
| 55 |
+
- Added Bandit security checks
|
| 56 |
+
- Implemented Safety dependency scanning
|
| 57 |
+
- Configured security-focused pre-commit hooks
|
| 58 |
+
- Added checks for hardcoded secrets
|
| 59 |
+
|
| 60 |
+
## [0.1.0] - YYYY-MM-DD
|
| 61 |
+
### Added
|
| 62 |
+
- First release of the project
|
| 63 |
+
- Basic project structure
|
| 64 |
+
- Core functionality implementation
|
| 65 |
+
- Initial test suite
|
| 66 |
+
- Basic documentation
|
| 67 |
+
|
| 68 |
+
[Unreleased]: https://github.com/username/repository/compare/v0.1.0...HEAD
|
| 69 |
+
[0.1.0]: https://github.com/username/repository/releases/tag/v0.1.0
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing Guidelines
|
| 2 |
+
|
| 3 |
+
## Table of Contents
|
| 4 |
+
- [Development Setup](#development-setup)
|
| 5 |
+
- [Code Style](#code-style)
|
| 6 |
+
- [Type Checking](#type-checking)
|
| 7 |
+
- [Code Quality](#code-quality)
|
| 8 |
+
- [Testing](#testing)
|
| 9 |
+
- [Pull Request Process](#pull-request-process)
|
| 10 |
+
- [Commit Guidelines](#commit-guidelines)
|
| 11 |
+
|
| 12 |
+
## Development Setup
|
| 13 |
+
|
| 14 |
+
### Prerequisites
|
| 15 |
+
- Python 3.9+
|
| 16 |
+
- pip
|
| 17 |
+
- git
|
| 18 |
+
|
| 19 |
+
### Setting Up Development Environment
|
| 20 |
+
1. Clone the repository:
|
| 21 |
+
```bash
|
| 22 |
+
git clone <repository-url>
|
| 23 |
+
cd <repository-name>
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
2. Create and activate a virtual environment:
|
| 27 |
+
```bash
|
| 28 |
+
python -m venv .venv
|
| 29 |
+
source .venv/bin/activate # Linux/macOS
|
| 30 |
+
.venv\Scripts\activate # Windows
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
3. Install development dependencies:
|
| 34 |
+
```bash
|
| 35 |
+
pip install -r requirements-dev.txt
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
4. Install pre-commit hooks:
|
| 39 |
+
```bash
|
| 40 |
+
pre-commit install
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
## Code Style
|
| 44 |
+
|
| 45 |
+
We follow a strict Python code style guide to maintain consistency across the project. Our code style is enforced through various tools:
|
| 46 |
+
|
| 47 |
+
### Formatting
|
| 48 |
+
- **Black**: Code formatting with line length of 100 characters
|
| 49 |
+
- **isort**: Import sorting with three sections (stdlib, third-party, local)
|
| 50 |
+
- **EditorConfig**: Editor-agnostic formatting rules
|
| 51 |
+
|
| 52 |
+
### Style Rules
|
| 53 |
+
- Use 4 spaces for indentation
|
| 54 |
+
- Use meaningful variable and function names
|
| 55 |
+
- Follow PEP 8 naming conventions
|
| 56 |
+
- Write descriptive docstrings in Google style
|
| 57 |
+
- Keep functions focused and concise
|
| 58 |
+
|
| 59 |
+
Example:
|
| 60 |
+
```python
|
| 61 |
+
from typing import List, Optional
|
| 62 |
+
|
| 63 |
+
def process_data(items: List[str], max_length: Optional[int] = None) -> List[str]:
|
| 64 |
+
"""Process a list of string items with optional length limitation.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
items: List of strings to process.
|
| 68 |
+
max_length: Optional maximum length for each string.
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
List of processed strings.
|
| 72 |
+
|
| 73 |
+
Raises:
|
| 74 |
+
ValueError: If any string is empty.
|
| 75 |
+
"""
|
| 76 |
+
if not items:
|
| 77 |
+
raise ValueError("Items list cannot be empty")
|
| 78 |
+
|
| 79 |
+
return [item[:max_length] if max_length else item for item in items]
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## Type Checking
|
| 83 |
+
|
| 84 |
+
We use multiple type checking tools to ensure type safety:
|
| 85 |
+
|
| 86 |
+
### MyPy
|
| 87 |
+
- Strict type checking enabled
|
| 88 |
+
- All functions must have type annotations
|
| 89 |
+
- Generic types must be fully specified
|
| 90 |
+
- No implicit Optional types
|
| 91 |
+
|
| 92 |
+
### Pyright
|
| 93 |
+
- Strict mode enabled
|
| 94 |
+
- Reports missing type stubs
|
| 95 |
+
- Enforces strict collection type inference
|
| 96 |
+
- Checks for proper None handling
|
| 97 |
+
|
| 98 |
+
Example of proper type usage:
|
| 99 |
+
```python
|
| 100 |
+
from typing import Dict, List, Optional, TypeVar
|
| 101 |
+
|
| 102 |
+
T = TypeVar("T")
|
| 103 |
+
|
| 104 |
+
def merge_lists(list1: List[T], list2: List[T]) -> List[T]:
|
| 105 |
+
"""Merge two lists of the same type."""
|
| 106 |
+
return list1 + list2
|
| 107 |
+
|
| 108 |
+
def get_config(path: str) -> Dict[str, Optional[str]]:
|
| 109 |
+
"""Get configuration with optional values."""
|
| 110 |
+
return {"key": "value", "optional_key": None}
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
## Code Quality
|
| 114 |
+
|
| 115 |
+
We maintain high code quality standards through various tools:
|
| 116 |
+
|
| 117 |
+
### Complexity Checks
|
| 118 |
+
- Maximum cyclomatic complexity: C grade (radon)
|
| 119 |
+
- Maximum cognitive complexity: 10 (flake8-cognitive-complexity)
|
| 120 |
+
- Maximum function length: 50 lines
|
| 121 |
+
- Maximum expression complexity: 3 (flake8-expression-complexity)
|
| 122 |
+
|
| 123 |
+
### Security
|
| 124 |
+
- Bandit security checks
|
| 125 |
+
- Safety dependency checks
|
| 126 |
+
- No hardcoded secrets
|
| 127 |
+
- Proper error handling
|
| 128 |
+
|
| 129 |
+
### Documentation
|
| 130 |
+
- All public APIs must be documented
|
| 131 |
+
- Complex algorithms need detailed explanations
|
| 132 |
+
- Update documentation when changing functionality
|
| 133 |
+
- Include examples in docstrings
|
| 134 |
+
|
| 135 |
+
## Testing
|
| 136 |
+
|
| 137 |
+
### Test Requirements
|
| 138 |
+
- All new code must have tests
|
| 139 |
+
- Maintain minimum 90% code coverage
|
| 140 |
+
- Test both success and error cases
|
| 141 |
+
- Use pytest fixtures for common setup
|
| 142 |
+
|
| 143 |
+
### Running Tests
|
| 144 |
+
```bash
|
| 145 |
+
# Run all tests
|
| 146 |
+
pytest
|
| 147 |
+
|
| 148 |
+
# Run with coverage
|
| 149 |
+
pytest --cov
|
| 150 |
+
|
| 151 |
+
# Run specific test file
|
| 152 |
+
pytest tests/test_specific.py
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
## Pull Request Process
|
| 156 |
+
|
| 157 |
+
1. **Branch Naming**
|
| 158 |
+
- feature/description-of-feature
|
| 159 |
+
- bugfix/description-of-bug
|
| 160 |
+
- hotfix/description-of-hotfix
|
| 161 |
+
|
| 162 |
+
2. **Before Submitting**
|
| 163 |
+
- Run all pre-commit hooks
|
| 164 |
+
- Ensure all tests pass
|
| 165 |
+
- Update documentation if needed
|
| 166 |
+
- Add tests for new features
|
| 167 |
+
|
| 168 |
+
3. **PR Description**
|
| 169 |
+
- Clear description of changes
|
| 170 |
+
- Link to related issues
|
| 171 |
+
- List of testing steps
|
| 172 |
+
- Screenshots (if UI changes)
|
| 173 |
+
|
| 174 |
+
4. **Review Process**
|
| 175 |
+
- At least one approval required
|
| 176 |
+
- All comments must be resolved
|
| 177 |
+
- CI checks must pass
|
| 178 |
+
- No merge conflicts
|
| 179 |
+
|
| 180 |
+
## Commit Guidelines
|
| 181 |
+
|
| 182 |
+
### Commit Message Format
|
| 183 |
+
```
|
| 184 |
+
<type>(<scope>): <subject>
|
| 185 |
+
|
| 186 |
+
<body>
|
| 187 |
+
|
| 188 |
+
<footer>
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
### Types
|
| 192 |
+
- feat: New feature
|
| 193 |
+
- fix: Bug fix
|
| 194 |
+
- docs: Documentation
|
| 195 |
+
- style: Formatting
|
| 196 |
+
- refactor: Code restructuring
|
| 197 |
+
- test: Adding tests
|
| 198 |
+
- chore: Maintenance
|
| 199 |
+
|
| 200 |
+
### Example
|
| 201 |
+
```
|
| 202 |
+
feat(auth): implement OAuth2 authentication
|
| 203 |
+
|
| 204 |
+
- Add OAuth2 provider integration
|
| 205 |
+
- Implement token refresh mechanism
|
| 206 |
+
- Add user session management
|
| 207 |
+
|
| 208 |
+
Closes #123
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
### Best Practices
|
| 212 |
+
- Keep commits focused and atomic
|
| 213 |
+
- Write clear commit messages
|
| 214 |
+
- Reference issues in commits
|
| 215 |
+
- Squash related commits
|
| 216 |
+
|
| 217 |
+
## Questions or Problems?
|
| 218 |
+
|
| 219 |
+
Feel free to:
|
| 220 |
+
- Open an issue for questions
|
| 221 |
+
- Join our community chat
|
| 222 |
+
- Check existing documentation
|
| 223 |
+
- Contact the maintainers
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 AI Assistant Team
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -16,3 +16,675 @@ tags:
|
|
| 16 |
---
|
| 17 |
|
| 18 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
---
|
| 17 |
|
| 18 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
| 19 |
+
|
| 20 |
+
# AI Assistant Framework
|
| 21 |
+
|
| 22 |
+
[](https://github.com/yourusername/ai-assistant/actions/workflows/test.yml)
|
| 23 |
+
[](https://github.com/yourusername/ai-assistant/actions/workflows/test.yml)
|
| 24 |
+
[](LICENSE)
|
| 25 |
+
|
| 26 |
+
A robust framework for building AI assistants with built-in error handling, logging, and API integration capabilities.
|
| 27 |
+
|
| 28 |
+
## Features
|
| 29 |
+
|
| 30 |
+
- 🛠 **Extensible Tool System**: Create and register custom tools with a simple interface
|
| 31 |
+
- 🔄 **Error Recovery**: Built-in retry mechanism and circuit breaker pattern
|
| 32 |
+
- 📝 **Comprehensive Logging**: Structured logging with security-aware data sanitization
|
| 33 |
+
- 🔒 **Type Safety**: Full type hints and runtime type checking
|
| 34 |
+
- 🌐 **API Integration**: Robust API error handling and rate limiting
|
| 35 |
+
- ⚡ **Performance**: Efficient caching and resource management
|
| 36 |
+
- 🧪 **Testing**: Extensive test coverage and mocking utilities
|
| 37 |
+
|
| 38 |
+
## Quick Start
|
| 39 |
+
|
| 40 |
+
### Installation
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
pip install ai-assistant
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### Basic Usage
|
| 47 |
+
|
| 48 |
+
```python
|
| 49 |
+
from tools import get_tool
|
| 50 |
+
|
| 51 |
+
# Get a timezone tool
|
| 52 |
+
timezone_tool = get_tool("timezone")
|
| 53 |
+
|
| 54 |
+
# Use the tool
|
| 55 |
+
current_time = timezone_tool.get_current_time("America/New_York")
|
| 56 |
+
print(f"Current time: {current_time}")
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### API Integration Example
|
| 60 |
+
|
| 61 |
+
```python
|
| 62 |
+
from tools.api_logging import log_api_call
|
| 63 |
+
from tools.error_recovery import RetryConfig, CircuitBreaker
|
| 64 |
+
|
| 65 |
+
@log_api_call(
|
| 66 |
+
retry_config=RetryConfig(max_retries=3),
|
| 67 |
+
circuit_breaker=CircuitBreaker(failure_threshold=5)
|
| 68 |
+
)
|
| 69 |
+
def get_weather(zipcode: str) -> dict:
|
| 70 |
+
# Your API call here
|
| 71 |
+
pass
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
## Documentation
|
| 75 |
+
|
| 76 |
+
- [Getting Started Guide](docs/getting_started.md)
|
| 77 |
+
- [API Reference](docs/api_reference.md)
|
| 78 |
+
- [Error Handling Guide](docs/error_handling.md)
|
| 79 |
+
- [Logging System](docs/logging.md)
|
| 80 |
+
|
| 81 |
+
## Core Components
|
| 82 |
+
|
| 83 |
+
### Tool Registry
|
| 84 |
+
|
| 85 |
+
The framework uses a central registry for managing tools:
|
| 86 |
+
|
| 87 |
+
```python
|
| 88 |
+
from tools import ToolRegistry, register_tool
|
| 89 |
+
|
| 90 |
+
@register_tool
|
| 91 |
+
class MyCustomTool(BaseTool):
|
| 92 |
+
@property
|
| 93 |
+
def name(self) -> str:
|
| 94 |
+
return "my_tool"
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### Error Recovery
|
| 98 |
+
|
| 99 |
+
Built-in support for handling API errors:
|
| 100 |
+
|
| 101 |
+
```python
|
| 102 |
+
from tools.exceptions import APIError, APITimeoutError
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
result = api_call()
|
| 106 |
+
except APITimeoutError as e:
|
| 107 |
+
print(f"Timeout after {e.timeout}s")
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### Logging System
|
| 111 |
+
|
| 112 |
+
Comprehensive logging with security features:
|
| 113 |
+
|
| 114 |
+
```python
|
| 115 |
+
@log_api_call(
|
| 116 |
+
log_response=True,
|
| 117 |
+
log_request_body=True
|
| 118 |
+
)
|
| 119 |
+
def secure_api_call():
|
| 120 |
+
# Sensitive data is automatically sanitized
|
| 121 |
+
pass
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## Development
|
| 125 |
+
|
| 126 |
+
### Prerequisites
|
| 127 |
+
|
| 128 |
+
- Python 3.9+
|
| 129 |
+
- pip
|
| 130 |
+
|
| 131 |
+
### Setup
|
| 132 |
+
|
| 133 |
+
1. Clone the repository:
|
| 134 |
+
```bash
|
| 135 |
+
git clone https://github.com/yourusername/ai-assistant.git
|
| 136 |
+
cd ai-assistant
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
2. Create a virtual environment:
|
| 140 |
+
```bash
|
| 141 |
+
python -m venv venv
|
| 142 |
+
source venv/bin/activate # Linux/Mac
|
| 143 |
+
# or
|
| 144 |
+
.\venv\Scripts\activate # Windows
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
3. Install dependencies:
|
| 148 |
+
```bash
|
| 149 |
+
pip install -r requirements.txt
|
| 150 |
+
pip install -r requirements-dev.txt
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
### Running Tests
|
| 154 |
+
|
| 155 |
+
```bash
|
| 156 |
+
python scripts/run_tests.py
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
### Code Quality
|
| 160 |
+
|
| 161 |
+
```bash
|
| 162 |
+
# Format code
|
| 163 |
+
black .
|
| 164 |
+
|
| 165 |
+
# Type checking
|
| 166 |
+
mypy tools tests
|
| 167 |
+
|
| 168 |
+
# Linting
|
| 169 |
+
flake8
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
## Contributing
|
| 173 |
+
|
| 174 |
+
1. Fork the repository
|
| 175 |
+
2. Create a feature branch
|
| 176 |
+
3. Commit your changes
|
| 177 |
+
4. Push to the branch
|
| 178 |
+
5. Create a Pull Request
|
| 179 |
+
|
| 180 |
+
Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and development process.
|
| 181 |
+
|
| 182 |
+
## License
|
| 183 |
+
|
| 184 |
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
| 185 |
+
|
| 186 |
+
## Acknowledgments
|
| 187 |
+
|
| 188 |
+
- Thanks to all contributors who have helped shape this framework
|
| 189 |
+
- Special thanks to the open source community for their invaluable tools and libraries
|
| 190 |
+
|
| 191 |
+
# AI Assistant Application
|
| 192 |
+
|
| 193 |
+
A Python-based AI assistant application that provides weather information and timezone conversions through a user-friendly Gradio interface.
|
| 194 |
+
|
| 195 |
+
## Features
|
| 196 |
+
|
| 197 |
+
- Weather information lookup by zip code using WeatherAPI
|
| 198 |
+
- Timezone conversion functionality
|
| 199 |
+
- Rate limiting for API calls
|
| 200 |
+
- Configurable UI with Gradio
|
| 201 |
+
- Comprehensive error handling
|
| 202 |
+
- Logging system
|
| 203 |
+
|
| 204 |
+
## Prerequisites
|
| 205 |
+
|
| 206 |
+
- Python 3.8 or higher
|
| 207 |
+
- pip package manager
|
| 208 |
+
|
| 209 |
+
## Installation
|
| 210 |
+
|
| 211 |
+
1. Clone the repository:
|
| 212 |
+
```bash
|
| 213 |
+
git clone <repository-url>
|
| 214 |
+
cd <repository-name>
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
2. Create and activate a virtual environment:
|
| 218 |
+
```bash
|
| 219 |
+
python3 -m venv venv
|
| 220 |
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
3. Install dependencies:
|
| 224 |
+
```bash
|
| 225 |
+
pip install -r requirements.txt
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
4. Create a `.env` file in the project root and configure your environment variables:
|
| 229 |
+
```
|
| 230 |
+
WEATHER_API_KEY=your_weather_api_key_here
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
## Configuration
|
| 234 |
+
|
| 235 |
+
The application can be configured through:
|
| 236 |
+
- `.env` file for environment variables
|
| 237 |
+
- `config.py` for application settings
|
| 238 |
+
- `prompts.yaml` for AI assistant prompts
|
| 239 |
+
|
| 240 |
+
## Project Structure
|
| 241 |
+
|
| 242 |
+
```
|
| 243 |
+
.
|
| 244 |
+
├── app.py # Main application file
|
| 245 |
+
├── config.py # Configuration settings
|
| 246 |
+
├── rate_limiter.py # Rate limiting implementation
|
| 247 |
+
├── ui.py # Gradio UI implementation
|
| 248 |
+
├── tools/ # Tool implementations
|
| 249 |
+
│ ├── __init__.py
|
| 250 |
+
│ └── final_answer.py
|
| 251 |
+
├── prompts.yaml # AI assistant prompts
|
| 252 |
+
├── requirements.txt # Project dependencies
|
| 253 |
+
└── .env # Environment variables
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
## Usage
|
| 257 |
+
|
| 258 |
+
1. Ensure your virtual environment is activated
|
| 259 |
+
2. Run the application:
|
| 260 |
+
```bash
|
| 261 |
+
python app.py
|
| 262 |
+
```
|
| 263 |
+
3. Open your web browser and navigate to the URL shown in the console
|
| 264 |
+
|
| 265 |
+
## Error Handling
|
| 266 |
+
|
| 267 |
+
The application includes comprehensive error handling for:
|
| 268 |
+
- API rate limits
|
| 269 |
+
- Invalid inputs
|
| 270 |
+
- Network errors
|
| 271 |
+
- Configuration issues
|
| 272 |
+
|
| 273 |
+
## Logging
|
| 274 |
+
|
| 275 |
+
Logs are written to `assistant.log` with configurable log levels through the `.env` file.
|
| 276 |
+
|
| 277 |
+
## Contributing
|
| 278 |
+
|
| 279 |
+
1. Fork the repository
|
| 280 |
+
2. Create a feature branch
|
| 281 |
+
3. Commit your changes
|
| 282 |
+
4. Push to the branch
|
| 283 |
+
5. Create a Pull Request
|
| 284 |
+
|
| 285 |
+
## License
|
| 286 |
+
|
| 287 |
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
| 288 |
+
|
| 289 |
+
# AI Assistant Configuration Guide
|
| 290 |
+
|
| 291 |
+
This document provides a comprehensive guide to configuring the AI Assistant application. Configuration can be managed through environment variables or a JSON configuration file.
|
| 292 |
+
|
| 293 |
+
## Table of Contents
|
| 294 |
+
- [Environment Variables](#environment-variables)
|
| 295 |
+
- [Configuration Options](#configuration-options)
|
| 296 |
+
- [Weather API Configuration](#weather-api-configuration)
|
| 297 |
+
- [Timezone API Configuration](#timezone-api-configuration)
|
| 298 |
+
- [Assistant Configuration](#assistant-configuration)
|
| 299 |
+
- [UI Configuration](#ui-configuration)
|
| 300 |
+
- [Logging Configuration](#logging-configuration)
|
| 301 |
+
- [Model Configuration](#model-configuration)
|
| 302 |
+
- [Usage Examples](#usage-examples)
|
| 303 |
+
|
| 304 |
+
## Environment Variables
|
| 305 |
+
|
| 306 |
+
All configuration options can be set via environment variables. Environment variables take precedence over JSON configuration. Create a `.env` file in the project root to set these variables.
|
| 307 |
+
|
| 308 |
+
Example `.env` file:
|
| 309 |
+
```bash
|
| 310 |
+
WEATHER_API_KEY=your_api_key_here
|
| 311 |
+
LOG_LEVEL=INFO
|
| 312 |
+
UI_THEME=dark
|
| 313 |
+
```
|
| 314 |
+
|
| 315 |
+
## Configuration Options
|
| 316 |
+
|
| 317 |
+
### Weather API Configuration
|
| 318 |
+
|
| 319 |
+
Controls the weather service API settings.
|
| 320 |
+
|
| 321 |
+
| Option | Environment Variable | Default | Description |
|
| 322 |
+
|--------|---------------------|---------|-------------|
|
| 323 |
+
| `base_url` | `WEATHER_API_BASE_URL` | `http://api.weatherapi.com/v1` | Base URL for the weather API |
|
| 324 |
+
| `rate_limit_per_minute` | `WEATHER_API_RATE_LIMIT` | `60` | Maximum API calls per minute |
|
| 325 |
+
| `cache_timeout_seconds` | `WEATHER_CACHE_TIMEOUT` | `300` | How long to cache weather data |
|
| 326 |
+
| `api_key` | `WEATHER_API_KEY` | `None` | Your weather API key |
|
| 327 |
+
|
| 328 |
+
### Timezone API Configuration
|
| 329 |
+
|
| 330 |
+
Controls the timezone service API settings.
|
| 331 |
+
|
| 332 |
+
| Option | Environment Variable | Default | Description |
|
| 333 |
+
|--------|---------------------|---------|-------------|
|
| 334 |
+
| `rate_limit_per_minute` | `TIMEZONE_API_RATE_LIMIT` | `100` | Maximum API calls per minute |
|
| 335 |
+
|
| 336 |
+
### Assistant Configuration
|
| 337 |
+
|
| 338 |
+
Controls the AI Assistant's behavior.
|
| 339 |
+
|
| 340 |
+
#### Command History
|
| 341 |
+
| Option | Environment Variable | Default | Description |
|
| 342 |
+
|--------|---------------------|---------|-------------|
|
| 343 |
+
| `max_size` | `COMMAND_HISTORY_MAX_SIZE` | `10` | Maximum number of commands to remember |
|
| 344 |
+
|
| 345 |
+
#### Cache Settings
|
| 346 |
+
| Option | Environment Variable | Default | Description |
|
| 347 |
+
|--------|---------------------|---------|-------------|
|
| 348 |
+
| `cleanup_interval_seconds` | `CACHE_CLEANUP_INTERVAL` | `600` | Interval between cache cleanup operations |
|
| 349 |
+
|
| 350 |
+
### UI Configuration
|
| 351 |
+
|
| 352 |
+
Controls the user interface appearance and behavior.
|
| 353 |
+
|
| 354 |
+
| Option | Environment Variable | Default | Description |
|
| 355 |
+
|--------|---------------------|---------|-------------|
|
| 356 |
+
| `title` | `UI_TITLE` | `AI Assistant` | Application title |
|
| 357 |
+
| `description` | `UI_DESCRIPTION` | `Weather and Timezone Assistant` | Application description |
|
| 358 |
+
| `theme` | `UI_THEME` | `light` | UI theme (`light` or `dark`) |
|
| 359 |
+
| `input_placeholder` | `UI_INPUT_PLACEHOLDER` | `Enter your command (type 'help' for available commands)` | Input field placeholder text |
|
| 360 |
+
| `width` | `UI_WIDTH` | `100%` | UI width (CSS units) |
|
| 361 |
+
| `height` | `UI_HEIGHT` | `600px` | UI height (CSS units) |
|
| 362 |
+
|
| 363 |
+
### Logging Configuration
|
| 364 |
+
|
| 365 |
+
Controls application logging behavior.
|
| 366 |
+
|
| 367 |
+
| Option | Environment Variable | Default | Description |
|
| 368 |
+
|--------|---------------------|---------|-------------|
|
| 369 |
+
| `level` | `LOG_LEVEL` | `INFO` | Log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) |
|
| 370 |
+
| `file` | `LOG_FILE` | `assistant.log` | Log file path |
|
| 371 |
+
| `format` | `LOG_FORMAT` | `%(asctime)s - %(name)s - %(levelname)s - %(message)s` | Log message format |
|
| 372 |
+
|
| 373 |
+
### Model Configuration
|
| 374 |
+
|
| 375 |
+
Controls the AI model behavior.
|
| 376 |
+
|
| 377 |
+
| Option | Environment Variable | Default | Description |
|
| 378 |
+
|--------|---------------------|---------|-------------|
|
| 379 |
+
| `class_name` | `MODEL_CLASS` | `HfApiModel` | Model class to use |
|
| 380 |
+
| `max_tokens` | `MODEL_MAX_TOKENS` | `2096` | Maximum tokens per response |
|
| 381 |
+
| `temperature` | `MODEL_TEMPERATURE` | `0.5` | Response randomness (0.0-1.0) |
|
| 382 |
+
| `model_id` | `MODEL_ID` | `Qwen/Qwen2.5-Coder-32B-Instruct` | Model identifier |
|
| 383 |
+
|
| 384 |
+
## Usage Examples
|
| 385 |
+
|
| 386 |
+
### Using Environment Variables
|
| 387 |
+
|
| 388 |
+
```bash
|
| 389 |
+
# Set weather API configuration
|
| 390 |
+
export WEATHER_API_KEY=your_api_key_here
|
| 391 |
+
export WEATHER_API_RATE_LIMIT=30
|
| 392 |
+
|
| 393 |
+
# Configure logging
|
| 394 |
+
export LOG_LEVEL=DEBUG
|
| 395 |
+
export LOG_FILE=logs/assistant.log
|
| 396 |
+
|
| 397 |
+
# Set UI preferences
|
| 398 |
+
export UI_THEME=dark
|
| 399 |
+
export UI_WIDTH=80%
|
| 400 |
+
```
|
| 401 |
+
|
| 402 |
+
### Using JSON Configuration
|
| 403 |
+
|
| 404 |
+
Create a `config.json` file:
|
| 405 |
+
|
| 406 |
+
```json
|
| 407 |
+
{
|
| 408 |
+
"config": {
|
| 409 |
+
"api": {
|
| 410 |
+
"weather": {
|
| 411 |
+
"base_url": "http://api.weatherapi.com/v1",
|
| 412 |
+
"rate_limit_per_minute": 30,
|
| 413 |
+
"cache_timeout_seconds": 600
|
| 414 |
+
},
|
| 415 |
+
"timezone": {
|
| 416 |
+
"rate_limit_per_minute": 50
|
| 417 |
+
}
|
| 418 |
+
},
|
| 419 |
+
"assistant": {
|
| 420 |
+
"command_history": {
|
| 421 |
+
"max_size": 20
|
| 422 |
+
},
|
| 423 |
+
"cache": {
|
| 424 |
+
"cleanup_interval_seconds": 300
|
| 425 |
+
}
|
| 426 |
+
},
|
| 427 |
+
"ui": {
|
| 428 |
+
"theme": "dark",
|
| 429 |
+
"width": "80%",
|
| 430 |
+
"height": "800px"
|
| 431 |
+
},
|
| 432 |
+
"logging": {
|
| 433 |
+
"level": "DEBUG",
|
| 434 |
+
"file": "logs/debug.log"
|
| 435 |
+
}
|
| 436 |
+
},
|
| 437 |
+
"model": {
|
| 438 |
+
"class": "HfApiModel",
|
| 439 |
+
"data": {
|
| 440 |
+
"max_tokens": 1024,
|
| 441 |
+
"temperature": 0.7,
|
| 442 |
+
"model_id": "Qwen/Qwen2.5-Coder-32B-Instruct"
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
}
|
| 446 |
+
```
|
| 447 |
+
|
| 448 |
+
### Loading Configuration in Code
|
| 449 |
+
|
| 450 |
+
```python
|
| 451 |
+
from config import Config
|
| 452 |
+
|
| 453 |
+
# Load from environment variables
|
| 454 |
+
config = Config()
|
| 455 |
+
|
| 456 |
+
# Or load from JSON file
|
| 457 |
+
with open('config.json') as f:
|
| 458 |
+
import json
|
| 459 |
+
config = Config.from_json(json.load(f))
|
| 460 |
+
|
| 461 |
+
# Access configuration
|
| 462 |
+
weather_url = config.api.weather.base_url
|
| 463 |
+
log_level = config.logging.level
|
| 464 |
+
ui_theme = config.ui.theme
|
| 465 |
+
```
|
| 466 |
+
|
| 467 |
+
## Validation
|
| 468 |
+
|
| 469 |
+
The configuration system includes comprehensive validation:
|
| 470 |
+
|
| 471 |
+
- All numeric values are checked for valid ranges
|
| 472 |
+
- URLs are validated for correct format
|
| 473 |
+
- File paths are checked for existence/createability
|
| 474 |
+
- Enums (like log levels and themes) are validated against allowed values
|
| 475 |
+
- Cross-field validation ensures consistent configuration
|
| 476 |
+
|
| 477 |
+
If validation fails, a `ConfigValidationError` is raised with a descriptive message and the field name that caused the error.
|
| 478 |
+
|
| 479 |
+
# API Configuration Guide
|
| 480 |
+
|
| 481 |
+
This document describes all available configuration options for the API client. Configuration can be provided via environment variables or a JSON configuration file.
|
| 482 |
+
|
| 483 |
+
## Table of Contents
|
| 484 |
+
- [Configuration Methods](#configuration-methods)
|
| 485 |
+
- [Base Configuration](#base-configuration)
|
| 486 |
+
- [Connection Pool Settings](#connection-pool-settings)
|
| 487 |
+
- [Rate Limiting Settings](#rate-limiting-settings)
|
| 488 |
+
- [Batch Processing Settings](#batch-processing-settings)
|
| 489 |
+
- [Monitoring Settings](#monitoring-settings)
|
| 490 |
+
- [Retry Settings](#retry-settings)
|
| 491 |
+
- [Environment Variables](#environment-variables)
|
| 492 |
+
- [Examples](#examples)
|
| 493 |
+
|
| 494 |
+
## Configuration Methods
|
| 495 |
+
|
| 496 |
+
Configuration can be provided in two ways:
|
| 497 |
+
|
| 498 |
+
1. **Environment Variables**: Set using the format `API_<SECTION>_<SETTING>`
|
| 499 |
+
2. **JSON Configuration File**: Provide settings in `agent.json`
|
| 500 |
+
|
| 501 |
+
Environment variables take precedence over file-based configuration.
|
| 502 |
+
|
| 503 |
+
## Base Configuration
|
| 504 |
+
|
| 505 |
+
| Setting | Description | Type | Required | Default | Validation |
|
| 506 |
+
|------------|--------------------|----------|----------|---------------------|-------------------------------|
|
| 507 |
+
| `base_url` | Base URL for API | string | Yes | http://localhost:8000 | Must be valid HTTP/HTTPS URL |
|
| 508 |
+
|
| 509 |
+
## Connection Pool Settings
|
| 510 |
+
|
| 511 |
+
| Setting | Description | Type | Default | Range | Environment Variable |
|
| 512 |
+
|-------------------|------------------------------|---------|---------|--------------|----------------------------------|
|
| 513 |
+
| `pool_size` | Maximum connections | integer | 10 | 1-100 | `API_CONNECTION_POOL_SIZE` |
|
| 514 |
+
| `timeout` | Connection timeout (seconds) | float | 30.0 | 0.0-300.0 | `API_CONNECTION_TIMEOUT` |
|
| 515 |
+
| `keepalive_timeout` | Keep-alive timeout (seconds) | float | None | 0.0-300.0 | `API_CONNECTION_KEEPALIVE_TIMEOUT` |
|
| 516 |
+
| `ttl_dns_cache` | DNS cache TTL (seconds) | integer | None | 0-3600 | `API_CONNECTION_TTL_DNS_CACHE` |
|
| 517 |
+
| `force_close` | Force close connections | boolean | false | true/false | `API_CONNECTION_FORCE_CLOSE` |
|
| 518 |
+
|
| 519 |
+
**Validation Rules**:
|
| 520 |
+
- `keepalive_timeout` must be less than or equal to `timeout`
|
| 521 |
+
- `pool_size` should be set based on expected concurrent connections
|
| 522 |
+
|
| 523 |
+
## Rate Limiting Settings
|
| 524 |
+
|
| 525 |
+
| Setting | Description | Type | Default | Range | Environment Variable |
|
| 526 |
+
|-----------|------------------------|---------|---------|------------|------------------------|
|
| 527 |
+
| `rate` | Requests per second | float | 10.0 | 0.1-1000.0 | `API_RATE_LIMIT_RATE` |
|
| 528 |
+
| `burst` | Maximum burst size | integer | 20 | 1-2000 | `API_RATE_LIMIT_BURST` |
|
| 529 |
+
| `enabled` | Enable rate limiting | boolean | true | true/false | `API_RATE_LIMIT_ENABLED` |
|
| 530 |
+
|
| 531 |
+
**Validation Rules**:
|
| 532 |
+
- `burst` must be greater than or equal to `rate`
|
| 533 |
+
- `burst` must not exceed 10x the `rate`
|
| 534 |
+
- Recommended: Set `burst` to 2-3x the `rate` for normal operation
|
| 535 |
+
|
| 536 |
+
## Batch Processing Settings
|
| 537 |
+
|
| 538 |
+
| Setting | Description | Type | Default | Range | Environment Variable |
|
| 539 |
+
|------------|-------------------------|---------|---------|-----------|---------------------|
|
| 540 |
+
| `size` | Maximum items per batch | integer | 100 | 1-1000 | `API_BATCH_SIZE` |
|
| 541 |
+
| `interval` | Flush interval (seconds)| float | 1.0 | 0.1-60.0 | `API_BATCH_INTERVAL` |
|
| 542 |
+
| `enabled` | Enable batch processing | boolean | true | true/false| `API_BATCH_ENABLED` |
|
| 543 |
+
|
| 544 |
+
**Best Practices**:
|
| 545 |
+
- Set `size` based on your API's batch processing capabilities
|
| 546 |
+
- Adjust `interval` based on latency requirements and load
|
| 547 |
+
|
| 548 |
+
## Monitoring Settings
|
| 549 |
+
|
| 550 |
+
| Setting | Description | Type | Default | Range | Environment Variable |
|
| 551 |
+
|----------------|--------------------------------|---------|---------|------------|-------------------------------|
|
| 552 |
+
| `metrics_ttl` | Metrics TTL (seconds) | integer | 3600 | 0-86400 | `API_MONITORING_METRICS_TTL` |
|
| 553 |
+
| `max_metrics` | Maximum metrics to store | integer | 1000 | 1-10000 | `API_MONITORING_MAX_METRICS` |
|
| 554 |
+
| `log_interval` | Metrics logging interval (sec) | float | 60.0 | 0.1-3600.0 | `API_MONITORING_LOG_INTERVAL` |
|
| 555 |
+
| `enabled` | Enable monitoring | boolean | true | true/false | `API_MONITORING_ENABLED` |
|
| 556 |
+
|
| 557 |
+
**Validation Rules**:
|
| 558 |
+
- `log_interval` must be less than `metrics_ttl`
|
| 559 |
+
- Consider memory usage when setting `max_metrics`
|
| 560 |
+
|
| 561 |
+
## Retry Settings
|
| 562 |
+
|
| 563 |
+
| Setting | Description | Type | Default | Range | Environment Variable |
|
| 564 |
+
|----------------|---------------------------------|---------|---------|------------|-------------------------|
|
| 565 |
+
| `max_attempts` | Maximum retry attempts | integer | 3 | 1-10 | `API_RETRY_MAX_ATTEMPTS` |
|
| 566 |
+
| `min_wait` | Minimum wait time (seconds) | float | 1.0 | 0.1-60.0 | `API_RETRY_MIN_WAIT` |
|
| 567 |
+
| `max_wait` | Maximum wait time (seconds) | float | 10.0 | 0.1-300.0 | `API_RETRY_MAX_WAIT` |
|
| 568 |
+
| `enabled` | Enable retry mechanism | boolean | true | true/false | `API_RETRY_ENABLED` |
|
| 569 |
+
|
| 570 |
+
**Validation Rules**:
|
| 571 |
+
- `max_wait` must be greater than `min_wait`
|
| 572 |
+
- `max_wait` must not exceed 10x `min_wait`
|
| 573 |
+
- Uses exponential backoff between `min_wait` and `max_wait`
|
| 574 |
+
|
| 575 |
+
## Environment Variables
|
| 576 |
+
|
| 577 |
+
All settings can be configured using environment variables. Example `.env` file:
|
| 578 |
+
|
| 579 |
+
```bash
|
| 580 |
+
# Base Configuration
|
| 581 |
+
API_BASE_URL=http://api.example.com
|
| 582 |
+
|
| 583 |
+
# Connection Pool
|
| 584 |
+
API_CONNECTION_POOL_SIZE=20
|
| 585 |
+
API_CONNECTION_TIMEOUT=30.0
|
| 586 |
+
API_CONNECTION_KEEPALIVE_TIMEOUT=60.0
|
| 587 |
+
API_CONNECTION_TTL_DNS_CACHE=300
|
| 588 |
+
API_CONNECTION_FORCE_CLOSE=false
|
| 589 |
+
|
| 590 |
+
# Rate Limiting
|
| 591 |
+
API_RATE_LIMIT_RATE=50.0
|
| 592 |
+
API_RATE_LIMIT_BURST=100
|
| 593 |
+
API_RATE_LIMIT_ENABLED=true
|
| 594 |
+
|
| 595 |
+
# Batch Processing
|
| 596 |
+
API_BATCH_SIZE=500
|
| 597 |
+
API_BATCH_INTERVAL=2.0
|
| 598 |
+
API_BATCH_ENABLED=true
|
| 599 |
+
|
| 600 |
+
# Monitoring
|
| 601 |
+
API_MONITORING_METRICS_TTL=7200
|
| 602 |
+
API_MONITORING_MAX_METRICS=5000
|
| 603 |
+
API_MONITORING_LOG_INTERVAL=300.0
|
| 604 |
+
API_MONITORING_ENABLED=true
|
| 605 |
+
|
| 606 |
+
# Retry
|
| 607 |
+
API_RETRY_MAX_ATTEMPTS=5
|
| 608 |
+
API_RETRY_MIN_WAIT=1.0
|
| 609 |
+
API_RETRY_MAX_WAIT=30.0
|
| 610 |
+
API_RETRY_ENABLED=true
|
| 611 |
+
```
|
| 612 |
+
|
| 613 |
+
## Examples
|
| 614 |
+
|
| 615 |
+
### Minimal Configuration
|
| 616 |
+
|
| 617 |
+
```json
|
| 618 |
+
{
|
| 619 |
+
"config": {
|
| 620 |
+
"base_url": "http://api.example.com"
|
| 621 |
+
}
|
| 622 |
+
}
|
| 623 |
+
```
|
| 624 |
+
|
| 625 |
+
### High-Performance Configuration
|
| 626 |
+
|
| 627 |
+
```json
|
| 628 |
+
{
|
| 629 |
+
"config": {
|
| 630 |
+
"base_url": "http://api.example.com",
|
| 631 |
+
"connection_pool": {
|
| 632 |
+
"pool_size": 50,
|
| 633 |
+
"timeout": 60.0,
|
| 634 |
+
"keepalive_timeout": 30.0,
|
| 635 |
+
"ttl_dns_cache": 300
|
| 636 |
+
},
|
| 637 |
+
"rate_limit": {
|
| 638 |
+
"rate": 100.0,
|
| 639 |
+
"burst": 200
|
| 640 |
+
},
|
| 641 |
+
"batch": {
|
| 642 |
+
"size": 500,
|
| 643 |
+
"interval": 2.0
|
| 644 |
+
}
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
```
|
| 648 |
+
|
| 649 |
+
### High-Reliability Configuration
|
| 650 |
+
|
| 651 |
+
```json
|
| 652 |
+
{
|
| 653 |
+
"config": {
|
| 654 |
+
"base_url": "http://api.example.com",
|
| 655 |
+
"retry": {
|
| 656 |
+
"max_attempts": 5,
|
| 657 |
+
"min_wait": 1.0,
|
| 658 |
+
"max_wait": 30.0
|
| 659 |
+
},
|
| 660 |
+
"monitoring": {
|
| 661 |
+
"metrics_ttl": 7200,
|
| 662 |
+
"max_metrics": 5000,
|
| 663 |
+
"log_interval": 300.0
|
| 664 |
+
}
|
| 665 |
+
}
|
| 666 |
+
}
|
| 667 |
+
```
|
| 668 |
+
|
| 669 |
+
### Development Configuration
|
| 670 |
+
|
| 671 |
+
```json
|
| 672 |
+
{
|
| 673 |
+
"config": {
|
| 674 |
+
"base_url": "http://localhost:8000",
|
| 675 |
+
"connection_pool": {
|
| 676 |
+
"pool_size": 5,
|
| 677 |
+
"timeout": 5.0
|
| 678 |
+
},
|
| 679 |
+
"rate_limit": {
|
| 680 |
+
"rate": 10.0,
|
| 681 |
+
"burst": 20
|
| 682 |
+
},
|
| 683 |
+
"monitoring": {
|
| 684 |
+
"metrics_ttl": 3600,
|
| 685 |
+
"max_metrics": 1000,
|
| 686 |
+
"log_interval": 60.0
|
| 687 |
+
}
|
| 688 |
+
}
|
| 689 |
+
}
|
| 690 |
+
```
|
SECURITY.md
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Best Practices
|
| 2 |
+
|
| 3 |
+
This document outlines security best practices for using the API key management system.
|
| 4 |
+
|
| 5 |
+
## Table of Contents
|
| 6 |
+
1. [API Key Management](#api-key-management)
|
| 7 |
+
2. [Rate Limiting](#rate-limiting)
|
| 8 |
+
3. [Key Storage](#key-storage)
|
| 9 |
+
4. [Environment Variables](#environment-variables)
|
| 10 |
+
5. [Error Handling](#error-handling)
|
| 11 |
+
6. [Encryption](#encryption)
|
| 12 |
+
7. [Monitoring and Logging](#monitoring-and-logging)
|
| 13 |
+
8. [Security Checklist](#security-checklist)
|
| 14 |
+
|
| 15 |
+
## API Key Management
|
| 16 |
+
|
| 17 |
+
### Key Generation
|
| 18 |
+
- Use cryptographically secure random generators for API keys
|
| 19 |
+
- Minimum key length: 32 characters
|
| 20 |
+
- Use URL-safe characters: `[A-Za-z0-9_.-]`
|
| 21 |
+
- Example:
|
| 22 |
+
```python
|
| 23 |
+
import secrets
|
| 24 |
+
import string
|
| 25 |
+
|
| 26 |
+
def generate_api_key(length: int = 32) -> str:
|
| 27 |
+
alphabet = string.ascii_letters + string.digits + '_.-'
|
| 28 |
+
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
### Key Rotation
|
| 32 |
+
- Rotate keys regularly (recommended: every 90 days)
|
| 33 |
+
- Implement automatic key expiration
|
| 34 |
+
- Keep old keys active during transition period
|
| 35 |
+
- Use the `rotate_key()` method:
|
| 36 |
+
```python
|
| 37 |
+
from datetime import datetime, timedelta, timezone
|
| 38 |
+
|
| 39 |
+
# Rotate key with 7-day overlap
|
| 40 |
+
new_key = key_manager.rotate_key(
|
| 41 |
+
name="service_name",
|
| 42 |
+
new_key=generate_api_key(),
|
| 43 |
+
expires_at=datetime.now(timezone.utc) + timedelta(days=90)
|
| 44 |
+
)
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
### Access Control
|
| 48 |
+
- Use scoped access for fine-grained control
|
| 49 |
+
- Implement the principle of least privilege
|
| 50 |
+
- Regularly audit key usage and permissions
|
| 51 |
+
- Example scopes:
|
| 52 |
+
```python
|
| 53 |
+
key_manager.add_key(
|
| 54 |
+
name="read_only_key",
|
| 55 |
+
key=generate_api_key(),
|
| 56 |
+
scopes=["read:data", "list:items"]
|
| 57 |
+
)
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
## Rate Limiting
|
| 61 |
+
|
| 62 |
+
### Configuration
|
| 63 |
+
- Set appropriate rate limits based on service capacity
|
| 64 |
+
- Configure burst limits for handling traffic spikes
|
| 65 |
+
- Monitor rate limit violations
|
| 66 |
+
- Example:
|
| 67 |
+
```python
|
| 68 |
+
key_manager.add_key(
|
| 69 |
+
name="high_throughput",
|
| 70 |
+
key=generate_api_key(),
|
| 71 |
+
rate_limit=100.0, # 100 requests per second
|
| 72 |
+
burst_limit=200 # Allow bursts up to 200
|
| 73 |
+
)
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
### Best Practices
|
| 77 |
+
- Implement client-side rate limiting
|
| 78 |
+
- Use exponential backoff for retries
|
| 79 |
+
- Monitor rate limit errors
|
| 80 |
+
- Handle rate limit errors gracefully:
|
| 81 |
+
```python
|
| 82 |
+
try:
|
| 83 |
+
wait_time = await key_manager.check_rate_limit("my_key")
|
| 84 |
+
if wait_time > 0:
|
| 85 |
+
# Implement backoff strategy
|
| 86 |
+
await asyncio.sleep(wait_time)
|
| 87 |
+
except RateLimitError as e:
|
| 88 |
+
# Handle rate limit exceeded
|
| 89 |
+
logger.warning(f"Rate limit exceeded: {e}")
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
## Key Storage
|
| 93 |
+
|
| 94 |
+
### File Storage
|
| 95 |
+
- Use encrypted storage for API keys
|
| 96 |
+
- Secure file permissions (600 or more restrictive)
|
| 97 |
+
- Regular backups of key storage
|
| 98 |
+
- Example file permissions:
|
| 99 |
+
```bash
|
| 100 |
+
chmod 600 keys.json
|
| 101 |
+
chown service_user:service_group keys.json
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
### Memory Storage
|
| 105 |
+
- Never log API keys
|
| 106 |
+
- Clear sensitive data from memory when no longer needed
|
| 107 |
+
- Use secure string handling:
|
| 108 |
+
```python
|
| 109 |
+
from pydantic import SecretStr
|
| 110 |
+
|
| 111 |
+
# Keys are automatically masked in logs/str representation
|
| 112 |
+
key_config = APIKeyConfig(
|
| 113 |
+
key=SecretStr("my-secret-key"),
|
| 114 |
+
name="service_name"
|
| 115 |
+
)
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
## Environment Variables
|
| 119 |
+
|
| 120 |
+
### Configuration
|
| 121 |
+
- Use environment variables for sensitive data
|
| 122 |
+
- Follow naming conventions
|
| 123 |
+
- Set appropriate file permissions for env files
|
| 124 |
+
- Example `.env` file:
|
| 125 |
+
```bash
|
| 126 |
+
# API Keys
|
| 127 |
+
API_SERVICE_KEY=your-secret-key
|
| 128 |
+
API_SERVICE_RATE_LIMIT=100
|
| 129 |
+
API_SERVICE_BURST_LIMIT=200
|
| 130 |
+
|
| 131 |
+
# Encryption
|
| 132 |
+
API_ENCRYPTION_KEY=your-encryption-key
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
### Security Measures
|
| 136 |
+
- Never commit `.env` files to version control
|
| 137 |
+
- Use different keys for different environments
|
| 138 |
+
- Rotate environment variables regularly
|
| 139 |
+
- Example `.gitignore`:
|
| 140 |
+
```
|
| 141 |
+
.env
|
| 142 |
+
*.key
|
| 143 |
+
keys.json
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
## Error Handling
|
| 147 |
+
|
| 148 |
+
### Best Practices
|
| 149 |
+
- Use specific error types for different security issues
|
| 150 |
+
- Never expose sensitive information in error messages
|
| 151 |
+
- Log security events appropriately
|
| 152 |
+
- Example:
|
| 153 |
+
```python
|
| 154 |
+
try:
|
| 155 |
+
key = key_manager.get_key("service_name")
|
| 156 |
+
except InvalidKeyError:
|
| 157 |
+
# Log the error, but don't expose key details
|
| 158 |
+
logger.error("Invalid API key used", extra={
|
| 159 |
+
"service": "service_name",
|
| 160 |
+
"error_type": "invalid_key"
|
| 161 |
+
})
|
| 162 |
+
raise HTTPError(401, "Authentication failed")
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
### Error Types
|
| 166 |
+
- `SecurityError`: Base class for security errors
|
| 167 |
+
- `AuthenticationError`: Authentication failures
|
| 168 |
+
- `AuthorizationError`: Permission issues
|
| 169 |
+
- `InvalidKeyError`: Invalid/missing keys
|
| 170 |
+
- `KeyExpiredError`: Expired keys
|
| 171 |
+
- `RateLimitError`: Rate limit violations
|
| 172 |
+
- `EncryptionError`: Encryption issues
|
| 173 |
+
- `ValidationError`: Input validation failures
|
| 174 |
+
- `ConfigurationError`: Configuration issues
|
| 175 |
+
|
| 176 |
+
## Encryption
|
| 177 |
+
|
| 178 |
+
### Key Management
|
| 179 |
+
- Use strong encryption keys (min 32 bytes)
|
| 180 |
+
- Regular rotation of encryption keys
|
| 181 |
+
- Secure key storage
|
| 182 |
+
- Example:
|
| 183 |
+
```python
|
| 184 |
+
import base64
|
| 185 |
+
import os
|
| 186 |
+
|
| 187 |
+
# Generate new encryption key
|
| 188 |
+
encryption_key = base64.urlsafe_b64encode(os.urandom(32)).decode()
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
### Best Practices
|
| 192 |
+
- Use industry-standard encryption (Fernet)
|
| 193 |
+
- Implement key rotation
|
| 194 |
+
- Secure key transmission
|
| 195 |
+
- Example:
|
| 196 |
+
```python
|
| 197 |
+
from cryptography.fernet import Fernet
|
| 198 |
+
|
| 199 |
+
# Initialize with encryption key
|
| 200 |
+
key_manager = KeyManager(
|
| 201 |
+
encryption_key=os.getenv("API_ENCRYPTION_KEY")
|
| 202 |
+
)
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
## Monitoring and Logging
|
| 206 |
+
|
| 207 |
+
### Security Events
|
| 208 |
+
- Log all security-related events
|
| 209 |
+
- Use appropriate log levels
|
| 210 |
+
- Include relevant context
|
| 211 |
+
- Example:
|
| 212 |
+
```python
|
| 213 |
+
import structlog
|
| 214 |
+
|
| 215 |
+
logger = structlog.get_logger()
|
| 216 |
+
|
| 217 |
+
# Log security event
|
| 218 |
+
logger.warning("rate_limit_exceeded",
|
| 219 |
+
key_name="service_name",
|
| 220 |
+
wait_time=1.5,
|
| 221 |
+
threshold=100
|
| 222 |
+
)
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
### Monitoring
|
| 226 |
+
- Monitor failed authentication attempts
|
| 227 |
+
- Track rate limit violations
|
| 228 |
+
- Alert on suspicious activity
|
| 229 |
+
- Example metrics:
|
| 230 |
+
```python
|
| 231 |
+
# Track security metrics
|
| 232 |
+
metrics = {
|
| 233 |
+
"failed_auth_attempts": counter,
|
| 234 |
+
"rate_limit_violations": counter,
|
| 235 |
+
"key_rotations": counter,
|
| 236 |
+
"encryption_errors": counter
|
| 237 |
+
}
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
## Security Checklist
|
| 241 |
+
|
| 242 |
+
### Implementation
|
| 243 |
+
- [ ] Use secure random API key generation
|
| 244 |
+
- [ ] Implement key rotation
|
| 245 |
+
- [ ] Configure appropriate rate limits
|
| 246 |
+
- [ ] Set up encrypted storage
|
| 247 |
+
- [ ] Use environment variables
|
| 248 |
+
- [ ] Implement proper error handling
|
| 249 |
+
- [ ] Set up security logging
|
| 250 |
+
- [ ] Configure monitoring
|
| 251 |
+
|
| 252 |
+
### Deployment
|
| 253 |
+
- [ ] Secure file permissions
|
| 254 |
+
- [ ] Environment separation
|
| 255 |
+
- [ ] Backup strategy
|
| 256 |
+
- [ ] Monitoring setup
|
| 257 |
+
- [ ] Alert configuration
|
| 258 |
+
- [ ] Audit logging
|
| 259 |
+
- [ ] Regular security reviews
|
| 260 |
+
|
| 261 |
+
### Maintenance
|
| 262 |
+
- [ ] Regular key rotation
|
| 263 |
+
- [ ] Log review
|
| 264 |
+
- [ ] Security updates
|
| 265 |
+
- [ ] Access audit
|
| 266 |
+
- [ ] Performance monitoring
|
| 267 |
+
- [ ] Incident response plan
|
| 268 |
+
- [ ] Documentation updates
|
| 269 |
+
|
| 270 |
+
## Reporting Security Issues
|
| 271 |
+
|
| 272 |
+
If you discover a security vulnerability, please follow these steps:
|
| 273 |
+
|
| 274 |
+
1. **DO NOT** open a public issue
|
| 275 |
+
2. Send a private report to [security@example.com]
|
| 276 |
+
3. Include detailed information about the vulnerability
|
| 277 |
+
4. Wait for confirmation before any disclosure
|
| 278 |
+
|
| 279 |
+
## Updates and Changes
|
| 280 |
+
|
| 281 |
+
This security documentation is regularly reviewed and updated. Last update: [Current Date]
|
| 282 |
+
|
| 283 |
+
For questions or suggestions about security practices, please contact the security team.
|
STYLE_GUIDE.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python Code Style Guide
|
| 2 |
+
|
| 3 |
+
## General Guidelines
|
| 4 |
+
|
| 5 |
+
- Line length: 100 characters maximum
|
| 6 |
+
- Indentation: 4 spaces (no tabs)
|
| 7 |
+
- File encoding: UTF-8
|
| 8 |
+
- Line endings: LF (Unix-style)
|
| 9 |
+
- One blank line between functions and classes
|
| 10 |
+
- Two blank lines between top-level functions and classes
|
| 11 |
+
|
| 12 |
+
## Naming Conventions
|
| 13 |
+
|
| 14 |
+
- Package names: `lowercase`
|
| 15 |
+
- Module names: `lowercase_with_underscores`
|
| 16 |
+
- Class names: `CapWords`
|
| 17 |
+
- Function names: `lowercase_with_underscores`
|
| 18 |
+
- Variable names: `lowercase_with_underscores`
|
| 19 |
+
- Constants: `UPPERCASE_WITH_UNDERSCORES`
|
| 20 |
+
- Protected attributes: `_leading_underscore`
|
| 21 |
+
- Private attributes: `__double_leading_underscore`
|
| 22 |
+
|
| 23 |
+
## Imports
|
| 24 |
+
|
| 25 |
+
- Order imports in three groups, separated by a blank line:
|
| 26 |
+
1. Standard library imports
|
| 27 |
+
2. Third-party imports
|
| 28 |
+
3. Local application imports
|
| 29 |
+
- Use absolute imports over relative imports
|
| 30 |
+
- One import per line
|
| 31 |
+
- No wildcard imports (`from module import *`)
|
| 32 |
+
|
| 33 |
+
Example:
|
| 34 |
+
```python
|
| 35 |
+
import os
|
| 36 |
+
import sys
|
| 37 |
+
from typing import Dict, List, Optional
|
| 38 |
+
|
| 39 |
+
import requests
|
| 40 |
+
from pydantic import BaseModel
|
| 41 |
+
|
| 42 |
+
from tools.config import Config
|
| 43 |
+
from tools.utils import format_response
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
## String Formatting
|
| 47 |
+
|
| 48 |
+
- Use f-strings for string formatting
|
| 49 |
+
- Use raw strings (`r"..."`) for regular expressions
|
| 50 |
+
- Use triple quotes for docstrings and multiline strings
|
| 51 |
+
|
| 52 |
+
Example:
|
| 53 |
+
```python
|
| 54 |
+
name = "World"
|
| 55 |
+
greeting = f"Hello, {name}!"
|
| 56 |
+
pattern = r"^\d{3}-\d{2}-\d{4}$"
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## Type Hints
|
| 60 |
+
|
| 61 |
+
- Use type hints for all function arguments and return values
|
| 62 |
+
- Use `Optional` for nullable values
|
| 63 |
+
- Use `Union` for multiple possible types
|
| 64 |
+
- Use `Any` sparingly and document why when used
|
| 65 |
+
|
| 66 |
+
Example:
|
| 67 |
+
```python
|
| 68 |
+
def process_data(
|
| 69 |
+
input_data: Dict[str, Any],
|
| 70 |
+
max_items: Optional[int] = None,
|
| 71 |
+
) -> List[str]:
|
| 72 |
+
"""Process input data and return list of strings."""
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
## Docstrings
|
| 76 |
+
|
| 77 |
+
- Use Google-style docstrings
|
| 78 |
+
- Include Args, Returns, Raises sections when applicable
|
| 79 |
+
- Document exceptions that may be raised
|
| 80 |
+
- Include type information in docstring descriptions
|
| 81 |
+
|
| 82 |
+
Example:
|
| 83 |
+
```python
|
| 84 |
+
def calculate_average(numbers: List[float]) -> float:
|
| 85 |
+
"""Calculate the average of a list of numbers.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
numbers: List of numbers to average.
|
| 89 |
+
Must be non-empty list of floats.
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
float: The arithmetic mean of the input numbers.
|
| 93 |
+
|
| 94 |
+
Raises:
|
| 95 |
+
ValueError: If the input list is empty.
|
| 96 |
+
TypeError: If any element is not a number.
|
| 97 |
+
"""
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
## Classes
|
| 101 |
+
|
| 102 |
+
- Use dataclasses or Pydantic models for data containers
|
| 103 |
+
- Implement `__repr__` for debugging
|
| 104 |
+
- Use properties instead of getter/setter methods
|
| 105 |
+
- Document class attributes in class docstring
|
| 106 |
+
|
| 107 |
+
Example:
|
| 108 |
+
```python
|
| 109 |
+
from dataclasses import dataclass
|
| 110 |
+
from datetime import datetime
|
| 111 |
+
|
| 112 |
+
@dataclass
|
| 113 |
+
class User:
|
| 114 |
+
"""User information container.
|
| 115 |
+
|
| 116 |
+
Attributes:
|
| 117 |
+
username: Unique identifier for the user
|
| 118 |
+
email: User's email address
|
| 119 |
+
created_at: Timestamp of user creation
|
| 120 |
+
"""
|
| 121 |
+
username: str
|
| 122 |
+
email: str
|
| 123 |
+
created_at: datetime = field(default_factory=datetime.utcnow)
|
| 124 |
+
|
| 125 |
+
def __repr__(self) -> str:
|
| 126 |
+
return f"User(username={self.username})"
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
## Error Handling
|
| 130 |
+
|
| 131 |
+
- Use specific exception types
|
| 132 |
+
- Always include error messages
|
| 133 |
+
- Clean up resources in finally blocks
|
| 134 |
+
- Use context managers when possible
|
| 135 |
+
|
| 136 |
+
Example:
|
| 137 |
+
```python
|
| 138 |
+
try:
|
| 139 |
+
with open(filename, "r") as f:
|
| 140 |
+
data = f.read()
|
| 141 |
+
except FileNotFoundError:
|
| 142 |
+
logger.error(f"Config file {filename} not found")
|
| 143 |
+
raise ConfigError(f"Missing configuration file: {filename}")
|
| 144 |
+
except IOError as e:
|
| 145 |
+
logger.error(f"IO error reading {filename}: {e}")
|
| 146 |
+
raise
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
## Testing
|
| 150 |
+
|
| 151 |
+
- Test file names: `test_*.py`
|
| 152 |
+
- Test function names: `test_*`
|
| 153 |
+
- Use descriptive test names
|
| 154 |
+
- One assertion per test when possible
|
| 155 |
+
- Use fixtures for setup/teardown
|
| 156 |
+
|
| 157 |
+
Example:
|
| 158 |
+
```python
|
| 159 |
+
def test_user_creation_with_valid_data():
|
| 160 |
+
"""Test user creation with valid input data."""
|
| 161 |
+
user = User(username="test", email="test@example.com")
|
| 162 |
+
assert user.username == "test"
|
| 163 |
+
assert user.email == "test@example.com"
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
## Comments
|
| 167 |
+
|
| 168 |
+
- Write self-documenting code
|
| 169 |
+
- Use comments to explain why, not what
|
| 170 |
+
- Keep comments up-to-date with code
|
| 171 |
+
- Use TODO comments with ticket numbers
|
| 172 |
+
|
| 173 |
+
Example:
|
| 174 |
+
```python
|
| 175 |
+
# TODO(#123): Implement rate limiting for API calls
|
| 176 |
+
def process_api_request():
|
| 177 |
+
# Temporary workaround for race condition
|
| 178 |
+
# Remove once transaction handling is implemented
|
| 179 |
+
time.sleep(0.1)
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
## Tools and Automation
|
| 183 |
+
|
| 184 |
+
Our codebase is automatically formatted and checked using:
|
| 185 |
+
|
| 186 |
+
- `black`: Code formatting
|
| 187 |
+
- `isort`: Import sorting
|
| 188 |
+
- `flake8`: Style guide enforcement
|
| 189 |
+
- `mypy`: Type checking
|
| 190 |
+
- `pylint`: Code analysis
|
| 191 |
+
- `bandit`: Security checks
|
| 192 |
+
|
| 193 |
+
Run all checks locally:
|
| 194 |
+
```bash
|
| 195 |
+
pre-commit run --all-files
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
## IDE Integration
|
| 199 |
+
|
| 200 |
+
### VS Code Settings
|
| 201 |
+
```json
|
| 202 |
+
{
|
| 203 |
+
"python.formatting.provider": "black",
|
| 204 |
+
"python.linting.enabled": true,
|
| 205 |
+
"python.linting.flake8Enabled": true,
|
| 206 |
+
"python.linting.pylintEnabled": true,
|
| 207 |
+
"python.linting.mypyEnabled": true,
|
| 208 |
+
"editor.formatOnSave": true,
|
| 209 |
+
"editor.rulers": [100],
|
| 210 |
+
"files.trimTrailingWhitespace": true,
|
| 211 |
+
"files.insertFinalNewline": true
|
| 212 |
+
}
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
### PyCharm Settings
|
| 216 |
+
- Enable Black formatter
|
| 217 |
+
- Enable Flake8 and PyLint
|
| 218 |
+
- Set line length to 100
|
| 219 |
+
- Enable "Optimize imports on the fly"
|
| 220 |
+
- Enable "Add final newline on Save"
|
agent.json
CHANGED
|
@@ -1,7 +1,59 @@
|
|
| 1 |
{
|
| 2 |
"tools": [
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
],
|
| 6 |
"model": {
|
| 7 |
"class": "HfApiModel",
|
|
@@ -15,26 +67,8 @@
|
|
| 15 |
}
|
| 16 |
},
|
| 17 |
"prompt_templates": {
|
| 18 |
-
"system_prompt": "You are an
|
| 19 |
-
"planning": {
|
| 20 |
-
"initial_facts": "Below I will present you a task.\n\nYou will now build a comprehensive preparatory survey of which facts we have at our disposal and which ones we still need.\nTo do so, you will have to read the task and identify things that must be discovered in order to successfully complete it.\nDon't make any assumptions. For each item, provide a thorough reasoning. Here is how you will structure this survey:\n\n---\n### 1. Facts given in the task\nList here the specific facts given in the task that could help you (there might be nothing here).\n\n### 2. Facts to look up\nList here any facts that we may need to look up.\nAlso list where to find each of these, for instance a website, a file... - maybe the task contains some sources that you should re-use here.\n\n### 3. Facts to derive\nList here anything that we want to derive from the above by logical reasoning, for instance computation or simulation.\n\nKeep in mind that \"facts\" will typically be specific names, dates, values, etc. Your answer should use the below headings:\n### 1. Facts given in the task\n### 2. Facts to look up\n### 3. Facts to derive\nDo not add anything else.",
|
| 21 |
-
"initial_plan": "You are a world expert at making efficient plans to solve any task using a set of carefully crafted tools.\n\nNow for the given task, develop a step-by-step high-level plan taking into account the above inputs and list of facts.\nThis plan should involve individual tasks based on the available tools, that if executed correctly will yield the correct answer.\nDo not skip steps, do not add any superfluous steps. Only write the high-level plan, DO NOT DETAIL INDIVIDUAL TOOL CALLS.\nAfter writing the final step of the plan, write the '\\n<end_plan>' tag and stop there.\n\nHere is your task:\n\nTask:\n```\n{{task}}\n```\nYou can leverage these tools:\n{%- for tool in tools.values() %}\n- {{ tool.name }}: {{ tool.description }}\n Takes inputs: {{tool.inputs}}\n Returns an output of type: {{tool.output_type}}\n{%- endfor %}\n\n{%- if managed_agents and managed_agents.values() | list %}\nYou can also give tasks to team members.\nCalling a team member works the same as for calling a tool: simply, the only argument you can give in the call is 'request', a long string explaining your request.\nGiven that this team member is a real human, you should be very verbose in your request.\nHere is a list of the team members that you can call:\n{%- for agent in managed_agents.values() %}\n- {{ agent.name }}: {{ agent.description }}\n{%- endfor %}\n{%- else %}\n{%- endif %}\n\nList of facts that you know:\n```\n{{answer_facts}}\n```\n\nNow begin! Write your plan below.",
|
| 22 |
-
"update_facts_pre_messages": "You are a world expert at gathering known and unknown facts based on a conversation.\nBelow you will find a task, and a history of attempts made to solve the task. You will have to produce a list of these:\n### 1. Facts given in the task\n### 2. Facts that we have learned\n### 3. Facts still to look up\n### 4. Facts still to derive\nFind the task and history below:",
|
| 23 |
-
"update_facts_post_messages": "Earlier we've built a list of facts.\nBut since in your previous steps you may have learned useful new facts or invalidated some false ones.\nPlease update your list of facts based on the previous history, and provide these headings:\n### 1. Facts given in the task\n### 2. Facts that we have learned\n### 3. Facts still to look up\n### 4. Facts still to derive\n\nNow write your new list of facts below.",
|
| 24 |
-
"update_plan_pre_messages": "You are a world expert at making efficient plans to solve any task using a set of carefully crafted tools.\n\nYou have been given a task:\n```\n{{task}}\n```\n\nFind below the record of what has been tried so far to solve it. Then you will be asked to make an updated plan to solve the task.\nIf the previous tries so far have met some success, you can make an updated plan based on these actions.\nIf you are stalled, you can make a completely new plan starting from scratch.",
|
| 25 |
-
"update_plan_post_messages": "You're still working towards solving this task:\n```\n{{task}}\n```\n\nYou can leverage these tools:\n{%- for tool in tools.values() %}\n- {{ tool.name }}: {{ tool.description }}\n Takes inputs: {{tool.inputs}}\n Returns an output of type: {{tool.output_type}}\n{%- endfor %}\n\n{%- if managed_agents and managed_agents.values() | list %}\nYou can also give tasks to team members.\nCalling a team member works the same as for calling a tool: simply, the only argument you can give in the call is 'task'.\nGiven that this team member is a real human, you should be very verbose in your task, it should be a long string providing informations as detailed as necessary.\nHere is a list of the team members that you can call:\n{%- for agent in managed_agents.values() %}\n- {{ agent.name }}: {{ agent.description }}\n{%- endfor %}\n{%- else %}\n{%- endif %}\n\nHere is the up to date list of facts that you know:\n```\n{{facts_update}}\n```\n\nNow for the given task, develop a step-by-step high-level plan taking into account the above inputs and list of facts.\nThis plan should involve individual tasks based on the available tools, that if executed correctly will yield the correct answer.\nBeware that you have {remaining_steps} steps remaining.\nDo not skip steps, do not add any superfluous steps. Only write the high-level plan, DO NOT DETAIL INDIVIDUAL TOOL CALLS.\nAfter writing the final step of the plan, write the '\\n<end_plan>' tag and stop there.\n\nNow write your new plan below."
|
| 26 |
-
},
|
| 27 |
-
"managed_agent": {
|
| 28 |
-
"task": "You're a helpful agent named '{{name}}'.\nYou have been submitted this task by your manager.\n---\nTask:\n{{task}}\n---\nYou're helping your manager solve a wider task: so make sure to not provide a one-line answer, but give as much information as possible to give them a clear understanding of the answer.\n\nYour final_answer WILL HAVE to contain these parts:\n### 1. Task outcome (short version):\n### 2. Task outcome (extremely detailed version):\n### 3. Additional context (if relevant):\n\nPut all these in your final_answer tool, everything that you do not pass as an argument to final_answer will be lost.\nAnd even if your task resolution is not successful, please return as much context as possible, so that your manager can act upon this feedback.",
|
| 29 |
-
"report": "Here is the final answer from your managed agent '{{name}}':\n{{final_answer}}"
|
| 30 |
-
}
|
| 31 |
},
|
| 32 |
-
"max_steps": 6,
|
| 33 |
-
"verbosity_level": 1,
|
| 34 |
-
"grammar": null,
|
| 35 |
-
"planning_interval": null,
|
| 36 |
-
"name": null,
|
| 37 |
-
"description": null,
|
| 38 |
"authorized_imports": [
|
| 39 |
"unicodedata",
|
| 40 |
"stat",
|
|
@@ -47,6 +81,76 @@
|
|
| 47 |
"queue",
|
| 48 |
"time",
|
| 49 |
"collections",
|
| 50 |
-
"re"
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
{
|
| 2 |
"tools": [
|
| 3 |
+
{
|
| 4 |
+
"name": "WeatherTool",
|
| 5 |
+
"description": "Class-based tool for retrieving weather information by ZIP code",
|
| 6 |
+
"inputs": {
|
| 7 |
+
"zipcode": {
|
| 8 |
+
"type": "string",
|
| 9 |
+
"description": "5-digit US ZIP code"
|
| 10 |
+
}
|
| 11 |
+
},
|
| 12 |
+
"output_type": "string",
|
| 13 |
+
"output_description": "Formatted weather information including temperature, conditions, humidity, and wind",
|
| 14 |
+
"class_implementation": true
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"name": "AIAssistant",
|
| 18 |
+
"description": "Main assistant class implementing core functionality",
|
| 19 |
+
"methods": {
|
| 20 |
+
"get_current_time_in_timezone": {
|
| 21 |
+
"description": "Get current time in a specified timezone",
|
| 22 |
+
"inputs": {
|
| 23 |
+
"timezone": {
|
| 24 |
+
"type": "string",
|
| 25 |
+
"description": "Valid timezone name from pytz.all_timezones"
|
| 26 |
+
}
|
| 27 |
+
},
|
| 28 |
+
"output_type": "string",
|
| 29 |
+
"output_description": "Current time in the specified timezone format: YYYY-MM-DD HH:MM:SS TZ"
|
| 30 |
+
},
|
| 31 |
+
"get_matching_timezones": {
|
| 32 |
+
"description": "Search for timezone names matching a partial string",
|
| 33 |
+
"inputs": {
|
| 34 |
+
"partial": {
|
| 35 |
+
"type": "string",
|
| 36 |
+
"description": "Partial timezone name to search for"
|
| 37 |
+
}
|
| 38 |
+
},
|
| 39 |
+
"output_type": "list",
|
| 40 |
+
"output_description": "List of timezone names containing the search string"
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"name": "FinalAnswerTool",
|
| 46 |
+
"description": "Tool for providing the final answer to user queries",
|
| 47 |
+
"inputs": {
|
| 48 |
+
"answer": {
|
| 49 |
+
"type": "any",
|
| 50 |
+
"description": "The answer to be returned to the user"
|
| 51 |
+
}
|
| 52 |
+
},
|
| 53 |
+
"output_type": "string",
|
| 54 |
+
"output_description": "Formatted answer string",
|
| 55 |
+
"class_implementation": true
|
| 56 |
+
}
|
| 57 |
],
|
| 58 |
"model": {
|
| 59 |
"class": "HfApiModel",
|
|
|
|
| 67 |
}
|
| 68 |
},
|
| 69 |
"prompt_templates": {
|
| 70 |
+
"system_prompt": "You are an AI Assistant with capabilities for weather information and timezone conversions. You have access to the following tools:\n\n- WeatherTool: Get current weather for US ZIP codes\n- Time/Timezone functions: Get current time and search timezones\n- FinalAnswerTool: Format and return final answers\n\nYou should process user requests by:\n1. Understanding the request\n2. Using appropriate tools to gather information\n3. Formatting responses clearly\n4. Handling errors gracefully\n\nAll responses should be clear, professional, and user-friendly."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
"authorized_imports": [
|
| 73 |
"unicodedata",
|
| 74 |
"stat",
|
|
|
|
| 81 |
"queue",
|
| 82 |
"time",
|
| 83 |
"collections",
|
| 84 |
+
"re",
|
| 85 |
+
"os",
|
| 86 |
+
"logging",
|
| 87 |
+
"pytz",
|
| 88 |
+
"requests",
|
| 89 |
+
"signal",
|
| 90 |
+
"sys",
|
| 91 |
+
"dotenv"
|
| 92 |
+
],
|
| 93 |
+
"config": {
|
| 94 |
+
"api": {
|
| 95 |
+
"weather": {
|
| 96 |
+
"base_url": "http://api.weatherapi.com/v1",
|
| 97 |
+
"rate_limit_per_minute": 60,
|
| 98 |
+
"cache_timeout_seconds": 300
|
| 99 |
+
},
|
| 100 |
+
"timezone": {
|
| 101 |
+
"rate_limit_per_minute": 100
|
| 102 |
+
}
|
| 103 |
+
},
|
| 104 |
+
"assistant": {
|
| 105 |
+
"command_history": {
|
| 106 |
+
"max_size": 10
|
| 107 |
+
},
|
| 108 |
+
"cache": {
|
| 109 |
+
"cleanup_interval_seconds": 600
|
| 110 |
+
}
|
| 111 |
+
},
|
| 112 |
+
"ui": {
|
| 113 |
+
"title": "AI Assistant",
|
| 114 |
+
"description": "Weather and Timezone Assistant",
|
| 115 |
+
"theme": "light",
|
| 116 |
+
"input_placeholder": "Enter your command (type 'help' for available commands)",
|
| 117 |
+
"width": "100%",
|
| 118 |
+
"height": "600px"
|
| 119 |
+
},
|
| 120 |
+
"logging": {
|
| 121 |
+
"level": "INFO",
|
| 122 |
+
"file": "assistant.log",
|
| 123 |
+
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 124 |
+
},
|
| 125 |
+
"base_url": "http://api.example.com",
|
| 126 |
+
"connection_pool": {
|
| 127 |
+
"pool_size": 20,
|
| 128 |
+
"timeout": 30.0,
|
| 129 |
+
"keepalive_timeout": 60.0,
|
| 130 |
+
"ttl_dns_cache": 300,
|
| 131 |
+
"force_close": false
|
| 132 |
+
},
|
| 133 |
+
"rate_limit": {
|
| 134 |
+
"rate": 50.0,
|
| 135 |
+
"burst": 100,
|
| 136 |
+
"enabled": true
|
| 137 |
+
},
|
| 138 |
+
"batch": {
|
| 139 |
+
"size": 500,
|
| 140 |
+
"interval": 2.0,
|
| 141 |
+
"enabled": true
|
| 142 |
+
},
|
| 143 |
+
"monitoring": {
|
| 144 |
+
"metrics_ttl": 7200,
|
| 145 |
+
"max_metrics": 5000,
|
| 146 |
+
"log_interval": 300.0,
|
| 147 |
+
"enabled": true
|
| 148 |
+
},
|
| 149 |
+
"retry": {
|
| 150 |
+
"max_attempts": 5,
|
| 151 |
+
"min_wait": 1.0,
|
| 152 |
+
"max_wait": 30.0,
|
| 153 |
+
"enabled": true
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
}
|
app.py
CHANGED
|
@@ -1,111 +1,590 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
from Gradio_UI import GradioUI
|
| 13 |
|
| 14 |
-
#
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
"""
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
"""
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
@tool
|
| 41 |
-
def get_weather_by_zipcode(zipcode: str) -> str:
|
| 42 |
-
"""Get weather forecast for a given US zip code
|
| 43 |
Args:
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
"""
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
return "Please provide a valid 5-digit US zip code"
|
| 50 |
-
|
| 51 |
-
# Get forecast data using WeatherAPI.com
|
| 52 |
-
weather_api_key = os.getenv('WEATHERAPI_KEY')
|
| 53 |
-
url = f"http://api.weatherapi.com/v1/forecast.json?key={weather_api_key}&q={zipcode}&days=3&aqi=no"
|
| 54 |
-
|
| 55 |
-
response = requests.get(url)
|
| 56 |
-
if response.status_code != 200:
|
| 57 |
-
return "Error fetching weather data"
|
| 58 |
-
|
| 59 |
-
weather_data = response.json()
|
| 60 |
-
location = weather_data['location']['name']
|
| 61 |
-
forecast = weather_data['forecast']['forecastday']
|
| 62 |
-
|
| 63 |
-
result = f"3-Day Forecast for {location} ({zipcode}):\n\n"
|
| 64 |
-
|
| 65 |
-
for day in forecast:
|
| 66 |
-
date = day['date']
|
| 67 |
-
max_temp = day['day']['maxtemp_f']
|
| 68 |
-
min_temp = day['day']['mintemp_f']
|
| 69 |
-
condition = day['day']['condition']['text']
|
| 70 |
-
result += f"{date}:\n"
|
| 71 |
-
result += f"High: {max_temp}°F, Low: {min_temp}°F\n"
|
| 72 |
-
result += f"Conditions: {condition}\n\n"
|
| 73 |
-
|
| 74 |
-
return result
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
|
|
|
| 78 |
|
| 79 |
-
|
|
|
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
#
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
-
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main application file for the AI Assistant.
|
| 3 |
+
Version: 1.0.0
|
| 4 |
+
License: MIT
|
| 5 |
+
Copyright (c) 2024 AI Assistant Team
|
| 6 |
+
|
| 7 |
+
This module implements a command-line AI assistant with weather and timezone capabilities.
|
| 8 |
+
It provides a user-friendly interface for accessing weather information and timezone
|
| 9 |
+
conversions, with features like caching, rate limiting, and usage statistics.
|
| 10 |
+
|
| 11 |
+
Features:
|
| 12 |
+
- Weather information by ZIP code with caching
|
| 13 |
+
- Timezone conversion and search
|
| 14 |
+
- Command history tracking
|
| 15 |
+
- Usage statistics
|
| 16 |
+
- Rate limiting for API calls
|
| 17 |
+
- Graceful shutdown handling
|
| 18 |
+
|
| 19 |
+
Example Usage:
|
| 20 |
+
$ python app.py
|
| 21 |
+
Then use commands like:
|
| 22 |
+
- weather 12345
|
| 23 |
+
- time America/New_York
|
| 24 |
+
- timezone asia
|
| 25 |
+
- help
|
| 26 |
+
|
| 27 |
+
Environment Variables:
|
| 28 |
+
WEATHER_API_KEY: API key for weather service
|
| 29 |
+
LOG_LEVEL: Logging level (default: INFO)
|
| 30 |
+
LOG_FILE: Log file path (default: assistant.log)
|
| 31 |
+
Other variables defined in config.py
|
| 32 |
+
|
| 33 |
+
This project is licensed under the MIT License.
|
| 34 |
+
See the LICENSE file for the full license text.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
import os
|
| 38 |
+
import logging
|
| 39 |
+
import pytz
|
| 40 |
+
import requests
|
| 41 |
+
import signal
|
| 42 |
+
import sys
|
| 43 |
+
import time
|
| 44 |
+
from datetime import datetime, timedelta
|
| 45 |
+
from dotenv import load_dotenv
|
| 46 |
+
from typing import Optional, Dict, Any, List
|
| 47 |
|
| 48 |
+
from config import Config, ModelConfig, UIConfig
|
| 49 |
+
from rate_limiter import RateLimiter
|
| 50 |
+
from tools.final_answer import FinalAnswerTool
|
| 51 |
+
from tools.weather import WeatherTool
|
| 52 |
+
from tools.stats import UsageStats
|
| 53 |
+
from tools.logging_config import setup_logging
|
| 54 |
+
from tools.health import HealthCheck, HealthStatus
|
| 55 |
+
from tools.error_recovery import CircuitBreaker
|
| 56 |
+
from tools.system_status import SystemMonitor
|
| 57 |
from Gradio_UI import GradioUI
|
| 58 |
|
| 59 |
+
# Application constants
|
| 60 |
+
__version__ = "1.0.0"
|
| 61 |
+
__author__ = "AI Assistant Team"
|
| 62 |
+
|
| 63 |
+
# Load environment variables
|
| 64 |
+
load_dotenv()
|
| 65 |
+
|
| 66 |
+
# Configure logging with rotation
|
| 67 |
+
logger = setup_logging(
|
| 68 |
+
log_dir=os.getenv('LOG_DIR', 'logs'),
|
| 69 |
+
log_level=os.getenv('LOG_LEVEL', 'INFO'),
|
| 70 |
+
max_bytes=int(os.getenv('LOG_MAX_BYTES', 10 * 1024 * 1024)), # Default 10MB
|
| 71 |
+
backup_count=int(os.getenv('LOG_BACKUP_COUNT', 5)),
|
| 72 |
+
log_format=os.getenv('LOG_FORMAT'),
|
| 73 |
+
date_format=os.getenv('LOG_DATE_FORMAT')
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
def get_system_info() -> str:
|
| 77 |
"""
|
| 78 |
+
Get formatted system information string.
|
| 79 |
|
| 80 |
+
Returns:
|
| 81 |
+
str: Formatted string containing system information including:
|
| 82 |
+
- Application version
|
| 83 |
+
- Python version
|
| 84 |
+
- Platform
|
| 85 |
+
- Current timezone
|
| 86 |
+
- Log file location
|
| 87 |
"""
|
| 88 |
+
return (
|
| 89 |
+
f"AI Assistant v{__version__}\n"
|
| 90 |
+
f"Python {sys.version.split()[0]}\n"
|
| 91 |
+
f"Platform: {sys.platform}\n"
|
| 92 |
+
f"Timezone: {datetime.now().astimezone().tzname()}\n"
|
| 93 |
+
f"Log File: {os.getenv('LOG_FILE', 'assistant.log')}"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def signal_handler(signum: int, frame: Any) -> None:
|
| 97 |
+
"""
|
| 98 |
+
Handle system signals for graceful shutdown.
|
| 99 |
|
|
|
|
|
|
|
|
|
|
| 100 |
Args:
|
| 101 |
+
signum: Signal number received
|
| 102 |
+
frame: Current stack frame (not used)
|
| 103 |
+
|
| 104 |
+
Note:
|
| 105 |
+
Handles SIGINT (Ctrl+C) and SIGTERM signals.
|
| 106 |
+
Logs shutdown message and exits cleanly.
|
| 107 |
"""
|
| 108 |
+
logger.info("Received shutdown signal, cleaning up...")
|
| 109 |
+
print("\nShutting down gracefully...")
|
| 110 |
+
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
+
class AIAssistant:
|
| 113 |
+
"""
|
| 114 |
+
Main AI Assistant class implementing the core functionality.
|
| 115 |
|
| 116 |
+
This class provides weather information and timezone conversion services,
|
| 117 |
+
with features like command history, caching, and usage statistics.
|
| 118 |
|
| 119 |
+
Attributes:
|
| 120 |
+
config (Config): Application configuration
|
| 121 |
+
weather_limiter (RateLimiter): Rate limiter for weather API
|
| 122 |
+
timezone_limiter (RateLimiter): Rate limiter for timezone API
|
| 123 |
+
command_history (List[str]): List of recent commands
|
| 124 |
+
max_history (int): Maximum number of commands to keep in history
|
| 125 |
+
stats (UsageStats): Usage statistics collector
|
| 126 |
+
"""
|
| 127 |
|
| 128 |
+
def __init__(self):
|
| 129 |
+
"""Initialize the AI Assistant with configuration and tools."""
|
| 130 |
+
self.config = Config()
|
| 131 |
+
self.weather_limiter = RateLimiter(
|
| 132 |
+
rate_limit=self.config.api.weather_api_rate_limit,
|
| 133 |
+
time_window=60
|
| 134 |
+
)
|
| 135 |
+
self.timezone_limiter = RateLimiter(
|
| 136 |
+
rate_limit=self.config.api.timezone_api_rate_limit,
|
| 137 |
+
time_window=60
|
| 138 |
+
)
|
| 139 |
+
self.final_answer_tool = FinalAnswerTool()
|
| 140 |
+
self.weather_tool = WeatherTool()
|
| 141 |
+
self.command_history = []
|
| 142 |
+
self.max_history = 10
|
| 143 |
|
| 144 |
+
# Initialize statistics collector
|
| 145 |
+
self.stats = UsageStats(
|
| 146 |
+
stats_dir=os.getenv('STATS_DIR', 'stats')
|
| 147 |
+
)
|
| 148 |
|
| 149 |
+
# Initialize circuit breaker
|
| 150 |
+
self.circuit_breaker = CircuitBreaker(
|
| 151 |
+
failure_threshold=5,
|
| 152 |
+
reset_timeout=60.0
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# Initialize health check
|
| 156 |
+
self.health_check = HealthCheck(
|
| 157 |
+
circuit_breaker=self.circuit_breaker,
|
| 158 |
+
memory_threshold=90.0,
|
| 159 |
+
cpu_threshold=80.0
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Initialize system monitor
|
| 163 |
+
self.system_monitor = SystemMonitor(
|
| 164 |
+
history_size=int(os.getenv('METRICS_HISTORY_SIZE', '60')),
|
| 165 |
+
disk_path=os.getenv('MONITOR_DISK_PATH')
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
logger.info("AI Assistant initialized")
|
| 169 |
+
logger.debug(f"Configuration loaded: {self.config}")
|
| 170 |
+
|
| 171 |
+
def _add_to_history(self, command: str) -> None:
|
| 172 |
+
"""
|
| 173 |
+
Add command to history, maintaining max size.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
command: Command string to add to history
|
| 177 |
+
|
| 178 |
+
Note:
|
| 179 |
+
Maintains a fixed-size history by removing oldest entries
|
| 180 |
+
when maximum size is reached. Also updates total command counter.
|
| 181 |
+
"""
|
| 182 |
+
self.command_history.append(command)
|
| 183 |
+
if len(self.command_history) > self.max_history:
|
| 184 |
+
self.command_history.pop(0)
|
| 185 |
+
|
| 186 |
+
def _clean_expired_cache(self) -> None:
|
| 187 |
+
"""
|
| 188 |
+
Remove expired entries from weather cache.
|
| 189 |
+
|
| 190 |
+
This method checks all cache entries and removes those that have
|
| 191 |
+
exceeded the cache timeout period.
|
| 192 |
+
"""
|
| 193 |
+
now = datetime.now()
|
| 194 |
+
expired = [
|
| 195 |
+
zip_code for zip_code, data in self.weather_tool.cache.items()
|
| 196 |
+
if (now - data['timestamp']).total_seconds() >= self.weather_tool.cache_timeout
|
| 197 |
+
]
|
| 198 |
+
for zip_code in expired:
|
| 199 |
+
del self.weather_tool.cache[zip_code]
|
| 200 |
+
|
| 201 |
+
def _get_matching_timezones(self, partial: str) -> List[str]:
|
| 202 |
+
"""
|
| 203 |
+
Get list of timezones matching partial string.
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
partial: Partial timezone name to search for
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
List[str]: List of timezone names containing the search string
|
| 210 |
+
|
| 211 |
+
Note:
|
| 212 |
+
Search is case-insensitive and matches any part of timezone name.
|
| 213 |
+
"""
|
| 214 |
+
partial = partial.lower()
|
| 215 |
+
return [tz for tz in pytz.all_timezones if partial in tz.lower()]
|
| 216 |
+
|
| 217 |
+
def _is_cache_valid(self, zipcode: str) -> bool:
|
| 218 |
+
"""
|
| 219 |
+
Check if cached weather data is still valid.
|
| 220 |
+
|
| 221 |
+
Args:
|
| 222 |
+
zipcode: ZIP code to check in cache
|
| 223 |
+
|
| 224 |
+
Returns:
|
| 225 |
+
bool: True if cache entry exists and is within timeout period
|
| 226 |
+
"""
|
| 227 |
+
if zipcode not in self.weather_tool.cache:
|
| 228 |
+
return False
|
| 229 |
+
cache_time = self.weather_tool.cache[zipcode]['timestamp']
|
| 230 |
+
return (datetime.now() - cache_time).total_seconds() < self.weather_tool.cache_timeout
|
| 231 |
+
|
| 232 |
+
def _format_weather_response(self, data: Dict[str, Any]) -> str:
|
| 233 |
+
"""
|
| 234 |
+
Format weather data into a user-friendly string.
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
data: Weather data from API
|
| 238 |
+
|
| 239 |
+
Returns:
|
| 240 |
+
str: Formatted weather information
|
| 241 |
+
"""
|
| 242 |
+
try:
|
| 243 |
+
location = data['location']
|
| 244 |
+
current = data['current']
|
| 245 |
+
|
| 246 |
+
return (
|
| 247 |
+
f"Weather for {location['name']}, {location.get('region', '')}\n"
|
| 248 |
+
f"Temperature: {current['temp_f']}°F ({current['temp_c']}°C)\n"
|
| 249 |
+
f"Condition: {current['condition']['text']}\n"
|
| 250 |
+
f"Humidity: {current['humidity']}%\n"
|
| 251 |
+
f"Wind: {current['wind_mph']} mph ({current['wind_kph']} kph) {current['wind_dir']}\n"
|
| 252 |
+
f"Last Updated: {current['last_updated']}"
|
| 253 |
+
)
|
| 254 |
+
except KeyError as e:
|
| 255 |
+
logger.error(f"Error formatting weather data: {str(e)}")
|
| 256 |
+
return "Error formatting weather data"
|
| 257 |
+
|
| 258 |
+
def get_weather_by_zipcode(self, zipcode: str) -> str:
|
| 259 |
+
"""
|
| 260 |
+
Get weather information for a given ZIP code.
|
| 261 |
+
|
| 262 |
+
Args:
|
| 263 |
+
zipcode: The ZIP code to get weather for
|
| 264 |
|
| 265 |
+
Returns:
|
| 266 |
+
str: Formatted weather information
|
| 267 |
+
|
| 268 |
+
Raises:
|
| 269 |
+
ValueError: If ZIP code is invalid or malformed
|
| 270 |
+
RuntimeError: If API call fails or rate limit exceeded
|
| 271 |
+
"""
|
| 272 |
+
start_time = time.time()
|
| 273 |
+
success = True
|
| 274 |
+
error_type = None
|
| 275 |
+
|
| 276 |
+
try:
|
| 277 |
+
if not self.weather_limiter.allow_request():
|
| 278 |
+
success = False
|
| 279 |
+
error_type = "RateLimitError"
|
| 280 |
+
raise RuntimeError("Rate limit exceeded for weather API")
|
| 281 |
+
|
| 282 |
+
result = self.weather_tool(zipcode)
|
| 283 |
+
|
| 284 |
+
# Record cache operation
|
| 285 |
+
if 'Using cached weather data' in self.weather_tool.logger.handlers[0].records[-1].message:
|
| 286 |
+
self.stats.record_cache_operation(hit=True)
|
| 287 |
+
else:
|
| 288 |
+
self.stats.record_cache_operation(hit=False)
|
| 289 |
+
|
| 290 |
+
return result
|
| 291 |
+
except Exception as e:
|
| 292 |
+
success = False
|
| 293 |
+
error_type = e.__class__.__name__
|
| 294 |
+
logger.error(f"Weather API error: {str(e)}")
|
| 295 |
+
raise
|
| 296 |
+
finally:
|
| 297 |
+
# Record API call statistics
|
| 298 |
+
response_time = time.time() - start_time
|
| 299 |
+
self.stats.record_api_call(
|
| 300 |
+
endpoint="weather_api",
|
| 301 |
+
success=success,
|
| 302 |
+
response_time=response_time,
|
| 303 |
+
rate_limited=(error_type == "RateLimitError"),
|
| 304 |
+
error_type=error_type
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
def get_current_time_in_timezone(self, timezone: str) -> str:
|
| 308 |
+
"""Get current time in specified timezone."""
|
| 309 |
+
start_time = time.time()
|
| 310 |
+
success = True
|
| 311 |
+
error_type = None
|
| 312 |
+
|
| 313 |
+
try:
|
| 314 |
+
if not timezone or not timezone.strip():
|
| 315 |
+
success = False
|
| 316 |
+
error_type = "ValueError"
|
| 317 |
+
raise ValueError("Timezone cannot be empty")
|
| 318 |
+
|
| 319 |
+
if not self.timezone_limiter.allow_request():
|
| 320 |
+
success = False
|
| 321 |
+
error_type = "RateLimitError"
|
| 322 |
+
raise RuntimeError("Rate limit exceeded for timezone API")
|
| 323 |
+
|
| 324 |
+
if timezone not in pytz.all_timezones:
|
| 325 |
+
success = False
|
| 326 |
+
error_type = "ValueError"
|
| 327 |
+
raise ValueError(f"Invalid timezone: {timezone}")
|
| 328 |
+
|
| 329 |
+
tz = pytz.timezone(timezone)
|
| 330 |
+
current_time = datetime.now(tz)
|
| 331 |
+
return current_time.strftime("%Y-%m-%d %H:%M:%S %Z")
|
| 332 |
+
except Exception as e:
|
| 333 |
+
success = False
|
| 334 |
+
error_type = e.__class__.__name__
|
| 335 |
+
logger.error(f"Timezone error: {str(e)}")
|
| 336 |
+
raise RuntimeError(f"Failed to get timezone data: {str(e)}")
|
| 337 |
+
finally:
|
| 338 |
+
# Record API call statistics
|
| 339 |
+
response_time = time.time() - start_time
|
| 340 |
+
self.stats.record_api_call(
|
| 341 |
+
endpoint="timezone_api",
|
| 342 |
+
success=success,
|
| 343 |
+
response_time=response_time,
|
| 344 |
+
error_type=error_type
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
def process_input(self, message: str) -> str:
|
| 348 |
+
"""Process user input and return response."""
|
| 349 |
+
start_time = time.time()
|
| 350 |
+
command_type = "unknown"
|
| 351 |
+
success = True
|
| 352 |
+
error_type = None
|
| 353 |
+
|
| 354 |
+
try:
|
| 355 |
+
if not message or not message.strip():
|
| 356 |
+
return "Please enter a message."
|
| 357 |
+
|
| 358 |
+
original_message = message
|
| 359 |
+
message = message.strip().lower()
|
| 360 |
+
|
| 361 |
+
# Determine command type
|
| 362 |
+
if message == "help":
|
| 363 |
+
command_type = "help"
|
| 364 |
+
elif message == "stats":
|
| 365 |
+
command_type = "stats"
|
| 366 |
+
elif message == "cache clear":
|
| 367 |
+
command_type = "cache_clear"
|
| 368 |
+
elif message == "history":
|
| 369 |
+
command_type = "history"
|
| 370 |
+
elif message.startswith("timezone "):
|
| 371 |
+
command_type = "timezone_search"
|
| 372 |
+
elif message.startswith("weather "):
|
| 373 |
+
command_type = "weather"
|
| 374 |
+
elif message.startswith("time "):
|
| 375 |
+
command_type = "time"
|
| 376 |
+
|
| 377 |
+
result = self._process_command(message, original_message)
|
| 378 |
+
return result
|
| 379 |
+
except Exception as e:
|
| 380 |
+
success = False
|
| 381 |
+
error_type = e.__class__.__name__
|
| 382 |
+
logger.error(f"Error processing input: {str(e)}")
|
| 383 |
+
return f"Error processing your request: {str(e)}"
|
| 384 |
+
finally:
|
| 385 |
+
# Record command statistics
|
| 386 |
+
response_time = time.time() - start_time
|
| 387 |
+
self.stats.record_command(
|
| 388 |
+
command=command_type,
|
| 389 |
+
success=success,
|
| 390 |
+
response_time=response_time,
|
| 391 |
+
error_type=error_type
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
def _process_command(self, message: str, original_message: str) -> str:
|
| 395 |
+
"""Process specific command type."""
|
| 396 |
+
# Update system metrics before processing command
|
| 397 |
+
self.system_monitor.update_metrics()
|
| 398 |
+
|
| 399 |
+
if message == "help":
|
| 400 |
+
return (
|
| 401 |
+
"Available commands:\n"
|
| 402 |
+
"- weather <zipcode>: Get weather for a ZIP code\n"
|
| 403 |
+
"- time <timezone>: Get current time in timezone\n"
|
| 404 |
+
"- timezone <search>: Search for available timezones\n"
|
| 405 |
+
"- history: Show command history\n"
|
| 406 |
+
"- stats: Show usage statistics\n"
|
| 407 |
+
"- status: Show system status\n"
|
| 408 |
+
"- cache clear: Clear weather cache\n"
|
| 409 |
+
"- help: Show this help message"
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
if message == "stats":
|
| 413 |
+
return self._format_stats_output(self.stats.get_summary())
|
| 414 |
+
|
| 415 |
+
if message == "status":
|
| 416 |
+
return self._format_system_status()
|
| 417 |
+
|
| 418 |
+
if message == "cache clear":
|
| 419 |
+
self.weather_tool.cache.clear()
|
| 420 |
+
return "Weather cache cleared."
|
| 421 |
+
|
| 422 |
+
if message == "history":
|
| 423 |
+
if not self.command_history:
|
| 424 |
+
return "No command history available."
|
| 425 |
+
return "Command History:\n" + "\n".join(
|
| 426 |
+
f"{i+1}. {cmd}" for i, cmd in enumerate(self.command_history)
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
if message.startswith("timezone "):
|
| 430 |
+
search_term = message.split("timezone ", 1)[1].strip()
|
| 431 |
+
matches = self._get_matching_timezones(search_term)
|
| 432 |
+
if not matches:
|
| 433 |
+
return f"No timezones found matching '{search_term}'"
|
| 434 |
+
return "Matching Timezones:\n" + "\n".join(matches[:10]) + (
|
| 435 |
+
"\n(showing first 10 matches)" if len(matches) > 10 else ""
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
if message.startswith("weather "):
|
| 439 |
+
try:
|
| 440 |
+
zipcode = message.split("weather ", 1)[1].strip()
|
| 441 |
+
result = self.get_weather_by_zipcode(zipcode)
|
| 442 |
+
self._add_to_history(original_message)
|
| 443 |
+
return result
|
| 444 |
+
except (IndexError, ValueError, RuntimeError) as e:
|
| 445 |
+
return str(e)
|
| 446 |
+
|
| 447 |
+
if message.startswith("time "):
|
| 448 |
+
try:
|
| 449 |
+
timezone = message.split("time ", 1)[1].strip()
|
| 450 |
+
result = self.get_current_time_in_timezone(timezone)
|
| 451 |
+
self._add_to_history(original_message)
|
| 452 |
+
return result
|
| 453 |
+
except (IndexError, ValueError, RuntimeError) as e:
|
| 454 |
+
return str(e)
|
| 455 |
+
|
| 456 |
+
return (
|
| 457 |
+
"I didn't understand that command. Type 'help' to see available commands."
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
def _format_stats_output(self, stats: Dict[str, Any]) -> str:
|
| 461 |
+
"""Format statistics for display."""
|
| 462 |
+
return (
|
| 463 |
+
"=== AI Assistant Statistics ===\n"
|
| 464 |
+
f"\nSession Information:\n"
|
| 465 |
+
f"Start Time: {stats['session']['start_time']}\n"
|
| 466 |
+
f"Uptime: {timedelta(seconds=int(stats['session']['uptime_seconds']))}\n"
|
| 467 |
+
f"\nCommand Statistics:\n"
|
| 468 |
+
f"Total Commands: {stats['commands']['total']}\n"
|
| 469 |
+
f"Success Rate: {stats['commands']['success_rate']:.1f}%\n"
|
| 470 |
+
f"\nAPI Statistics:\n"
|
| 471 |
+
f"Total API Calls: {stats['api']['total_calls']}\n"
|
| 472 |
+
f"API Success Rate: {stats['api']['success_rate']:.1f}%\n"
|
| 473 |
+
f"Rate Limit Hits: {stats['api']['rate_limit_hits']}\n"
|
| 474 |
+
f"\nCache Statistics:\n"
|
| 475 |
+
f"Hit Rate: {stats['cache']['hit_rate']:.1f}%\n"
|
| 476 |
+
f"Total Entries: {stats['cache']['total_entries']}\n"
|
| 477 |
+
f"Total Size: {stats['cache']['total_size_bytes'] / 1024:.1f}KB\n"
|
| 478 |
+
f"Evictions: {stats['cache']['evictions']}"
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
def _format_system_status(self) -> str:
|
| 482 |
+
"""Format system status for display."""
|
| 483 |
+
metrics = self.system_monitor.get_metrics_summary()
|
| 484 |
+
warnings = self.system_monitor.get_resource_warnings()
|
| 485 |
+
|
| 486 |
+
status_lines = [
|
| 487 |
+
"=== System Status ===\n",
|
| 488 |
+
f"Timestamp: {metrics['timestamp']}",
|
| 489 |
+
"\nSystem Resources:",
|
| 490 |
+
f"CPU Usage: {metrics['system']['cpu_percent']:.1f}%",
|
| 491 |
+
f"Memory Usage: {metrics['system']['memory_percent']:.1f}%",
|
| 492 |
+
f"Disk Usage: {metrics['system']['disk_usage_percent']:.1f}%",
|
| 493 |
+
"\nProcess Information:",
|
| 494 |
+
f"Memory Usage: {metrics['process']['memory_mb']:.1f}MB",
|
| 495 |
+
f"Threads: {metrics['process']['threads']}",
|
| 496 |
+
f"CPU Usage: {metrics['process']['cpu_percent']:.1f}%"
|
| 497 |
+
]
|
| 498 |
+
|
| 499 |
+
if metrics.get('trends'):
|
| 500 |
+
status_lines.extend([
|
| 501 |
+
"\nTrends (since start):",
|
| 502 |
+
f"CPU: {metrics['trends']['cpu_trend']:+.1f}%",
|
| 503 |
+
f"Memory: {metrics['trends']['memory_trend']:+.1f}%",
|
| 504 |
+
f"Disk: {metrics['trends']['disk_trend']:+.1f}%",
|
| 505 |
+
f"Process Memory: {metrics['trends']['process_memory_trend']:+.1f}MB"
|
| 506 |
+
])
|
| 507 |
+
|
| 508 |
+
if warnings:
|
| 509 |
+
status_lines.extend([
|
| 510 |
+
"\nWarnings:",
|
| 511 |
+
*[f"- {warning}" for warning in warnings]
|
| 512 |
+
])
|
| 513 |
+
|
| 514 |
+
return "\n".join(status_lines)
|
| 515 |
+
|
| 516 |
+
def get_health(self) -> HealthStatus:
|
| 517 |
+
"""
|
| 518 |
+
Get current health status.
|
| 519 |
+
|
| 520 |
+
Returns:
|
| 521 |
+
HealthStatus: Current health status including all checks
|
| 522 |
+
"""
|
| 523 |
+
status = self.health_check.check_health()
|
| 524 |
+
|
| 525 |
+
# Add system metrics to health status
|
| 526 |
+
system_metrics = self.system_monitor.get_metrics_summary()
|
| 527 |
+
status.details["system"] = system_metrics.get("system", {})
|
| 528 |
+
status.details["process"] = system_metrics.get("process", {})
|
| 529 |
+
|
| 530 |
+
# Add application-specific metrics
|
| 531 |
+
status.details["application"] = {
|
| 532 |
+
"total_commands": sum(s.total_calls for s in self.stats.commands.values()),
|
| 533 |
+
"weather_requests": self.stats.api_stats.get("weather_api", {}).get("total_requests", 0),
|
| 534 |
+
"timezone_requests": self.stats.api_stats.get("timezone_api", {}).get("total_requests", 0),
|
| 535 |
+
"cache_stats": self.stats.get_cache_stats()
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
# Add any resource warnings
|
| 539 |
+
warnings = self.system_monitor.get_resource_warnings()
|
| 540 |
+
if warnings:
|
| 541 |
+
status.warnings.extend(warnings)
|
| 542 |
+
|
| 543 |
+
return status
|
| 544 |
+
|
| 545 |
+
def main():
|
| 546 |
+
"""
|
| 547 |
+
Main function to run the application.
|
| 548 |
+
|
| 549 |
+
This function:
|
| 550 |
+
1. Sets up signal handlers for graceful shutdown
|
| 551 |
+
2. Displays system information
|
| 552 |
+
3. Initializes the AI Assistant
|
| 553 |
+
4. Launches the user interface
|
| 554 |
+
|
| 555 |
+
The application can be terminated with Ctrl+C.
|
| 556 |
+
|
| 557 |
+
Note:
|
| 558 |
+
Requires environment variables to be properly configured.
|
| 559 |
+
See module docstring for required environment variables.
|
| 560 |
+
"""
|
| 561 |
+
try:
|
| 562 |
+
# Set up signal handlers
|
| 563 |
+
signal.signal(signal.SIGINT, signal_handler)
|
| 564 |
+
signal.signal(signal.SIGTERM, signal_handler)
|
| 565 |
+
|
| 566 |
+
# Print startup information
|
| 567 |
+
print("\n=== AI Assistant Starting ===")
|
| 568 |
+
print(get_system_info())
|
| 569 |
+
print("\nInitializing components...")
|
| 570 |
+
|
| 571 |
+
assistant = AIAssistant()
|
| 572 |
+
print("Assistant initialized successfully")
|
| 573 |
+
|
| 574 |
+
print("\nStarting UI...")
|
| 575 |
+
print("Type 'help' to see available commands")
|
| 576 |
+
print("Press Ctrl+C to exit\n")
|
| 577 |
+
|
| 578 |
+
ui = GradioUI(
|
| 579 |
+
callback=assistant.process_input,
|
| 580 |
+
config=assistant.config.ui
|
| 581 |
+
)
|
| 582 |
+
ui.launch(share=False)
|
| 583 |
+
except Exception as e:
|
| 584 |
+
logger.error(f"Application error: {str(e)}")
|
| 585 |
+
print(f"Error starting application: {str(e)}")
|
| 586 |
+
finally:
|
| 587 |
+
logger.info("Application shutdown complete")
|
| 588 |
|
| 589 |
+
if __name__ == "__main__":
|
| 590 |
+
main()
|
config.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration settings for the AI Assistant application.
|
| 3 |
+
|
| 4 |
+
This module provides a configuration system that can be controlled through environment
|
| 5 |
+
variables or a JSON configuration file. Environment variables take precedence over
|
| 6 |
+
JSON configuration.
|
| 7 |
+
|
| 8 |
+
Environment Variables:
|
| 9 |
+
See .env.example for a complete list of available environment variables and their defaults.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import logging
|
| 14 |
+
import re
|
| 15 |
+
from dataclasses import dataclass, field, fields, FrozenInstanceError
|
| 16 |
+
from typing import Optional, Dict, Any, List, Union, TypeVar, Type, cast, ClassVar, Pattern, Callable
|
| 17 |
+
from enum import Enum
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
T = TypeVar('T')
|
| 23 |
+
|
| 24 |
+
class ConfigValidationError(ValueError):
|
| 25 |
+
"""Raised when configuration validation fails."""
|
| 26 |
+
def __init__(self, message: str, field_name: Optional[str] = None):
|
| 27 |
+
self.field_name = field_name
|
| 28 |
+
super().__init__(f"{field_name + ': ' if field_name else ''}{message}")
|
| 29 |
+
|
| 30 |
+
def validate_positive(value: Union[int, float], field_name: str) -> None:
|
| 31 |
+
"""Validate that a numeric value is positive."""
|
| 32 |
+
if value <= 0:
|
| 33 |
+
raise ConfigValidationError(f"Value must be positive, got {value}", field_name)
|
| 34 |
+
|
| 35 |
+
def validate_non_negative(value: Union[int, float], field_name: str) -> None:
|
| 36 |
+
"""Validate that a numeric value is non-negative."""
|
| 37 |
+
if value < 0:
|
| 38 |
+
raise ConfigValidationError(f"Value cannot be negative, got {value}", field_name)
|
| 39 |
+
|
| 40 |
+
def validate_range(value: Union[int, float], min_val: Union[int, float], max_val: Union[int, float], field_name: str) -> None:
|
| 41 |
+
"""Validate that a numeric value is within a specified range."""
|
| 42 |
+
if not min_val <= value <= max_val:
|
| 43 |
+
raise ConfigValidationError(f"Value must be between {min_val} and {max_val}, got {value}", field_name)
|
| 44 |
+
|
| 45 |
+
def validate_non_empty_string(value: str, field_name: str) -> None:
|
| 46 |
+
"""Validate that a string is not empty or just whitespace."""
|
| 47 |
+
if not value.strip():
|
| 48 |
+
raise ConfigValidationError("Value cannot be empty or whitespace", field_name)
|
| 49 |
+
|
| 50 |
+
def validate_path_exists_or_creatable(path: str, field_name: str) -> None:
|
| 51 |
+
"""Validate that a path exists or can be created."""
|
| 52 |
+
try:
|
| 53 |
+
path_obj = Path(path)
|
| 54 |
+
if not path_obj.parent.exists():
|
| 55 |
+
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
| 56 |
+
except (OSError, RuntimeError) as e:
|
| 57 |
+
raise ConfigValidationError(f"Invalid path or cannot create directory: {str(e)}", field_name)
|
| 58 |
+
|
| 59 |
+
class LogLevel(str, Enum):
|
| 60 |
+
"""Valid logging levels."""
|
| 61 |
+
DEBUG = "DEBUG"
|
| 62 |
+
INFO = "INFO"
|
| 63 |
+
WARNING = "WARNING"
|
| 64 |
+
ERROR = "ERROR"
|
| 65 |
+
CRITICAL = "CRITICAL"
|
| 66 |
+
|
| 67 |
+
class Theme(str, Enum):
|
| 68 |
+
"""Valid UI themes."""
|
| 69 |
+
LIGHT = "light"
|
| 70 |
+
DARK = "dark"
|
| 71 |
+
|
| 72 |
+
def get_env_value(key: str, default: T, value_type: Type[T] = str) -> T:
|
| 73 |
+
"""
|
| 74 |
+
Get an environment variable with type conversion and validation.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
key: Environment variable name
|
| 78 |
+
default: Default value if not set
|
| 79 |
+
value_type: Type to convert the value to
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
The environment variable value converted to the specified type
|
| 83 |
+
|
| 84 |
+
Raises:
|
| 85 |
+
ValueError: If type conversion fails
|
| 86 |
+
"""
|
| 87 |
+
value = os.getenv(key)
|
| 88 |
+
if value is None:
|
| 89 |
+
return default
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
if value_type == bool:
|
| 93 |
+
return cast(T, value.lower() == 'true')
|
| 94 |
+
return value_type(value)
|
| 95 |
+
except (ValueError, TypeError) as e:
|
| 96 |
+
logger.warning(f"Invalid value for {key}: {value}. Using default: {default}. Error: {str(e)}")
|
| 97 |
+
return default
|
| 98 |
+
|
| 99 |
+
@dataclass(frozen=True)
|
| 100 |
+
class WeatherAPIConfig:
|
| 101 |
+
"""Weather API specific configuration."""
|
| 102 |
+
URL_PATTERN: ClassVar[Pattern] = re.compile(r'^https?://[^\s/$.?#].[^\s]*$')
|
| 103 |
+
|
| 104 |
+
base_url: str = field(default_factory=lambda: get_env_value(
|
| 105 |
+
'WEATHER_API_BASE_URL',
|
| 106 |
+
'http://api.weatherapi.com/v1'
|
| 107 |
+
))
|
| 108 |
+
rate_limit_per_minute: int = field(default_factory=lambda: get_env_value(
|
| 109 |
+
'WEATHER_API_RATE_LIMIT',
|
| 110 |
+
60,
|
| 111 |
+
int
|
| 112 |
+
))
|
| 113 |
+
cache_timeout_seconds: int = field(default_factory=lambda: get_env_value(
|
| 114 |
+
'WEATHER_CACHE_TIMEOUT',
|
| 115 |
+
300,
|
| 116 |
+
int
|
| 117 |
+
))
|
| 118 |
+
api_key: Optional[str] = field(default_factory=lambda: os.getenv('WEATHER_API_KEY'))
|
| 119 |
+
|
| 120 |
+
def __post_init__(self):
|
| 121 |
+
"""Validate configuration after initialization."""
|
| 122 |
+
validate_non_empty_string(self.base_url, "base_url")
|
| 123 |
+
if not self.URL_PATTERN.match(self.base_url):
|
| 124 |
+
raise ConfigValidationError("Invalid URL format", "base_url")
|
| 125 |
+
|
| 126 |
+
validate_positive(self.rate_limit_per_minute, "rate_limit_per_minute")
|
| 127 |
+
validate_non_negative(self.cache_timeout_seconds, "cache_timeout_seconds")
|
| 128 |
+
|
| 129 |
+
if not self.api_key:
|
| 130 |
+
logger.warning("Weather API key not set. Weather functionality will be unavailable.")
|
| 131 |
+
elif not isinstance(self.api_key, str):
|
| 132 |
+
raise ConfigValidationError("API key must be a string", "api_key")
|
| 133 |
+
|
| 134 |
+
@dataclass(frozen=True)
|
| 135 |
+
class TimezoneAPIConfig:
|
| 136 |
+
"""Timezone API specific configuration."""
|
| 137 |
+
rate_limit_per_minute: int = field(default_factory=lambda: get_env_value(
|
| 138 |
+
'TIMEZONE_API_RATE_LIMIT',
|
| 139 |
+
100,
|
| 140 |
+
int
|
| 141 |
+
))
|
| 142 |
+
|
| 143 |
+
def __post_init__(self):
|
| 144 |
+
"""Validate configuration after initialization."""
|
| 145 |
+
validate_positive(self.rate_limit_per_minute, "rate_limit_per_minute")
|
| 146 |
+
|
| 147 |
+
@dataclass(frozen=True)
|
| 148 |
+
class APIConfig:
|
| 149 |
+
"""API configuration settings."""
|
| 150 |
+
weather: WeatherAPIConfig = field(default_factory=WeatherAPIConfig)
|
| 151 |
+
timezone: TimezoneAPIConfig = field(default_factory=TimezoneAPIConfig)
|
| 152 |
+
|
| 153 |
+
@dataclass(frozen=True)
|
| 154 |
+
class CommandHistoryConfig:
|
| 155 |
+
"""Command history configuration."""
|
| 156 |
+
max_size: int = field(default_factory=lambda: get_env_value(
|
| 157 |
+
'COMMAND_HISTORY_MAX_SIZE',
|
| 158 |
+
10,
|
| 159 |
+
int
|
| 160 |
+
))
|
| 161 |
+
|
| 162 |
+
def __post_init__(self):
|
| 163 |
+
"""Validate configuration after initialization."""
|
| 164 |
+
if self.max_size < 1:
|
| 165 |
+
raise ConfigValidationError("Command history size must be at least 1")
|
| 166 |
+
|
| 167 |
+
@dataclass(frozen=True)
|
| 168 |
+
class CacheConfig:
|
| 169 |
+
"""Cache configuration settings."""
|
| 170 |
+
cleanup_interval_seconds: int = field(default_factory=lambda: get_env_value(
|
| 171 |
+
'CACHE_CLEANUP_INTERVAL',
|
| 172 |
+
600,
|
| 173 |
+
int
|
| 174 |
+
))
|
| 175 |
+
|
| 176 |
+
def __post_init__(self):
|
| 177 |
+
"""Validate configuration after initialization."""
|
| 178 |
+
if self.cleanup_interval_seconds < 0:
|
| 179 |
+
raise ConfigValidationError("Cache cleanup interval cannot be negative")
|
| 180 |
+
|
| 181 |
+
@dataclass(frozen=True)
|
| 182 |
+
class AssistantConfig:
|
| 183 |
+
"""Assistant specific configuration."""
|
| 184 |
+
command_history: CommandHistoryConfig = field(default_factory=CommandHistoryConfig)
|
| 185 |
+
cache: CacheConfig = field(default_factory=CacheConfig)
|
| 186 |
+
|
| 187 |
+
@dataclass(frozen=True)
|
| 188 |
+
class UIConfig:
|
| 189 |
+
"""UI configuration settings."""
|
| 190 |
+
DIMENSION_PATTERN: ClassVar[Pattern] = re.compile(r'^\d+(%|px|em|rem|vh|vw)$')
|
| 191 |
+
ALLOWED_THEMES: ClassVar[List[str]] = [theme.value for theme in Theme]
|
| 192 |
+
|
| 193 |
+
title: str = field(default_factory=lambda: get_env_value(
|
| 194 |
+
'UI_TITLE',
|
| 195 |
+
'AI Assistant'
|
| 196 |
+
))
|
| 197 |
+
description: str = field(default_factory=lambda: get_env_value(
|
| 198 |
+
'UI_DESCRIPTION',
|
| 199 |
+
'Weather and Timezone Assistant'
|
| 200 |
+
))
|
| 201 |
+
theme: Theme = field(default_factory=lambda: Theme(get_env_value(
|
| 202 |
+
'UI_THEME',
|
| 203 |
+
'light'
|
| 204 |
+
)))
|
| 205 |
+
input_placeholder: str = field(default_factory=lambda: get_env_value(
|
| 206 |
+
'UI_INPUT_PLACEHOLDER',
|
| 207 |
+
'Enter your command (type \'help\' for available commands)'
|
| 208 |
+
))
|
| 209 |
+
width: str = field(default_factory=lambda: get_env_value(
|
| 210 |
+
'UI_WIDTH',
|
| 211 |
+
'100%'
|
| 212 |
+
))
|
| 213 |
+
height: str = field(default_factory=lambda: get_env_value(
|
| 214 |
+
'UI_HEIGHT',
|
| 215 |
+
'600px'
|
| 216 |
+
))
|
| 217 |
+
|
| 218 |
+
def __post_init__(self):
|
| 219 |
+
"""Validate configuration after initialization."""
|
| 220 |
+
validate_non_empty_string(self.title, "title")
|
| 221 |
+
validate_non_empty_string(self.description, "description")
|
| 222 |
+
validate_non_empty_string(self.input_placeholder, "input_placeholder")
|
| 223 |
+
|
| 224 |
+
if not self.DIMENSION_PATTERN.match(self.width):
|
| 225 |
+
raise ConfigValidationError(
|
| 226 |
+
f"Invalid format. Must be a number followed by %, px, em, rem, vh, or vw",
|
| 227 |
+
"width"
|
| 228 |
+
)
|
| 229 |
+
if not self.DIMENSION_PATTERN.match(self.height):
|
| 230 |
+
raise ConfigValidationError(
|
| 231 |
+
f"Invalid format. Must be a number followed by %, px, em, rem, vh, or vw",
|
| 232 |
+
"height"
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
if self.theme.value not in self.ALLOWED_THEMES:
|
| 236 |
+
raise ConfigValidationError(
|
| 237 |
+
f"Invalid theme. Must be one of: {', '.join(self.ALLOWED_THEMES)}",
|
| 238 |
+
"theme"
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
@dataclass(frozen=True)
|
| 242 |
+
class LoggingConfig:
|
| 243 |
+
"""Logging configuration settings."""
|
| 244 |
+
ALLOWED_LEVELS: ClassVar[List[str]] = [level.value for level in LogLevel]
|
| 245 |
+
|
| 246 |
+
level: LogLevel = field(default_factory=lambda: LogLevel(get_env_value(
|
| 247 |
+
'LOG_LEVEL',
|
| 248 |
+
'INFO'
|
| 249 |
+
)))
|
| 250 |
+
file: str = field(default_factory=lambda: get_env_value(
|
| 251 |
+
'LOG_FILE',
|
| 252 |
+
'assistant.log'
|
| 253 |
+
))
|
| 254 |
+
format: str = field(default_factory=lambda: get_env_value(
|
| 255 |
+
'LOG_FORMAT',
|
| 256 |
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 257 |
+
))
|
| 258 |
+
|
| 259 |
+
def __post_init__(self):
|
| 260 |
+
"""Validate configuration after initialization."""
|
| 261 |
+
validate_non_empty_string(self.file, "file")
|
| 262 |
+
validate_non_empty_string(self.format, "format")
|
| 263 |
+
|
| 264 |
+
if self.level.value not in self.ALLOWED_LEVELS:
|
| 265 |
+
raise ConfigValidationError(
|
| 266 |
+
f"Invalid log level. Must be one of: {', '.join(self.ALLOWED_LEVELS)}",
|
| 267 |
+
"level"
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
validate_path_exists_or_creatable(self.file, "file")
|
| 271 |
+
|
| 272 |
+
# Validate log format by attempting to use it
|
| 273 |
+
try:
|
| 274 |
+
logging.Formatter(self.format)
|
| 275 |
+
except ValueError as e:
|
| 276 |
+
raise ConfigValidationError(f"Invalid log format: {str(e)}", "format")
|
| 277 |
+
|
| 278 |
+
@dataclass(frozen=True)
|
| 279 |
+
class ModelConfig:
|
| 280 |
+
"""Model configuration settings."""
|
| 281 |
+
class_name: str = field(default_factory=lambda: get_env_value(
|
| 282 |
+
'MODEL_CLASS',
|
| 283 |
+
'HfApiModel'
|
| 284 |
+
))
|
| 285 |
+
max_tokens: int = field(default_factory=lambda: get_env_value(
|
| 286 |
+
'MODEL_MAX_TOKENS',
|
| 287 |
+
2096,
|
| 288 |
+
int
|
| 289 |
+
))
|
| 290 |
+
temperature: float = field(default_factory=lambda: get_env_value(
|
| 291 |
+
'MODEL_TEMPERATURE',
|
| 292 |
+
0.5,
|
| 293 |
+
float
|
| 294 |
+
))
|
| 295 |
+
model_id: str = field(default_factory=lambda: get_env_value(
|
| 296 |
+
'MODEL_ID',
|
| 297 |
+
'Qwen/Qwen2.5-Coder-32B-Instruct'
|
| 298 |
+
))
|
| 299 |
+
last_input_token_count: Optional[int] = None
|
| 300 |
+
last_output_token_count: Optional[int] = None
|
| 301 |
+
custom_role_conversions: Optional[Dict[str, Any]] = None
|
| 302 |
+
|
| 303 |
+
def __post_init__(self):
|
| 304 |
+
"""Validate configuration after initialization."""
|
| 305 |
+
validate_non_empty_string(self.class_name, "class_name")
|
| 306 |
+
validate_non_empty_string(self.model_id, "model_id")
|
| 307 |
+
validate_positive(self.max_tokens, "max_tokens")
|
| 308 |
+
validate_range(self.temperature, 0.0, 1.0, "temperature")
|
| 309 |
+
|
| 310 |
+
if self.last_input_token_count is not None:
|
| 311 |
+
validate_non_negative(self.last_input_token_count, "last_input_token_count")
|
| 312 |
+
if self.last_output_token_count is not None:
|
| 313 |
+
validate_non_negative(self.last_output_token_count, "last_output_token_count")
|
| 314 |
+
|
| 315 |
+
if self.custom_role_conversions is not None and not isinstance(self.custom_role_conversions, dict):
|
| 316 |
+
raise ConfigValidationError("Must be a dictionary", "custom_role_conversions")
|
| 317 |
+
|
| 318 |
+
@dataclass(frozen=True)
|
| 319 |
+
class Config:
|
| 320 |
+
"""Main configuration class combining all settings."""
|
| 321 |
+
api: APIConfig = field(default_factory=APIConfig)
|
| 322 |
+
assistant: AssistantConfig = field(default_factory=AssistantConfig)
|
| 323 |
+
ui: UIConfig = field(default_factory=UIConfig)
|
| 324 |
+
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
| 325 |
+
model: ModelConfig = field(default_factory=ModelConfig)
|
| 326 |
+
|
| 327 |
+
def __post_init__(self):
|
| 328 |
+
"""Cross-validate configuration settings."""
|
| 329 |
+
# Validate that cache cleanup interval is less than cache timeout
|
| 330 |
+
if self.assistant.cache.cleanup_interval_seconds >= self.api.weather.cache_timeout_seconds:
|
| 331 |
+
raise ConfigValidationError(
|
| 332 |
+
"Cache cleanup interval must be less than cache timeout",
|
| 333 |
+
"assistant.cache.cleanup_interval_seconds"
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
@classmethod
|
| 337 |
+
def from_json(cls, json_data: Dict[str, Any]) -> 'Config':
|
| 338 |
+
"""
|
| 339 |
+
Create a Config instance from JSON data.
|
| 340 |
+
|
| 341 |
+
Args:
|
| 342 |
+
json_data: Dictionary containing configuration data
|
| 343 |
+
|
| 344 |
+
Returns:
|
| 345 |
+
Config: New configuration instance
|
| 346 |
+
|
| 347 |
+
Raises:
|
| 348 |
+
ConfigValidationError: If the configuration is invalid
|
| 349 |
+
"""
|
| 350 |
+
try:
|
| 351 |
+
config_data = json_data.get('config', {})
|
| 352 |
+
model_data = json_data.get('model', {}).get('data', {})
|
| 353 |
+
|
| 354 |
+
return cls(
|
| 355 |
+
api=APIConfig(
|
| 356 |
+
weather=WeatherAPIConfig(
|
| 357 |
+
base_url=config_data.get('api', {}).get('weather', {}).get('base_url', WeatherAPIConfig.base_url),
|
| 358 |
+
rate_limit_per_minute=config_data.get('api', {}).get('weather', {}).get('rate_limit_per_minute', WeatherAPIConfig.rate_limit_per_minute),
|
| 359 |
+
cache_timeout_seconds=config_data.get('api', {}).get('weather', {}).get('cache_timeout_seconds', WeatherAPIConfig.cache_timeout_seconds)
|
| 360 |
+
),
|
| 361 |
+
timezone=TimezoneAPIConfig(
|
| 362 |
+
rate_limit_per_minute=config_data.get('api', {}).get('timezone', {}).get('rate_limit_per_minute', TimezoneAPIConfig.rate_limit_per_minute)
|
| 363 |
+
)
|
| 364 |
+
),
|
| 365 |
+
assistant=AssistantConfig(
|
| 366 |
+
command_history=CommandHistoryConfig(
|
| 367 |
+
max_size=config_data.get('assistant', {}).get('command_history', {}).get('max_size', CommandHistoryConfig.max_size)
|
| 368 |
+
),
|
| 369 |
+
cache=CacheConfig(
|
| 370 |
+
cleanup_interval_seconds=config_data.get('assistant', {}).get('cache', {}).get('cleanup_interval_seconds', CacheConfig.cleanup_interval_seconds)
|
| 371 |
+
)
|
| 372 |
+
),
|
| 373 |
+
ui=UIConfig(
|
| 374 |
+
title=config_data.get('ui', {}).get('title', UIConfig.title),
|
| 375 |
+
description=config_data.get('ui', {}).get('description', UIConfig.description),
|
| 376 |
+
theme=Theme(config_data.get('ui', {}).get('theme', UIConfig.theme.value)),
|
| 377 |
+
input_placeholder=config_data.get('ui', {}).get('input_placeholder', UIConfig.input_placeholder),
|
| 378 |
+
width=config_data.get('ui', {}).get('width', UIConfig.width),
|
| 379 |
+
height=config_data.get('ui', {}).get('height', UIConfig.height)
|
| 380 |
+
),
|
| 381 |
+
logging=LoggingConfig(
|
| 382 |
+
level=LogLevel(config_data.get('logging', {}).get('level', LoggingConfig.level.value)),
|
| 383 |
+
file=config_data.get('logging', {}).get('file', LoggingConfig.file),
|
| 384 |
+
format=config_data.get('logging', {}).get('format', LoggingConfig.format)
|
| 385 |
+
),
|
| 386 |
+
model=ModelConfig(
|
| 387 |
+
class_name=model_data.get('class', ModelConfig.class_name),
|
| 388 |
+
max_tokens=model_data.get('max_tokens', ModelConfig.max_tokens),
|
| 389 |
+
temperature=model_data.get('temperature', ModelConfig.temperature),
|
| 390 |
+
model_id=model_data.get('model_id', ModelConfig.model_id),
|
| 391 |
+
last_input_token_count=model_data.get('last_input_token_count'),
|
| 392 |
+
last_output_token_count=model_data.get('last_output_token_count'),
|
| 393 |
+
custom_role_conversions=model_data.get('custom_role_conversions')
|
| 394 |
+
)
|
| 395 |
+
)
|
| 396 |
+
except (ValueError, TypeError, FrozenInstanceError) as e:
|
| 397 |
+
raise ConfigValidationError(f"Invalid configuration: {str(e)}")
|
| 398 |
+
|
| 399 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 400 |
+
"""
|
| 401 |
+
Convert configuration to a dictionary.
|
| 402 |
+
|
| 403 |
+
Returns:
|
| 404 |
+
Dict[str, Any]: Dictionary representation of the configuration
|
| 405 |
+
"""
|
| 406 |
+
return {
|
| 407 |
+
'config': {
|
| 408 |
+
'api': {
|
| 409 |
+
'weather': {
|
| 410 |
+
'base_url': self.api.weather.base_url,
|
| 411 |
+
'rate_limit_per_minute': self.api.weather.rate_limit_per_minute,
|
| 412 |
+
'cache_timeout_seconds': self.api.weather.cache_timeout_seconds
|
| 413 |
+
},
|
| 414 |
+
'timezone': {
|
| 415 |
+
'rate_limit_per_minute': self.api.timezone.rate_limit_per_minute
|
| 416 |
+
}
|
| 417 |
+
},
|
| 418 |
+
'assistant': {
|
| 419 |
+
'command_history': {
|
| 420 |
+
'max_size': self.assistant.command_history.max_size
|
| 421 |
+
},
|
| 422 |
+
'cache': {
|
| 423 |
+
'cleanup_interval_seconds': self.assistant.cache.cleanup_interval_seconds
|
| 424 |
+
}
|
| 425 |
+
},
|
| 426 |
+
'ui': {
|
| 427 |
+
'title': self.ui.title,
|
| 428 |
+
'description': self.ui.description,
|
| 429 |
+
'theme': self.ui.theme.value,
|
| 430 |
+
'input_placeholder': self.ui.input_placeholder,
|
| 431 |
+
'width': self.ui.width,
|
| 432 |
+
'height': self.ui.height
|
| 433 |
+
},
|
| 434 |
+
'logging': {
|
| 435 |
+
'level': self.logging.level.value,
|
| 436 |
+
'file': self.logging.file,
|
| 437 |
+
'format': self.logging.format
|
| 438 |
+
}
|
| 439 |
+
},
|
| 440 |
+
'model': {
|
| 441 |
+
'class': self.model.class_name,
|
| 442 |
+
'data': {
|
| 443 |
+
'max_tokens': self.model.max_tokens,
|
| 444 |
+
'temperature': self.model.temperature,
|
| 445 |
+
'model_id': self.model.model_id,
|
| 446 |
+
'last_input_token_count': self.model.last_input_token_count,
|
| 447 |
+
'last_output_token_count': self.model.last_output_token_count,
|
| 448 |
+
'custom_role_conversions': self.model.custom_role_conversions
|
| 449 |
+
}
|
| 450 |
+
}
|
| 451 |
+
}
|
docs/api.md
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Documentation
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
The API key management system provides secure handling of API keys with features like encryption, rate limiting, and key rotation. This documentation covers all available APIs, their usage, and best practices.
|
| 6 |
+
|
| 7 |
+
## Table of Contents
|
| 8 |
+
|
| 9 |
+
1. [Key Manager](#key-manager)
|
| 10 |
+
2. [API Key Configuration](#api-key-configuration)
|
| 11 |
+
3. [Rate Limiting](#rate-limiting)
|
| 12 |
+
4. [Error Handling](#error-handling)
|
| 13 |
+
5. [Examples](#examples)
|
| 14 |
+
|
| 15 |
+
## Key Manager
|
| 16 |
+
|
| 17 |
+
### Initialization
|
| 18 |
+
|
| 19 |
+
```python
|
| 20 |
+
from tools.security import KeyManager, key_manager
|
| 21 |
+
|
| 22 |
+
# Use global instance
|
| 23 |
+
manager = key_manager
|
| 24 |
+
|
| 25 |
+
# Or create custom instance
|
| 26 |
+
custom_manager = KeyManager(
|
| 27 |
+
keys_file="custom_keys.json",
|
| 28 |
+
env_prefix="API",
|
| 29 |
+
encryption_key="your-encryption-key"
|
| 30 |
+
)
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
#### Parameters
|
| 34 |
+
|
| 35 |
+
- `keys_file` (str | Path, optional): Path to keys storage file. Default: "keys.json"
|
| 36 |
+
- `env_prefix` (str, optional): Prefix for environment variables. Default: "API"
|
| 37 |
+
- `encryption_key` (str, optional): Key for encrypting stored keys. Default: None (auto-generated)
|
| 38 |
+
|
| 39 |
+
### Methods
|
| 40 |
+
|
| 41 |
+
#### add_key
|
| 42 |
+
|
| 43 |
+
Add a new API key with configuration.
|
| 44 |
+
|
| 45 |
+
```python
|
| 46 |
+
key = key_manager.add_key(
|
| 47 |
+
name="service_name",
|
| 48 |
+
key="your-api-key",
|
| 49 |
+
scopes=["read:data", "write:data"],
|
| 50 |
+
expires_at=datetime.now(timezone.utc) + timedelta(days=90),
|
| 51 |
+
rate_limit=10.0,
|
| 52 |
+
burst_limit=20
|
| 53 |
+
)
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
**Parameters:**
|
| 57 |
+
- `name` (str): Key identifier
|
| 58 |
+
- `key` (str): API key value
|
| 59 |
+
- `scopes` (List[str], optional): Access scopes. Default: []
|
| 60 |
+
- `expires_at` (datetime, optional): Expiration date. Default: None
|
| 61 |
+
- `rate_limit` (float, optional): Requests per second. Default: 10.0
|
| 62 |
+
- `burst_limit` (int, optional): Maximum burst size. Default: 20
|
| 63 |
+
|
| 64 |
+
**Returns:**
|
| 65 |
+
- `APIKeyConfig`: Created key configuration
|
| 66 |
+
|
| 67 |
+
**Raises:**
|
| 68 |
+
- `ValidationError`: If any input is invalid
|
| 69 |
+
- `InvalidKeyError`: If key already exists
|
| 70 |
+
- `ConfigurationError`: If key cannot be saved
|
| 71 |
+
|
| 72 |
+
#### get_key
|
| 73 |
+
|
| 74 |
+
Get an API key by name.
|
| 75 |
+
|
| 76 |
+
```python
|
| 77 |
+
key = key_manager.get_key("service_name")
|
| 78 |
+
if key and key.is_valid:
|
| 79 |
+
api_key = key.key.get_secret_value()
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
**Parameters:**
|
| 83 |
+
- `name` (str): Key identifier
|
| 84 |
+
|
| 85 |
+
**Returns:**
|
| 86 |
+
- `Optional[APIKeyConfig]`: Key configuration if found and valid
|
| 87 |
+
|
| 88 |
+
**Raises:**
|
| 89 |
+
- `ValidationError`: If name is invalid
|
| 90 |
+
- `InvalidKeyError`: If key is not found
|
| 91 |
+
- `KeyExpiredError`: If key is expired
|
| 92 |
+
|
| 93 |
+
#### rotate_key
|
| 94 |
+
|
| 95 |
+
Rotate an existing API key.
|
| 96 |
+
|
| 97 |
+
```python
|
| 98 |
+
new_key = key_manager.rotate_key(
|
| 99 |
+
name="service_name",
|
| 100 |
+
new_key="new-api-key",
|
| 101 |
+
expires_at=datetime.now(timezone.utc) + timedelta(days=90),
|
| 102 |
+
rate_limit=20.0,
|
| 103 |
+
burst_limit=40
|
| 104 |
+
)
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
**Parameters:**
|
| 108 |
+
- `name` (str): Key identifier
|
| 109 |
+
- `new_key` (str): New API key value
|
| 110 |
+
- `expires_at` (datetime, optional): New expiration date
|
| 111 |
+
- `rate_limit` (float, optional): New rate limit
|
| 112 |
+
- `burst_limit` (int, optional): New burst limit
|
| 113 |
+
|
| 114 |
+
**Returns:**
|
| 115 |
+
- `APIKeyConfig`: Updated key configuration
|
| 116 |
+
|
| 117 |
+
**Raises:**
|
| 118 |
+
- `ValidationError`: If any input is invalid
|
| 119 |
+
- `InvalidKeyError`: If key does not exist
|
| 120 |
+
- `ConfigurationError`: If key cannot be saved
|
| 121 |
+
|
| 122 |
+
#### remove_key
|
| 123 |
+
|
| 124 |
+
Remove an API key.
|
| 125 |
+
|
| 126 |
+
```python
|
| 127 |
+
key_manager.remove_key("service_name")
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
**Parameters:**
|
| 131 |
+
- `name` (str): Key identifier
|
| 132 |
+
|
| 133 |
+
**Raises:**
|
| 134 |
+
- `ValidationError`: If name is invalid
|
| 135 |
+
- `InvalidKeyError`: If key does not exist
|
| 136 |
+
- `ConfigurationError`: If key cannot be removed
|
| 137 |
+
|
| 138 |
+
#### list_keys
|
| 139 |
+
|
| 140 |
+
List all valid API keys.
|
| 141 |
+
|
| 142 |
+
```python
|
| 143 |
+
keys = key_manager.list_keys()
|
| 144 |
+
for name, key in keys.items():
|
| 145 |
+
print(f"{name}: {key.scopes}")
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
**Returns:**
|
| 149 |
+
- `Dict[str, APIKeyConfig]`: Dictionary of valid key configurations
|
| 150 |
+
|
| 151 |
+
#### check_rate_limit
|
| 152 |
+
|
| 153 |
+
Check rate limit for a key.
|
| 154 |
+
|
| 155 |
+
```python
|
| 156 |
+
try:
|
| 157 |
+
wait_time = await key_manager.check_rate_limit("service_name")
|
| 158 |
+
if wait_time > 0:
|
| 159 |
+
await asyncio.sleep(wait_time)
|
| 160 |
+
except RateLimitError as e:
|
| 161 |
+
# Handle rate limit exceeded
|
| 162 |
+
logger.warning(f"Rate limited: {e.wait_time}s")
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
**Parameters:**
|
| 166 |
+
- `name` (str): Key identifier
|
| 167 |
+
|
| 168 |
+
**Returns:**
|
| 169 |
+
- `float`: Wait time in seconds if rate limited, 0 otherwise
|
| 170 |
+
|
| 171 |
+
**Raises:**
|
| 172 |
+
- `InvalidKeyError`: If key does not exist
|
| 173 |
+
- `KeyExpiredError`: If key is expired
|
| 174 |
+
- `RateLimitError`: If rate limit is exceeded
|
| 175 |
+
|
| 176 |
+
## API Key Configuration
|
| 177 |
+
|
| 178 |
+
### APIKeyConfig Class
|
| 179 |
+
|
| 180 |
+
Configuration class for API keys with validation.
|
| 181 |
+
|
| 182 |
+
```python
|
| 183 |
+
from tools.security import APIKeyConfig
|
| 184 |
+
from pydantic import SecretStr
|
| 185 |
+
|
| 186 |
+
config = APIKeyConfig(
|
| 187 |
+
key=SecretStr("api-key"),
|
| 188 |
+
name="service_name",
|
| 189 |
+
scopes=["read:data"],
|
| 190 |
+
expires_at=datetime.now(timezone.utc) + timedelta(days=90),
|
| 191 |
+
rate_limit=10.0,
|
| 192 |
+
burst_limit=20
|
| 193 |
+
)
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
#### Attributes
|
| 197 |
+
|
| 198 |
+
- `key` (SecretStr): API key value (masked in logs)
|
| 199 |
+
- `name` (str): Key identifier
|
| 200 |
+
- `created_at` (datetime): Creation timestamp
|
| 201 |
+
- `expires_at` (Optional[datetime]): Expiration date
|
| 202 |
+
- `scopes` (List[str]): Access scopes
|
| 203 |
+
- `is_active` (bool): Whether key is active
|
| 204 |
+
- `rate_limit` (float): Requests per second
|
| 205 |
+
- `burst_limit` (int): Maximum burst size
|
| 206 |
+
|
| 207 |
+
#### Properties
|
| 208 |
+
|
| 209 |
+
- `is_expired` (bool): Check if key is expired
|
| 210 |
+
- `is_valid` (bool): Check if key is valid for use
|
| 211 |
+
|
| 212 |
+
## Rate Limiting
|
| 213 |
+
|
| 214 |
+
### Configuration
|
| 215 |
+
|
| 216 |
+
Rate limiting is configured per key using token bucket algorithm:
|
| 217 |
+
|
| 218 |
+
```python
|
| 219 |
+
# Configure rate limits
|
| 220 |
+
key = key_manager.add_key(
|
| 221 |
+
name="service_name",
|
| 222 |
+
key="api-key",
|
| 223 |
+
rate_limit=10.0, # 10 requests per second
|
| 224 |
+
burst_limit=20 # Allow bursts up to 20
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
# Check rate limit before requests
|
| 228 |
+
try:
|
| 229 |
+
wait_time = await key_manager.check_rate_limit("service_name")
|
| 230 |
+
if wait_time > 0:
|
| 231 |
+
# Wait before next request
|
| 232 |
+
await asyncio.sleep(wait_time)
|
| 233 |
+
except RateLimitError as e:
|
| 234 |
+
# Rate limit exceeded
|
| 235 |
+
logger.warning(f"Rate limited for {e.wait_time}s")
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
### Best Practices
|
| 239 |
+
|
| 240 |
+
1. Set appropriate limits based on service capacity
|
| 241 |
+
2. Configure burst limits for traffic spikes
|
| 242 |
+
3. Implement client-side rate limiting
|
| 243 |
+
4. Use exponential backoff for retries
|
| 244 |
+
5. Monitor rate limit violations
|
| 245 |
+
|
| 246 |
+
## Error Handling
|
| 247 |
+
|
| 248 |
+
### Error Types
|
| 249 |
+
|
| 250 |
+
- `SecurityError`: Base class for security errors
|
| 251 |
+
- `AuthenticationError`: Authentication failures
|
| 252 |
+
- `AuthorizationError`: Permission issues
|
| 253 |
+
- `InvalidKeyError`: Invalid/missing keys
|
| 254 |
+
- `KeyExpiredError`: Expired keys
|
| 255 |
+
- `RateLimitError`: Rate limit violations
|
| 256 |
+
- `EncryptionError`: Encryption issues
|
| 257 |
+
- `ValidationError`: Input validation failures
|
| 258 |
+
- `ConfigurationError`: Configuration issues
|
| 259 |
+
|
| 260 |
+
### Error Handling Example
|
| 261 |
+
|
| 262 |
+
```python
|
| 263 |
+
from tools.errors import (
|
| 264 |
+
SecurityError,
|
| 265 |
+
InvalidKeyError,
|
| 266 |
+
KeyExpiredError,
|
| 267 |
+
RateLimitError
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
try:
|
| 271 |
+
# Attempt to use key
|
| 272 |
+
key = key_manager.get_key("service_name")
|
| 273 |
+
if not key or not key.is_valid:
|
| 274 |
+
raise InvalidKeyError("Invalid key")
|
| 275 |
+
|
| 276 |
+
# Check rate limit
|
| 277 |
+
wait_time = await key_manager.check_rate_limit("service_name")
|
| 278 |
+
if wait_time > 0:
|
| 279 |
+
await asyncio.sleep(wait_time)
|
| 280 |
+
|
| 281 |
+
# Use key
|
| 282 |
+
api_key = key.key.get_secret_value()
|
| 283 |
+
|
| 284 |
+
except InvalidKeyError as e:
|
| 285 |
+
logger.error("Invalid API key", service="service_name")
|
| 286 |
+
raise HTTPError(401, "Authentication failed")
|
| 287 |
+
|
| 288 |
+
except KeyExpiredError as e:
|
| 289 |
+
logger.error("Expired API key", service="service_name")
|
| 290 |
+
raise HTTPError(401, "Authentication failed")
|
| 291 |
+
|
| 292 |
+
except RateLimitError as e:
|
| 293 |
+
logger.warning("Rate limit exceeded",
|
| 294 |
+
service="service_name",
|
| 295 |
+
wait_time=e.wait_time
|
| 296 |
+
)
|
| 297 |
+
raise HTTPError(429, f"Rate limit exceeded. Try again in {e.wait_time}s")
|
| 298 |
+
|
| 299 |
+
except SecurityError as e:
|
| 300 |
+
logger.error("Security error",
|
| 301 |
+
service="service_name",
|
| 302 |
+
error=str(e)
|
| 303 |
+
)
|
| 304 |
+
raise HTTPError(500, "Internal security error")
|
| 305 |
+
```
|
| 306 |
+
|
| 307 |
+
## Examples
|
| 308 |
+
|
| 309 |
+
### Basic Usage
|
| 310 |
+
|
| 311 |
+
```python
|
| 312 |
+
# Initialize
|
| 313 |
+
from tools.security import key_manager
|
| 314 |
+
|
| 315 |
+
# Add new key
|
| 316 |
+
key = key_manager.add_key(
|
| 317 |
+
name="my_service",
|
| 318 |
+
key="my-api-key",
|
| 319 |
+
scopes=["read:data"]
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
# Use key
|
| 323 |
+
try:
|
| 324 |
+
key = key_manager.get_key("my_service")
|
| 325 |
+
if key and key.is_valid:
|
| 326 |
+
api_key = key.key.get_secret_value()
|
| 327 |
+
# Use api_key...
|
| 328 |
+
except SecurityError as e:
|
| 329 |
+
logger.error("Security error", error=str(e))
|
| 330 |
+
```
|
| 331 |
+
|
| 332 |
+
### Key Rotation
|
| 333 |
+
|
| 334 |
+
```python
|
| 335 |
+
# Generate new key
|
| 336 |
+
import secrets
|
| 337 |
+
import string
|
| 338 |
+
new_key = ''.join(secrets.choice(string.ascii_letters + string.digits + '_.-')
|
| 339 |
+
for _ in range(32))
|
| 340 |
+
|
| 341 |
+
# Rotate key
|
| 342 |
+
try:
|
| 343 |
+
key = key_manager.rotate_key(
|
| 344 |
+
name="my_service",
|
| 345 |
+
new_key=new_key,
|
| 346 |
+
expires_at=datetime.now(timezone.utc) + timedelta(days=90)
|
| 347 |
+
)
|
| 348 |
+
logger.info("Key rotated successfully", service="my_service")
|
| 349 |
+
except SecurityError as e:
|
| 350 |
+
logger.error("Key rotation failed", error=str(e))
|
| 351 |
+
```
|
| 352 |
+
|
| 353 |
+
### Rate Limited Service
|
| 354 |
+
|
| 355 |
+
```python
|
| 356 |
+
async def make_api_request(service_name: str):
|
| 357 |
+
try:
|
| 358 |
+
# Get key
|
| 359 |
+
key = key_manager.get_key(service_name)
|
| 360 |
+
if not key or not key.is_valid:
|
| 361 |
+
raise InvalidKeyError("Invalid key")
|
| 362 |
+
|
| 363 |
+
# Check rate limit
|
| 364 |
+
wait_time = await key_manager.check_rate_limit(service_name)
|
| 365 |
+
if wait_time > 0:
|
| 366 |
+
await asyncio.sleep(wait_time)
|
| 367 |
+
|
| 368 |
+
# Make request
|
| 369 |
+
api_key = key.key.get_secret_value()
|
| 370 |
+
response = await make_request(api_key)
|
| 371 |
+
return response
|
| 372 |
+
|
| 373 |
+
except RateLimitError as e:
|
| 374 |
+
logger.warning("Rate limited",
|
| 375 |
+
service=service_name,
|
| 376 |
+
wait_time=e.wait_time
|
| 377 |
+
)
|
| 378 |
+
raise
|
| 379 |
+
```
|
| 380 |
+
|
| 381 |
+
For more examples and best practices, see:
|
| 382 |
+
- [Security Documentation](../SECURITY.md)
|
| 383 |
+
- [Quick Start Guide](./security_quickstart.md)
|
| 384 |
+
- [Error Handling Guide](./errors.md)
|
| 385 |
+
- [Monitoring Guide](./monitoring.md)
|
docs/api_reference.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Reference
|
| 2 |
+
|
| 3 |
+
## Core Components
|
| 4 |
+
|
| 5 |
+
### Tool Registry
|
| 6 |
+
|
| 7 |
+
The `ToolRegistry` class manages and instantiates tools in the AI Assistant framework.
|
| 8 |
+
|
| 9 |
+
```python
|
| 10 |
+
from tools import ToolRegistry, get_tool, get_all_tools
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
#### Methods
|
| 14 |
+
|
| 15 |
+
- `get_tool(name: str) -> BaseTool`: Get a tool instance by name
|
| 16 |
+
- `get_all_tools() -> dict[str, BaseTool]`: Get all registered tools
|
| 17 |
+
- `register(tool_class: Type[T]) -> Type[T]`: Register a tool class (decorator)
|
| 18 |
+
- `clear() -> None`: Clear all registered tools (mainly for testing)
|
| 19 |
+
|
| 20 |
+
### Base Tool
|
| 21 |
+
|
| 22 |
+
The `BaseTool` abstract base class defines the interface for all tools.
|
| 23 |
+
|
| 24 |
+
```python
|
| 25 |
+
from tools import BaseTool
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
#### Required Properties
|
| 29 |
+
|
| 30 |
+
- `name -> str`: The unique name of the tool
|
| 31 |
+
- `description -> str`: A description of what the tool does
|
| 32 |
+
|
| 33 |
+
## Error Handling
|
| 34 |
+
|
| 35 |
+
### API Errors
|
| 36 |
+
|
| 37 |
+
The framework provides specialized error types for API-related issues:
|
| 38 |
+
|
| 39 |
+
```python
|
| 40 |
+
from tools.exceptions import APIError, APITimeoutError, APIRateLimitError
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
#### Error Types
|
| 44 |
+
|
| 45 |
+
- `APIError`: Base exception for API-related errors
|
| 46 |
+
- `APITimeoutError`: Raised when an API request times out
|
| 47 |
+
- `APIRateLimitError`: Raised when API rate limits are exceeded
|
| 48 |
+
|
| 49 |
+
### Error Recovery
|
| 50 |
+
|
| 51 |
+
#### Circuit Breaker
|
| 52 |
+
|
| 53 |
+
The `CircuitBreaker` class prevents cascading failures:
|
| 54 |
+
|
| 55 |
+
```python
|
| 56 |
+
from tools.error_recovery import CircuitBreaker
|
| 57 |
+
|
| 58 |
+
circuit_breaker = CircuitBreaker(
|
| 59 |
+
failure_threshold=5,
|
| 60 |
+
reset_timeout=60.0,
|
| 61 |
+
half_open_timeout=30.0
|
| 62 |
+
)
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
#### Retry Configuration
|
| 66 |
+
|
| 67 |
+
The `RetryConfig` class configures retry behavior:
|
| 68 |
+
|
| 69 |
+
```python
|
| 70 |
+
from tools.error_recovery import RetryConfig
|
| 71 |
+
|
| 72 |
+
retry_config = RetryConfig(
|
| 73 |
+
max_retries=3,
|
| 74 |
+
initial_delay=1.0,
|
| 75 |
+
max_delay=60.0,
|
| 76 |
+
exponential_base=2.0,
|
| 77 |
+
jitter=0.1
|
| 78 |
+
)
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
## API Logging
|
| 82 |
+
|
| 83 |
+
The `log_api_call` decorator provides comprehensive API call logging:
|
| 84 |
+
|
| 85 |
+
```python
|
| 86 |
+
from tools.api_logging import log_api_call
|
| 87 |
+
|
| 88 |
+
@log_api_call(
|
| 89 |
+
log_response=True,
|
| 90 |
+
log_request_body=True,
|
| 91 |
+
timeout=5.0,
|
| 92 |
+
retry_config=RetryConfig(max_retries=3),
|
| 93 |
+
circuit_breaker=CircuitBreaker(failure_threshold=5)
|
| 94 |
+
)
|
| 95 |
+
def my_api_function():
|
| 96 |
+
pass
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
### Features
|
| 100 |
+
|
| 101 |
+
- Request/response logging with sanitization
|
| 102 |
+
- Timeout handling
|
| 103 |
+
- Retry mechanism with exponential backoff
|
| 104 |
+
- Circuit breaker integration
|
| 105 |
+
- Rate limit handling
|
| 106 |
+
|
| 107 |
+
## Tools
|
| 108 |
+
|
| 109 |
+
### Timezone Tools
|
| 110 |
+
|
| 111 |
+
The `TimezoneTools` class provides timezone conversion functionality:
|
| 112 |
+
|
| 113 |
+
```python
|
| 114 |
+
from tools import TimezoneTools
|
| 115 |
+
|
| 116 |
+
timezone_tool = TimezoneTools()
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
#### Methods
|
| 120 |
+
|
| 121 |
+
- `get_current_time(tz: str = "UTC") -> datetime`: Get current time in specified timezone
|
| 122 |
+
- `list_common_timezones() -> List[str]`: Get list of common timezone names
|
| 123 |
+
- `is_valid_timezone(tz: str) -> bool`: Check if timezone name is valid
|
| 124 |
+
|
| 125 |
+
### Weather Tool
|
| 126 |
+
|
| 127 |
+
The `WeatherTool` class provides weather information by ZIP code:
|
| 128 |
+
|
| 129 |
+
```python
|
| 130 |
+
from tools.weather import WeatherTool
|
| 131 |
+
|
| 132 |
+
weather_tool = WeatherTool(
|
| 133 |
+
api_base_url="http://api.weatherapi.com/v1",
|
| 134 |
+
cache_timeout=300
|
| 135 |
+
)
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
#### Methods
|
| 139 |
+
|
| 140 |
+
- `__call__(zipcode: str) -> str`: Get weather information for a ZIP code
|
| 141 |
+
- Includes built-in caching
|
| 142 |
+
- Validates ZIP code format
|
| 143 |
+
- Handles API errors and rate limiting
|
docs/error_handling.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Error Handling Guide
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
The AI Assistant framework provides a robust error handling system with several key features:
|
| 6 |
+
- Custom exception types for different error scenarios
|
| 7 |
+
- Automatic retry mechanism with exponential backoff
|
| 8 |
+
- Circuit breaker pattern to prevent cascading failures
|
| 9 |
+
- Comprehensive error logging
|
| 10 |
+
|
| 11 |
+
## Exception Hierarchy
|
| 12 |
+
|
| 13 |
+
```
|
| 14 |
+
ToolError
|
| 15 |
+
└── APIError
|
| 16 |
+
├── APITimeoutError
|
| 17 |
+
└── APIRateLimitError
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
### Base Exceptions
|
| 21 |
+
|
| 22 |
+
#### ToolError
|
| 23 |
+
Base exception class for all tool-related errors.
|
| 24 |
+
|
| 25 |
+
#### APIError
|
| 26 |
+
```python
|
| 27 |
+
from tools.exceptions import APIError
|
| 28 |
+
|
| 29 |
+
raise APIError(
|
| 30 |
+
message="API request failed",
|
| 31 |
+
endpoint="/api/v1/data",
|
| 32 |
+
status_code=500,
|
| 33 |
+
response_body="Internal server error",
|
| 34 |
+
request_data={"key": "value"},
|
| 35 |
+
tool_name="my_tool"
|
| 36 |
+
)
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
Properties:
|
| 40 |
+
- `message`: Error description
|
| 41 |
+
- `endpoint`: The API endpoint that failed
|
| 42 |
+
- `status_code`: HTTP status code (optional)
|
| 43 |
+
- `response_body`: Response from the API (optional)
|
| 44 |
+
- `request_data`: Data sent in the request (optional)
|
| 45 |
+
- `tool_name`: Name of the tool where the error occurred
|
| 46 |
+
- `timestamp`: When the error occurred
|
| 47 |
+
|
| 48 |
+
### Specialized Exceptions
|
| 49 |
+
|
| 50 |
+
#### APITimeoutError
|
| 51 |
+
```python
|
| 52 |
+
from tools.exceptions import APITimeoutError
|
| 53 |
+
|
| 54 |
+
raise APITimeoutError(
|
| 55 |
+
endpoint="/api/v1/data",
|
| 56 |
+
timeout=5.0,
|
| 57 |
+
request_data={"key": "value"},
|
| 58 |
+
tool_name="my_tool"
|
| 59 |
+
)
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Additional properties:
|
| 63 |
+
- `timeout`: The timeout duration in seconds
|
| 64 |
+
|
| 65 |
+
#### APIRateLimitError
|
| 66 |
+
```python
|
| 67 |
+
from tools.exceptions import APIRateLimitError
|
| 68 |
+
|
| 69 |
+
raise APIRateLimitError(
|
| 70 |
+
endpoint="/api/v1/data",
|
| 71 |
+
retry_after=60,
|
| 72 |
+
request_data={"key": "value"},
|
| 73 |
+
tool_name="my_tool"
|
| 74 |
+
)
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
Additional properties:
|
| 78 |
+
- `retry_after`: Seconds to wait before retrying
|
| 79 |
+
|
| 80 |
+
## Error Recovery
|
| 81 |
+
|
| 82 |
+
### Circuit Breaker Pattern
|
| 83 |
+
|
| 84 |
+
The circuit breaker prevents cascading failures by temporarily stopping operations after repeated failures.
|
| 85 |
+
|
| 86 |
+
```python
|
| 87 |
+
from tools.error_recovery import CircuitBreaker
|
| 88 |
+
|
| 89 |
+
circuit_breaker = CircuitBreaker(
|
| 90 |
+
failure_threshold=5, # Open after 5 failures
|
| 91 |
+
reset_timeout=60.0, # Try to reset after 60 seconds
|
| 92 |
+
half_open_timeout=30.0 # Wait 30 seconds between half-open attempts
|
| 93 |
+
)
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
States:
|
| 97 |
+
1. **Closed**: Normal operation
|
| 98 |
+
2. **Open**: Failing fast, no operations allowed
|
| 99 |
+
3. **Half-Open**: Testing if system has recovered
|
| 100 |
+
|
| 101 |
+
### Retry Mechanism
|
| 102 |
+
|
| 103 |
+
The retry mechanism automatically retries failed operations with exponential backoff.
|
| 104 |
+
|
| 105 |
+
```python
|
| 106 |
+
from tools.error_recovery import RetryConfig
|
| 107 |
+
|
| 108 |
+
retry_config = RetryConfig(
|
| 109 |
+
max_retries=3, # Maximum number of retry attempts
|
| 110 |
+
initial_delay=1.0, # Initial delay between retries (seconds)
|
| 111 |
+
max_delay=60.0, # Maximum delay between retries (seconds)
|
| 112 |
+
exponential_base=2.0, # Base for exponential backoff
|
| 113 |
+
jitter=0.1 # Random jitter factor (0.0-1.0)
|
| 114 |
+
)
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
Features:
|
| 118 |
+
- Exponential backoff with jitter
|
| 119 |
+
- Configurable retry conditions
|
| 120 |
+
- Automatic handling of rate limits
|
| 121 |
+
- Integration with circuit breaker
|
| 122 |
+
|
| 123 |
+
## Combining Error Recovery Mechanisms
|
| 124 |
+
|
| 125 |
+
Use the `@log_api_call` decorator to combine logging, retries, and circuit breaker:
|
| 126 |
+
|
| 127 |
+
```python
|
| 128 |
+
from tools.api_logging import log_api_call
|
| 129 |
+
from tools.error_recovery import RetryConfig, CircuitBreaker
|
| 130 |
+
|
| 131 |
+
@log_api_call(
|
| 132 |
+
retry_config=RetryConfig(
|
| 133 |
+
max_retries=3,
|
| 134 |
+
initial_delay=1.0
|
| 135 |
+
),
|
| 136 |
+
circuit_breaker=CircuitBreaker(
|
| 137 |
+
failure_threshold=5
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
def call_api():
|
| 141 |
+
# Your API call here
|
| 142 |
+
pass
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
## Best Practices
|
| 146 |
+
|
| 147 |
+
1. **Use Specific Exceptions**
|
| 148 |
+
```python
|
| 149 |
+
# Good
|
| 150 |
+
raise APITimeoutError(endpoint="/api/data", timeout=5.0)
|
| 151 |
+
|
| 152 |
+
# Avoid
|
| 153 |
+
raise Exception("API timeout")
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
2. **Include Context**
|
| 157 |
+
```python
|
| 158 |
+
# Good
|
| 159 |
+
raise APIError(
|
| 160 |
+
message="Invalid response format",
|
| 161 |
+
endpoint="/api/data",
|
| 162 |
+
response_body=response_text
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# Avoid
|
| 166 |
+
raise APIError("Invalid response format")
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
3. **Configure Recovery Appropriately**
|
| 170 |
+
```python
|
| 171 |
+
# Good - Configured for specific use case
|
| 172 |
+
RetryConfig(
|
| 173 |
+
max_retries=3,
|
| 174 |
+
initial_delay=0.1,
|
| 175 |
+
max_delay=1.0
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# Avoid - Using defaults without consideration
|
| 179 |
+
RetryConfig()
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
4. **Handle Rate Limits**
|
| 183 |
+
```python
|
| 184 |
+
# Good
|
| 185 |
+
except APIRateLimitError as e:
|
| 186 |
+
wait_time = e.retry_after or 60
|
| 187 |
+
time.sleep(wait_time)
|
| 188 |
+
|
| 189 |
+
# Avoid
|
| 190 |
+
except APIRateLimitError:
|
| 191 |
+
time.sleep(60) # Hard-coded wait time
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
5. **Log Errors Appropriately**
|
| 195 |
+
```python
|
| 196 |
+
# Good
|
| 197 |
+
@log_api_call(log_response=True, log_request_body=True)
|
| 198 |
+
|
| 199 |
+
# Avoid
|
| 200 |
+
# No logging or manual logging without sanitization
|
| 201 |
+
```
|
docs/errors.md
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Error Handling Guide
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
This guide covers error handling in the API key management system, including error types, best practices, and examples.
|
| 6 |
+
|
| 7 |
+
## Table of Contents
|
| 8 |
+
|
| 9 |
+
1. [Error Types](#error-types)
|
| 10 |
+
2. [Error Handling Best Practices](#error-handling-best-practices)
|
| 11 |
+
3. [Common Error Scenarios](#common-error-scenarios)
|
| 12 |
+
4. [Logging Best Practices](#logging-best-practices)
|
| 13 |
+
5. [Error Recovery](#error-recovery)
|
| 14 |
+
|
| 15 |
+
## Error Types
|
| 16 |
+
|
| 17 |
+
### SecurityError
|
| 18 |
+
|
| 19 |
+
Base class for all security-related errors.
|
| 20 |
+
|
| 21 |
+
```python
|
| 22 |
+
try:
|
| 23 |
+
# Security operation
|
| 24 |
+
raise SecurityError("Security violation")
|
| 25 |
+
except SecurityError as e:
|
| 26 |
+
print(f"Error code: {e.code}") # SECURITY_ERROR
|
| 27 |
+
print(f"Message: {e.message}") # Security violation
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
### AuthenticationError
|
| 31 |
+
|
| 32 |
+
Raised when authentication fails.
|
| 33 |
+
|
| 34 |
+
```python
|
| 35 |
+
try:
|
| 36 |
+
raise AuthenticationError("Invalid credentials")
|
| 37 |
+
except AuthenticationError as e:
|
| 38 |
+
print(f"Error code: {e.code}") # AUTH_ERROR
|
| 39 |
+
print(f"Message: {e.message}") # Invalid credentials
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
### AuthorizationError
|
| 43 |
+
|
| 44 |
+
Raised when authorization fails.
|
| 45 |
+
|
| 46 |
+
```python
|
| 47 |
+
try:
|
| 48 |
+
raise AuthorizationError("Insufficient permissions")
|
| 49 |
+
except AuthorizationError as e:
|
| 50 |
+
print(f"Error code: {e.code}") # ACCESS_DENIED
|
| 51 |
+
print(f"Message: {e.message}") # Insufficient permissions
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
### InvalidKeyError
|
| 55 |
+
|
| 56 |
+
Raised when an API key is invalid.
|
| 57 |
+
|
| 58 |
+
```python
|
| 59 |
+
try:
|
| 60 |
+
key = key_manager.get_key("nonexistent")
|
| 61 |
+
if not key:
|
| 62 |
+
raise InvalidKeyError("Key not found")
|
| 63 |
+
except InvalidKeyError as e:
|
| 64 |
+
print(f"Error code: {e.code}") # INVALID_KEY
|
| 65 |
+
print(f"Message: {e.message}") # Key not found
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
### KeyExpiredError
|
| 69 |
+
|
| 70 |
+
Raised when an API key has expired.
|
| 71 |
+
|
| 72 |
+
```python
|
| 73 |
+
try:
|
| 74 |
+
key = key_manager.get_key("expired_key")
|
| 75 |
+
if key.is_expired:
|
| 76 |
+
raise KeyExpiredError("Key has expired")
|
| 77 |
+
except KeyExpiredError as e:
|
| 78 |
+
print(f"Error code: {e.code}") # KEY_EXPIRED
|
| 79 |
+
print(f"Message: {e.message}") # Key has expired
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
### RateLimitError
|
| 83 |
+
|
| 84 |
+
Raised when rate limit is exceeded.
|
| 85 |
+
|
| 86 |
+
```python
|
| 87 |
+
try:
|
| 88 |
+
await key_manager.check_rate_limit("service")
|
| 89 |
+
except RateLimitError as e:
|
| 90 |
+
print(f"Error code: {e.code}") # RATE_LIMITED
|
| 91 |
+
print(f"Message: {e.message}") # Rate limit exceeded
|
| 92 |
+
print(f"Wait time: {e.wait_time}") # Time to wait in seconds
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
### EncryptionError
|
| 96 |
+
|
| 97 |
+
Raised when encryption/decryption fails.
|
| 98 |
+
|
| 99 |
+
```python
|
| 100 |
+
try:
|
| 101 |
+
key_manager = KeyManager(encryption_key="invalid-key")
|
| 102 |
+
except EncryptionError as e:
|
| 103 |
+
print(f"Error code: {e.code}") # ENCRYPTION_ERROR
|
| 104 |
+
print(f"Message: {e.message}") # Failed to decrypt
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### ValidationError
|
| 108 |
+
|
| 109 |
+
Raised when validation fails.
|
| 110 |
+
|
| 111 |
+
```python
|
| 112 |
+
try:
|
| 113 |
+
key_manager.add_key("service", "invalid@key")
|
| 114 |
+
except ValidationError as e:
|
| 115 |
+
print(f"Error code: {e.code}") # VALIDATION_ERROR
|
| 116 |
+
print(f"Message: {e.message}") # Invalid key format
|
| 117 |
+
print(f"Field: {e.field}") # key
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
### ConfigurationError
|
| 121 |
+
|
| 122 |
+
Raised when configuration is invalid.
|
| 123 |
+
|
| 124 |
+
```python
|
| 125 |
+
try:
|
| 126 |
+
key_manager = KeyManager(env_prefix="123") # Must start with letter
|
| 127 |
+
except ConfigurationError as e:
|
| 128 |
+
print(f"Error code: {e.code}") # CONFIG_ERROR
|
| 129 |
+
print(f"Message: {e.message}") # Invalid prefix
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
## Error Handling Best Practices
|
| 133 |
+
|
| 134 |
+
### 1. Use Specific Error Types
|
| 135 |
+
|
| 136 |
+
```python
|
| 137 |
+
# ❌ WRONG - Too generic
|
| 138 |
+
try:
|
| 139 |
+
key = key_manager.get_key("service")
|
| 140 |
+
except Exception as e:
|
| 141 |
+
logger.error("Error occurred")
|
| 142 |
+
|
| 143 |
+
# ✅ RIGHT - Specific error handling
|
| 144 |
+
try:
|
| 145 |
+
key = key_manager.get_key("service")
|
| 146 |
+
except InvalidKeyError:
|
| 147 |
+
logger.error("Invalid key", service="service")
|
| 148 |
+
raise HTTPError(401)
|
| 149 |
+
except KeyExpiredError:
|
| 150 |
+
logger.error("Expired key", service="service")
|
| 151 |
+
raise HTTPError(401)
|
| 152 |
+
except SecurityError as e:
|
| 153 |
+
logger.error("Security error", error=str(e))
|
| 154 |
+
raise HTTPError(500)
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
### 2. Never Expose Sensitive Data
|
| 158 |
+
|
| 159 |
+
```python
|
| 160 |
+
# ❌ WRONG - Exposing sensitive data
|
| 161 |
+
except InvalidKeyError as e:
|
| 162 |
+
logger.error(f"Invalid key: {api_key}")
|
| 163 |
+
return {"error": f"Invalid key: {api_key}"}
|
| 164 |
+
|
| 165 |
+
# ✅ RIGHT - Safe error handling
|
| 166 |
+
except InvalidKeyError as e:
|
| 167 |
+
logger.error("Invalid key used", service="service_name")
|
| 168 |
+
return {"error": "Authentication failed"}
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
### 3. Proper Error Propagation
|
| 172 |
+
|
| 173 |
+
```python
|
| 174 |
+
# ❌ WRONG - Swallowing errors
|
| 175 |
+
try:
|
| 176 |
+
key = key_manager.get_key("service")
|
| 177 |
+
except SecurityError:
|
| 178 |
+
pass # Don't do this!
|
| 179 |
+
|
| 180 |
+
# ✅ RIGHT - Proper propagation
|
| 181 |
+
try:
|
| 182 |
+
key = key_manager.get_key("service")
|
| 183 |
+
except SecurityError as e:
|
| 184 |
+
logger.error("Security error", error=str(e))
|
| 185 |
+
raise # Re-raise or raise appropriate HTTP error
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
### 4. Context Preservation
|
| 189 |
+
|
| 190 |
+
```python
|
| 191 |
+
# ❌ WRONG - Lost context
|
| 192 |
+
try:
|
| 193 |
+
key = key_manager.get_key("service")
|
| 194 |
+
except SecurityError as e:
|
| 195 |
+
raise RuntimeError("Error occurred")
|
| 196 |
+
|
| 197 |
+
# ✅ RIGHT - Preserve context
|
| 198 |
+
try:
|
| 199 |
+
key = key_manager.get_key("service")
|
| 200 |
+
except SecurityError as e:
|
| 201 |
+
logger.error("Security error",
|
| 202 |
+
service="service",
|
| 203 |
+
error_code=e.code,
|
| 204 |
+
error_type=type(e).__name__
|
| 205 |
+
)
|
| 206 |
+
raise HTTPError(500, str(e)) from e
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
## Common Error Scenarios
|
| 210 |
+
|
| 211 |
+
### Authentication Failures
|
| 212 |
+
|
| 213 |
+
```python
|
| 214 |
+
from tools.errors import AuthenticationError, InvalidKeyError, KeyExpiredError
|
| 215 |
+
|
| 216 |
+
def authenticate_request(service_name: str, api_key: str) -> bool:
|
| 217 |
+
try:
|
| 218 |
+
# Validate key
|
| 219 |
+
key = key_manager.get_key(service_name)
|
| 220 |
+
if not key or not key.is_valid:
|
| 221 |
+
raise InvalidKeyError("Invalid key")
|
| 222 |
+
|
| 223 |
+
# Verify key value
|
| 224 |
+
if key.key.get_secret_value() != api_key:
|
| 225 |
+
raise AuthenticationError("Invalid credentials")
|
| 226 |
+
|
| 227 |
+
return True
|
| 228 |
+
|
| 229 |
+
except InvalidKeyError:
|
| 230 |
+
logger.error("Invalid API key", service=service_name)
|
| 231 |
+
raise HTTPError(401, "Authentication failed")
|
| 232 |
+
|
| 233 |
+
except KeyExpiredError:
|
| 234 |
+
logger.error("Expired API key", service=service_name)
|
| 235 |
+
raise HTTPError(401, "Authentication failed")
|
| 236 |
+
|
| 237 |
+
except AuthenticationError:
|
| 238 |
+
logger.error("Authentication failed", service=service_name)
|
| 239 |
+
raise HTTPError(401, "Authentication failed")
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
### Rate Limiting
|
| 243 |
+
|
| 244 |
+
```python
|
| 245 |
+
async def rate_limited_request(service_name: str):
|
| 246 |
+
try:
|
| 247 |
+
# Check rate limit
|
| 248 |
+
wait_time = await key_manager.check_rate_limit(service_name)
|
| 249 |
+
if wait_time > 0:
|
| 250 |
+
# Implement backoff strategy
|
| 251 |
+
await asyncio.sleep(wait_time)
|
| 252 |
+
|
| 253 |
+
# Make request
|
| 254 |
+
return await make_request()
|
| 255 |
+
|
| 256 |
+
except RateLimitError as e:
|
| 257 |
+
logger.warning("Rate limit exceeded",
|
| 258 |
+
service=service_name,
|
| 259 |
+
wait_time=e.wait_time
|
| 260 |
+
)
|
| 261 |
+
raise HTTPError(
|
| 262 |
+
429,
|
| 263 |
+
f"Too many requests. Try again in {e.wait_time} seconds"
|
| 264 |
+
)
|
| 265 |
+
```
|
| 266 |
+
|
| 267 |
+
### Configuration Issues
|
| 268 |
+
|
| 269 |
+
```python
|
| 270 |
+
def setup_key_manager() -> KeyManager:
|
| 271 |
+
try:
|
| 272 |
+
# Load configuration
|
| 273 |
+
encryption_key = os.getenv("API_ENCRYPTION_KEY")
|
| 274 |
+
if not encryption_key:
|
| 275 |
+
raise ConfigurationError("Missing encryption key")
|
| 276 |
+
|
| 277 |
+
# Initialize manager
|
| 278 |
+
return KeyManager(
|
| 279 |
+
keys_file="keys.json",
|
| 280 |
+
encryption_key=encryption_key
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
except ConfigurationError as e:
|
| 284 |
+
logger.error("Configuration error",
|
| 285 |
+
error=str(e),
|
| 286 |
+
code=e.code
|
| 287 |
+
)
|
| 288 |
+
raise SystemExit("Failed to initialize key manager")
|
| 289 |
+
|
| 290 |
+
except EncryptionError as e:
|
| 291 |
+
logger.error("Encryption error",
|
| 292 |
+
error=str(e),
|
| 293 |
+
code=e.code
|
| 294 |
+
)
|
| 295 |
+
raise SystemExit("Failed to initialize encryption")
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
## Logging Best Practices
|
| 299 |
+
|
| 300 |
+
### 1. Use Structured Logging
|
| 301 |
+
|
| 302 |
+
```python
|
| 303 |
+
import structlog
|
| 304 |
+
|
| 305 |
+
logger = structlog.get_logger()
|
| 306 |
+
|
| 307 |
+
try:
|
| 308 |
+
key = key_manager.get_key("service")
|
| 309 |
+
except SecurityError as e:
|
| 310 |
+
logger.error("security_error",
|
| 311 |
+
service="service",
|
| 312 |
+
error_code=e.code,
|
| 313 |
+
error_type=type(e).__name__,
|
| 314 |
+
error_message=str(e)
|
| 315 |
+
)
|
| 316 |
+
```
|
| 317 |
+
|
| 318 |
+
### 2. Appropriate Log Levels
|
| 319 |
+
|
| 320 |
+
```python
|
| 321 |
+
# Error - Security incidents
|
| 322 |
+
logger.error("security_violation",
|
| 323 |
+
service="service",
|
| 324 |
+
error_code="AUTH_ERROR"
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# Warning - Rate limits, temporary issues
|
| 328 |
+
logger.warning("rate_limit_exceeded",
|
| 329 |
+
service="service",
|
| 330 |
+
wait_time=1.5
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
# Info - Normal security events
|
| 334 |
+
logger.info("key_rotated",
|
| 335 |
+
service="service",
|
| 336 |
+
expires_in_days=90
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# Debug - Detailed security info
|
| 340 |
+
logger.debug("rate_limit_check",
|
| 341 |
+
service="service",
|
| 342 |
+
current_rate=5.0,
|
| 343 |
+
limit=10.0
|
| 344 |
+
)
|
| 345 |
+
```
|
| 346 |
+
|
| 347 |
+
### 3. Context Preservation
|
| 348 |
+
|
| 349 |
+
```python
|
| 350 |
+
# Add context to all logs
|
| 351 |
+
logger = logger.bind(
|
| 352 |
+
service="my_service",
|
| 353 |
+
environment="production"
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
try:
|
| 357 |
+
key = key_manager.get_key("service")
|
| 358 |
+
except SecurityError as e:
|
| 359 |
+
logger.error("security_error",
|
| 360 |
+
error_code=e.code,
|
| 361 |
+
error_message=str(e)
|
| 362 |
+
)
|
| 363 |
+
```
|
| 364 |
+
|
| 365 |
+
## Error Recovery
|
| 366 |
+
|
| 367 |
+
### 1. Automatic Retry
|
| 368 |
+
|
| 369 |
+
```python
|
| 370 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 371 |
+
|
| 372 |
+
@retry(
|
| 373 |
+
stop=stop_after_attempt(3),
|
| 374 |
+
wait=wait_exponential(multiplier=1, min=4, max=10),
|
| 375 |
+
retry=retry_if_exception_type(RateLimitError)
|
| 376 |
+
)
|
| 377 |
+
async def make_rate_limited_request(service_name: str):
|
| 378 |
+
wait_time = await key_manager.check_rate_limit(service_name)
|
| 379 |
+
if wait_time > 0:
|
| 380 |
+
await asyncio.sleep(wait_time)
|
| 381 |
+
return await make_request()
|
| 382 |
+
```
|
| 383 |
+
|
| 384 |
+
### 2. Fallback Strategies
|
| 385 |
+
|
| 386 |
+
```python
|
| 387 |
+
def get_api_key(service_name: str) -> Optional[str]:
|
| 388 |
+
try:
|
| 389 |
+
# Try primary key
|
| 390 |
+
key = key_manager.get_key(service_name)
|
| 391 |
+
if key and key.is_valid:
|
| 392 |
+
return key.key.get_secret_value()
|
| 393 |
+
|
| 394 |
+
# Try fallback key
|
| 395 |
+
fallback_key = key_manager.get_key(f"{service_name}_fallback")
|
| 396 |
+
if fallback_key and fallback_key.is_valid:
|
| 397 |
+
return fallback_key.key.get_secret_value()
|
| 398 |
+
|
| 399 |
+
return None
|
| 400 |
+
|
| 401 |
+
except SecurityError as e:
|
| 402 |
+
logger.error("Failed to get API key",
|
| 403 |
+
service=service_name,
|
| 404 |
+
error=str(e)
|
| 405 |
+
)
|
| 406 |
+
return None
|
| 407 |
+
```
|
| 408 |
+
|
| 409 |
+
### 3. Circuit Breaker
|
| 410 |
+
|
| 411 |
+
```python
|
| 412 |
+
from circuitbreaker import circuit
|
| 413 |
+
|
| 414 |
+
@circuit(
|
| 415 |
+
failure_threshold=5,
|
| 416 |
+
recovery_timeout=60,
|
| 417 |
+
expected_exception=SecurityError
|
| 418 |
+
)
|
| 419 |
+
async def make_secure_request(service_name: str):
|
| 420 |
+
key = key_manager.get_key(service_name)
|
| 421 |
+
if not key or not key.is_valid:
|
| 422 |
+
raise InvalidKeyError("Invalid key")
|
| 423 |
+
|
| 424 |
+
wait_time = await key_manager.check_rate_limit(service_name)
|
| 425 |
+
if wait_time > 0:
|
| 426 |
+
await asyncio.sleep(wait_time)
|
| 427 |
+
|
| 428 |
+
return await make_request(key.key.get_secret_value())
|
| 429 |
+
```
|
| 430 |
+
|
| 431 |
+
For more information, see:
|
| 432 |
+
- [API Documentation](./api.md)
|
| 433 |
+
- [Security Guide](../SECURITY.md)
|
| 434 |
+
- [Monitoring Guide](./monitoring.md)
|
docs/getting_started.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Getting Started
|
| 2 |
+
|
| 3 |
+
## Installation
|
| 4 |
+
|
| 5 |
+
Install the AI Assistant framework using pip:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
pip install ai-assistant
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
## Quick Start
|
| 12 |
+
|
| 13 |
+
### 1. Basic Tool Usage
|
| 14 |
+
|
| 15 |
+
```python
|
| 16 |
+
from tools import get_tool
|
| 17 |
+
|
| 18 |
+
# Get a tool instance
|
| 19 |
+
timezone_tool = get_tool("timezone")
|
| 20 |
+
|
| 21 |
+
# Use the tool
|
| 22 |
+
current_time = timezone_tool.get_current_time("America/New_York")
|
| 23 |
+
print(f"Current time: {current_time}")
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
### 2. Creating a Custom Tool
|
| 27 |
+
|
| 28 |
+
```python
|
| 29 |
+
from tools import BaseTool, register_tool
|
| 30 |
+
|
| 31 |
+
@register_tool
|
| 32 |
+
class MyCustomTool(BaseTool):
|
| 33 |
+
@property
|
| 34 |
+
def name(self) -> str:
|
| 35 |
+
return "my_custom_tool"
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def description(self) -> str:
|
| 39 |
+
return "Description of what my tool does"
|
| 40 |
+
|
| 41 |
+
def my_method(self) -> str:
|
| 42 |
+
return "Hello from my custom tool!"
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## Core Concepts
|
| 46 |
+
|
| 47 |
+
### 1. Tool Registry
|
| 48 |
+
|
| 49 |
+
The tool registry manages all available tools:
|
| 50 |
+
|
| 51 |
+
```python
|
| 52 |
+
from tools import ToolRegistry, get_all_tools
|
| 53 |
+
|
| 54 |
+
# Get all registered tools
|
| 55 |
+
tools = get_all_tools()
|
| 56 |
+
|
| 57 |
+
# Get a specific tool
|
| 58 |
+
weather_tool = tools["weather"]
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### 2. API Integration
|
| 62 |
+
|
| 63 |
+
Example of making an API call with error handling:
|
| 64 |
+
|
| 65 |
+
```python
|
| 66 |
+
from tools.api_logging import log_api_call
|
| 67 |
+
from tools.error_recovery import RetryConfig, CircuitBreaker
|
| 68 |
+
|
| 69 |
+
@log_api_call(
|
| 70 |
+
log_response=True,
|
| 71 |
+
retry_config=RetryConfig(max_retries=3),
|
| 72 |
+
circuit_breaker=CircuitBreaker(failure_threshold=5)
|
| 73 |
+
)
|
| 74 |
+
def get_weather(zipcode: str) -> dict:
|
| 75 |
+
response = requests.get(
|
| 76 |
+
f"https://api.weather.com/v1/{zipcode}",
|
| 77 |
+
headers={"Authorization": "Bearer YOUR_API_KEY"}
|
| 78 |
+
)
|
| 79 |
+
response.raise_for_status()
|
| 80 |
+
return response.json()
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### 3. Error Handling
|
| 84 |
+
|
| 85 |
+
Example of handling API errors:
|
| 86 |
+
|
| 87 |
+
```python
|
| 88 |
+
from tools.exceptions import APIError, APITimeoutError, APIRateLimitError
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
result = get_weather("12345")
|
| 92 |
+
except APITimeoutError as e:
|
| 93 |
+
print(f"Request timed out after {e.timeout} seconds")
|
| 94 |
+
except APIRateLimitError as e:
|
| 95 |
+
print(f"Rate limited, retry after {e.retry_after} seconds")
|
| 96 |
+
except APIError as e:
|
| 97 |
+
print(f"API error: {e.message}")
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
## Common Use Cases
|
| 101 |
+
|
| 102 |
+
### 1. Weather Information
|
| 103 |
+
|
| 104 |
+
```python
|
| 105 |
+
from tools.weather import WeatherTool
|
| 106 |
+
|
| 107 |
+
weather = WeatherTool()
|
| 108 |
+
forecast = weather("90210") # Get weather for Beverly Hills
|
| 109 |
+
print(forecast)
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
### 2. Timezone Conversion
|
| 113 |
+
|
| 114 |
+
```python
|
| 115 |
+
from tools import TimezoneTools
|
| 116 |
+
|
| 117 |
+
tz = TimezoneTools()
|
| 118 |
+
|
| 119 |
+
# Get current time in different timezones
|
| 120 |
+
nyc_time = tz.get_current_time("America/New_York")
|
| 121 |
+
tokyo_time = tz.get_current_time("Asia/Tokyo")
|
| 122 |
+
|
| 123 |
+
# List available timezones
|
| 124 |
+
timezones = tz.list_common_timezones()
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
## Configuration
|
| 128 |
+
|
| 129 |
+
### 1. Logging Setup
|
| 130 |
+
|
| 131 |
+
```python
|
| 132 |
+
import logging
|
| 133 |
+
|
| 134 |
+
# Configure logging
|
| 135 |
+
logging.basicConfig(
|
| 136 |
+
level=logging.INFO,
|
| 137 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 138 |
+
)
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
### 2. API Configuration
|
| 142 |
+
|
| 143 |
+
```python
|
| 144 |
+
import os
|
| 145 |
+
|
| 146 |
+
# Set API keys in environment variables
|
| 147 |
+
os.environ['WEATHER_API_KEY'] = 'your_api_key_here'
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
## Best Practices
|
| 151 |
+
|
| 152 |
+
1. **Use Environment Variables for Secrets**
|
| 153 |
+
```python
|
| 154 |
+
# Good
|
| 155 |
+
api_key = os.getenv('API_KEY')
|
| 156 |
+
|
| 157 |
+
# Avoid
|
| 158 |
+
api_key = 'hardcoded_secret'
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
2. **Enable Appropriate Logging**
|
| 162 |
+
```python
|
| 163 |
+
# Development
|
| 164 |
+
@log_api_call(log_response=True, log_request_body=True)
|
| 165 |
+
|
| 166 |
+
# Production
|
| 167 |
+
@log_api_call(log_response=False, log_request_body=False)
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
3. **Handle Errors Gracefully**
|
| 171 |
+
```python
|
| 172 |
+
try:
|
| 173 |
+
result = api_call()
|
| 174 |
+
except APIError as e:
|
| 175 |
+
logger.error(f"API error: {e.message}")
|
| 176 |
+
# Implement fallback behavior
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
4. **Use Type Hints**
|
| 180 |
+
```python
|
| 181 |
+
def process_data(data: Dict[str, Any]) -> List[str]:
|
| 182 |
+
return [item['name'] for item in data['items']]
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
5. **Document Your Tools**
|
| 186 |
+
```python
|
| 187 |
+
class MyTool(BaseTool):
|
| 188 |
+
"""
|
| 189 |
+
My custom tool for processing data.
|
| 190 |
+
|
| 191 |
+
Attributes:
|
| 192 |
+
name: The unique identifier for this tool
|
| 193 |
+
description: What this tool does
|
| 194 |
+
"""
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
## Next Steps
|
| 198 |
+
|
| 199 |
+
1. Read the [API Reference](api_reference.md) for detailed documentation
|
| 200 |
+
2. Learn about [Error Handling](error_handling.md)
|
| 201 |
+
3. Explore the [Logging System](logging.md)
|
| 202 |
+
4. Check out example implementations in the `examples/` directory
|
docs/logging.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logging System Documentation
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
The AI Assistant framework provides a comprehensive logging system focused on API interactions and error handling. The system includes:
|
| 6 |
+
- Request/response logging
|
| 7 |
+
- Error tracking
|
| 8 |
+
- Sensitive data sanitization
|
| 9 |
+
- Integration with error recovery mechanisms
|
| 10 |
+
|
| 11 |
+
## API Call Logging
|
| 12 |
+
|
| 13 |
+
### Basic Usage
|
| 14 |
+
|
| 15 |
+
```python
|
| 16 |
+
from tools.api_logging import log_api_call
|
| 17 |
+
|
| 18 |
+
@log_api_call()
|
| 19 |
+
def my_api_function(url: str, data: dict):
|
| 20 |
+
# Function implementation
|
| 21 |
+
pass
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
### Configuration Options
|
| 25 |
+
|
| 26 |
+
```python
|
| 27 |
+
@log_api_call(
|
| 28 |
+
log_response=True, # Log response bodies
|
| 29 |
+
log_request_body=True, # Log request bodies
|
| 30 |
+
timeout=5.0, # Request timeout in seconds
|
| 31 |
+
retry_config=RetryConfig(...),
|
| 32 |
+
circuit_breaker=CircuitBreaker(...)
|
| 33 |
+
)
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## Security Features
|
| 37 |
+
|
| 38 |
+
### Header Sanitization
|
| 39 |
+
|
| 40 |
+
The system automatically sanitizes sensitive information in headers:
|
| 41 |
+
|
| 42 |
+
```python
|
| 43 |
+
# Original headers
|
| 44 |
+
headers = {
|
| 45 |
+
'Authorization': 'Bearer abc123',
|
| 46 |
+
'X-API-Key': 'secret_key',
|
| 47 |
+
'Content-Type': 'application/json'
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
# Logged headers
|
| 51 |
+
{
|
| 52 |
+
'Authorization': '****',
|
| 53 |
+
'X-API-Key': '****',
|
| 54 |
+
'Content-Type': 'application/json'
|
| 55 |
+
}
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
### URL Sanitization
|
| 59 |
+
|
| 60 |
+
URLs are sanitized to remove sensitive parameters:
|
| 61 |
+
|
| 62 |
+
```python
|
| 63 |
+
# Original URL
|
| 64 |
+
"https://api.example.com/v1/data?api_key=secret&user=john"
|
| 65 |
+
|
| 66 |
+
# Logged URL
|
| 67 |
+
"https://api.example.com/v1/data?api_key=****&user=john"
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
## Log Format
|
| 71 |
+
|
| 72 |
+
### Request Logging
|
| 73 |
+
|
| 74 |
+
```json
|
| 75 |
+
{
|
| 76 |
+
"timestamp": "2024-01-01T12:00:00Z",
|
| 77 |
+
"function": "get_weather",
|
| 78 |
+
"url": "https://api.example.com/v1/weather",
|
| 79 |
+
"headers": {
|
| 80 |
+
"Authorization": "****",
|
| 81 |
+
"Content-Type": "application/json"
|
| 82 |
+
},
|
| 83 |
+
"body": {
|
| 84 |
+
"location": "New York"
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### Response Logging
|
| 90 |
+
|
| 91 |
+
```json
|
| 92 |
+
{
|
| 93 |
+
"timestamp": "2024-01-01T12:00:01Z",
|
| 94 |
+
"status": 200,
|
| 95 |
+
"url": "https://api.example.com/v1/weather",
|
| 96 |
+
"body": {
|
| 97 |
+
"temperature": 72,
|
| 98 |
+
"condition": "sunny"
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
### Error Logging
|
| 104 |
+
|
| 105 |
+
```json
|
| 106 |
+
{
|
| 107 |
+
"timestamp": "2024-01-01T12:00:01Z",
|
| 108 |
+
"error_type": "APITimeoutError",
|
| 109 |
+
"error_message": "Request timed out after 5 seconds",
|
| 110 |
+
"url": "https://api.example.com/v1/weather"
|
| 111 |
+
}
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
## Integration with Error Recovery
|
| 115 |
+
|
| 116 |
+
The logging system integrates with the error recovery mechanisms:
|
| 117 |
+
|
| 118 |
+
```python
|
| 119 |
+
@log_api_call(
|
| 120 |
+
retry_config=RetryConfig(max_retries=3),
|
| 121 |
+
circuit_breaker=CircuitBreaker(failure_threshold=5)
|
| 122 |
+
)
|
| 123 |
+
def api_call():
|
| 124 |
+
# Each retry attempt is logged
|
| 125 |
+
# Circuit breaker state changes are logged
|
| 126 |
+
pass
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
Example retry log:
|
| 130 |
+
```json
|
| 131 |
+
{
|
| 132 |
+
"timestamp": "2024-01-01T12:00:01Z",
|
| 133 |
+
"message": "Retry attempt 1 of 3",
|
| 134 |
+
"delay": 1.0,
|
| 135 |
+
"error": "Connection refused"
|
| 136 |
+
}
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
## Best Practices
|
| 140 |
+
|
| 141 |
+
1. **Enable Appropriate Logging Levels**
|
| 142 |
+
```python
|
| 143 |
+
# Good - Log both request and response for debugging
|
| 144 |
+
@log_api_call(log_response=True, log_request_body=True)
|
| 145 |
+
|
| 146 |
+
# Production - Log only essential info
|
| 147 |
+
@log_api_call(log_response=False, log_request_body=False)
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
2. **Handle Sensitive Data**
|
| 151 |
+
```python
|
| 152 |
+
# Good - Let the logger handle sanitization
|
| 153 |
+
headers = {'Authorization': f'Bearer {api_key}'}
|
| 154 |
+
|
| 155 |
+
# Avoid - Manual sanitization
|
| 156 |
+
headers = {'Authorization': 'Bearer ****'}
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
3. **Include Context in Logs**
|
| 160 |
+
```python
|
| 161 |
+
# Good
|
| 162 |
+
logger.info({
|
| 163 |
+
'action': 'api_call',
|
| 164 |
+
'endpoint': '/api/v1/data',
|
| 165 |
+
'status': 'success'
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
# Avoid
|
| 169 |
+
logger.info('API call successful')
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
4. **Use Structured Logging**
|
| 173 |
+
```python
|
| 174 |
+
# Good
|
| 175 |
+
logger.info(json.dumps({
|
| 176 |
+
'event': 'rate_limit',
|
| 177 |
+
'retry_after': 60,
|
| 178 |
+
'endpoint': '/api/v1/data'
|
| 179 |
+
}))
|
| 180 |
+
|
| 181 |
+
# Avoid
|
| 182 |
+
logger.info(f"Rate limited on /api/v1/data, retry after 60s")
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
5. **Log at Appropriate Levels**
|
| 186 |
+
```python
|
| 187 |
+
# Debug - Detailed information
|
| 188 |
+
logger.debug('Request parameters: %s', params)
|
| 189 |
+
|
| 190 |
+
# Info - General operational events
|
| 191 |
+
logger.info('API call completed successfully')
|
| 192 |
+
|
| 193 |
+
# Warning - Potential issues
|
| 194 |
+
logger.warning('Rate limit threshold approaching')
|
| 195 |
+
|
| 196 |
+
# Error - Error events
|
| 197 |
+
logger.error('API call failed', exc_info=True)
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
## Configuration
|
| 201 |
+
|
| 202 |
+
### Setting Up Logging
|
| 203 |
+
|
| 204 |
+
```python
|
| 205 |
+
import logging
|
| 206 |
+
|
| 207 |
+
# Configure logging
|
| 208 |
+
logging.basicConfig(
|
| 209 |
+
level=logging.INFO,
|
| 210 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Get logger
|
| 214 |
+
logger = logging.getLogger(__name__)
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
### Environment-Specific Configuration
|
| 218 |
+
|
| 219 |
+
```python
|
| 220 |
+
# Development
|
| 221 |
+
logging.basicConfig(
|
| 222 |
+
level=logging.DEBUG,
|
| 223 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Production
|
| 227 |
+
logging.basicConfig(
|
| 228 |
+
level=logging.WARNING,
|
| 229 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 230 |
+
filename='api.log'
|
| 231 |
+
)
|
docs/security_quickstart.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Quick Reference
|
| 2 |
+
|
| 3 |
+
## Quick Start
|
| 4 |
+
|
| 5 |
+
```python
|
| 6 |
+
from tools.security import key_manager
|
| 7 |
+
from datetime import datetime, timedelta, timezone
|
| 8 |
+
|
| 9 |
+
# 1. Generate secure API key
|
| 10 |
+
import secrets
|
| 11 |
+
import string
|
| 12 |
+
api_key = ''.join(secrets.choice(string.ascii_letters + string.digits + '_.-') for _ in range(32))
|
| 13 |
+
|
| 14 |
+
# 2. Add key with security settings
|
| 15 |
+
key = key_manager.add_key(
|
| 16 |
+
name="my_service",
|
| 17 |
+
key=api_key,
|
| 18 |
+
scopes=["read:data", "write:data"],
|
| 19 |
+
expires_at=datetime.now(timezone.utc) + timedelta(days=90),
|
| 20 |
+
rate_limit=10.0,
|
| 21 |
+
burst_limit=20
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# 3. Use key securely
|
| 25 |
+
try:
|
| 26 |
+
# Check rate limit
|
| 27 |
+
await key_manager.check_rate_limit("my_service")
|
| 28 |
+
|
| 29 |
+
# Use key
|
| 30 |
+
key = key_manager.get_key("my_service")
|
| 31 |
+
if key and key.is_valid:
|
| 32 |
+
api_key = key.key.get_secret_value()
|
| 33 |
+
# Use api_key for requests...
|
| 34 |
+
|
| 35 |
+
except RateLimitError as e:
|
| 36 |
+
# Handle rate limiting
|
| 37 |
+
logger.warning(f"Rate limited: {e.wait_time}s")
|
| 38 |
+
await asyncio.sleep(e.wait_time)
|
| 39 |
+
|
| 40 |
+
except (InvalidKeyError, KeyExpiredError) as e:
|
| 41 |
+
# Handle authentication errors
|
| 42 |
+
logger.error(f"Authentication failed: {e}")
|
| 43 |
+
raise HTTPError(401, "Authentication failed")
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
## Security Checklist
|
| 47 |
+
|
| 48 |
+
✅ **API Keys**
|
| 49 |
+
- [ ] 32+ character length
|
| 50 |
+
- [ ] URL-safe characters only
|
| 51 |
+
- [ ] 90-day expiration
|
| 52 |
+
- [ ] Scoped permissions
|
| 53 |
+
- [ ] Rate limits configured
|
| 54 |
+
|
| 55 |
+
✅ **Environment**
|
| 56 |
+
- [ ] `API_ENCRYPTION_KEY` set
|
| 57 |
+
- [ ] `.env` in `.gitignore`
|
| 58 |
+
- [ ] Secure file permissions
|
| 59 |
+
- [ ] Different keys per environment
|
| 60 |
+
|
| 61 |
+
✅ **Error Handling**
|
| 62 |
+
- [ ] Catch security exceptions
|
| 63 |
+
- [ ] No sensitive data in errors
|
| 64 |
+
- [ ] Proper error logging
|
| 65 |
+
- [ ] Rate limit handling
|
| 66 |
+
|
| 67 |
+
✅ **Monitoring**
|
| 68 |
+
- [ ] Security event logging
|
| 69 |
+
- [ ] Rate limit monitoring
|
| 70 |
+
- [ ] Failed auth tracking
|
| 71 |
+
- [ ] Regular log review
|
| 72 |
+
|
| 73 |
+
## Common Security Errors
|
| 74 |
+
|
| 75 |
+
```python
|
| 76 |
+
# ❌ WRONG - Insecure key generation
|
| 77 |
+
api_key = "service123" # Too short, predictable
|
| 78 |
+
|
| 79 |
+
# ✅ RIGHT - Secure key generation
|
| 80 |
+
api_key = ''.join(secrets.choice(string.ascii_letters + string.digits + '_.-') for _ in range(32))
|
| 81 |
+
|
| 82 |
+
# ❌ WRONG - No error handling
|
| 83 |
+
key = key_manager.get_key("service")
|
| 84 |
+
api_key = key.key.get_secret_value()
|
| 85 |
+
|
| 86 |
+
# ✅ RIGHT - Proper error handling
|
| 87 |
+
try:
|
| 88 |
+
key = key_manager.get_key("service")
|
| 89 |
+
if not key or not key.is_valid:
|
| 90 |
+
raise InvalidKeyError("Invalid key")
|
| 91 |
+
api_key = key.key.get_secret_value()
|
| 92 |
+
except SecurityError as e:
|
| 93 |
+
logger.error("Security error", error=str(e))
|
| 94 |
+
raise
|
| 95 |
+
|
| 96 |
+
# ❌ WRONG - Exposed sensitive data
|
| 97 |
+
except InvalidKeyError as e:
|
| 98 |
+
logger.error(f"Invalid key: {api_key}") # Don't log keys!
|
| 99 |
+
|
| 100 |
+
# ✅ RIGHT - Secure error logging
|
| 101 |
+
except InvalidKeyError as e:
|
| 102 |
+
logger.error("Invalid key used", service="my_service")
|
| 103 |
+
|
| 104 |
+
# ❌ WRONG - No rate limiting
|
| 105 |
+
while True:
|
| 106 |
+
make_api_request(key)
|
| 107 |
+
|
| 108 |
+
# ✅ RIGHT - Rate limit handling
|
| 109 |
+
try:
|
| 110 |
+
wait_time = await key_manager.check_rate_limit("service")
|
| 111 |
+
if wait_time > 0:
|
| 112 |
+
await asyncio.sleep(wait_time)
|
| 113 |
+
make_api_request(key)
|
| 114 |
+
except RateLimitError as e:
|
| 115 |
+
# Handle rate limiting
|
| 116 |
+
await asyncio.sleep(e.wait_time)
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
## Security Contacts
|
| 120 |
+
|
| 121 |
+
- Security Team: security@example.com
|
| 122 |
+
- Emergency Contact: +1-XXX-XXX-XXXX
|
| 123 |
+
- Bug Reports: https://example.com/security
|
| 124 |
+
|
| 125 |
+
## Additional Resources
|
| 126 |
+
|
| 127 |
+
- [Full Security Documentation](../SECURITY.md)
|
| 128 |
+
- [API Documentation](./api.md)
|
| 129 |
+
- [Error Handling Guide](./errors.md)
|
| 130 |
+
- [Monitoring Guide](./monitoring.md)
|
docs/user_guide.md
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Key Management System User Guide
|
| 2 |
+
|
| 3 |
+
This guide provides comprehensive documentation for using the API key management system, including examples and best practices.
|
| 4 |
+
|
| 5 |
+
## Table of Contents
|
| 6 |
+
- [Developer Setup](#developer-setup)
|
| 7 |
+
- [Command Reference](#command-reference)
|
| 8 |
+
- [Quick Start](#quick-start)
|
| 9 |
+
- [Key Management](#key-management)
|
| 10 |
+
- [Security Features](#security-features)
|
| 11 |
+
- [Rate Limiting](#rate-limiting)
|
| 12 |
+
- [Error Handling](#error-handling)
|
| 13 |
+
- [Best Practices](#best-practices)
|
| 14 |
+
- [Examples](#examples)
|
| 15 |
+
|
| 16 |
+
## Developer Setup
|
| 17 |
+
|
| 18 |
+
### Prerequisites
|
| 19 |
+
|
| 20 |
+
- Python 3.8 or higher
|
| 21 |
+
- pip (Python package installer)
|
| 22 |
+
- virtualenv (recommended)
|
| 23 |
+
|
| 24 |
+
### Installation
|
| 25 |
+
|
| 26 |
+
1. **Create and activate a virtual environment:**
|
| 27 |
+
```bash
|
| 28 |
+
python -m venv venv
|
| 29 |
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
2. **Install required packages:**
|
| 33 |
+
```bash
|
| 34 |
+
pip install -r requirements.txt
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
The `requirements.txt` should include:
|
| 38 |
+
```
|
| 39 |
+
pydantic>=2.0.0
|
| 40 |
+
cryptography>=41.0.0
|
| 41 |
+
aiohttp>=3.8.0
|
| 42 |
+
tenacity>=8.0.0
|
| 43 |
+
structlog>=23.0.0
|
| 44 |
+
cachetools>=5.0.0
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
3. **Set up environment variables:**
|
| 48 |
+
```bash
|
| 49 |
+
# Create a .env file
|
| 50 |
+
touch .env
|
| 51 |
+
|
| 52 |
+
# Add required environment variables
|
| 53 |
+
echo "API_ENCRYPTION_KEY=$(python -c 'import base64; import os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())')" >> .env
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### Configuration
|
| 57 |
+
|
| 58 |
+
1. **Basic setup:**
|
| 59 |
+
```python
|
| 60 |
+
from tools.security import KeyManager
|
| 61 |
+
|
| 62 |
+
# Initialize with default settings
|
| 63 |
+
key_manager = KeyManager()
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
2. **Custom configuration:**
|
| 67 |
+
```python
|
| 68 |
+
key_manager = KeyManager(
|
| 69 |
+
keys_file="custom_keys.json", # Custom storage location
|
| 70 |
+
env_prefix="MYAPP", # Custom environment prefix
|
| 71 |
+
encryption_key="your-secret-key" # Custom encryption key
|
| 72 |
+
)
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### Development Tools
|
| 76 |
+
|
| 77 |
+
1. **Install development dependencies:**
|
| 78 |
+
```bash
|
| 79 |
+
pip install -r requirements-dev.txt
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
The `requirements-dev.txt` should include:
|
| 83 |
+
```
|
| 84 |
+
pytest>=7.0.0
|
| 85 |
+
pytest-asyncio>=0.21.0
|
| 86 |
+
pytest-cov>=4.0.0
|
| 87 |
+
black>=23.0.0
|
| 88 |
+
isort>=5.0.0
|
| 89 |
+
mypy>=1.0.0
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
2. **Run tests:**
|
| 93 |
+
```bash
|
| 94 |
+
# Run all tests
|
| 95 |
+
pytest
|
| 96 |
+
|
| 97 |
+
# Run with coverage
|
| 98 |
+
pytest --cov=tools
|
| 99 |
+
|
| 100 |
+
# Run specific test file
|
| 101 |
+
pytest tests/test_security.py
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
3. **Code formatting:**
|
| 105 |
+
```bash
|
| 106 |
+
# Format code
|
| 107 |
+
black .
|
| 108 |
+
|
| 109 |
+
# Sort imports
|
| 110 |
+
isort .
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
4. **Type checking:**
|
| 114 |
+
```bash
|
| 115 |
+
mypy tools/
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### Project Structure
|
| 119 |
+
|
| 120 |
+
```
|
| 121 |
+
.
|
| 122 |
+
├── tools/
|
| 123 |
+
│ ├── __init__.py
|
| 124 |
+
│ ├── security.py # Key management implementation
|
| 125 |
+
│ ├── errors.py # Custom exceptions
|
| 126 |
+
│ ├── validation.py # Input validation
|
| 127 |
+
│ ├── api_optimization.py # Rate limiting and optimization
|
| 128 |
+
│ └── monitoring.py # Performance monitoring
|
| 129 |
+
├── tests/
|
| 130 |
+
│ ├── __init__.py
|
| 131 |
+
│ ├── test_security.py
|
| 132 |
+
│ ├── test_validation.py
|
| 133 |
+
│ └── test_errors.py
|
| 134 |
+
├── docs/
|
| 135 |
+
│ ├── api.md
|
| 136 |
+
│ ├── security_quickstart.md
|
| 137 |
+
│ ├── errors.md
|
| 138 |
+
│ └── user_guide.md
|
| 139 |
+
├── requirements.txt
|
| 140 |
+
├── requirements-dev.txt
|
| 141 |
+
└── README.md
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
### Debugging
|
| 145 |
+
|
| 146 |
+
1. **Enable debug logging:**
|
| 147 |
+
```python
|
| 148 |
+
import logging
|
| 149 |
+
logging.basicConfig(level=logging.DEBUG)
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
2. **Monitor rate limiting:**
|
| 153 |
+
```python
|
| 154 |
+
async def monitor_rate_limits():
|
| 155 |
+
metrics = await key_manager.get_metrics()
|
| 156 |
+
print(f"Rate limit delays: {metrics['rate_limiting']['total_delays']}s")
|
| 157 |
+
print(f"Rate limit count: {metrics['rate_limiting']['count']}")
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
3. **Test key validation:**
|
| 161 |
+
```python
|
| 162 |
+
from tools.validation import validate_api_key
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
validate_api_key("test-key")
|
| 166 |
+
except ValidationError as e:
|
| 167 |
+
print(f"Validation error: {e.message}")
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
### Common Issues
|
| 171 |
+
|
| 172 |
+
1. **Import Errors**
|
| 173 |
+
- Ensure your PYTHONPATH includes the project root
|
| 174 |
+
- Check that all dependencies are installed
|
| 175 |
+
- Verify virtual environment is activated
|
| 176 |
+
|
| 177 |
+
2. **Encryption Errors**
|
| 178 |
+
- Verify encryption key is properly formatted (base64)
|
| 179 |
+
- Check environment variable is set correctly
|
| 180 |
+
- Ensure key file permissions are correct
|
| 181 |
+
|
| 182 |
+
3. **Rate Limiting Issues**
|
| 183 |
+
- Monitor rate limit metrics
|
| 184 |
+
- Check burst limit configuration
|
| 185 |
+
- Verify clock synchronization
|
| 186 |
+
|
| 187 |
+
4. **Performance Issues**
|
| 188 |
+
- Enable performance monitoring
|
| 189 |
+
- Check connection pool settings
|
| 190 |
+
- Monitor batch processing metrics
|
| 191 |
+
|
| 192 |
+
### Contributing
|
| 193 |
+
|
| 194 |
+
1. **Fork and clone the repository**
|
| 195 |
+
```bash
|
| 196 |
+
git clone https://github.com/yourusername/api-key-manager.git
|
| 197 |
+
cd api-key-manager
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
2. **Create a feature branch**
|
| 201 |
+
```bash
|
| 202 |
+
git checkout -b feature/your-feature-name
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
3. **Make changes and test**
|
| 206 |
+
```bash
|
| 207 |
+
# Run tests
|
| 208 |
+
pytest
|
| 209 |
+
|
| 210 |
+
# Check code style
|
| 211 |
+
black .
|
| 212 |
+
isort .
|
| 213 |
+
mypy tools/
|
| 214 |
+
|
| 215 |
+
# Run full test suite with coverage
|
| 216 |
+
pytest --cov=tools --cov-report=html
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
4. **Submit a pull request**
|
| 220 |
+
- Include test coverage
|
| 221 |
+
- Update documentation
|
| 222 |
+
- Follow code style guidelines
|
| 223 |
+
|
| 224 |
+
## Command Reference
|
| 225 |
+
|
| 226 |
+
### Key Management Commands
|
| 227 |
+
|
| 228 |
+
#### Initialize Key Manager
|
| 229 |
+
```python
|
| 230 |
+
KeyManager(
|
| 231 |
+
keys_file: str = "keys.json",
|
| 232 |
+
env_prefix: str = "API",
|
| 233 |
+
encryption_key: Optional[str] = None
|
| 234 |
+
) -> KeyManager
|
| 235 |
+
```
|
| 236 |
+
Initialize a new key manager instance.
|
| 237 |
+
- `keys_file`: Path to the key storage file (default: "keys.json")
|
| 238 |
+
- `env_prefix`: Prefix for environment variables (default: "API")
|
| 239 |
+
- `encryption_key`: Optional encryption key for storing keys
|
| 240 |
+
|
| 241 |
+
#### Add Key
|
| 242 |
+
```python
|
| 243 |
+
key_manager.add_key(
|
| 244 |
+
name: str,
|
| 245 |
+
key: str,
|
| 246 |
+
scopes: Optional[List[str]] = None,
|
| 247 |
+
expires_at: Optional[datetime] = None,
|
| 248 |
+
rate_limit: float = 10.0,
|
| 249 |
+
burst_limit: int = 20
|
| 250 |
+
) -> APIKeyConfig
|
| 251 |
+
```
|
| 252 |
+
Add a new API key to the manager.
|
| 253 |
+
- `name`: Unique identifier for the key
|
| 254 |
+
- `key`: The API key value
|
| 255 |
+
- `scopes`: List of access scopes
|
| 256 |
+
- `expires_at`: Optional expiration date
|
| 257 |
+
- `rate_limit`: Requests per second (default: 10.0)
|
| 258 |
+
- `burst_limit`: Maximum burst size (default: 20)
|
| 259 |
+
|
| 260 |
+
#### Get Key
|
| 261 |
+
```python
|
| 262 |
+
key_manager.get_key(name: str) -> Optional[APIKeyConfig]
|
| 263 |
+
```
|
| 264 |
+
Retrieve an API key by name.
|
| 265 |
+
- `name`: Key identifier
|
| 266 |
+
- Returns: Key configuration or None if not found
|
| 267 |
+
|
| 268 |
+
#### Rotate Key
|
| 269 |
+
```python
|
| 270 |
+
key_manager.rotate_key(
|
| 271 |
+
name: str,
|
| 272 |
+
new_key: str,
|
| 273 |
+
expires_at: Optional[datetime] = None,
|
| 274 |
+
rate_limit: Optional[float] = None,
|
| 275 |
+
burst_limit: Optional[int] = None
|
| 276 |
+
) -> APIKeyConfig
|
| 277 |
+
```
|
| 278 |
+
Rotate an existing API key.
|
| 279 |
+
- `name`: Key identifier
|
| 280 |
+
- `new_key`: New API key value
|
| 281 |
+
- `expires_at`: Optional new expiration date
|
| 282 |
+
- `rate_limit`: Optional new rate limit
|
| 283 |
+
- `burst_limit`: Optional new burst limit
|
| 284 |
+
|
| 285 |
+
#### Remove Key
|
| 286 |
+
```python
|
| 287 |
+
key_manager.remove_key(name: str) -> None
|
| 288 |
+
```
|
| 289 |
+
Remove an API key.
|
| 290 |
+
- `name`: Key identifier to remove
|
| 291 |
+
|
| 292 |
+
#### List Keys
|
| 293 |
+
```python
|
| 294 |
+
key_manager.list_keys() -> Dict[str, APIKeyConfig]
|
| 295 |
+
```
|
| 296 |
+
List all valid API keys.
|
| 297 |
+
- Returns: Dictionary of key names to configurations
|
| 298 |
+
|
| 299 |
+
### Rate Limiting Commands
|
| 300 |
+
|
| 301 |
+
#### Check Rate Limit
|
| 302 |
+
```python
|
| 303 |
+
await key_manager.check_rate_limit(name: str) -> float
|
| 304 |
+
```
|
| 305 |
+
Check if a key has exceeded its rate limit.
|
| 306 |
+
- `name`: Key identifier
|
| 307 |
+
- Returns: Wait time in seconds (0.0 if not rate limited)
|
| 308 |
+
|
| 309 |
+
#### Rate Limiter Configuration
|
| 310 |
+
```python
|
| 311 |
+
RateLimiter(
|
| 312 |
+
rate: float,
|
| 313 |
+
burst: int
|
| 314 |
+
) -> RateLimiter
|
| 315 |
+
```
|
| 316 |
+
Create a new rate limiter instance.
|
| 317 |
+
- `rate`: Tokens (requests) per second
|
| 318 |
+
- `burst`: Maximum burst size
|
| 319 |
+
|
| 320 |
+
#### Acquire Rate Limit Token
|
| 321 |
+
```python
|
| 322 |
+
await rate_limiter.acquire(tokens: float = 1.0) -> float
|
| 323 |
+
```
|
| 324 |
+
Acquire tokens from the rate limiter.
|
| 325 |
+
- `tokens`: Number of tokens to acquire (default: 1.0)
|
| 326 |
+
- Returns: Wait time in seconds
|
| 327 |
+
|
| 328 |
+
### Batch Processing Commands
|
| 329 |
+
|
| 330 |
+
#### Initialize Batch Processor
|
| 331 |
+
```python
|
| 332 |
+
BatchProcessor(
|
| 333 |
+
batch_size: int = 100,
|
| 334 |
+
flush_interval: float = 1.0
|
| 335 |
+
) -> BatchProcessor
|
| 336 |
+
```
|
| 337 |
+
Create a new batch processor.
|
| 338 |
+
- `batch_size`: Maximum items per batch
|
| 339 |
+
- `flush_interval`: Time between automatic flushes
|
| 340 |
+
|
| 341 |
+
#### Add to Batch
|
| 342 |
+
```python
|
| 343 |
+
await batch_processor.add(item: Any) -> None
|
| 344 |
+
```
|
| 345 |
+
Add an item to the batch.
|
| 346 |
+
- `item`: Item to add to batch
|
| 347 |
+
|
| 348 |
+
#### Flush Batch
|
| 349 |
+
```python
|
| 350 |
+
await batch_processor.flush() -> None
|
| 351 |
+
```
|
| 352 |
+
Process current batch immediately.
|
| 353 |
+
|
| 354 |
+
### Performance Monitoring Commands
|
| 355 |
+
|
| 356 |
+
#### Initialize Monitor
|
| 357 |
+
```python
|
| 358 |
+
PerformanceMonitor(
|
| 359 |
+
metrics_ttl: int = 3600,
|
| 360 |
+
max_metrics: int = 1000
|
| 361 |
+
) -> PerformanceMonitor
|
| 362 |
+
```
|
| 363 |
+
Create a new performance monitor.
|
| 364 |
+
- `metrics_ttl`: Time to live for metrics in seconds
|
| 365 |
+
- `max_metrics`: Maximum number of metrics to store
|
| 366 |
+
|
| 367 |
+
#### Start Request Monitoring
|
| 368 |
+
```python
|
| 369 |
+
await monitor.start_request(endpoint: str) -> float
|
| 370 |
+
```
|
| 371 |
+
Start timing a request.
|
| 372 |
+
- `endpoint`: API endpoint being called
|
| 373 |
+
- Returns: Start timestamp
|
| 374 |
+
|
| 375 |
+
#### End Request Monitoring
|
| 376 |
+
```python
|
| 377 |
+
await monitor.end_request(
|
| 378 |
+
endpoint: str,
|
| 379 |
+
start_time: float
|
| 380 |
+
) -> None
|
| 381 |
+
```
|
| 382 |
+
Record the end of a request.
|
| 383 |
+
- `endpoint`: API endpoint called
|
| 384 |
+
- `start_time`: Start timestamp from start_request()
|
| 385 |
+
|
| 386 |
+
#### Get Metrics
|
| 387 |
+
```python
|
| 388 |
+
monitor.get_metrics() -> Dict[str, Any]
|
| 389 |
+
```
|
| 390 |
+
Get comprehensive metrics report.
|
| 391 |
+
- Returns: Dictionary of all collected metrics
|
| 392 |
+
|
| 393 |
+
#### Log Metrics
|
| 394 |
+
```python
|
| 395 |
+
await monitor.log_metrics(logger: Any) -> None
|
| 396 |
+
```
|
| 397 |
+
Log current metrics using structlog.
|
| 398 |
+
- `logger`: Structlog logger instance
|
| 399 |
+
|
| 400 |
+
### API Client Commands
|
| 401 |
+
|
| 402 |
+
#### Initialize API Client
|
| 403 |
+
```python
|
| 404 |
+
APIClient(
|
| 405 |
+
base_url: str,
|
| 406 |
+
pool_size: int = 10,
|
| 407 |
+
rate_limit: float = 10.0,
|
| 408 |
+
burst_limit: int = 20,
|
| 409 |
+
batch_size: int = 100,
|
| 410 |
+
**kwargs: Any
|
| 411 |
+
) -> APIClient
|
| 412 |
+
```
|
| 413 |
+
Create optimized API client.
|
| 414 |
+
- `base_url`: Base URL for API requests
|
| 415 |
+
- `pool_size`: Connection pool size
|
| 416 |
+
- `rate_limit`: Requests per second
|
| 417 |
+
- `burst_limit`: Maximum burst size
|
| 418 |
+
- `batch_size`: Default batch size
|
| 419 |
+
- `**kwargs`: Additional client options
|
| 420 |
+
|
| 421 |
+
#### Make Request
|
| 422 |
+
```python
|
| 423 |
+
await client.request(
|
| 424 |
+
method: str,
|
| 425 |
+
endpoint: str,
|
| 426 |
+
**kwargs: Any
|
| 427 |
+
) -> Any
|
| 428 |
+
```
|
| 429 |
+
Make an API request with optimizations.
|
| 430 |
+
- `method`: HTTP method
|
| 431 |
+
- `endpoint`: API endpoint
|
| 432 |
+
- `**kwargs`: Request parameters
|
| 433 |
+
- Returns: JSON response
|
| 434 |
+
|
| 435 |
+
#### Batch Request
|
| 436 |
+
```python
|
| 437 |
+
await client.batch_request(
|
| 438 |
+
method: str,
|
| 439 |
+
endpoint: str,
|
| 440 |
+
items: List[Dict[str, Any]]
|
| 441 |
+
) -> List[Any]
|
| 442 |
+
```
|
| 443 |
+
Make batch API request.
|
| 444 |
+
- `method`: HTTP method
|
| 445 |
+
- `endpoint`: API endpoint
|
| 446 |
+
- `items`: List of items to process
|
| 447 |
+
- Returns: List of results
|
| 448 |
+
|
| 449 |
+
### Validation Commands
|
| 450 |
+
|
| 451 |
+
#### Validate API Key
|
| 452 |
+
```python
|
| 453 |
+
validate_api_key(key: str) -> str
|
| 454 |
+
```
|
| 455 |
+
Validate API key format.
|
| 456 |
+
- `key`: API key to validate
|
| 457 |
+
- Returns: Validated key or raises ValidationError
|
| 458 |
+
|
| 459 |
+
#### Validate Name
|
| 460 |
+
```python
|
| 461 |
+
validate_name(name: str) -> str
|
| 462 |
+
```
|
| 463 |
+
Validate key name format.
|
| 464 |
+
- `name`: Name to validate
|
| 465 |
+
- Returns: Validated name or raises ValidationError
|
| 466 |
+
|
| 467 |
+
#### Validate Scopes
|
| 468 |
+
```python
|
| 469 |
+
validate_scopes(scopes: List[str]) -> List[str]
|
| 470 |
+
```
|
| 471 |
+
Validate scope formats.
|
| 472 |
+
- `scopes`: List of scopes to validate
|
| 473 |
+
- Returns: Validated scopes or raises ValidationError
|
| 474 |
+
|
| 475 |
+
#### Validate Rate Limits
|
| 476 |
+
```python
|
| 477 |
+
validate_rate_limits(
|
| 478 |
+
rate_limit: float,
|
| 479 |
+
burst_limit: int
|
| 480 |
+
) -> Tuple[float, int]
|
| 481 |
+
```
|
| 482 |
+
Validate rate limiting parameters.
|
| 483 |
+
- `rate_limit`: Requests per second
|
| 484 |
+
- `burst_limit`: Maximum burst size
|
| 485 |
+
- Returns: Tuple of validated limits or raises ValidationError
|
| 486 |
+
|
| 487 |
+
## Quick Start
|
| 488 |
+
|
| 489 |
+
```python
|
| 490 |
+
from tools.security import KeyManager
|
| 491 |
+
|
| 492 |
+
# Initialize the key manager
|
| 493 |
+
key_manager = KeyManager(
|
| 494 |
+
keys_file="keys.json",
|
| 495 |
+
env_prefix="API"
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
# Add a new API key
|
| 499 |
+
key_config = key_manager.add_key(
|
| 500 |
+
name="service_name",
|
| 501 |
+
key="api-key-123",
|
| 502 |
+
scopes=["read:data", "write:data"],
|
| 503 |
+
rate_limit=10.0, # requests per second
|
| 504 |
+
burst_limit=20 # maximum burst size
|
| 505 |
+
)
|
| 506 |
+
|
| 507 |
+
# Use the key
|
| 508 |
+
if key := key_manager.get_key("service_name"):
|
| 509 |
+
print(f"Key is valid: {key.is_valid}")
|
| 510 |
+
print(f"Scopes: {key.scopes}")
|
| 511 |
+
```
|
| 512 |
+
|
| 513 |
+
## Key Management
|
| 514 |
+
|
| 515 |
+
### Creating Keys
|
| 516 |
+
|
| 517 |
+
Keys can be created with various parameters:
|
| 518 |
+
```python
|
| 519 |
+
key_config = key_manager.add_key(
|
| 520 |
+
name="analytics_service",
|
| 521 |
+
key="api-key-xyz",
|
| 522 |
+
scopes=["read:analytics", "export:data"],
|
| 523 |
+
expires_at=datetime.now(timezone.utc) + timedelta(days=90),
|
| 524 |
+
rate_limit=5.0,
|
| 525 |
+
burst_limit=10
|
| 526 |
+
)
|
| 527 |
+
```
|
| 528 |
+
|
| 529 |
+
### Rotating Keys
|
| 530 |
+
|
| 531 |
+
For security, rotate keys periodically:
|
| 532 |
+
```python
|
| 533 |
+
new_config = key_manager.rotate_key(
|
| 534 |
+
name="analytics_service",
|
| 535 |
+
new_key="new-api-key-xyz",
|
| 536 |
+
expires_at=datetime.now(timezone.utc) + timedelta(days=90)
|
| 537 |
+
)
|
| 538 |
+
```
|
| 539 |
+
|
| 540 |
+
### Environment Variables
|
| 541 |
+
|
| 542 |
+
Keys can be loaded from environment variables:
|
| 543 |
+
```bash
|
| 544 |
+
export API_SERVICE_NAME_KEY=api-key-123
|
| 545 |
+
export API_SERVICE_NAME_RATE_LIMIT=10.0
|
| 546 |
+
export API_SERVICE_NAME_BURST_LIMIT=20
|
| 547 |
+
```
|
| 548 |
+
|
| 549 |
+
```python
|
| 550 |
+
key = key_manager.get_key("service_name") # Loads from environment
|
| 551 |
+
```
|
| 552 |
+
|
| 553 |
+
## Security Features
|
| 554 |
+
|
| 555 |
+
### Encryption
|
| 556 |
+
|
| 557 |
+
Keys are automatically encrypted at rest:
|
| 558 |
+
```python
|
| 559 |
+
# Keys are encrypted in storage
|
| 560 |
+
key_manager = KeyManager(
|
| 561 |
+
encryption_key="your-encryption-key", # Optional, can use env var
|
| 562 |
+
keys_file="keys.json"
|
| 563 |
+
)
|
| 564 |
+
```
|
| 565 |
+
|
| 566 |
+
### Validation
|
| 567 |
+
|
| 568 |
+
All inputs are validated:
|
| 569 |
+
```python
|
| 570 |
+
try:
|
| 571 |
+
key_config = key_manager.add_key(
|
| 572 |
+
name="service",
|
| 573 |
+
key="invalid-key!", # Will raise ValidationError
|
| 574 |
+
scopes=["invalid:scope"] # Will raise ValidationError
|
| 575 |
+
)
|
| 576 |
+
except ValidationError as e:
|
| 577 |
+
print(f"Validation failed: {e.message}")
|
| 578 |
+
```
|
| 579 |
+
|
| 580 |
+
### Scopes
|
| 581 |
+
|
| 582 |
+
Control access with scopes:
|
| 583 |
+
```python
|
| 584 |
+
key_config = key_manager.add_key(
|
| 585 |
+
name="limited_service",
|
| 586 |
+
key="api-key-123",
|
| 587 |
+
scopes=["read:only", "list:items"]
|
| 588 |
+
)
|
| 589 |
+
```
|
| 590 |
+
|
| 591 |
+
## Rate Limiting
|
| 592 |
+
|
| 593 |
+
### Basic Rate Limiting
|
| 594 |
+
|
| 595 |
+
```python
|
| 596 |
+
# Create key with rate limits
|
| 597 |
+
key_config = key_manager.add_key(
|
| 598 |
+
name="service",
|
| 599 |
+
key="api-key-123",
|
| 600 |
+
rate_limit=10.0, # 10 requests per second
|
| 601 |
+
burst_limit=20 # Allow bursts up to 20 requests
|
| 602 |
+
)
|
| 603 |
+
|
| 604 |
+
# Check rate limit before request
|
| 605 |
+
async def make_request():
|
| 606 |
+
wait_time = await key_manager.check_rate_limit("service")
|
| 607 |
+
if wait_time > 0:
|
| 608 |
+
await asyncio.sleep(wait_time)
|
| 609 |
+
# Make request...
|
| 610 |
+
```
|
| 611 |
+
|
| 612 |
+
### Burst Handling
|
| 613 |
+
|
| 614 |
+
```python
|
| 615 |
+
# Allow higher burst rates for batch operations
|
| 616 |
+
key_config = key_manager.add_key(
|
| 617 |
+
name="batch_service",
|
| 618 |
+
key="api-key-123",
|
| 619 |
+
rate_limit=50.0, # 50 requests per second
|
| 620 |
+
burst_limit=100 # Allow bursts up to 100 requests
|
| 621 |
+
)
|
| 622 |
+
```
|
| 623 |
+
|
| 624 |
+
## Error Handling
|
| 625 |
+
|
| 626 |
+
### Common Errors
|
| 627 |
+
|
| 628 |
+
```python
|
| 629 |
+
from tools.errors import (
|
| 630 |
+
AuthenticationError,
|
| 631 |
+
AuthorizationError,
|
| 632 |
+
InvalidKeyError,
|
| 633 |
+
KeyExpiredError,
|
| 634 |
+
RateLimitError,
|
| 635 |
+
ValidationError
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
+
try:
|
| 639 |
+
key = key_manager.get_key("service")
|
| 640 |
+
if not key:
|
| 641 |
+
raise AuthenticationError("Invalid API key")
|
| 642 |
+
|
| 643 |
+
if not key.is_valid:
|
| 644 |
+
if key.is_expired:
|
| 645 |
+
raise KeyExpiredError(f"Key expired on {key.expires_at}")
|
| 646 |
+
raise InvalidKeyError("Key is inactive")
|
| 647 |
+
|
| 648 |
+
wait_time = await key_manager.check_rate_limit("service")
|
| 649 |
+
if wait_time > 0:
|
| 650 |
+
raise RateLimitError(
|
| 651 |
+
"Rate limit exceeded",
|
| 652 |
+
wait_time=wait_time
|
| 653 |
+
)
|
| 654 |
+
except SecurityError as e:
|
| 655 |
+
print(f"Security error: [{e.code}] {e.message}")
|
| 656 |
+
```
|
| 657 |
+
|
| 658 |
+
### Error Recovery
|
| 659 |
+
|
| 660 |
+
```python
|
| 661 |
+
async def make_api_request():
|
| 662 |
+
try:
|
| 663 |
+
wait_time = await key_manager.check_rate_limit("service")
|
| 664 |
+
if wait_time > 0:
|
| 665 |
+
# Handle rate limiting gracefully
|
| 666 |
+
await asyncio.sleep(wait_time)
|
| 667 |
+
return await make_api_request() # Retry after waiting
|
| 668 |
+
|
| 669 |
+
# Make request...
|
| 670 |
+
except RateLimitError as e:
|
| 671 |
+
logger.warning(f"Rate limited for {e.wait_time}s")
|
| 672 |
+
await asyncio.sleep(e.wait_time)
|
| 673 |
+
return await make_api_request() # Retry after waiting
|
| 674 |
+
except KeyExpiredError:
|
| 675 |
+
# Rotate to new key
|
| 676 |
+
new_config = await key_manager.rotate_key(
|
| 677 |
+
name="service",
|
| 678 |
+
new_key=generate_new_key()
|
| 679 |
+
)
|
| 680 |
+
return await make_api_request() # Retry with new key
|
| 681 |
+
```
|
| 682 |
+
|
| 683 |
+
## Best Practices
|
| 684 |
+
|
| 685 |
+
1. **Key Rotation**
|
| 686 |
+
- Rotate keys regularly (e.g., every 90 days)
|
| 687 |
+
- Use expiration dates to enforce rotation
|
| 688 |
+
- Keep old keys active briefly during rotation
|
| 689 |
+
|
| 690 |
+
2. **Rate Limiting**
|
| 691 |
+
- Set appropriate rate limits for your use case
|
| 692 |
+
- Use burst limits for handling traffic spikes
|
| 693 |
+
- Implement graceful backoff when rate limited
|
| 694 |
+
|
| 695 |
+
3. **Error Handling**
|
| 696 |
+
- Always catch and handle security errors
|
| 697 |
+
- Log security events appropriately
|
| 698 |
+
- Implement retry logic with backoff
|
| 699 |
+
|
| 700 |
+
4. **Validation**
|
| 701 |
+
- Validate all inputs before use
|
| 702 |
+
- Use appropriate scopes for access control
|
| 703 |
+
- Regularly audit active keys
|
| 704 |
+
|
| 705 |
+
5. **Security**
|
| 706 |
+
- Store encryption keys securely
|
| 707 |
+
- Use environment variables for sensitive data
|
| 708 |
+
- Monitor and log security events
|
| 709 |
+
|
| 710 |
+
## Examples
|
| 711 |
+
|
| 712 |
+
### Complete Service Example
|
| 713 |
+
|
| 714 |
+
```python
|
| 715 |
+
from datetime import datetime, timezone, timedelta
|
| 716 |
+
from tools.security import KeyManager
|
| 717 |
+
from tools.errors import SecurityError
|
| 718 |
+
|
| 719 |
+
class APIService:
|
| 720 |
+
def __init__(self):
|
| 721 |
+
self.key_manager = KeyManager(
|
| 722 |
+
keys_file="keys.json",
|
| 723 |
+
env_prefix="API"
|
| 724 |
+
)
|
| 725 |
+
|
| 726 |
+
async def initialize(self):
|
| 727 |
+
# Create service key if not exists
|
| 728 |
+
if not self.key_manager.get_key("service"):
|
| 729 |
+
await self.create_service_key()
|
| 730 |
+
|
| 731 |
+
async def create_service_key(self):
|
| 732 |
+
return self.key_manager.add_key(
|
| 733 |
+
name="service",
|
| 734 |
+
key=generate_secure_key(),
|
| 735 |
+
scopes=["read:data", "write:data"],
|
| 736 |
+
expires_at=datetime.now(timezone.utc) + timedelta(days=90),
|
| 737 |
+
rate_limit=10.0,
|
| 738 |
+
burst_limit=20
|
| 739 |
+
)
|
| 740 |
+
|
| 741 |
+
async def make_request(self, endpoint: str, data: dict):
|
| 742 |
+
try:
|
| 743 |
+
# Get and validate key
|
| 744 |
+
key = self.key_manager.get_key("service")
|
| 745 |
+
if not key:
|
| 746 |
+
raise AuthenticationError("Invalid API key")
|
| 747 |
+
|
| 748 |
+
if not key.is_valid:
|
| 749 |
+
if key.is_expired:
|
| 750 |
+
# Rotate expired key
|
| 751 |
+
key = await self.rotate_service_key()
|
| 752 |
+
else:
|
| 753 |
+
raise InvalidKeyError("Key is inactive")
|
| 754 |
+
|
| 755 |
+
# Check rate limit
|
| 756 |
+
wait_time = await self.key_manager.check_rate_limit("service")
|
| 757 |
+
if wait_time > 0:
|
| 758 |
+
await asyncio.sleep(wait_time)
|
| 759 |
+
|
| 760 |
+
# Make request...
|
| 761 |
+
return await self._do_request(endpoint, data, key)
|
| 762 |
+
|
| 763 |
+
except RateLimitError as e:
|
| 764 |
+
logger.warning(f"Rate limited for {e.wait_time}s")
|
| 765 |
+
await asyncio.sleep(e.wait_time)
|
| 766 |
+
return await self.make_request(endpoint, data)
|
| 767 |
+
|
| 768 |
+
except KeyExpiredError:
|
| 769 |
+
logger.info("Rotating expired key")
|
| 770 |
+
await self.rotate_service_key()
|
| 771 |
+
return await self.make_request(endpoint, data)
|
| 772 |
+
|
| 773 |
+
except SecurityError as e:
|
| 774 |
+
logger.error(f"Security error: [{e.code}] {e.message}")
|
| 775 |
+
raise
|
| 776 |
+
|
| 777 |
+
async def rotate_service_key(self):
|
| 778 |
+
return self.key_manager.rotate_key(
|
| 779 |
+
name="service",
|
| 780 |
+
new_key=generate_secure_key(),
|
| 781 |
+
expires_at=datetime.now(timezone.utc) + timedelta(days=90)
|
| 782 |
+
)
|
| 783 |
+
```
|
| 784 |
+
|
| 785 |
+
### Batch Processing Example
|
| 786 |
+
|
| 787 |
+
```python
|
| 788 |
+
async def process_batch(items: list):
|
| 789 |
+
async with APIClient(
|
| 790 |
+
base_url="https://api.example.com",
|
| 791 |
+
rate_limit=50.0,
|
| 792 |
+
burst_limit=100
|
| 793 |
+
) as client:
|
| 794 |
+
try:
|
| 795 |
+
results = await client.batch_request(
|
| 796 |
+
method="POST",
|
| 797 |
+
endpoint="/batch",
|
| 798 |
+
items=items
|
| 799 |
+
)
|
| 800 |
+
return results
|
| 801 |
+
except RateLimitError as e:
|
| 802 |
+
logger.warning(f"Batch rate limited for {e.wait_time}s")
|
| 803 |
+
# Split batch and retry with smaller chunks
|
| 804 |
+
mid = len(items) // 2
|
| 805 |
+
results1 = await process_batch(items[:mid])
|
| 806 |
+
results2 = await process_batch(items[mid:])
|
| 807 |
+
return results1 + results2
|
| 808 |
+
```
|
| 809 |
+
|
| 810 |
+
For more detailed information, refer to:
|
| 811 |
+
- [API Documentation](api.md)
|
| 812 |
+
- [Security Guide](security_quickstart.md)
|
| 813 |
+
- [Error Handling Guide](errors.md)
|
prompts.yaml
CHANGED
|
@@ -319,3 +319,18 @@
|
|
| 319 |
"report": |-
|
| 320 |
Here is the final answer from your managed agent '{{name}}':
|
| 321 |
{{final_answer}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
"report": |-
|
| 320 |
Here is the final answer from your managed agent '{{name}}':
|
| 321 |
{{final_answer}}
|
| 322 |
+
|
| 323 |
+
system: |
|
| 324 |
+
You are an AI assistant that helps users with various tasks.
|
| 325 |
+
You have access to several tools including weather information, timezone conversion, and more.
|
| 326 |
+
Always be helpful, professional, and provide clear explanations in your responses.
|
| 327 |
+
|
| 328 |
+
user: |
|
| 329 |
+
{input}
|
| 330 |
+
|
| 331 |
+
assistant: |
|
| 332 |
+
I'll help you with your request: {output}
|
| 333 |
+
|
| 334 |
+
error: |
|
| 335 |
+
I apologize, but I encountered an error: {error}
|
| 336 |
+
Please try again or rephrase your request.
|
pyproject.toml
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[tool.black]
|
| 2 |
+
line-length = 100
|
| 3 |
+
target-version = ['py39']
|
| 4 |
+
include = '\.pyi?$'
|
| 5 |
+
extend-exclude = '''
|
| 6 |
+
# A regex preceded with ^/ will apply only to files and directories
|
| 7 |
+
# in the root of the project.
|
| 8 |
+
^/tests/data/
|
| 9 |
+
'''
|
| 10 |
+
|
| 11 |
+
[tool.isort]
|
| 12 |
+
profile = "black"
|
| 13 |
+
multi_line_output = 3
|
| 14 |
+
include_trailing_comma = true
|
| 15 |
+
force_grid_wrap = 0
|
| 16 |
+
use_parentheses = true
|
| 17 |
+
ensure_newline_before_comments = true
|
| 18 |
+
line_length = 100
|
| 19 |
+
skip_gitignore = true
|
| 20 |
+
skip_glob = ["tests/data/*"]
|
| 21 |
+
|
| 22 |
+
[tool.pylint.messages_control]
|
| 23 |
+
disable = [
|
| 24 |
+
"C0111", # missing-docstring
|
| 25 |
+
"C0103", # invalid-name
|
| 26 |
+
"C0330", # bad-continuation
|
| 27 |
+
"C0326", # bad-whitespace
|
| 28 |
+
"W0621", # redefined-outer-name
|
| 29 |
+
"W0614", # unused-wildcard-import
|
| 30 |
+
"R0903", # too-few-public-methods
|
| 31 |
+
"R0913", # too-many-arguments
|
| 32 |
+
"R0914", # too-many-locals
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
[tool.pylint.format]
|
| 36 |
+
max-line-length = 100
|
| 37 |
+
|
| 38 |
+
[tool.pylint.basic]
|
| 39 |
+
good-names = ["i", "j", "k", "ex", "Run", "_", "fp", "id"]
|
| 40 |
+
|
| 41 |
+
[tool.pylint.design]
|
| 42 |
+
max-args = 8
|
| 43 |
+
max-attributes = 12
|
| 44 |
+
max-bool-expr = 5
|
| 45 |
+
max-branches = 12
|
| 46 |
+
max-locals = 20
|
| 47 |
+
max-parents = 7
|
| 48 |
+
max-public-methods = 20
|
| 49 |
+
max-returns = 6
|
| 50 |
+
max-statements = 50
|
| 51 |
+
min-public-methods = 1
|
| 52 |
+
|
| 53 |
+
[tool.mypy]
|
| 54 |
+
python_version = "3.9"
|
| 55 |
+
warn_return_any = true
|
| 56 |
+
warn_unused_configs = true
|
| 57 |
+
disallow_untyped_defs = true
|
| 58 |
+
disallow_incomplete_defs = true
|
| 59 |
+
check_untyped_defs = true
|
| 60 |
+
disallow_untyped_decorators = false
|
| 61 |
+
no_implicit_optional = true
|
| 62 |
+
warn_redundant_casts = true
|
| 63 |
+
warn_unused_ignores = true
|
| 64 |
+
warn_no_return = true
|
| 65 |
+
warn_unreachable = true
|
| 66 |
+
strict_equality = true
|
| 67 |
+
|
| 68 |
+
[[tool.mypy.overrides]]
|
| 69 |
+
module = "tests.*"
|
| 70 |
+
disallow_untyped_defs = false
|
| 71 |
+
disallow_incomplete_defs = false
|
| 72 |
+
|
| 73 |
+
[tool.pytest.ini_options]
|
| 74 |
+
minversion = "6.0"
|
| 75 |
+
addopts = "-ra -q --cov=tools --cov-report=term-missing"
|
| 76 |
+
testpaths = ["tests"]
|
| 77 |
+
python_files = ["test_*.py"]
|
| 78 |
+
python_classes = ["Test*"]
|
| 79 |
+
python_functions = ["test_*"]
|
| 80 |
+
markers = [
|
| 81 |
+
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
| 82 |
+
"integration: marks tests as integration tests",
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
[tool.bandit]
|
| 86 |
+
exclude_dirs = ["tests", "venv", ".env", ".venv"]
|
| 87 |
+
skips = ["B101", "B404", "B603"]
|
| 88 |
+
targets = ["tools"]
|
| 89 |
+
|
| 90 |
+
[tool.bandit.assert_used]
|
| 91 |
+
skips = ["*_test.py", "*/test_*.py"]
|
| 92 |
+
|
| 93 |
+
# Security settings
|
| 94 |
+
[tool.bandit.any_other_function_with_shell_equals_true]
|
| 95 |
+
no_shell = [
|
| 96 |
+
"subprocess.Popen",
|
| 97 |
+
"subprocess.run",
|
| 98 |
+
"subprocess.call",
|
| 99 |
+
"subprocess.check_call",
|
| 100 |
+
"subprocess.check_output",
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
[tool.bandit.hardcoded_tmp_directory]
|
| 104 |
+
tmp_dirs = ["/tmp", "/var/tmp", "/dev/shm"]
|
| 105 |
+
|
| 106 |
+
[tool.bandit.hardcoded_password_string]
|
| 107 |
+
possible_strings = ["password", "pass", "pwd", "secret", "token"]
|
pytest.ini
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
testpaths = tests
|
| 3 |
+
python_files = test_*.py
|
| 4 |
+
python_classes = Test*
|
| 5 |
+
python_functions = test_*
|
| 6 |
+
|
| 7 |
+
# Coverage settings
|
| 8 |
+
addopts =
|
| 9 |
+
--cov=tools
|
| 10 |
+
--cov-report=term-missing
|
| 11 |
+
--cov-report=html
|
| 12 |
+
--cov-report=xml
|
| 13 |
+
--cov-branch
|
| 14 |
+
--no-cov-on-fail
|
| 15 |
+
|
| 16 |
+
# Coverage configuration
|
| 17 |
+
[coverage:run]
|
| 18 |
+
branch = True
|
| 19 |
+
source = tools
|
| 20 |
+
omit =
|
| 21 |
+
*/__init__.py
|
| 22 |
+
*/tests/*
|
| 23 |
+
*/venv/*
|
| 24 |
+
setup.py
|
| 25 |
+
|
| 26 |
+
[coverage:report]
|
| 27 |
+
exclude_lines =
|
| 28 |
+
pragma: no cover
|
| 29 |
+
def __repr__
|
| 30 |
+
raise NotImplementedError
|
| 31 |
+
if __name__ == .__main__.:
|
| 32 |
+
pass
|
| 33 |
+
raise ImportError
|
| 34 |
+
except ImportError:
|
| 35 |
+
if TYPE_CHECKING:
|
| 36 |
+
|
| 37 |
+
show_missing = True
|
| 38 |
+
skip_covered = False
|
| 39 |
+
fail_under = 90
|
| 40 |
+
|
| 41 |
+
[coverage:html]
|
| 42 |
+
directory = coverage_html
|
| 43 |
+
|
| 44 |
+
[coverage:xml]
|
| 45 |
+
output = coverage.xml
|
rate_limiter.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rate limiter implementation using token bucket algorithm.
|
| 3 |
+
"""
|
| 4 |
+
import time
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
from typing import Dict, Tuple
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class RateLimiter:
|
| 12 |
+
def __init__(self, requests_per_minute: int):
|
| 13 |
+
self.requests_per_minute = requests_per_minute
|
| 14 |
+
self.tokens = requests_per_minute
|
| 15 |
+
self.last_update = time.time()
|
| 16 |
+
|
| 17 |
+
def _add_tokens(self) -> None:
|
| 18 |
+
now = time.time()
|
| 19 |
+
time_passed = now - self.last_update
|
| 20 |
+
new_tokens = time_passed * (self.requests_per_minute / 60.0)
|
| 21 |
+
self.tokens = min(self.requests_per_minute, self.tokens + new_tokens)
|
| 22 |
+
self.last_update = now
|
| 23 |
+
|
| 24 |
+
def acquire(self) -> bool:
|
| 25 |
+
self._add_tokens()
|
| 26 |
+
if self.tokens >= 1:
|
| 27 |
+
self.tokens -= 1
|
| 28 |
+
return True
|
| 29 |
+
return False
|
| 30 |
+
|
| 31 |
+
# Global rate limiters
|
| 32 |
+
rate_limiters: Dict[str, RateLimiter] = {}
|
requirements-dev.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
CHANGED
|
@@ -1,7 +1,47 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core dependencies
|
| 2 |
+
requests>=2.31.0,<3.0.0
|
| 3 |
+
python-dateutil>=2.8.2,<3.0.0
|
| 4 |
+
pytz>=2024.1
|
| 5 |
+
typing-extensions>=4.8.0
|
| 6 |
+
pydantic>=2.0.0
|
| 7 |
+
|
| 8 |
+
# API and HTTP
|
| 9 |
+
aiohttp>=3.8.0
|
| 10 |
+
urllib3>=2.2.0,<3.0.0 # Required by requests
|
| 11 |
+
certifi>=2024.2.2 # For SSL certificates
|
| 12 |
+
|
| 13 |
+
# Logging and monitoring
|
| 14 |
+
structlog>=23.0.0,<25.0.0 # For structured logging
|
| 15 |
+
python-json-logger>=2.0.7,<3.0.0 # JSON log formatting
|
| 16 |
+
|
| 17 |
+
# Security
|
| 18 |
+
python-jose[cryptography]>=3.3.0
|
| 19 |
+
passlib[bcrypt]>=1.7.4
|
| 20 |
+
python-dotenv>=0.19.0
|
| 21 |
+
|
| 22 |
+
# UI
|
| 23 |
+
gradio>=4.16.0,<5.0.0 # For web interface
|
| 24 |
+
markdown>=3.5.2,<4.0.0 # For markdown rendering
|
| 25 |
+
|
| 26 |
+
# Caching
|
| 27 |
+
cachetools>=5.0.0
|
| 28 |
+
|
| 29 |
+
# Error handling
|
| 30 |
+
backoff>=2.2.1
|
| 31 |
+
tenacity>=8.0.0
|
| 32 |
+
|
| 33 |
+
# System monitoring
|
| 34 |
+
psutil>=5.9.0,<6.0.0 # System resource monitoring
|
| 35 |
+
|
| 36 |
+
# For testing
|
| 37 |
+
pytest>=7.0.0
|
| 38 |
+
pytest-asyncio>=0.21.0
|
| 39 |
+
aioresponses>=0.7.5
|
| 40 |
+
pytest-aiohttp>=1.0.5
|
| 41 |
+
|
| 42 |
+
# Additional dependencies
|
| 43 |
+
fastapi>=0.68.0
|
| 44 |
+
uvicorn>=0.15.0
|
| 45 |
+
python-multipart>=0.0.5
|
| 46 |
+
httpx>=0.24.0
|
| 47 |
+
cryptography>=41.0.0
|
scripts/run_tests.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to run tests with coverage reporting.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import subprocess
|
| 9 |
+
from typing import List, Optional
|
| 10 |
+
|
| 11 |
+
def run_tests(args: Optional[List[str]] = None) -> int:
|
| 12 |
+
"""
|
| 13 |
+
Run tests with coverage reporting.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
args: Additional pytest arguments.
|
| 17 |
+
|
| 18 |
+
Returns:
|
| 19 |
+
Exit code from pytest.
|
| 20 |
+
"""
|
| 21 |
+
# Ensure we're in the project root directory
|
| 22 |
+
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 23 |
+
os.chdir(project_root)
|
| 24 |
+
|
| 25 |
+
# Base pytest command with coverage
|
| 26 |
+
cmd = [
|
| 27 |
+
"pytest",
|
| 28 |
+
"--verbose",
|
| 29 |
+
"--cov=tools",
|
| 30 |
+
"--cov-report=term-missing",
|
| 31 |
+
"--cov-report=html",
|
| 32 |
+
"--cov-report=xml",
|
| 33 |
+
"--cov-branch",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
# Add any additional arguments
|
| 37 |
+
if args:
|
| 38 |
+
cmd.extend(args)
|
| 39 |
+
|
| 40 |
+
# Run tests
|
| 41 |
+
try:
|
| 42 |
+
result = subprocess.run(cmd, check=True)
|
| 43 |
+
return result.returncode
|
| 44 |
+
except subprocess.CalledProcessError as e:
|
| 45 |
+
print(f"Error running tests: {e}", file=sys.stderr)
|
| 46 |
+
return e.returncode
|
| 47 |
+
|
| 48 |
+
def print_coverage_report() -> None:
|
| 49 |
+
"""Print a summary of the coverage report."""
|
| 50 |
+
try:
|
| 51 |
+
# Read the coverage XML report
|
| 52 |
+
import xml.etree.ElementTree as ET
|
| 53 |
+
tree = ET.parse('coverage.xml')
|
| 54 |
+
root = tree.getroot()
|
| 55 |
+
|
| 56 |
+
# Get overall coverage metrics
|
| 57 |
+
metrics = root.find('.//metrics')
|
| 58 |
+
if metrics is not None:
|
| 59 |
+
statements = int(metrics.get('statements', 0))
|
| 60 |
+
covered = int(metrics.get('covered', 0))
|
| 61 |
+
branches = int(metrics.get('branches', 0))
|
| 62 |
+
branches_covered = int(metrics.get('branches-covered', 0))
|
| 63 |
+
|
| 64 |
+
# Calculate percentages
|
| 65 |
+
line_coverage = (covered / statements * 100) if statements else 0
|
| 66 |
+
branch_coverage = (branches_covered / branches * 100) if branches else 0
|
| 67 |
+
|
| 68 |
+
print("\nCoverage Summary:")
|
| 69 |
+
print(f"Line Coverage: {line_coverage:.1f}%")
|
| 70 |
+
print(f"Branch Coverage: {branch_coverage:.1f}%")
|
| 71 |
+
print(f"Statements: {covered}/{statements}")
|
| 72 |
+
print(f"Branches: {branches_covered}/{branches}")
|
| 73 |
+
|
| 74 |
+
# Print location of detailed reports
|
| 75 |
+
print("\nDetailed reports generated:")
|
| 76 |
+
print("- HTML report: coverage_html/index.html")
|
| 77 |
+
print("- XML report: coverage.xml")
|
| 78 |
+
except Exception as e:
|
| 79 |
+
print(f"Error reading coverage report: {e}", file=sys.stderr)
|
| 80 |
+
|
| 81 |
+
def main() -> int:
|
| 82 |
+
"""Main entry point."""
|
| 83 |
+
# Get any additional arguments
|
| 84 |
+
args = sys.argv[1:] if len(sys.argv) > 1 else None
|
| 85 |
+
|
| 86 |
+
# Run tests
|
| 87 |
+
result = run_tests(args)
|
| 88 |
+
|
| 89 |
+
# Print coverage summary if tests passed
|
| 90 |
+
if result == 0:
|
| 91 |
+
print_coverage_report()
|
| 92 |
+
|
| 93 |
+
return result
|
| 94 |
+
|
| 95 |
+
if __name__ == "__main__":
|
| 96 |
+
sys.exit(main())
|
setup.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Setup configuration for the AI Assistant package.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from setuptools import setup, find_packages
|
| 7 |
+
|
| 8 |
+
# Read the contents of README.md
|
| 9 |
+
with open("README.md", encoding="utf-8") as f:
|
| 10 |
+
long_description = f.read()
|
| 11 |
+
|
| 12 |
+
# Read the contents of requirements.txt
|
| 13 |
+
with open("requirements.txt", encoding="utf-8") as f:
|
| 14 |
+
requirements = [
|
| 15 |
+
line.strip()
|
| 16 |
+
for line in f
|
| 17 |
+
if line.strip() and not line.startswith("#") and not line.startswith("-")
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
# Package metadata
|
| 21 |
+
NAME = "ai-assistant"
|
| 22 |
+
DESCRIPTION = "A robust framework for building AI assistants with built-in error handling, logging, and API integration capabilities"
|
| 23 |
+
AUTHOR = "Your Name"
|
| 24 |
+
AUTHOR_EMAIL = "your.email@example.com"
|
| 25 |
+
URL = "https://github.com/yourusername/ai-assistant"
|
| 26 |
+
LICENSE = "MIT"
|
| 27 |
+
PYTHON_REQUIRES = ">=3.9"
|
| 28 |
+
VERSION = "0.1.0" # Also update in __init__.py
|
| 29 |
+
|
| 30 |
+
# Classifiers help users find your project
|
| 31 |
+
CLASSIFIERS = [
|
| 32 |
+
# Development Status
|
| 33 |
+
"Development Status :: 4 - Beta",
|
| 34 |
+
|
| 35 |
+
# Intended Audience
|
| 36 |
+
"Intended Audience :: Developers",
|
| 37 |
+
"Intended Audience :: Science/Research",
|
| 38 |
+
|
| 39 |
+
# License
|
| 40 |
+
"License :: OSI Approved :: MIT License",
|
| 41 |
+
|
| 42 |
+
# Python versions
|
| 43 |
+
"Programming Language :: Python :: 3",
|
| 44 |
+
"Programming Language :: Python :: 3.9",
|
| 45 |
+
"Programming Language :: Python :: 3.10",
|
| 46 |
+
"Programming Language :: Python :: 3.11",
|
| 47 |
+
"Programming Language :: Python :: 3.12",
|
| 48 |
+
|
| 49 |
+
# Environment
|
| 50 |
+
"Operating System :: OS Independent",
|
| 51 |
+
|
| 52 |
+
# Topics
|
| 53 |
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
| 54 |
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
| 55 |
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
| 56 |
+
|
| 57 |
+
# Typing
|
| 58 |
+
"Typing :: Typed",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
# Additional package data
|
| 62 |
+
PACKAGE_DATA = {
|
| 63 |
+
"ai_assistant": [
|
| 64 |
+
"py.typed", # Marker file for PEP 561
|
| 65 |
+
"prompts/*.yaml", # Include prompt templates
|
| 66 |
+
"config/*.json", # Include default configurations
|
| 67 |
+
]
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
setup(
|
| 71 |
+
# Basic package information
|
| 72 |
+
name=NAME,
|
| 73 |
+
version=VERSION,
|
| 74 |
+
description=DESCRIPTION,
|
| 75 |
+
long_description=long_description,
|
| 76 |
+
long_description_content_type="text/markdown",
|
| 77 |
+
|
| 78 |
+
# Author information
|
| 79 |
+
author=AUTHOR,
|
| 80 |
+
author_email=AUTHOR_EMAIL,
|
| 81 |
+
|
| 82 |
+
# Project URLs
|
| 83 |
+
url=URL,
|
| 84 |
+
project_urls={
|
| 85 |
+
"Bug Tracker": f"{URL}/issues",
|
| 86 |
+
"Documentation": f"{URL}/docs",
|
| 87 |
+
"Source Code": URL,
|
| 88 |
+
},
|
| 89 |
+
|
| 90 |
+
# License and Python version
|
| 91 |
+
license=LICENSE,
|
| 92 |
+
python_requires=PYTHON_REQUIRES,
|
| 93 |
+
|
| 94 |
+
# Package configuration
|
| 95 |
+
packages=find_packages(exclude=["tests*", "docs*"]),
|
| 96 |
+
package_data=PACKAGE_DATA,
|
| 97 |
+
include_package_data=True,
|
| 98 |
+
zip_safe=False, # Required for mypy to find py.typed
|
| 99 |
+
|
| 100 |
+
# Dependencies
|
| 101 |
+
install_requires=requirements,
|
| 102 |
+
extras_require={
|
| 103 |
+
"dev": [
|
| 104 |
+
line.strip()
|
| 105 |
+
for line in open("requirements-dev.txt")
|
| 106 |
+
if line.strip() and not line.startswith("#") and not line.startswith("-")
|
| 107 |
+
],
|
| 108 |
+
"docs": [
|
| 109 |
+
"sphinx>=7.2.6,<8.0.0",
|
| 110 |
+
"sphinx-rtd-theme>=2.0.0,<3.0.0",
|
| 111 |
+
"sphinx-autodoc-typehints>=1.25.2,<2.0.0",
|
| 112 |
+
"sphinx-copybutton>=0.5.2,<1.0.0",
|
| 113 |
+
"myst-parser>=2.0.0,<3.0.0",
|
| 114 |
+
],
|
| 115 |
+
"test": [
|
| 116 |
+
"pytest>=8.0.0,<9.0.0",
|
| 117 |
+
"pytest-cov>=4.1.0,<5.0.0",
|
| 118 |
+
"pytest-asyncio>=0.23.5,<1.0.0",
|
| 119 |
+
"pytest-mock>=3.12.0,<4.0.0",
|
| 120 |
+
"responses>=0.24.1,<1.0.0",
|
| 121 |
+
"freezegun>=1.4.0,<2.0.0",
|
| 122 |
+
"faker>=22.6.0,<23.0.0",
|
| 123 |
+
],
|
| 124 |
+
},
|
| 125 |
+
|
| 126 |
+
# Entry points for CLI tools
|
| 127 |
+
entry_points={
|
| 128 |
+
"console_scripts": [
|
| 129 |
+
"ai-assistant=tools.cli:main",
|
| 130 |
+
],
|
| 131 |
+
},
|
| 132 |
+
|
| 133 |
+
# Classifiers
|
| 134 |
+
classifiers=CLASSIFIERS,
|
| 135 |
+
|
| 136 |
+
# Keywords for PyPI
|
| 137 |
+
keywords=[
|
| 138 |
+
"ai",
|
| 139 |
+
"assistant",
|
| 140 |
+
"nlp",
|
| 141 |
+
"machine-learning",
|
| 142 |
+
"api",
|
| 143 |
+
"tools",
|
| 144 |
+
"automation",
|
| 145 |
+
"error-handling",
|
| 146 |
+
"logging",
|
| 147 |
+
],
|
| 148 |
+
)
|
test_app.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the API key management web interface."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from fastapi.testclient import TestClient
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
|
| 7 |
+
from app import app
|
| 8 |
+
|
| 9 |
+
client = TestClient(app)
|
| 10 |
+
|
| 11 |
+
def test_root():
|
| 12 |
+
"""Test root endpoint."""
|
| 13 |
+
response = client.get("/")
|
| 14 |
+
assert response.status_code == 200
|
| 15 |
+
assert response.json()["message"] == "API Key Management System"
|
| 16 |
+
|
| 17 |
+
def test_create_and_get_key():
|
| 18 |
+
"""Test key creation and retrieval."""
|
| 19 |
+
# Create a key
|
| 20 |
+
create_response = client.post(
|
| 21 |
+
"/keys",
|
| 22 |
+
json={
|
| 23 |
+
"name": "test_service",
|
| 24 |
+
"scopes": ["read:data", "write:data"],
|
| 25 |
+
"rate_limit": 5.0,
|
| 26 |
+
"burst_limit": 10,
|
| 27 |
+
"expires_in_days": 90
|
| 28 |
+
}
|
| 29 |
+
)
|
| 30 |
+
assert create_response.status_code == 200
|
| 31 |
+
key_data = create_response.json()
|
| 32 |
+
assert key_data["name"] == "test_service"
|
| 33 |
+
assert key_data["scopes"] == ["read:data", "write:data"]
|
| 34 |
+
assert key_data["rate_limit"] == 5.0
|
| 35 |
+
assert key_data["burst_limit"] == 10
|
| 36 |
+
assert key_data["is_active"] is True
|
| 37 |
+
|
| 38 |
+
# Get the key
|
| 39 |
+
get_response = client.get(f"/keys/test_service")
|
| 40 |
+
assert get_response.status_code == 200
|
| 41 |
+
get_data = get_response.json()
|
| 42 |
+
assert get_data["name"] == "test_service"
|
| 43 |
+
assert get_data["scopes"] == ["read:data", "write:data"]
|
| 44 |
+
assert get_data["is_active"] is True
|
| 45 |
+
|
| 46 |
+
def test_list_keys():
|
| 47 |
+
"""Test listing keys."""
|
| 48 |
+
# Create a test key first
|
| 49 |
+
client.post(
|
| 50 |
+
"/keys",
|
| 51 |
+
json={
|
| 52 |
+
"name": "list_test_service",
|
| 53 |
+
"scopes": ["read:only"]
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# List keys
|
| 58 |
+
response = client.get("/keys")
|
| 59 |
+
assert response.status_code == 200
|
| 60 |
+
keys = response.json()
|
| 61 |
+
assert "list_test_service" in keys
|
| 62 |
+
assert keys["list_test_service"]["scopes"] == ["read:only"]
|
| 63 |
+
|
| 64 |
+
def test_rotate_key():
|
| 65 |
+
"""Test key rotation."""
|
| 66 |
+
# Create a key first
|
| 67 |
+
client.post(
|
| 68 |
+
"/keys",
|
| 69 |
+
json={
|
| 70 |
+
"name": "rotate_test_service",
|
| 71 |
+
"scopes": ["read:data"]
|
| 72 |
+
}
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Rotate the key
|
| 76 |
+
rotate_response = client.post(
|
| 77 |
+
"/keys/rotate_test_service/rotate",
|
| 78 |
+
params={"expires_in_days": 30}
|
| 79 |
+
)
|
| 80 |
+
assert rotate_response.status_code == 200
|
| 81 |
+
rotated_key = rotate_response.json()
|
| 82 |
+
assert rotated_key["name"] == "rotate_test_service"
|
| 83 |
+
assert rotated_key["is_active"] is True
|
| 84 |
+
|
| 85 |
+
def test_delete_key():
|
| 86 |
+
"""Test key deletion."""
|
| 87 |
+
# Create a key first
|
| 88 |
+
client.post(
|
| 89 |
+
"/keys",
|
| 90 |
+
json={
|
| 91 |
+
"name": "delete_test_service",
|
| 92 |
+
"scopes": ["read:data"]
|
| 93 |
+
}
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Delete the key
|
| 97 |
+
delete_response = client.delete("/keys/delete_test_service")
|
| 98 |
+
assert delete_response.status_code == 200
|
| 99 |
+
|
| 100 |
+
# Verify key is gone
|
| 101 |
+
get_response = client.get("/keys/delete_test_service")
|
| 102 |
+
assert get_response.status_code == 404
|
| 103 |
+
|
| 104 |
+
def test_invalid_key_creation():
|
| 105 |
+
"""Test validation for key creation."""
|
| 106 |
+
response = client.post(
|
| 107 |
+
"/keys",
|
| 108 |
+
json={
|
| 109 |
+
"name": "invalid!name", # Invalid character in name
|
| 110 |
+
"scopes": ["invalid:scope"], # Invalid scope format
|
| 111 |
+
"rate_limit": -1.0, # Invalid rate limit
|
| 112 |
+
"burst_limit": 0 # Invalid burst limit
|
| 113 |
+
}
|
| 114 |
+
)
|
| 115 |
+
assert response.status_code == 400
|
| 116 |
+
|
| 117 |
+
def test_metrics():
|
| 118 |
+
"""Test metrics endpoint."""
|
| 119 |
+
response = client.get("/metrics")
|
| 120 |
+
assert response.status_code == 200
|
| 121 |
+
metrics = response.json()
|
| 122 |
+
assert "latency" in metrics
|
| 123 |
+
assert "rate_limiting" in metrics
|
| 124 |
+
assert "connections" in metrics
|
tests/__pycache__/test_gradio_ui.cpython-312-pytest-8.3.5.pyc
ADDED
|
Binary file (11 kB). View file
|
|
|
tests/__pycache__/test_security.cpython-312-pytest-8.3.5.pyc
ADDED
|
Binary file (19.1 kB). View file
|
|
|
tests/__pycache__/test_tools.cpython-312-pytest-8.3.5.pyc
ADDED
|
Binary file (25 kB). View file
|
|
|
tests/integration/test_assistant.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Integration tests for the AI Assistant.
|
| 3 |
+
|
| 4 |
+
These tests verify that different tools work together correctly in common scenarios
|
| 5 |
+
that the assistant might encounter.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from typing import List, Dict, Any
|
| 11 |
+
from unittest.mock import Mock, patch
|
| 12 |
+
|
| 13 |
+
from tools.base import ToolRegistry
|
| 14 |
+
from tools.timezone_tools import TimezoneTools
|
| 15 |
+
from tools.final_answer import FinalAnswerTool
|
| 16 |
+
from tools.exceptions import (
|
| 17 |
+
InvalidTimezoneError,
|
| 18 |
+
TimeFormatError,
|
| 19 |
+
ValidationError,
|
| 20 |
+
ToolNotFoundError
|
| 21 |
+
)
|
| 22 |
+
from tests.utils.mock_responses import (
|
| 23 |
+
MOCK_RESPONSES,
|
| 24 |
+
MOCK_ENDPOINTS,
|
| 25 |
+
create_mock_time_response,
|
| 26 |
+
create_mock_timezone_response,
|
| 27 |
+
create_mock_error_response
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
@pytest.fixture
|
| 31 |
+
def registry() -> ToolRegistry:
|
| 32 |
+
"""Fixture that provides a clean ToolRegistry instance with all tools registered."""
|
| 33 |
+
registry = ToolRegistry()
|
| 34 |
+
registry.clear()
|
| 35 |
+
# Register all tools that should be available to the assistant
|
| 36 |
+
registry.register(TimezoneTools)
|
| 37 |
+
registry.register(FinalAnswerTool)
|
| 38 |
+
return registry
|
| 39 |
+
|
| 40 |
+
@pytest.fixture
|
| 41 |
+
def mock_api_calls() -> None:
|
| 42 |
+
"""Fixture that mocks all API calls."""
|
| 43 |
+
with patch('tools.timezone_tools.requests.get') as mock_get:
|
| 44 |
+
def mock_api_response(*args: Any, **kwargs: Any) -> Mock:
|
| 45 |
+
url = args[0]
|
| 46 |
+
if "time" in url:
|
| 47 |
+
timezone_param = kwargs.get('params', {}).get('timezone', 'UTC')
|
| 48 |
+
return MOCK_ENDPOINTS["time"].get(
|
| 49 |
+
timezone_param,
|
| 50 |
+
MOCK_RESPONSES["invalid_timezone"]
|
| 51 |
+
)
|
| 52 |
+
elif "timezone" in url:
|
| 53 |
+
timezone_name = url.split('/')[-1]
|
| 54 |
+
return MOCK_ENDPOINTS["timezone"].get(
|
| 55 |
+
timezone_name,
|
| 56 |
+
MOCK_RESPONSES["invalid_timezone"]
|
| 57 |
+
)
|
| 58 |
+
return MOCK_RESPONSES["invalid_timezone"]
|
| 59 |
+
|
| 60 |
+
mock_get.side_effect = mock_api_response
|
| 61 |
+
yield mock_get
|
| 62 |
+
|
| 63 |
+
class TestTimezoneFinalAnswerIntegration:
|
| 64 |
+
"""Test integration between timezone and final answer tools."""
|
| 65 |
+
|
| 66 |
+
def test_timezone_conversion_to_final_answer(
|
| 67 |
+
self,
|
| 68 |
+
registry: ToolRegistry,
|
| 69 |
+
mock_api_calls: Mock
|
| 70 |
+
) -> None:
|
| 71 |
+
"""Test converting time and formatting it as a final answer."""
|
| 72 |
+
# Get tool instances
|
| 73 |
+
tz_tool = registry.get_tool("timezone")
|
| 74 |
+
answer_tool = registry.get_tool("final_answer")
|
| 75 |
+
|
| 76 |
+
# Convert time using timezone tool
|
| 77 |
+
time_str = "2024-01-01T12:00:00"
|
| 78 |
+
converted = tz_tool.convert_time(
|
| 79 |
+
time_str,
|
| 80 |
+
from_tz="UTC",
|
| 81 |
+
to_tz="America/New_York"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Format as final answer
|
| 85 |
+
answer = answer_tool.submit_answer(
|
| 86 |
+
answer=converted.strftime("%Y-%m-%d %H:%M:%S %Z"),
|
| 87 |
+
confidence=1.0,
|
| 88 |
+
source="Timezone conversion",
|
| 89 |
+
reasoning=f"Converted {time_str} from UTC to America/New_York"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# Verify the answer and API calls
|
| 93 |
+
assert "2024-01-01 07:00:00" in answer
|
| 94 |
+
assert "America/New_York" in answer
|
| 95 |
+
assert "Confidence: 100%" in answer
|
| 96 |
+
mock_api_calls.assert_called()
|
| 97 |
+
|
| 98 |
+
def test_multiple_timezone_conversions_to_final_answer(
|
| 99 |
+
self,
|
| 100 |
+
registry: ToolRegistry,
|
| 101 |
+
mock_api_calls: Mock
|
| 102 |
+
) -> None:
|
| 103 |
+
"""Test converting time to multiple zones and combining in final answer."""
|
| 104 |
+
tz_tool = registry.get_tool("timezone")
|
| 105 |
+
answer_tool = registry.get_tool("final_answer")
|
| 106 |
+
|
| 107 |
+
# Convert current time to multiple zones
|
| 108 |
+
zones = ["UTC", "America/New_York", "Asia/Tokyo"]
|
| 109 |
+
times = []
|
| 110 |
+
for zone in zones:
|
| 111 |
+
time = tz_tool.get_current_time(zone)
|
| 112 |
+
times.append(f"{zone}: {time.strftime('%H:%M:%S %Z')}")
|
| 113 |
+
|
| 114 |
+
# Combine results in final answer
|
| 115 |
+
answer = answer_tool.submit_answer(
|
| 116 |
+
answer="\n".join(times),
|
| 117 |
+
confidence=1.0,
|
| 118 |
+
source="Current time in multiple zones",
|
| 119 |
+
reasoning="Retrieved current time in UTC, New York, and Tokyo"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Verify the answer and API calls
|
| 123 |
+
for zone in zones:
|
| 124 |
+
assert zone in answer
|
| 125 |
+
assert "Confidence: 100%" in answer
|
| 126 |
+
assert mock_api_calls.call_count == len(zones)
|
| 127 |
+
|
| 128 |
+
class TestErrorHandlingIntegration:
|
| 129 |
+
"""Test error handling across multiple tools."""
|
| 130 |
+
|
| 131 |
+
def test_invalid_timezone_to_final_answer(
|
| 132 |
+
self,
|
| 133 |
+
registry: ToolRegistry,
|
| 134 |
+
mock_api_calls: Mock
|
| 135 |
+
) -> None:
|
| 136 |
+
"""Test handling invalid timezone error in final answer."""
|
| 137 |
+
tz_tool = registry.get_tool("timezone")
|
| 138 |
+
answer_tool = registry.get_tool("final_answer")
|
| 139 |
+
|
| 140 |
+
# Try to get time for invalid timezone
|
| 141 |
+
invalid_zone = "Invalid/Zone"
|
| 142 |
+
error_message = ""
|
| 143 |
+
try:
|
| 144 |
+
tz_tool.get_current_time(invalid_zone)
|
| 145 |
+
except InvalidTimezoneError as e:
|
| 146 |
+
error_message = str(e)
|
| 147 |
+
|
| 148 |
+
# Report error in final answer
|
| 149 |
+
answer = answer_tool.submit_answer(
|
| 150 |
+
answer=f"Error: {error_message}",
|
| 151 |
+
confidence=1.0,
|
| 152 |
+
source="Timezone operation",
|
| 153 |
+
reasoning=f"Failed to get time for invalid timezone: {invalid_zone}"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# Verify error handling and API calls
|
| 157 |
+
assert "Invalid timezone" in answer
|
| 158 |
+
assert invalid_zone in answer
|
| 159 |
+
assert "Error" in answer
|
| 160 |
+
mock_api_calls.assert_called_once()
|
| 161 |
+
|
| 162 |
+
def test_rate_limit_handling(
|
| 163 |
+
self,
|
| 164 |
+
registry: ToolRegistry,
|
| 165 |
+
mock_api_calls: Mock
|
| 166 |
+
) -> None:
|
| 167 |
+
"""Test handling rate limit errors."""
|
| 168 |
+
mock_api_calls.side_effect = lambda *args, **kwargs: MOCK_RESPONSES["rate_limit"]
|
| 169 |
+
|
| 170 |
+
tz_tool = registry.get_tool("timezone")
|
| 171 |
+
answer_tool = registry.get_tool("final_answer")
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
tz_tool.get_current_time("UTC")
|
| 175 |
+
except Exception as e:
|
| 176 |
+
error_message = str(e)
|
| 177 |
+
|
| 178 |
+
answer = answer_tool.submit_answer(
|
| 179 |
+
answer=f"Error: {error_message}",
|
| 180 |
+
confidence=1.0,
|
| 181 |
+
source="Rate limit handling",
|
| 182 |
+
reasoning="API rate limit exceeded"
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
assert "rate limit" in answer.lower()
|
| 186 |
+
assert "30" in error_message # Retry-After value
|
| 187 |
+
mock_api_calls.assert_called_once()
|
| 188 |
+
|
| 189 |
+
class TestToolRegistryIntegration:
|
| 190 |
+
"""Test tool registry integration scenarios."""
|
| 191 |
+
|
| 192 |
+
def test_tool_dependencies(
|
| 193 |
+
self,
|
| 194 |
+
registry: ToolRegistry,
|
| 195 |
+
mock_api_calls: Mock
|
| 196 |
+
) -> None:
|
| 197 |
+
"""Test tools that depend on other tools."""
|
| 198 |
+
class DependentTool(TimezoneTools):
|
| 199 |
+
def __init__(self) -> None:
|
| 200 |
+
super().__init__()
|
| 201 |
+
self._answer_tool = registry.get_tool("final_answer")
|
| 202 |
+
|
| 203 |
+
@property
|
| 204 |
+
def name(self) -> str:
|
| 205 |
+
return "dependent_tool"
|
| 206 |
+
|
| 207 |
+
def get_formatted_time(self, timezone_name: str) -> str:
|
| 208 |
+
"""Get current time and format it using final answer tool."""
|
| 209 |
+
time = self.get_current_time(timezone_name)
|
| 210 |
+
return self._answer_tool.submit_answer(
|
| 211 |
+
answer=time.strftime("%Y-%m-%d %H:%M:%S %Z"),
|
| 212 |
+
confidence=1.0,
|
| 213 |
+
source=f"Current time in {timezone_name}",
|
| 214 |
+
reasoning=f"Retrieved current time for {timezone_name}"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
# Register and test dependent tool
|
| 218 |
+
registry.register(DependentTool)
|
| 219 |
+
dependent_tool = registry.get_tool("dependent_tool")
|
| 220 |
+
|
| 221 |
+
result = dependent_tool.get_formatted_time("UTC")
|
| 222 |
+
assert "UTC" in result
|
| 223 |
+
assert "Confidence: 100%" in result
|
| 224 |
+
mock_api_calls.assert_called_once()
|
| 225 |
+
|
| 226 |
+
def test_tool_chain(
|
| 227 |
+
self,
|
| 228 |
+
registry: ToolRegistry,
|
| 229 |
+
mock_api_calls: Mock
|
| 230 |
+
) -> None:
|
| 231 |
+
"""Test chaining multiple tools together."""
|
| 232 |
+
tz_tool = registry.get_tool("timezone")
|
| 233 |
+
answer_tool = registry.get_tool("final_answer")
|
| 234 |
+
|
| 235 |
+
# Chain of operations:
|
| 236 |
+
# 1. Get current UTC time
|
| 237 |
+
# 2. Convert to NY time
|
| 238 |
+
# 3. Get timezone offset
|
| 239 |
+
# 4. Format as final answer
|
| 240 |
+
|
| 241 |
+
utc_time = tz_tool.get_current_time("UTC")
|
| 242 |
+
ny_time = tz_tool.convert_time(utc_time, to_tz="America/New_York")
|
| 243 |
+
ny_offset = tz_tool.get_timezone_offset("America/New_York")
|
| 244 |
+
|
| 245 |
+
answer = answer_tool.submit_answer(
|
| 246 |
+
answer=f"New York: {ny_time.strftime('%H:%M:%S')} ({ny_offset})",
|
| 247 |
+
confidence=1.0,
|
| 248 |
+
source="Time conversion chain",
|
| 249 |
+
reasoning=(
|
| 250 |
+
f"Converted current UTC time ({utc_time.strftime('%H:%M:%S')}) "
|
| 251 |
+
f"to New York time with offset {ny_offset}"
|
| 252 |
+
)
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
# Verify chain results and API calls
|
| 256 |
+
assert "New York" in answer
|
| 257 |
+
assert ny_offset in answer
|
| 258 |
+
assert "UTC" in answer
|
| 259 |
+
assert "Confidence: 100%" in answer
|
| 260 |
+
assert mock_api_calls.call_count >= 3 # At least 3 API calls in the chain
|
| 261 |
+
|
| 262 |
+
class TestErrorRecoveryIntegration:
|
| 263 |
+
"""Test error recovery scenarios across tools."""
|
| 264 |
+
|
| 265 |
+
def test_fallback_timezone_conversion(
|
| 266 |
+
self,
|
| 267 |
+
registry: ToolRegistry,
|
| 268 |
+
mock_api_calls: Mock
|
| 269 |
+
) -> None:
|
| 270 |
+
"""Test fallback to UTC when timezone conversion fails."""
|
| 271 |
+
tz_tool = registry.get_tool("timezone")
|
| 272 |
+
answer_tool = registry.get_tool("final_answer")
|
| 273 |
+
|
| 274 |
+
time_str = "2024-01-01T12:00:00"
|
| 275 |
+
target_zone = "Invalid/Zone"
|
| 276 |
+
|
| 277 |
+
# First call will fail, second call to UTC will succeed
|
| 278 |
+
mock_api_calls.side_effect = [
|
| 279 |
+
MOCK_RESPONSES["invalid_timezone"],
|
| 280 |
+
MOCK_RESPONSES["utc_time"]
|
| 281 |
+
]
|
| 282 |
+
|
| 283 |
+
try:
|
| 284 |
+
# Try to convert to invalid timezone
|
| 285 |
+
converted = tz_tool.convert_time(time_str, to_tz=target_zone)
|
| 286 |
+
except InvalidTimezoneError:
|
| 287 |
+
# Fallback to UTC
|
| 288 |
+
converted = tz_tool.convert_time(time_str, to_tz="UTC")
|
| 289 |
+
|
| 290 |
+
answer = answer_tool.submit_answer(
|
| 291 |
+
answer=converted.strftime("%Y-%m-%d %H:%M:%S %Z"),
|
| 292 |
+
confidence=0.8, # Lower confidence due to fallback
|
| 293 |
+
source="Timezone conversion with fallback",
|
| 294 |
+
reasoning=(
|
| 295 |
+
f"Failed to convert to {target_zone}, "
|
| 296 |
+
f"falling back to UTC time"
|
| 297 |
+
)
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
# Verify fallback behavior and API calls
|
| 301 |
+
assert "UTC" in answer
|
| 302 |
+
assert "Confidence: 80%" in answer
|
| 303 |
+
assert "falling back to UTC" in answer.lower()
|
| 304 |
+
assert mock_api_calls.call_count == 2
|
| 305 |
+
|
| 306 |
+
def test_graceful_degradation(
|
| 307 |
+
self,
|
| 308 |
+
registry: ToolRegistry,
|
| 309 |
+
mock_api_calls: Mock
|
| 310 |
+
) -> None:
|
| 311 |
+
"""Test graceful degradation when some operations fail."""
|
| 312 |
+
tz_tool = registry.get_tool("timezone")
|
| 313 |
+
answer_tool = registry.get_tool("final_answer")
|
| 314 |
+
|
| 315 |
+
# Configure mock to alternate between success and failure
|
| 316 |
+
mock_api_calls.side_effect = [
|
| 317 |
+
MOCK_RESPONSES["utc_time"],
|
| 318 |
+
MOCK_RESPONSES["invalid_timezone"],
|
| 319 |
+
MOCK_RESPONSES["ny_time"],
|
| 320 |
+
MOCK_RESPONSES["invalid_timezone"]
|
| 321 |
+
]
|
| 322 |
+
|
| 323 |
+
# Try to perform multiple operations, some of which will fail
|
| 324 |
+
results = []
|
| 325 |
+
zones = ["UTC", "Invalid/Zone1", "America/New_York", "Invalid/Zone2"]
|
| 326 |
+
|
| 327 |
+
for zone in zones:
|
| 328 |
+
try:
|
| 329 |
+
time = tz_tool.get_current_time(zone)
|
| 330 |
+
results.append(f"{zone}: {time.strftime('%H:%M:%S %Z')}")
|
| 331 |
+
except InvalidTimezoneError:
|
| 332 |
+
results.append(f"{zone}: Invalid timezone")
|
| 333 |
+
|
| 334 |
+
# Report partial results
|
| 335 |
+
answer = answer_tool.submit_answer(
|
| 336 |
+
answer="\n".join(results),
|
| 337 |
+
confidence=0.7, # Lower confidence due to partial failure
|
| 338 |
+
source="Multi-timezone operation with errors",
|
| 339 |
+
reasoning="Some timezone operations failed, reporting partial results"
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
# Verify partial results handling and API calls
|
| 343 |
+
assert "UTC" in answer
|
| 344 |
+
assert "America/New_York" in answer
|
| 345 |
+
assert "Invalid timezone" in answer
|
| 346 |
+
assert "Confidence: 70%" in answer
|
| 347 |
+
assert "partial results" in answer.lower()
|
| 348 |
+
assert mock_api_calls.call_count == 4
|
| 349 |
+
|
| 350 |
+
class TestConcurrentToolUsage:
|
| 351 |
+
"""Test using multiple tools concurrently."""
|
| 352 |
+
|
| 353 |
+
def test_parallel_timezone_queries(
|
| 354 |
+
self,
|
| 355 |
+
registry: ToolRegistry,
|
| 356 |
+
mock_api_calls: Mock
|
| 357 |
+
) -> None:
|
| 358 |
+
"""Test querying multiple timezones in parallel."""
|
| 359 |
+
import concurrent.futures
|
| 360 |
+
|
| 361 |
+
tz_tool = registry.get_tool("timezone")
|
| 362 |
+
answer_tool = registry.get_tool("final_answer")
|
| 363 |
+
|
| 364 |
+
zones = [
|
| 365 |
+
"UTC",
|
| 366 |
+
"America/New_York",
|
| 367 |
+
"Europe/London",
|
| 368 |
+
"Asia/Tokyo",
|
| 369 |
+
"Australia/Sydney"
|
| 370 |
+
]
|
| 371 |
+
|
| 372 |
+
def get_zone_time(zone: str) -> str:
|
| 373 |
+
time = tz_tool.get_current_time(zone)
|
| 374 |
+
return f"{zone}: {time.strftime('%H:%M:%S %Z')}"
|
| 375 |
+
|
| 376 |
+
# Configure mock responses for parallel calls
|
| 377 |
+
mock_api_calls.side_effect = [
|
| 378 |
+
MOCK_RESPONSES["utc_time"],
|
| 379 |
+
MOCK_RESPONSES["ny_time"],
|
| 380 |
+
create_mock_time_response("2024-01-01T12:00:00Z", "Europe/London"),
|
| 381 |
+
MOCK_RESPONSES["tokyo_time"],
|
| 382 |
+
create_mock_time_response("2024-01-01T23:00:00Z", "Australia/Sydney")
|
| 383 |
+
]
|
| 384 |
+
|
| 385 |
+
# Get times in parallel
|
| 386 |
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
| 387 |
+
futures = [executor.submit(get_zone_time, zone) for zone in zones]
|
| 388 |
+
results = [f.result() for f in futures]
|
| 389 |
+
|
| 390 |
+
# Combine results
|
| 391 |
+
answer = answer_tool.submit_answer(
|
| 392 |
+
answer="\n".join(results),
|
| 393 |
+
confidence=1.0,
|
| 394 |
+
source="Parallel timezone queries",
|
| 395 |
+
reasoning="Retrieved current time for multiple zones concurrently"
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
# Verify parallel execution results and API calls
|
| 399 |
+
for zone in zones:
|
| 400 |
+
assert zone in answer
|
| 401 |
+
assert "Confidence: 100%" in answer
|
| 402 |
+
assert mock_api_calls.call_count == len(zones)
|
tests/test_api_logging.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the API logging functionality.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Dict, Any, List
|
| 9 |
+
import pytest
|
| 10 |
+
from unittest.mock import Mock, patch
|
| 11 |
+
|
| 12 |
+
from tools.api_logging import log_api_call, sanitize_headers, sanitize_url
|
| 13 |
+
from tools.exceptions import APIError, APIRateLimitError, APITimeoutError
|
| 14 |
+
from tools.error_recovery import RetryConfig, CircuitBreaker
|
| 15 |
+
|
| 16 |
+
# Test data
|
| 17 |
+
TEST_URL = "https://api.example.com/v1/data?api_key=secret123&format=json"
|
| 18 |
+
TEST_HEADERS = {
|
| 19 |
+
"Authorization": "Bearer token123",
|
| 20 |
+
"Content-Type": "application/json",
|
| 21 |
+
"X-API-Key": "secret456"
|
| 22 |
+
}
|
| 23 |
+
TEST_REQUEST_BODY = {
|
| 24 |
+
"query": "test",
|
| 25 |
+
"limit": 10
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
def test_sanitize_headers() -> None:
|
| 29 |
+
"""Test sanitization of headers."""
|
| 30 |
+
sanitized = sanitize_headers(TEST_HEADERS)
|
| 31 |
+
assert sanitized["Authorization"] == "****"
|
| 32 |
+
assert sanitized["Content-Type"] == "application/json"
|
| 33 |
+
assert sanitized["X-API-Key"] == "****"
|
| 34 |
+
|
| 35 |
+
def test_sanitize_url() -> None:
|
| 36 |
+
"""Test sanitization of URLs."""
|
| 37 |
+
sanitized = sanitize_url(TEST_URL)
|
| 38 |
+
assert "api_key=****" in sanitized
|
| 39 |
+
assert "secret123" not in sanitized
|
| 40 |
+
assert "format=json" in sanitized
|
| 41 |
+
|
| 42 |
+
@pytest.fixture
|
| 43 |
+
def mock_logger() -> Mock:
|
| 44 |
+
"""Fixture that provides a mock logger."""
|
| 45 |
+
with patch('tools.api_logging.logger') as mock_log:
|
| 46 |
+
yield mock_log
|
| 47 |
+
|
| 48 |
+
def test_successful_api_call(mock_logger: Mock) -> None:
|
| 49 |
+
"""Test logging of successful API calls."""
|
| 50 |
+
|
| 51 |
+
@log_api_call
|
| 52 |
+
def mock_api_call(url: str, headers: Dict[str, str], json: Dict[str, Any]) -> Mock:
|
| 53 |
+
response = Mock()
|
| 54 |
+
response.status_code = 200
|
| 55 |
+
response.json.return_value = {"status": "success"}
|
| 56 |
+
return response
|
| 57 |
+
|
| 58 |
+
response = mock_api_call(TEST_URL, headers=TEST_HEADERS, json=TEST_REQUEST_BODY)
|
| 59 |
+
|
| 60 |
+
# Check request logging
|
| 61 |
+
request_call = mock_logger.info.call_args_list[0]
|
| 62 |
+
request_log = json.loads(request_call.args[0].replace("API Request: ", ""))
|
| 63 |
+
assert "timestamp" in request_log
|
| 64 |
+
assert request_log["function"] == "mock_api_call"
|
| 65 |
+
assert "api_key=****" in request_log["url"]
|
| 66 |
+
assert request_log["headers"]["Authorization"] == "****"
|
| 67 |
+
|
| 68 |
+
# Check response logging
|
| 69 |
+
response_call = mock_logger.info.call_args_list[1]
|
| 70 |
+
response_log = json.loads(response_call.args[0].replace("API Response: ", ""))
|
| 71 |
+
assert response_log["status"] == 200
|
| 72 |
+
assert response_log["body"] == {"status": "success"}
|
| 73 |
+
|
| 74 |
+
def test_api_call_with_retry(mock_logger: Mock) -> None:
|
| 75 |
+
"""Test API call with retry configuration."""
|
| 76 |
+
attempts = 0
|
| 77 |
+
|
| 78 |
+
@log_api_call(
|
| 79 |
+
retry_config=RetryConfig(max_retries=2, initial_delay=0.01),
|
| 80 |
+
circuit_breaker=CircuitBreaker(failure_threshold=3)
|
| 81 |
+
)
|
| 82 |
+
def failing_then_succeeding(url: str) -> Mock:
|
| 83 |
+
nonlocal attempts
|
| 84 |
+
attempts += 1
|
| 85 |
+
if attempts < 2:
|
| 86 |
+
raise TimeoutError("Connection timeout")
|
| 87 |
+
response = Mock()
|
| 88 |
+
response.status_code = 200
|
| 89 |
+
response.json.return_value = {"status": "success"}
|
| 90 |
+
return response
|
| 91 |
+
|
| 92 |
+
response = failing_then_succeeding(TEST_URL)
|
| 93 |
+
|
| 94 |
+
# Should have logged initial failure and final success
|
| 95 |
+
assert attempts == 2
|
| 96 |
+
assert len(mock_logger.info.call_args_list) == 4 # 2 requests + 2 responses
|
| 97 |
+
assert len(mock_logger.error.call_args_list) == 1 # 1 error
|
| 98 |
+
|
| 99 |
+
# Check final response
|
| 100 |
+
assert response.status_code == 200
|
| 101 |
+
assert response.json() == {"status": "success"}
|
| 102 |
+
|
| 103 |
+
def test_api_call_with_circuit_breaker(mock_logger: Mock) -> None:
|
| 104 |
+
"""Test API call with circuit breaker."""
|
| 105 |
+
attempts = 0
|
| 106 |
+
cb = CircuitBreaker(failure_threshold=2, reset_timeout=0.1)
|
| 107 |
+
|
| 108 |
+
@log_api_call(circuit_breaker=cb)
|
| 109 |
+
def always_failing(url: str) -> None:
|
| 110 |
+
nonlocal attempts
|
| 111 |
+
attempts += 1
|
| 112 |
+
raise ConnectionError("Connection failed")
|
| 113 |
+
|
| 114 |
+
# First call should fail normally
|
| 115 |
+
with pytest.raises(APIError):
|
| 116 |
+
always_failing(TEST_URL)
|
| 117 |
+
assert attempts == 1
|
| 118 |
+
|
| 119 |
+
# Second call should fail and open the circuit
|
| 120 |
+
with pytest.raises(APIError):
|
| 121 |
+
always_failing(TEST_URL)
|
| 122 |
+
assert attempts == 2
|
| 123 |
+
|
| 124 |
+
# Third call should fail immediately with circuit breaker error
|
| 125 |
+
with pytest.raises(APIError) as exc_info:
|
| 126 |
+
always_failing(TEST_URL)
|
| 127 |
+
assert attempts == 2 # No additional attempt
|
| 128 |
+
assert "Circuit breaker is open" in str(exc_info.value)
|
| 129 |
+
|
| 130 |
+
def test_api_call_with_rate_limit_retry(mock_logger: Mock) -> None:
|
| 131 |
+
"""Test API call with rate limit retry."""
|
| 132 |
+
attempts = 0
|
| 133 |
+
|
| 134 |
+
@log_api_call(
|
| 135 |
+
retry_config=RetryConfig(max_retries=1, initial_delay=0.01)
|
| 136 |
+
)
|
| 137 |
+
def rate_limited(url: str) -> None:
|
| 138 |
+
nonlocal attempts
|
| 139 |
+
attempts += 1
|
| 140 |
+
response = Mock()
|
| 141 |
+
response.status_code = 429
|
| 142 |
+
response.headers = {"Retry-After": "0.1"}
|
| 143 |
+
error = Exception("Rate limit exceeded")
|
| 144 |
+
error.response = response
|
| 145 |
+
raise error
|
| 146 |
+
|
| 147 |
+
with pytest.raises(APIRateLimitError) as exc_info:
|
| 148 |
+
rate_limited(TEST_URL)
|
| 149 |
+
|
| 150 |
+
assert attempts == 2 # Initial + 1 retry
|
| 151 |
+
error = exc_info.value
|
| 152 |
+
assert error.retry_after == 0.1
|
| 153 |
+
assert "rate limit exceeded" in str(error).lower()
|
| 154 |
+
|
| 155 |
+
def test_api_call_with_non_json_response(mock_logger: Mock) -> None:
|
| 156 |
+
"""Test API call with non-JSON response."""
|
| 157 |
+
|
| 158 |
+
@log_api_call
|
| 159 |
+
def text_response(url: str) -> Mock:
|
| 160 |
+
response = Mock()
|
| 161 |
+
response.status_code = 200
|
| 162 |
+
response.json.side_effect = ValueError("Invalid JSON")
|
| 163 |
+
response.__str__ = lambda self: "Plain text response"
|
| 164 |
+
return response
|
| 165 |
+
|
| 166 |
+
response = text_response(TEST_URL)
|
| 167 |
+
|
| 168 |
+
# Check response logging
|
| 169 |
+
response_call = mock_logger.info.call_args_list[1]
|
| 170 |
+
response_log = json.loads(response_call.args[0].replace("API Response: ", ""))
|
| 171 |
+
assert response_log["status"] == 200
|
| 172 |
+
assert response_log["body"] == "Plain text response"
|
| 173 |
+
|
| 174 |
+
def test_api_call_with_custom_retry_conditions(mock_logger: Mock) -> None:
|
| 175 |
+
"""Test API call with custom retry conditions."""
|
| 176 |
+
attempts = 0
|
| 177 |
+
|
| 178 |
+
class CustomError(Exception):
|
| 179 |
+
pass
|
| 180 |
+
|
| 181 |
+
retry_config = RetryConfig(
|
| 182 |
+
max_retries=2,
|
| 183 |
+
initial_delay=0.01,
|
| 184 |
+
retry_on={CustomError, APITimeoutError}
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
@log_api_call(retry_config=retry_config)
|
| 188 |
+
def custom_error(url: str) -> None:
|
| 189 |
+
nonlocal attempts
|
| 190 |
+
attempts += 1
|
| 191 |
+
raise CustomError("Custom error")
|
| 192 |
+
|
| 193 |
+
with pytest.raises(APIError):
|
| 194 |
+
custom_error(TEST_URL)
|
| 195 |
+
|
| 196 |
+
assert attempts == 3 # Initial + 2 retries
|
| 197 |
+
|
| 198 |
+
def test_api_call_logs_sanitized_data(mock_logger: Mock) -> None:
|
| 199 |
+
"""Test that API call logs are properly sanitized."""
|
| 200 |
+
sensitive_data = {
|
| 201 |
+
"api_key": "secret123",
|
| 202 |
+
"password": "sensitive",
|
| 203 |
+
"query": "safe_data"
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
@log_api_call(log_request_body=True)
|
| 207 |
+
def sensitive_call(url: str, json: Dict[str, Any]) -> Mock:
|
| 208 |
+
response = Mock()
|
| 209 |
+
response.status_code = 200
|
| 210 |
+
response.json.return_value = {"status": "success"}
|
| 211 |
+
return response
|
| 212 |
+
|
| 213 |
+
sensitive_call(TEST_URL, json=sensitive_data)
|
| 214 |
+
|
| 215 |
+
# Check request logging
|
| 216 |
+
request_call = mock_logger.info.call_args_list[0]
|
| 217 |
+
request_log = json.loads(request_call.args[0].replace("API Request: ", ""))
|
| 218 |
+
log_body = json.loads(request_log["body"])
|
| 219 |
+
assert log_body["api_key"] == "secret123" # Body content isn't sanitized
|
| 220 |
+
assert "api_key=****" in request_log["url"] # URL is sanitized
|
tests/test_api_optimization.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for API optimization utilities."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from datetime import timedelta
|
| 5 |
+
from typing import Any, Dict, List
|
| 6 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 7 |
+
|
| 8 |
+
import aiohttp
|
| 9 |
+
import pytest
|
| 10 |
+
from aioresponses import aioresponses
|
| 11 |
+
|
| 12 |
+
from tools.api_optimization import (
|
| 13 |
+
APIClient,
|
| 14 |
+
BatchProcessor,
|
| 15 |
+
ConnectionPool,
|
| 16 |
+
RateLimiter,
|
| 17 |
+
with_rate_limit,
|
| 18 |
+
with_retry,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
def rate_limiter() -> RateLimiter:
|
| 23 |
+
"""Create a test rate limiter."""
|
| 24 |
+
return RateLimiter(rate=10.0, burst=20)
|
| 25 |
+
|
| 26 |
+
@pytest.fixture
|
| 27 |
+
def batch_processor() -> BatchProcessor:
|
| 28 |
+
"""Create a test batch processor."""
|
| 29 |
+
return BatchProcessor(batch_size=2, flush_interval=0.1)
|
| 30 |
+
|
| 31 |
+
@pytest.fixture
|
| 32 |
+
def connection_pool() -> ConnectionPool:
|
| 33 |
+
"""Create a test connection pool."""
|
| 34 |
+
return ConnectionPool(pool_size=2, timeout=1.0)
|
| 35 |
+
|
| 36 |
+
@pytest.fixture
|
| 37 |
+
def api_client() -> APIClient:
|
| 38 |
+
"""Create a test API client."""
|
| 39 |
+
return APIClient(
|
| 40 |
+
base_url="http://test.api",
|
| 41 |
+
pool_size=2,
|
| 42 |
+
rate_limit=10.0,
|
| 43 |
+
burst_limit=20,
|
| 44 |
+
batch_size=2
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
async def test_rate_limiter(rate_limiter: RateLimiter):
|
| 48 |
+
"""Test rate limiter functionality."""
|
| 49 |
+
# Test initial state
|
| 50 |
+
assert rate_limiter.tokens == rate_limiter.burst
|
| 51 |
+
|
| 52 |
+
# Test token acquisition
|
| 53 |
+
wait_time = rate_limiter.acquire(1.0)
|
| 54 |
+
assert wait_time == 0.0
|
| 55 |
+
assert rate_limiter.tokens == rate_limiter.burst - 1
|
| 56 |
+
|
| 57 |
+
# Test burst handling
|
| 58 |
+
wait_time = rate_limiter.acquire(rate_limiter.burst)
|
| 59 |
+
assert wait_time > 0.0
|
| 60 |
+
assert rate_limiter.tokens == 0.0
|
| 61 |
+
|
| 62 |
+
async def test_batch_processor(batch_processor: BatchProcessor):
|
| 63 |
+
"""Test batch processor functionality."""
|
| 64 |
+
# Test batch accumulation
|
| 65 |
+
await batch_processor.add({"key": "value1"})
|
| 66 |
+
assert len(batch_processor.batch) == 1
|
| 67 |
+
|
| 68 |
+
# Test batch flush on size
|
| 69 |
+
await batch_processor.add({"key": "value2"})
|
| 70 |
+
assert len(batch_processor.batch) == 0
|
| 71 |
+
|
| 72 |
+
# Test batch flush on interval
|
| 73 |
+
await batch_processor.add({"key": "value3"})
|
| 74 |
+
await asyncio.sleep(0.2) # Wait for flush interval
|
| 75 |
+
assert len(batch_processor.batch) == 0
|
| 76 |
+
|
| 77 |
+
async def test_connection_pool():
|
| 78 |
+
"""Test connection pool functionality."""
|
| 79 |
+
pool = ConnectionPool(pool_size=2)
|
| 80 |
+
|
| 81 |
+
async with pool as p:
|
| 82 |
+
assert isinstance(p, ConnectionPool)
|
| 83 |
+
assert p.session is not None
|
| 84 |
+
|
| 85 |
+
with aioresponses() as mocked:
|
| 86 |
+
url = "http://test.api/endpoint"
|
| 87 |
+
mocked.get(url, status=200, payload={"key": "value"})
|
| 88 |
+
|
| 89 |
+
response = await p.request("GET", url)
|
| 90 |
+
assert response.status == 200
|
| 91 |
+
|
| 92 |
+
async def test_retry_decorator():
|
| 93 |
+
"""Test retry decorator functionality."""
|
| 94 |
+
mock_func = AsyncMock(side_effect=[Exception, Exception, "success"])
|
| 95 |
+
|
| 96 |
+
@with_retry(max_attempts=3, min_wait=0.1, max_wait=0.3)
|
| 97 |
+
async def test_func():
|
| 98 |
+
return await mock_func()
|
| 99 |
+
|
| 100 |
+
result = await test_func()
|
| 101 |
+
assert result == "success"
|
| 102 |
+
assert mock_func.call_count == 3
|
| 103 |
+
|
| 104 |
+
async def test_rate_limit_decorator():
|
| 105 |
+
"""Test rate limit decorator functionality."""
|
| 106 |
+
call_times: List[float] = []
|
| 107 |
+
|
| 108 |
+
@with_rate_limit(rate=10.0, burst=2)
|
| 109 |
+
async def test_func():
|
| 110 |
+
call_times.append(asyncio.get_event_loop().time())
|
| 111 |
+
|
| 112 |
+
# Make multiple calls
|
| 113 |
+
await asyncio.gather(*[test_func() for _ in range(3)])
|
| 114 |
+
|
| 115 |
+
# Verify rate limiting
|
| 116 |
+
time_diffs = [
|
| 117 |
+
call_times[i] - call_times[i-1]
|
| 118 |
+
for i in range(1, len(call_times))
|
| 119 |
+
]
|
| 120 |
+
assert all(diff >= 0.1 for diff in time_diffs) # At least 0.1s between calls
|
| 121 |
+
|
| 122 |
+
async def test_api_client(api_client: APIClient):
|
| 123 |
+
"""Test API client functionality."""
|
| 124 |
+
with aioresponses() as mocked:
|
| 125 |
+
# Test single request
|
| 126 |
+
url = "http://test.api/test"
|
| 127 |
+
mocked.post(url, status=200, payload={"result": "success"})
|
| 128 |
+
|
| 129 |
+
response = await api_client.request(
|
| 130 |
+
"POST",
|
| 131 |
+
"test",
|
| 132 |
+
json={"data": "test"}
|
| 133 |
+
)
|
| 134 |
+
assert response == {"result": "success"}
|
| 135 |
+
|
| 136 |
+
# Test batch request
|
| 137 |
+
items = [
|
| 138 |
+
{"id": 1, "data": "test1"},
|
| 139 |
+
{"id": 2, "data": "test2"}
|
| 140 |
+
]
|
| 141 |
+
mocked.post(url, repeat=True, status=200, payload={"result": "success"})
|
| 142 |
+
|
| 143 |
+
results = await api_client.batch_request("POST", "test", items)
|
| 144 |
+
assert len(results) == 2
|
| 145 |
+
assert all(r == {"result": "success"} for r in results)
|
| 146 |
+
|
| 147 |
+
async def test_api_client_error_handling(api_client: APIClient):
|
| 148 |
+
"""Test API client error handling."""
|
| 149 |
+
with aioresponses() as mocked:
|
| 150 |
+
url = "http://test.api/test"
|
| 151 |
+
|
| 152 |
+
# Test retry on error
|
| 153 |
+
mocked.post(
|
| 154 |
+
url,
|
| 155 |
+
status=500,
|
| 156 |
+
repeat=True,
|
| 157 |
+
exception=aiohttp.ClientError()
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
with pytest.raises(aiohttp.ClientError):
|
| 161 |
+
await api_client.request("POST", "test", json={"data": "test"})
|
| 162 |
+
|
| 163 |
+
async def test_api_client_rate_limiting(api_client: APIClient):
|
| 164 |
+
"""Test API client rate limiting."""
|
| 165 |
+
with aioresponses() as mocked:
|
| 166 |
+
url = "http://test.api/test"
|
| 167 |
+
mocked.post(
|
| 168 |
+
url,
|
| 169 |
+
status=200,
|
| 170 |
+
payload={"result": "success"},
|
| 171 |
+
repeat=True
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
start_time = asyncio.get_event_loop().time()
|
| 175 |
+
|
| 176 |
+
# Make multiple requests
|
| 177 |
+
responses = await asyncio.gather(*[
|
| 178 |
+
api_client.request("POST", "test", json={"data": f"test{i}"})
|
| 179 |
+
for i in range(5)
|
| 180 |
+
])
|
| 181 |
+
|
| 182 |
+
end_time = asyncio.get_event_loop().time()
|
| 183 |
+
duration = end_time - start_time
|
| 184 |
+
|
| 185 |
+
# Verify rate limiting
|
| 186 |
+
assert duration >= 0.4 # At least 0.4s for 5 requests at 10 RPS
|
| 187 |
+
assert all(r == {"result": "success"} for r in responses)
|
| 188 |
+
|
| 189 |
+
async def test_connection_pool_limits(api_client: APIClient):
|
| 190 |
+
"""Test connection pool concurrent connection limits."""
|
| 191 |
+
with aioresponses() as mocked:
|
| 192 |
+
url = "http://test.api/test"
|
| 193 |
+
mocked.get(
|
| 194 |
+
url,
|
| 195 |
+
status=200,
|
| 196 |
+
payload={"result": "success"},
|
| 197 |
+
repeat=True
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
async def make_request():
|
| 201 |
+
return await api_client.request("GET", "test")
|
| 202 |
+
|
| 203 |
+
# Make concurrent requests
|
| 204 |
+
results = await asyncio.gather(*[
|
| 205 |
+
make_request() for _ in range(5)
|
| 206 |
+
])
|
| 207 |
+
|
| 208 |
+
assert len(results) == 5
|
| 209 |
+
assert all(r == {"result": "success"} for r in results)
|
tests/test_cache.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the caching module."""
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from unittest.mock import MagicMock, patch
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
import redis
|
| 10 |
+
from redis.exceptions import RedisError
|
| 11 |
+
|
| 12 |
+
from tools.cache import CacheConfig, CacheEntry, CacheManager, LRUCache, cached
|
| 13 |
+
from tools.stats import UsageStats
|
| 14 |
+
|
| 15 |
+
@pytest.fixture
|
| 16 |
+
def cache_config() -> CacheConfig:
|
| 17 |
+
"""Create a test cache configuration."""
|
| 18 |
+
return CacheConfig(
|
| 19 |
+
redis_host="localhost",
|
| 20 |
+
redis_port=6379,
|
| 21 |
+
redis_db=0,
|
| 22 |
+
default_ttl=60,
|
| 23 |
+
local_cache_size=10,
|
| 24 |
+
enable_local_cache=True,
|
| 25 |
+
enable_redis_cache=True
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
def mock_redis() -> MagicMock:
|
| 30 |
+
"""Create a mock Redis client."""
|
| 31 |
+
with patch("redis.Redis") as mock:
|
| 32 |
+
mock.return_value.ping.return_value = True
|
| 33 |
+
yield mock.return_value
|
| 34 |
+
|
| 35 |
+
@pytest.fixture
|
| 36 |
+
def mock_stats() -> MagicMock:
|
| 37 |
+
"""Create a mock stats manager."""
|
| 38 |
+
return MagicMock(spec=UsageStats)
|
| 39 |
+
|
| 40 |
+
@pytest.fixture
|
| 41 |
+
def cache_manager(
|
| 42 |
+
cache_config: CacheConfig,
|
| 43 |
+
mock_redis: MagicMock,
|
| 44 |
+
mock_stats: MagicMock
|
| 45 |
+
) -> CacheManager:
|
| 46 |
+
"""Create a test cache manager."""
|
| 47 |
+
with patch("redis.Redis", return_value=mock_redis):
|
| 48 |
+
return CacheManager(cache_config, mock_stats)
|
| 49 |
+
|
| 50 |
+
def test_lru_cache_basic_operations():
|
| 51 |
+
"""Test basic LRU cache operations."""
|
| 52 |
+
cache: LRUCache[str, int] = LRUCache(max_size=2)
|
| 53 |
+
|
| 54 |
+
# Test set and get
|
| 55 |
+
cache.set("a", 1)
|
| 56 |
+
cache.set("b", 2)
|
| 57 |
+
assert cache.get("a") == 1
|
| 58 |
+
assert cache.get("b") == 2
|
| 59 |
+
|
| 60 |
+
# Test LRU eviction
|
| 61 |
+
cache.set("c", 3) # Should evict "a"
|
| 62 |
+
assert cache.get("a") is None
|
| 63 |
+
assert cache.get("b") == 2
|
| 64 |
+
assert cache.get("c") == 3
|
| 65 |
+
|
| 66 |
+
# Test removal
|
| 67 |
+
cache.remove("b")
|
| 68 |
+
assert cache.get("b") is None
|
| 69 |
+
|
| 70 |
+
# Test clear
|
| 71 |
+
cache.clear()
|
| 72 |
+
assert cache.get("c") is None
|
| 73 |
+
|
| 74 |
+
def test_lru_cache_ttl():
|
| 75 |
+
"""Test LRU cache TTL functionality."""
|
| 76 |
+
cache: LRUCache[str, int] = LRUCache(max_size=2)
|
| 77 |
+
|
| 78 |
+
# Set with TTL
|
| 79 |
+
cache.set("a", 1, ttl=1)
|
| 80 |
+
assert cache.get("a") == 1
|
| 81 |
+
|
| 82 |
+
# Wait for expiration
|
| 83 |
+
time.sleep(1.1)
|
| 84 |
+
assert cache.get("a") is None
|
| 85 |
+
|
| 86 |
+
def test_cache_entry_serialization():
|
| 87 |
+
"""Test cache entry serialization."""
|
| 88 |
+
entry = CacheEntry[str](
|
| 89 |
+
value="test",
|
| 90 |
+
created_at=datetime.utcnow(),
|
| 91 |
+
expires_at=datetime.utcnow() + timedelta(seconds=60),
|
| 92 |
+
hit_count=5
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Test serialization
|
| 96 |
+
json_str = entry.to_json()
|
| 97 |
+
|
| 98 |
+
# Test deserialization
|
| 99 |
+
loaded_entry = CacheEntry.from_json(json_str, str)
|
| 100 |
+
assert loaded_entry.value == entry.value
|
| 101 |
+
assert loaded_entry.hit_count == entry.hit_count
|
| 102 |
+
assert abs((loaded_entry.created_at - entry.created_at).total_seconds()) < 1
|
| 103 |
+
assert abs((loaded_entry.expires_at - entry.expires_at).total_seconds()) < 1
|
| 104 |
+
|
| 105 |
+
def test_cache_manager_local_only(cache_config: CacheConfig, mock_stats: MagicMock):
|
| 106 |
+
"""Test cache manager with local cache only."""
|
| 107 |
+
config = cache_config.copy()
|
| 108 |
+
config.enable_redis_cache = False
|
| 109 |
+
manager = CacheManager(config, mock_stats)
|
| 110 |
+
|
| 111 |
+
# Test set and get
|
| 112 |
+
manager.set("key", "value")
|
| 113 |
+
assert manager.get("key", str) == "value"
|
| 114 |
+
|
| 115 |
+
# Test stats recording
|
| 116 |
+
mock_stats.record_cache_operation.assert_called()
|
| 117 |
+
|
| 118 |
+
def test_cache_manager_redis_operations(
|
| 119 |
+
cache_manager: CacheManager,
|
| 120 |
+
mock_redis: MagicMock
|
| 121 |
+
):
|
| 122 |
+
"""Test cache manager Redis operations."""
|
| 123 |
+
# Setup mock Redis response
|
| 124 |
+
entry = CacheEntry[str](value="test", expires_at=datetime.utcnow() + timedelta(seconds=60))
|
| 125 |
+
mock_redis.get.return_value = entry.to_json()
|
| 126 |
+
|
| 127 |
+
# Test get from Redis
|
| 128 |
+
value = cache_manager.get("key", str)
|
| 129 |
+
assert value == "test"
|
| 130 |
+
mock_redis.get.assert_called_with("key")
|
| 131 |
+
|
| 132 |
+
# Test set to Redis
|
| 133 |
+
cache_manager.set("key", "value", ttl=60)
|
| 134 |
+
mock_redis.setex.assert_called()
|
| 135 |
+
|
| 136 |
+
# Test Redis error handling
|
| 137 |
+
mock_redis.get.side_effect = RedisError("Test error")
|
| 138 |
+
assert cache_manager.get("key", str) is None
|
| 139 |
+
|
| 140 |
+
def test_cache_manager_two_level_caching(
|
| 141 |
+
cache_manager: CacheManager,
|
| 142 |
+
mock_redis: MagicMock
|
| 143 |
+
):
|
| 144 |
+
"""Test two-level caching strategy."""
|
| 145 |
+
# Set value
|
| 146 |
+
cache_manager.set("key", "value")
|
| 147 |
+
|
| 148 |
+
# First get should use Redis
|
| 149 |
+
entry = CacheEntry[str](value="value", expires_at=datetime.utcnow() + timedelta(seconds=60))
|
| 150 |
+
mock_redis.get.return_value = entry.to_json()
|
| 151 |
+
assert cache_manager.get("key", str) == "value"
|
| 152 |
+
mock_redis.get.assert_called_once()
|
| 153 |
+
|
| 154 |
+
# Second get should use local cache
|
| 155 |
+
mock_redis.get.reset_mock()
|
| 156 |
+
assert cache_manager.get("key", str) == "value"
|
| 157 |
+
mock_redis.get.assert_not_called()
|
| 158 |
+
|
| 159 |
+
@pytest.mark.parametrize("ttl", [None, 60])
|
| 160 |
+
def test_cached_decorator(cache_manager: CacheManager, ttl: Optional[int]):
|
| 161 |
+
"""Test cached decorator."""
|
| 162 |
+
call_count = 0
|
| 163 |
+
|
| 164 |
+
@cached(ttl=ttl, cache_manager=cache_manager)
|
| 165 |
+
def test_func(x: int, y: str = "default") -> str:
|
| 166 |
+
nonlocal call_count
|
| 167 |
+
call_count += 1
|
| 168 |
+
return f"{x}-{y}"
|
| 169 |
+
|
| 170 |
+
# First call should execute function
|
| 171 |
+
result1 = test_func(1, y="test")
|
| 172 |
+
assert result1 == "1-test"
|
| 173 |
+
assert call_count == 1
|
| 174 |
+
|
| 175 |
+
# Second call should use cache
|
| 176 |
+
result2 = test_func(1, y="test")
|
| 177 |
+
assert result2 == "1-test"
|
| 178 |
+
assert call_count == 1
|
| 179 |
+
|
| 180 |
+
# Different arguments should execute function
|
| 181 |
+
result3 = test_func(2, y="test")
|
| 182 |
+
assert result3 == "2-test"
|
| 183 |
+
assert call_count == 2
|
| 184 |
+
|
| 185 |
+
def test_cached_decorator_type_handling(cache_manager: CacheManager):
|
| 186 |
+
"""Test cached decorator type handling."""
|
| 187 |
+
@cached(cache_manager=cache_manager)
|
| 188 |
+
def test_func() -> Optional[str]:
|
| 189 |
+
return "test"
|
| 190 |
+
|
| 191 |
+
# Should correctly handle Optional types
|
| 192 |
+
result = test_func()
|
| 193 |
+
assert result == "test"
|
| 194 |
+
|
| 195 |
+
# Should correctly cache and return the value
|
| 196 |
+
cached_result = test_func()
|
| 197 |
+
assert cached_result == "test"
|
tests/test_cache_invalidation.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for cache invalidation strategies."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timedelta, timezone
|
| 4 |
+
from typing import List, Pattern
|
| 5 |
+
from unittest.mock import MagicMock, call
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
from typing_extensions import TypeAlias
|
| 9 |
+
|
| 10 |
+
from tools.cache import CacheConfig, CacheManager
|
| 11 |
+
from tools.cache_invalidation import (
|
| 12 |
+
CacheInvalidator,
|
| 13 |
+
InvalidationEvent,
|
| 14 |
+
InvalidationStrategy,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
# Type aliases
|
| 18 |
+
PatternType: TypeAlias = "str | Pattern[str]"
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
def cache_config() -> CacheConfig:
|
| 22 |
+
"""Create a test cache configuration."""
|
| 23 |
+
return CacheConfig(
|
| 24 |
+
redis_host="localhost",
|
| 25 |
+
redis_port=6379,
|
| 26 |
+
redis_db=0,
|
| 27 |
+
default_ttl=60,
|
| 28 |
+
local_cache_size=10,
|
| 29 |
+
enable_local_cache=True,
|
| 30 |
+
enable_redis_cache=True
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
@pytest.fixture
|
| 34 |
+
def mock_cache_manager(cache_config: CacheConfig) -> MagicMock:
|
| 35 |
+
"""Create a mock cache manager."""
|
| 36 |
+
manager = MagicMock(spec=CacheManager)
|
| 37 |
+
manager.config = cache_config
|
| 38 |
+
return manager
|
| 39 |
+
|
| 40 |
+
@pytest.fixture
|
| 41 |
+
def invalidator(mock_cache_manager: MagicMock) -> CacheInvalidator:
|
| 42 |
+
"""Create a test cache invalidator."""
|
| 43 |
+
return CacheInvalidator(mock_cache_manager)
|
| 44 |
+
|
| 45 |
+
def test_add_and_remove_tags(invalidator: CacheInvalidator):
|
| 46 |
+
"""Test adding and removing tags from cache keys."""
|
| 47 |
+
# Add tags
|
| 48 |
+
invalidator.add_tags("key1", ["tag1", "tag2"])
|
| 49 |
+
invalidator.add_tags("key2", ["tag2", "tag3"])
|
| 50 |
+
|
| 51 |
+
# Verify tag mappings
|
| 52 |
+
assert invalidator.key_tags["key1"] == {"tag1", "tag2"}
|
| 53 |
+
assert invalidator.key_tags["key2"] == {"tag2", "tag3"}
|
| 54 |
+
assert invalidator.tag_keys["tag1"] == {"key1"}
|
| 55 |
+
assert invalidator.tag_keys["tag2"] == {"key1", "key2"}
|
| 56 |
+
assert invalidator.tag_keys["tag3"] == {"key2"}
|
| 57 |
+
|
| 58 |
+
# Remove specific tags
|
| 59 |
+
invalidator.remove_tags("key1", ["tag1"])
|
| 60 |
+
assert invalidator.key_tags["key1"] == {"tag2"}
|
| 61 |
+
assert "tag1" not in invalidator.tag_keys
|
| 62 |
+
|
| 63 |
+
# Remove all tags
|
| 64 |
+
invalidator.remove_tags("key2")
|
| 65 |
+
assert "key2" not in invalidator.key_tags
|
| 66 |
+
assert invalidator.tag_keys["tag2"] == {"key1"}
|
| 67 |
+
assert "tag3" not in invalidator.tag_keys
|
| 68 |
+
|
| 69 |
+
def test_invalidate_by_pattern(
|
| 70 |
+
invalidator: CacheInvalidator,
|
| 71 |
+
mock_cache_manager: MagicMock
|
| 72 |
+
):
|
| 73 |
+
"""Test pattern-based cache invalidation."""
|
| 74 |
+
# Setup test data
|
| 75 |
+
invalidator.add_tags("user:1", ["user"])
|
| 76 |
+
invalidator.add_tags("user:2", ["user"])
|
| 77 |
+
invalidator.add_tags("post:1", ["post"])
|
| 78 |
+
|
| 79 |
+
# Test glob pattern invalidation
|
| 80 |
+
keys = invalidator.invalidate_by_pattern("user:*")
|
| 81 |
+
assert set(keys) == {"user:1", "user:2"}
|
| 82 |
+
assert mock_cache_manager.remove.call_count == 2
|
| 83 |
+
mock_cache_manager.remove.assert_has_calls([
|
| 84 |
+
call("user:1"),
|
| 85 |
+
call("user:2")
|
| 86 |
+
], any_order=True)
|
| 87 |
+
|
| 88 |
+
# Verify tags were removed
|
| 89 |
+
assert "user:1" not in invalidator.key_tags
|
| 90 |
+
assert "user:2" not in invalidator.key_tags
|
| 91 |
+
assert "post:1" in invalidator.key_tags
|
| 92 |
+
|
| 93 |
+
def test_invalidate_by_tags(
|
| 94 |
+
invalidator: CacheInvalidator,
|
| 95 |
+
mock_cache_manager: MagicMock
|
| 96 |
+
):
|
| 97 |
+
"""Test tag-based cache invalidation."""
|
| 98 |
+
# Setup test data
|
| 99 |
+
invalidator.add_tags("key1", ["tag1", "tag2"])
|
| 100 |
+
invalidator.add_tags("key2", ["tag2", "tag3"])
|
| 101 |
+
invalidator.add_tags("key3", ["tag3"])
|
| 102 |
+
|
| 103 |
+
# Test invalidation with any tag
|
| 104 |
+
keys = invalidator.invalidate_by_tags(["tag1", "tag3"])
|
| 105 |
+
assert set(keys) == {"key1", "key2", "key3"}
|
| 106 |
+
assert mock_cache_manager.remove.call_count == 3
|
| 107 |
+
|
| 108 |
+
# Test invalidation with all tags
|
| 109 |
+
invalidator.add_tags("key4", ["tag4", "tag5"])
|
| 110 |
+
invalidator.add_tags("key5", ["tag4", "tag5", "tag6"])
|
| 111 |
+
keys = invalidator.invalidate_by_tags(["tag4", "tag5"], match_all=True)
|
| 112 |
+
assert set(keys) == {"key4", "key5"}
|
| 113 |
+
|
| 114 |
+
def test_invalidate_by_time(
|
| 115 |
+
invalidator: CacheInvalidator,
|
| 116 |
+
mock_cache_manager: MagicMock
|
| 117 |
+
):
|
| 118 |
+
"""Test time-based cache invalidation."""
|
| 119 |
+
now = datetime.now(timezone.utc)
|
| 120 |
+
old_time = now - timedelta(hours=2)
|
| 121 |
+
recent_time = now - timedelta(minutes=30)
|
| 122 |
+
|
| 123 |
+
# Setup test data with different timestamps
|
| 124 |
+
invalidator.add_tags("old_key", ["tag1"])
|
| 125 |
+
invalidator.key_timestamps["old_key"] = old_time
|
| 126 |
+
|
| 127 |
+
invalidator.add_tags("recent_key", ["tag1"])
|
| 128 |
+
invalidator.key_timestamps["recent_key"] = recent_time
|
| 129 |
+
|
| 130 |
+
# Test invalidation of old keys
|
| 131 |
+
keys = invalidator.invalidate_by_time(timedelta(hours=1))
|
| 132 |
+
assert keys == ["old_key"]
|
| 133 |
+
mock_cache_manager.remove.assert_called_once_with("old_key")
|
| 134 |
+
|
| 135 |
+
# Verify old key was removed from all mappings
|
| 136 |
+
assert "old_key" not in invalidator.key_tags
|
| 137 |
+
assert "old_key" not in invalidator.key_timestamps
|
| 138 |
+
assert "recent_key" in invalidator.key_tags
|
| 139 |
+
|
| 140 |
+
def test_bulk_invalidation(
|
| 141 |
+
invalidator: CacheInvalidator,
|
| 142 |
+
mock_cache_manager: MagicMock
|
| 143 |
+
):
|
| 144 |
+
"""Test bulk cache invalidation."""
|
| 145 |
+
now = datetime.now(timezone.utc)
|
| 146 |
+
old_time = now - timedelta(hours=2)
|
| 147 |
+
|
| 148 |
+
# Setup test data
|
| 149 |
+
invalidator.add_tags("user:1", ["user", "active"])
|
| 150 |
+
invalidator.add_tags("user:2", ["user", "inactive"])
|
| 151 |
+
invalidator.add_tags("post:1", ["post"])
|
| 152 |
+
invalidator.key_timestamps["user:2"] = old_time
|
| 153 |
+
|
| 154 |
+
# Test bulk invalidation with multiple criteria
|
| 155 |
+
keys = invalidator.bulk_invalidate(
|
| 156 |
+
patterns=["user:*"],
|
| 157 |
+
tags=["inactive"],
|
| 158 |
+
max_age=timedelta(hours=1)
|
| 159 |
+
)
|
| 160 |
+
assert set(keys) == {"user:1", "user:2"}
|
| 161 |
+
assert mock_cache_manager.remove.call_count == 2
|
| 162 |
+
|
| 163 |
+
def test_invalidation_callbacks(invalidator: CacheInvalidator):
|
| 164 |
+
"""Test invalidation callbacks."""
|
| 165 |
+
callback_called = False
|
| 166 |
+
|
| 167 |
+
def test_callback() -> None:
|
| 168 |
+
nonlocal callback_called
|
| 169 |
+
callback_called = True
|
| 170 |
+
|
| 171 |
+
# Register callback
|
| 172 |
+
invalidator.register_callback("test", test_callback)
|
| 173 |
+
|
| 174 |
+
# Setup test data
|
| 175 |
+
invalidator.add_tags("key1", ["tag1"])
|
| 176 |
+
|
| 177 |
+
# Test callback execution
|
| 178 |
+
invalidator.invalidate_by_pattern("key*", callback="test")
|
| 179 |
+
assert callback_called
|
| 180 |
+
|
| 181 |
+
def test_invalidation_events(
|
| 182 |
+
invalidator: CacheInvalidator,
|
| 183 |
+
mock_cache_manager: MagicMock
|
| 184 |
+
):
|
| 185 |
+
"""Test invalidation events."""
|
| 186 |
+
# Setup test data
|
| 187 |
+
invalidator.add_tags("user:1", ["user"])
|
| 188 |
+
invalidator.add_tags("post:1", ["post"])
|
| 189 |
+
|
| 190 |
+
# Test pattern invalidation event
|
| 191 |
+
event = InvalidationEvent(
|
| 192 |
+
strategy=InvalidationStrategy.PATTERN,
|
| 193 |
+
pattern="user:*"
|
| 194 |
+
)
|
| 195 |
+
keys = invalidator.record_invalidation(event)
|
| 196 |
+
assert keys == ["user:1"]
|
| 197 |
+
|
| 198 |
+
# Test tag invalidation event
|
| 199 |
+
event = InvalidationEvent(
|
| 200 |
+
strategy=InvalidationStrategy.TAG,
|
| 201 |
+
tags=["post"]
|
| 202 |
+
)
|
| 203 |
+
keys = invalidator.record_invalidation(event)
|
| 204 |
+
assert keys == ["post:1"]
|
| 205 |
+
|
| 206 |
+
# Test time invalidation event
|
| 207 |
+
event = InvalidationEvent(
|
| 208 |
+
strategy=InvalidationStrategy.TIME,
|
| 209 |
+
max_age=timedelta(hours=1)
|
| 210 |
+
)
|
| 211 |
+
keys = invalidator.record_invalidation(event)
|
| 212 |
+
assert keys == []
|
| 213 |
+
|
| 214 |
+
# Test bulk invalidation event
|
| 215 |
+
event = InvalidationEvent(
|
| 216 |
+
strategy=InvalidationStrategy.BULK,
|
| 217 |
+
pattern="*:1",
|
| 218 |
+
tags=["user", "post"]
|
| 219 |
+
)
|
| 220 |
+
keys = invalidator.record_invalidation(event)
|
| 221 |
+
assert set(keys) == {"user:1", "post:1"}
|
tests/test_error_recovery.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the error recovery mechanisms.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
import time
|
| 7 |
+
from typing import Dict, Any, Optional, List
|
| 8 |
+
import pytest
|
| 9 |
+
from unittest.mock import Mock, patch
|
| 10 |
+
|
| 11 |
+
from tools.error_recovery import (
|
| 12 |
+
CircuitBreaker,
|
| 13 |
+
CircuitBreakerState,
|
| 14 |
+
RetryConfig,
|
| 15 |
+
with_retry
|
| 16 |
+
)
|
| 17 |
+
from tools.exceptions import APIError, APIRateLimitError, APITimeoutError
|
| 18 |
+
|
| 19 |
+
def test_circuit_breaker_state() -> None:
|
| 20 |
+
"""Test circuit breaker state initialization and updates."""
|
| 21 |
+
state = CircuitBreakerState()
|
| 22 |
+
assert not state.is_open
|
| 23 |
+
assert state.failure_count == 0
|
| 24 |
+
assert state.last_failure_time is None
|
| 25 |
+
assert state.last_attempt_time is None
|
| 26 |
+
assert len(state.endpoints) == 0
|
| 27 |
+
|
| 28 |
+
def test_retry_config() -> None:
|
| 29 |
+
"""Test retry configuration initialization."""
|
| 30 |
+
config = RetryConfig()
|
| 31 |
+
assert config.max_retries == 3
|
| 32 |
+
assert config.initial_delay == 1.0
|
| 33 |
+
assert config.max_delay == 60.0
|
| 34 |
+
assert config.exponential_base == 2.0
|
| 35 |
+
assert 0 < config.jitter <= 1.0
|
| 36 |
+
assert APITimeoutError in config.retry_on
|
| 37 |
+
assert APIRateLimitError in config.retry_on
|
| 38 |
+
|
| 39 |
+
def test_circuit_breaker_basic() -> None:
|
| 40 |
+
"""Test basic circuit breaker functionality."""
|
| 41 |
+
cb = CircuitBreaker(failure_threshold=2)
|
| 42 |
+
|
| 43 |
+
# Initially should allow execution
|
| 44 |
+
assert cb.can_execute("test_key")
|
| 45 |
+
|
| 46 |
+
# Record a failure
|
| 47 |
+
cb.record_failure("test_key", "test_endpoint")
|
| 48 |
+
assert cb.can_execute("test_key")
|
| 49 |
+
|
| 50 |
+
# Record another failure to trigger circuit open
|
| 51 |
+
cb.record_failure("test_key", "test_endpoint")
|
| 52 |
+
assert not cb.can_execute("test_key")
|
| 53 |
+
|
| 54 |
+
# Record success should reset the circuit
|
| 55 |
+
cb.record_success("test_key")
|
| 56 |
+
assert cb.can_execute("test_key")
|
| 57 |
+
state = cb.get_state("test_key")
|
| 58 |
+
assert not state.is_open
|
| 59 |
+
assert state.failure_count == 0
|
| 60 |
+
assert len(state.endpoints) == 0
|
| 61 |
+
|
| 62 |
+
def test_circuit_breaker_timeout() -> None:
|
| 63 |
+
"""Test circuit breaker timeout and half-open state."""
|
| 64 |
+
cb = CircuitBreaker(
|
| 65 |
+
failure_threshold=1,
|
| 66 |
+
reset_timeout=0.1, # Short timeout for testing
|
| 67 |
+
half_open_timeout=0.05
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Open the circuit
|
| 71 |
+
cb.record_failure("test_key")
|
| 72 |
+
assert not cb.can_execute("test_key")
|
| 73 |
+
|
| 74 |
+
# Wait for reset timeout
|
| 75 |
+
time.sleep(0.15)
|
| 76 |
+
|
| 77 |
+
# Should allow one test request
|
| 78 |
+
assert cb.can_execute("test_key")
|
| 79 |
+
|
| 80 |
+
# Should not allow another request too soon
|
| 81 |
+
assert not cb.can_execute("test_key")
|
| 82 |
+
|
| 83 |
+
# Wait for half-open timeout
|
| 84 |
+
time.sleep(0.1)
|
| 85 |
+
|
| 86 |
+
# Should allow another test request
|
| 87 |
+
assert cb.can_execute("test_key")
|
| 88 |
+
|
| 89 |
+
@pytest.mark.parametrize("error_class", [
|
| 90 |
+
APITimeoutError,
|
| 91 |
+
APIRateLimitError,
|
| 92 |
+
TimeoutError,
|
| 93 |
+
ConnectionError
|
| 94 |
+
])
|
| 95 |
+
def test_retry_on_different_errors(error_class: type) -> None:
|
| 96 |
+
"""Test retry behavior with different error types."""
|
| 97 |
+
retry_count = 0
|
| 98 |
+
|
| 99 |
+
@with_retry(retry_config=RetryConfig(max_retries=2, initial_delay=0.01))
|
| 100 |
+
def failing_function() -> None:
|
| 101 |
+
nonlocal retry_count
|
| 102 |
+
retry_count += 1
|
| 103 |
+
if error_class == APIRateLimitError:
|
| 104 |
+
response = Mock()
|
| 105 |
+
response.headers = {"Retry-After": "1"}
|
| 106 |
+
error = Exception("Rate limit")
|
| 107 |
+
error.response = response
|
| 108 |
+
error.response.status_code = 429
|
| 109 |
+
raise error
|
| 110 |
+
raise error_class("Test error")
|
| 111 |
+
|
| 112 |
+
with pytest.raises((APIError, error_class)):
|
| 113 |
+
failing_function()
|
| 114 |
+
|
| 115 |
+
assert retry_count == 3 # Initial attempt + 2 retries
|
| 116 |
+
|
| 117 |
+
def test_retry_with_success() -> None:
|
| 118 |
+
"""Test successful retry after failures."""
|
| 119 |
+
attempts = 0
|
| 120 |
+
|
| 121 |
+
@with_retry(retry_config=RetryConfig(max_retries=3, initial_delay=0.01))
|
| 122 |
+
def eventually_succeeds() -> str:
|
| 123 |
+
nonlocal attempts
|
| 124 |
+
attempts += 1
|
| 125 |
+
if attempts < 2:
|
| 126 |
+
raise APITimeoutError("test", 1.0)
|
| 127 |
+
return "success"
|
| 128 |
+
|
| 129 |
+
result = eventually_succeeds()
|
| 130 |
+
assert result == "success"
|
| 131 |
+
assert attempts == 2
|
| 132 |
+
|
| 133 |
+
def test_retry_respects_rate_limit() -> None:
|
| 134 |
+
"""Test that retry mechanism respects rate limit retry-after."""
|
| 135 |
+
attempts = 0
|
| 136 |
+
start_time = time.time()
|
| 137 |
+
|
| 138 |
+
@with_retry(retry_config=RetryConfig(max_retries=1, initial_delay=0.01))
|
| 139 |
+
def rate_limited() -> None:
|
| 140 |
+
nonlocal attempts
|
| 141 |
+
attempts += 1
|
| 142 |
+
response = Mock()
|
| 143 |
+
response.headers = {"Retry-After": "0.1"} # 100ms
|
| 144 |
+
error = Exception("Rate limit")
|
| 145 |
+
error.response = response
|
| 146 |
+
error.response.status_code = 429
|
| 147 |
+
raise error
|
| 148 |
+
|
| 149 |
+
with pytest.raises(APIError):
|
| 150 |
+
rate_limited()
|
| 151 |
+
|
| 152 |
+
duration = time.time() - start_time
|
| 153 |
+
assert duration >= 0.1 # Should have waited for retry-after
|
| 154 |
+
assert attempts == 2 # Initial attempt + 1 retry
|
| 155 |
+
|
| 156 |
+
def test_combined_circuit_breaker_and_retry() -> None:
|
| 157 |
+
"""Test circuit breaker and retry working together."""
|
| 158 |
+
cb = CircuitBreaker(failure_threshold=3)
|
| 159 |
+
attempts = 0
|
| 160 |
+
|
| 161 |
+
@with_retry(
|
| 162 |
+
retry_config=RetryConfig(max_retries=2, initial_delay=0.01),
|
| 163 |
+
circuit_breaker=cb
|
| 164 |
+
)
|
| 165 |
+
def failing_function() -> None:
|
| 166 |
+
nonlocal attempts
|
| 167 |
+
attempts += 1
|
| 168 |
+
raise APITimeoutError("test", 1.0)
|
| 169 |
+
|
| 170 |
+
# First call should retry twice
|
| 171 |
+
with pytest.raises(APITimeoutError):
|
| 172 |
+
failing_function()
|
| 173 |
+
assert attempts == 3
|
| 174 |
+
|
| 175 |
+
# Second call should retry twice
|
| 176 |
+
attempts = 0
|
| 177 |
+
with pytest.raises(APITimeoutError):
|
| 178 |
+
failing_function()
|
| 179 |
+
assert attempts == 3
|
| 180 |
+
|
| 181 |
+
# Third call should fail immediately due to circuit breaker
|
| 182 |
+
attempts = 0
|
| 183 |
+
with pytest.raises(APIError) as exc_info:
|
| 184 |
+
failing_function()
|
| 185 |
+
assert attempts == 0
|
| 186 |
+
assert "Circuit breaker is open" in str(exc_info.value)
|
| 187 |
+
|
| 188 |
+
def test_exponential_backoff() -> None:
|
| 189 |
+
"""Test exponential backoff with jitter."""
|
| 190 |
+
sleep_times: List[float] = []
|
| 191 |
+
|
| 192 |
+
@with_retry(
|
| 193 |
+
retry_config=RetryConfig(
|
| 194 |
+
max_retries=2,
|
| 195 |
+
initial_delay=0.1,
|
| 196 |
+
exponential_base=2.0,
|
| 197 |
+
jitter=0.1
|
| 198 |
+
)
|
| 199 |
+
)
|
| 200 |
+
def failing_function() -> None:
|
| 201 |
+
raise APITimeoutError("test", 1.0)
|
| 202 |
+
|
| 203 |
+
with patch('time.sleep') as mock_sleep:
|
| 204 |
+
mock_sleep.side_effect = lambda x: sleep_times.append(x)
|
| 205 |
+
with pytest.raises(APITimeoutError):
|
| 206 |
+
failing_function()
|
| 207 |
+
|
| 208 |
+
assert len(sleep_times) == 2
|
| 209 |
+
# First delay should be around 0.1s (with jitter)
|
| 210 |
+
assert 0.09 <= sleep_times[0] <= 0.11
|
| 211 |
+
# Second delay should be around 0.2s (with jitter)
|
| 212 |
+
assert 0.18 <= sleep_times[1] <= 0.22
|
tests/test_errors.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for security error handling."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from datetime import datetime, timedelta, timezone
|
| 5 |
+
|
| 6 |
+
from tools.errors import (
|
| 7 |
+
SecurityError,
|
| 8 |
+
AuthenticationError,
|
| 9 |
+
AuthorizationError,
|
| 10 |
+
InvalidKeyError,
|
| 11 |
+
KeyExpiredError,
|
| 12 |
+
RateLimitError,
|
| 13 |
+
EncryptionError,
|
| 14 |
+
ValidationError,
|
| 15 |
+
ConfigurationError,
|
| 16 |
+
)
|
| 17 |
+
from tools.security import KeyManager
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_security_error_base():
|
| 21 |
+
"""Test base security error."""
|
| 22 |
+
error = SecurityError("Test error")
|
| 23 |
+
assert str(error) == "[SECURITY_ERROR] Test error"
|
| 24 |
+
assert error.message == "Test error"
|
| 25 |
+
assert error.code == "SECURITY_ERROR"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_authentication_error():
|
| 29 |
+
"""Test authentication error."""
|
| 30 |
+
error = AuthenticationError("Invalid credentials")
|
| 31 |
+
assert str(error) == "[AUTH_ERROR] Invalid credentials"
|
| 32 |
+
assert error.message == "Invalid credentials"
|
| 33 |
+
assert error.code == "AUTH_ERROR"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_authorization_error():
|
| 37 |
+
"""Test authorization error."""
|
| 38 |
+
error = AuthorizationError("Insufficient permissions")
|
| 39 |
+
assert str(error) == "[ACCESS_DENIED] Insufficient permissions"
|
| 40 |
+
assert error.message == "Insufficient permissions"
|
| 41 |
+
assert error.code == "ACCESS_DENIED"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_invalid_key_error():
|
| 45 |
+
"""Test invalid key error."""
|
| 46 |
+
error = InvalidKeyError("Key not found")
|
| 47 |
+
assert str(error) == "[INVALID_KEY] Key not found"
|
| 48 |
+
assert error.message == "Key not found"
|
| 49 |
+
assert error.code == "INVALID_KEY"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_key_expired_error():
|
| 53 |
+
"""Test key expired error."""
|
| 54 |
+
error = KeyExpiredError("Key has expired")
|
| 55 |
+
assert str(error) == "[KEY_EXPIRED] Key has expired"
|
| 56 |
+
assert error.message == "Key has expired"
|
| 57 |
+
assert error.code == "KEY_EXPIRED"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_rate_limit_error():
|
| 61 |
+
"""Test rate limit error."""
|
| 62 |
+
error = RateLimitError("Too many requests", wait_time=1.5)
|
| 63 |
+
assert str(error) == "[RATE_LIMITED] Too many requests"
|
| 64 |
+
assert error.message == "Too many requests"
|
| 65 |
+
assert error.code == "RATE_LIMITED"
|
| 66 |
+
assert error.wait_time == 1.5
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_encryption_error():
|
| 70 |
+
"""Test encryption error."""
|
| 71 |
+
error = EncryptionError("Failed to decrypt")
|
| 72 |
+
assert str(error) == "[ENCRYPTION_ERROR] Failed to decrypt"
|
| 73 |
+
assert error.message == "Failed to decrypt"
|
| 74 |
+
assert error.code == "ENCRYPTION_ERROR"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_validation_error():
|
| 78 |
+
"""Test validation error."""
|
| 79 |
+
# Without field
|
| 80 |
+
error = ValidationError("Invalid value")
|
| 81 |
+
assert str(error) == "[VALIDATION_ERROR] Invalid value"
|
| 82 |
+
assert error.message == "Invalid value"
|
| 83 |
+
assert error.code == "VALIDATION_ERROR"
|
| 84 |
+
assert error.field is None
|
| 85 |
+
|
| 86 |
+
# With field
|
| 87 |
+
error = ValidationError("Invalid format", field="api_key")
|
| 88 |
+
assert str(error) == "[VALIDATION_ERROR] api_key: Invalid format"
|
| 89 |
+
assert error.message == "api_key: Invalid format"
|
| 90 |
+
assert error.code == "VALIDATION_ERROR"
|
| 91 |
+
assert error.field == "api_key"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_configuration_error():
|
| 95 |
+
"""Test configuration error."""
|
| 96 |
+
error = ConfigurationError("Invalid settings")
|
| 97 |
+
assert str(error) == "[CONFIG_ERROR] Invalid settings"
|
| 98 |
+
assert error.message == "Invalid settings"
|
| 99 |
+
assert error.code == "CONFIG_ERROR"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@pytest.fixture
|
| 103 |
+
def key_manager():
|
| 104 |
+
"""Create test key manager."""
|
| 105 |
+
return KeyManager(keys_file="test_keys.json")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_invalid_key_handling(key_manager):
|
| 109 |
+
"""Test handling of invalid keys."""
|
| 110 |
+
# Test adding invalid key
|
| 111 |
+
with pytest.raises(ValidationError) as exc:
|
| 112 |
+
key_manager.add_key("test", "invalid@key")
|
| 113 |
+
assert "API key must contain only" in str(exc.value)
|
| 114 |
+
assert exc.value.field == "key"
|
| 115 |
+
|
| 116 |
+
# Test getting non-existent key
|
| 117 |
+
with pytest.raises(InvalidKeyError) as exc:
|
| 118 |
+
key_manager.get_key("nonexistent")
|
| 119 |
+
assert "Key 'nonexistent' not found" in str(exc.value)
|
| 120 |
+
|
| 121 |
+
# Test removing non-existent key
|
| 122 |
+
with pytest.raises(InvalidKeyError) as exc:
|
| 123 |
+
key_manager.remove_key("nonexistent")
|
| 124 |
+
assert "Key 'nonexistent' not found" in str(exc.value)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_expired_key_handling(key_manager):
|
| 128 |
+
"""Test handling of expired keys."""
|
| 129 |
+
# Add key that expires in 1 second
|
| 130 |
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=1)
|
| 131 |
+
key_manager.add_key("test", "valid-key-123", expires_at=expires_at)
|
| 132 |
+
|
| 133 |
+
# Key should be valid initially
|
| 134 |
+
key = key_manager.get_key("test")
|
| 135 |
+
assert key is not None
|
| 136 |
+
assert key.is_valid
|
| 137 |
+
|
| 138 |
+
# Wait for key to expire
|
| 139 |
+
import time
|
| 140 |
+
time.sleep(1.1)
|
| 141 |
+
|
| 142 |
+
# Key should raise expired error
|
| 143 |
+
with pytest.raises(KeyExpiredError) as exc:
|
| 144 |
+
key_manager.get_key("test")
|
| 145 |
+
assert "Key 'test' has expired" in str(exc.value)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@pytest.mark.asyncio
|
| 149 |
+
async def test_rate_limit_handling(key_manager):
|
| 150 |
+
"""Test handling of rate limits."""
|
| 151 |
+
# Add key with low rate limit
|
| 152 |
+
key_manager.add_key(
|
| 153 |
+
"test",
|
| 154 |
+
"valid-key-123",
|
| 155 |
+
rate_limit=1.0, # 1 request per second
|
| 156 |
+
burst_limit=1
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# First request should succeed
|
| 160 |
+
wait_time = await key_manager.check_rate_limit("test")
|
| 161 |
+
assert wait_time == 0
|
| 162 |
+
|
| 163 |
+
# Second request should be rate limited
|
| 164 |
+
with pytest.raises(RateLimitError) as exc:
|
| 165 |
+
await key_manager.check_rate_limit("test")
|
| 166 |
+
assert "Rate limit exceeded" in str(exc.value)
|
| 167 |
+
assert exc.value.wait_time > 0
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_encryption_handling(key_manager):
|
| 171 |
+
"""Test handling of encryption errors."""
|
| 172 |
+
# Test invalid encryption key
|
| 173 |
+
with pytest.raises(EncryptionError) as exc:
|
| 174 |
+
KeyManager(encryption_key="invalid-key")
|
| 175 |
+
assert "Invalid base64 encoding" in str(exc.value)
|
| 176 |
+
|
| 177 |
+
# Test corrupted keys file
|
| 178 |
+
with open("test_keys.json", "wb") as f:
|
| 179 |
+
f.write(b"corrupted data")
|
| 180 |
+
|
| 181 |
+
with pytest.raises(EncryptionError) as exc:
|
| 182 |
+
key_manager._load_keys()
|
| 183 |
+
assert "Failed to decrypt keys file" in str(exc.value)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_configuration_handling(key_manager):
|
| 187 |
+
"""Test handling of configuration errors."""
|
| 188 |
+
# Test invalid environment variable
|
| 189 |
+
import os
|
| 190 |
+
os.environ["API_TEST_KEY"] = "invalid@key"
|
| 191 |
+
os.environ["API_TEST_RATE_LIMIT"] = "invalid"
|
| 192 |
+
|
| 193 |
+
with pytest.raises(ConfigurationError) as exc:
|
| 194 |
+
key_manager.get_key("test")
|
| 195 |
+
assert "Invalid environment configuration" in str(exc.value)
|
| 196 |
+
|
| 197 |
+
# Test invalid rate limits
|
| 198 |
+
with pytest.raises(ValidationError) as exc:
|
| 199 |
+
key_manager.add_key("test", "valid-key-123", rate_limit=0)
|
| 200 |
+
assert "Rate limit must be between" in str(exc.value)
|
| 201 |
+
|
| 202 |
+
with pytest.raises(ValidationError) as exc:
|
| 203 |
+
key_manager.add_key("test", "valid-key-123", burst_limit=0)
|
| 204 |
+
assert "Burst limit must be at least" in str(exc.value)
|
tests/test_gradio_ui.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the Gradio UI implementation."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional, Generator, Any, Dict
|
| 4 |
+
import os
|
| 5 |
+
import tempfile
|
| 6 |
+
try:
|
| 7 |
+
import gradio as gr
|
| 8 |
+
except ImportError:
|
| 9 |
+
print("Warning: gradio package not found. Please install it with 'pip install gradio'")
|
| 10 |
+
from Gradio_UI import GradioUI
|
| 11 |
+
|
| 12 |
+
class MockTool:
|
| 13 |
+
"""Mock tool for testing."""
|
| 14 |
+
def __init__(self) -> None:
|
| 15 |
+
self.description: str = "A mock tool for testing"
|
| 16 |
+
self.name: str = "mock_tool"
|
| 17 |
+
self.inputs: Dict[str, Dict[str, str]] = {
|
| 18 |
+
"input_text": {
|
| 19 |
+
"type": "string",
|
| 20 |
+
"description": "Input text to process"
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
self.output_type: str = "string"
|
| 24 |
+
|
| 25 |
+
def run(self, input_text: str) -> str:
|
| 26 |
+
"""Process input text."""
|
| 27 |
+
return f"Processed: {input_text}"
|
| 28 |
+
|
| 29 |
+
class MockModel:
|
| 30 |
+
"""Mock model for testing."""
|
| 31 |
+
def generate(self, prompt: str) -> str:
|
| 32 |
+
"""Generate a response to the prompt."""
|
| 33 |
+
return f"Response to: {prompt}"
|
| 34 |
+
|
| 35 |
+
class MockAgent:
|
| 36 |
+
"""Mock agent for testing."""
|
| 37 |
+
def __init__(self) -> None:
|
| 38 |
+
self.tools: List[MockTool] = [MockTool()]
|
| 39 |
+
self.model: MockModel = MockModel()
|
| 40 |
+
|
| 41 |
+
def run(
|
| 42 |
+
self,
|
| 43 |
+
task: str,
|
| 44 |
+
stream: bool = False,
|
| 45 |
+
reset: bool = False,
|
| 46 |
+
additional_args: Optional[Dict[str, Any]] = None
|
| 47 |
+
) -> List[str]:
|
| 48 |
+
"""Run the agent with the given task."""
|
| 49 |
+
return [self.model.generate(task)]
|
| 50 |
+
|
| 51 |
+
def test_gradio_ui_initialization() -> None:
|
| 52 |
+
"""Test GradioUI initialization."""
|
| 53 |
+
agent = MockAgent()
|
| 54 |
+
ui = GradioUI(agent)
|
| 55 |
+
assert ui.agent == agent
|
| 56 |
+
|
| 57 |
+
def test_gradio_ui_interaction() -> None:
|
| 58 |
+
"""Test GradioUI interaction."""
|
| 59 |
+
agent = MockAgent()
|
| 60 |
+
ui = GradioUI(agent)
|
| 61 |
+
messages: List[Dict[str, Any]] = []
|
| 62 |
+
response_generator: Generator[List[Dict[str, Any]], None, None] = ui.interact_with_agent("Hello", messages)
|
| 63 |
+
responses: List[List[Dict[str, Any]]] = list(response_generator)
|
| 64 |
+
assert len(responses) > 0
|
| 65 |
+
assert any("Hello" in str(msg) for msg in responses[0])
|
| 66 |
+
|
| 67 |
+
def test_gradio_ui_file_upload() -> None:
|
| 68 |
+
"""Test GradioUI file upload."""
|
| 69 |
+
# Create a temporary directory for file uploads
|
| 70 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 71 |
+
agent = MockAgent()
|
| 72 |
+
ui = GradioUI(agent, file_upload_folder=temp_dir)
|
| 73 |
+
|
| 74 |
+
# Create a test file
|
| 75 |
+
test_file_path = os.path.join(temp_dir, "test.txt")
|
| 76 |
+
with open(test_file_path, "w") as f:
|
| 77 |
+
f.write("Test content")
|
| 78 |
+
|
| 79 |
+
# Create a mock file object
|
| 80 |
+
class MockFile:
|
| 81 |
+
def __init__(self, path: str) -> None:
|
| 82 |
+
self.name: str = path
|
| 83 |
+
|
| 84 |
+
mock_file = MockFile(test_file_path)
|
| 85 |
+
file_uploads_log: List[str] = []
|
| 86 |
+
|
| 87 |
+
# Test file upload
|
| 88 |
+
result, log = ui.upload_file(mock_file, file_uploads_log)
|
| 89 |
+
assert isinstance(result, gr.Textbox)
|
| 90 |
+
assert "File uploaded:" in result.value
|
| 91 |
+
assert len(log) == 1 # One file should be added to the log
|
| 92 |
+
assert os.path.splitext(os.path.basename(test_file_path))[0] in os.path.basename(log[0]) # The file name (without extension) should be in the log
|
tests/test_health.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the health check functionality.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
from unittest.mock import Mock, patch
|
| 8 |
+
|
| 9 |
+
from tools.health import HealthCheck, HealthStatus
|
| 10 |
+
from tools.error_recovery import CircuitBreaker, CircuitBreakerState
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def health_check() -> HealthCheck:
|
| 14 |
+
"""Create a health check instance for testing."""
|
| 15 |
+
return HealthCheck(
|
| 16 |
+
circuit_breaker=CircuitBreaker(),
|
| 17 |
+
memory_threshold=90.0,
|
| 18 |
+
cpu_threshold=80.0
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
def test_health_status_init() -> None:
|
| 22 |
+
"""Test health status initialization."""
|
| 23 |
+
status = HealthStatus()
|
| 24 |
+
assert status.status == "healthy"
|
| 25 |
+
assert isinstance(status.timestamp, datetime)
|
| 26 |
+
assert status.checks == {}
|
| 27 |
+
assert status.details == {}
|
| 28 |
+
|
| 29 |
+
@patch("psutil.virtual_memory")
|
| 30 |
+
@patch("psutil.cpu_percent")
|
| 31 |
+
def test_check_system_resources(mock_cpu: Mock, mock_memory: Mock, health_check: HealthCheck) -> None:
|
| 32 |
+
"""Test system resource check."""
|
| 33 |
+
# Mock healthy system
|
| 34 |
+
mock_memory.return_value.percent = 50.0
|
| 35 |
+
mock_cpu.return_value = 30.0
|
| 36 |
+
|
| 37 |
+
result = health_check.check_system_resources()
|
| 38 |
+
assert result["status"] == "healthy"
|
| 39 |
+
assert result["details"]["memory_usage"] == 50.0
|
| 40 |
+
assert result["details"]["cpu_usage"] == 30.0
|
| 41 |
+
|
| 42 |
+
# Mock degraded system
|
| 43 |
+
mock_memory.return_value.percent = 95.0
|
| 44 |
+
mock_cpu.return_value = 85.0
|
| 45 |
+
|
| 46 |
+
result = health_check.check_system_resources()
|
| 47 |
+
assert result["status"] == "degraded"
|
| 48 |
+
assert result["details"]["memory_usage"] == 95.0
|
| 49 |
+
assert result["details"]["cpu_usage"] == 85.0
|
| 50 |
+
|
| 51 |
+
def test_check_circuit_breaker(health_check: HealthCheck) -> None:
|
| 52 |
+
"""Test circuit breaker status check."""
|
| 53 |
+
# Test with no open circuits
|
| 54 |
+
result = health_check.check_circuit_breaker()
|
| 55 |
+
assert result["status"] == "healthy"
|
| 56 |
+
assert result["details"]["total_circuits"] == 0
|
| 57 |
+
assert result["details"]["open_circuits"] == []
|
| 58 |
+
|
| 59 |
+
# Test with open circuit
|
| 60 |
+
state = CircuitBreakerState(
|
| 61 |
+
is_open=True,
|
| 62 |
+
failure_count=5,
|
| 63 |
+
last_failure_time=datetime.utcnow(),
|
| 64 |
+
endpoints={"api/weather"}
|
| 65 |
+
)
|
| 66 |
+
health_check.circuit_breaker._states["test_circuit"] = state
|
| 67 |
+
|
| 68 |
+
result = health_check.check_circuit_breaker()
|
| 69 |
+
assert result["status"] == "degraded"
|
| 70 |
+
assert result["details"]["total_circuits"] == 1
|
| 71 |
+
assert len(result["details"]["open_circuits"]) == 1
|
| 72 |
+
assert result["details"]["open_circuits"][0]["key"] == "test_circuit"
|
| 73 |
+
assert result["details"]["open_circuits"][0]["failures"] == 5
|
| 74 |
+
assert "api/weather" in result["details"]["open_circuits"][0]["endpoints"]
|
| 75 |
+
|
| 76 |
+
@patch("tools.health.requests")
|
| 77 |
+
@patch("tools.health.pydantic")
|
| 78 |
+
@patch("tools.health.typing_extensions")
|
| 79 |
+
def test_check_dependencies(
|
| 80 |
+
mock_typing: Mock,
|
| 81 |
+
mock_pydantic: Mock,
|
| 82 |
+
mock_requests: Mock,
|
| 83 |
+
health_check: HealthCheck
|
| 84 |
+
) -> None:
|
| 85 |
+
"""Test dependency check."""
|
| 86 |
+
# Mock healthy dependencies
|
| 87 |
+
mock_requests.__version__ = "2.31.0"
|
| 88 |
+
mock_pydantic.__version__ = "2.5.2"
|
| 89 |
+
mock_typing.__version__ = "4.8.0"
|
| 90 |
+
|
| 91 |
+
result = health_check.check_dependencies()
|
| 92 |
+
assert result["status"] == "healthy"
|
| 93 |
+
assert result["details"]["dependencies"]["requests"] == "2.31.0"
|
| 94 |
+
assert result["details"]["dependencies"]["pydantic"] == "2.5.2"
|
| 95 |
+
assert result["details"]["dependencies"]["typing_extensions"] == "4.8.0"
|
| 96 |
+
|
| 97 |
+
# Test with missing dependency
|
| 98 |
+
mock_requests.__version__ = None
|
| 99 |
+
result = health_check.check_dependencies()
|
| 100 |
+
assert result["status"] == "unhealthy"
|
| 101 |
+
assert "error" in result["details"]
|
| 102 |
+
|
| 103 |
+
def test_get_uptime(health_check: HealthCheck) -> None:
|
| 104 |
+
"""Test uptime information."""
|
| 105 |
+
# Set start time to 1 hour ago
|
| 106 |
+
health_check.start_time = datetime.utcnow() - timedelta(hours=1)
|
| 107 |
+
|
| 108 |
+
result = health_check.get_uptime()
|
| 109 |
+
assert result["status"] == "healthy"
|
| 110 |
+
assert "start_time" in result["details"]
|
| 111 |
+
assert result["details"]["uptime_seconds"] >= 3600
|
| 112 |
+
assert "uptime_human" in result["details"]
|
| 113 |
+
|
| 114 |
+
def test_check_health(health_check: HealthCheck) -> None:
|
| 115 |
+
"""Test comprehensive health check."""
|
| 116 |
+
with patch.multiple(
|
| 117 |
+
health_check,
|
| 118 |
+
check_system_resources=Mock(return_value={"status": "healthy", "details": {}}),
|
| 119 |
+
check_circuit_breaker=Mock(return_value={"status": "healthy", "details": {}}),
|
| 120 |
+
check_dependencies=Mock(return_value={"status": "healthy", "details": {}}),
|
| 121 |
+
get_uptime=Mock(return_value={"status": "healthy", "details": {}})
|
| 122 |
+
):
|
| 123 |
+
status = health_check.check_health()
|
| 124 |
+
assert status.status == "healthy"
|
| 125 |
+
assert len(status.checks) == 4
|
| 126 |
+
assert all(check["status"] == "healthy" for check in status.checks.values())
|
| 127 |
+
|
| 128 |
+
# Test degraded status
|
| 129 |
+
health_check.check_system_resources.return_value = {"status": "degraded", "details": {}}
|
| 130 |
+
status = health_check.check_health()
|
| 131 |
+
assert status.status == "degraded"
|
| 132 |
+
|
| 133 |
+
# Test unhealthy status
|
| 134 |
+
health_check.check_dependencies.return_value = {"status": "unhealthy", "details": {}}
|
| 135 |
+
status = health_check.check_health()
|
| 136 |
+
assert status.status == "unhealthy"
|
tests/test_logging.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the logging configuration.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import logging
|
| 7 |
+
import pytest
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from unittest.mock import patch, Mock
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
from tools.logging_config import setup_logging, get_log_stats
|
| 13 |
+
|
| 14 |
+
@pytest.fixture
|
| 15 |
+
def temp_log_dir(tmp_path):
|
| 16 |
+
"""Create a temporary log directory."""
|
| 17 |
+
log_dir = tmp_path / "logs"
|
| 18 |
+
log_dir.mkdir()
|
| 19 |
+
return log_dir
|
| 20 |
+
|
| 21 |
+
def test_setup_logging_basic(temp_log_dir):
|
| 22 |
+
"""Test basic logging setup."""
|
| 23 |
+
logger = setup_logging(
|
| 24 |
+
log_dir=str(temp_log_dir),
|
| 25 |
+
log_level="INFO"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
assert isinstance(logger, logging.Logger)
|
| 29 |
+
assert logger.level == logging.INFO
|
| 30 |
+
assert len(logger.handlers) >= 1 # At least one handler
|
| 31 |
+
|
| 32 |
+
# Check if log file was created
|
| 33 |
+
log_file = temp_log_dir / "assistant.log"
|
| 34 |
+
assert log_file.exists()
|
| 35 |
+
|
| 36 |
+
def test_setup_logging_custom_format(temp_log_dir):
|
| 37 |
+
"""Test logging setup with custom format."""
|
| 38 |
+
custom_format = "%(levelname)s - %(message)s"
|
| 39 |
+
custom_date = "%H:%M:%S"
|
| 40 |
+
|
| 41 |
+
logger = setup_logging(
|
| 42 |
+
log_dir=str(temp_log_dir),
|
| 43 |
+
log_format=custom_format,
|
| 44 |
+
date_format=custom_date
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
file_handler = next(h for h in logger.handlers
|
| 48 |
+
if isinstance(h, logging.handlers.RotatingFileHandler))
|
| 49 |
+
formatter = file_handler.formatter
|
| 50 |
+
|
| 51 |
+
assert formatter._fmt == custom_format
|
| 52 |
+
assert formatter.datefmt == custom_date
|
| 53 |
+
|
| 54 |
+
def test_setup_logging_rotation(temp_log_dir):
|
| 55 |
+
"""Test log file rotation."""
|
| 56 |
+
max_bytes = 100
|
| 57 |
+
backup_count = 3
|
| 58 |
+
|
| 59 |
+
logger = setup_logging(
|
| 60 |
+
log_dir=str(temp_log_dir),
|
| 61 |
+
max_bytes=max_bytes,
|
| 62 |
+
backup_count=backup_count
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Write enough data to trigger rotation
|
| 66 |
+
for i in range(10):
|
| 67 |
+
logger.info("x" * 20) # Each log entry > 20 bytes with timestamps etc.
|
| 68 |
+
|
| 69 |
+
# Check if backup files were created
|
| 70 |
+
backup_files = list(temp_log_dir.glob("assistant.log.*"))
|
| 71 |
+
assert len(backup_files) > 0
|
| 72 |
+
assert len(backup_files) <= backup_count
|
| 73 |
+
|
| 74 |
+
def test_setup_logging_environment(temp_log_dir):
|
| 75 |
+
"""Test logging setup in different environments."""
|
| 76 |
+
# Test production environment
|
| 77 |
+
with patch.dict(os.environ, {"ENVIRONMENT": "production"}):
|
| 78 |
+
logger = setup_logging(log_dir=str(temp_log_dir))
|
| 79 |
+
console_handlers = [h for h in logger.handlers
|
| 80 |
+
if isinstance(h, logging.StreamHandler)]
|
| 81 |
+
assert len(console_handlers) == 0
|
| 82 |
+
|
| 83 |
+
# Test development environment
|
| 84 |
+
with patch.dict(os.environ, {"ENVIRONMENT": "development"}):
|
| 85 |
+
logger = setup_logging(log_dir=str(temp_log_dir))
|
| 86 |
+
console_handlers = [h for h in logger.handlers
|
| 87 |
+
if isinstance(h, logging.StreamHandler)]
|
| 88 |
+
assert len(console_handlers) == 1
|
| 89 |
+
|
| 90 |
+
@patch('os.statvfs')
|
| 91 |
+
def test_get_log_stats(mock_statvfs, temp_log_dir):
|
| 92 |
+
"""Test log statistics collection."""
|
| 93 |
+
# Create a log file and some backups
|
| 94 |
+
log_file = temp_log_dir / "assistant.log"
|
| 95 |
+
log_file.write_text("test log content")
|
| 96 |
+
|
| 97 |
+
backup1 = temp_log_dir / "assistant.log.1"
|
| 98 |
+
backup1.write_text("backup 1 content")
|
| 99 |
+
|
| 100 |
+
# Mock filesystem stats
|
| 101 |
+
mock_stat = Mock()
|
| 102 |
+
mock_stat.f_blocks = 1000
|
| 103 |
+
mock_stat.f_frsize = 4096
|
| 104 |
+
mock_statvfs.return_value = mock_stat
|
| 105 |
+
|
| 106 |
+
with patch('tools.logging_config.Path') as mock_path:
|
| 107 |
+
mock_path.return_value = temp_log_dir
|
| 108 |
+
stats = get_log_stats()
|
| 109 |
+
|
| 110 |
+
assert stats["backup_files"] == 1
|
| 111 |
+
assert stats["current_log_size"] > 0
|
| 112 |
+
assert stats["total_size"] > 0
|
| 113 |
+
assert stats["directory_usage"] >= 0
|
| 114 |
+
assert stats["last_rotation"] is not None
|
| 115 |
+
|
| 116 |
+
def test_get_log_stats_no_logs(temp_log_dir):
|
| 117 |
+
"""Test log statistics with no log files."""
|
| 118 |
+
with patch('tools.logging_config.Path') as mock_path:
|
| 119 |
+
mock_path.return_value = temp_log_dir
|
| 120 |
+
stats = get_log_stats()
|
| 121 |
+
|
| 122 |
+
assert stats["backup_files"] == 0
|
| 123 |
+
assert stats["current_log_size"] == 0
|
| 124 |
+
assert stats["total_size"] == 0
|
| 125 |
+
assert stats["directory_usage"] == 0
|
| 126 |
+
assert stats["last_rotation"] is None
|
tests/test_monitoring.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for performance monitoring utilities."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from typing import AsyncGenerator
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from pytest_asyncio import fixture
|
| 8 |
+
|
| 9 |
+
from tools.monitoring import (
|
| 10 |
+
ErrorMetrics,
|
| 11 |
+
LatencyMetrics,
|
| 12 |
+
PerformanceMonitor,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
@fixture
|
| 16 |
+
async def monitor() -> AsyncGenerator[PerformanceMonitor, None]:
|
| 17 |
+
"""Create a test performance monitor."""
|
| 18 |
+
monitor = PerformanceMonitor(metrics_ttl=60, max_metrics=10)
|
| 19 |
+
yield monitor
|
| 20 |
+
|
| 21 |
+
@pytest.mark.asyncio
|
| 22 |
+
async def test_latency_metrics():
|
| 23 |
+
"""Test latency metrics tracking."""
|
| 24 |
+
metrics = LatencyMetrics()
|
| 25 |
+
|
| 26 |
+
# Test initial state
|
| 27 |
+
assert metrics.count == 0
|
| 28 |
+
assert metrics.avg_time == 0.0
|
| 29 |
+
|
| 30 |
+
# Test single measurement
|
| 31 |
+
await metrics.add_measurement(0.1)
|
| 32 |
+
assert metrics.count == 1
|
| 33 |
+
assert metrics.total_time == 0.1
|
| 34 |
+
assert metrics.min_time == 0.1
|
| 35 |
+
assert metrics.max_time == 0.1
|
| 36 |
+
assert metrics.avg_time == 0.1
|
| 37 |
+
|
| 38 |
+
# Test multiple measurements
|
| 39 |
+
await metrics.add_measurement(0.2)
|
| 40 |
+
await metrics.add_measurement(0.3)
|
| 41 |
+
assert metrics.count == 3
|
| 42 |
+
assert metrics.total_time == 0.6
|
| 43 |
+
assert metrics.min_time == 0.1
|
| 44 |
+
assert metrics.max_time == 0.3
|
| 45 |
+
assert metrics.avg_time == 0.2
|
| 46 |
+
|
| 47 |
+
@pytest.mark.asyncio
|
| 48 |
+
async def test_error_metrics():
|
| 49 |
+
"""Test error metrics tracking."""
|
| 50 |
+
metrics = ErrorMetrics()
|
| 51 |
+
|
| 52 |
+
# Test initial state
|
| 53 |
+
assert metrics.total_errors == 0
|
| 54 |
+
assert not metrics.error_counts
|
| 55 |
+
|
| 56 |
+
# Test error recording
|
| 57 |
+
await metrics.record_error("ValueError")
|
| 58 |
+
assert metrics.total_errors == 1
|
| 59 |
+
assert metrics.error_counts["ValueError"] == 1
|
| 60 |
+
|
| 61 |
+
# Test multiple errors
|
| 62 |
+
await metrics.record_error("ValueError")
|
| 63 |
+
await metrics.record_error("TypeError")
|
| 64 |
+
assert metrics.total_errors == 3
|
| 65 |
+
assert metrics.error_counts["ValueError"] == 2
|
| 66 |
+
assert metrics.error_counts["TypeError"] == 1
|
| 67 |
+
|
| 68 |
+
@pytest.mark.asyncio
|
| 69 |
+
async def test_request_tracking(monitor: PerformanceMonitor):
|
| 70 |
+
"""Test request tracking functionality."""
|
| 71 |
+
# Start request
|
| 72 |
+
start_time = await monitor.start_request("/test/endpoint")
|
| 73 |
+
await asyncio.sleep(0.1) # Simulate request time
|
| 74 |
+
|
| 75 |
+
# End request successfully
|
| 76 |
+
await monitor.end_request("/test/endpoint", start_time)
|
| 77 |
+
|
| 78 |
+
metrics = await monitor.get_metrics_report()
|
| 79 |
+
assert metrics["throughput"]["total_requests"] == 1
|
| 80 |
+
assert metrics["latencies"]["/test/endpoint"]["count"] == 1
|
| 81 |
+
assert metrics["latencies"]["/test/endpoint"]["avg_ms"] >= 100 # At least 100ms
|
| 82 |
+
|
| 83 |
+
# Test error tracking
|
| 84 |
+
start_time = await monitor.start_request("/error/endpoint")
|
| 85 |
+
await monitor.end_request("/error/endpoint", start_time, error=ValueError("Test error"))
|
| 86 |
+
|
| 87 |
+
metrics = await monitor.get_metrics_report()
|
| 88 |
+
assert metrics["errors"]["total"] == 1
|
| 89 |
+
assert metrics["errors"]["by_type"]["ValueError"] == 1
|
| 90 |
+
|
| 91 |
+
@pytest.mark.asyncio
|
| 92 |
+
async def test_connection_tracking(monitor: PerformanceMonitor):
|
| 93 |
+
"""Test connection pool metrics tracking."""
|
| 94 |
+
# Record connection states
|
| 95 |
+
await monitor.record_connection_state(1, 0.1)
|
| 96 |
+
await monitor.record_connection_state(2, 0.2)
|
| 97 |
+
await monitor.record_connection_state(1, 0.1)
|
| 98 |
+
|
| 99 |
+
metrics = await monitor.get_metrics_report()
|
| 100 |
+
assert metrics["connections"]["max_seen"] == 2
|
| 101 |
+
assert metrics["connections"]["active"] == 1
|
| 102 |
+
assert metrics["connections"]["total_wait_time"] == 0.4
|
| 103 |
+
|
| 104 |
+
@pytest.mark.asyncio
|
| 105 |
+
async def test_rate_limit_tracking(monitor: PerformanceMonitor):
|
| 106 |
+
"""Test rate limit metrics tracking."""
|
| 107 |
+
# Record rate limit delays
|
| 108 |
+
await monitor.record_rate_limit_delay(0.1)
|
| 109 |
+
await monitor.record_rate_limit_delay(0.2)
|
| 110 |
+
|
| 111 |
+
metrics = await monitor.get_metrics_report()
|
| 112 |
+
assert metrics["rate_limiting"]["delay_count"] == 2
|
| 113 |
+
assert metrics["rate_limiting"]["total_delays"] == 0.3
|
| 114 |
+
assert metrics["rate_limiting"]["avg_delay"] == 0.15
|
| 115 |
+
|
| 116 |
+
@pytest.mark.asyncio
|
| 117 |
+
async def test_batch_tracking(monitor: PerformanceMonitor):
|
| 118 |
+
"""Test batch processing metrics tracking."""
|
| 119 |
+
# Record batch metrics
|
| 120 |
+
await monitor.record_batch_metrics(10, 0.1)
|
| 121 |
+
await monitor.record_batch_metrics(20, 0.2)
|
| 122 |
+
|
| 123 |
+
metrics = await monitor.get_metrics_report()
|
| 124 |
+
assert metrics["batch_processing"]["total_batches"] == 2
|
| 125 |
+
assert metrics["batch_processing"]["avg_size"] == 15.0
|
| 126 |
+
assert metrics["batch_processing"]["avg_processing_time"] == 0.15
|
| 127 |
+
|
| 128 |
+
@pytest.mark.asyncio
|
| 129 |
+
async def test_throughput_calculation(monitor: PerformanceMonitor):
|
| 130 |
+
"""Test throughput calculation."""
|
| 131 |
+
# Simulate requests over time
|
| 132 |
+
for _ in range(5):
|
| 133 |
+
await monitor.start_request("/test/endpoint")
|
| 134 |
+
await asyncio.sleep(0.1)
|
| 135 |
+
|
| 136 |
+
metrics = await monitor.get_metrics_report()
|
| 137 |
+
assert 8.0 <= metrics["throughput"]["current_rps"] <= 12.0 # Approximately 10 RPS
|
| 138 |
+
|
| 139 |
+
@pytest.mark.asyncio
|
| 140 |
+
async def test_metrics_ttl(monitor: PerformanceMonitor):
|
| 141 |
+
"""Test metrics time-to-live functionality."""
|
| 142 |
+
# Add initial metrics
|
| 143 |
+
start_time = await monitor.start_request("/test/endpoint")
|
| 144 |
+
await monitor.end_request("/test/endpoint", start_time)
|
| 145 |
+
|
| 146 |
+
# Wait for TTL
|
| 147 |
+
await asyncio.sleep(1.1) # Just over the 1 second TTL
|
| 148 |
+
|
| 149 |
+
metrics = await monitor.get_metrics_report()
|
| 150 |
+
assert not metrics["latencies"] # Metrics should have expired
|
| 151 |
+
|
| 152 |
+
@pytest.mark.asyncio
|
| 153 |
+
async def test_max_metrics_limit(monitor: PerformanceMonitor):
|
| 154 |
+
"""Test maximum metrics limit."""
|
| 155 |
+
# Add more metrics than the limit
|
| 156 |
+
for i in range(15): # Max is 10
|
| 157 |
+
await monitor.record_batch_metrics(i, 0.1)
|
| 158 |
+
|
| 159 |
+
assert len(monitor.batch_sizes) == 10 # Should be limited to 10
|
| 160 |
+
assert monitor.batch_sizes[0] == 5 # Should have removed oldest entries
|