diff --git a/.cookiecutter.json b/.cookiecutter.json new file mode 100644 index 0000000..b3ffec7 --- /dev/null +++ b/.cookiecutter.json @@ -0,0 +1,20 @@ +{ + "_checkout": "2024.05.3", + "_output_dir": "/home/watervize/omenapps_packages", + "_repo_dir": "/home/watervize/.cookiecutters/cookiecutter-django-package", + "_template": "gh:OmenApps/cookiecutter-django-package", + "author": "Jack Linke", + "copyright_year": "2024", + "development_status": "Development Status :: 1 - Planning", + "docker_compose_python_version": "3.12", + "email": "jacklinke@gmail.com", + "github_owner": "OmenApps", + "github_user": "jacklinke", + "license": "MIT", + "package_description": "Weather from the Open Weather Map APIs", + "package_name": "django_owm", + "project_name": "django-owm", + "use_playwright": "n", + "use_postgres": "n", + "version": "2024.10.1" +} diff --git a/.darglint b/.darglint new file mode 100644 index 0000000..72ccc6c --- /dev/null +++ b/.darglint @@ -0,0 +1,2 @@ +[darglint] +strictness = long diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a8faee7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,toml}] +indent_style = space +indent_size = 4 + +[*.yml,yaml,json] +indent_style = space +indent_size = 2 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..433b81c --- /dev/null +++ b/.flake8 @@ -0,0 +1,10 @@ +[flake8] +select = B,B9,C,D,DAR,E,F,N,RST,W +ignore = E203,E501,RST201,RST203,RST301,W503 +max-line-length = 120 +max-complexity = 10 +docstring-convention = google +rst-roles = class,const,func,meth,mod,ref +rst-directives = deprecated +count = true +exclude = .git,.nox,__pycache__,dist,migrations diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..be60933 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: Bug report +about: Create a report to help us improve the package +title: "" +labels: "" +assignees: "" +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Browser (please complete the following information):** + +- Type [e.g. chrome, safari, etc] +- Version [e.g. 22] + +**Database (please complete the following information):** + +- Device: [e.g. iPhone6] +- Database Version [e.g. 22] +- Running local, remote (e.g. DBaaS), or locally with provided docker-compose +- OS running on: [e.g. Ubuntu 20.04] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..2bc5d5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "" +labels: "" +assignees: "" +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..803fa9b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + - package-ecosystem: pip + directory: "/.github/workflows" + schedule: + interval: weekly + - package-ecosystem: pip + directory: "/docs" + schedule: + interval: weekly + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + versioning-strategy: lockfile-only + allow: + - dependency-type: "all" diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..f7f83aa --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,66 @@ +--- +# Labels names are important as they are used by Release Drafter to decide +# regarding where to record them in changelog or if to skip them. +# +# The repository labels will be automatically configured using this file and +# the GitHub Action https://github.com/marketplace/actions/github-labeler. +- name: breaking + description: Breaking Changes + color: bfd4f2 +- name: bug + description: Something isn't working + color: d73a4a +- name: build + description: Build System and Dependencies + color: bfdadc +- name: ci + description: Continuous Integration + color: 4a97d6 +- name: dependencies + description: Pull requests that update a dependency file + color: 0366d6 +- name: documentation + description: Improvements or additions to documentation + color: 0075ca +- name: duplicate + description: This issue or pull request already exists + color: cfd3d7 +- name: enhancement + description: New feature or request + color: a2eeef +- name: github_actions + description: Pull requests that update Github_actions code + color: "000000" +- name: good first issue + description: Good for newcomers + color: 7057ff +- name: help wanted + description: Extra attention is needed + color: 008672 +- name: invalid + description: This doesn't seem right + color: e4e669 +- name: performance + description: Performance + color: "016175" +- name: python + description: Pull requests that update Python code + color: 2b67c6 +- name: question + description: Further information is requested + color: d876e3 +- name: refactoring + description: Refactoring + color: ef67c4 +- name: removal + description: Removals and Deprecations + color: 9ae7ea +- name: style + description: Style + color: c120e5 +- name: testing + description: Testing + color: b1fc6f +- name: wontfix + description: This will not be worked on + color: ffffff diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..7a04410 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,29 @@ +categories: + - title: ":boom: Breaking Changes" + label: "breaking" + - title: ":rocket: Features" + label: "enhancement" + - title: ":fire: Removals and Deprecations" + label: "removal" + - title: ":beetle: Fixes" + label: "bug" + - title: ":racehorse: Performance" + label: "performance" + - title: ":rotating_light: Testing" + label: "testing" + - title: ":construction_worker: Continuous Integration" + label: "ci" + - title: ":books: Documentation" + label: "documentation" + - title: ":hammer: Refactoring" + label: "refactoring" + - title: ":lipstick: Style" + label: "style" + - title: ":package: Dependencies" + labels: + - "dependencies" + - "build" +template: | + ## Changes + + $CHANGES diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt new file mode 100644 index 0000000..eab0084 --- /dev/null +++ b/.github/workflows/constraints.txt @@ -0,0 +1,9 @@ +pip==24.0 +pipx==1.5.0 +nox==2024.4.15 +nox-poetry==1.0.3 +poetry==1.8.3 +poetry-plugin-export==1.8.0 +pytest==8.2.1 +pytest-cov==5.0.0 +pytest-django==4.8.0 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..173926f --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,19 @@ +name: Labeler + +on: + push: + branches: + - main + - master + +jobs: + labeler: + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@v4 + + - name: Run Labeler + uses: crazy-max/ghaction-github-labeler@v5.0.0 + with: + skip-delete: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5a5d29a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: Release + +on: + push: + branches: + - main + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Python + uses: actions/setup-python@v5.1.0 + with: + python-version: "3.12" + + - name: Upgrade pip + run: | + pip install --constraint=.github/workflows/constraints.txt pip + pip --version + + - name: Install Poetry + run: | + pip install --constraint=.github/workflows/constraints.txt poetry + poetry --version + + - name: Check if there is a parent commit + id: check-parent-commit + run: | + echo "::set-output name=sha::$(git rev-parse --verify --quiet HEAD^)" + + - name: Detect and tag new version + id: check-version + if: steps.check-parent-commit.outputs.sha + uses: salsify/action-detect-and-tag-new-version@v2.0.3 + with: + version-command: | + bash -o pipefail -c "poetry version | awk '{ print \$2 }'" + + - name: Bump version for developmental release + if: "! steps.check-version.outputs.tag" + run: | + poetry version patch && + version=$(poetry version | awk '{ print $2 }') && + poetry version $version.dev.$(date +%s) + + - name: Build package + run: | + poetry build --ansi + + - name: Publish package on PyPI + if: steps.check-version.outputs.tag + uses: pypa/gh-action-pypi-publish@v1.8.14 + with: + user: __token__ + password: ${{ secrets.PYPI_TOKEN }} + + - name: Publish package on TestPyPI + if: "! steps.check-version.outputs.tag" + uses: pypa/gh-action-pypi-publish@v1.8.14 + with: + user: __token__ + password: ${{ secrets.TEST_PYPI_TOKEN }} + repository-url: https://test.pypi.org/legacy/ + + - name: Publish the release notes + uses: release-drafter/release-drafter@v6.0.0 + with: + publish: ${{ steps.check-version.outputs.tag != '' }} + tag: ${{ steps.check-version.outputs.tag }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c8685f0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,50 @@ +name: Tests +on: [push, pull_request] + +jobs: + tests: + env: + FORCE_COLOR: "1" + PRE_COMMIT_COLOR: "always" + + strategy: + fail-fast: false + + name: "Integration testing" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: "Set up environment" + run: | + docker compose build --build-arg MULTIPLE_PYTHON=True + docker compose up -d + + - name: "Wait for docker to finish building" + run: sleep 10 + + - name: "Run tests" + run: docker compose exec django_test nox -r --force-color + + - name: Upload documentation + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs/_build + + - name: Combine coverage data and display human readable report + run: | + docker compose exec django_test nox --session "coverage(django='5.0')" + + - name: Create coverage report and copy to artifacts + run: | + docker compose exec django_test nox --session "coverage(django='5.0')" -- xml + docker compose exec django_test cat coverage.xml > coverage.xml + + - name: Upload coverage report + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: "Shut down environment" + run: docker compose down diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ee82c2e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,73 @@ +repos: + - repo: local + hooks: + - id: bandit + name: bandit + entry: bandit + language: system + types: [python] + require_serial: true + args: ["-c", "bandit.yml"] + - id: black + name: black + entry: black + language: system + types: [python] + require_serial: true + - id: check-added-large-files + name: Check for added large files + entry: check-added-large-files + language: system + - id: check-toml + name: Check Toml + entry: check-toml + language: system + types: [toml] + - id: check-yaml + name: Check Yaml + entry: check-yaml + language: system + types: [yaml] + - id: darglint + name: darglint + entry: darglint + language: system + types: [python] + stages: [manual] + - id: end-of-file-fixer + name: Fix End of Files + entry: end-of-file-fixer + language: system + types: [text] + stages: [commit, push, manual] + - id: flake8 + name: flake8 + entry: flake8 + language: system + types: [python] + require_serial: true + args: [--darglint-ignore-regex, .*] + - id: isort + name: isort + entry: isort + require_serial: true + language: system + types_or: [cython, pyi, python] + args: ["--filter-files"] + - id: pyupgrade + name: pyupgrade + description: Automatically upgrade syntax for newer versions. + entry: pyupgrade + language: system + types: [python] + args: [--py39-plus] + - id: trailing-whitespace + name: Trim Trailing Whitespace + entry: trailing-whitespace-fixer + language: system + types: [text] + stages: [commit, push, manual] + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.6.0 + hooks: + - id: prettier diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5aa3461 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Ignore: +.nox diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..19710fa --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,15 @@ +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-24.04 + tools: + python: "3.12" + +sphinx: + configuration: docs/conf.py +formats: all +python: + install: + - requirements: docs/requirements.txt + - path: . diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0284140 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# 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/). + +## [Unreleased] + +## [2024.10.1] + +Initial release! + +### Added + +- TBD + +[unreleased]: https://github.com/jacklinke/django_owm/compare/HEAD...HEAD +[2024.10.1]: https://github.com/jacklinke/django_owm/releases/tag/2024.10.1 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2582fe9 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# 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, caste, color, 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 at +[jacklinke@gmail.com](mailto:jacklinke@gmail.com). +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.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla coc]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][faq]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[mozilla coc]: https://github.com/mozilla/diversity +[faq]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3c792ee --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contributor Guide + +Thank you for your interest in improving this project. +This project is open-source under the [MIT license] and +welcomes contributions in the form of bug reports, feature requests, and pull requests. + +Here is a list of important resources for contributors: + +- [Source Code] +- [Documentation] +- [Issue Tracker] +- [Code of Conduct] + +[mit license]: https://opensource.org/licenses/MIT +[source code]: https://github.com/jacklinke/django-owm +[documentation]: https://django-owm.readthedocs.io/ +[issue tracker]: https://github.com/jacklinke/django-owm/issues + +## How to report a bug + +Report bugs on the [Issue Tracker]. + +When filing an issue, make sure to answer these questions: + +- Which operating system and Python version are you using? +- Which version of this project are you using? +- What did you do? +- What did you expect to see? +- What did you see instead? + +The best way to get your bug fixed is to provide a test case, +and/or steps to reproduce the issue. + +## How to request a feature + +Request features on the [Issue Tracker]. + +## How to set up your development environment + +You need Python 3.9+ and the following tools: + +- [Poetry] +- [Nox] +- [nox-poetry] + +Install the package with development requirements: + +```console +$ poetry install +``` + +You can now run an interactive Python session, +or the command-line interface: + +```console +$ poetry run python +$ poetry run django-owm +``` + +[poetry]: https://python-poetry.org/ +[nox]: https://nox.thea.codes/ +[nox-poetry]: https://nox-poetry.readthedocs.io/ + +## How to test the project + +Run the full test suite: + +```console +$ nox +``` + +List the available Nox sessions: + +```console +$ nox --list-sessions +``` + +You can also run a specific Nox session. +For example, invoke the unit test suite like this: + +```console +$ nox --session=tests +``` + +Unit tests are located in the _tests_ directory, +and are written using the [pytest] testing framework. + +[pytest]: https://pytest.readthedocs.io/ + +## How to submit changes + +Open a [pull request] to submit changes to this project. + +Your pull request needs to meet the following guidelines for acceptance: + +- The Nox test suite must pass without errors and warnings. +- Include unit tests. This project maintains 100% code coverage. +- If your changes add functionality, update the documentation accordingly. + +Feel free to submit early, though—we can always iterate on this. + +To run linting and code formatting checks before committing your change, you can install pre-commit as a Git hook by running the following command: + +```console +$ nox --session=pre-commit -- install +``` + +It is recommended to open an issue before starting work on anything. +This will allow a chance to talk it over with the owners and validate your approach. + +[pull request]: https://github.com/jacklinke/django-owm/pulls + + + +[code of conduct]: CODE_OF_CONDUCT.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f74f89 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# django-owm + +[![PyPI](https://img.shields.io/pypi/v/ kiecutter.project_name }}.svg)][pypi status] +[![Status](https://img.shields.io/pypi/status/django-owm.svg)][pypi status] +[![Python Version](https://img.shields.io/pypi/pyversions/django-owm)][pypi status] +[![License](https://img.shields.io/pypi/l/django-owm)][license] + +[![Read the documentation at https://django-owm.readthedocs.io/](https://img.shields.io/readthedocs/django-owm/latest.svg?label=Read%20the%20Docs)][read the docs] +[![Tests](https://github.com/OmenApps/django-owm/actions/workflows/tests.yml/badge.svg)][tests] +[![Codecov](https://codecov.io/gh/OmenApps/django-owm/branch/main/graph/badge.svg)][codecov] + +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit] +[![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black] + +[pypi status]: https://pypi.org/project/django-owm/ +[read the docs]: https://django-owm.readthedocs.io/ +[tests]: https://github.com/jacklinke/django-owm/actions?workflow=Tests +[codecov]: https://app.codecov.io/gh/jacklinke/django-owm +[pre-commit]: https://github.com/pre-commit/pre-commit +[black]: https://github.com/psf/black + +## Features + +- TODO + +## Requirements + +- TODO + +## Installation + +You can install _django-owm_ via [pip] from [PyPI]: + +```console +$ pip install django_owm +``` + +## Usage + +Please see the [Command-line Reference] for details. + +## Contributing + +Contributions are very welcome. +To learn more, see the [Contributor Guide]. + +## License + +Distributed under the terms of the [MIT license][license], +_django-owm_ is free and open source software. + +## Issues + +If you encounter any problems, +please [file an issue] along with a detailed description. + +## Credits + +This project was generated from [@OmenApps]'s [Cookiecutter Django Package] template. + +[@omenapps]: https://github.com/OmenApps +[pypi]: https://pypi.org/ +[cookiecutter django package]: https://github.com/OmenApps/cookiecutter-django-package +[file an issue]: https://github.com/jacklinke/django-owm/issues +[pip]: https://pip.pypa.io/ + + + +[license]: https://github.com/jacklinke/django-owm/blob/main/LICENSE +[contributor guide]: https://github.com/jacklinke/django-owm/blob/main/CONTRIBUTING.md +[command-line reference]: https://django-owm.readthedocs.io/en/latest/usage.html diff --git a/bandit.yml b/bandit.yml new file mode 100644 index 0000000..57caded --- /dev/null +++ b/bandit.yml @@ -0,0 +1,3 @@ +assert_used: + skips: [".nox", "tests", "*/test_*.py"] +exclude_dirs: [".nox", "tests", "*/test_*.py"] diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..9ac2650 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,9 @@ +comment: false +coverage: + status: + project: + default: + target: "100" + patch: + default: + target: "100" diff --git a/compose/django/.django b/compose/django/.django new file mode 100644 index 0000000..bcde257 --- /dev/null +++ b/compose/django/.django @@ -0,0 +1,4 @@ +# General +# ------------------------------------------------------------------------------ +USE_DOCKER=yes +IPYTHONDIR=/app/.ipython diff --git a/compose/django/Dockerfile b/compose/django/Dockerfile new file mode 100644 index 0000000..b173bf6 --- /dev/null +++ b/compose/django/Dockerfile @@ -0,0 +1,106 @@ +FROM ubuntu:24.04 + +ARG BUILD_ENVIRONMENT=local +ARG APP_HOME=/app +ARG DEBIAN_FRONTEND=noninteractive +ARG MULTIPLE_PYTHON # Set to True if you want to use multiple Python versions + +ARG PYTHON_3_9=3.9.19 +ARG PYTHON_3_10=3.10.14 +ARG PYTHON_3_11=3.11.9 +ARG PYTHON_3_12=3.12.3 + +ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE 1 +ENV COMPOSE_DOCKER_CLI_BUILD 1 +ENV DOCKER_BUILDKIT 1 +ENV BUILD_ENV ${BUILD_ENVIRONMENT} + +WORKDIR ${APP_HOME} + +# Install apt packages +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt update \ + && apt-get --no-install-recommends install -y \ + # Some basic tools and libraries + bash curl wget git make \ + build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \ + libsqlite3-dev llvm libncurses5-dev libncursesw5-dev xz-utils tk-dev \ + libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev + +# Install pyenv +RUN git clone https://github.com/pyenv/pyenv.git .pyenv +ENV PYENV_ROOT ${APP_HOME}/.pyenv +ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH + +# Install Python version(s) +RUN if [ "$MULTIPLE_PYTHON" = "True" ] ; then \ + pyenv install ${PYTHON_3_12}; \ + pyenv install ${PYTHON_3_11}; \ + pyenv install ${PYTHON_3_10}; \ + pyenv install ${PYTHON_3_9}; \ + else \ + pyenv install 3.12; \ + fi + +# Initialize pyenv +RUN eval "$(pyenv init -)" + +# Add deadsnakes PPA +RUN apt-get install -y software-properties-common +RUN add-apt-repository 'ppa:deadsnakes/ppa' +RUN apt-get update + +# Make Python version(s) accessible in the project and install Python venv +RUN if [ "$MULTIPLE_PYTHON" = "True" ] ; then \ + apt-get install -y python3.12-venv python3.9-venv python3.10-venv python3.11-venv; \ + pyenv local ${PYTHON_3_12} ${PYTHON_3_9} ${PYTHON_3_10} ${PYTHON_3_11}; \ + else \ + apt-get install -y python3.12-venv; \ + pyenv local 3.12; \ + fi + +# Ensure pip is installed +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get --no-install-recommends install -y \ + python3-pip + +# Install Poetry +RUN pip install poetry + +# Copy poetry files +COPY poetry.lock pyproject.toml ${APP_HOME} + +# Install dependencies: +RUN poetry config virtualenvs.create false \ + && poetry install --with dev --no-interaction --no-ansi --no-root + +# Copy remaining project files +COPY noxfile.py manage.py ${APP_HOME} +COPY .darglint .editorconfig .flake8 .gitignore .pre-commit-config.yaml .prettierignore .readthedocs.yml bandit.yml ${APP_HOME} +COPY CHANGELOG.md CODE_OF_CONDUCT.md CONTRIBUTING.md LICENSE README.md ${APP_HOME} +COPY ./docs/ ${APP_HOME}/docs +COPY ./example_project/ ${APP_HOME}/example_project/ +COPY ./tests/ ${APP_HOME}/tests/ +COPY ./src/ ${APP_HOME}/ +COPY ./src/ ${APP_HOME}/src/ + +# Rehash pyenv shims +RUN pyenv rehash + +# Project initialization: +COPY ./compose/django/entrypoint /entrypoint +RUN sed -i 's/\r$//g' /entrypoint +RUN chmod +x /entrypoint + +COPY ./compose/django/start /start +RUN sed -i 's/\r$//g' /start +RUN chmod +x /start + +# Initialize git and add . +RUN git init +RUN git add . + +ENTRYPOINT ["/entrypoint"] diff --git a/compose/django/entrypoint b/compose/django/entrypoint new file mode 100644 index 0000000..7718f91 --- /dev/null +++ b/compose/django/entrypoint @@ -0,0 +1,7 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + +exec "$@" diff --git a/compose/django/start b/compose/django/start new file mode 100644 index 0000000..a6f8eb3 --- /dev/null +++ b/compose/django/start @@ -0,0 +1,8 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + +python manage.py migrate --noinput --skip-checks +python manage.py runserver 0.0.0.0:8111 --skip-checks diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2a77c77 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + django_test: + build: + context: . + dockerfile: ./compose/django/Dockerfile + image: django_owm_django + container_name: django_test + + env_file: + - ./compose/django/.django + ports: + - "8111:8111" + command: /start + diff --git a/docs/codeofconduct.md b/docs/codeofconduct.md new file mode 100644 index 0000000..58fd373 --- /dev/null +++ b/docs/codeofconduct.md @@ -0,0 +1,3 @@ +```{include} ../CODE_OF_CONDUCT.md + +``` diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..77fdb53 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,13 @@ +"""Sphinx configuration.""" + +project = "django-owm" +author = "Jack Linke" +copyright = "2024, Jack Linke" +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx_click", + "myst_parser", +] +autodoc_typehints = "description" +html_theme = "furo" diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..b941964 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,7 @@ +```{include} ../CONTRIBUTING.md +--- +end-before: +--- +``` + +[code of conduct]: codeofconduct diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d405b8a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,24 @@ +```{include} ../README.md +--- +end-before: +--- +``` + +[license]: license +[contributor guide]: contributing +[command-line reference]: usage + +```{toctree} +--- +hidden: +maxdepth: 1 +--- + +usage +terminology +reference +contributing +Code of Conduct +License +Changelog +``` diff --git a/docs/license.md b/docs/license.md new file mode 100644 index 0000000..218790f --- /dev/null +++ b/docs/license.md @@ -0,0 +1,7 @@ +# License + +```{literalinclude} ../LICENSE +--- +language: none +--- +``` diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..900bc50 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,8 @@ +# Reference + +## django_owm + +```{eval-rst} +.. automodule:: django_owm + :members: +``` diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..fc882d6 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +furo==2024.5.6 +sphinx==7.3.7 +sphinx-click==6.0.0 +myst-parser==3.0.1 diff --git a/docs/terminology.md b/docs/terminology.md new file mode 100644 index 0000000..8e5a62c --- /dev/null +++ b/docs/terminology.md @@ -0,0 +1,9 @@ +# Terminology and Definitions + +## Topic 1 + +Content + +## Topic 2 + +Content diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..f5b797e --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,43 @@ +# Usage + +## admin.py + +```{eval-rst} +.. automodule:: django_owm.admin + :members: +``` + +## apps.py + +```{eval-rst} +.. automodule:: django_owm.apps + :members: +``` + +## forms.py + +```{eval-rst} +.. automodule:: django_owm.forms + :members: +``` + +## models.py + +```{eval-rst} +.. automodule:: django_owm.models + :members: +``` + +## views.py + +```{eval-rst} +.. automodule:: django_owm.views + :members: +``` + +## urls.py + +```{eval-rst} +.. automodule:: django_owm.urls + :members: +``` diff --git a/example_project/__init__.py b/example_project/__init__.py new file mode 100644 index 0000000..ac292c4 --- /dev/null +++ b/example_project/__init__.py @@ -0,0 +1 @@ +"""Initialize core module.""" diff --git a/example_project/asgi.py b/example_project/asgi.py new file mode 100644 index 0000000..434d520 --- /dev/null +++ b/example_project/asgi.py @@ -0,0 +1,16 @@ +"""ASGI config for core project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") + +application = get_asgi_application() diff --git a/example_project/example/__init__.py b/example_project/example/__init__.py new file mode 100644 index 0000000..c40f955 --- /dev/null +++ b/example_project/example/__init__.py @@ -0,0 +1 @@ +"""Initialize example package.""" diff --git a/example_project/example/apps.py b/example_project/example/apps.py new file mode 100644 index 0000000..2b227f1 --- /dev/null +++ b/example_project/example/apps.py @@ -0,0 +1,10 @@ +"""Example app config.""" + +from django.apps import AppConfig + + +class ExampleConfig(AppConfig): + """Example app config.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "example_project.example" diff --git a/example_project/example/migrations/__init__.py b/example_project/example/migrations/__init__.py new file mode 100644 index 0000000..77bebb0 --- /dev/null +++ b/example_project/example/migrations/__init__.py @@ -0,0 +1 @@ +"""Initialize migrations.""" diff --git a/example_project/example/models.py b/example_project/example/models.py new file mode 100644 index 0000000..4a4a137 --- /dev/null +++ b/example_project/example/models.py @@ -0,0 +1 @@ +"""Models for the example app.""" diff --git a/example_project/example/tests.py b/example_project/example/tests.py new file mode 100644 index 0000000..a3f862e --- /dev/null +++ b/example_project/example/tests.py @@ -0,0 +1 @@ +"""Tests for django_owm example application.""" diff --git a/example_project/settings.py b/example_project/settings.py new file mode 100644 index 0000000..adbdb9a --- /dev/null +++ b/example_project/settings.py @@ -0,0 +1,121 @@ +"""Django settings for core project. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" + +from pathlib import Path + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. + +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! + +SECRET_KEY = "django-insecure-r88%=*g)x(&-&67duelz$=8mat90+aq^wo+6niu!rd2v4(#f#t" # nosec + +# SECURITY WARNING: don't run with debug turned on in production! + +DEBUG = True + +ALLOWED_HOSTS = ["*"] # nosec + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "example_project.example", + "django_owm", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "example_project.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "example_project.wsgi.application" +ASGI_APPLICATION = "example_project.asgi.application" + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/example_project/urls.py b/example_project/urls.py new file mode 100644 index 0000000..a1004dd --- /dev/null +++ b/example_project/urls.py @@ -0,0 +1,25 @@ +"""URL configuration for core project.""" + +from django.contrib import admin +from django.urls import path + + +""" +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: + Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') + Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') + Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/example_project/wsgi.py b/example_project/wsgi.py new file mode 100644 index 0000000..624ee32 --- /dev/null +++ b/example_project/wsgi.py @@ -0,0 +1,16 @@ +"""WSGI config for core project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..0a41077 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..3f893df --- /dev/null +++ b/noxfile.py @@ -0,0 +1,239 @@ +"""Nox sessions.""" +import os +import shlex +import shutil +import sys +from pathlib import Path +from textwrap import dedent + +import nox + + +try: + from nox_poetry import Session + from nox_poetry import session +except ImportError: + message = f"""\ + Nox failed to import the 'nox-poetry' package. + + Please install it using the following command: + + {sys.executable} -m pip install nox-poetry""" + raise SystemExit(dedent(message)) from None + + +# DJANGO_STABLE_VERSION should be set to the latest Django LTS version + +DJANGO_STABLE_VERSION = "5.0" +DJANGO_VERSIONS = [ + "4.2", + "5.0", +] + +# PYTHON_STABLE_VERSION should be set to the latest stable Python version + +PYTHON_STABLE_VERSION = "3.12" +PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12"] + + +package = "django_owm" + +nox.needs_version = ">= 2024.4.15" +nox.options.sessions = ( + "pre-commit", + "safety", + "tests", + "xdoctest", + "docs-build", +) +nox.options.default_venv_backend = "venv" + + +def activate_virtualenv_in_precommit_hooks(session: Session) -> None: + """Activate virtualenv in hooks installed by pre-commit. + + This function patches git hooks installed by pre-commit to activate the + session's virtual environment. This allows pre-commit to locate hooks in + that environment when invoked from git. + + Args: + session: The Session object. + """ + assert session.bin is not None # nosec + + # Only patch hooks containing a reference to this session's bindir. Support + # quoting rules for Python and bash, but strip the outermost quotes so we + # can detect paths within the bindir, like /python. + bindirs = [ + bindir[1:-1] if bindir[0] in "'\"" else bindir for bindir in (repr(session.bin), shlex.quote(session.bin)) + ] + + virtualenv = session.env.get("VIRTUAL_ENV") + if virtualenv is None: + return + + headers = { + # pre-commit < 2.16.0 + "python": f"""\ + import os + os.environ["VIRTUAL_ENV"] = {virtualenv!r} + os.environ["PATH"] = os.pathsep.join(( + {session.bin!r}, + os.environ.get("PATH", ""), + )) + """, + # pre-commit >= 2.16.0 + "bash": f"""\ + VIRTUAL_ENV={shlex.quote(virtualenv)} + PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" + """, + # pre-commit >= 2.17.0 on Windows forces sh shebang + "/bin/sh": f"""\ + VIRTUAL_ENV={shlex.quote(virtualenv)} + PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" + """, + } + + hookdir = Path(".git") / "hooks" + if not hookdir.is_dir(): + return + + for hook in hookdir.iterdir(): + if hook.name.endswith(".sample") or not hook.is_file(): + continue + + if not hook.read_bytes().startswith(b"#!"): + continue + + text = hook.read_text() + + if not any(Path("A") == Path("a") and bindir.lower() in text.lower() or bindir in text for bindir in bindirs): + continue + + lines = text.splitlines() + + for executable, header in headers.items(): + if executable in lines[0].lower(): + lines.insert(1, dedent(header)) + hook.write_text("\n".join(lines)) + break + + +@session(name="pre-commit", python=PYTHON_STABLE_VERSION) +@nox.parametrize("django", DJANGO_STABLE_VERSION) +def precommit(session: Session, django: str) -> None: + """Lint using pre-commit.""" + args = session.posargs or [ + "run", + "--all-files", + "--hook-stage=manual", + "--show-diff-on-failure", + ] + session.install( + "bandit", + "black", + "darglint", + "flake8", + "flake8-bugbear", + "flake8-docstrings", + "flake8-rst-docstrings", + "isort", + "pep8-naming", + "pre-commit", + "pre-commit-hooks", + "pyupgrade", + ) + session.run("pre-commit", *args) + if args and args[0] == "install": + activate_virtualenv_in_precommit_hooks(session) + + +@session(python=PYTHON_STABLE_VERSION) +@nox.parametrize("django", DJANGO_STABLE_VERSION) +def safety(session: Session, django: str) -> None: + """Scan dependencies for insecure packages.""" + requirements = session.poetry.export_requirements() + session.install("safety") + session.run("safety", "check", "--full-report", f"--file={requirements}") + + +@session(python=PYTHON_VERSIONS) +@nox.parametrize("django", DJANGO_VERSIONS) +def tests(session: Session, django: str) -> None: + """Run the test suite.""" + session.install(".") + session.install( + "coverage[toml]", + "pytest", + "pytest-django", + "pygments", + ) + try: + + session.run("coverage", "run", "-m", "pytest", *session.posargs) + finally: + if session.interactive: + session.notify("coverage", posargs=[]) + + +@session(python=PYTHON_STABLE_VERSION) +@nox.parametrize("django", DJANGO_STABLE_VERSION) +def coverage(session: Session, django: str) -> None: + """Produce the coverage report.""" + args = session.posargs or ["report"] + + session.install("coverage[toml]") + + if not session.posargs and any(Path().glob(".coverage.*")): + session.run("coverage", "combine") + + session.run("coverage", *args) + + +@session(python=PYTHON_STABLE_VERSION) +@nox.parametrize("django", DJANGO_STABLE_VERSION) +def xdoctest(session: Session, django: str) -> None: + """Run examples with xdoctest.""" + if session.posargs: + args = [package, *session.posargs] + else: + args = [f"--modname={package}", "--command=all"] + if "FORCE_COLOR" in os.environ: + args.append("--colored=1") + + session.install(".") + session.install("xdoctest[colors]") + session.run("python", "-m", "xdoctest", *args) + + +@session(name="docs-build", python=PYTHON_STABLE_VERSION) +@nox.parametrize("django", DJANGO_STABLE_VERSION) +def docs_build(session: Session, django: str) -> None: + """Build the documentation.""" + args = session.posargs or ["docs", "docs/_build"] + if not session.posargs and "FORCE_COLOR" in os.environ: + args.insert(0, "--color") + + session.install(".") + session.install("sphinx", "sphinx-click", "furo", "myst-parser") + + build_dir = Path("docs", "_build") + if build_dir.exists(): + shutil.rmtree(build_dir) + + session.run("sphinx-build", *args) + + +@session(python=PYTHON_STABLE_VERSION) +@nox.parametrize("django", DJANGO_STABLE_VERSION) +def docs(session: Session, django: str) -> None: + """Build and serve the documentation with live reloading on file changes.""" + args = session.posargs or ["--open-browser", "docs", "docs/_build"] + session.install(".") + session.install("sphinx", "sphinx-autobuild", "sphinx-click", "furo", "myst-parser") + + build_dir = Path("docs", "_build") + if build_dir.exists(): + shutil.rmtree(build_dir) + + session.run("sphinx-autobuild", *args) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..52a645a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,95 @@ +[build-system] +requires = ["uv>=0.3.0"] +build-backend = "uv" + +[project] +name = "django-owm" +version = "2024.10.1" +description = "Weather from the Open Weather Map APIs" +authors = [{ name = "Jack Linke", email = "" }] +license = { text = "MIT" } +readme = "README.md" +requires-python = ">=3.9,<4.0" +classifiers = [ + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Development Status :: 1 - Planning", +] +dependencies = ["django>=4.2", "click>=8.1.7"] + +[project.urls] +Changelog = "https://github.com/jacklinke/django-owm/releases" +Homepage = "https://github.com/jacklinke/django-owm" +Repository = "https://github.com/jacklinke/django-owm" +Documentation = "https://django-owm.readthedocs.io" +Issues = "https://github.com/jacklinke/django-owm/issues" + +[project.optional-dependencies] +dev = [ + "Pygments>=2.18.0", + "bandit>=1.7.8", + "black>=24.4.2", + "coverage[toml]>=7.5.1", + "darglint>=1.8.1", + "flake8>=7.0.0", + "flake8-bugbear>=24.4.26", + "flake8-docstrings>=1.7.0", + "flake8-rst-docstrings>=0.3.0", + "furo>=2024.5.6", + "isort>=5.13.2", + "nox>=2024.4.15", + "nox-poetry>=1.0.3", + "poetry-plugin-export>=1.8.0", + "pep8-naming>=0.14.1", + "pre-commit>=3.7.1", + "pre-commit-hooks>=4.6.0", + "pytest>=8.2.1", + "pytest-cov>=5.0.0", + "pytest-django>=4.8.0", + "pyupgrade>=3.15.2", + "safety>=3.2.0", + "sphinx>=7.3.7", + "sphinx-autobuild>=2024.4.16", + "sphinx-click>=6.0.0", + "xdoctest[colors]>=1.1.3", + "myst-parser>=3.0.1", +] + +[tool.uv] +package-dir = "src" + +[tool.black] +line-length = 120 +target-version = ["py39", "py310", "py311", "py312"] +force-exclude = ''' +( + .nox +) +''' + +[tool.coverage.paths] +source = ["src", "*/site-packages"] +tests = ["tests", "*/tests"] + +[tool.coverage.run] +branch = true +source = ["src", "tests"] + +[tool.coverage.report] +show_missing = true +fail_under = 50 +omit = [".nox/*", "tests/*", "**/migrations/*", "**/__init__.py"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "tests.settings" +python_files = ["*test_*.py", "*_test.py", "tests/*.py"] +log_cli = true +log_cli_level = "INFO" + +[tool.isort] +profile = "black" +force_single_line = true +lines_after_imports = 2 +extend_skip = [".nox"] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..60dde1b --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Initialize module.""" diff --git a/src/django_owm/__init__.py b/src/django_owm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/django_owm/admin.py b/src/django_owm/admin.py new file mode 100644 index 0000000..5a5696b --- /dev/null +++ b/src/django_owm/admin.py @@ -0,0 +1,43 @@ +"""Admin for the django_owm app.""" + +from django.contrib import admin +from .app_settings import OWM_MODEL_MAPPINGS, OWM_USE_BUILTIN_ADMIN,get_model_from_string +from django.apps import apps + + +if OWM_USE_BUILTIN_ADMIN: + WeatherLocationModel = get_model_from_string(OWM_MODEL_MAPPINGS.get("WeatherLocation")) + WeatherDataModel = get_model_from_string(OWM_MODEL_MAPPINGS.get("WeatherData")) + CurrentWeatherModel = get_model_from_string(OWM_MODEL_MAPPINGS.get("CurrentWeather")) + WeatherErrorLogModel = get_model_from_string(OWM_MODEL_MAPPINGS.get("WeatherErrorLog")) + APICallLogModel = get_model_from_string(OWM_MODEL_MAPPINGS.get("APICallLog")) + + if WeatherLocationModel: + + @admin.register(WeatherLocationModel) + class WeatherLocationAdmin(admin.ModelAdmin): + list_display = ("name", "latitude", "longitude", "timezone") + + if WeatherDataModel: + + @admin.register(WeatherDataModel) + class WeatherDataAdmin(admin.ModelAdmin): + list_display = ("location", "timestamp", "temp", "feels_like", "pressure", "humidity") + + if CurrentWeatherModel: + + @admin.register(CurrentWeatherModel) + class CurrentWeatherAdmin(admin.ModelAdmin): + list_display = ("location", "timestamp", "temp", "feels_like", "pressure", "humidity") + + if WeatherErrorLogModel: + + @admin.register(WeatherErrorLogModel) + class WeatherErrorLogAdmin(admin.ModelAdmin): + list_display = ("timestamp", "location", "api_name", "error_message") + + if APICallLogModel: + + @admin.register(APICallLogModel) + class APICallLogAdmin(admin.ModelAdmin): + list_display = ("timestamp", "api_name") diff --git a/src/django_owm/app_settings.py b/src/django_owm/app_settings.py new file mode 100644 index 0000000..2ecdc40 --- /dev/null +++ b/src/django_owm/app_settings.py @@ -0,0 +1,47 @@ +"""App settings for the django_owm app.""" +from django.conf import settings +from django.db import models +from django.apps import apps + + +DJANGO_OWM = getattr(settings, 'DJANGO_OWM', {}) + +# Example: +# DJANGO_OWM = { +# 'OWM_API_KEY': '', # Developer should provide their API key in settings.py +# 'OWM_API_RATE_LIMITS': { +# 'one_call': { +# 'calls_per_minute': 60, +# 'calls_per_month': 1000000, +# }, +# # Future APIs can be added here +# }, +# 'OWM_MODEL_MAPPINGS': { +# # Map abstract model names to concrete model paths +# # 'WeatherLocation': 'myapp.models.MyWeatherLocation', +# # 'CurrentWeather': 'myapp.models.MyCurrentWeather', +# # 'WeatherCondition': 'myapp.models.MyWeatherCondition', +# # etc. +# }, +# 'OWM_BASE_MODEL': models.Model, # Base model for OWM models +# 'OWM_USE_BUILTIN_ADMIN': True, # Use built-in admin for OWM models +# } + +class Model(models.Model): + """Simply provides a base model with a Meta class.""" + class Meta: + abstract = True + + objects = models.Manager() + + +OWM_API_KEY = DJANGO_OWM.get('OWM_API_KEY', '') +OWM_API_RATE_LIMITS = DJANGO_OWM.get('OWM_API_RATE_LIMITS', {"one_call": {"calls_per_minute": 60, "calls_per_month": 1000000}}) +OWM_MODEL_MAPPINGS = DJANGO_OWM.get('OWM_MODEL_MAPPINGS', {}) +OWM_BASE_MODEL = DJANGO_OWM.get('OWM_BASE_MODEL', Model) +OWM_USE_BUILTIN_ADMIN = DJANGO_OWM.get('OWM_USE_BUILTIN_ADMIN', True) + +def get_model_from_string(model_string): + """Get a model class from a string like 'app_label.model_name'.""" + app_label, model_name = model_string.split('.') + return apps.get_model(app_label=app_label, model_name=model_name) diff --git a/src/django_owm/apps.py b/src/django_owm/apps.py new file mode 100644 index 0000000..7fafc4f --- /dev/null +++ b/src/django_owm/apps.py @@ -0,0 +1,10 @@ +"""App configuration.""" + +from django.apps import AppConfig + + +class DjangoOwmConfig(AppConfig): + """App configuration for django-owm.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "django_owm" diff --git a/src/django_owm/forms.py b/src/django_owm/forms.py new file mode 100644 index 0000000..35ca509 --- /dev/null +++ b/src/django_owm/forms.py @@ -0,0 +1 @@ +"""Forms for django_owm.""" diff --git a/src/django_owm/migrations/__init__.py b/src/django_owm/migrations/__init__.py new file mode 100644 index 0000000..77bebb0 --- /dev/null +++ b/src/django_owm/migrations/__init__.py @@ -0,0 +1 @@ +"""Initialize migrations.""" diff --git a/src/django_owm/models.py b/src/django_owm/models.py new file mode 100644 index 0000000..c465f50 --- /dev/null +++ b/src/django_owm/models.py @@ -0,0 +1,273 @@ +"""Models for OpenWeatherMap API data storage in django_owm.""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from .app_settings import OWM_BASE_MODEL + +class WeatherLocation(OWM_BASE_MODEL): + """Abstract model for storing weather location data.""" + name = models.CharField( + _("Location Name"), + max_length=255, + blank=True, + null=True + ) + latitude = models.DecimalField( + _("Latitude"), + max_digits=5, + decimal_places=2, + ) + longitude = models.DecimalField( + _("Longitude"), + max_digits=5, + decimal_places=2, + ) + timezone = models.CharField( + _("Timezone"), + max_length=255, + blank=True, + null=True, + ) + + class Meta(OWM_BASE_MODEL.Meta): + abstract = True + +class WeatherData(OWM_BASE_MODEL): + """Abstract model for storing weather data.""" + location = models.ForeignKey('WeatherLocation', on_delete=models.CASCADE, related_name='weather_data', help_text=_("Location for this weather data")) + timestamp = models.DateTimeField( + _("Timestamp"), + help_text=_("Unix timestamp converted to DateTime"), + ) + sunrise = models.DateTimeField(blank=True, null=True) + sunset = models.DateTimeField(blank=True, null=True) + temp = models.DecimalField( + _("Temperature"), + max_digits=5, + decimal_places=2, + blank=True, + null=True, + ) + feels_like = models.DecimalField( + _("Feels Like Temperature"), + max_digits=5, + decimal_places=2, + blank=True, + null=True, + ) + pressure = models.IntegerField(blank=True, null=True) + humidity = models.IntegerField(blank=True, null=True) + dew_point = models.DecimalField( + _("Dew Point"), + max_digits=5, + decimal_places=2, + blank=True, + null=True, + ) + uvi = models.DecimalField( + _("UV Index"), + max_digits=5, + decimal_places=2, + blank=True, + null=True, + ) + clouds = models.IntegerField(blank=True, null=True) + visibility = models.IntegerField(blank=True, null=True) + wind_speed = models.DecimalField( + _("Wind Speed"), + max_digits=5, + decimal_places=2, + blank=True, + null=True, + ) + wind_deg = models.IntegerField(blank=True, null=True) + wind_gust = models.DecimalField( + _("Wind Gust"), + max_digits=5, + decimal_places=2, + blank=True, + null=True, + ) + + class Meta(OWM_BASE_MODEL.Meta): + abstract = True + +class WeatherCondition(OWM_BASE_MODEL): + """Abstract model for storing weather condition data.""" + weather_data = models.ForeignKey('WeatherData', related_name='weather_conditions', on_delete=models.CASCADE) + condition_id = models.IntegerField() + main = models.CharField(max_length=255) + description = models.CharField(max_length=255) + icon = models.CharField(max_length=10) + + class Meta(OWM_BASE_MODEL.Meta): + abstract = True + +class CurrentWeather(WeatherData): + """Abstract model for storing current weather data.""" + rain_1h = models.DecimalField( + _("Rain (1h)"), + max_digits=5, + decimal_places=2, + blank=True, + null=True, + ) + snow_1h = models.DecimalField( + _("Snow (1h)"), + max_digits=5, + decimal_places=2, + blank=True, + null=True, + ) + + class Meta: + abstract = True + +class MinutelyWeather(OWM_BASE_MODEL): + """Abstract model for storing minutely weather data.""" + location = models.ForeignKey('WeatherLocation', on_delete=models.CASCADE) + timestamp = models.DateTimeField( + _("Timestamp"), + help_text=_("Unix timestamp converted to DateTime"), + ) + precipitation = models.DecimalField( + _("Precipitation"), + max_digits=5, + decimal_places=2, + ) + + class Meta(OWM_BASE_MODEL.Meta): + abstract = True + +class HourlyWeather(WeatherData): + """Abstract model for storing hourly weather data.""" + pop = models.DecimalField( + _("Probability of Precipitation"), + max_digits=5, + decimal_places=2, + blank=True, null=True,) + rain_1h = models.DecimalField( + _("Rain (1h)"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + snow_1h = models.DecimalField( + _("Snow (1h)"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + + class Meta: + abstract = True + +class DailyWeather(WeatherData): + """Abstract model for storing daily weather data.""" + moonrise = models.DateTimeField(blank=True, null=True) + moonset = models.DateTimeField(blank=True, null=True) + moon_phase = models.DecimalField( + _("Moon Phase"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + summary = models.TextField(blank=True, null=True) + temp_min = models.DecimalField( + _("Temperature Min"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + temp_max = models.DecimalField( + _("Temperature Max"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + temp_morn = models.DecimalField( + _("Morning Temperature"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + temp_day = models.DecimalField( + _("Day Temperature"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + temp_eve = models.DecimalField( + _("Evening Temperature"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + temp_night = models.DecimalField( + _("Night Temperature"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + feels_like_morn = models.DecimalField( + _("Feels Like - Morning"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + feels_like_day = models.DecimalField( + _("Feels Like - Day"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + feels_like_eve = models.DecimalField( + _("Feels Like - Evening"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + feels_like_night = models.DecimalField( + _("Feels Like - Night"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + pop = models.DecimalField( + _("Probability of Precipitation"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + rain = models.DecimalField( + _("Rain"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + snow = models.DecimalField( + _("Snow"), + max_digits=5, + decimal_places=2, + blank=True, null=True) + + class Meta: + abstract = True + +class WeatherAlert(OWM_BASE_MODEL): + """Abstract model for storing weather alerts.""" + location = models.ForeignKey('WeatherLocation', on_delete=models.CASCADE) + sender_name = models.CharField(max_length=255) + event = models.CharField(max_length=255) + start = models.DateTimeField() + end = models.DateTimeField() + description = models.TextField() + tags = models.JSONField(blank=True, null=True) + + class Meta(OWM_BASE_MODEL.Meta): + abstract = True + +class WeatherErrorLog(OWM_BASE_MODEL): + """Abstract model for storing weather API error logs.""" + timestamp = models.DateTimeField(auto_now_add=True) + location = models.ForeignKey('WeatherLocation', on_delete=models.CASCADE) + api_name = models.CharField(max_length=255) + error_message = models.TextField() + response_data = models.TextField(blank=True, null=True) + + class Meta(OWM_BASE_MODEL.Meta): + abstract = True + +class APICallLog(OWM_BASE_MODEL): + """Abstract model for storing API call logs.""" + timestamp = models.DateTimeField(auto_now_add=True) + api_name = models.CharField(max_length=255) + + class Meta(OWM_BASE_MODEL.Meta): + abstract = True diff --git a/src/django_owm/tasks.py b/src/django_owm/tasks.py new file mode 100644 index 0000000..7f3c4c7 --- /dev/null +++ b/src/django_owm/tasks.py @@ -0,0 +1,133 @@ +"""Celery tasks for fetching weather data from OpenWeatherMap API for django_owm.""" + +from celery import shared_task +from django.utils import timezone +from datetime import timedelta +from .app_settings import OWM_MODEL_MAPPINGS, OWM_API_RATE_LIMITS, OWM_API_KEY,get_model_from_string +from django.apps import apps +import requests +import logging + +logger = logging.getLogger(__name__) + +def get_api_call_counts(api_name): + """Get the number of API calls made in the last minute and last month.""" + now = timezone.now() + one_minute_ago = now - timedelta(minutes=1) + one_month_ago = now - timedelta(days=30) + APICallLogModel = get_model_from_string(OWM_MODEL_MAPPINGS.get('APICallLog')) + if not APICallLogModel: + return 0, 0 + calls_last_minute = APICallLogModel.objects.filter(api_name=api_name, timestamp__gte=one_minute_ago).count() + calls_last_month = APICallLogModel.objects.filter(api_name=api_name, timestamp__gte=one_month_ago).count() + return calls_last_minute, calls_last_month + +@shared_task +def fetch_current_weather(): + """Fetch current weather data for all locations.""" + rate_limits = OWM_API_RATE_LIMITS.get('one_call', {}) + calls_per_minute = rate_limits.get('calls_per_minute', 60) + calls_per_month = rate_limits.get('calls_per_month', 1000000) + api_name = 'one_call' + + calls_last_minute, calls_last_month = get_api_call_counts(api_name) + if calls_last_minute >= calls_per_minute or calls_last_month >= calls_per_month: + logger.warning('API call limit exceeded. Skipping fetch_current_weather task.') + return + + WeatherLocationModel = get_model_from_string(OWM_MODEL_MAPPINGS.get('WeatherLocation')) + if not WeatherLocationModel: + logger.error('WeatherLocation model is not configured.') + return + + locations = WeatherLocationModel.objects.all() + for location in locations: + calls_last_minute, _ = get_api_call_counts(api_name) + if calls_last_minute >= calls_per_minute: + logger.warning('API call limit per minute exceeded. Stopping fetch_current_weather task.') + break + + # Make API call + lat = location.latitude + lon = location.longitude + api_key = OWM_API_KEY + if not api_key: + logger.error('OpenWeatherMap API key not set. Please set OWM_API_KEY in your settings.') + return + + url = f'https://api.openweathermap.org/data/3.0/onecall?lat={lat}&lon={lon}&exclude=minutely,hourly,daily,alerts&appid={api_key}' + + try: + response = requests.get(url) + if response.status_code == 200: + data = response.json() + # Save data to models + save_current_weather(location, data) + # Log API call + APICallLogModel = get_model_from_string(OWM_MODEL_MAPPINGS.get('APICallLog')) + if APICallLogModel: + APICallLogModel.objects.create(api_name=api_name) + else: + error_message = f'API call failed with status code {response.status_code}' + save_error_log(location, api_name, error_message, response.text) + logger.error(error_message) + except Exception as e: + error_message = str(e) + save_error_log(location, api_name, error_message) + logger.exception('Error fetching current weather') + +def save_current_weather(location, data): + """Save current weather data to the database.""" + model_mappings = OWM_MODEL_MAPPINGS + CurrentWeatherModel = get_model_from_string(model_mappings.get('CurrentWeather')) + WeatherConditionModel = get_model_from_string(model_mappings.get('WeatherCondition')) + + if not CurrentWeatherModel or not WeatherConditionModel: + logger.error('CurrentWeatherModel or WeatherConditionModel is not configured.') + return + + current_data = data.get('current', {}) + if not current_data: + return + + timestamp = timezone.datetime.fromtimestamp(current_data['dt'], tz=timezone.utc) + current_weather = CurrentWeatherModel.objects.create( + location=location, + timestamp=timestamp, + temp=current_data.get('temp'), + feels_like=current_data.get('feels_like'), + pressure=current_data.get('pressure'), + humidity=current_data.get('humidity'), + dew_point=current_data.get('dew_point'), + uvi=current_data.get('uvi'), + clouds=current_data.get('clouds'), + visibility=current_data.get('visibility'), + wind_speed=current_data.get('wind_speed'), + wind_deg=current_data.get('wind_deg'), + wind_gust=current_data.get('wind_gust'), + rain_1h=current_data.get('rain', {}).get('1h'), + snow_1h=current_data.get('snow', {}).get('1h'), + ) + # Save weather conditions + weather_conditions = current_data.get('weather', []) + for condition in weather_conditions: + WeatherConditionModel.objects.create( + weather_data=current_weather, + condition_id=condition.get('id'), + main=condition.get('main'), + description=condition.get('description'), + icon=condition.get('icon'), + ) + +def save_error_log(location, api_name, error_message, response_data=None): + """Save error log to the database.""" + WeatherErrorLogModel = get_model_from_string(OWM_MODEL_MAPPINGS.get('WeatherErrorLog')) + if not WeatherErrorLogModel: + logger.error('WeatherErrorLogModel is not configured.') + return + WeatherErrorLogModel.objects.create( + location=location, + api_name=api_name, + error_message=error_message, + response_data=response_data, + ) diff --git a/src/django_owm/templates/django_owm/weather_detail.html b/src/django_owm/templates/django_owm/weather_detail.html new file mode 100644 index 0000000..db5fc33 --- /dev/null +++ b/src/django_owm/templates/django_owm/weather_detail.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block content %} +
+

Weather for {{ location.name }}

+ {% if current_weather %} +
+
+
Current Weather
+

Temperature: {{ current_weather.temp }} K

+

Feels Like: {{ current_weather.feels_like }} K

+

Pressure: {{ current_weather.pressure }} hPa

+

Humidity: {{ current_weather.humidity }}%

+ +
+
+ {% else %} +

No current weather data available.

+ {% endif %} +
+{% endblock %} diff --git a/src/django_owm/tests.py b/src/django_owm/tests.py new file mode 100644 index 0000000..f629e0c --- /dev/null +++ b/src/django_owm/tests.py @@ -0,0 +1 @@ +"""Tests for django_owm.""" diff --git a/src/django_owm/urls.py b/src/django_owm/urls.py new file mode 100644 index 0000000..c4eb948 --- /dev/null +++ b/src/django_owm/urls.py @@ -0,0 +1,9 @@ +"""URLs for the django_owm app.""" +from django.urls import path +from . import views + +app_name = 'django_owm' + +urlpatterns = [ + path('weather//', views.weather_detail, name='weather_detail'), +] diff --git a/src/django_owm/views.py b/src/django_owm/views.py new file mode 100644 index 0000000..9cecf84 --- /dev/null +++ b/src/django_owm/views.py @@ -0,0 +1,19 @@ +"""Views for the django_owm app.""" +from django.shortcuts import render, get_object_or_404 +from .app_settings import OWM_MODEL_MAPPINGS +from django.apps import apps + +def weather_detail(request, location_id): + model_mappings = OWM_MODEL_MAPPINGS + WeatherLocationModel = apps.get_model(model_mappings.get('WeatherLocation')) + CurrentWeatherModel = apps.get_model(model_mappings.get('CurrentWeather')) + + location = get_object_or_404(WeatherLocationModel, pk=location_id) + current_weather = CurrentWeatherModel.objects.filter(location=location).order_by('-timestamp').first() + + context = { + 'location': location, + 'current_weather': current_weather, + } + + return render(request, 'django_owm/weather_detail.html', context) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f88947e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for the django_owm package.""" diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..f387734 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,30 @@ +"""Settings to be used for tests.""" + +from pathlib import Path + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-1uhn#!v$y#jmy)%mp03n%ao(c}7qa6h33Fw6n5qtk-@o_1^@z-" # nosec + +# Application definition + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +INSTALLED_APPS = [ + "src.django_owm.apps.DjangoOwmConfig", + "tests", +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + }, +} + +USE_TZ = True +DEBUG = True +ROOT_URLCONF = "src.django_owm.urls" diff --git a/tests/test_django_owm.py b/tests/test_django_owm.py new file mode 100644 index 0000000..6c44c2c --- /dev/null +++ b/tests/test_django_owm.py @@ -0,0 +1,27 @@ +"""Test cases for the django-owm package.""" + +import pytest +from click.testing import CliRunner +from django.apps import apps +from django.conf import settings + + +@pytest.fixture +def runner() -> CliRunner: + """Fixture for invoking command-line interfaces.""" + return CliRunner() + + +def test_succeeds(runner: CliRunner) -> None: + """It exits with a status code of zero.""" + assert 0 == 0 + + +def test_settings(runner: CliRunner) -> None: + """It exits with a status code of zero.""" + assert settings.USE_TZ is True + + +def test_apps(runner: CliRunner) -> None: + """It exits with a status code of zero.""" + assert "django_owm" in apps.get_app_config("django_owm").name