From 002397026d4180105278ee7206879b7cc448ebfd Mon Sep 17 00:00:00 2001 From: Becky Sweger Date: Mon, 22 Jul 2024 09:39:12 -0400 Subject: [PATCH] Initial commit --- .github/workflows/pythonapp-workflow.yml | 25 +++ .gitignore | 171 ++++++++++++++++++ .pre-commit-config.yaml | 29 +++ .python-version | 1 + LICENSE | 21 +++ README.md | 150 +++++++++++++++ pyproject.toml | 55 ++++++ requirements/requirements-dev.txt | 65 +++++++ requirements/requirements.txt | 18 ++ src/reichlab_python_template/__init__.py | 1 + src/reichlab_python_template/app.py | 20 ++ src/reichlab_python_template/util/__init__.py | 0 src/reichlab_python_template/util/date.py | 15 ++ src/reichlab_python_template/util/logs.py | 41 +++++ tests/reichlab_python_template/test_app.py | 8 + .../unit/util/test_date.py | 10 + 16 files changed, 630 insertions(+) create mode 100644 .github/workflows/pythonapp-workflow.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements/requirements-dev.txt create mode 100644 requirements/requirements.txt create mode 100644 src/reichlab_python_template/__init__.py create mode 100644 src/reichlab_python_template/app.py create mode 100644 src/reichlab_python_template/util/__init__.py create mode 100644 src/reichlab_python_template/util/date.py create mode 100644 src/reichlab_python_template/util/logs.py create mode 100644 tests/reichlab_python_template/test_app.py create mode 100644 tests/reichlab_python_template/unit/util/test_date.py diff --git a/.github/workflows/pythonapp-workflow.yml b/.github/workflows/pythonapp-workflow.yml new file mode 100644 index 0000000..15ac0f1 --- /dev/null +++ b/.github/workflows/pythonapp-workflow.yml @@ -0,0 +1,25 @@ +name: run-code-checks +on: [push] +jobs: + run-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + cache: 'pip' + - name: install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements/requirements-dev.txt -e . + - name: lint + run: | + ruff check . + - name: type check + run: | + mypy . + - name: run tests + run: | + pytest + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79fb643 --- /dev/null +++ b/.gitignore @@ -0,0 +1,171 @@ +**/.DS_Store + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml +.pdm-python +.pdm-build/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site +# ruff +**/.ruff_cache + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# IDE settings +.idea/ +.vscode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fcdaa28 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.5.3 + hooks: + # Run the linter. + - id: ruff + args: [ --fix ] + # Run the formatter. + - id: ruff-format +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: check-yaml + args: [--allow-multiple-documents] + - id: debug-statements + - id: detect-aws-credentials + args: [--allow-missing-credentials] + - id: detect-private-key +- repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v1.10.1' # Use the sha / tag you want to point at + hooks: + - id: mypy + additional_dependencies: [types-all] +- repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..fdcfcfd --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0174e19 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 The Reich Lab at UMass-Amherst + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e39af5 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# reichlab-python-template + +[REPLACE WITH A DESCRIPTION OF YOUR PROJECT] + +A Python template for Reich Lab projects. + +This repo contains a Python package with minimal functionality. It serves as a starting point for new projects (it can be selected as the template when creating a new repo in the Reich Lab org). + +There as some opinionated choices here (explained below) which people should override as needed. The main goal is to have a consistent starting point to get up and running with a new Python code base. + +## Getting started + +[REMOVE THIS SECTION AFTER FOLLOWING THE INSTRUCTIONS BELOW] + +If you're using this repo as a template for a new project, make the following changes: + +1. Replace all instances of `reichlab-python-template` with the name of your repo/project. + +2. Replace all instances of `reichlab_python_template` with the name of your module (remember that Python module names cannot contain hyphens). + +3. Update [`pyproject.toml`](pyproject.toml). This file is required and will describe several aspects of your project. `pyproject.toml` replaces `setup.py` and is described in detail on [Python's packaging website](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/). + +4. Follow the _Setup for local development_ instructions below to ensure that everything works as expected. + + +## Installing and running the package (no development) + +To install this package via pip: + +```bash +pip install git+[GITHUB LINK TO YOUR REPO] +``` + +To run it: +```bash +reichlab_python_template +``` + +## Setup for local development + +The steps below are for setting up a local development environment. This process entails more than just installing the package, +because we need to ensure that all developers have a consistent, reproducible environment. + +### Assumptions + +Developers will be using a Python virtual environment that: + +- is based on the Python version specified in [.python-version](.python-version). +- contains the dependency versions specified in the "lockfile" (in this case [requirements/requirements-dev.txt](requirements/requirements-dev.txt)). +- contains the package installed in ["editable" mode](https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#working-in-development-mode). + +### Setup steps + +1. Clone this repository + +2. Change to the repo's root directory: + + ```bash + cd reichlab-python-template + ``` + +3. Make sure the correct version of Python is currently active, and create a Python virtual environment: + + ```bash + python -m venv .venv + ``` + +4. Activate the virtual environment: + + ```bash + # MacOs/Linux + source .venv/bin/activate + + # Windows + .venv\Scripts\activate + ``` + +5. Install the package dependencies and install the package in editable mode: + + ```bash + python -m pip install -r requirements/requirements-dev.txt && python -m pip install -e . + ``` + +6. **Optional:** if you use [`pre-commit`](https://pre-commit.com/) in your workflow to automate code formatting and other tasks, [install it](https://pre-commit.com/#install). Otherwise, delete [`.pre-commit-config.yaml`](.pre-commit-config.yaml). + +7. Run the test suite to confirm that everything is working: + + ```bash + python -m pytest + ``` + +## Development workflow + +Because the package is installed in "editable" mode, you can run the code as though it were a normal Python package, while also +being able to make changes and see them immediately. + +### Updating dependencies + +Prerequisites: +- [`uv`](https://github.com/astral-sh/uv?tab=readme-ov-file#getting-started) + +**Note:** using [`pipx`](https://pipx.pypa.io/stable/) (instead of pip) to install `uv` is a handy way to ensure that uv is available for all of the Python environments on your machine. + +The "lockfile" for this project is simply an annotated requirements.txt that is generated by [uv](https://github.com/astral-sh/uv) (uv is a replacement for [pip-compile](https://pypi.org/project/pip-tools/), which +could also be used). There's also a requirements-dev.txt file that contains dependencies needed for development (_e.g._, pytest). + +While it's possible to use `pip freeze` to generate a detailed lockfile without a third-party tool like `uv`, the output of `pip freeze` doesn't distinguish between direct and indirect dependencies. This distinction probably doesn't matter for a small project, but on a large project, understanding the dependency graph is critical for resolving conflicts. + +Additionally, `uv` (and `pip-compile`) are able to use the list of high-level dependencies in `pyproject.toml` to generate a detailed requirements.txt file, which is a good workflow for keeping everything in sync. + +To add a dependency to the project: + +1. Add the dependency to the `[dependencies]` section of `pyproject.toml` (or to the `dev` section of `[project.optional-dependencies]`, if it's a development dependency). Don't pin a specific version, since that will make it harder for people to install the package. + +2. Generate updated requirements files: + + ```bash + uv pip compile pyproject.toml -o requirements/requirements.txt && uv pip compile pyproject.toml --extra dev -o requirements/requirements-dev.txt + ``` + +3. Update project dependencies: + + **Note:** This package was originally developed on MacOS. If you have trouble installing the dependencies. `uv pip sync` has a [`--python-platform` flag](https://github.com/astral-sh/uv?tab=readme-ov-file#multi-platform-resolution) that can be used to specify the platform. + + ```bash + # note: requirements-dev.txt contains the base requirements AND the dev requirements + # + # using pip + python -m pip install -r requirements/requirements-dev.txt + # + # alternately, you can use uv to install the dependencies: it is faster and has a + # a handy sync option that will cleanup unused dependencies + uv pip sync requirements/requirements-dev.txt + +## Opinionated notes on Python tooling + +[REMOVE THIS SECTION] + +The Python ecosystem is overwhelming! Current opinionated preferences, subject to change: + +- To install and manage various versions of Python: [pyenv](https://github.com/pyenv/pyenv) + a local .python-version file +- To install Python packages that are available from anywhere on the machine, regardless of which Python environment is activated: [pipx](https://pipx.pypa.io/stable/) +- To create and manage Python virtual environments: [venv](https://docs.python.org/3/library/venv.html). + - I like having the environment packages right there in the project directory + - Everything single third-party tool for managing virtual environments (_e.g._, poetry, PDM, pipenv) does _too much_ and gets in my way +- To generate requirements files from `pyproject.toml`: ['uv'](https://github.com/astral-sh/uv?tab=readme-ov-file#getting-started). I don't usually recommend things this new, but it's orders of magnitude faster than `pip-compile`. +- To install dependencies: uv again (again, mostly due to speed; good old pip is another fine option) +- Logging: [structlog](https://www.structlog.org/en/stable/). I recently stopped fighting Python's built-in logging module and haven't looked back. +- Linting and formatting: [ruff](https://github.com/astral-sh/ruff) because it does both and is fast. +- Pre-commit hooks: [pre-commit](https://pre-commit.com/). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..12329e7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[project] +name = "reichlab-python-template" +description = "Python template for Reich Lab projects" +license = {text = "MIT License"} +readme = "README.md" +requires-python = '>=3.10' +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", +] +dynamic = ["version"] + +dependencies = [ + "structlog", + "rich", + "toml" +] + +[project.optional-dependencies] +dev = [ + "coverage", + "freezegun", + "mypy", + "pre-commit", + "pytest", + "ruff", + "types-toml", +] + +[project.entry-points."console_scripts"] +reichlab_python_template = "reichlab_python_template.app:main" + +[build-system] +# Minimum requirements for the build system to execute. +requires = ["setuptools", "wheel"] + +[tools.setuptools] +packages = ["reichlab_python_template"] + +[tool.reichlab_python_template] +# to write json-formatted logs to disk, uncomment the following line specify the file location +# log_file = "/path/to/log/files/rechlab_python_template.log" + +[tool.ruff] +line-length = 120 +lint.extend-select = ["I", "Q"] + +[tool.ruff.lint.flake8-quotes] +inline-quotes = "double" + +[tool.ruff.format] +quote-style = "double" + +[tool.setuptools.dynamic] +version = {attr = "reichlab_python_template.__version__"} diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt new file mode 100644 index 0000000..1bcea02 --- /dev/null +++ b/requirements/requirements-dev.txt @@ -0,0 +1,65 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --extra=dev --output-file=requirements/requirements-dev.txt +# +cfgv==3.4.0 + # via pre-commit +coverage==7.5.1 + # via python-app (pyproject.toml) +distlib==0.3.8 + # via virtualenv +filelock==3.14.0 + # via virtualenv +freezegun==1.5.0 + # via python-app (pyproject.toml) +identify==2.5.36 + # via pre-commit +iniconfig==2.0.0 + # via pytest +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +mypy==1.10.0 + # via python-app (pyproject.toml) +mypy-extensions==1.0.0 + # via mypy +nodeenv==1.8.0 + # via pre-commit +packaging==24.0 + # via pytest +platformdirs==4.2.1 + # via virtualenv +pluggy==1.5.0 + # via pytest +pre-commit==3.7.0 + # via python-app (pyproject.toml) +pygments==2.18.0 + # via rich +pytest==8.2.0 + # via python-app (pyproject.toml) +python-dateutil==2.9.0.post0 + # via freezegun +pyyaml==6.0.1 + # via pre-commit +rich==13.7.1 + # via python-app (pyproject.toml) +ruff==0.4.3 + # via python-app (pyproject.toml) +six==1.16.0 + # via python-dateutil +structlog==24.1.0 + # via python-app (pyproject.toml) +toml==0.10.2 + # via python-app (pyproject.toml) +types-toml==0.10.8.20240310 + # via python-app (pyproject.toml) +typing-extensions==4.11.0 + # via mypy +virtualenv==20.26.1 + # via pre-commit + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/requirements.txt b/requirements/requirements.txt new file mode 100644 index 0000000..f535bab --- /dev/null +++ b/requirements/requirements.txt @@ -0,0 +1,18 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --output-file=requirements/requirements.txt +# +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +pygments==2.18.0 + # via rich +rich==13.7.1 + # via python-app (pyproject.toml) +structlog==24.1.0 + # via python-app (pyproject.toml) +toml==0.10.2 + # via python-app (pyproject.toml) diff --git a/src/reichlab_python_template/__init__.py b/src/reichlab_python_template/__init__.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/src/reichlab_python_template/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/src/reichlab_python_template/app.py b/src/reichlab_python_template/app.py new file mode 100644 index 0000000..e7505d6 --- /dev/null +++ b/src/reichlab_python_template/app.py @@ -0,0 +1,20 @@ +import structlog + +from reichlab_python_template.util.date import get_current_date +from reichlab_python_template.util.logs import setup_logging + +setup_logging() +logger = structlog.get_logger() + + +def main(): + """Application entry point.""" + + today = get_current_date() + logger.info("retrieved the date", date=today) + + return f"Hello, today is {today}!" + + +if __name__ == "__main__": + main() diff --git a/src/reichlab_python_template/util/__init__.py b/src/reichlab_python_template/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/reichlab_python_template/util/date.py b/src/reichlab_python_template/util/date.py new file mode 100644 index 0000000..dacc3ff --- /dev/null +++ b/src/reichlab_python_template/util/date.py @@ -0,0 +1,15 @@ +import datetime + +import structlog + +logger = structlog.get_logger() + + +def get_current_date() -> str: + """Return current date in human-readable format.""" + + logger.info("getting the current date") + current_date = datetime.datetime.now() + formatted_date = current_date.strftime("%B %d, %Y") + + return formatted_date diff --git a/src/reichlab_python_template/util/logs.py b/src/reichlab_python_template/util/logs.py new file mode 100644 index 0000000..f7d4a37 --- /dev/null +++ b/src/reichlab_python_template/util/logs.py @@ -0,0 +1,41 @@ +import sys + +import structlog + +import reichlab_python_template + + +def add_custom_info(logger, method_name, event_dict): + event_dict["version"] = reichlab_python_template.__version__ + return event_dict + + +def setup_logging(): + shared_processors = [ + add_custom_info, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.add_log_level, + structlog.processors.CallsiteParameterAdder( + [ + structlog.processors.CallsiteParameter.FILENAME, + structlog.processors.CallsiteParameter.FUNC_NAME, + ] + ), + ] + + if sys.stderr.isatty(): + # If we're in a terminal, pretty print the logs. + processors = shared_processors + [ + structlog.dev.ConsoleRenderer(), + ] + else: + # Otherwise, output logs in JSON format + processors = shared_processors + [ + structlog.processors.dict_tracebacks, + structlog.processors.JSONRenderer(), + ] + + structlog.configure( + processors=processors, + cache_logger_on_first_use=True, + ) diff --git a/tests/reichlab_python_template/test_app.py b/tests/reichlab_python_template/test_app.py new file mode 100644 index 0000000..31181c2 --- /dev/null +++ b/tests/reichlab_python_template/test_app.py @@ -0,0 +1,8 @@ +from freezegun import freeze_time +from reichlab_python_template.app import main + + +@freeze_time("2019-07-13") +def test_main_date(): + output = main() + assert "July 13, 2019" in output diff --git a/tests/reichlab_python_template/unit/util/test_date.py b/tests/reichlab_python_template/unit/util/test_date.py new file mode 100644 index 0000000..f098854 --- /dev/null +++ b/tests/reichlab_python_template/unit/util/test_date.py @@ -0,0 +1,10 @@ +"""Unit tests for the date module.""" + +from freezegun import freeze_time +from reichlab_python_template.util.date import get_current_date + + +@freeze_time("2024-01-02") +def test_current_date(): + cd = get_current_date() + assert cd == "January 02, 2024"