diff --git a/.github/workflows/build-merged-pull-requests.yml b/.github/workflows/build-merged-pull-requests.yml deleted file mode 100644 index 68f367e..0000000 --- a/.github/workflows/build-merged-pull-requests.yml +++ /dev/null @@ -1,32 +0,0 @@ -# vim: set tabstop=2 softtabstop=2 shiftwidth=2 expandtab: - -name: Build main branch - -on: - pull_request: - branches: [main] - types: [closed] - push: - branches: [main] - workflow_call: - -jobs: - build: - name: Build wheel(s) and packages - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4.1.1 - - name: Install Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install build tools - run: python -m pip install build - - name: Build wheel(s) and packages - run: python -m build . - - name: Upload built packages - uses: actions/upload-artifact@v3 - with: - name: package - path: dist/*.* diff --git a/.github/workflows/lint-test-upon-push.yml b/.github/workflows/lint-test-upon-push.yml deleted file mode 100644 index ac18263..0000000 --- a/.github/workflows/lint-test-upon-push.yml +++ /dev/null @@ -1,60 +0,0 @@ -# vim: set tabstop=2 softtabstop=2 shiftwidth=2 expandtab: - -name: Lint and test after every push or pull request -on: - push: - pull_request: - workflow_call: - workflow_dispatch: - schedule: - - cron: '0 0 * * SUN' # run at midnight every Sunday - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - lint: - name: Linting code - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4.1.1 - - uses: actions/setup-python@v5 - with: - python-version: 3.12 - cache: pip - - run: python -m pip install black flake8 - - run: python -m black --check . - - run: python -m flake8 . - - test: - name: Run tests (${{ matrix.os }}, Python ${{ matrix.python_version }}) - needs: lint - runs-on: ${{ matrix.os }} - defaults: - run: - shell: bash -l {0} - strategy: - fail-fast: false - matrix: - os: - - ubuntu-latest - - windows-latest - - macos-latest - python_version: - - '3.12' - - '3.11' - - '3.10' - - '3.9' - - steps: - - uses: actions/checkout@v4.1.1 - - - uses: actions/setup-python@v5 - with: - python-version: ${{matrix.python_version}} - cache: pip - - - run: pip install .[tests] - - - run: python -m pytest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..396741f --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,20 @@ +# vim: set tabstop=2 softtabstop=2 shiftwidth=2 expandtab: + +name: Lint code +on: + workflow_call: + +jobs: + lint: + name: Linting code + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: pip + - run: python -m pip install black flake8 pydocstyle + - run: python -m black --check . + - run: python -m flake8 . + - run: python -m pydocstyle . diff --git a/.github/workflows/release-and-deploy-v-tags.yml b/.github/workflows/release.yml similarity index 55% rename from .github/workflows/release-and-deploy-v-tags.yml rename to .github/workflows/release.yml index 87f993c..fac925f 100644 --- a/.github/workflows/release-and-deploy-v-tags.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,36 @@ # vim: set tabstop=2 softtabstop=2 shiftwidth=2 expandtab: -name: Create a release and deploy to PyPi whenever a protected tag (v0.0.0) is created +name: Create a release and deploy to PyPi on: push: tags: - v*.*.* + - v*.*.*.dev* + - v*.*.*.post* jobs: - build: - name: Build package - uses: ./.github/workflows/build-merged-pull-requests.yml + test: + uses: ./.github/workflows/test.yml secrets: inherit + build: + name: Build wheel + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: "pip" + - run: python -m pip install build + - run: python -m build . + - uses: actions/upload-artifact@v4 + with: + name: package + path: dist/*.* + merge-into-stable: name: Update stable branch to point to this release runs-on: ubuntu-latest @@ -20,8 +38,7 @@ jobs: if: "!contains(github.ref, 'dev')" permissions: write-all steps: - - name: Clone repository, check-out stable - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4 with: fetch-depth: 0 ref: stable @@ -32,21 +49,22 @@ jobs: git push deploy: - name: Upload built package to PyPi + name: Upload built wheel and source package to PyPi runs-on: ubuntu-latest needs: [build] + environment: + name: pypi + url: https://pypi.org/p/cartogram + permissions: + id-token: write steps: - - name: Download built artifacts - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: package path: dist/ - - name: Upload package to PyPi - uses: pypa/gh-action-pypi-publish@release/v1.9 + - uses: pypa/gh-action-pypi-publish@release/v1 with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} - skip_existing: true + skip-existing: true release: name: Create a new release @@ -56,13 +74,11 @@ jobs: permissions: contents: write steps: - - name: Download built artifacts - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: package path: dist/ - - name: Create release and upload package - uses: softprops/action-gh-release@v2 + - uses: softprops/action-gh-release@v2 with: files: dist/* @@ -74,13 +90,11 @@ jobs: permissions: contents: write steps: - - name: Download built artifacts - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: package path: dist/ - - name: Create release and upload package - uses: softprops/action-gh-release@v2 + - uses: softprops/action-gh-release@v2 with: files: dist/* prerelease: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..86454f3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,52 @@ +# vim: set tabstop=2 softtabstop=2 shiftwidth=2 expandtab: + +name: Unit tests +on: + pull_request: + workflow_call: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + uses: ./.github/workflows/lint.yml + secrets: inherit + + test: + name: Run tests (${{ matrix.os }}, Python ${{ matrix.python_version }} + needs: lint + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + python_version: + - '3.13' + - '3.12' + - '3.11' + - '3.10' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '${{matrix.python_version}}' + cache: 'pip' + + - run: python -m pip install --prefer-binary .[tests] + + - run: python -m pytest + + - uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 149fbe4..db369ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +- **1.0.0** (2025-04-25): + - migrated to PyPi Trusted Publishing + - included tests in source wheels + - **0.0.2** (2024-07-15): - added documentation diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ed8aaca --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include tests *.py +recursive-include tests/data * +global-exclude *.pyc diff --git a/docs/_extensions/bibliography_as_sphinxdesign_cards/__init__.py b/docs/_extensions/bibliography_as_sphinxdesign_cards/__init__.py index 770e947..dd0ce78 100644 --- a/docs/_extensions/bibliography_as_sphinxdesign_cards/__init__.py +++ b/docs/_extensions/bibliography_as_sphinxdesign_cards/__init__.py @@ -59,15 +59,19 @@ class CitationsToSphinxDesignCardsTransformer( sphinx.transforms.post_transforms.SphinxPostTransform ): + """Modify the bibliography entries created by sphinxcontrib.bibtex to be sphinx-design cards.""" + default_priority = ( 198 # before anything from sphinx_design, but after sphinxcontrib.bibtex ) def apply(self, **kwargs): + """Apply the transformation to all relevant nodes.""" for node in self.document.findall(docutils.nodes.citation): self.handle(node) def handle(self, node): + """Modify a single node.""" new_node = self._create_empty_sdcard_container() for attribute in ["backrefs", "docname", "ids"]: new_node[attribute] = node[attribute] diff --git a/docs/conf.py b/docs/conf.py index 13fb9cb..150b229 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,7 @@ +#!/bin/env python3 + +"""Define how the documentation is compiled.""" + # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: @@ -41,7 +45,6 @@ "sphinx.ext.napoleon", "sphinx_design", "sphinxcontrib.bibtex", - "sphinxcontrib.images", ] templates_path = ["_templates"] diff --git a/docs/index.md b/docs/index.md index 7fc0a1e..a707dae 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,13 +1,22 @@ # *python-cartogram* - compute continuous cartograms -:::{thumbnail} _static/images/Austria_PopulationCartogram_NUTS2_20170101.svg +:::{figure} _static/images/Austria_PopulationCartogram_NUTS2_20170101.svg :alt: A map showing the nine federal provinces of Austria, distorted in a way that their relative areas relate to their population numbers. -:title: The nine federal provinces of Austria, distorted so their area sizes match their population numbers -:show_caption: 1 :class: align-default + +The nine federal provinces of Austria, distorted so their area sizes match their population numbers ::: +% -> https://github.com/sphinx-contrib/images/pull/39#issuecomment-2258779386 + +% :::{thumbnail} _static/images/Austria_PopulationCartogram_NUTS2_20170101.svg +% :alt: A map showing the nine federal provinces of Austria, distorted in a way that their relative areas relate to their population numbers. +% :title: The nine federal provinces of Austria, distorted so their area sizes match their population numbers +% :show_caption: 1 +% :class: align-default +% ::: + **python-cartogram** is a Python package that can be used to compute continuous cartograms. These map-like cartographic visualisations, also known as diff --git a/pyproject.toml b/pyproject.toml index f4bca46..1f41513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "pandas", "shapely" ] -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", @@ -35,7 +35,7 @@ docs = ["folium", "GitPython", "jupyterlab_myst", "mapclassify", "matplotlib", "myst-nb", "nbsphinx", "pybtex-apa7-style", "sphinx", "sphinx-book-theme", "sphinx-design", "sphinxcontrib-bibtex", "sphinxcontrib-images", "xyzservices"] -tests = ["pytest", "pytest-asyncio", "pytest-lazy-fixtures"] +tests = ["pytest", "pytest-cov", "pytest-lazy-fixtures"] [project.urls] Documentation = "https://python-cartogram.readthedocs.org/" @@ -47,9 +47,9 @@ Repository = "https://github.com/austromorph/python-cartogram" omit = ["tests/*", ".virtualenv/**/*"] [tool.pytest.ini_options] -#addopts = "-p no:faulthandler -r s" +addopts = "--cov=cartogram --cov-report term-missing --cov-report xml" +pythonpath = ["src"] testpaths = ["tests"] -asyncio_mode = "auto" [tool.setuptools.dynamic] version = {attr = "cartogram.__version__"} diff --git a/src/cartogram/__init__.py b/src/cartogram/__init__.py index 077a605..69a4b57 100644 --- a/src/cartogram/__init__.py +++ b/src/cartogram/__init__.py @@ -2,7 +2,7 @@ """Compute continuous cartograms.""" -__version__ = "0.0.2" +__version__ = "1.0.0" from .cartogram import Cartogram diff --git a/src/cartogram/cartogram.py b/src/cartogram/cartogram.py index baff6bb..860cbaf 100644 --- a/src/cartogram/cartogram.py +++ b/src/cartogram/cartogram.py @@ -95,9 +95,8 @@ def average_error(self): def _check_cartogram_attribute(self): if isinstance(self.cartogram_attribute, pandas.Series): - cartogram_attribute_series = self.cartogram_attribute - else: - cartogram_attribute_series = self[self.cartogram_attribute] + self.cartogram_attribute = self.cartogram_attribute.name + cartogram_attribute_series = self[self.cartogram_attribute] if not pandas.api.types.is_numeric_dtype(cartogram_attribute_series): raise ValueError("Cartogram attribute is not numeric") if cartogram_attribute_series.hasnans: @@ -152,7 +151,6 @@ def _feature_error(self, feature): try: error = max(area, target_area) / min(area, target_area) except ZeroDivisionError: - print("ZeroDiv") error = 1.0 return error @@ -264,7 +262,7 @@ def _transform_vertices(self, vertices, features, reduction_factor): @functools.cached_property def total_area(self): - """Total area of all polygons""" + """Total area of all polygons.""" return self.geometry.area.sum() @functools.cached_property diff --git a/tests/conftest.py b/tests/conftest.py index 25134f9..1ab01b3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +"""Define the fixtures for unit tests.""" + # This is a init file common to all tests. It is automatically sourced # by pytest et al. @@ -20,18 +22,46 @@ ) AUSTRIA_NUTS2_POPULATION_COLUMN_NAME = "pop20170101" +AUSTRIA_NUTS2_NONNUMERIC_COLUMN_NAME = "nuts2" @pytest.fixture(scope="session") def austria_nuts2_population_geodataframe(): + """Return a geopandas.GeoDataFrame with test data.""" yield geopandas.read_file(AUSTRIA_NUTS2_POPULATION) @pytest.fixture(scope="session") def austria_nuts2_population_cartogram_geodataframe(): + """Return a geopandas.GeoDataFrame with already distorted test data.""" yield geopandas.read_file(AUSTRIA_NUTS2_POPULATION_CARTOGRAM) @pytest.fixture(scope="session") def austria_nuts2_population_column_name(): + """Return the name of the cartogram column in the test data set.""" yield AUSTRIA_NUTS2_POPULATION_COLUMN_NAME + + +@pytest.fixture(scope="session") +def austria_nuts2_population_column( + austria_nuts2_population_geodataframe, + austria_nuts2_population_column_name, +): + """Return the cartogram column in the test data set as a pandas.Series.""" + yield austria_nuts2_population_geodataframe[austria_nuts2_population_column_name] + + +@pytest.fixture(scope="session") +def austria_nuts2_nonnumeric_column_name(): + """Return the name of a non-numeric column in the test data set.""" + yield AUSTRIA_NUTS2_NONNUMERIC_COLUMN_NAME + + +@pytest.fixture(scope="session") +def austria_nuts2_nonnumeric_column( + austria_nuts2_population_geodataframe, + austria_nuts2_nonnumeric_column_name, +): + """Return a non-numeric column in the test data set as a pandas.Series.""" + yield austria_nuts2_population_geodataframe[austria_nuts2_nonnumeric_column_name] diff --git a/tests/test_cartogram.py b/tests/test_cartogram.py index c252854..0bdb92b 100644 --- a/tests/test_cartogram.py +++ b/tests/test_cartogram.py @@ -4,6 +4,7 @@ import geopandas.testing import pytest import pytest_lazy_fixtures +import shapely import cartogram @@ -22,7 +23,14 @@ class TestCartogram: pytest_lazy_fixtures.lf( "austria_nuts2_population_cartogram_geodataframe" ), - ) + ), + ( + pytest_lazy_fixtures.lf("austria_nuts2_population_geodataframe"), + pytest_lazy_fixtures.lf("austria_nuts2_population_column"), + pytest_lazy_fixtures.lf( + "austria_nuts2_population_cartogram_geodataframe" + ), + ), ], ) def test_cartogram( @@ -38,3 +46,113 @@ def test_cartogram( check_less_precise=True, normalize=True, ) + + @pytest.mark.parametrize( + [ + "input_geodataframe", + "column_name", + "expected_result_geodataframe", + ], + [ + ( + pytest_lazy_fixtures.lf("austria_nuts2_population_geodataframe"), + pytest_lazy_fixtures.lf("austria_nuts2_population_column_name"), + pytest_lazy_fixtures.lf( + "austria_nuts2_population_cartogram_geodataframe" + ), + ), + ( + pytest_lazy_fixtures.lf("austria_nuts2_population_geodataframe"), + pytest_lazy_fixtures.lf("austria_nuts2_population_column"), + pytest_lazy_fixtures.lf( + "austria_nuts2_population_cartogram_geodataframe" + ), + ), + ], + ) + def test_cartogram_verbose( + self, + input_geodataframe, + column_name, + expected_result_geodataframe, + ): + geopandas.testing.assert_geodataframe_equal( + cartogram.Cartogram(input_geodataframe, column_name, verbose=True), + expected_result_geodataframe, + check_like=True, + check_less_precise=True, + normalize=True, + ) + + def test_cartogram_with_pandas_series( + self, + austria_nuts2_population_geodataframe, + austria_nuts2_population_column_name, + austria_nuts2_population_cartogram_geodataframe, + ): + geopandas.testing.assert_geodataframe_equal( + cartogram.Cartogram( + austria_nuts2_population_geodataframe, + austria_nuts2_population_geodataframe[ + austria_nuts2_population_column_name + ], + ), + austria_nuts2_population_cartogram_geodataframe, + check_like=True, + check_less_precise=True, + normalize=True, + ) + + @pytest.mark.parametrize( + [ + "input_geodataframe", + "column_name", + ], + [ + ( + pytest_lazy_fixtures.lf("austria_nuts2_population_geodataframe"), + pytest_lazy_fixtures.lf("austria_nuts2_nonnumeric_column_name"), + ), + ( + pytest_lazy_fixtures.lf("austria_nuts2_population_geodataframe"), + pytest_lazy_fixtures.lf("austria_nuts2_nonnumeric_column"), + ), + ], + ) + def test_cartogram_non_numeric( + self, + input_geodataframe, + column_name, + ): + with pytest.raises(ValueError, match="Cartogram attribute is not numeric"): + _ = (cartogram.Cartogram(input_geodataframe, column_name),) + + def test_cartogram_null_values( + self, + austria_nuts2_population_geodataframe, + austria_nuts2_population_column_name, + ): + austria_nuts2_population_geodataframe.at[ + 1, austria_nuts2_population_column_name + ] = None + with pytest.raises( + ValueError, match="Cartogram attribute contains NULL values" + ): + _ = cartogram.Cartogram( + austria_nuts2_population_geodataframe, + austria_nuts2_population_column_name, + ) + + def test_cartogram_other_geometries( + self, + austria_nuts2_population_geodataframe, + austria_nuts2_population_column_name, + ): + austria_nuts2_population_geodataframe["geometry"] = shapely.Point() + with pytest.raises( + ValueError, match="Only POLYGON or MULTIPOLYGON geometries supported" + ): + _ = cartogram.Cartogram( + austria_nuts2_population_geodataframe, + austria_nuts2_population_column_name, + )