Skip to content

Commit 1d5e7ae

Browse files
committed
Initial commit
0 parents  commit 1d5e7ae

21 files changed

+1119
-0
lines changed

.github/workflows/tests.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
2+
name: Full Python Tests, Lint, and Coverage (all versions and OSes)
3+
on:
4+
push:
5+
# only on commits, not on tags
6+
branches:
7+
- '**'
8+
pull_request:
9+
jobs:
10+
tests:
11+
name: CPython ${{ matrix.python-version }} on ${{ matrix.os }}
12+
# Reminder: Keep in sync with dev/local-actions.sh
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
os: [Ubuntu, Windows, macOS]
17+
# Remember that some tests below only run on one version, so keep that up-to-date.
18+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
19+
runs-on: ${{ matrix.os }}-latest
20+
steps:
21+
- name: Disable autocrlf on Windows
22+
if: ${{ matrix.os == 'Windows' }}
23+
# https://github.com/actions/checkout/issues/135
24+
run: git config --global core.autocrlf false
25+
- uses: actions/checkout@v4
26+
- uses: actions/setup-node@v4
27+
with:
28+
node-version: 20
29+
- name: Install pyright
30+
run: npm install --global pyright
31+
- name: Set up Python ${{ matrix.python-version }}
32+
uses: actions/setup-python@v5
33+
with:
34+
python-version: ${{ matrix.python-version }}
35+
allow-prereleases: true
36+
# https://github.com/actions/setup-python#caching-packages-dependencies
37+
cache: pip
38+
# remember to keep in sync with Makefile:
39+
cache-dependency-path: |
40+
requirements.txt
41+
dev/requirements.txt
42+
- name: Install dependencies
43+
run: make installdeps
44+
- name: Run checks and lint
45+
run: make smoke-checks ver-checks
46+
- name: Run version-independent checks
47+
if: ${{ matrix.python-version == '3.13' }}
48+
run: make other-checks
49+
- name: Run nix-checks and shellcheck on Linux
50+
if: ${{ matrix.os == 'Ubuntu' }}
51+
# Only run nix-checks on Ubuntu because it doesn't work on Windows and bash is too old on macOS.
52+
# Only run shellcheck on Ubuntu because it's only installed there by default.
53+
run: make nix-checks shellcheck
54+
- name: Tests and Coverage
55+
run: make coverage

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/.devcontainer/.devpod-internal/
2+
.venv*/
3+
__pycache__/
4+
.mypy_cache/
5+
.coverage
6+
coverage.xml
7+
htmlcov/
8+
/dist/
9+
/*.egg-info/

.vscode/extensions.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"recommendations": [
3+
"ms-vscode.makefile-tools",
4+
"github.vscode-github-actions",
5+
"ms-python.python",
6+
"ms-python.vscode-pylance",
7+
"ms-python.mypy-type-checker",
8+
"ms-python.pylint",
9+
"ms-python.flake8",
10+
"mads-hartmann.bash-ide-vscode",
11+
"timonwong.shellcheck",
12+
"ryanluker.vscode-coverage-gutters",
13+
"oderwat.indent-rainbow",
14+
"tamasfe.even-better-toml",
15+
"streetsidesoftware.code-spell-checker",
16+
]
17+
}
18+
/* vim: set filetype=javascript ts=4 sw=4 expandtab : */

.vscode/settings.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"files.eol": "\n",
3+
"files.trimTrailingWhitespace": true,
4+
"editor.rulers": [ 150 ], // keep in sync with pyproject.toml
5+
"[markdown]": {
6+
"editor.rulers": [ 100 ],
7+
},
8+
"cSpell.language": "en,en-US",
9+
"python.testing.pytestEnabled": false,
10+
"python.testing.unittestEnabled": true,
11+
"python.testing.unittestArgs": [ "-v", "-s", "${workspaceFolder}", ],
12+
"pylint.importStrategy": "fromEnvironment",
13+
"pylint.args": [ "--rcfile=${workspaceFolder}/pyproject.toml", ],
14+
"pylint.severity": {
15+
// raised the default severity so they are easier to find
16+
"convention": "Warning",
17+
"refactor": "Warning",
18+
"info": "Warning"
19+
},
20+
"flake8.importStrategy": "fromEnvironment",
21+
"flake8.args": [ "--toml-config=${workspaceFolder}/pyproject.toml", ],
22+
"mypy-type-checker.importStrategy": "fromEnvironment",
23+
"mypy-type-checker.reportingScope": "workspace",
24+
"mypy-type-checker.args": [ "--config-file", "${workspaceFolder}/pyproject.toml", ],
25+
"indentRainbow.ignoreErrorLanguages": [
26+
"python",
27+
"markdown"
28+
],
29+
}
30+
/* vim: set filetype=javascript ts=4 sw=4 expandtab : */

CITATION.cff

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# https://bit.ly/cffinit
2+
cff-version: 1.2.0
3+
title: Merge-Insertion Sort a.k.a. Ford-Johnson Algorithm
4+
message: If you use this software, please cite it using the metadata from this file.
5+
type: software
6+
authors:
7+
- given-names: Hauke
8+
family-names: Dämpfling
9+
email: haukex@zero-g.net
10+
repository-code: 'https://github.com/haukex/merge-insertion.py'
11+
abstract: >-
12+
The merge-insertion sort (aka the Ford-Johnson algorithm)
13+
is optimized for using few comparisons. This implementation
14+
in Python allows the user to provide an async comparator.
15+
license: ISC

Makefile

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
## To get help on this makefile, run `make help`.
2+
# https://www.gnu.org/software/make/manual/make.html
3+
4+
# Adapt these variables for this project:
5+
py_code_locs = merge_insertion tests
6+
# Hint: $(filter-out whatever,$(py_code_locs))
7+
# Remember to keep in sync with GitHub Actions workflows:
8+
requirement_txts = dev/requirements.txt docs/requirements.txt
9+
perm_checks = ./* .gitignore .vscode .github
10+
11+
# The user can change the following on the command line:
12+
PYTHON3BIN = python
13+
14+
.PHONY: help tasklist installdeps test build-check
15+
.PHONY: smoke-checks nix-checks shellcheck ver-checks other-checks coverage unittest
16+
test: smoke-checks nix-checks shellcheck ver-checks other-checks coverage ## Run all tests
17+
# Reminder: If the `test` target changes, make the appropriate changes to .github/workflows/tests.yml
18+
19+
SHELL = /bin/bash
20+
.ONESHELL: # each recipe is executed as a single script
21+
22+
README.md: docs/requirements.txt docs/conf.py docs/index.rst merge_insertion/__init__.py
23+
@set -euxo pipefail
24+
make -C docs output/markdown/index.md
25+
cp docs/output/markdown/index.md README.md
26+
make -C docs clean
27+
28+
build-check: smoke-checks
29+
@set -euxo pipefail
30+
[[ "$$OSTYPE" =~ linux.* ]]
31+
$(PYTHON3BIN) -m build --sdist
32+
dist_files=(dist/*.tar.gz)
33+
$(PYTHON3BIN) -m twine check --strict "$${dist_files[@]}"
34+
if [[ $${#dist_files[@]} -ne 1 ]]; then echo "More than one dist file:" "$${dist_files[@]}"; exit 1; fi
35+
PYTHON3BIN="$(PYTHON3BIN)" dev/isolated-dist-test.sh "$${dist_files[0]}"
36+
echo "$${dist_files[@]}"
37+
38+
tasklist: ## List open tasks.
39+
@grep --color=auto \
40+
--exclude-dir=.git --exclude-dir=__pycache__ --exclude-dir=.ipynb_checkpoints --exclude-dir='.venv*' \
41+
--exclude-dir='.*cache' --exclude-dir=node_modules --exclude='LICENSE*' --exclude='.*.swp' \
42+
-Eri '\bto.?do\b'
43+
true # ignore nonzero exit code from grep
44+
45+
installdeps: ## Install project dependencies
46+
@set -euxo pipefail
47+
$(PYTHON3BIN) -m pip install --upgrade --upgrade-strategy=eager --no-warn-script-location pip
48+
$(PYTHON3BIN) -m pip install --upgrade --upgrade-strategy=eager --no-warn-script-location $(foreach x,$(requirement_txts),-r $(x))
49+
# $(PYTHON3BIN) -m pip install --editable . # for modules/packages
50+
# other examples: git lfs install / npm ci
51+
52+
smoke-checks: ## Basic smoke tests
53+
@set -euxo pipefail
54+
# example: [[ "$$OSTYPE" =~ linux.* ]] # this project only runs on Linux
55+
$(PYTHON3BIN) -c 'import sys; sys.exit(0 if sys.version_info.major==3 else 1)' # make sure we're on Python 3
56+
57+
nix-checks: ## Checks that depend on a *NIX OS/FS
58+
@set -euo pipefail
59+
unreliable_perms="yes"
60+
if [ "$$OSTYPE" == "msys" ]; then # e.g. Git bash on Windows
61+
echo "- Assuming unreliable permission bits because Windows"
62+
set -x
63+
else
64+
fstype="$$( findmnt --all --first --noheadings --list --output FSTYPE --notruncate --target . )"
65+
if [[ "$$fstype" =~ ^(vfat|vboxsf|9p)$$ ]]; then
66+
echo "- Assuming unreliable permission bits because fstype=$$fstype"
67+
set -x
68+
else # we can probably depend on permission bits being correct
69+
unreliable_perms=""
70+
set -x
71+
$(PYTHON3BIN) -m simple_perms -r $(perm_checks) # if this errors, run `simple-perms -m ...` for auto fix
72+
test -z "$$( find . \( -type d -name '.venv*' -prune \) -o \( -iname '*.sh' ! -executable -print \) )"
73+
fi
74+
fi
75+
# exclusions to the following can be done by adding a line `-path '*/exclude/me.py' -o \` after `find`
76+
find $(py_code_locs) \
77+
-type f -iname '*.py' -exec \
78+
$(PYTHON3BIN) -m igbpyutils.dev.script_vs_lib $${unreliable_perms:+"--exec-git"} --notice '{}' +
79+
80+
shellcheck: ## Run shellcheck
81+
@set -euxo pipefail
82+
# https://www.gnu.org/software/findutils/manual/html_mono/find.html
83+
find . \( -type d \( -name '.venv*' -o -name '.devpod-internal' \) -prune \) -o \( -iname '*.sh' -exec shellcheck '{}' + \)
84+
85+
ver-checks: ## Checks that depend on the Python version
86+
@set -euxo pipefail
87+
# https://microsoft.github.io/pyright/#/command-line
88+
npx pyright --project pyproject.toml --pythonpath "$$( $(PYTHON3BIN) -c 'import sys; print(sys.executable)' )" $(py_code_locs)
89+
$(PYTHON3BIN) -m mypy --config-file pyproject.toml $(py_code_locs)
90+
$(PYTHON3BIN) -m flake8 --toml-config=pyproject.toml $(py_code_locs)
91+
$(PYTHON3BIN) -m pylint --rcfile=pyproject.toml --recursive=y $(py_code_locs)
92+
93+
other-checks: ## Checks not depending on the Python version
94+
@set -euxo pipefail
95+
# note the following is on one line b/c GitHub macOS Action Runners are running bash 3.2 and the multiline version didn't work there...
96+
for REQ in $(requirement_txts); do $(PYTHON3BIN) -m pur --skip-gt --dry-run-changed --nonzero-exit-code -r "$$REQ"; done
97+
98+
unittest: ## Run unit tests
99+
$(PYTHON3BIN) -X dev -X warn_default_encoding -W error -m unittest -v
100+
101+
coverage: ## Run unit tests with coverage
102+
@set -euxo pipefail
103+
# Note: Don't add command-line arguments here, put them in the rcfile
104+
# We also don't use --fail_under=100 because then the report won't be written.
105+
$(PYTHON3BIN) -X dev -X warn_default_encoding -W error -m coverage run --rcfile=pyproject.toml
106+
$(PYTHON3BIN) -m coverage report --rcfile=pyproject.toml
107+
# $(PYTHON3BIN) -m coverage html --rcfile=pyproject.toml
108+
$(PYTHON3BIN) -m coverage xml --rcfile=pyproject.toml
109+
$(PYTHON3BIN) -m coverage json --rcfile=pyproject.toml -o- \
110+
| perl -wM5.014 -MJSON::PP=decode_json -MTerm::ANSIColor=colored -0777 -ne \
111+
'$$_=decode_json($$_)->{totals}{percent_covered};print"=> ",colored([$$_==100?"green":"red"],"$$_% Coverage")," <=\n";exit($$_==100?0:1)'
112+
113+
# https://stackoverflow.com/q/8889035
114+
help: ## Show this help
115+
@sed -ne 's/^\([^[:space:]]*\):.*##/\1:\t/p' $(MAKEFILE_LIST) | column -t -s $$'\t'

README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<a id="module-merge_insertion"></a>
2+
3+
# Merge-Insertion Sort a.k.a. Ford-Johnson Algorithm
4+
5+
The Ford-Johnson algorithm[1], also known as the merge-insertion sort[2,3] uses the minimum
6+
number of possible comparisons for lists of 22 items or less, and at the time of writing has
7+
the fewest comparisons known for lists of 46 items or less. It is therefore very well suited
8+
for cases where comparisons are expensive, such as user input, and the API is implemented to
9+
take an async comparator function for this reason.
10+
11+
```pycon
12+
>>> from merge_insertion import merge_insertion_sort
13+
>>> # A Comparator must return 0 if the first item is larger, or 1 if the second item is larger.
14+
>>> # It can use any criteria for comparison, in this example we'll use user input:
15+
>>> async def comparator(ab :tuple[str,str]):
16+
... choice = None
17+
... while choice not in ab:
18+
... choice = input(f"Please choose {ab[0]!r} or {ab[1]!r}: ")
19+
... return 0 if choice == ab[0] else 1
20+
...
21+
>>> # Sort five items in ascending order with a maximum of only seven comparisons:
22+
>>> sorted = merge_insertion_sort('DABEC', comparator)
23+
>>> # Since we can't `await` in the REPL, use asyncio to run the coroutine here:
24+
>>> import asyncio
25+
>>> asyncio.run(sorted)
26+
Please choose 'D' or 'A': D
27+
...
28+
Please choose 'B' or 'A': B
29+
['A', 'B', 'C', 'D', 'E']
30+
```
31+
32+
**References**
33+
34+
1. Ford, L. R., & Johnson, S. M. (1959). A Tournament Problem.
35+
The American Mathematical Monthly, 66(5), 387-389. <[https://doi.org/10.1080/00029890.1959.11989306](https://doi.org/10.1080/00029890.1959.11989306)>
36+
2. Knuth, D. E. (1998). The Art of Computer Programming: Volume 3: Sorting and Searching (2nd ed.).
37+
Addison-Wesley. <[https://cs.stanford.edu/~knuth/taocp.html#vol3](https://cs.stanford.edu/~knuth/taocp.html#vol3)>
38+
3. <[https://en.wikipedia.org/wiki/Merge-insertion_sort](https://en.wikipedia.org/wiki/Merge-insertion_sort)>
39+
40+
## API
41+
42+
<a id="merge_insertion.T"></a>
43+
44+
### *class* merge_insertion.T
45+
46+
A type of object that can be compared by a [`Comparator`](#merge_insertion.Comparator) and therefore sorted by
47+
[`merge_insertion_sort()`](#merge_insertion.merge_insertion_sort). Must have sensible support for the equality operators.
48+
49+
alias of TypeVar(‘T’)
50+
51+
<a id="merge_insertion.Comparator"></a>
52+
53+
### merge_insertion.Comparator
54+
55+
A user-supplied function to compare two items.
56+
The argument is a tuple of the two items to be compared; they must not be equal.
57+
Must return a Promise resolving to 0 if the first item is ranked higher, or 1 if the second item is ranked higher.
58+
59+
<a id="merge_insertion.merge_insertion_sort"></a>
60+
61+
### *async* merge_insertion.merge_insertion_sort(array: [Sequence](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence)[[T](#merge_insertion.T)], comparator: [Callable](https://docs.python.org/3/library/collections.abc.html#collections.abc.Callable)[[[tuple](https://docs.python.org/3/library/stdtypes.html#tuple)[[T](#merge_insertion.T), [T](#merge_insertion.T)]], [Awaitable](https://docs.python.org/3/library/collections.abc.html#collections.abc.Awaitable)[[Literal](https://docs.python.org/3/library/typing.html#typing.Literal)[0, 1]]]) → [Sequence](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence)[[T](#merge_insertion.T)]
62+
63+
Merge-Insertion Sort (Ford-Johnson algorithm) with async comparison.
64+
65+
* **Parameters:**
66+
* **array** – Array of to sort. Duplicate items are not allowed.
67+
* **comparator** – Async comparison function.
68+
* **Returns:**
69+
A shallow copy of the array sorted in ascending order.
70+
71+
<a id="merge_insertion.merge_insertion_max_comparisons"></a>
72+
73+
### merge_insertion.merge_insertion_max_comparisons(n: [int](https://docs.python.org/3/library/functions.html#int)) → [int](https://docs.python.org/3/library/functions.html#int)
74+
75+
Returns the maximum number of comparisons that [`merge_insertion_sort()`](#merge_insertion.merge_insertion_sort) will perform depending on the input length.
76+
77+
* **Parameters:**
78+
**n** – The number of items in the list to be sorted.
79+
* **Returns:**
80+
The expected maximum number of comparisons.
81+
82+
## Author, Copyright and License
83+
84+
Copyright © 2025 Hauke Dämpfling ([haukex@zero-g.net](mailto:haukex@zero-g.net))
85+
86+
Permission to use, copy, modify, and/or distribute this software for any
87+
purpose with or without fee is hereby granted, provided that the above
88+
copyright notice and this permission notice appear in all copies.
89+
90+
THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES
91+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
92+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
93+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
94+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
95+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
96+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

dev/DevNotes.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Development Notes
2+
=================
3+
4+
Identical to <https://github.com/haukex/my-py-templ/blob/main/dev/DevNotes.md> except:
5+
- Minimum Python version is 3.10
6+
- Documentation is generated by `make README.md` (`make -B` to force)

dev/isolated-dist-test.sh

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/bin/bash
2+
set -euxo pipefail
3+
4+
##### Test distribution in an isolated environment
5+
# This test takes a built .tar.gz distribution (must be passed as first argument)
6+
# and runs the test suite on it in an isolated venv.
7+
###
8+
9+
python3bin="${PYTHON3BIN:-python}"
10+
11+
usage() { echo "Usage: $0 DIST_FILE" 1>&2; exit 1; }
12+
[[ $# -eq 1 ]] || usage
13+
dist_file="$(realpath "$1")"
14+
test -f "$dist_file" || usage
15+
16+
cd -- "$( dirname -- "${BASH_SOURCE[0]}" )"/..
17+
18+
temp_dir="$( mktemp --directory )"
19+
trap 'set +e; popd; rm -rf "$temp_dir"' EXIT
20+
21+
rsync -a tests "$temp_dir" --exclude=__pycache__
22+
23+
pushd "$temp_dir"
24+
$python3bin -m venv .venv
25+
.venv/bin/python -m pip -q install --upgrade pip
26+
.venv/bin/python -m pip install "$dist_file"
27+
.venv/bin/python -I -X dev -X warn_default_encoding -W error -m unittest -v

0 commit comments

Comments
 (0)