diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f56ebcd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,64 @@ +# Git +.git +.gitignore +.gitattributes + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info +dist +build +.eggs +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +htmlcov + +# Virtual environments +venv +env +ENV +.venv + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# Documentation +*.md +!README.md +docs + +# CI/CD +.github + +# Tests +tests +.coverage + +# Development +Makefile +.flake8 +requirements-dev.txt +.env +.env.example + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# OS +.DS_Store +Thumbs.db + +# Examples (uncomment if you don't want examples in the image) +# examples diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..22096b9 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# OpenWeatherMap API Configuration +# Get your free API key at: https://openweathermap.org/api + +# Required: Your OpenWeatherMap API key +OPENWEATHER_API_KEY=your_api_key_here + +# Optional: Default temperature units (metric, imperial, or standard) +# metric = Celsius, imperial = Fahrenheit, standard = Kelvin +WEATHER_UNITS=metric diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..d701902 --- /dev/null +++ b/.flake8 @@ -0,0 +1,18 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203, E266, E501, W503 +exclude = + .git, + __pycache__, + build, + dist, + .eggs, + *.egg-info, + .venv, + venv, + .tox, + .pytest_cache, + .mypy_cache +max-complexity = 10 +per-file-ignores = + __init__.py:F401 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..13ec941 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,32 @@ +# Set default behavior to automatically normalize line endings +* text=auto + +# Python files +*.py text eol=lf + +# Shell scripts +*.sh text eol=lf + +# Windows scripts +*.bat text eol=crlf +*.cmd text eol=crlf + +# Documentation +*.md text +*.txt text +LICENSE text +CHANGELOG text + +# Configuration files +*.yml text +*.yaml text +*.toml text +*.ini text +*.cfg text + +# Exclude files from exporting +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore +tests export-ignore +Makefile export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..bbbb8c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,67 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## Bug Description + +A clear and concise description of what the bug is. + +## To Reproduce + +Steps to reproduce the behavior: + +1. Run command '...' +2. With arguments '...' +3. See error '...' + +## Expected Behavior + +A clear and concise description of what you expected to happen. + +## Actual Behavior + +What actually happened instead. + +## Screenshots + +If applicable, add screenshots to help explain your problem. + +## Environment + +- OS: [e.g., Ubuntu 22.04, macOS 13.0, Windows 11] +- Python Version: [e.g., 3.11.0] +- Weather CLI Version: [e.g., 1.0.0] +- Installation Method: [e.g., pip, docker, source] + +## Command Output + +```bash +# Paste the full command and output here +$ python -m weather_cli.cli London +... +``` + +## Error Logs + +``` +# Paste any error messages or stack traces here +``` + +## Additional Context + +Add any other context about the problem here. + +## Possible Solution + +If you have ideas on how to fix the issue, please describe them here. + +## Checklist + +- [ ] I have searched for similar issues +- [ ] I am using the latest version +- [ ] I have set the OPENWEATHER_API_KEY environment variable +- [ ] I have read the documentation diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a1fa738 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://github.com/codeforgood-org/weather-cli/blob/main/README.md + about: Please check the documentation before opening an issue + - name: Examples + url: https://github.com/codeforgood-org/weather-cli/tree/main/examples + about: Check out example scripts demonstrating various use cases diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..375a82a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,57 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## Feature Description + +A clear and concise description of the feature you'd like to see. + +## Problem Statement + +What problem does this feature solve? Describe the use case. + +Example: "I'm always frustrated when..." + +## Proposed Solution + +Describe how you envision this feature working. + +## Example Usage + +Show how this feature would be used: + +```bash +# Example command +$ weather-cli London --your-new-feature + +# Expected output +... +``` + +## Alternatives Considered + +Describe any alternative solutions or features you've considered. + +## Additional Context + +Add any other context, mockups, or screenshots about the feature request here. + +## Benefits + +- Who would benefit from this feature? +- How would it improve the tool? + +## Implementation Ideas + +If you have thoughts on how this could be implemented, share them here. + +## Checklist + +- [ ] I have searched for similar feature requests +- [ ] This feature aligns with the project's goals +- [ ] I am willing to help implement this feature +- [ ] I have considered backward compatibility diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..29a7c54 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,38 @@ +--- +name: Question +about: Ask a question about using Weather CLI +title: '[QUESTION] ' +labels: question +assignees: '' +--- + +## Question + +What would you like to know? + +## Context + +Provide any relevant context that might help us answer your question better. + +## What I've Tried + +Describe what you've already tried or researched: + +- [ ] I've read the README +- [ ] I've checked the examples +- [ ] I've searched existing issues +- [ ] I've reviewed the documentation + +## Expected Information + +What kind of information or guidance are you looking for? + +## Code Example (if applicable) + +```python +# Include any relevant code here +``` + +## Additional Context + +Add any other context or screenshots about your question here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2c92f2f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,86 @@ +# Pull Request + +## Description + +Please include a summary of the changes and which issue is fixed. Include relevant motivation and context. + +Fixes # (issue) + +## Type of Change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Code refactoring +- [ ] Performance improvement +- [ ] Test improvement + +## Changes Made + +- +- +- + +## How Has This Been Tested? + +Please describe the tests that you ran to verify your changes. + +- [ ] Test A +- [ ] Test B + +**Test Configuration**: +- Python version: +- OS: + +## Screenshots (if applicable) + +Add screenshots to demonstrate the changes. + +## Checklist + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published + +## Code Quality + +- [ ] Code formatted with Black +- [ ] Imports sorted with isort +- [ ] Linted with flake8 +- [ ] Type checked with mypy +- [ ] All tests passing + +## Documentation + +- [ ] README updated (if needed) +- [ ] Docstrings added/updated +- [ ] Examples added/updated (if applicable) +- [ ] CHANGELOG updated + +## Breaking Changes + +If this PR introduces breaking changes, please describe: + +- What breaks: +- Migration path: +- Deprecation notice: + +## Additional Notes + +Add any additional notes or context for reviewers. + +## Related Issues + +Link any related issues here: + +- Related to # +- Depends on # +- Blocks # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..60b330a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,109 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip packages + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run tests with pytest + run: | + pytest --cov=weather_cli --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Check code formatting with black + run: | + black --check weather_cli tests + + - name: Check import sorting with isort + run: | + isort --check-only weather_cli tests + + - name: Lint with flake8 + run: | + flake8 weather_cli tests --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 weather_cli tests --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics + + - name: Type check with mypy + run: | + mypy weather_cli + continue-on-error: true + + security: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install safety bandit + + - name: Security check with bandit + run: | + bandit -r weather_cli -ll + continue-on-error: true + + - name: Check dependencies for vulnerabilities + run: | + safety check --json + continue-on-error: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3c629ec --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,111 @@ +# Pre-commit hooks for Weather CLI +# Install: pip install pre-commit +# Setup: pre-commit install +# Run manually: pre-commit run --all-files + +repos: + # General hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-json + - id: check-added-large-files + args: ['--maxkb=1000'] + - id: check-merge-conflict + - id: check-case-conflict + - id: detect-private-key + - id: mixed-line-ending + args: ['--fix=lf'] + - id: name-tests-test + args: ['--pytest-test-first'] + + # Python code formatting with Black + - repo: https://github.com/psf/black + rev: 23.12.1 + hooks: + - id: black + language_version: python3.11 + args: ['--line-length=88'] + + # Import sorting with isort + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + args: ['--profile', 'black'] + + # Linting with flake8 + - repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + args: ['--max-line-length=88', '--extend-ignore=E203,E266,E501,W503'] + additional_dependencies: [flake8-docstrings, flake8-bugbear] + + # Type checking with mypy + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + additional_dependencies: [types-requests] + args: ['--ignore-missing-imports', '--no-strict-optional'] + files: ^weather_cli/ + + # Security checks with bandit + - repo: https://github.com/PyCQA/bandit + rev: 1.7.6 + hooks: + - id: bandit + args: ['-r', 'weather_cli', '-ll'] + + # Docstring coverage + - repo: https://github.com/econchick/interrogate + rev: 1.5.0 + hooks: + - id: interrogate + args: ['-vv', '--fail-under=60', 'weather_cli'] + + # Python upgrade syntax + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: [--py37-plus] + + # Check for common security issues + - repo: https://github.com/Lucas-C/pre-commit-hooks-safety + rev: v1.3.3 + hooks: + - id: python-safety-dependencies-check + files: requirements.txt + + # Markdown linting + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.38.0 + hooks: + - id: markdownlint + args: ['--fix'] + + # YAML formatting + - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks + rev: v2.12.0 + hooks: + - id: pretty-format-yaml + args: [--autofix, --indent, '2'] + +# CI configuration +ci: + autofix_commit_msg: | + [pre-commit.ci] auto fixes from pre-commit hooks + + for more information, see https://pre-commit.ci + autofix_prs: true + autoupdate_branch: '' + autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' + autoupdate_schedule: weekly + skip: [python-safety-dependencies-check] + submodules: false diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..22d4de1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,17 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.black-formatter", + "ms-python.isort", + "ms-python.flake8", + "ms-python.mypy-type-checker", + "ms-azuretools.vscode-docker", + "redhat.vscode-yaml", + "github.vscode-github-actions", + "eamodio.gitlens", + "usernamehw.errorlens", + "njpwerner.autodocstring", + "tamasfe.even-better-toml" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..f4a842b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,80 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Weather CLI: London", + "type": "python", + "request": "launch", + "module": "weather_cli.cli", + "args": ["London"], + "console": "integratedTerminal", + "justMyCode": true, + "env": { + "PYTHONPATH": "${workspaceFolder}" + } + }, + { + "name": "Weather CLI: Custom City", + "type": "python", + "request": "launch", + "module": "weather_cli.cli", + "args": ["${input:cityName}"], + "console": "integratedTerminal", + "justMyCode": true, + "env": { + "PYTHONPATH": "${workspaceFolder}" + } + }, + { + "name": "Weather CLI: JSON Output", + "type": "python", + "request": "launch", + "module": "weather_cli.cli", + "args": ["London", "--format", "json"], + "console": "integratedTerminal", + "justMyCode": true, + "env": { + "PYTHONPATH": "${workspaceFolder}" + } + }, + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true + }, + { + "name": "Python: Debug Tests", + "type": "python", + "request": "launch", + "module": "pytest", + "args": [ + "-v", + "${file}" + ], + "console": "integratedTerminal", + "justMyCode": false + }, + { + "name": "Python: Debug All Tests", + "type": "python", + "request": "launch", + "module": "pytest", + "args": [ + "-v" + ], + "console": "integratedTerminal", + "justMyCode": false + } + ], + "inputs": [ + { + "id": "cityName", + "type": "promptString", + "description": "Enter city name", + "default": "London" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5d421e3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,53 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.linting.flake8Enabled": true, + "python.linting.mypyEnabled": true, + "python.formatting.provider": "black", + "python.formatting.blackArgs": [ + "--line-length=88" + ], + "python.sortImports.args": [ + "--profile", + "black" + ], + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "python.testing.pytestArgs": [ + "tests" + ], + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true + }, + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true, + "**/*.pyo": true, + "**/*.egg-info": true, + "**/.pytest_cache": true, + "**/.mypy_cache": true, + "**/.ruff_cache": true + }, + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/node_modules/**": true, + "**/.venv/**": true, + "**/venv/**": true + }, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + }, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.diagnosticMode": "workspace", + "python.analysis.autoImportCompletions": true, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..8bad0e9 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,57 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Install Dependencies", + "type": "shell", + "command": "pip install -r requirements-dev.txt", + "problemMatcher": [] + }, + { + "label": "Run Tests", + "type": "shell", + "command": "pytest", + "group": { + "kind": "test", + "isDefault": true + }, + "problemMatcher": [] + }, + { + "label": "Run Tests with Coverage", + "type": "shell", + "command": "pytest --cov=weather_cli --cov-report=html --cov-report=term", + "problemMatcher": [] + }, + { + "label": "Format Code", + "type": "shell", + "command": "black weather_cli tests && isort weather_cli tests", + "problemMatcher": [] + }, + { + "label": "Lint Code", + "type": "shell", + "command": "flake8 weather_cli tests && mypy weather_cli", + "problemMatcher": [] + }, + { + "label": "Build Docker Image", + "type": "shell", + "command": "docker build -t weather-cli:latest .", + "problemMatcher": [] + }, + { + "label": "Run Weather CLI (London)", + "type": "shell", + "command": "python -m weather_cli.cli London", + "problemMatcher": [] + }, + { + "label": "Clean", + "type": "shell", + "command": "make clean", + "problemMatcher": [] + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..315762e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2025-11-13 + +### Added +- Initial release of Weather CLI +- Command-line interface for fetching weather data +- Support for OpenWeatherMap API integration +- Environment variable configuration for API keys +- Comprehensive error handling and validation +- Support for multiple temperature units (Celsius, Fahrenheit, Kelvin) +- Detailed weather information display (temperature, humidity, wind, etc.) +- Full test suite with pytest +- Type hints throughout the codebase +- Modular package structure +- Documentation (README, CONTRIBUTING, CHANGELOG) +- GitHub Actions CI/CD pipeline +- Code quality tools configuration (Black, isort, flake8, mypy) +- MIT License + +### Features +- Fetch current weather for any city worldwide +- Display comprehensive weather information +- Context manager support for HTTP client +- Proper timeout handling for API requests +- User-friendly error messages +- Command-line argument parsing with argparse + +### Documentation +- Comprehensive README with usage examples +- Contributing guidelines +- Environment variable configuration examples +- Installation instructions +- Development setup guide + +### Developer Experience +- Automated testing with pytest +- Code coverage reporting +- Multiple Python version support (3.7-3.12) +- Cross-platform compatibility (Linux, macOS, Windows) +- Pre-configured development dependencies +- Linting and formatting tools + +## [Unreleased] + +### Planned Features +- 5-day weather forecast +- Multiple city comparison +- Weather alerts and notifications +- Historical weather data +- Colorized terminal output +- JSON output format option +- Configuration file support + +--- + +[1.0.0]: https://github.com/codeforgood-org/weather-cli/releases/tag/v1.0.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6bf31a2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,127 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via GitHub issues. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2ea57b3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,212 @@ +# Contributing to Weather CLI + +First off, thank you for considering contributing to Weather CLI! It's people like you that make Weather CLI such a great tool. + +## Code of Conduct + +This project and everyone participating in it is governed by our commitment to creating a welcoming and inclusive environment. Please be respectful and constructive in your interactions. + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the existing issues to avoid duplicates. When you create a bug report, include as many details as possible: + +- **Use a clear and descriptive title** +- **Describe the exact steps to reproduce the problem** +- **Provide specific examples to demonstrate the steps** +- **Describe the behavior you observed and what you expected** +- **Include screenshots if applicable** +- **Include your environment details** (OS, Python version, etc.) + +### Suggesting Enhancements + +Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion: + +- **Use a clear and descriptive title** +- **Provide a detailed description of the suggested enhancement** +- **Explain why this enhancement would be useful** +- **List any similar features in other tools if applicable** + +### Pull Requests + +1. **Fork the repository** and create your branch from `main` +2. **Make your changes** following the code style guidelines +3. **Add tests** if you've added code that should be tested +4. **Ensure the test suite passes** +5. **Update documentation** as needed +6. **Submit your pull request** + +## Development Setup + +### Prerequisites + +- Python 3.7 or higher +- Git + +### Setting Up Development Environment + +1. Clone your fork of the repository: +```bash +git clone https://github.com/YOUR_USERNAME/weather-cli.git +cd weather-cli +``` + +2. Create a virtual environment: +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +3. Install development dependencies: +```bash +pip install -r requirements-dev.txt +``` + +4. Set up your API key: +```bash +cp .env.example .env +# Edit .env and add your OpenWeatherMap API key +``` + +## Code Style Guidelines + +We follow PEP 8 with some modifications: + +- **Line length**: Maximum 88 characters (Black default) +- **Imports**: Sorted with isort +- **Type hints**: Use type hints for function signatures +- **Docstrings**: Use Google-style docstrings + +### Running Code Quality Tools + +Before submitting a pull request, run these tools: + +```bash +# Format code with Black +black weather_cli tests + +# Sort imports with isort +isort weather_cli tests + +# Lint with flake8 +flake8 weather_cli tests + +# Type check with mypy +mypy weather_cli + +# Run all linters (optional) +pylint weather_cli +``` + +## Testing + +### Running Tests + +```bash +# Run all tests +pytest + +# Run with coverage report +pytest --cov=weather_cli --cov-report=html + +# Run specific test file +pytest tests/test_weather.py + +# Run specific test +pytest tests/test_weather.py::TestWeatherClient::test_get_weather_success +``` + +### Writing Tests + +- Write tests for all new features and bug fixes +- Aim for high code coverage (target: >80%) +- Use descriptive test names +- Follow the Arrange-Act-Assert pattern +- Use fixtures for common test setup +- Mock external API calls + +Example test: +```python +def test_get_weather_success(self, monkeypatch): + """Test successful weather fetch.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_key") + # ... rest of test +``` + +## Project Structure + +``` +weather-cli/ +├── weather_cli/ # Main package +│ ├── __init__.py # Package initialization +│ ├── cli.py # CLI interface +│ ├── config.py # Configuration management +│ └── weather.py # Weather API client +├── tests/ # Test suite +│ ├── test_cli.py +│ ├── test_config.py +│ └── test_weather.py +├── .github/ # GitHub configuration +│ └── workflows/ # CI/CD workflows +├── docs/ # Documentation (if added) +├── requirements.txt # Production dependencies +├── requirements-dev.txt # Development dependencies +└── pyproject.toml # Project configuration +``` + +## Commit Message Guidelines + +We follow conventional commit messages: + +- `feat:` New feature +- `fix:` Bug fix +- `docs:` Documentation changes +- `style:` Code style changes (formatting, etc.) +- `refactor:` Code refactoring +- `test:` Adding or updating tests +- `chore:` Maintenance tasks + +Examples: +``` +feat: add support for 5-day forecast +fix: handle connection timeout errors +docs: update README with new examples +test: add tests for config validation +``` + +## Branch Naming + +Use descriptive branch names: + +- `feature/feature-name` for new features +- `fix/bug-description` for bug fixes +- `docs/description` for documentation +- `refactor/description` for refactoring + +## Release Process + +Releases are managed by maintainers: + +1. Update version in `__init__.py` and `pyproject.toml` +2. Update CHANGELOG.md +3. Create a git tag +4. Push to PyPI (maintainers only) + +## Need Help? + +Don't hesitate to ask questions: + +- Open an issue with the `question` label +- Reach out to maintainers +- Check existing documentation + +## Recognition + +Contributors will be recognized in: + +- README.md contributors section +- Release notes +- GitHub contributors page + +Thank you for contributing! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3821dc9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +# Weather CLI Dockerfile +# Multi-stage build for optimal image size + +# Build stage +FROM python:3.11-slim as builder + +# Set working directory +WORKDIR /app + +# Install build dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends gcc && \ + rm -rf /var/lib/apt/lists/* + +# Copy dependency files +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --user --no-cache-dir -r requirements.txt + +# Runtime stage +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Copy Python dependencies from builder +COPY --from=builder /root/.local /root/.local + +# Copy application code +COPY weather_cli/ ./weather_cli/ +COPY setup.py pyproject.toml ./ + +# Make sure scripts in .local are usable +ENV PATH=/root/.local/bin:$PATH + +# Install the package +RUN pip install --no-cache-dir -e . + +# Set environment variables +ENV PYTHONUNBUFFERED=1 + +# Create a non-root user +RUN useradd -m -u 1000 weatheruser && \ + chown -R weatheruser:weatheruser /app + +# Switch to non-root user +USER weatheruser + +# Set entrypoint +ENTRYPOINT ["python", "-m", "weather_cli.cli"] + +# Default command (can be overridden) +CMD ["--help"] + +# Labels +LABEL maintainer="codeforgood-org" +LABEL version="1.0.0" +LABEL description="Weather CLI - A simple command-line weather application" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6b7134c --- /dev/null +++ b/Makefile @@ -0,0 +1,68 @@ +.PHONY: install install-dev test coverage lint format clean help + +help: + @echo "Weather CLI - Development Makefile" + @echo "" + @echo "Available targets:" + @echo " install Install production dependencies" + @echo " install-dev Install development dependencies" + @echo " test Run tests" + @echo " coverage Run tests with coverage report" + @echo " lint Run all linters" + @echo " format Format code with black and isort" + @echo " clean Remove build artifacts and cache files" + @echo " run Run the CLI (usage: make run CITY='London')" + @echo "" + +install: + pip install -r requirements.txt + +install-dev: + pip install -r requirements-dev.txt + +test: + pytest + +coverage: + pytest --cov=weather_cli --cov-report=html --cov-report=term + @echo "Coverage report generated in htmlcov/index.html" + +lint: + @echo "Running Black..." + black --check weather_cli tests + @echo "Running isort..." + isort --check-only weather_cli tests + @echo "Running flake8..." + flake8 weather_cli tests + @echo "Running mypy..." + mypy weather_cli + @echo "All linters passed!" + +format: + @echo "Formatting with Black..." + black weather_cli tests + @echo "Sorting imports with isort..." + isort weather_cli tests + @echo "Code formatted!" + +clean: + @echo "Cleaning up..." + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info + rm -rf .pytest_cache + rm -rf .coverage + rm -rf htmlcov/ + rm -rf .mypy_cache + rm -rf .ruff_cache + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name '*.pyc' -delete + find . -type f -name '*.pyo' -delete + @echo "Clean complete!" + +run: + @if [ -z "$(CITY)" ]; then \ + echo "Usage: make run CITY='London'"; \ + else \ + python -m weather_cli.cli $(CITY); \ + fi diff --git a/README.md b/README.md new file mode 100644 index 0000000..5377912 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# Weather CLI 🌤️ + +A simple, elegant command-line interface for fetching real-time weather information from OpenWeatherMap API. + +## Features + +- 🌍 Get weather for any city worldwide +- 🌡️ Detailed weather information including temperature, humidity, wind speed, and more +- 🔧 Configurable temperature units (Celsius, Fahrenheit, Kelvin) +- 🔐 Secure API key management via environment variables +- ✨ Clean, modular Python code with type hints +- 🧪 Comprehensive error handling +- 📦 Easy installation and setup + +## Installation + +### Prerequisites + +- Python 3.7 or higher +- pip (Python package manager) +- An OpenWeatherMap API key ([get one free here](https://openweathermap.org/api)) + +### Quick Install + +1. Clone this repository: +```bash +git clone https://github.com/codeforgood-org/weather-cli.git +cd weather-cli +``` + +2. Install dependencies: +```bash +pip install -r requirements.txt +``` + +3. Set up your API key: +```bash +export OPENWEATHER_API_KEY='your_api_key_here' +``` + +Or create a `.env` file: +```bash +echo "OPENWEATHER_API_KEY=your_api_key_here" > .env +``` + +### Package Installation (Optional) + +Install as a package for system-wide access: + +```bash +pip install -e . +``` + +## Usage + +### Basic Usage + +Get weather for a city: + +```bash +python -m weather_cli.cli London +``` + +Or if installed as a package: + +```bash +weather-cli London +``` + +### Multi-word City Names + +For cities with spaces in their names: + +```bash +python -m weather_cli.cli New York +python -m weather_cli.cli San Francisco +``` + +### Temperature Units + +Choose your preferred temperature unit: + +```bash +# Celsius (default) +python -m weather_cli.cli Tokyo + +# Fahrenheit +python -m weather_cli.cli Tokyo --units imperial + +# Kelvin +python -m weather_cli.cli Tokyo --units standard +``` + +### Examples + +```bash +# Get weather for Paris +$ python -m weather_cli.cli Paris + +Weather in Paris, FR: + Description: Clear sky + Temperature: 18.5°C (feels like 17.8°C) + Humidity: 65% + Pressure: 1015 hPa + Wind Speed: 3.5 m/s + Cloudiness: 10% +``` + +## Development + +### Project Structure + +``` +weather-cli/ +├── weather_cli/ # Main package directory +│ ├── __init__.py # Package initialization +│ ├── cli.py # Command-line interface +│ ├── config.py # Configuration management +│ └── weather.py # Weather data fetching and processing +├── tests/ # Test directory +│ ├── __init__.py +│ ├── test_config.py +│ ├── test_weather.py +│ └── test_cli.py +├── .env.example # Example environment variables +├── .gitignore # Git ignore rules +├── LICENSE # MIT License +├── README.md # This file +├── requirements.txt # Production dependencies +├── requirements-dev.txt # Development dependencies +└── pyproject.toml # Project configuration +``` + +### Running Tests + +Install development dependencies: + +```bash +pip install -r requirements-dev.txt +``` + +Run tests: + +```bash +pytest +``` + +Run tests with coverage: + +```bash +pytest --cov=weather_cli --cov-report=html +``` + +### Code Quality + +This project uses various tools to maintain code quality: + +```bash +# Format code with black +black weather_cli tests + +# Sort imports +isort weather_cli tests + +# Lint with flake8 +flake8 weather_cli tests + +# Type checking with mypy +mypy weather_cli +``` + +## Configuration + +### Environment Variables + +- `OPENWEATHER_API_KEY` (required): Your OpenWeatherMap API key +- `WEATHER_UNITS` (optional): Default temperature units (`metric`, `imperial`, or `standard`) + +### Getting an API Key + +1. Visit [OpenWeatherMap](https://openweathermap.org/api) +2. Sign up for a free account +3. Navigate to API Keys section +4. Generate a new API key +5. Add it to your environment variables + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +Please make sure to update tests as appropriate and follow the code style guidelines. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- Weather data provided by [OpenWeatherMap](https://openweathermap.org/) +- Built with ❤️ by [codeforgood-org](https://github.com/codeforgood-org) + +## Support + +If you encounter any issues or have questions: + +1. Check the [Issues](https://github.com/codeforgood-org/weather-cli/issues) page +2. Create a new issue if your problem isn't already listed +3. Provide as much detail as possible including error messages and your environment + +## Roadmap + +Future enhancements planned: + +- [ ] 5-day weather forecast +- [ ] Multiple city comparison +- [ ] Weather alerts and notifications +- [ ] Historical weather data +- [ ] Colorized terminal output +- [ ] JSON output format option +- [ ] Configuration file support + +--- + +Made with Python 🐍 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..828455c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +version: '3.8' + +services: + weather-cli: + build: + context: . + dockerfile: Dockerfile + image: weather-cli:latest + container_name: weather-cli + environment: + - OPENWEATHER_API_KEY=${OPENWEATHER_API_KEY} + - WEATHER_UNITS=${WEATHER_UNITS:-metric} + # Override default command to get weather for a city + # Usage: docker-compose run weather-cli London + command: ["London"] + # If you want to run interactively + stdin_open: true + tty: true + + # Example service for running multiple cities + weather-multi: + build: + context: . + dockerfile: Dockerfile + image: weather-cli:latest + environment: + - OPENWEATHER_API_KEY=${OPENWEATHER_API_KEY} + - WEATHER_UNITS=${WEATHER_UNITS:-metric} + volumes: + - ./examples:/app/examples:ro + command: ["python", "/app/examples/basic_usage.py"] + profiles: + - examples + + # Development service with volume mounting + weather-dev: + build: + context: . + dockerfile: Dockerfile + image: weather-cli:latest + environment: + - OPENWEATHER_API_KEY=${OPENWEATHER_API_KEY} + - WEATHER_UNITS=${WEATHER_UNITS:-metric} + volumes: + - .:/app:rw + command: ["--help"] + profiles: + - dev diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..37434d3 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,212 @@ +# Docker Usage Guide + +This guide explains how to use Weather CLI with Docker. + +## Quick Start + +### Build the Image + +```bash +docker build -t weather-cli:latest . +``` + +### Run with Docker + +```bash +# Get weather for a city +docker run --rm -e OPENWEATHER_API_KEY='your_api_key' weather-cli:latest London + +# Get weather with custom units +docker run --rm \ + -e OPENWEATHER_API_KEY='your_api_key' \ + -e WEATHER_UNITS='imperial' \ + weather-cli:latest "New York" + +# JSON output +docker run --rm \ + -e OPENWEATHER_API_KEY='your_api_key' \ + weather-cli:latest London --format json +``` + +## Using Docker Compose + +### Basic Usage + +1. Create a `.env` file with your API key: +```bash +echo "OPENWEATHER_API_KEY=your_api_key_here" > .env +``` + +2. Run with docker-compose: +```bash +# Get weather for London (default) +docker-compose run --rm weather-cli + +# Get weather for a specific city +docker-compose run --rm weather-cli Tokyo + +# Multiple cities with examples +docker-compose --profile examples run --rm weather-multi +``` + +### Development Mode + +Run in development mode with live code updates: + +```bash +docker-compose --profile dev run --rm weather-dev +``` + +## Building Multi-Architecture Images + +Build for multiple architectures (ARM and x86): + +```bash +# Set up buildx +docker buildx create --use + +# Build for multiple platforms +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t weather-cli:latest \ + --push . +``` + +## Docker Hub Deployment + +### Tag and Push + +```bash +# Tag the image +docker tag weather-cli:latest your-username/weather-cli:latest +docker tag weather-cli:latest your-username/weather-cli:1.0.0 + +# Push to Docker Hub +docker push your-username/weather-cli:latest +docker push your-username/weather-cli:1.0.0 +``` + +### Pull and Run from Docker Hub + +```bash +docker pull your-username/weather-cli:latest +docker run --rm -e OPENWEATHER_API_KEY='your_api_key' your-username/weather-cli:latest London +``` + +## Advanced Usage + +### Running Examples Inside Container + +```bash +# Mount examples and run +docker run --rm \ + -e OPENWEATHER_API_KEY='your_api_key' \ + -v $(pwd)/examples:/app/examples:ro \ + weather-cli:latest python /app/examples/temperature_comparison.py +``` + +### Saving Output to File + +```bash +# Export JSON to file +docker run --rm \ + -e OPENWEATHER_API_KEY='your_api_key' \ + -v $(pwd)/output:/output \ + weather-cli:latest London --format json > output/weather.json +``` + +### Interactive Shell + +```bash +# Run interactive Python shell with weather_cli available +docker run --rm -it \ + -e OPENWEATHER_API_KEY='your_api_key' \ + --entrypoint python \ + weather-cli:latest +``` + +Then in Python: +```python +from weather_cli.config import Config +from weather_cli.weather import WeatherClient + +config = Config() +with WeatherClient(config) as client: + weather = client.get_weather("Paris") + print(weather) +``` + +## Image Details + +### Size Optimization + +The Dockerfile uses multi-stage builds to minimize image size: +- Build stage: Includes build dependencies +- Runtime stage: Only includes necessary runtime dependencies +- Final image size: ~150-200MB + +### Security + +- Runs as non-root user (`weatheruser`) +- Minimal base image (python:3.11-slim) +- No unnecessary packages installed +- Build dependencies removed in final image + +## Troubleshooting + +### API Key Not Working + +Make sure your API key is properly set: +```bash +# Check if env var is set +docker run --rm -e OPENWEATHER_API_KEY='your_api_key' weather-cli:latest --help +``` + +### Permission Denied + +If you get permission errors: +```bash +# Run as root (not recommended for production) +docker run --rm --user root -e OPENWEATHER_API_KEY='your_api_key' weather-cli:latest London +``` + +### Updating the Image + +After code changes: +```bash +# Rebuild without cache +docker build --no-cache -t weather-cli:latest . + +# Or with docker-compose +docker-compose build --no-cache +``` + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +- name: Build Docker image + run: docker build -t weather-cli:latest . + +- name: Test Docker image + env: + OPENWEATHER_API_KEY: ${{ secrets.OPENWEATHER_API_KEY }} + run: docker run --rm -e OPENWEATHER_API_KEY weather-cli:latest London --format json +``` + +## Best Practices + +1. **Always use environment variables** for API keys +2. **Use .env file** for local development +3. **Tag images with versions** for production +4. **Use slim base images** to reduce size +5. **Run as non-root user** for security +6. **Use .dockerignore** to exclude unnecessary files +7. **Implement health checks** for production deployments + +## Resources + +- [Docker Documentation](https://docs.docker.com/) +- [Docker Compose Documentation](https://docs.docker.com/compose/) +- [Best practices for writing Dockerfiles](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b25325b --- /dev/null +++ b/examples/README.md @@ -0,0 +1,101 @@ +# Weather CLI Examples + +This directory contains example scripts demonstrating various ways to use the Weather CLI package. + +## Examples + +### 1. Basic Usage (`basic_usage.py`) + +Demonstrates basic programmatic usage of the weather_cli package to fetch weather for multiple cities. + +```bash +python examples/basic_usage.py +``` + +Features: +- Fetching weather for multiple cities +- Using the WeatherClient context manager +- Error handling +- Rich formatted output + +### 2. JSON Export (`json_export.py`) + +Shows how to fetch weather data and export it to a JSON file with timestamps. + +```bash +python examples/json_export.py +``` + +Features: +- Batch weather data fetching +- JSON file export +- Timestamped data +- Progress indicators + +### 3. Temperature Comparison (`temperature_comparison.py`) + +Compares temperatures across multiple cities and displays results in a table. + +```bash +python examples/temperature_comparison.py +``` + +Features: +- Multi-city comparison +- Sorted temperature display +- Finding temperature extremes +- Rich table formatting + +## Requirements + +All examples require: +- An OpenWeatherMap API key set as `OPENWEATHER_API_KEY` environment variable +- The weather_cli package installed + +## Running Examples + +1. Set up your environment: +```bash +export OPENWEATHER_API_KEY='your_api_key_here' +``` + +2. Install the package: +```bash +pip install -e . +``` + +3. Run any example: +```bash +python examples/basic_usage.py +``` + +## Creating Your Own Scripts + +You can use these examples as templates for your own scripts. The basic pattern is: + +```python +from weather_cli.config import Config +from weather_cli.weather import WeatherClient + +config = Config() +with WeatherClient(config) as client: + weather = client.get_weather("London") + print(weather) +``` + +## Additional Ideas + +Here are some ideas for extending these examples: + +- **Weather Alerts**: Check if temperature exceeds certain thresholds +- **Historical Tracking**: Store weather data in a database over time +- **Notifications**: Send alerts when conditions change +- **Data Visualization**: Create charts from weather data +- **API Integration**: Use weather data in web applications +- **Automation**: Schedule regular weather checks with cron + +## Need Help? + +- Check the main [README](../README.md) for more information +- Review the [API documentation](../weather_cli/) +- Open an issue on GitHub if you find problems diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..73c2112 --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +Basic usage example for Weather CLI. + +This example demonstrates how to use the weather_cli package +programmatically in your Python scripts. +""" + +import os +from weather_cli.config import Config +from weather_cli.weather import WeatherClient +from weather_cli.formatter import WeatherFormatter + + +def main(): + """Fetch and display weather for multiple cities.""" + # Ensure API key is set + if not os.getenv("OPENWEATHER_API_KEY"): + print("Please set OPENWEATHER_API_KEY environment variable") + return + + # Cities to check + cities = ["London", "Tokyo", "New York", "Paris", "Sydney"] + + # Initialize configuration + config = Config() + formatter = WeatherFormatter() + + print("Fetching weather for multiple cities...\n") + + # Create weather client + with WeatherClient(config) as client: + for city in cities: + try: + weather = client.get_weather(city) + if weather: + formatter.format_rich(weather) + print() # Empty line between cities + except Exception as e: + formatter.print_error(f"Failed to fetch weather for {city}: {e}") + + +if __name__ == "__main__": + main() diff --git a/examples/json_export.py b/examples/json_export.py new file mode 100644 index 0000000..3db8938 --- /dev/null +++ b/examples/json_export.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Example: Export weather data to JSON file. + +This example shows how to fetch weather data and save it to a JSON file. +""" + +import os +import json +from datetime import datetime +from weather_cli.config import Config +from weather_cli.weather import WeatherClient + + +def main(): + """Fetch weather and save to JSON file.""" + if not os.getenv("OPENWEATHER_API_KEY"): + print("Please set OPENWEATHER_API_KEY environment variable") + return + + cities = ["London", "Tokyo", "New York"] + config = Config() + results = [] + + print("Fetching weather data...") + + with WeatherClient(config) as client: + for city in cities: + try: + weather = client.get_weather(city) + if weather: + results.append({ + "city": weather.city, + "country": weather.country, + "description": weather.description, + "temperature": weather.temperature, + "feels_like": weather.feels_like, + "humidity": weather.humidity, + "pressure": weather.pressure, + "wind_speed": weather.wind_speed, + "cloudiness": weather.clouds, + "timestamp": datetime.now().isoformat() + }) + print(f"✓ Fetched data for {city}") + except Exception as e: + print(f"✗ Failed to fetch data for {city}: {e}") + + # Save to file + filename = f"weather_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + with open(filename, 'w') as f: + json.dump(results, f, indent=2) + + print(f"\n✓ Weather data saved to {filename}") + + +if __name__ == "__main__": + main() diff --git a/examples/temperature_comparison.py b/examples/temperature_comparison.py new file mode 100644 index 0000000..aecdbb2 --- /dev/null +++ b/examples/temperature_comparison.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +Example: Compare temperatures across multiple cities. + +This example demonstrates comparing weather data and finding extremes. +""" + +import os +from weather_cli.config import Config +from weather_cli.weather import WeatherClient +from rich.console import Console +from rich.table import Table + + +def main(): + """Compare temperatures across multiple cities.""" + if not os.getenv("OPENWEATHER_API_KEY"): + print("Please set OPENWEATHER_API_KEY environment variable") + return + + # Cities to compare + cities = [ + "London", "Tokyo", "New York", "Paris", "Sydney", + "Dubai", "Moscow", "Singapore", "Rio de Janeiro" + ] + + config = Config() + console = Console() + weather_data = [] + + console.print("\n[bold cyan]Fetching weather data...[/bold cyan]\n") + + with WeatherClient(config) as client: + for city in cities: + try: + weather = client.get_weather(city) + if weather: + weather_data.append(weather) + console.print(f"✓ {city}", style="green") + except Exception as e: + console.print(f"✗ {city}: {e}", style="red") + + if not weather_data: + console.print("\n[red]No weather data fetched[/red]") + return + + # Create comparison table + table = Table(title="Temperature Comparison") + table.add_column("City", style="cyan", no_wrap=True) + table.add_column("Temperature", justify="right", style="yellow") + table.add_column("Feels Like", justify="right", style="yellow") + table.add_column("Description", style="green") + + # Sort by temperature + weather_data.sort(key=lambda x: x.temperature, reverse=True) + + for weather in weather_data: + location = f"{weather.city}, {weather.country}" if weather.country else weather.city + table.add_row( + location, + f"{weather.temperature}°C", + f"{weather.feels_like}°C", + weather.description.capitalize() + ) + + console.print("\n") + console.print(table) + + # Find extremes + hottest = max(weather_data, key=lambda x: x.temperature) + coldest = min(weather_data, key=lambda x: x.temperature) + + console.print(f"\n[red]🔥 Hottest:[/red] {hottest.city} at {hottest.temperature}°C") + console.print(f"[blue]❄️ Coldest:[/blue] {coldest.city} at {coldest.temperature}°C\n") + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py deleted file mode 100644 index 86dba94..0000000 --- a/main.py +++ /dev/null @@ -1,31 +0,0 @@ -import requests -import sys - -API_KEY = "YOUR_API_KEY" # Replace with your OpenWeatherMap API key -BASE_URL = "https://api.openweathermap.org/data/2.5/weather" - -def get_weather(city): - params = { - "q": city, - "appid": API_KEY, - "units": "metric" - } - response = requests.get(BASE_URL, params=params) - if response.status_code == 200: - data = response.json() - weather = data['weather'][0]['description'] - temp = data['main']['temp'] - city_name = data['name'] - print(f"Weather in {city_name}: {weather}, {temp}°C") - else: - print("Error fetching weather data. Check city name or API key.") - -def main(): - if len(sys.argv) < 2: - print("Usage: python weather.py [city]") - return - city = " ".join(sys.argv[1:]) - get_weather(city) - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8bd41f6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,149 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "weather-cli" +version = "1.0.0" +description = "A simple command-line weather application" +readme = "README.md" +requires-python = ">=3.7" +license = {text = "MIT"} +authors = [ + {name = "codeforgood-org"} +] +keywords = ["weather", "cli", "openweathermap", "forecast"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Utilities", +] +dependencies = [ + "requests>=2.31.0,<3.0.0", + "python-dotenv>=1.0.0,<2.0.0", + "rich>=13.7.0,<14.0.0", + "click>=8.1.0,<9.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0,<9.0.0", + "pytest-cov>=4.1.0,<6.0.0", + "pytest-mock>=3.12.0,<4.0.0", + "responses>=0.24.0,<1.0.0", + "black>=23.12.0,<25.0.0", + "isort>=5.13.0,<6.0.0", + "flake8>=6.1.0,<8.0.0", + "mypy>=1.8.0,<2.0.0", + "pylint>=3.0.0,<4.0.0", + "bandit>=1.7.0,<2.0.0", + "safety>=3.0.0,<4.0.0", + "pre-commit>=3.6.0,<4.0.0", + "types-requests>=2.31.0,<3.0.0", +] + +[project.urls] +Homepage = "https://github.com/codeforgood-org/weather-cli" +Issues = "https://github.com/codeforgood-org/weather-cli/issues" +Repository = "https://github.com/codeforgood-org/weather-cli" + +[project.scripts] +weather-cli = "weather_cli.cli:main" + +[tool.setuptools] +packages = ["weather_cli"] + +[tool.black] +line-length = 88 +target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +line_length = 88 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "-ra", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", +] + +[tool.coverage.run] +source = ["weather_cli"] +omit = [ + "*/tests/*", + "*/test_*.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] + +[tool.mypy] +python_version = "3.7" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +strict_equality = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false + +[tool.pylint.messages_control] +max-line-length = 88 +disable = [ + "C0111", # missing-docstring + "C0103", # invalid-name + "R0903", # too-few-public-methods +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..3e2779e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,24 @@ +-r requirements.txt + +# Testing +pytest>=7.4.0,<9.0.0 +pytest-cov>=4.1.0,<6.0.0 +pytest-mock>=3.12.0,<4.0.0 +responses>=0.24.0,<1.0.0 + +# Code quality +black>=23.12.0,<25.0.0 +isort>=5.13.0,<6.0.0 +flake8>=6.1.0,<8.0.0 +mypy>=1.8.0,<2.0.0 +pylint>=3.0.0,<4.0.0 + +# Security +bandit>=1.7.0,<2.0.0 +safety>=3.0.0,<4.0.0 + +# Pre-commit hooks +pre-commit>=3.6.0,<4.0.0 + +# Type stubs +types-requests>=2.31.0,<3.0.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f9bdb1c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests>=2.31.0,<3.0.0 +python-dotenv>=1.0.0,<2.0.0 +rich>=13.7.0,<14.0.0 +click>=8.1.0,<9.0.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a4fbe0c --- /dev/null +++ b/setup.py @@ -0,0 +1,11 @@ +"""Setup script for backward compatibility. + +For modern installations, use pyproject.toml with pip. +This file is kept for backward compatibility with older tools. +""" + +from setuptools import setup + +# All configuration is in pyproject.toml +# This file exists only for backward compatibility +setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c104b65 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Weather CLI.""" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..bf1037f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,116 @@ +"""Tests for CLI module.""" + +import pytest +import responses +from weather_cli.cli import create_parser, main + + +class TestCLI: + """Test cases for CLI functionality.""" + + def test_create_parser(self): + """Test argument parser creation.""" + parser = create_parser() + assert parser.prog == "weather-cli" + + def test_parser_city_argument(self): + """Test parsing city argument.""" + parser = create_parser() + args = parser.parse_args(["London"]) + assert args.city == ["London"] + + def test_parser_multi_word_city(self): + """Test parsing multi-word city names.""" + parser = create_parser() + args = parser.parse_args(["New", "York"]) + assert args.city == ["New", "York"] + + def test_parser_units_argument(self): + """Test parsing units argument.""" + parser = create_parser() + args = parser.parse_args(["Tokyo", "--units", "imperial"]) + assert args.units == "imperial" + + def test_parser_default_units(self): + """Test default units.""" + parser = create_parser() + args = parser.parse_args(["Tokyo"]) + assert args.units == "metric" + + def test_main_no_api_key(self, monkeypatch, capsys): + """Test main function without API key.""" + monkeypatch.delenv("OPENWEATHER_API_KEY", raising=False) + exit_code = main(["London"]) + captured = capsys.readouterr() + + assert exit_code == 1 + assert "OPENWEATHER_API_KEY" in captured.err + + @responses.activate + def test_main_success(self, monkeypatch, capsys): + """Test successful execution of main.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_key") + + responses.add( + responses.GET, + "https://api.openweathermap.org/data/2.5/weather", + json={ + "name": "Berlin", + "sys": {"country": "DE"}, + "weather": [{"description": "rainy"}], + "main": {"temp": 15, "feels_like": 14, "humidity": 80, "pressure": 1008}, + "wind": {"speed": 4.5}, + "clouds": {"all": 90} + }, + status=200 + ) + + exit_code = main(["Berlin"]) + captured = capsys.readouterr() + + assert exit_code == 0 + assert "Berlin" in captured.out + assert "15°C" in captured.out + + @responses.activate + def test_main_city_not_found(self, monkeypatch, capsys): + """Test main function with invalid city.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_key") + + responses.add( + responses.GET, + "https://api.openweathermap.org/data/2.5/weather", + json={"message": "city not found"}, + status=404 + ) + + exit_code = main(["InvalidCity123"]) + captured = capsys.readouterr() + + assert exit_code == 1 + assert "Error" in captured.err + + @responses.activate + def test_main_with_units(self, monkeypatch, capsys): + """Test main function with custom units.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_key") + + responses.add( + responses.GET, + "https://api.openweathermap.org/data/2.5/weather", + json={ + "name": "Miami", + "sys": {"country": "US"}, + "weather": [{"description": "hot"}], + "main": {"temp": 85, "feels_like": 88, "humidity": 75, "pressure": 1012}, + "wind": {"speed": 10}, + "clouds": {"all": 30} + }, + status=200 + ) + + exit_code = main(["Miami", "--units", "imperial"]) + captured = capsys.readouterr() + + assert exit_code == 0 + assert "Miami" in captured.out diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..4ea5cdc --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,49 @@ +"""Tests for config module.""" + +import os +import pytest +from weather_cli.config import Config + + +class TestConfig: + """Test cases for Config class.""" + + def test_config_initialization(self, monkeypatch): + """Test that config initializes correctly.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_api_key") + config = Config() + assert config.api_key == "test_api_key" + assert config.base_url == "https://api.openweathermap.org/data/2.5/weather" + assert config.units == "metric" + + def test_config_custom_units(self, monkeypatch): + """Test that custom units can be set.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_api_key") + monkeypatch.setenv("WEATHER_UNITS", "imperial") + config = Config() + assert config.units == "imperial" + + def test_validate_with_api_key(self, monkeypatch): + """Test validation passes with API key.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_api_key") + config = Config() + assert config.validate() is True + + def test_validate_without_api_key(self, monkeypatch): + """Test validation fails without API key.""" + monkeypatch.delenv("OPENWEATHER_API_KEY", raising=False) + config = Config() + assert config.validate() is False + + def test_get_api_key_success(self, monkeypatch): + """Test getting API key when it exists.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_api_key") + config = Config() + assert config.get_api_key() == "test_api_key" + + def test_get_api_key_failure(self, monkeypatch): + """Test getting API key raises error when not set.""" + monkeypatch.delenv("OPENWEATHER_API_KEY", raising=False) + config = Config() + with pytest.raises(ValueError, match="API key not found"): + config.get_api_key() diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..fff6298 --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,151 @@ +"""Tests for weather module.""" + +import pytest +import responses +from requests.exceptions import RequestException + +from weather_cli.config import Config +from weather_cli.weather import WeatherClient, WeatherData + + +class TestWeatherData: + """Test cases for WeatherData class.""" + + def test_weather_data_initialization(self): + """Test WeatherData initialization with API response.""" + data = { + "name": "London", + "sys": {"country": "GB"}, + "weather": [{"description": "clear sky"}], + "main": { + "temp": 20.5, + "feels_like": 19.8, + "humidity": 65, + "pressure": 1015 + }, + "wind": {"speed": 3.5}, + "clouds": {"all": 10} + } + weather = WeatherData(data) + + assert weather.city == "London" + assert weather.country == "GB" + assert weather.description == "clear sky" + assert weather.temperature == 20.5 + assert weather.feels_like == 19.8 + assert weather.humidity == 65 + assert weather.pressure == 1015 + assert weather.wind_speed == 3.5 + assert weather.clouds == 10 + + def test_weather_data_string_representation(self): + """Test string representation of WeatherData.""" + data = { + "name": "Tokyo", + "sys": {"country": "JP"}, + "weather": [{"description": "cloudy"}], + "main": {"temp": 25, "feels_like": 24, "humidity": 70, "pressure": 1010}, + "wind": {"speed": 5}, + "clouds": {"all": 50} + } + weather = WeatherData(data) + result = str(weather) + + assert "Tokyo, JP" in result + assert "Cloudy" in result # Should be capitalized + assert "25°C" in result + assert "70%" in result + + +class TestWeatherClient: + """Test cases for WeatherClient class.""" + + def test_client_initialization(self, monkeypatch): + """Test WeatherClient initialization.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_key") + config = Config() + client = WeatherClient(config) + assert client.config == config + + @responses.activate + def test_get_weather_success(self, monkeypatch): + """Test successful weather fetch.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_key") + + # Mock API response + responses.add( + responses.GET, + "https://api.openweathermap.org/data/2.5/weather", + json={ + "name": "Paris", + "sys": {"country": "FR"}, + "weather": [{"description": "sunny"}], + "main": {"temp": 22, "feels_like": 21, "humidity": 60, "pressure": 1013}, + "wind": {"speed": 2.5}, + "clouds": {"all": 20} + }, + status=200 + ) + + config = Config() + client = WeatherClient(config) + weather = client.get_weather("Paris") + + assert weather is not None + assert weather.city == "Paris" + assert weather.country == "FR" + assert weather.temperature == 22 + + def test_get_weather_empty_city(self, monkeypatch): + """Test that empty city name raises ValueError.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_key") + config = Config() + client = WeatherClient(config) + + with pytest.raises(ValueError, match="City name cannot be empty"): + client.get_weather("") + + @responses.activate + def test_get_weather_city_not_found(self, monkeypatch): + """Test handling of 404 error.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_key") + + responses.add( + responses.GET, + "https://api.openweathermap.org/data/2.5/weather", + json={"message": "city not found"}, + status=404 + ) + + config = Config() + client = WeatherClient(config) + + with pytest.raises(ValueError, match="City .* not found"): + client.get_weather("InvalidCity123") + + @responses.activate + def test_get_weather_invalid_api_key(self, monkeypatch): + """Test handling of 401 error.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "invalid_key") + + responses.add( + responses.GET, + "https://api.openweathermap.org/data/2.5/weather", + json={"message": "Invalid API key"}, + status=401 + ) + + config = Config() + client = WeatherClient(config) + + with pytest.raises(ValueError, match="Invalid API key"): + client.get_weather("London") + + def test_context_manager(self, monkeypatch): + """Test that client works as context manager.""" + monkeypatch.setenv("OPENWEATHER_API_KEY", "test_key") + config = Config() + + with WeatherClient(config) as client: + assert client is not None + assert client.session is not None diff --git a/weather_cli/__init__.py b/weather_cli/__init__.py new file mode 100644 index 0000000..72158aa --- /dev/null +++ b/weather_cli/__init__.py @@ -0,0 +1,10 @@ +""" +Weather CLI - A simple command-line weather application. + +This package provides a command-line interface for fetching weather information +from OpenWeatherMap API. +""" + +__version__ = "1.0.0" +__author__ = "codeforgood-org" +__license__ = "MIT" diff --git a/weather_cli/__main__.py b/weather_cli/__main__.py new file mode 100644 index 0000000..ad9806c --- /dev/null +++ b/weather_cli/__main__.py @@ -0,0 +1,7 @@ +"""Main entry point for running weather_cli as a module.""" + +import sys +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/weather_cli/cli.py b/weather_cli/cli.py new file mode 100644 index 0000000..41552c6 --- /dev/null +++ b/weather_cli/cli.py @@ -0,0 +1,146 @@ +"""Command-line interface for Weather CLI.""" + +import argparse +import sys +from typing import Optional, List + +from . import __version__ +from .config import Config +from .weather import WeatherClient +from .formatter import WeatherFormatter + + +def create_parser() -> argparse.ArgumentParser: + """ + Create and configure the argument parser. + + Returns: + Configured ArgumentParser instance. + """ + parser = argparse.ArgumentParser( + prog="weather-cli", + description="Fetch current weather information for any city", + epilog="Example: weather-cli London" + ) + + parser.add_argument( + "city", + nargs="+", + help="City name (can include spaces, e.g., 'New York')" + ) + + parser.add_argument( + "-v", "--version", + action="version", + version=f"%(prog)s {__version__}" + ) + + parser.add_argument( + "--units", + choices=["metric", "imperial", "standard"], + default="metric", + help="Temperature units (default: metric/Celsius)" + ) + + parser.add_argument( + "--format", + choices=["rich", "plain", "json"], + default="rich", + help="Output format (default: rich/colored)" + ) + + parser.add_argument( + "--no-color", + action="store_true", + help="Disable colored output (same as --format plain)" + ) + + return parser + + +def main(argv: Optional[List[str]] = None) -> int: + """ + Main entry point for the CLI application. + + Args: + argv: Command-line arguments (defaults to sys.argv). + + Returns: + Exit code (0 for success, 1 for error). + """ + parser = create_parser() + args = parser.parse_args(argv) + + # Combine city name parts + city = " ".join(args.city) + + # Determine output format + output_format = "plain" if args.no_color else args.format + formatter = WeatherFormatter() + + # Initialize configuration + config = Config() + if args.units: + config.units = args.units + + # Validate configuration + if not config.validate(): + if output_format == "rich": + formatter.print_error("OPENWEATHER_API_KEY environment variable is not set.") + formatter.print_info("Please set your API key: export OPENWEATHER_API_KEY='your_api_key_here'") + formatter.print_info("Get your free API key at: https://openweathermap.org/api") + else: + print( + "Error: OPENWEATHER_API_KEY environment variable is not set.", + file=sys.stderr + ) + print( + "\nPlease set your API key:", + file=sys.stderr + ) + print( + " export OPENWEATHER_API_KEY='your_api_key_here'", + file=sys.stderr + ) + print( + "\nGet your free API key at: https://openweathermap.org/api", + file=sys.stderr + ) + return 1 + + # Fetch and display weather + try: + with WeatherClient(config) as client: + weather = client.get_weather(city) + if weather: + if output_format == "json": + print(formatter.format_json(weather)) + elif output_format == "plain": + print(formatter.format_plain(weather)) + else: # rich + formatter.format_rich(weather) + return 0 + else: + if output_format == "rich": + formatter.print_error("Failed to fetch weather data.") + else: + print("Failed to fetch weather data.", file=sys.stderr) + return 1 + + except ValueError as e: + if output_format == "rich": + formatter.print_error(str(e)) + else: + print(f"Error: {e}", file=sys.stderr) + return 1 + + except Exception as e: + if output_format == "rich": + formatter.print_error(f"Unexpected error: {e}") + else: + print(f"Unexpected error: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/weather_cli/config.py b/weather_cli/config.py new file mode 100644 index 0000000..eb62f13 --- /dev/null +++ b/weather_cli/config.py @@ -0,0 +1,46 @@ +"""Configuration management for Weather CLI.""" + +import os +from pathlib import Path +from typing import Optional + +from dotenv import load_dotenv + +# Load environment variables from .env file if it exists +load_dotenv() + + +class Config: + """Configuration class for managing API keys and settings.""" + + def __init__(self): + """Initialize configuration from environment variables.""" + self.api_key: Optional[str] = os.getenv("OPENWEATHER_API_KEY") + self.base_url: str = "https://api.openweathermap.org/data/2.5/weather" + self.units: str = os.getenv("WEATHER_UNITS", "metric") + + def validate(self) -> bool: + """ + Validate that required configuration is present. + + Returns: + bool: True if configuration is valid, False otherwise. + """ + return self.api_key is not None and self.api_key != "" + + def get_api_key(self) -> str: + """ + Get the API key. + + Returns: + str: The OpenWeatherMap API key. + + Raises: + ValueError: If API key is not configured. + """ + if not self.api_key: + raise ValueError( + "API key not found. Please set the OPENWEATHER_API_KEY " + "environment variable." + ) + return self.api_key diff --git a/weather_cli/formatter.py b/weather_cli/formatter.py new file mode 100644 index 0000000..a56c470 --- /dev/null +++ b/weather_cli/formatter.py @@ -0,0 +1,134 @@ +"""Output formatting utilities for Weather CLI.""" + +import json +from typing import Dict, Any + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich import box + +from .weather import WeatherData + + +console = Console() + + +class WeatherFormatter: + """Formatter for weather data output.""" + + @staticmethod + def format_json(weather: WeatherData) -> str: + """ + Format weather data as JSON. + + Args: + weather: WeatherData object to format. + + Returns: + JSON string representation of weather data. + """ + data = { + "location": { + "city": weather.city, + "country": weather.country + }, + "weather": { + "description": weather.description, + "temperature": { + "current": weather.temperature, + "feels_like": weather.feels_like + }, + "humidity": weather.humidity, + "pressure": weather.pressure, + "wind_speed": weather.wind_speed, + "cloudiness": weather.clouds + } + } + return json.dumps(data, indent=2) + + @staticmethod + def format_plain(weather: WeatherData) -> str: + """ + Format weather data as plain text. + + Args: + weather: WeatherData object to format. + + Returns: + Plain text representation of weather data. + """ + return str(weather) + + @staticmethod + def format_rich(weather: WeatherData) -> None: + """ + Format and display weather data with rich formatting. + + Args: + weather: WeatherData object to format. + """ + # Create location header + location = f"{weather.city}, {weather.country}" if weather.country else weather.city + + # Create weather table + table = Table(show_header=False, box=box.ROUNDED, padding=(0, 2)) + table.add_column("Property", style="cyan bold", no_wrap=True) + table.add_column("Value", style="white") + + # Determine color based on temperature + temp_color = "red" if weather.temperature > 30 else "yellow" if weather.temperature > 20 else "blue" + + # Add weather information + table.add_row("🌤️ Description", weather.description.capitalize()) + table.add_row( + "🌡️ Temperature", + f"[{temp_color}]{weather.temperature}°C[/{temp_color}] (feels like {weather.feels_like}°C)" + ) + table.add_row("💧 Humidity", f"{weather.humidity}%") + table.add_row("🔽 Pressure", f"{weather.pressure} hPa") + table.add_row("💨 Wind Speed", f"{weather.wind_speed} m/s") + + # Determine cloud emoji based on cloudiness + cloud_emoji = "☁️" if weather.clouds > 50 else "⛅" if weather.clouds > 20 else "☀️" + table.add_row(f"{cloud_emoji} Cloudiness", f"{weather.clouds}%") + + # Display in a panel + panel = Panel( + table, + title=f"[bold green]Weather in {location}[/bold green]", + border_style="green", + padding=(1, 2) + ) + + console.print(panel) + + @staticmethod + def print_error(message: str) -> None: + """ + Print an error message with formatting. + + Args: + message: Error message to display. + """ + console.print(f"[bold red]Error:[/bold red] {message}") + + @staticmethod + def print_info(message: str) -> None: + """ + Print an informational message with formatting. + + Args: + message: Info message to display. + """ + console.print(f"[blue]ℹ️ {message}[/blue]") + + @staticmethod + def print_success(message: str) -> None: + """ + Print a success message with formatting. + + Args: + message: Success message to display. + """ + console.print(f"[bold green]✓[/bold green] {message}") diff --git a/weather_cli/weather.py b/weather_cli/weather.py new file mode 100644 index 0000000..76ed6ff --- /dev/null +++ b/weather_cli/weather.py @@ -0,0 +1,128 @@ +"""Weather data fetching and processing.""" + +from typing import Dict, Any, Optional +import requests + +from .config import Config + + +class WeatherData: + """Class to represent weather data.""" + + def __init__(self, data: Dict[str, Any]): + """ + Initialize weather data from API response. + + Args: + data: JSON response from OpenWeatherMap API. + """ + self.city = data.get("name", "Unknown") + self.country = data.get("sys", {}).get("country", "") + self.description = data.get("weather", [{}])[0].get("description", "N/A") + self.temperature = data.get("main", {}).get("temp", 0) + self.feels_like = data.get("main", {}).get("feels_like", 0) + self.humidity = data.get("main", {}).get("humidity", 0) + self.pressure = data.get("main", {}).get("pressure", 0) + self.wind_speed = data.get("wind", {}).get("speed", 0) + self.clouds = data.get("clouds", {}).get("all", 0) + + def __str__(self) -> str: + """ + Format weather data as a string. + + Returns: + str: Formatted weather information. + """ + location = f"{self.city}, {self.country}" if self.country else self.city + return f""" +Weather in {location}: + Description: {self.description.capitalize()} + Temperature: {self.temperature}°C (feels like {self.feels_like}°C) + Humidity: {self.humidity}% + Pressure: {self.pressure} hPa + Wind Speed: {self.wind_speed} m/s + Cloudiness: {self.clouds}% +""" + + +class WeatherClient: + """Client for fetching weather data from OpenWeatherMap API.""" + + def __init__(self, config: Config): + """ + Initialize the weather client. + + Args: + config: Configuration object containing API key and settings. + """ + self.config = config + self.session = requests.Session() + + def get_weather(self, city: str) -> Optional[WeatherData]: + """ + Fetch weather data for a given city. + + Args: + city: Name of the city to get weather for. + + Returns: + WeatherData object if successful, None otherwise. + + Raises: + ValueError: If city name is empty. + requests.RequestException: If API request fails. + """ + if not city or not city.strip(): + raise ValueError("City name cannot be empty") + + params = { + "q": city.strip(), + "appid": self.config.get_api_key(), + "units": self.config.units + } + + try: + response = self.session.get( + self.config.base_url, + params=params, + timeout=10 + ) + response.raise_for_status() + return WeatherData(response.json()) + + except requests.exceptions.HTTPError as e: + if response.status_code == 404: + raise ValueError(f"City '{city}' not found") from e + elif response.status_code == 401: + raise ValueError("Invalid API key") from e + else: + raise requests.RequestException( + f"HTTP error occurred: {e}" + ) from e + + except requests.exceptions.ConnectionError as e: + raise requests.RequestException( + "Connection error. Please check your internet connection." + ) from e + + except requests.exceptions.Timeout as e: + raise requests.RequestException( + "Request timed out. Please try again." + ) from e + + except requests.exceptions.RequestException as e: + raise requests.RequestException( + f"An error occurred while fetching weather data: {e}" + ) from e + + def close(self): + """Close the HTTP session.""" + self.session.close() + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close()