diff --git a/.gitignore b/.gitignore index ccc4a583..1b4b309f 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ target/ # pyenv python configuration file .python-version .venv +.docs_venv junit.xml # crapple diff --git a/README.rst b/README.rst index 4554c93b..8728bcc2 100644 --- a/README.rst +++ b/README.rst @@ -196,7 +196,7 @@ adjacencies or reading in shapefiles, then run This approach sometimes fails due to compatibility issues between our different Python GIS dependencies, like ``geopandas``, ``pyproj``, ``fiona``, and ``shapely``. If you run into this issue, try installing -the dependencies using the `geo_settings.txt `_ +the dependencies using the `geo_settings.txt `_ file. To do this, run ``pip install -r geo_settings.txt`` from the command line. diff --git a/docs/user/data.rst b/docs/user/data.rst index 71480c85..f6007874 100644 --- a/docs/user/data.rst +++ b/docs/user/data.rst @@ -24,7 +24,7 @@ Writing Data to JSONL .. raw:: html
@@ -217,42 +217,40 @@ which will produce: +-----+------+---------------+------------+----------+-------------+ | | step | district_name | population | area | n_cut_edges | +=====+======+===============+============+==========+=============+ -| 198 | 11 | 3 | 699433 | 0.831304 | 2162 | +| 198 | 11 | 3 | 704364 | 0.883639 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 199 | 11 | 10 | 700040 | 1.562749 | 2162 | +| 199 | 11 | 10 | 709547 | 2.228103 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 200 | 11 | 9 | 702500 | 1.579113 | 2162 | +| 200 | 11 | 9 | 712201 | 0.801655 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 201 | 11 | 5 | 695917 | 3.012263 | 2162 | +| 201 | 11 | 5 | 699705 | 1.639986 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 202 | 11 | 15 | 700895 | 1.616416 | 2162 | +| 202 | 11 | 15 | 706694 | 0.744557 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 203 | 11 | 6 | 705782 | 0.239069 | 2162 | +| 203 | 11 | 6 | 708502 | 0.351298 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 204 | 11 | 11 | 709813 | 0.357564 | 2162 | +| 204 | 11 | 11 | 705406 | 0.948691 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 205 | 11 | 8 | 705689 | 0.199275 | 2162 | +| 205 | 11 | 8 | 702576 | 0.109261 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 206 | 11 | 4 | 705669 | 0.418513 | 2162 | +| 206 | 11 | 4 | 705669 | 0.418513 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 207 | 11 | 18 | 706380 | 0.421818 | 2162 | +| 207 | 11 | 18 | 705847 | 0.569159 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 208 | 11 | 12 | 713452 | 0.856847 | 2162 | +| 208 | 11 | 12 | 695032 | 2.954248 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 209 | 11 | 17 | 706041 | 0.622091 | 2162 | +| 209 | 11 | 17 | 695142 | 0.237470 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 210 | 11 | 7 | 697675 | 0.329930 | 2162 | +| 210 | 11 | 7 | 711035 | 0.018885 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 211 | 11 | 16 | 716162 | 0.194045 | 2162 | +| 211 | 11 | 16 | 699557 | 0.283365 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 212 | 11 | 14 | 704993 | 0.207707 | 2162 | +| 212 | 11 | 14 | 705526 | 0.060366 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 213 | 11 | 13 | 705028 | 0.042608 | 2162 | +| 213 | 11 | 13 | 705028 | 0.042608 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 214 | 11 | 2 | 710286 | 0.021872 | 2162 | +| 214 | 11 | 2 | 705218 | 0.021515 | 2103 | +-----+------+---------------+------------+----------+-------------+ -| 215 | 11 | 1 | 699174 | 0.021139 | 2162 | +| 215 | 11 | 1 | 707880 | 0.221007 | 2103 | +-----+------+---------------+------------+----------+-------------+ - - diff --git a/docs/user/geometries.rst b/docs/user/geometries.rst index d55751ce..f6c11875 100644 --- a/docs/user/geometries.rst +++ b/docs/user/geometries.rst @@ -35,7 +35,7 @@ Loading and Running a Plan .. raw:: html
diff --git a/docs/user/images/MN_geopartition_ensamble.gif b/docs/user/images/MN_geopartition_ensamble.gif index 5c127ef1..ef8c6d3e 100644 Binary files a/docs/user/images/MN_geopartition_ensamble.gif and b/docs/user/images/MN_geopartition_ensamble.gif differ diff --git a/docs/user/images/example_box_pandas.svg b/docs/user/images/example_box_pandas.svg index a6c7f2a0..a60186e1 100644 --- a/docs/user/images/example_box_pandas.svg +++ b/docs/user/images/example_box_pandas.svg @@ -6,11 +6,11 @@ - 2024-01-19T17:48:06.033215 + 2024-02-28T17:32:05.673977 image/svg+xml - Matplotlib v3.8.2, https://matplotlib.org/ + Matplotlib v3.8.3, https://matplotlib.org/ @@ -42,16 +42,16 @@ z +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - - + @@ -88,11 +88,11 @@ z +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -122,11 +122,11 @@ z +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -166,11 +166,11 @@ z +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -218,11 +218,11 @@ z +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -257,11 +257,11 @@ z +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -302,11 +302,11 @@ z +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -352,11 +352,11 @@ z +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -382,11 +382,11 @@ z +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -441,11 +441,11 @@ z +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -491,11 +491,11 @@ z +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -510,11 +510,11 @@ L 265.92 41.472 +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -529,11 +529,11 @@ L 285.76 41.472 +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -548,11 +548,11 @@ L 305.6 41.472 +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -567,11 +567,11 @@ L 325.44 41.472 +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -586,11 +586,11 @@ L 345.28 41.472 +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -605,11 +605,11 @@ L 365.12 41.472 +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -624,11 +624,11 @@ L 384.96 41.472 +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + @@ -643,23 +643,23 @@ L 404.8 41.472 - + - - + - + - + - + - + @@ -697,18 +697,18 @@ L 414.72 245.82512 - + - + - + @@ -717,18 +717,18 @@ L 414.72 199.58656 - + - + - + @@ -737,18 +737,18 @@ L 414.72 153.348001 - + - + - + @@ -757,18 +757,18 @@ L 414.72 107.109442 - + - + - + @@ -777,65 +777,36 @@ L 414.72 60.870883 - + - +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #1f77b4; stroke-linecap: square"/> - + +" clip-path="url(#p43b0dc85f3)" style="fill: none; stroke: #000000; stroke-linecap: square"/> - + - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - + - + + + + + + + + - - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + - - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - + - + - + - + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - - - - - - - - - - - - - - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + diff --git a/docs/user/images/gerrymandria_ensamble.gif b/docs/user/images/gerrymandria_ensamble.gif index 729d49a4..630f6bbf 100644 Binary files a/docs/user/images/gerrymandria_ensamble.gif and b/docs/user/images/gerrymandria_ensamble.gif differ diff --git a/docs/user/images/gerrymandria_region_ensamble.gif b/docs/user/images/gerrymandria_region_ensamble.gif index acf69f85..91aed385 100644 Binary files a/docs/user/images/gerrymandria_region_ensamble.gif and b/docs/user/images/gerrymandria_region_ensamble.gif differ diff --git a/docs/user/images/gerrymandria_water_and_muni_aware.png b/docs/user/images/gerrymandria_water_and_muni_aware.png index fb824a2f..74f4c0f0 100644 Binary files a/docs/user/images/gerrymandria_water_and_muni_aware.png and b/docs/user/images/gerrymandria_water_and_muni_aware.png differ diff --git a/docs/user/images/gerrymandria_water_muni_ensamble.gif b/docs/user/images/gerrymandria_water_muni_ensamble.gif index e821488d..1b6e8412 100644 Binary files a/docs/user/images/gerrymandria_water_muni_ensamble.gif and b/docs/user/images/gerrymandria_water_muni_ensamble.gif differ diff --git a/docs/user/install.rst b/docs/user/install.rst index 3e8f5a31..3c805a91 100644 --- a/docs/user/install.rst +++ b/docs/user/install.rst @@ -9,6 +9,7 @@ The most recent version of GerryChain (as of January 2024) supports - Python 3.9 - Python 3.10 - Python 3.11 +- Python 3.12 If you do not have one of these versions installed on you machine, we recommend that you go to the @@ -135,7 +136,7 @@ This approach sometimes fails due to compatibility issues between our different Python GIS dependencies, like ``geopandas``, ``pyproj``, ``fiona``, and ``shapely``. If you run into this issue, try installing the dependencies using the -`geo_settings.txt `_ +`geo_settings.txt `_ file. To do this, run ``pip install -r geo_settings.txt`` from the command line. diff --git a/docs/user/partitions.rst b/docs/user/partitions.rst index 3241c539..2bbe049b 100644 --- a/docs/user/partitions.rst +++ b/docs/user/partitions.rst @@ -4,7 +4,7 @@ Working with Partitions .. raw:: html
@@ -17,7 +17,7 @@ GerryChain ``Partition`` object. from gerrychain.updaters import cut_edges We'll use our -`Pennsylvania VTD json `_ +`Pennsylvania VTD json `_ to create the graph we'll use in these examples. .. code-block:: python diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst index b5cdf8ce..5a6f4af9 100644 --- a/docs/user/quickstart.rst +++ b/docs/user/quickstart.rst @@ -13,7 +13,7 @@ What you'll need Before we can start running Markov chains, you'll need to: * Install ``gerrychain`` from PyPI. See the :doc:`installation guide <./install>` for instructions. -* Download `this example json of Pennsylvania's VTDs `_. +* Download `this example json of Pennsylvania's VTDs `_. * Open your preferred Python environment (e.g. JupyterLab, IPython, or a ``.py`` file in your favorite editor) in the directory containing the ``PA_VTDs.json`` file that you downloaded. @@ -43,7 +43,7 @@ Creating the initial partition .. raw:: html
diff --git a/docs/user/recom.rst b/docs/user/recom.rst index 5e45f9b9..a3c76e77 100644 --- a/docs/user/recom.rst +++ b/docs/user/recom.rst @@ -28,7 +28,7 @@ A Simple Recom Chain .. raw:: html @@ -49,7 +49,7 @@ the first thing to do is to import the required packages: # Set the random seed so that the results are reproducible! import random - random.seed(42) + random.seed(2024) Now we set up the initial partition: @@ -94,7 +94,7 @@ We can now set up the chain: constraints=[contiguous], accept=accept.always_accept, initial_state=initial_partition, - total_steps=20 + total_steps=40 ) and run it with @@ -131,12 +131,12 @@ bad idea to do this for a chain with a large number of steps). pos = {node :(data['x'],data['y']) for node, data in graph.nodes(data=True)} node_colors = [mcm.tab20(int(assignment_list[i][node]) % 20) for node in graph.nodes()] node_labels = {node: str(assignment_list[i][node]) for node in graph.nodes()} - + nx.draw_networkx_nodes(graph, pos, node_color=node_colors) nx.draw_networkx_edges(graph, pos) nx.draw_networkx_labels(graph, pos, labels=node_labels) plt.axis('off') - + buffer = io.BytesIO() plt.savefig(buffer, format='png') buffer.seek(0) @@ -176,7 +176,7 @@ Fortunately, ``gerrychain`` has a built-in functionality that allows for region-aware ReCom chains which create ensembles of districting plans that try to keep particular regions of interest together. And it only takes one extra line of code: we simply update -our proposal to include a ``weight_dict`` which increases the importance of the +our proposal to include a ``region_surcharge`` which increases the importance of the edges within the municipalities. .. code-block:: python @@ -187,7 +187,7 @@ edges within the municipalities. pop_target=ideal_population, epsilon=0.01, node_repeats=2, - weight_dict={"muni": 0.8}, + region_surcharge={"muni": 1.0}, ) And this will produce the following ensemble: @@ -210,7 +210,7 @@ and so it is not going to be possible to keep all of the water districts togethe and all of the municipalities together in one plan. However, we can try to keep the water districts together as much as possible, and then, within those water districts, try to be sensitive to the boundaries of the municipalities. Again, -this only requires for us to edit the ``weight_dict`` parameter of the proposal +this only requires for us to edit the ``region_surcharge`` parameter of the proposal .. code-block:: python @@ -220,7 +220,7 @@ this only requires for us to edit the ``weight_dict`` parameter of the proposal pop_target=ideal_population, epsilon=0.01, node_repeats=2, - weight_dict={"muni": 0.2, "water": 0.8}, + region_surcharge={"muni": 0.2, "water": 0.8}, ) Since we are trying to be sensitive to multiple bits of information, we should probably @@ -236,7 +236,7 @@ also increase the length of our chain to make sure that we have time to mix prop total_steps=10000 ) -Then, we can run the chain and look at the last 20 assignments in the ensemble +Then, we can run the chain and look at the last 40 assignments in the ensemble .. image:: ./images/gerrymandria_water_muni_ensamble.gif :width: 400px @@ -251,45 +251,106 @@ while also being sensitive to the municipalities :align: center The last map in the ensemble from the 10000 step region-aware ReCom chain with - weights of 0.2 for the municipalities and 0.8 for the water districts. + surcharges of 0.2 for the municipalities and 0.8 for the water districts. .. raw:: html
- +
Municipalities of Gerrymandria
- +
Water Districts of GerryMandria
-.. _weight-dict-warning: +How the Region Aware Implementation Works +----------------------------------------- + +When working with region-aware ReCom chains, it is worth knowing how the spanning tree +of the dual graph is being split. Weights from the interval :math:`[0,1]` are randomly +assigned to the edges of the graph and then the surcharges are applied to the edges in +the graph that span different regions specified by the ``region_surcharge`` dictionary. +So if we have ``region_surcharge={"muni": 0.2, "water": 0.8}``, then the edges that +span different municipalities will be upweighted by 0.2 and the edges that span different +water districts will be upweighted by 0.8. We then draw a minimum spanning tree using +by greedily selecting the lowest-weight edges via Kruskal's algorithm. The surcharges on +the edges helps ensure that the algorithm picks the edges interior to the region +before it picks the edges that bridge different regions. + +This makes it very likely that each region is largely contained in a connected subtree +attached to a bridge node. Thus, when we make a cut, the regions attached to the +bridge node are more likely to be (mostly) preserved in the subtree on either side +of the cut. + +In the implementation of :meth:`~gerrychain.tree.biparition_tree` we further bias this +choice by deterministically cutting bridge edges first (when possible). In the event that +multiple types of regions are specified, the surcharges are added together, and edges are +selected first by the number of types of regions that they span, and then by the +surcharge added to those weights. So, if we have a region surcharge dictionary of +``{"a": 1, "b": 4, "c": 2}`` then we we look for edges according to the order + +- ("a", "b", "c") +- ("b", "c") +- ("a", "b") +- ("a", "c") +- ("b") +- ("c") +- ("a") +- random + +where the tuples indicate that a desired cut edge bridges both types of region in +the tuple. In the event that this is not the desired behaviour, then the user can simply +alter the ``cut_choice`` function in the constraints to be different. So, if the user +would prefer the cut edge to be a random edge with no deference to bridge edges, +then they might use ``random.choice()`` in the following way: + +.. code-block:: python + + proposal = partial( + recom, + pop_col="TOTPOP", + pop_target=ideal_population, + epsilon=0.01, + node_repeats=1, + region_surcharge={ + "muni": 2.0, + "water_dist": 2.0 + }, + method = partial( + bipartition_tree, + cut_choice = random.choice, + ) + ) + +**Note**: When ``region_surcharge`` is not specified, ``bipartition_tree`` will behave as if +``cut_choice`` is set to ``random.choice``. + -.. attention:: +.. .. attention:: - The ``weight_dict`` parameter is a dictionary that assigns a weight to each - edge within a particular region that is determined by the keys of the dictionary. - In the event that multiple regions are specified, the weights are added together, - and if the weights add to more than 1, then the following warning will be printed - to the user: +.. The ``region_surcharge`` parameter is a dictionary that assigns a surcharge to each +.. edge within a particular region that is determined by the keys of the dictionary. +.. In the event that multiple regions are specified, the surcharges are added together, +.. and if the surcharges add to more than 1, then the following warning will be printed +.. to the user: - .. code-block:: console +.. .. code-block:: console - ValueWarning: - The sum of the weights in the weight dictionary is greater than 1. - Please consider normalizing the weights. +.. ValueWarning: +.. The sum of the surcharges in the surcharge dictionary is greater than 1. +.. Please consider normalizing the surcharges. - It is generally inadvisable to set the weight of a region to 1 or more. When - using :meth:`~gerrychain.proposals.recom` with a ``weight_dict``, the proposal - will try to draw a minimum spanning tree using Kruskal's algorithm where, - the weights are in the range :math:`[0,1]`, then the weights from the weight - dictionary are added to them. In the event that - many edges within the tree have a weight above 1, then it can sometimes - cause the biparitioning step to stall. +.. It is generally inadvisable to set the surcharge of a region to 1 or more. When +.. using :meth:`~gerrychain.proposals.recom` with a ``region_surcharge``, the proposal +.. will try to draw a minimum spanning tree using Kruskal's algorithm where, +.. the surcharges are in the range :math:`[0,1]`, then the surcharges from the surcharge +.. dictionary are added to them. In the event that +.. many edges within the tree have a surcharge above 1, then it can sometimes +.. cause the bipartitioning step to stall. What to do if the Chain Gets Stuck @@ -311,7 +372,7 @@ district, then the chain will get stuck and throw an error. Here is the setup: from gerrychain.constraints import contiguous from functools import partial import random - random.seed(42) + random.seed(0) graph = Graph.from_json("./gerrymandria.json") @@ -334,7 +395,14 @@ district, then the chain will get stuck and throw an error. Here is the setup: pop_target=ideal_population, epsilon=0.01, node_repeats=1, - weight_dict={"muni": 1.0, "water_dist": 1.0}, + region_surcharge={ + "muni": 2.0, + "water_dist": 2.0 + }, + method = partial( + bipartition_tree, + max_attempts=100, + ) ) recom_chain = MarkovChain( @@ -342,23 +410,19 @@ district, then the chain will get stuck and throw an error. Here is the setup: constraints=[contiguous], accept=accept.always_accept, initial_state=initial_partition, - total_steps=20 + total_steps=20, ) assignment_list = [] for i, item in enumerate(recom_chain): print(f"Finished step {i + 1}/{len(recom_chain)}", end="\r") - assignment_list.append(item.assignment) + assignment_list.append(item.assignment)) This will output the following sequence of warnings and errors .. code-block:: console - ValueWarning: - The sum of the weights in the weight dictionary is greater than 1. - Please consider normalizing the weights. - BipartitionWarning: Failed to find a balanced cut after 50 attempts. If possible, consider enabling pair reselection within your @@ -374,12 +438,6 @@ Let's break down what is happening in each of these: .. raw:: html
    -
  • ValueWarning - This is just telling us that we have made an ill-advised - choice of weights for our regions. See the above warning - for more information. -
  • -
  • BipartitionWarning This is telling us that somewhere along the way, we picked a pair of districts that were difficult to bipartition underneath @@ -419,7 +477,10 @@ node repeats: pop_target=ideal_population, epsilon=0.01, node_repeats=100, - weight_dict={"muni": 1.0, "water_dist": 1.0}, + region_surcharge={ + "muni": 1.0, + "water_dist": 1.0 + }, ) Running this code, we can see that we get stuck once again, so this was not the fix. @@ -427,7 +488,11 @@ Let's try to enable reselection instead: .. code-block:: python - method = partial(bipartition_tree, allow_pair_reselection=True) + method = partial( + bipartition_tree, + max_attempts=100, + allow_pair_reselection=True + ) proposal = partial( recom, @@ -435,7 +500,10 @@ Let's try to enable reselection instead: pop_target=ideal_population, epsilon=0.01, node_repeats=1, - weight_dict={"muni": 1.0, "water_dist": 1.0}, + region_surcharge={ + "muni": 1.0, + "water_dist": 1.0 + }, method=method ) @@ -473,7 +541,7 @@ Setting up the initial districting plan .. raw:: html
    diff --git a/gerrychain/__init__.py b/gerrychain/__init__.py index 1e379e24..7eac04a8 100644 --- a/gerrychain/__init__.py +++ b/gerrychain/__init__.py @@ -26,3 +26,6 @@ __version__ = get_versions()["version"] del get_versions + +from . import _version +__version__ = _version.get_versions()['version'] diff --git a/gerrychain/_version.py b/gerrychain/_version.py index 9cea6280..1dfe475c 100644 --- a/gerrychain/_version.py +++ b/gerrychain/_version.py @@ -1,11 +1,13 @@ + # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build # directories (produced by setup.py build) will contain a much shorter file # that just contains the computed version number. -# This file is released into the public domain. Generated by -# versioneer-0.18 (https://github.com/warner/python-versioneer) +# This file is released into the public domain. +# Generated by versioneer-0.29 +# https://github.com/python-versioneer/python-versioneer """Git implementation of _version.py.""" @@ -14,9 +16,11 @@ import re import subprocess import sys +from typing import Any, Callable, Dict, List, Optional, Tuple +import functools -def get_keywords(): +def get_keywords() -> Dict[str, str]: """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must @@ -32,8 +36,15 @@ def get_keywords(): class VersioneerConfig: """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + parentdir_prefix: str + versionfile_source: str + verbose: bool + -def get_config(): +def get_config() -> VersioneerConfig: """Create, populate and return the VersioneerConfig() object.""" # these strings are filled in when 'setup.py versioneer' creates # _version.py @@ -51,41 +62,50 @@ class NotThisMethod(Exception): """Exception raised if a method is not valid for the current scenario.""" -LONG_VERSION_PY = {} # type: ignore -HANDLERS = {} - +LONG_VERSION_PY: Dict[str, str] = {} +HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - def decorate(f): +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator + """Create decorator to mark a method as the handler of a VCS.""" + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} HANDLERS[vcs][method] = f return f - return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) - p = None - for c in commands: + process = None + + popen_kwargs: Dict[str, Any] = {} + if sys.platform == "win32": + # This hides the console window if pythonw.exe is used + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + popen_kwargs["startupinfo"] = startupinfo + + for command in commands: try: - dispcmd = str([c] + args) + dispcmd = str([command] + args) # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen( - [c] + args, - cwd=cwd, - env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr else None), - ) + process = subprocess.Popen([command] + args, cwd=cwd, env=env, + stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr + else None), **popen_kwargs) break - except EnvironmentError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: @@ -96,18 +116,20 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= if verbose: print("unable to find command, tried %s" % (commands,)) return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: + stdout = process.communicate()[0].strip().decode() + if process.returncode != 0: if verbose: print("unable to run %s (error)" % dispcmd) print("stdout was %s" % stdout) - return None, p.returncode - return stdout, p.returncode + return None, process.returncode + return stdout, process.returncode -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -116,64 +138,64 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): """ rootdirs = [] - for i in range(3): + for _ in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): - return { - "version": dirname[len(parentdir_prefix) :], - "full-revisionid": None, - "dirty": False, - "error": None, - "date": None, - } - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level + return {"version": dirname[len(parentdir_prefix):], + "full-revisionid": None, + "dirty": False, "error": None, "date": None} + rootdirs.append(root) + root = os.path.dirname(root) # up a level if verbose: - print( - "Tried directories %s but none started with prefix %s" - % (str(rootdirs), parentdir_prefix) - ) + print("Tried directories %s but none started with prefix %s" % + (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: - f = open(versionfile_abs, "r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() - except EnvironmentError: + with open(versionfile_abs, "r") as fobj: + for line in fobj: + if line.strip().startswith("git_refnames ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["refnames"] = mo.group(1) + if line.strip().startswith("git_full ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["full"] = mo.group(1) + if line.strip().startswith("git_date ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["date"] = mo.group(1) + except OSError: pass return keywords @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") + if "refnames" not in keywords: + raise NotThisMethod("Short version file found") date = keywords.get("date") if date is not None: + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because @@ -186,11 +208,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -199,7 +221,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r"\d", r)]) + tags = {r for r in refs if re.search(r'\d', r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -207,30 +229,33 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix) :] + r = ref[len(tag_prefix):] + # Filter out refs that exactly match prefix or that don't start + # with a number once the prefix is stripped (mostly a concern + # when prefix is '') + if not re.match(r'\d', r): + continue if verbose: print("picking %s" % r) - return { - "version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": None, - "date": date, - } + return {"version": r, + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": None, + "date": date} # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") - return { - "version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": "no suitable tags", - "date": None, - } + return {"version": "0+unknown", + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": "no suitable tags", "date": None} @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -241,7 +266,15 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) + # GIT_DIR can interfere with correct operation of Versioneer. + # It may be intended to be passed to the Versioneer-versioned project, + # but that should not change where we get our version from. + env = os.environ.copy() + env.pop("GIT_DIR", None) + runner = functools.partial(runner, env=env) + + _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, + hide_stderr=not verbose) if rc != 0: if verbose: print("Directory %s not under git control" % root) @@ -249,33 +282,57 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command( - GITS, - [ - "describe", - "--tags", - "--dirty", - "--always", - "--long", - "--match", - "%s*" % tag_prefix, - ], - cwd=root, - ) + describe_out, rc = runner(GITS, [ + "describe", "--tags", "--dirty", "--always", "--long", + "--match", f"{tag_prefix}[[:digit:]]*" + ], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) + full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) if full_out is None: raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None + branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=root) + # --abbrev-ref was added in git-1.6.3 + if rc != 0 or branch_name is None: + raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") + branch_name = branch_name.strip() + + if branch_name == "HEAD": + # If we aren't exactly on a branch, pick a branch which represents + # the current commit. If all else fails, we are on a branchless + # commit. + branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) + # --contains was added in git-1.5.4 + if rc != 0 or branches is None: + raise NotThisMethod("'git branch --contains' returned error") + branches = branches.split("\n") + + # Remove the first line if we're running detached + if "(" in branches[0]: + branches.pop(0) + + # Strip off the leading "* " from the list of branches. + branches = [branch[2:] for branch in branches] + if "master" in branches: + branch_name = "master" + elif not branches: + branch_name = None + else: + # Pick the first branch that is returned. Good or bad. + branch_name = branches[0] + + pieces["branch"] = branch_name + # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] # TAG might have hyphens. git_describe = describe_out @@ -284,16 +341,17 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: - git_describe = git_describe[: git_describe.rindex("-dirty")] + git_describe = git_describe[:git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX - mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) + mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) if not mo: - # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out + # unparsable. Maybe git-describe is misbehaving? + pieces["error"] = ("unable to parse git-describe output: '%s'" + % describe_out) return pieces # tag @@ -302,12 +360,10 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( - full_tag, - tag_prefix, - ) + pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" + % (full_tag, tag_prefix)) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix) :] + pieces["closest-tag"] = full_tag[len(tag_prefix):] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) @@ -318,26 +374,27 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) - pieces["distance"] = int(count_out) # total number of commits + out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) + pieces["distance"] = len(out.split()) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[ - 0 - ].strip() + date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -355,29 +412,78 @@ def render_pep440(pieces): rendered += ".dirty" else: # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) + rendered = "0+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. +def render_pep440_branch(pieces: Dict[str, Any]) -> str: + """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . + + The ".dev0" means not master branch. Note that .dev0 sorts backwards + (a feature branch will appear "older" than the master branch). Exceptions: - 1: no tags. 0.post.devDISTANCE + 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0" + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: + """Split pep440 version string at the post-release segment. + + Returns the release segments before the post-release and the + post-release version number (or -1 if no post-release segment is present). + """ + vc = str.split(ver, ".post") + return vc[0], int(vc[1] or 0) if len(vc) == 2 else None + + +def render_pep440_pre(pieces: Dict[str, Any]) -> str: + """TAG[.postN.devDISTANCE] -- No -dirty. + + Exceptions: + 1: no tags. 0.post0.devDISTANCE + """ + if pieces["closest-tag"]: if pieces["distance"]: - rendered += ".post.dev%d" % pieces["distance"] + # update the post release segment + tag_version, post_version = pep440_split_post(pieces["closest-tag"]) + rendered = tag_version + if post_version is not None: + rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) + else: + rendered += ".post0.dev%d" % (pieces["distance"]) + else: + # no commits, use the tag as the version + rendered = pieces["closest-tag"] else: # exception #1 - rendered = "0.post.dev%d" % pieces["distance"] + rendered = "0.post0.dev%d" % pieces["distance"] return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -404,12 +510,41 @@ def render_pep440_post(pieces): return rendered -def render_pep440_old(pieces): +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: + """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . + + The ".dev0" means not master branch. + + Exceptions: + 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0.post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. - Eexceptions: + Exceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: @@ -426,7 +561,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -446,7 +581,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -466,26 +601,28 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: - return { - "version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None, - } + return {"version": "unknown", + "full-revisionid": pieces.get("long"), + "dirty": None, + "error": pieces["error"], + "date": None} if not style or style == "default": style = "pep440" # the default if style == "pep440": rendered = render_pep440(pieces) + elif style == "pep440-branch": + rendered = render_pep440_branch(pieces) elif style == "pep440-pre": rendered = render_pep440_pre(pieces) elif style == "pep440-post": rendered = render_pep440_post(pieces) + elif style == "pep440-post-branch": + rendered = render_pep440_post_branch(pieces) elif style == "pep440-old": rendered = render_pep440_old(pieces) elif style == "git-describe": @@ -495,16 +632,12 @@ def render(pieces, style): else: raise ValueError("unknown style '%s'" % style) - return { - "version": rendered, - "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], - "error": None, - "date": pieces.get("date"), - } + return {"version": rendered, "full-revisionid": pieces["long"], + "dirty": pieces["dirty"], "error": None, + "date": pieces.get("date")} -def get_versions(): +def get_versions() -> Dict[str, Any]: """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some @@ -515,7 +648,8 @@ def get_versions(): verbose = cfg.verbose try: - return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose) + return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, + verbose) except NotThisMethod: pass @@ -524,16 +658,13 @@ def get_versions(): # versionfile_source is the relative path from the top of the source # tree (where the .git directory might live) to this file. Invert # this to find the root from __file__. - for i in cfg.versionfile_source.split("/"): + for _ in cfg.versionfile_source.split('/'): root = os.path.dirname(root) except NameError: - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to find root of source tree", - "date": None, - } + return {"version": "0+unknown", "full-revisionid": None, + "dirty": None, + "error": "unable to find root of source tree", + "date": None} try: pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) @@ -547,10 +678,6 @@ def get_versions(): except NotThisMethod: pass - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", - "date": None, - } + return {"version": "0+unknown", "full-revisionid": None, + "dirty": None, + "error": "unable to compute version", "date": None} diff --git a/gerrychain/proposals/tree_proposals.py b/gerrychain/proposals/tree_proposals.py index a3117137..f711026e 100644 --- a/gerrychain/proposals/tree_proposals.py +++ b/gerrychain/proposals/tree_proposals.py @@ -13,7 +13,6 @@ ReselectException, ) from typing import Callable, Optional, Dict, Union -import warnings class MetagraphError(Exception): @@ -40,7 +39,7 @@ def recom( pop_target: Union[int, float], epsilon: float, node_repeats: int = 1, - weight_dict: Optional[Dict] = None, + region_surcharge: Optional[Dict] = None, method: Callable = bipartition_tree, ) -> Partition: """ @@ -82,33 +81,24 @@ def recom( :type epsilon: float :param node_repeats: The number of times to repeat the bipartitioning step. Default is 1. :type node_repeats: int, optional - :param weight_dict: The weight dictionary for the graph used for region-aware + :param region_surcharge: The surcharge dictionary for the graph used for region-aware partitioning of the grid. Default is None. - :type weight_dict: Optional[Dict], optional + :type region_surcharge: Optional[Dict], optional :param method: The method used for bipartitioning the tree. Default is :func:`~gerrychain.tree.bipartition_tree`. :type method: Callable, optional :returns: The new partition resulting from the ReCom algorithm. :rtype: Partition - - :raises ValueWarning: Raised when the sum of the weights in the weight dictionary is - greater than 1. """ bad_district_pairs = set() n_parts = len(partition) tot_pairs = n_parts * (n_parts - 1) / 2 # n choose 2 - # Try to add the region aware in if the method accepts the weight dictionary - if "weight_dict" in signature(method).parameters: - method = partial(method, weight_dict=weight_dict) - if weight_dict is not None and sum(weight_dict.values()) > 1: - warnings.warn( - "\nThe sum of the weights in the weight dictionary is greater than 1.\n" - "Please consider normalizing the weights.", - ValueWarning, - ) + # Try to add the region aware in if the method accepts the surcharge dictionary + if "region_surcharge" in signature(method).parameters: + method = partial(method, region_surcharge=region_surcharge) while len(bad_district_pairs) < tot_pairs: try: diff --git a/gerrychain/tree.py b/gerrychain/tree.py index 25190089..0f61583f 100644 --- a/gerrychain/tree.py +++ b/gerrychain/tree.py @@ -33,6 +33,7 @@ from inspect import signature import random from collections import deque, namedtuple +import itertools from typing import ( Any, Callable, @@ -57,35 +58,35 @@ def successors(h: nx.Graph, root: Any) -> Dict: def random_spanning_tree( - graph: nx.Graph, weight_dict: Optional[Dict] = None + graph: nx.Graph, region_surcharge: Optional[Dict] = None ) -> nx.Graph: """ Builds a spanning tree chosen by Kruskal's method using random weights. :param graph: The input graph to build the spanning tree from. Should be a Networkx Graph. :type graph: nx.Graph - :param weight_dict: Dictionary of weights to add to the random weights used in region-aware - variants. - :type weight_dict: Optional[Dict], optional + :param region_surcharge: Dictionary of surcharges to add to the random + weights used in region-aware variants. + :type region_surcharge: Optional[Dict], optional :returns: The maximal spanning tree represented as a Networkx Graph. :rtype: nx.Graph """ - if weight_dict is None: - weight_dict = dict() + if region_surcharge is None: + region_surcharge = dict() for edge in graph.edges(): weight = random.random() - for key, value in weight_dict.items(): + for key, value in region_surcharge.items(): if ( - graph.nodes[edge[0]][key] == graph.nodes[edge[1]][key] + graph.nodes[edge[0]][key] != graph.nodes[edge[1]][key] and graph.nodes[edge[0]][key] is not None ): weight += value graph.edges[edge]["random_weight"] = weight - spanning_tree = tree.maximum_spanning_tree( + spanning_tree = tree.minimum_spanning_tree( graph, algorithm="kruskal", weight="random_weight" ) return spanning_tree @@ -204,10 +205,12 @@ def __repr__(self) -> str: # Tuple that is used in the find_balanced_edge_cuts function -Cut = namedtuple("Cut", "edge subset") +Cut = namedtuple("Cut", "edge weight subset") +Cut.__new__.__defaults__ = (None, None, None) Cut.__doc__ = "Represents a cut in a graph." -Cut.edge.__doc__ = "The edge where the cut is made." -Cut.subset.__doc__ = "The subset of nodes on one side of the cut." +Cut.edge.__doc__ = "The edge where the cut is made. Defaults to None." +Cut.weight.__doc__ = "The weight assigned to the edge (if any). Defaults to None." +Cut.subset.__doc__ = "The (frozen) subset of nodes on one side of the cut. Defaults to None." def find_balanced_edge_cuts_contraction( @@ -234,7 +237,14 @@ def find_balanced_edge_cuts_contraction( while len(leaves) > 0: leaf = leaves.popleft() if h.has_ideal_population(leaf): - cuts.append(Cut(edge=(leaf, pred[leaf]), subset=h.subsets[leaf].copy())) + e = (leaf, pred[leaf]) + cuts.append( + Cut( + edge=e, + weight=h.graph.edges[e].get("random_weight", random.random()), + subset=frozenset(h.subsets[leaf].copy()) + ) + ) # Contract the leaf: parent = pred[leaf] h.contract_node(leaf, parent) @@ -243,6 +253,68 @@ def find_balanced_edge_cuts_contraction( return cuts +def _calc_pops(succ, root, h): + """ + Calculates the population of each subtree in the graph + by traversing the graph using a depth-first search. + + :param succ: The successors of the graph. + :type succ: Dict + :param root: The root node of the graph. + :type root: Any + :param h: The populated graph. + :type h: PopulatedGraph + + :returns: A dictionary mapping nodes to their subtree populations. + :rtype: Dict + """ + subtree_pops: Dict[Any, Union[int, float]] = {} + stack = deque(n for n in succ[root]) + while stack: + next_node = stack.pop() + if next_node not in subtree_pops: + if next_node in succ: + children = succ[next_node] + if all(c in subtree_pops for c in children): + subtree_pops[next_node] = sum(subtree_pops[c] for c in children) + subtree_pops[next_node] += h.population[next_node] + else: + stack.append(next_node) + for c in children: + if c not in subtree_pops: + stack.append(c) + else: + subtree_pops[next_node] = h.population[next_node] + + return subtree_pops + + +def _part_nodes(start, succ): + """ + Partitions the nodes of a graph into two sets. + based on the start node and the successors of the graph. + + :param start: The start node. + :type start: Any + :param succ: The successors of the graph. + :type succ: Dict + + :returns: A set of nodes for a particular district (only one side of the cut). + :rtype: Set + """ + nodes = set() + queue = deque([start]) + while queue: + next_node = queue.pop() + if next_node not in nodes: + nodes.add(next_node) + if next_node in succ: + for c in succ[next_node]: + if c not in nodes: + queue.append(c) + return nodes + + def find_balanced_edge_cuts_memoization( h: PopulatedGraph, choice: Callable = random.choice ) -> List[Cut]: @@ -267,47 +339,29 @@ def find_balanced_edge_cuts_memoization( pred = predecessors(h.graph, root) succ = successors(h.graph, root) total_pop = h.tot_pop - subtree_pops: Dict[Any, Union[int, float]] = {} - stack = deque(n for n in succ[root]) - while stack: - next_node = stack.pop() - if next_node not in subtree_pops: - if next_node in succ: - children = succ[next_node] - if all(c in subtree_pops for c in children): - subtree_pops[next_node] = sum(subtree_pops[c] for c in children) - subtree_pops[next_node] += h.population[next_node] - else: - stack.append(next_node) - for c in children: - if c not in subtree_pops: - stack.append(c) - else: - subtree_pops[next_node] = h.population[next_node] + + subtree_pops = _calc_pops(succ, root, h) cuts = [] for node, tree_pop in subtree_pops.items(): - - def part_nodes(start): - nodes = set() - queue = deque([start]) - while queue: - next_node = queue.pop() - if next_node not in nodes: - nodes.add(next_node) - if next_node in succ: - for c in succ[next_node]: - if c not in nodes: - queue.append(c) - return nodes - if abs(tree_pop - h.ideal_pop) <= h.ideal_pop * h.epsilon: - cuts.append(Cut(edge=(node, pred[node]), subset=part_nodes(node))) + e = (node, pred[node]) + wt = random.random() + cuts.append( + Cut( + edge=e, + weight=h.graph.edges[e].get("random_weight", wt), + subset=frozenset(_part_nodes(node, succ)) + ) + ) elif abs((total_pop - tree_pop) - h.ideal_pop) <= h.ideal_pop * h.epsilon: + e = (node, pred[node]) + wt = random.random() cuts.append( Cut( - edge=(node, pred[node]), - subset=set(h.graph.nodes) - part_nodes(node), + edge=e, + weight=h.graph.edges[e].get("random_weight", wt), + subset=frozenset(set(h.graph.nodes) - _part_nodes(node, succ)), ) ) return cuts @@ -332,6 +386,146 @@ class ReselectException(Exception): pass +def _max_weight_choice( + cut_edge_list: List[Cut] +) -> Cut: + """ + Each Cut object in the list is assigned a random weight. + This random weight is either assigned during the call to + the minimum spanning tree algorithm (Kruskal's) algorithm + or it is generated during the selection of the balanced edges + (cf. :meth:`find_balanced_edge_cuts_memoization` and + :meth:`find_balanced_edge_cuts_contraction`). + This function returns the cut with the highest weight. + + In the case where a region aware chain is run, this will + preferentially select for cuts that span different regions, rather + than cuts that are interior to that region (the likelihood of this + is generally controlled by the ``region_surcharge`` parameter). + + In any case where the surcharges are either not set or zero, + this is effectively the same as calling random.choice() on the + list of cuts. Under the above conditions, all of the weights + on the cuts are randomly generated on the interval [0,1], and + there is no outside force that might make the weight assigned + to a particular type of cut higher than another. + + :param cut_edge_list: A list of Cut objects. Each object has an + edge, a weight, and a subset attribute. + :type cut_edge_list: List[Cut] + + :returns: The cut with the highest random weight. + :rtype: Cut + """ + + # Just in case, default to random choice + if not isinstance(cut_edge_list[0], Cut) or cut_edge_list[0].weight is None: + return random.choice(cut_edge_list) + + return max(cut_edge_list, key=lambda cut: cut.weight) + + +def _power_set_sorted_by_size_then_sum(d): + power_set = [ + s + for i in range(1, len(d) + 1) + for s in itertools.combinations(d.keys(), i) + ] + + # Sort the subsets in descending order based on + # the sum of their corresponding values in the dictionary + sorted_power_set = sorted( + power_set, + key=lambda s: (len(s), sum(d[i] for i in s)), + reverse=True + ) + + return sorted_power_set + + +# Note that the populated graph and the region surcharge are passed +# by object reference. This means that a copy is not made since we +# are not modifying the object in the function, and the speed of +# this randomized selection will not suffer for it. +def _region_preferred_max_weight_choice( + populated_graph: PopulatedGraph, + region_surcharge: Dict, + cut_edge_list: List[Cut] +) -> Cut: + """ + This function is used in the case of a region-aware chain. It + is similar to the as :meth:`_max_weight_choice` function except + that it will preferentially select one of the cuts that has the + highest surcharge. So, if we have a weight dict of the form + ``{region1: wt1, region2: wt2}`` , then this function first looks + for a cut that is a cut edge for both ``region1`` and ``region2`` + and then selects the one with the highest weight. If no such cut + exists, then it will then look for a cut that is a cut edge for the + region with the highest surcharge (presumably the region that we care + more about not splitting). + + In the case of 3 regions, it will first look for a cut that is a + cut edge for all 3 regions, then for a cut that is a cut edge for + 2 regions sorted by the highest total surcharge, and then for a cut + that is a cut edge for the region with the highest surcharge. + + For the case of 4 or more regions, the power set starts to get a bit + large, so we default back to the :meth:`_max_weight_choice` function + and just select the cut with the highest weight, which will still + preferentially select for cuts that span the most regions that we + care about. + + :param populated_graph: The populated graph. + :type populated_graph: PopulatedGraph + :param region_surcharge: A dictionary of surcharges for the spanning + tree algorithm. + :type region_surcharge: Dict + :param cut_edge_list: A list of Cut objects. Each object has an + edge, a weight, and a subset attribute. + :type cut_edge_list: List[Cut] + + :returns: A random Cut from the set of possible Cuts with the highest + surcharge. + :rtype: Cut + """ + if ( + not isinstance(region_surcharge, dict) + or not isinstance(cut_edge_list[0], Cut) + or cut_edge_list[0].weight is None + ): + return random.choice(cut_edge_list) + + # Early return for simple cases + if len(region_surcharge) < 1 or len(region_surcharge) > 3: + return _max_weight_choice(cut_edge_list) + + # Prepare data for efficient access + edge_region_info = { + cut: { + key: (populated_graph.graph.nodes[cut.edge[0]].get(key), + populated_graph.graph.nodes[cut.edge[1]].get(key)) + for key in region_surcharge + } + for cut in cut_edge_list + } + + # Generate power set sorted by surcharge, then filter cuts based + # on region matching + power_set = _power_set_sorted_by_size_then_sum(region_surcharge) + for region_combination in power_set: + suitable_cuts = [ + cut for cut in cut_edge_list + if all( + edge_region_info[cut][key][0] != edge_region_info[cut][key][1] + for key in region_combination + ) + ] + if suitable_cuts: + return _max_weight_choice(suitable_cuts) + + return _max_weight_choice(cut_edge_list) + + def bipartition_tree( graph: nx.Graph, pop_col: str, @@ -340,11 +534,13 @@ def bipartition_tree( node_repeats: int = 1, spanning_tree: Optional[nx.Graph] = None, spanning_tree_fn: Callable = random_spanning_tree, - weight_dict: Optional[Dict] = None, + region_surcharge: Optional[Dict] = None, balance_edge_fn: Callable = find_balanced_edge_cuts_memoization, choice: Callable = random.choice, max_attempts: Optional[int] = 100000, + warn_attempts: int = 1000, allow_pair_reselection: bool = False, + cut_choice: Callable = _region_preferred_max_weight_choice ) -> Set: """ This function finds a balanced 2 partition of a graph by drawing a @@ -373,62 +569,80 @@ def bipartition_tree( :param spanning_tree_fn: The random spanning tree algorithm to use if a spanning tree is not provided. Defaults to :func:`random_spanning_tree`. :type spanning_tree_fn: Callable, optional - :param weight_dict: A dictionary of weights for the spanning tree algorithm. + :param region_surcharge: A dictionary of surcharges for the spanning tree algorithm. Defaults to None. - :type weight_dict: Optional[Dict], optional + :type region_surcharge: Optional[Dict], optional :param balance_edge_fn: The function to find balanced edge cuts. Defaults to :func:`find_balanced_edge_cuts_memoization`. :type balance_edge_fn: Callable, optional - :param choice: The function to make a random choice. Can be substituted for testing. - Defaults to :func:`random.choice`. + :param choice: The function to make a random choice of root node for the population + tree. Passed to ``balance_edge_fn``. Can be substituted for testing. + Defaults to :func:`random.random()`. :type choice: Callable, optional :param max_attempts: The maximum number of attempts that should be made to bipartition. Defaults to 10000. :type max_attempts: Optional[int], optional + :param warn_attempts: The number of attempts after which a warning is issued if a balanced + cut cannot be found. Defaults to 1000. + :type warn_attempts: int, optional :param allow_pair_reselection: Whether we would like to return an error to the calling function to ask it to reselect the pair of nodes to try and recombine. Defaults to False. :type allow_pair_reselection: bool, optional + :param cut_choice: The function used to select the cut edge from the list of possible + balanced cuts. Defaults to :meth:`_region_preferred_max_weight_choice` . + :type cut_choice: Callable, optional :returns: A subset of nodes of ``graph`` (whose induced subgraph is connected). The other part of the partition is the complement of this subset. :rtype: Set - :raises BipartitionWarning: If a possible cut cannot be found after 50 attempts. + :raises BipartitionWarning: If a possible cut cannot be found after 1000 attempts. :raises RuntimeError: If a possible cut cannot be found after the maximum number of attempts given by ``max_attempts``. """ - # Try to add the region-aware in if the spanning_tree_fn accepts a weight dictionary - if "weight_dict" in signature(spanning_tree_fn).parameters: - spanning_tree_fn = partial(spanning_tree_fn, weight_dict=weight_dict) + # Try to add the region-aware in if the spanning_tree_fn accepts a surcharge dictionary + if "region_surcharge" in signature(spanning_tree_fn).parameters: + spanning_tree_fn = partial(spanning_tree_fn, region_surcharge=region_surcharge) populations = {node: graph.nodes[node][pop_col] for node in graph.node_indices} - possible_cuts = [] + possible_cuts: List[Cut] = [] if spanning_tree is None: spanning_tree = spanning_tree_fn(graph) restarts = 0 attempts = 0 + while max_attempts is None or attempts < max_attempts: if restarts == node_repeats: spanning_tree = spanning_tree_fn(graph) restarts = 0 h = PopulatedGraph(spanning_tree, populations, pop_target, epsilon) + + is_region_cut = ( + "region_surcharge" in signature(cut_choice).parameters + and "populated_graph" in signature(cut_choice).parameters + ) + + # This returns a list of Cut objects with attributes edge and subset possible_cuts = balance_edge_fn(h, choice=choice) if len(possible_cuts) != 0: - return choice(possible_cuts).subset + if is_region_cut: + return cut_choice(h, region_surcharge, possible_cuts).subset + + return cut_choice(possible_cuts).subset restarts += 1 attempts += 1 # Don't forget to change the documentation if you change this number - if attempts == 50 and not allow_pair_reselection: + if attempts == warn_attempts and not allow_pair_reselection: warnings.warn( - "\nFailed to find a balanced cut after 50 attempts.\n" + f"\nFailed to find a balanced cut after {warn_attempts} attempts.\n" "If possible, consider enabling pair reselection within your\n" "MarkovChain proposal method to allow the algorithm to select\n" - "a different pair of districts to try and recombine.", + "a different pair of districts for recombination.", BipartitionWarning, ) @@ -499,9 +713,9 @@ def _bipartition_tree_random_all( if spanning_tree is None: spanning_tree = spanning_tree_fn(graph) - repeat = True restarts = 0 attempts = 0 + while max_attempts is None or attempts < max_attempts: if restarts == node_repeats: spanning_tree = spanning_tree_fn(graph) @@ -509,9 +723,7 @@ def _bipartition_tree_random_all( h = PopulatedGraph(spanning_tree, populations, pop_target, epsilon) possible_cuts = balance_edge_fn(h, choice=choice) - repeat = repeat_until_valid - - if not (repeat and len(possible_cuts) == 0): + if not (repeat_until_valid and len(possible_cuts) == 0): return possible_cuts restarts += 1 diff --git a/setup.py b/setup.py index b14321cb..45bf7bab 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Operating System :: OS Independent", "License :: OSI Approved :: BSD License", ], diff --git a/tests/test_region_aware.py b/tests/test_region_aware.py index 2690bc37..bcbe1e0f 100644 --- a/tests/test_region_aware.py +++ b/tests/test_region_aware.py @@ -1,23 +1,35 @@ import random + random.seed(2018) import pytest from functools import partial from concurrent.futures import ProcessPoolExecutor from gerrychain import ( - MarkovChain, Partition, accept, - constraints, proposals, updaters, Graph, tree) + MarkovChain, + Partition, + accept, + constraints, + proposals, + updaters, + Graph, + tree, +) from gerrychain.tree import ReselectException, BipartitionWarning def run_chain_single( - seed, - category, - steps, - weight, - max_attempts=100000, - reselect=False + seed, category, steps, surcharge, max_attempts=100000, reselect=False ): - from gerrychain import MarkovChain, Partition, accept, constraints, proposals, updaters, Graph, tree + from gerrychain import ( + MarkovChain, + Partition, + accept, + constraints, + proposals, + updaters, + Graph, + tree, + ) from gerrychain.tree import ReselectException from functools import partial import random @@ -25,58 +37,66 @@ def run_chain_single( graph = Graph.from_json("tests/graphs_for_test/8x8_with_muni.json") population_col = "TOTPOP" - updaters = {"population": updaters.Tally(population_col, alias="population"), - "cut_edges": updaters.cut_edges, - f"{category}_splits": updaters.tally_region_splits([category]), - } - initial_partition = Partition(graph, assignment="district", updaters=updaters) + updaters = { + "population": updaters.Tally(population_col, alias="population"), + "cut_edges": updaters.cut_edges, + f"{category}_splits": updaters.tally_region_splits([category]), + } + initial_partition = Partition(graph, assignment="district", updaters=updaters) ideal_pop = sum(initial_partition["population"].values()) / len(initial_partition) - weights = {category: 0.8} + surcharges = {category: surcharge} num_steps = steps epsilon = 0.01 random.seed(seed) - weighted_proposal = partial(proposals.recom, - pop_col=population_col, - pop_target=ideal_pop, - epsilon=epsilon, - weight_dict=weights, - node_repeats=10, - method=partial(tree.bipartition_tree, - max_attempts=max_attempts, - allow_pair_reselection=reselect)) - - weighted_chain = MarkovChain(proposal=weighted_proposal, - constraints=[constraints.contiguous], - accept=accept.always_accept, - initial_state=initial_partition, - total_steps=num_steps) - + surcharged_proposal = partial( + proposals.recom, + pop_col=population_col, + pop_target=ideal_pop, + epsilon=epsilon, + region_surcharge=surcharges, + node_repeats=10, + method=partial( + tree.bipartition_tree, + max_attempts=max_attempts, + allow_pair_reselection=reselect, + ), + ) + + surcharged_chain = MarkovChain( + proposal=surcharged_proposal, + constraints=[constraints.contiguous], + accept=accept.always_accept, + initial_state=initial_partition, + total_steps=num_steps, + ) + n_splits = -1 - for item in weighted_chain: + for item in surcharged_chain: n_splits = item[f"{category}_splits"][category] - + return n_splits + @pytest.mark.slow def test_region_aware_muni(): n_samples = 30 region = "muni" n_regions = 16 - + random.seed(2018) + with ProcessPoolExecutor() as executor: - results = executor.map(partial(run_chain_single, - category=region, - steps=500, - weight=0.5), - range(n_samples)) + results = executor.map( + partial(run_chain_single, category=region, steps=5000, surcharge=0.5), + range(n_samples), + ) tot_splits = sum(results) - + random.seed(2018) # Check if splits less than 5% of the time on average - assert (float(tot_splits) / (n_samples*n_regions)) < 0.05 + assert (float(tot_splits) / (n_samples * n_regions)) < 0.05 def test_region_aware_muni_errors(): @@ -84,33 +104,14 @@ def test_region_aware_muni_errors(): with pytest.raises(RuntimeError) as exec_info: # Random seed 0 should fail here - run_chain_single(seed=0, - category=region, - steps=10000, - max_attempts=10, - weight=2.0) + run_chain_single( + seed=0, category=region, steps=10000, max_attempts=1, surcharge=2.0 + ) random.seed(2018) - assert "Could not find a possible cut after 10 attempts" in str(exec_info.value) + assert "Could not find a possible cut after 1 attempts" in str(exec_info.value) -def test_region_aware_muni_warning(): - n_samples = 1 - region = "muni" - - with pytest.warns(UserWarning) as record: - # Random seed 2 should succeed, but drawing the - # tree is hard, so we should get a warning - run_chain_single(seed=2, - category=region, - steps=500, - weight=1.0) - - random.seed(2018) - - assert record[0].category == BipartitionWarning - assert "Failed to find a balanced cut after 50 attempts." in str(record[0].message) - @pytest.mark.slow def test_region_aware_muni_reselect(): n_samples = 30 @@ -118,18 +119,23 @@ def test_region_aware_muni_reselect(): n_regions = 16 with ProcessPoolExecutor() as executor: - results = executor.map(partial(run_chain_single, - category=region, - steps=500, - weight=1.0, - reselect=True), - range(n_samples)) + results = executor.map( + partial( + run_chain_single, + category=region, + steps=500, + surcharge=1.0, + reselect=True, + max_attempts=100, + ), + range(n_samples), + ) tot_splits = sum(results) random.seed(2018) # Check if splits less than 5% of the time on average - assert (float(tot_splits) / (n_samples*n_regions)) < 0.05 + assert (float(tot_splits) / (n_samples * n_regions)) < 0.05 @pytest.mark.slow @@ -139,91 +145,125 @@ def test_region_aware_county(): n_regions = 8 with ProcessPoolExecutor() as executor: - results = executor.map(partial(run_chain_single, - category=region, - steps=5000, - weight=2.0), - range(n_samples)) + results = executor.map( + partial(run_chain_single, category=region, steps=5000, surcharge=0.8), + range(n_samples), + ) tot_splits = sum(results) - random.seed(2018) + random.seed(2018) # Check if splits less than 5% of the time on average - assert (float(tot_splits) / (n_samples*n_regions)) < 0.05 - - + assert (float(tot_splits) / (n_samples * n_regions)) < 0.05 + + def straddled_regions(partition, reg_attr, all_reg_names): """Returns the total number of district that straddle two regions in the partition.""" split = {name: 0 for name in all_reg_names} - - for node1, node2 in (set(partition.graph.edges() - partition["cut_edges"])): + + for node1, node2 in set(partition.graph.edges() - partition["cut_edges"]): split[partition.graph.nodes[node1][reg_attr]] += 1 split[partition.graph.nodes[node2][reg_attr]] += 1 return sum(1 for value in split.values() if value > 0) -def run_chain_dual(seed, steps): - from gerrychain import MarkovChain, Partition, accept, constraints, proposals, updaters, Graph, tree + +def run_chain_dual( + seed, steps, surcharges={"muni": 0.5, "county": 0.5}, warn_attempts=1000 +): + from gerrychain import ( + MarkovChain, + Partition, + accept, + constraints, + proposals, + updaters, + Graph, + tree, + ) from functools import partial import random graph = Graph.from_json("tests/graphs_for_test/8x8_with_muni.json") population_col = "TOTPOP" - muni_names= [str(i) for i in range(1,17)] - county_names = [str(i) for i in range(1,5)] + muni_names = [str(i) for i in range(1, 17)] + county_names = [str(i) for i in range(1, 5)] updaters = { "population": updaters.Tally(population_col, alias="population"), "cut_edges": updaters.cut_edges, "splits": updaters.tally_region_splits(["muni", "county"]), } - initial_partition = Partition(graph, assignment="district", updaters=updaters) + initial_partition = Partition(graph, assignment="district", updaters=updaters) ideal_pop = sum(initial_partition["population"].values()) / len(initial_partition) - weights = {"muni": 0.5, "county": 0.5} num_steps = steps epsilon = 0.01 random.seed(seed) - weighted_proposal = partial(proposals.recom, - pop_col=population_col, - pop_target=ideal_pop, - epsilon=epsilon, - weight_dict=weights, - node_repeats=10, - method=partial(tree.bipartition_tree, max_attempts=10000)) - - weighted_chain = MarkovChain(proposal=weighted_proposal, - constraints=[constraints.contiguous], - accept=accept.always_accept, - initial_state=initial_partition, - total_steps=num_steps) - + surcharged_proposal = partial( + proposals.recom, + pop_col=population_col, + pop_target=ideal_pop, + epsilon=epsilon, + region_surcharge=surcharges, + node_repeats=10, + method=partial( + tree.bipartition_tree, + max_attempts=10000, + warn_attempts=warn_attempts, + ), + ) + + surcharged_chain = MarkovChain( + proposal=surcharged_proposal, + constraints=[constraints.contiguous], + accept=accept.always_accept, + initial_state=initial_partition, + total_steps=num_steps, + ) + n_muni_splits = -1 n_county_splits = -1 - for item in weighted_chain: + for item in surcharged_chain: n_muni_splits = item["splits"]["muni"] n_county_splits = item["splits"]["county"] - + return (n_muni_splits, n_county_splits) + +def test_region_aware_muni_warning(): + with pytest.warns(UserWarning) as record: + # Random seed 2 should succeed, but drawing the + # tree is hard, so we should get a warning + run_chain_dual( + seed=2, + steps=1000, + surcharges={"muni": 2.0, "county": 2.0}, + warn_attempts=2, + ) + + random.seed(2018) + + assert record[0].category == BipartitionWarning + assert "Failed to find a balanced cut after 2 attempts." in str(record[0].message) + + @pytest.mark.slow def test_region_aware_dual(): n_samples = 30 n_munis = 16 - n_counties = 4 - + n_counties = 4 + with ProcessPoolExecutor() as executor: - results = executor.map(partial(run_chain_dual, - steps=10000), - range(n_samples)) + results = executor.map(partial(run_chain_dual, steps=10000), range(n_samples)) tot_muni_splits = sum([item[0] for item in results]) tot_county_splits = sum([item[1] for item in results]) - - random.seed(2018) + + random.seed(2018) # Check if splits less than 5% of the time on average - assert (float(tot_muni_splits) / (n_samples*n_munis)) < 0.05 - assert (float(tot_county_splits) / (n_samples*n_counties)) < 0.05 \ No newline at end of file + assert (float(tot_muni_splits) / (n_samples * n_munis)) < 0.05 + assert (float(tot_county_splits) / (n_samples * n_counties)) < 0.05 diff --git a/tests/test_reproducibility.py b/tests/test_reproducibility.py index 46d0eae5..755d499b 100644 --- a/tests/test_reproducibility.py +++ b/tests/test_reproducibility.py @@ -112,5 +112,5 @@ def test_pa_freeze(): # This needs to be changed every time we change the # tests around - assert hashlib.sha256(result.encode()).hexdigest() == "9f811f294e4fdcd805a9bcbe65e0a32634b8732ae2aafac92bb946f0ea0a61f4" + assert hashlib.sha256(result.encode()).hexdigest() == "7f355cd0f7c235f4d285db1c7593ba0d4a5558c404b70521c9837125df418384" \ No newline at end of file diff --git a/tests/test_tree.py b/tests/test_tree.py index 5ea3f65f..e419a3ca 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -53,7 +53,7 @@ def twelve_by_twelve_with_pop(): def test_bipartition_tree_returns_a_subset_of_nodes(graph_with_pop): ideal_pop = sum(graph_with_pop.nodes[node]["pop"] for node in graph_with_pop) / 2 result = bipartition_tree(graph_with_pop, "pop", ideal_pop, 0.25, 10) - assert isinstance(result, set) + assert isinstance(result, frozenset) assert all(node in graph_with_pop.nodes for node in result) @@ -240,7 +240,7 @@ def test_prime_bound(): def test_bipartition_tree_random_returns_a_subset_of_nodes(graph_with_pop): ideal_pop = sum(graph_with_pop.nodes[node]["pop"] for node in graph_with_pop) / 2 result = bipartition_tree_random(graph_with_pop, "pop", ideal_pop, 0.25, 10) - assert isinstance(result, set) + assert isinstance(result, frozenset) assert all(node in graph_with_pop.nodes for node in result) diff --git a/versioneer.py b/versioneer.py index 64fea1c8..1e3753e6 100644 --- a/versioneer.py +++ b/versioneer.py @@ -1,5 +1,5 @@ -# Version: 0.18 +# Version: 0.29 """The Versioneer - like a rocketeer, but for versions. @@ -7,18 +7,14 @@ ============== * like a rocketeer, but for versions! -* https://github.com/warner/python-versioneer +* https://github.com/python-versioneer/python-versioneer * Brian Warner -* License: Public Domain -* Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, and pypy -* [![Latest Version] -(https://pypip.in/version/versioneer/badge.svg?style=flat) -](https://pypi.python.org/pypi/versioneer/) -* [![Build Status] -(https://travis-ci.org/warner/python-versioneer.png?branch=master) -](https://travis-ci.org/warner/python-versioneer) - -This is a tool for managing a recorded version number in distutils-based +* License: Public Domain (Unlicense) +* Compatible with: Python 3.7, 3.8, 3.9, 3.10, 3.11 and pypy3 +* [![Latest Version][pypi-image]][pypi-url] +* [![Build Status][travis-image]][travis-url] + +This is a tool for managing a recorded version number in setuptools-based python projects. The goal is to remove the tedious and error-prone "update the embedded version string" step from your release process. Making a new release should be as easy as recording a new tag in your version-control @@ -27,9 +23,38 @@ ## Quick Install -* `pip install versioneer` to somewhere to your $PATH -* add a `[versioneer]` section to your setup.cfg (see below) -* run `versioneer install` in your source tree, commit the results +Versioneer provides two installation modes. The "classic" vendored mode installs +a copy of versioneer into your repository. The experimental build-time dependency mode +is intended to allow you to skip this step and simplify the process of upgrading. + +### Vendored mode + +* `pip install versioneer` to somewhere in your $PATH + * A [conda-forge recipe](https://github.com/conda-forge/versioneer-feedstock) is + available, so you can also use `conda install -c conda-forge versioneer` +* add a `[tool.versioneer]` section to your `pyproject.toml` or a + `[versioneer]` section to your `setup.cfg` (see [Install](INSTALL.md)) + * Note that you will need to add `tomli; python_version < "3.11"` to your + build-time dependencies if you use `pyproject.toml` +* run `versioneer install --vendor` in your source tree, commit the results +* verify version information with `python setup.py version` + +### Build-time dependency mode + +* `pip install versioneer` to somewhere in your $PATH + * A [conda-forge recipe](https://github.com/conda-forge/versioneer-feedstock) is + available, so you can also use `conda install -c conda-forge versioneer` +* add a `[tool.versioneer]` section to your `pyproject.toml` or a + `[versioneer]` section to your `setup.cfg` (see [Install](INSTALL.md)) +* add `versioneer` (with `[toml]` extra, if configuring in `pyproject.toml`) + to the `requires` key of the `build-system` table in `pyproject.toml`: + ```toml + [build-system] + requires = ["setuptools", "versioneer[toml]"] + build-backend = "setuptools.build_meta" + ``` +* run `versioneer install --no-vendor` in your source tree, commit the results +* verify version information with `python setup.py version` ## Version Identifiers @@ -61,7 +86,7 @@ for example `git describe --tags --dirty --always` reports things like "0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the 0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has -uncommitted changes. +uncommitted changes). The version identifier is used for multiple purposes: @@ -166,7 +191,7 @@ Some situations are known to cause problems for Versioneer. This details the most significant ones. More can be found on Github -[issues page](https://github.com/warner/python-versioneer/issues). +[issues page](https://github.com/python-versioneer/python-versioneer/issues). ### Subprojects @@ -180,7 +205,7 @@ `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI distributions (and upload multiple independently-installable tarballs). * Source trees whose main purpose is to contain a C library, but which also - provide bindings to Python (and perhaps other langauges) in subdirectories. + provide bindings to Python (and perhaps other languages) in subdirectories. Versioneer will look for `.git` in parent directories, and most operations should get the right version string. However `pip` and `setuptools` have bugs @@ -194,9 +219,9 @@ Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in some later version. -[Bug #38](https://github.com/warner/python-versioneer/issues/38) is tracking +[Bug #38](https://github.com/python-versioneer/python-versioneer/issues/38) is tracking this issue. The discussion in -[PR #61](https://github.com/warner/python-versioneer/pull/61) describes the +[PR #61](https://github.com/python-versioneer/python-versioneer/pull/61) describes the issue from the Versioneer side in more detail. [pip PR#3176](https://github.com/pypa/pip/pull/3176) and [pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve @@ -224,31 +249,20 @@ cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into a different virtualenv), so this can be surprising. -[Bug #83](https://github.com/warner/python-versioneer/issues/83) describes +[Bug #83](https://github.com/python-versioneer/python-versioneer/issues/83) describes this one, but upgrading to a newer version of setuptools should probably resolve it. -### Unicode version strings - -While Versioneer works (and is continually tested) with both Python 2 and -Python 3, it is not entirely consistent with bytes-vs-unicode distinctions. -Newer releases probably generate unicode version strings on py2. It's not -clear that this is wrong, but it may be surprising for applications when then -write these strings to a network connection or include them in bytes-oriented -APIs like cryptographic checksums. - -[Bug #71](https://github.com/warner/python-versioneer/issues/71) investigates -this question. - ## Updating Versioneer To upgrade your project to a new release of Versioneer, do the following: * install the new Versioneer (`pip install -U versioneer` or equivalent) -* edit `setup.cfg`, if necessary, to include any new configuration settings - indicated by the release notes. See [UPGRADING](./UPGRADING.md) for details. -* re-run `versioneer install` in your source tree, to replace +* edit `setup.cfg` and `pyproject.toml`, if necessary, + to include any new configuration settings indicated by the release notes. + See [UPGRADING](./UPGRADING.md) for details. +* re-run `versioneer install --[no-]vendor` in your source tree, to replace `SRC/_version.py` * commit any changed files @@ -265,35 +279,70 @@ direction and include code from all supported VCS systems, reducing the number of intermediate scripts. +## Similar projects + +* [setuptools_scm](https://github.com/pypa/setuptools_scm/) - a non-vendored build-time + dependency +* [minver](https://github.com/jbweston/miniver) - a lightweight reimplementation of + versioneer +* [versioningit](https://github.com/jwodder/versioningit) - a PEP 518-based setuptools + plugin ## License To make Versioneer easier to embed, all its code is dedicated to the public domain. The `_version.py` that it creates is also in the public domain. -Specifically, both are released under the Creative Commons "Public Domain -Dedication" license (CC0-1.0), as described in -https://creativecommons.org/publicdomain/zero/1.0/ . +Specifically, both are released under the "Unlicense", as described in +https://unlicense.org/. + +[pypi-image]: https://img.shields.io/pypi/v/versioneer.svg +[pypi-url]: https://pypi.python.org/pypi/versioneer/ +[travis-image]: +https://img.shields.io/travis/com/python-versioneer/python-versioneer.svg +[travis-url]: https://travis-ci.com/github/python-versioneer/python-versioneer """ +# pylint:disable=invalid-name,import-outside-toplevel,missing-function-docstring +# pylint:disable=missing-class-docstring,too-many-branches,too-many-statements +# pylint:disable=raise-missing-from,too-many-lines,too-many-locals,import-error +# pylint:disable=too-few-public-methods,redefined-outer-name,consider-using-with +# pylint:disable=attribute-defined-outside-init,too-many-arguments -from __future__ import print_function -try: - import configparser -except ImportError: - import ConfigParser as configparser +import configparser import errno import json import os import re import subprocess import sys +from pathlib import Path +from typing import Any, Callable, cast, Dict, List, Optional, Tuple, Union +from typing import NoReturn +import functools + +have_tomllib = True +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomli as tomllib + except ImportError: + have_tomllib = False class VersioneerConfig: """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + versionfile_source: str + versionfile_build: Optional[str] + parentdir_prefix: Optional[str] + verbose: Optional[bool] + -def get_root(): +def get_root() -> str: """Get the project root directory. We require that all commands are run from the project root, i.e. the @@ -301,13 +350,23 @@ def get_root(): """ root = os.path.realpath(os.path.abspath(os.getcwd())) setup_py = os.path.join(root, "setup.py") + pyproject_toml = os.path.join(root, "pyproject.toml") versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): + if not ( + os.path.exists(setup_py) + or os.path.exists(pyproject_toml) + or os.path.exists(versioneer_py) + ): # allow 'python path/to/setup.py COMMAND' root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) setup_py = os.path.join(root, "setup.py") + pyproject_toml = os.path.join(root, "pyproject.toml") versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): + if not ( + os.path.exists(setup_py) + or os.path.exists(pyproject_toml) + or os.path.exists(versioneer_py) + ): err = ("Versioneer was unable to run the project root directory. " "Versioneer requires setup.py to be executed from " "its immediate directory (like 'python setup.py COMMAND'), " @@ -321,43 +380,62 @@ def get_root(): # module-import table will cache the first one. So we can't use # os.path.dirname(__file__), as that will find whichever # versioneer.py was first imported, even in later projects. - me = os.path.realpath(os.path.abspath(__file__)) - me_dir = os.path.normcase(os.path.splitext(me)[0]) + my_path = os.path.realpath(os.path.abspath(__file__)) + me_dir = os.path.normcase(os.path.splitext(my_path)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) - if me_dir != vsr_dir: + if me_dir != vsr_dir and "VERSIONEER_PEP518" not in globals(): print("Warning: build in %s is using versioneer.py from %s" - % (os.path.dirname(me), versioneer_py)) + % (os.path.dirname(my_path), versioneer_py)) except NameError: pass return root -def get_config_from_root(root): +def get_config_from_root(root: str) -> VersioneerConfig: """Read the project setup.cfg file to determine Versioneer config.""" - # This might raise EnvironmentError (if setup.cfg is missing), or + # This might raise OSError (if setup.cfg is missing), or # configparser.NoSectionError (if it lacks a [versioneer] section), or # configparser.NoOptionError (if it lacks "VCS="). See the docstring at # the top of versioneer.py for instructions on writing your setup.cfg . - setup_cfg = os.path.join(root, "setup.cfg") - parser = configparser.SafeConfigParser() - with open(setup_cfg, "r") as f: - parser.readfp(f) - VCS = parser.get("versioneer", "VCS") # mandatory - - def get(parser, name): - if parser.has_option("versioneer", name): - return parser.get("versioneer", name) - return None + root_pth = Path(root) + pyproject_toml = root_pth / "pyproject.toml" + setup_cfg = root_pth / "setup.cfg" + section: Union[Dict[str, Any], configparser.SectionProxy, None] = None + if pyproject_toml.exists() and have_tomllib: + try: + with open(pyproject_toml, 'rb') as fobj: + pp = tomllib.load(fobj) + section = pp['tool']['versioneer'] + except (tomllib.TOMLDecodeError, KeyError) as e: + print(f"Failed to load config from {pyproject_toml}: {e}") + print("Try to load it from setup.cfg") + if not section: + parser = configparser.ConfigParser() + with open(setup_cfg) as cfg_file: + parser.read_file(cfg_file) + parser.get("versioneer", "VCS") # raise error if missing + + section = parser["versioneer"] + + # `cast`` really shouldn't be used, but its simplest for the + # common VersioneerConfig users at the moment. We verify against + # `None` values elsewhere where it matters + cfg = VersioneerConfig() - cfg.VCS = VCS - cfg.style = get(parser, "style") or "" - cfg.versionfile_source = get(parser, "versionfile_source") - cfg.versionfile_build = get(parser, "versionfile_build") - cfg.tag_prefix = get(parser, "tag_prefix") - if cfg.tag_prefix in ("''", '""'): + cfg.VCS = section['VCS'] + cfg.style = section.get("style", "") + cfg.versionfile_source = cast(str, section.get("versionfile_source")) + cfg.versionfile_build = section.get("versionfile_build") + cfg.tag_prefix = cast(str, section.get("tag_prefix")) + if cfg.tag_prefix in ("''", '""', None): cfg.tag_prefix = "" - cfg.parentdir_prefix = get(parser, "parentdir_prefix") - cfg.verbose = get(parser, "verbose") + cfg.parentdir_prefix = section.get("parentdir_prefix") + if isinstance(section, configparser.SectionProxy): + # Make sure configparser translates to bool + cfg.verbose = section.getboolean("verbose") + else: + cfg.verbose = section.get("verbose") + return cfg @@ -366,37 +444,48 @@ class NotThisMethod(Exception): # these dictionaries contain VCS-specific tools -LONG_VERSION_PY = {} -HANDLERS = {} +LONG_VERSION_PY: Dict[str, str] = {} +HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - def decorate(f): +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator + """Create decorator to mark a method as the handler of a VCS.""" + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" - if vcs not in HANDLERS: - HANDLERS[vcs] = {} - HANDLERS[vcs][method] = f + HANDLERS.setdefault(vcs, {})[method] = f return f return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) - p = None - for c in commands: + process = None + + popen_kwargs: Dict[str, Any] = {} + if sys.platform == "win32": + # This hides the console window if pythonw.exe is used + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + popen_kwargs["startupinfo"] = startupinfo + + for command in commands: try: - dispcmd = str([c] + args) + dispcmd = str([command] + args) # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) + process = subprocess.Popen([command] + args, cwd=cwd, env=env, + stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr + else None), **popen_kwargs) break - except EnvironmentError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: @@ -407,26 +496,25 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, if verbose: print("unable to find command, tried %s" % (commands,)) return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: + stdout = process.communicate()[0].strip().decode() + if process.returncode != 0: if verbose: print("unable to run %s (error)" % dispcmd) print("stdout was %s" % stdout) - return None, p.returncode - return stdout, p.returncode + return None, process.returncode + return stdout, process.returncode -LONG_VERSION_PY['git'] = ''' +LONG_VERSION_PY['git'] = r''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build # directories (produced by setup.py build) will contain a much shorter file # that just contains the computed version number. -# This file is released into the public domain. Generated by -# versioneer-0.18 (https://github.com/warner/python-versioneer) +# This file is released into the public domain. +# Generated by versioneer-0.29 +# https://github.com/python-versioneer/python-versioneer """Git implementation of _version.py.""" @@ -435,9 +523,11 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, import re import subprocess import sys +from typing import Any, Callable, Dict, List, Optional, Tuple +import functools -def get_keywords(): +def get_keywords() -> Dict[str, str]: """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must @@ -453,8 +543,15 @@ def get_keywords(): class VersioneerConfig: """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + parentdir_prefix: str + versionfile_source: str + verbose: bool + -def get_config(): +def get_config() -> VersioneerConfig: """Create, populate and return the VersioneerConfig() object.""" # these strings are filled in when 'setup.py versioneer' creates # _version.py @@ -472,13 +569,13 @@ class NotThisMethod(Exception): """Exception raised if a method is not valid for the current scenario.""" -LONG_VERSION_PY = {} -HANDLERS = {} +LONG_VERSION_PY: Dict[str, str] = {} +HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - def decorate(f): +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator + """Create decorator to mark a method as the handler of a VCS.""" + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} @@ -487,22 +584,35 @@ def decorate(f): return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) - p = None - for c in commands: + process = None + + popen_kwargs: Dict[str, Any] = {} + if sys.platform == "win32": + # This hides the console window if pythonw.exe is used + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + popen_kwargs["startupinfo"] = startupinfo + + for command in commands: try: - dispcmd = str([c] + args) + dispcmd = str([command] + args) # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) + process = subprocess.Popen([command] + args, cwd=cwd, env=env, + stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr + else None), **popen_kwargs) break - except EnvironmentError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: @@ -513,18 +623,20 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, if verbose: print("unable to find command, tried %%s" %% (commands,)) return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: + stdout = process.communicate()[0].strip().decode() + if process.returncode != 0: if verbose: print("unable to run %%s (error)" %% dispcmd) print("stdout was %%s" %% stdout) - return None, p.returncode - return stdout, p.returncode + return None, process.returncode + return stdout, process.returncode -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -533,15 +645,14 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): """ rootdirs = [] - for i in range(3): + for _ in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): return {"version": dirname[len(parentdir_prefix):], "full-revisionid": None, "dirty": False, "error": None, "date": None} - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level + rootdirs.append(root) + root = os.path.dirname(root) # up a level if verbose: print("Tried directories %%s but none started with prefix %%s" %% @@ -550,41 +661,48 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: - f = open(versionfile_abs, "r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() - except EnvironmentError: + with open(versionfile_abs, "r") as fobj: + for line in fobj: + if line.strip().startswith("git_refnames ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["refnames"] = mo.group(1) + if line.strip().startswith("git_full ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["full"] = mo.group(1) + if line.strip().startswith("git_date ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["date"] = mo.group(1) + except OSError: pass return keywords @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") + if "refnames" not in keywords: + raise NotThisMethod("Short version file found") date = keywords.get("date") if date is not None: + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because @@ -597,11 +715,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %%d @@ -610,7 +728,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) + tags = {r for r in refs if re.search(r'\d', r)} if verbose: print("discarding '%%s', no digits" %% ",".join(refs - tags)) if verbose: @@ -619,6 +737,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): r = ref[len(tag_prefix):] + # Filter out refs that exactly match prefix or that don't start + # with a number once the prefix is stripped (mostly a concern + # when prefix is '') + if not re.match(r'\d', r): + continue if verbose: print("picking %%s" %% r) return {"version": r, @@ -634,7 +757,12 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -645,8 +773,15 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=True) + # GIT_DIR can interfere with correct operation of Versioneer. + # It may be intended to be passed to the Versioneer-versioned project, + # but that should not change where we get our version from. + env = os.environ.copy() + env.pop("GIT_DIR", None) + runner = functools.partial(runner, env=env) + + _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, + hide_stderr=not verbose) if rc != 0: if verbose: print("Directory %%s not under git control" %% root) @@ -654,24 +789,57 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", "%%s*" %% tag_prefix], - cwd=root) + describe_out, rc = runner(GITS, [ + "describe", "--tags", "--dirty", "--always", "--long", + "--match", f"{tag_prefix}[[:digit:]]*" + ], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) + full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) if full_out is None: raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None + branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=root) + # --abbrev-ref was added in git-1.6.3 + if rc != 0 or branch_name is None: + raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") + branch_name = branch_name.strip() + + if branch_name == "HEAD": + # If we aren't exactly on a branch, pick a branch which represents + # the current commit. If all else fails, we are on a branchless + # commit. + branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) + # --contains was added in git-1.5.4 + if rc != 0 or branches is None: + raise NotThisMethod("'git branch --contains' returned error") + branches = branches.split("\n") + + # Remove the first line if we're running detached + if "(" in branches[0]: + branches.pop(0) + + # Strip off the leading "* " from the list of branches. + branches = [branch[2:] for branch in branches] + if "master" in branches: + branch_name = "master" + elif not branches: + branch_name = None + else: + # Pick the first branch that is returned. Good or bad. + branch_name = branches[0] + + pieces["branch"] = branch_name + # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] # TAG might have hyphens. git_describe = describe_out @@ -688,7 +856,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # TAG-NUM-gHEX mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) if not mo: - # unparseable. Maybe git-describe is misbehaving? + # unparsable. Maybe git-describe is misbehaving? pieces["error"] = ("unable to parse git-describe output: '%%s'" %% describe_out) return pieces @@ -713,26 +881,27 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], - cwd=root) - pieces["distance"] = int(count_out) # total number of commits + out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) + pieces["distance"] = len(out.split()) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%%ci", "HEAD"], - cwd=root)[0].strip() + date = runner(GITS, ["show", "-s", "--format=%%ci", "HEAD"], cwd=root)[0].strip() + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -757,23 +926,71 @@ def render_pep440(pieces): return rendered -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. +def render_pep440_branch(pieces: Dict[str, Any]) -> str: + """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . + + The ".dev0" means not master branch. Note that .dev0 sorts backwards + (a feature branch will appear "older" than the master branch). Exceptions: - 1: no tags. 0.post.devDISTANCE + 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0" + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+untagged.%%d.g%%s" %% (pieces["distance"], + pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: + """Split pep440 version string at the post-release segment. + + Returns the release segments before the post-release and the + post-release version number (or -1 if no post-release segment is present). + """ + vc = str.split(ver, ".post") + return vc[0], int(vc[1] or 0) if len(vc) == 2 else None + + +def render_pep440_pre(pieces: Dict[str, Any]) -> str: + """TAG[.postN.devDISTANCE] -- No -dirty. + + Exceptions: + 1: no tags. 0.post0.devDISTANCE + """ + if pieces["closest-tag"]: if pieces["distance"]: - rendered += ".post.dev%%d" %% pieces["distance"] + # update the post release segment + tag_version, post_version = pep440_split_post(pieces["closest-tag"]) + rendered = tag_version + if post_version is not None: + rendered += ".post%%d.dev%%d" %% (post_version + 1, pieces["distance"]) + else: + rendered += ".post0.dev%%d" %% (pieces["distance"]) + else: + # no commits, use the tag as the version + rendered = pieces["closest-tag"] else: # exception #1 - rendered = "0.post.dev%%d" %% pieces["distance"] + rendered = "0.post0.dev%%d" %% pieces["distance"] return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -800,12 +1017,41 @@ def render_pep440_post(pieces): return rendered -def render_pep440_old(pieces): +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: + """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . + + The ".dev0" means not master branch. + + Exceptions: + 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%%d" %% pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "g%%s" %% pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0.post%%d" %% pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+g%%s" %% pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. - Eexceptions: + Exceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: @@ -822,7 +1068,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -842,7 +1088,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -862,7 +1108,7 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: return {"version": "unknown", @@ -876,10 +1122,14 @@ def render(pieces, style): if style == "pep440": rendered = render_pep440(pieces) + elif style == "pep440-branch": + rendered = render_pep440_branch(pieces) elif style == "pep440-pre": rendered = render_pep440_pre(pieces) elif style == "pep440-post": rendered = render_pep440_post(pieces) + elif style == "pep440-post-branch": + rendered = render_pep440_post_branch(pieces) elif style == "pep440-old": rendered = render_pep440_old(pieces) elif style == "git-describe": @@ -894,7 +1144,7 @@ def render(pieces, style): "date": pieces.get("date")} -def get_versions(): +def get_versions() -> Dict[str, Any]: """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some @@ -915,7 +1165,7 @@ def get_versions(): # versionfile_source is the relative path from the top of the source # tree (where the .git directory might live) to this file. Invert # this to find the root from __file__. - for i in cfg.versionfile_source.split('/'): + for _ in cfg.versionfile_source.split('/'): root = os.path.dirname(root) except NameError: return {"version": "0+unknown", "full-revisionid": None, @@ -942,41 +1192,48 @@ def get_versions(): @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: - f = open(versionfile_abs, "r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() - except EnvironmentError: + with open(versionfile_abs, "r") as fobj: + for line in fobj: + if line.strip().startswith("git_refnames ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["refnames"] = mo.group(1) + if line.strip().startswith("git_full ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["full"] = mo.group(1) + if line.strip().startswith("git_date ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["date"] = mo.group(1) + except OSError: pass return keywords @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") + if "refnames" not in keywords: + raise NotThisMethod("Short version file found") date = keywords.get("date") if date is not None: + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because @@ -989,11 +1246,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1002,7 +1259,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) + tags = {r for r in refs if re.search(r'\d', r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1011,6 +1268,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): r = ref[len(tag_prefix):] + # Filter out refs that exactly match prefix or that don't start + # with a number once the prefix is stripped (mostly a concern + # when prefix is '') + if not re.match(r'\d', r): + continue if verbose: print("picking %s" % r) return {"version": r, @@ -1026,7 +1288,12 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -1037,8 +1304,15 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=True) + # GIT_DIR can interfere with correct operation of Versioneer. + # It may be intended to be passed to the Versioneer-versioned project, + # but that should not change where we get our version from. + env = os.environ.copy() + env.pop("GIT_DIR", None) + runner = functools.partial(runner, env=env) + + _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, + hide_stderr=not verbose) if rc != 0: if verbose: print("Directory %s not under git control" % root) @@ -1046,24 +1320,57 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", "%s*" % tag_prefix], - cwd=root) + describe_out, rc = runner(GITS, [ + "describe", "--tags", "--dirty", "--always", "--long", + "--match", f"{tag_prefix}[[:digit:]]*" + ], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) + full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) if full_out is None: raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None + branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=root) + # --abbrev-ref was added in git-1.6.3 + if rc != 0 or branch_name is None: + raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") + branch_name = branch_name.strip() + + if branch_name == "HEAD": + # If we aren't exactly on a branch, pick a branch which represents + # the current commit. If all else fails, we are on a branchless + # commit. + branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) + # --contains was added in git-1.5.4 + if rc != 0 or branches is None: + raise NotThisMethod("'git branch --contains' returned error") + branches = branches.split("\n") + + # Remove the first line if we're running detached + if "(" in branches[0]: + branches.pop(0) + + # Strip off the leading "* " from the list of branches. + branches = [branch[2:] for branch in branches] + if "master" in branches: + branch_name = "master" + elif not branches: + branch_name = None + else: + # Pick the first branch that is returned. Good or bad. + branch_name = branches[0] + + pieces["branch"] = branch_name + # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] # TAG might have hyphens. git_describe = describe_out @@ -1080,7 +1387,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # TAG-NUM-gHEX mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) if not mo: - # unparseable. Maybe git-describe is misbehaving? + # unparsable. Maybe git-describe is misbehaving? pieces["error"] = ("unable to parse git-describe output: '%s'" % describe_out) return pieces @@ -1105,19 +1412,20 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], - cwd=root) - pieces["distance"] = int(count_out) # total number of commits + out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) + pieces["distance"] = len(out.split()) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], - cwd=root)[0].strip() + date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces -def do_vcs_install(manifest_in, versionfile_source, ipy): +def do_vcs_install(versionfile_source: str, ipy: Optional[str]) -> None: """Git-specific installation logic for Versioneer. For Git, this means creating/changing .gitattributes to mark _version.py @@ -1126,36 +1434,40 @@ def do_vcs_install(manifest_in, versionfile_source, ipy): GITS = ["git"] if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - files = [manifest_in, versionfile_source] + files = [versionfile_source] if ipy: files.append(ipy) - try: - me = __file__ - if me.endswith(".pyc") or me.endswith(".pyo"): - me = os.path.splitext(me)[0] + ".py" - versioneer_file = os.path.relpath(me) - except NameError: - versioneer_file = "versioneer.py" - files.append(versioneer_file) + if "VERSIONEER_PEP518" not in globals(): + try: + my_path = __file__ + if my_path.endswith((".pyc", ".pyo")): + my_path = os.path.splitext(my_path)[0] + ".py" + versioneer_file = os.path.relpath(my_path) + except NameError: + versioneer_file = "versioneer.py" + files.append(versioneer_file) present = False try: - f = open(".gitattributes", "r") - for line in f.readlines(): - if line.strip().startswith(versionfile_source): - if "export-subst" in line.strip().split()[1:]: - present = True - f.close() - except EnvironmentError: + with open(".gitattributes", "r") as fobj: + for line in fobj: + if line.strip().startswith(versionfile_source): + if "export-subst" in line.strip().split()[1:]: + present = True + break + except OSError: pass if not present: - f = open(".gitattributes", "a+") - f.write("%s export-subst\n" % versionfile_source) - f.close() + with open(".gitattributes", "a+") as fobj: + fobj.write(f"{versionfile_source} export-subst\n") files.append(".gitattributes") run_command(GITS, ["add", "--"] + files) -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -1164,15 +1476,14 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): """ rootdirs = [] - for i in range(3): + for _ in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): return {"version": dirname[len(parentdir_prefix):], "full-revisionid": None, "dirty": False, "error": None, "date": None} - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level + rootdirs.append(root) + root = os.path.dirname(root) # up a level if verbose: print("Tried directories %s but none started with prefix %s" % @@ -1181,7 +1492,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): SHORT_VERSION_PY = """ -# This file was generated by 'versioneer.py' (0.18) from +# This file was generated by 'versioneer.py' (0.29) from # revision-control system data, or from the parent directory name of an # unpacked source archive. Distribution tarballs contain a pre-generated copy # of this file. @@ -1198,12 +1509,12 @@ def get_versions(): """ -def versions_from_file(filename): +def versions_from_file(filename: str) -> Dict[str, Any]: """Try to determine the version from _version.py if present.""" try: with open(filename) as f: contents = f.read() - except EnvironmentError: + except OSError: raise NotThisMethod("unable to read _version.py") mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) @@ -1215,9 +1526,8 @@ def versions_from_file(filename): return json.loads(mo.group(1)) -def write_to_version_file(filename, versions): +def write_to_version_file(filename: str, versions: Dict[str, Any]) -> None: """Write the given version number to the given _version.py file.""" - os.unlink(filename) contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": ")) with open(filename, "w") as f: @@ -1226,14 +1536,14 @@ def write_to_version_file(filename, versions): print("set %s to '%s'" % (filename, versions["version"])) -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -1258,23 +1568,71 @@ def render_pep440(pieces): return rendered -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. +def render_pep440_branch(pieces: Dict[str, Any]) -> str: + """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . + + The ".dev0" means not master branch. Note that .dev0 sorts backwards + (a feature branch will appear "older" than the master branch). Exceptions: - 1: no tags. 0.post.devDISTANCE + 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0" + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: + """Split pep440 version string at the post-release segment. + + Returns the release segments before the post-release and the + post-release version number (or -1 if no post-release segment is present). + """ + vc = str.split(ver, ".post") + return vc[0], int(vc[1] or 0) if len(vc) == 2 else None + + +def render_pep440_pre(pieces: Dict[str, Any]) -> str: + """TAG[.postN.devDISTANCE] -- No -dirty. + + Exceptions: + 1: no tags. 0.post0.devDISTANCE + """ + if pieces["closest-tag"]: if pieces["distance"]: - rendered += ".post.dev%d" % pieces["distance"] + # update the post release segment + tag_version, post_version = pep440_split_post(pieces["closest-tag"]) + rendered = tag_version + if post_version is not None: + rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) + else: + rendered += ".post0.dev%d" % (pieces["distance"]) + else: + # no commits, use the tag as the version + rendered = pieces["closest-tag"] else: # exception #1 - rendered = "0.post.dev%d" % pieces["distance"] + rendered = "0.post0.dev%d" % pieces["distance"] return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -1301,12 +1659,41 @@ def render_pep440_post(pieces): return rendered -def render_pep440_old(pieces): +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: + """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . + + The ".dev0" means not master branch. + + Exceptions: + 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0.post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. - Eexceptions: + Exceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: @@ -1323,7 +1710,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -1343,7 +1730,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -1363,7 +1750,7 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: return {"version": "unknown", @@ -1377,10 +1764,14 @@ def render(pieces, style): if style == "pep440": rendered = render_pep440(pieces) + elif style == "pep440-branch": + rendered = render_pep440_branch(pieces) elif style == "pep440-pre": rendered = render_pep440_pre(pieces) elif style == "pep440-post": rendered = render_pep440_post(pieces) + elif style == "pep440-post-branch": + rendered = render_pep440_post_branch(pieces) elif style == "pep440-old": rendered = render_pep440_old(pieces) elif style == "git-describe": @@ -1399,7 +1790,7 @@ class VersioneerBadRootError(Exception): """The project root directory is unknown or missing key files.""" -def get_versions(verbose=False): +def get_versions(verbose: bool = False) -> Dict[str, Any]: """Get the project version from whatever source is available. Returns dict with two keys: 'version' and 'full'. @@ -1414,7 +1805,7 @@ def get_versions(verbose=False): assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" handlers = HANDLERS.get(cfg.VCS) assert handlers, "unrecognized VCS '%s'" % cfg.VCS - verbose = verbose or cfg.verbose + verbose = verbose or bool(cfg.verbose) # `bool()` used to avoid `None` assert cfg.versionfile_source is not None, \ "please set versioneer.versionfile_source" assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" @@ -1475,13 +1866,17 @@ def get_versions(verbose=False): "date": None} -def get_version(): +def get_version() -> str: """Get the short version string for this project.""" return get_versions()["version"] -def get_cmdclass(): - """Get the custom setuptools/distutils subclasses used by Versioneer.""" +def get_cmdclass(cmdclass: Optional[Dict[str, Any]] = None): + """Get the custom setuptools subclasses used by Versioneer. + + If the package uses a different cmdclass (e.g. one from numpy), it + should be provide as an argument. + """ if "versioneer" in sys.modules: del sys.modules["versioneer"] # this fixes the "python setup.py develop" case (also 'install' and @@ -1495,25 +1890,25 @@ def get_cmdclass(): # parent is protected against the child's "import versioneer". By # removing ourselves from sys.modules here, before the child build # happens, we protect the child from the parent's versioneer too. - # Also see https://github.com/warner/python-versioneer/issues/52 + # Also see https://github.com/python-versioneer/python-versioneer/issues/52 - cmds = {} + cmds = {} if cmdclass is None else cmdclass.copy() - # we add "version" to both distutils and setuptools - from distutils.core import Command + # we add "version" to setuptools + from setuptools import Command class cmd_version(Command): description = "report generated version string" - user_options = [] - boolean_options = [] + user_options: List[Tuple[str, str, str]] = [] + boolean_options: List[str] = [] - def initialize_options(self): + def initialize_options(self) -> None: pass - def finalize_options(self): + def finalize_options(self) -> None: pass - def run(self): + def run(self) -> None: vers = get_versions(verbose=True) print("Version: %s" % vers["version"]) print(" full-revisionid: %s" % vers.get("full-revisionid")) @@ -1523,7 +1918,7 @@ def run(self): print(" error: %s" % vers["error"]) cmds["version"] = cmd_version - # we override "build_py" in both distutils and setuptools + # we override "build_py" in setuptools # # most invocation pathways end up running build_py: # distutils/build -> build_py @@ -1538,18 +1933,25 @@ def run(self): # then does setup.py bdist_wheel, or sometimes setup.py install # setup.py egg_info -> ? + # pip install -e . and setuptool/editable_wheel will invoke build_py + # but the build_py command is not expected to copy any files. + # we override different "build_py" commands for both environments - if "setuptools" in sys.modules: - from setuptools.command.build_py import build_py as _build_py + if 'build_py' in cmds: + _build_py: Any = cmds['build_py'] else: - from distutils.command.build_py import build_py as _build_py + from setuptools.command.build_py import build_py as _build_py class cmd_build_py(_build_py): - def run(self): + def run(self) -> None: root = get_root() cfg = get_config_from_root(root) versions = get_versions() _build_py.run(self) + if getattr(self, "editable_mode", False): + # During editable installs `.py` and data files are + # not copied to build_lib + return # now locate _version.py in the new build/ directory and replace # it with an updated value if cfg.versionfile_build: @@ -1559,8 +1961,40 @@ def run(self): write_to_version_file(target_versionfile, versions) cmds["build_py"] = cmd_build_py + if 'build_ext' in cmds: + _build_ext: Any = cmds['build_ext'] + else: + from setuptools.command.build_ext import build_ext as _build_ext + + class cmd_build_ext(_build_ext): + def run(self) -> None: + root = get_root() + cfg = get_config_from_root(root) + versions = get_versions() + _build_ext.run(self) + if self.inplace: + # build_ext --inplace will only build extensions in + # build/lib<..> dir with no _version.py to write to. + # As in place builds will already have a _version.py + # in the module dir, we do not need to write one. + return + # now locate _version.py in the new build/ directory and replace + # it with an updated value + if not cfg.versionfile_build: + return + target_versionfile = os.path.join(self.build_lib, + cfg.versionfile_build) + if not os.path.exists(target_versionfile): + print(f"Warning: {target_versionfile} does not exist, skipping " + "version update. This can happen if you are running build_ext " + "without first running build_py.") + return + print("UPDATING %s" % target_versionfile) + write_to_version_file(target_versionfile, versions) + cmds["build_ext"] = cmd_build_ext + if "cx_Freeze" in sys.modules: # cx_freeze enabled? - from cx_Freeze.dist import build_exe as _build_exe + from cx_Freeze.dist import build_exe as _build_exe # type: ignore # nczeczulin reports that py2exe won't like the pep440-style string # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. # setup(console=[{ @@ -1569,7 +2003,7 @@ def run(self): # ... class cmd_build_exe(_build_exe): - def run(self): + def run(self) -> None: root = get_root() cfg = get_config_from_root(root) versions = get_versions() @@ -1593,12 +2027,12 @@ def run(self): if 'py2exe' in sys.modules: # py2exe enabled? try: - from py2exe.distutils_buildexe import py2exe as _py2exe # py3 + from py2exe.setuptools_buildexe import py2exe as _py2exe # type: ignore except ImportError: - from py2exe.build_exe import py2exe as _py2exe # py2 + from py2exe.distutils_buildexe import py2exe as _py2exe # type: ignore class cmd_py2exe(_py2exe): - def run(self): + def run(self) -> None: root = get_root() cfg = get_config_from_root(root) versions = get_versions() @@ -1619,14 +2053,51 @@ def run(self): }) cmds["py2exe"] = cmd_py2exe + # sdist farms its file list building out to egg_info + if 'egg_info' in cmds: + _egg_info: Any = cmds['egg_info'] + else: + from setuptools.command.egg_info import egg_info as _egg_info + + class cmd_egg_info(_egg_info): + def find_sources(self) -> None: + # egg_info.find_sources builds the manifest list and writes it + # in one shot + super().find_sources() + + # Modify the filelist and normalize it + root = get_root() + cfg = get_config_from_root(root) + self.filelist.append('versioneer.py') + if cfg.versionfile_source: + # There are rare cases where versionfile_source might not be + # included by default, so we must be explicit + self.filelist.append(cfg.versionfile_source) + self.filelist.sort() + self.filelist.remove_duplicates() + + # The write method is hidden in the manifest_maker instance that + # generated the filelist and was thrown away + # We will instead replicate their final normalization (to unicode, + # and POSIX-style paths) + from setuptools import unicode_utils + normalized = [unicode_utils.filesys_decode(f).replace(os.sep, '/') + for f in self.filelist.files] + + manifest_filename = os.path.join(self.egg_info, 'SOURCES.txt') + with open(manifest_filename, 'w') as fobj: + fobj.write('\n'.join(normalized)) + + cmds['egg_info'] = cmd_egg_info + # we override different "sdist" commands for both environments - if "setuptools" in sys.modules: - from setuptools.command.sdist import sdist as _sdist + if 'sdist' in cmds: + _sdist: Any = cmds['sdist'] else: - from distutils.command.sdist import sdist as _sdist + from setuptools.command.sdist import sdist as _sdist class cmd_sdist(_sdist): - def run(self): + def run(self) -> None: versions = get_versions() self._versioneer_generated_versions = versions # unless we update this, the command will keep using the old @@ -1634,7 +2105,7 @@ def run(self): self.distribution.metadata.version = versions["version"] return _sdist.run(self) - def make_release_tree(self, base_dir, files): + def make_release_tree(self, base_dir: str, files: List[str]) -> None: root = get_root() cfg = get_config_from_root(root) _sdist.make_release_tree(self, base_dir, files) @@ -1687,21 +2158,26 @@ def make_release_tree(self, base_dir, files): """ -INIT_PY_SNIPPET = """ +OLD_SNIPPET = """ from ._version import get_versions __version__ = get_versions()['version'] del get_versions """ +INIT_PY_SNIPPET = """ +from . import {0} +__version__ = {0}.get_versions()['version'] +""" -def do_setup(): - """Main VCS-independent setup function for installing Versioneer.""" + +def do_setup() -> int: + """Do main VCS-independent setup function for installing Versioneer.""" root = get_root() try: cfg = get_config_from_root(root) - except (EnvironmentError, configparser.NoSectionError, + except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: - if isinstance(e, (EnvironmentError, configparser.NoSectionError)): + if isinstance(e, (OSError, configparser.NoSectionError)): print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: @@ -1721,62 +2197,37 @@ def do_setup(): ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") + maybe_ipy: Optional[str] = ipy if os.path.exists(ipy): try: with open(ipy, "r") as f: old = f.read() - except EnvironmentError: + except OSError: old = "" - if INIT_PY_SNIPPET not in old: + module = os.path.splitext(os.path.basename(cfg.versionfile_source))[0] + snippet = INIT_PY_SNIPPET.format(module) + if OLD_SNIPPET in old: + print(" replacing boilerplate in %s" % ipy) + with open(ipy, "w") as f: + f.write(old.replace(OLD_SNIPPET, snippet)) + elif snippet not in old: print(" appending to %s" % ipy) with open(ipy, "a") as f: - f.write(INIT_PY_SNIPPET) + f.write(snippet) else: print(" %s unmodified" % ipy) else: print(" %s doesn't exist, ok" % ipy) - ipy = None - - # Make sure both the top-level "versioneer.py" and versionfile_source - # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so - # they'll be copied into source distributions. Pip won't be able to - # install the package without this. - manifest_in = os.path.join(root, "MANIFEST.in") - simple_includes = set() - try: - with open(manifest_in, "r") as f: - for line in f: - if line.startswith("include "): - for include in line.split()[1:]: - simple_includes.add(include) - except EnvironmentError: - pass - # That doesn't cover everything MANIFEST.in can do - # (http://docs.python.org/2/distutils/sourcedist.html#commands), so - # it might give some false negatives. Appending redundant 'include' - # lines is safe, though. - if "versioneer.py" not in simple_includes: - print(" appending 'versioneer.py' to MANIFEST.in") - with open(manifest_in, "a") as f: - f.write("include versioneer.py\n") - else: - print(" 'versioneer.py' already in MANIFEST.in") - if cfg.versionfile_source not in simple_includes: - print(" appending versionfile_source ('%s') to MANIFEST.in" % - cfg.versionfile_source) - with open(manifest_in, "a") as f: - f.write("include %s\n" % cfg.versionfile_source) - else: - print(" versionfile_source already in MANIFEST.in") + maybe_ipy = None # Make VCS-specific changes. For git, this means creating/changing # .gitattributes to mark _version.py for export-subst keyword # substitution. - do_vcs_install(manifest_in, cfg.versionfile_source, ipy) + do_vcs_install(cfg.versionfile_source, maybe_ipy) return 0 -def scan_setup_py(): +def scan_setup_py() -> int: """Validate the contents of setup.py against Versioneer's expectations.""" found = set() setters = False @@ -1813,10 +2264,14 @@ def scan_setup_py(): return errors +def setup_command() -> NoReturn: + """Set up Versioneer and exit with appropriate error code.""" + errors = do_setup() + errors += scan_setup_py() + sys.exit(1 if errors else 0) + + if __name__ == "__main__": cmd = sys.argv[1] if cmd == "setup": - errors = do_setup() - errors += scan_setup_py() - if errors: - sys.exit(1) + setup_command()