diff --git a/docs/tutorial/code_quality.md b/docs/tutorial/code_quality.md deleted file mode 100644 index bf6750d..0000000 --- a/docs/tutorial/code_quality.md +++ /dev/null @@ -1 +0,0 @@ -# Code Quality diff --git a/docs/tutorial/code_quality/code_quality.md b/docs/tutorial/code_quality/code_quality.md new file mode 100644 index 0000000..5b67f01 --- /dev/null +++ b/docs/tutorial/code_quality/code_quality.md @@ -0,0 +1,11 @@ +# Code quality + +intro + +???question "Why standardizing my code?" + Allows running tools + +- [pre-commit hooks](pre_commit_hooks.md) +- [formatter: black](formatter.md) +- [type_checking: mypy](type_checking.md) +- [linter: ruff](linter.md) diff --git a/docs/tutorial/code_quality/formatter.md b/docs/tutorial/code_quality/formatter.md new file mode 100644 index 0000000..1f9c5d4 --- /dev/null +++ b/docs/tutorial/code_quality/formatter.md @@ -0,0 +1,14 @@ +# Formatting code with black + +Why manually bother to format your code when you can automate it? + +```yaml title=".pre-commit-config.yaml" +repos: + ... + + - repo: https://github.com/psf/black + rev: 23.1.0 + hooks: + - id: black + +``` diff --git a/docs/tutorial/code_quality/linter.md b/docs/tutorial/code_quality/linter.md new file mode 100644 index 0000000..eec394c --- /dev/null +++ b/docs/tutorial/code_quality/linter.md @@ -0,0 +1,53 @@ +# Linting with ruff + +```yaml title=".pre-commit-config.yaml" +repos: + ... + + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.0.257 + hooks: + - id: ruff + args: [--fix] + +``` + +```toml title="pyproject.toml" +# https://github.com/charliermarsh/ruff +[tool.ruff] +line-length = 88 +target-version = "py38" +# https://beta.ruff.rs/docs/rules/ +extend-select = [ + "E", # style errors + "W", # style warnings + "F", # flakes + "D", # pydocstyle + "I", # isort + "U", # pyupgrade + # "S", # bandit + "C", # flake8-comprehensions + "B", # flake8-bugbear + "A001", # flake8-builtins + "RUF", # ruff-specific rules +] +# I do this to get numpy-style docstrings AND retain +# D417 (Missing argument descriptions in the docstring) +# otherwise, see: +# https://beta.ruff.rs/docs/faq/#does-ruff-support-numpy-or-google-style-docstrings +# https://github.com/charliermarsh/ruff/issues/2606 +extend-ignore = [ + "D100", # Missing docstring in public module + "D107", # Missing docstring in __init__ + "D203", # 1 blank line required before class docstring + "D212", # Multi-line docstring summary should start at the first line + "D213", # Multi-line docstring summary should start at the second line + "D401", # First line should be in imperative mood + "D413", # Missing blank line after last section + "D416", # Section name should end with a colon +] + +[tool.ruff.per-file-ignores] +"tests/*.py" = ["D", "S"] +"setup.py" = ["D"] +``` diff --git a/docs/tutorial/code_quality/pre_commit_hooks.md b/docs/tutorial/code_quality/pre_commit_hooks.md new file mode 100644 index 0000000..1645dc2 --- /dev/null +++ b/docs/tutorial/code_quality/pre_commit_hooks.md @@ -0,0 +1,18 @@ +# pre-commit + +Clean your room before going out! + +```text title="File Structure" +src/ +└── pydev_tutorial/ + ├── __init__.py +.pre-commit-config.yaml +``` + +```yaml title=".pre-commit-config.yaml" +repos: + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.12.1 + hooks: + - id: validate-pyproject +``` diff --git a/docs/tutorial/code_quality/type_checking.md b/docs/tutorial/code_quality/type_checking.md new file mode 100644 index 0000000..70d0ccb --- /dev/null +++ b/docs/tutorial/code_quality/type_checking.md @@ -0,0 +1,26 @@ +# Enforce types with mypy + +```yaml title=".pre-commit-config.yaml" +repos: + ... + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.1.1 + hooks: + - id: mypy + files: "^src/" + # # you have to add the things you want to type check against here + # additional_dependencies: + # - numpy +``` + +```toml title="pyproject.toml" +# https://mypy.readthedocs.io/en/stable/config_file.html +[tool.mypy] +files = "src/**/" +strict = true +disallow_any_generics = false +disallow_subclassing_any = false +show_error_codes = true +pretty = true +``` diff --git a/docs/tutorial/github/continuous_integration.md b/docs/tutorial/github/continuous_integration.md new file mode 100644 index 0000000..aee2ee1 --- /dev/null +++ b/docs/tutorial/github/continuous_integration.md @@ -0,0 +1,90 @@ +# Continuous integration with Actions + +```text title="File Structure" +.github/ +└── workflows/ + └── ci.yaml +src/ +tests/ +``` + +```yaml title="ci.yaml" +name: CI + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + workflow_dispatch: + schedule: + # run every week (for --pre release tests) + - cron: "0 0 * * 0" + +jobs: + check-manifest: + # check-manifest is a tool that checks that all files in version control are + # included in the sdist (unless explicitly excluded) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: pipx run check-manifest + + test: + name: ${{ matrix.platform }} (${{ matrix.python-version }}) + runs-on: ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + platform: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + with: + access_token: ${{ github.token }} + + - uses: actions/checkout@v3 + + - name: 🐍 Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache-dependency-path: "pyproject.toml" + cache: "pip" + + - name: Install pip + run: | + python -m pip install -U pip + + # if running a cron job, we add the --pre flag to + # test against pre-releases + - name: Install Dependencies + run: > + python -m pip install .[test] + ${{ github.event_name == 'schedule' && '--pre' || '' }} + + - name: 🧪 Run Tests + run: pytest --color=yes --cov --cov-report=xml --cov-report=term-missing + + # If something goes wrong with --pre tests, + # we can open an issue in the repo + - name: 📝 Report --pre Failures + if: failure() && github.event_name == 'schedule' + uses: JasonEtco/create-an-issue@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLATFORM: ${{ matrix.platform }} + PYTHON: ${{ matrix.python-version }} + RUN_ID: ${{ github.run_id }} + TITLE: "[test-bot] pip install --pre is failing" + with: + filename: .github/TEST_FAIL_TEMPLATE.md + update_existing: true + + - name: Coverage + uses: codecov/codecov-action@v3 +``` diff --git a/docs/tutorial/github/deployment.md b/docs/tutorial/github/deployment.md new file mode 100644 index 0000000..28fc81c --- /dev/null +++ b/docs/tutorial/github/deployment.md @@ -0,0 +1,55 @@ +# Deployment to PyPi + +## Secrets + +```yaml +name: CI + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + workflow_dispatch: + schedule: + # run every week (for --pre release tests) + - cron: "0 0 * * 0" + +jobs: + ... + + deploy: + name: Deploy + needs: test + if: > + success() + && startsWith(github.ref, 'refs/tags/') + && github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: 🐍 Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - name: 👷 Build + run: | + python -m pip install build + python -m build + + - name: 🚢 Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.TWINE_API_KEY }} + + - uses: softprops/action-gh-release@v1 + with: + generate_release_notes: true + +``` diff --git a/docs/tutorial/github.md b/docs/tutorial/github/github.md similarity index 100% rename from docs/tutorial/github.md rename to docs/tutorial/github/github.md diff --git a/docs/tutorial/github/github_apps.md b/docs/tutorial/github/github_apps.md new file mode 100644 index 0000000..3bba51a --- /dev/null +++ b/docs/tutorial/github/github_apps.md @@ -0,0 +1,5 @@ +# Using Github apps + +## Codecov + +## Pre-commit diff --git a/docs/tutorial/github/github_pages.md b/docs/tutorial/github/github_pages.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/tutorial/publishing.md b/docs/tutorial/github/publishing.md similarity index 100% rename from docs/tutorial/publishing.md rename to docs/tutorial/github/publishing.md diff --git a/docs/tutorial/pre_requisite/ide.md b/docs/tutorial/pre_requisite/ide.md new file mode 100644 index 0000000..1110174 --- /dev/null +++ b/docs/tutorial/pre_requisite/ide.md @@ -0,0 +1,13 @@ +# IDE: VSCode + +## Download and install VSCode + +## Basics + +## Debugging + +## Some settings + +### Starting from the command line + +### Setting a ruler diff --git a/docs/tutorial/pre_requisite/version_control.md b/docs/tutorial/pre_requisite/version_control.md new file mode 100644 index 0000000..9477274 --- /dev/null +++ b/docs/tutorial/pre_requisite/version_control.md @@ -0,0 +1,25 @@ +# Git + +Link to tutorial + +=== "macOS" + + 1. + +=== "Linux" + + 1. + +=== "Windows" + + 1. Git for Windows + +## Typical workflow + +```shell +git status +git checkout -b mybranch +git add +git commit -m "" +git push +``` diff --git a/docs/tutorial/pre_requisite/virtual_environment.md b/docs/tutorial/pre_requisite/virtual_environment.md new file mode 100644 index 0000000..ed32d57 --- /dev/null +++ b/docs/tutorial/pre_requisite/virtual_environment.md @@ -0,0 +1,5 @@ +# Virtual environments with Conda + +miniconda + +mamba? diff --git a/docs/tutorial/summary.md b/docs/tutorial/summary.md new file mode 100644 index 0000000..52a1319 --- /dev/null +++ b/docs/tutorial/summary.md @@ -0,0 +1,43 @@ + +# Summary + +Tooling, testing, packaging... These are words floating around but they sound +like work and we all have enough of that already, so why bother? In the end, you +just want code that works and that can be used by people! + +And that's exactly where tooling comes in. In this tutorial, we are going to +walk you through a set of tools. Each section is straightforward and shouldn't +take long to absorb. This is the pyramid scheme of programming: **for a small +investment you will get high returns**. + +!!!tip "What are these tools for?" + These tools are all about automating the tedious tasks and forcing you to + perform the dull chores bit by bit rather than just before release! + +Here is a list of the main tools we will go over: + +- `Conda`: virtual environments to code in a safe space. +- `git`: keep track of changes and benefit from all the advantages of Github. +- `VSCode`: a lightweight IDE to be more effective at writing and debugging +code. +- `pyproject.toml`: a single file with all your Python packaging information. +- `pytest`: get in the habit of writing tests to make your code bullet-proof. +- `black`: make your code more readable using automatic formatting. +- `mypy`: enforce types, and never be surprised by inputs and outputs. +- `ruff`: improve code quality using a linter. +- `codecov`: find the blind spots of your tests. +- `pre-commit`: automate formatter, linter, or type checker before every commit. +- `Github actions`: automate everything everywhere all at once! + +Apply these tools and you will see the result rapidly![^1] + +!!! note "Example repositories" + This tutorial guides you through the same tools that are automatically + installed if you create a repository using + [pyrepo-copier](https://github.com/pydev-guide/pyrepo-copier). + + Finally, in this tutorial we also create some code. The + [pydev-tutorial](https://github.com/pydev-guide/pydev-tutorial) repository + corresponds to what you will implement by following this step by step. + +[^1]: Did you know that 9 out of 10 programmers recommend these tools? diff --git a/docs/tutorial/testing/code_to_be_tested.md b/docs/tutorial/testing/code_to_be_tested.md new file mode 100644 index 0000000..119a545 --- /dev/null +++ b/docs/tutorial/testing/code_to_be_tested.md @@ -0,0 +1,75 @@ +# Adding code + +Let's create some code to be tested. Imagine we want to model the speed of a +swallow carrying a cargo. We know from movies that there are multiple swallow +species and that they are not all migratories. So first, we could create an +`enum` to account for the species: + +```python +from enum import Enum + + +class SwallowSpecies(str, Enum): + AFRICAN = "african" # non-migratory + EUROPEAN = "european" # migratory +``` + +Next, we want to define our swallow and its cargo, so let's define a class: + +```python +class Swallow: + def __init__(self, species: str, cargo_weight: float = 0) -> None: + if cargo_weight < 0: + raise ValueError("Cargo weight cannot be negative") + + if species.upper() != "AFRICAN" and species.upper() != "EUROPEAN": + raise ValueError('Species must be either "african" or "european"') + + self.species = SwallowSpecies[species.upper()] + self._cargo_weight = cargo_weight +``` + +We made `_cargo` private because we don't want negative weights to be set after +initialization. How to make sure of that? Let's prevent it in the `setter`: + +```python +class Swallow: + ... + + @property + def cargo_weight(self) -> float: + return self._cargo_weight + + @cargo_weight.setter + def cargo_weight(self, value: float) -> None: + if value < 0: + raise ValueError("Cargo weight cannot be negative") + self._cargo_weight = value +``` + +Right, now we cannot change the cargo weight. Let's finally add methods to +compute the speed or tell users whether the species is migratory: + +```python +class Swallow: + ... + + def get_speed(self) -> float: + if self.cargo_weight >= 0.45: + # the swallow is going backward, one pound is too heavy + return -60.0 / (1 + self._cargo_weight) + return 60.0 / (1 + self._cargo_weight) + + def is_migratory(self) -> bool: + return self.species == SwallowSpecies.AFRICAN +``` + +Now that we have code, we can write tests that can verify it behaves as +expected! + +!!!tip "The code could not be simpler, why test it?" + What if you decided to add an Asian swallow that is migratory. You might + modify `SwallowSpecies` and then the `Swallow.__init__` but forget to update + `Swallow.is_migratory`. How would you know about the problem before running + into it? By running tests automatically! Never underestimate our propension + to forget things! diff --git a/docs/tutorial/testing/real_tests.md b/docs/tutorial/testing/real_tests.md new file mode 100644 index 0000000..dab5b36 --- /dev/null +++ b/docs/tutorial/testing/real_tests.md @@ -0,0 +1,40 @@ +# Test for real + +It passes, but this does not give us any information about our code. Let's +replace it with actual tests: + +```python +def test_migratory(): + """Test that the European swallow is migratory, while the African swallow is + not.""" + european_swallow = Swallow(species="european") + assert european_swallow.is_migratory() + + african_swallow = Swallow(species="african") + assert not african_swallow.is_migratory() + + +def test_unladen_velocity(): + """Test that unladen swallows fly at 60 km/h.""" + european_swallow = Swallow(species="european", cargo_weight=0) + assert european_swallow.get_speed() == 60.0 + + african_swallow = Swallow(species="african") + assert african_swallow.get_speed() == 60.0 +``` + +These tests are naive (see next sections) but they work. Or... do they? That's +right, `test_migratory` should actually not pass. + +???question "Why is that?" + It seems we were a bit too fast writig our code and we made a mistake. Is + it thecode or the test that is wrong? The European swallows should be + migratory, which is what the tests checks. Indeed, there is a mistake in + the code `return self.species == SwallowSpecies.AFRICAN` should actually be: + `return self.species == SwallowSpecies.EUROPEAN` + + Good that we tested our code! + + Writing the tests before implementing the code is called + **test-driven development** and you might want take this approach when + coding. diff --git a/docs/tutorial/testing/test_conclusion.md b/docs/tutorial/testing/test_conclusion.md new file mode 100644 index 0000000..7c7c922 --- /dev/null +++ b/docs/tutorial/testing/test_conclusion.md @@ -0,0 +1,5 @@ +# Conclusion + +Tests are very powerful and can prevent bugs from insidiously live in your code +without your knowledge. In the next section, we will go one step further and +automate not only tests but also principles to improve code quality! diff --git a/docs/tutorial/testing/test_coverage.md b/docs/tutorial/testing/test_coverage.md new file mode 100644 index 0000000..28a1688 --- /dev/null +++ b/docs/tutorial/testing/test_coverage.md @@ -0,0 +1,91 @@ +# Testing coverage + +Code coverage tells you how much of your code is ran by your tests. While this +is a crude metrics and does not ensure that your tests are good, at least +it points your towards blind spots in your tests! + +So `coverage`, one more tool for testing? Fortunately there is a test coverage +plugin for `pytest`: `pytest-cov`. + +## Adding pytest-cov + +The first thing is to add the dependency: + +```toml title="pyproject.toml" +[project.optional-dependencies] +# add dependencies used for testing here +test = ["pytest", "pytest-cov"] +``` + +Then, let's add some parameters to `coverage`: + +```toml title="pyproject.toml" +# https://coverage.readthedocs.io/en/6.4/config.html +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "@overload", + "except ImportError", + "\\.\\.\\.", + "raise NotImplementedError()", +] +[tool.coverage.run] +source = ["src"] +``` + +Finally, let's grab the dependencies using the `test` dependency group: + +```console +pip install -e ".[test]" +``` + +## Run your tests with coverage + +Now you can check coverage report using the following `pytest` command: + +