koncurrent 0.0.20261015__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ # git-cliff configuration for commit-level GitHub release notes.
2
+ # https://git-cliff.org/docs/configuration
3
+
4
+ [changelog]
5
+ # Match GitHub's generated-notes layout while keeping individual commit subjects.
6
+ # Deliberately use commit.message rather than remote.pr_title so one PR can produce several bullets.
7
+ # Always link the exact commit using its locally available ID.
8
+ # GitHub metadata adds authors, containing PR links, and first-time contributors when available.
9
+ body = """
10
+ ## What's Changed
11
+ {% for commit in commits %}
12
+ * {{ commit.message | split(pat="\n") | first | trim }}\
13
+ {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%}
14
+ {{ " " }}in [{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }})\
15
+ {% if commit.remote.pr_number %} \
16
+ [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \
17
+ {%- endif %}
18
+ {%- endfor -%}
19
+
20
+ {%- if github -%}
21
+ {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
22
+ {% raw %}\n{% endraw -%}
23
+ ### New Contributors
24
+ {%- endif %}\
25
+ {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
26
+ * @{{ contributor.username }} made their first contribution
27
+ {%- if contributor.pr_number %} in \
28
+ [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
29
+ {%- endif %}
30
+ {%- endfor -%}
31
+ {%- endif -%}
32
+
33
+ {% if version %}
34
+ {% if previous.version %}
35
+ **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}
36
+ {% endif %}
37
+ {% else -%}
38
+ {% raw %}\n{% endraw %}
39
+ {% endif %}
40
+
41
+ {%- macro remote_url() -%}
42
+ https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
43
+ {%- endmacro -%}
44
+ """
45
+ # Remove whitespace introduced by formatting the Tera template for readability.
46
+ trim = true
47
+ footer = """
48
+ <!-- generated by git-cliff -->
49
+ """
50
+
51
+ [git]
52
+ # These two settings include every commit without requiring Conventional Commit syntax.
53
+ # Subjects such as "my.module: fix parsing" are useful release notes but are not conventional commits.
54
+ conventional_commits = false
55
+ filter_unconventional = false
56
+ # Treat a multi-line commit as one change rather than turning each body line into another entry.
57
+ split_commits = false
58
+ # Remove PR suffixes added to some commit subjects because the template adds richer PR links separately.
59
+ commit_preprocessors = [{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }]
60
+ # Keep commits even though this configuration does not classify them with commit_parsers.
61
+ filter_commits = false
62
+ # Only version-like v-prefixed tags delimit releases.
63
+ tag_pattern = "^v[0-9]+.*"
64
+ # Process release tags chronologically rather than by graph topology.
65
+ topo_order = false
66
+ # Present changes in development order, matching GitHub's usual generated-notes order.
67
+ sort_commits = "oldest"
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """Prepare commit-level release notes and prefill GitHub's release form."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import re
8
+ import webbrowser
9
+ from datetime import date
10
+ from pathlib import Path
11
+ from subprocess import check_call, check_output
12
+ from urllib.parse import urlencode
13
+
14
+ GITHUB_REMOTE = re.compile(r'github\.com(?::|/)(?P<repository>[^/]+/[^/]+?)(?:\.git)?$')
15
+ RELEASE_TAG_PATTERN = 'v[0-9]*'
16
+
17
+
18
+ def command_output(*command: str, cwd: Path) -> str:
19
+ return check_output(command, cwd=cwd, text=True).strip()
20
+
21
+
22
+ def git_output(*arguments: str, root: Path) -> str:
23
+ return command_output('git', *arguments, cwd=root)
24
+
25
+
26
+ def github_repository(*, root: Path) -> str:
27
+ remote = git_output('remote', 'get-url', 'origin', root=root)
28
+ match = GITHUB_REMOTE.search(remote)
29
+ assert match is not None, remote
30
+ return match.group('repository')
31
+
32
+
33
+ def default_tag(*, previous_tag: str | None) -> str:
34
+ today = f'{date.today():%Y%m%d}'
35
+ if previous_tag is None:
36
+ return f'v0.1.{today}'
37
+
38
+ error = f"can't infer the next tag from {previous_tag!r}; rerun with an explicit --tag"
39
+ assert previous_tag.startswith('v'), error
40
+ components = previous_tag.removeprefix('v').split('.')
41
+ assert len(components) == 3, error
42
+ major, minor, previous_suffix = components
43
+ assert major.isdigit(), error
44
+ assert minor.isdigit(), error
45
+ assert previous_suffix.isdigit(), error
46
+ return f'v{major}.{int(minor) + 1}.{today}'
47
+
48
+
49
+ def fetch_latest_release_tag(*, root: Path) -> str | None:
50
+ remote_tags = git_output(
51
+ 'ls-remote',
52
+ '--tags',
53
+ '--refs',
54
+ '--sort=-version:refname',
55
+ 'origin',
56
+ RELEASE_TAG_PATTERN,
57
+ root=root,
58
+ ).splitlines()
59
+ if len(remote_tags) == 0:
60
+ return None
61
+
62
+ _, ref = remote_tags[0].split(maxsplit=1)
63
+ tag_prefix = 'refs/tags/'
64
+ assert ref.startswith(tag_prefix), ref
65
+ check_call(['git', 'fetch', '--no-tags', 'origin', f'{ref}:{ref}'], cwd=root)
66
+ return ref.removeprefix(tag_prefix)
67
+
68
+
69
+ def fetch_default_branch(*, root: Path) -> tuple[str, str]:
70
+ lines = git_output('ls-remote', '--symref', 'origin', 'HEAD', root=root).splitlines()
71
+ assert len(lines) == 2, lines
72
+
73
+ symbolic_ref_prefix = 'ref: '
74
+ branch_line = lines[0]
75
+ assert branch_line.startswith(symbolic_ref_prefix), branch_line
76
+ branch_ref, head = branch_line.removeprefix(symbolic_ref_prefix).split()
77
+ assert head == 'HEAD', head
78
+
79
+ commit, head = lines[1].split()
80
+ assert head == 'HEAD', head
81
+
82
+ branch_ref_prefix = 'refs/heads/'
83
+ assert branch_ref.startswith(branch_ref_prefix), branch_ref
84
+ branch = branch_ref.removeprefix(branch_ref_prefix)
85
+ remote_ref = f'refs/remotes/origin/{branch}'
86
+ check_call(['git', 'fetch', '--no-tags', 'origin', f'+{branch_ref}:{remote_ref}'], cwd=root)
87
+ assert git_output('rev-parse', remote_ref, root=root) == commit, remote_ref
88
+ return remote_ref, commit
89
+
90
+
91
+ def generate_notes(*, commit_range: str, repository: str, root: Path, tag: str) -> str:
92
+ return check_output(
93
+ [
94
+ 'nix', 'run', 'nixpkgs#git-cliff',
95
+ '--',
96
+ '--config', str(root / '.ci' / 'cliff.toml'),
97
+ '--unreleased',
98
+ '--tag', tag,
99
+ '--github-repo', repository,
100
+ commit_range,
101
+ ],
102
+ cwd=root,
103
+ text=True,
104
+ ).strip() # fmt: skip
105
+
106
+
107
+ def main() -> None:
108
+ parser = argparse.ArgumentParser(
109
+ description="Generate commit-level notes and a prefilled GitHub release form without creating a tag or release.",
110
+ )
111
+ parser.add_argument(
112
+ '--tag',
113
+ help='release tag; defaults to v0.1.YYYYMMDD initially or the next minor version thereafter',
114
+ )
115
+ parser.add_argument('--title', help="release title; defaults to '<tag>: rolling release'")
116
+ parser.add_argument('--target', help='Git ref for the release; defaults to the remote default branch')
117
+ parser.add_argument('--no-open', action='store_false', dest='open_browser', help='only print the release form URL')
118
+ args = parser.parse_args()
119
+
120
+ root = Path(__file__).absolute().parent.parent
121
+ previous_tag = fetch_latest_release_tag(root=root)
122
+ tag = args.tag if args.tag is not None else default_tag(previous_tag=previous_tag)
123
+ assert git_output('tag', '--list', tag, root=root) == '', tag
124
+
125
+ if args.target is None:
126
+ target_ref, target = fetch_default_branch(root=root)
127
+ else:
128
+ target_ref = args.target
129
+ target = git_output('rev-parse', target_ref, root=root)
130
+
131
+ commit_range = target if previous_tag is None else f'{previous_tag}..{target}'
132
+ commit_count = int(git_output('rev-list', '--count', commit_range, root=root))
133
+ assert commit_count > 0, commit_range
134
+
135
+ repository = github_repository(root=root)
136
+ title = args.title if args.title is not None else f'{tag}: rolling release'
137
+ notes = generate_notes(commit_range=commit_range, repository=repository, root=root, tag=tag)
138
+ query = urlencode({'tag': tag, 'target': target, 'title': title, 'body': notes})
139
+ release_url = f'https://github.com/{repository}/releases/new?{query}'
140
+
141
+ print(f'Tag: {tag}')
142
+ print(f'Title: {title}')
143
+ print(f'Target: {target} ({target_ref})')
144
+ print()
145
+ print(notes)
146
+ print()
147
+ print('Release form:')
148
+ print(release_url)
149
+
150
+ if args.open_browser:
151
+ assert webbrowser.open(release_url), release_url
152
+
153
+
154
+ if __name__ == '__main__':
155
+ main()
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ '''
3
+ Deploys Python package onto [[https://pypi.org][PyPi]] or [[https://test.pypi.org][test PyPi]].
4
+
5
+ - running manually
6
+
7
+ You'll need =UV_PUBLISH_TOKEN= env variable
8
+
9
+ - running on Github Actions
10
+
11
+ Instead of env variable, relies on configuring github as Trusted publisher (https://docs.pypi.org/trusted-publishers/) -- both for test and regular pypi
12
+
13
+ It's running as =pypi= job in [[file:.github/workflows/main.yml][Github Actions config]].
14
+ Packages are deployed on:
15
+ - every master commit, onto test pypi
16
+ - every new tag, onto production pypi
17
+ '''
18
+
19
+ UV_PUBLISH_TOKEN = 'UV_PUBLISH_TOKEN'
20
+
21
+ import argparse
22
+ import os
23
+ from pathlib import Path
24
+ from subprocess import check_call
25
+
26
+ is_ci = os.environ.get('CI') is not None
27
+
28
+
29
+ def main() -> None:
30
+ p = argparse.ArgumentParser()
31
+ p.add_argument('--use-test-pypi', action='store_true')
32
+ args = p.parse_args()
33
+
34
+ publish_url = ['--publish-url', 'https://test.pypi.org/legacy/'] if args.use_test_pypi else []
35
+
36
+ root = Path(__file__).absolute().parent.parent
37
+ os.chdir(root) # just in case
38
+
39
+ check_call(['uv', 'build', '--clear'])
40
+
41
+ if not is_ci:
42
+ # CI relies on trusted publishers so doesn't need env variable
43
+ assert UV_PUBLISH_TOKEN in os.environ, f'no {UV_PUBLISH_TOKEN} passed'
44
+
45
+ check_call(['uv', 'publish', *publish_url])
46
+
47
+
48
+ if __name__ == '__main__':
49
+ main()
@@ -0,0 +1,39 @@
1
+ #!/bin/bash
2
+ set -eu
3
+
4
+ cd "$(dirname "$0")"
5
+ cd .. # git root
6
+
7
+ # projects might need sudo to install OS specific stuff below.
8
+ if ! command -v sudo >/dev/null; then
9
+ # CI or Docker sometimes doesn't have it, so useful to have a dummy
10
+ function sudo {
11
+ "$@"
12
+ }
13
+ fi
14
+
15
+ # --parallel-live to show outputs while it's running
16
+ tox_cmd='run-parallel --parallel-live'
17
+ if [ -n "${CI-}" ]; then
18
+ # install OS specific stuff here
19
+ case "$OSTYPE" in
20
+ darwin*)
21
+ # macos
22
+ :
23
+ ;;
24
+ cygwin* | msys* | win*)
25
+ # windows
26
+ # ugh. parallel stuff seems super flaky under windows, some random failures, "file used by other process" and crap like that
27
+ tox_cmd='run'
28
+ ;;
29
+ *)
30
+ # must be linux?
31
+ :
32
+ ;;
33
+ esac
34
+ fi
35
+
36
+ # NOTE: expects uv installed
37
+ uv tool run --with tox-uv tox $tox_cmd "$@"
38
+ # NOTE: experimenting for now... might switch later for good
39
+ # uv tool run nox "$@"
@@ -0,0 +1,153 @@
1
+ # see https://github.com/karlicoss/pymplate for up-to-date reference
2
+
3
+ name: CI
4
+ on:
5
+ push:
6
+ # Deliberate: we want CI on pushed branches without having to open a PR.
7
+ branches: '**'
8
+ tags: 'v[0-9]+.*' # only trigger on 'release' tags for PyPi
9
+ # Ideally I would put this in the pypi job... but github syntax doesn't allow for regexes there :shrug:
10
+
11
+ # Needed to trigger on others' PRs.
12
+ # Note that people who fork it need to go to "Actions" tab on their fork and click "I understand my workflows, go ahead and enable them".
13
+ # NOTE: together with the push trigger above, same-repo branches with an open PR get double CI runs.
14
+ # Keeping both anyway: push covers branches without a PR, this one is needed for fork PRs,
15
+ # and the runs aren't identical -- pull_request checks the merge commit against the base branch, push checks the branch head.
16
+ pull_request:
17
+
18
+ # Needed to trigger workflows manually.
19
+ workflow_dispatch:
20
+ inputs:
21
+ debug_enabled:
22
+ type: boolean
23
+ description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)'
24
+ required: false
25
+ default: false
26
+
27
+ schedule:
28
+ - cron: '31 18 * * 5' # run every Friday
29
+
30
+
31
+ permissions:
32
+ contents: read # repos created before 2023 have write permissions as default. Protects against compromised actions.
33
+
34
+
35
+ jobs:
36
+ # GitHub disables scheduled workflows in public repositories after 60 days without repository activity:
37
+ # https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule
38
+ # Calling enable on an active workflow prevents this timeout (a workaround not documented by GitHub).
39
+ # See https://github.com/liskin/gh-workflow-keepalive
40
+ keepalive:
41
+ # Run independently of build so failing tests do not stop the keepalive.
42
+ if: github.event_name == 'schedule'
43
+ runs-on: ubuntu-latest
44
+ timeout-minutes: 2
45
+ permissions:
46
+ actions: write
47
+ steps:
48
+ - name: Keep scheduled (cron) workflows enabled during inactivity
49
+ env:
50
+ GH_TOKEN: ${{ github.token }}
51
+ run: gh api --method PUT "repos/$GITHUB_REPOSITORY/actions/workflows/main.yml/enable"
52
+
53
+
54
+ build:
55
+ strategy:
56
+ fail-fast: false
57
+ matrix:
58
+ platform:
59
+ - ubuntu-latest
60
+ - macos-latest
61
+ - windows-latest
62
+ python-version:
63
+ - &minimum_python '3.12'
64
+ - '3.13'
65
+ - '3.14'
66
+ - '3.15'
67
+ # free-threaded builds, which koncurrent mainly targets
68
+ - '3.14t'
69
+ - '3.15t'
70
+ # vvv just an example of excluding stuff from matrix
71
+ # exclude: [{platform: macos-latest, python-version: '3.6'}]
72
+
73
+ runs-on: ${{ matrix.platform }}
74
+
75
+ # useful for 'optional' pipelines
76
+ # continue-on-error: ${{ matrix.platform == 'windows-latest' }}
77
+
78
+ steps:
79
+ - uses: actions/checkout@v7
80
+ with:
81
+ persist-credentials: false
82
+ submodules: recursive
83
+ fetch-depth: 0 # nicer to have all git history when debugging/for tests
84
+
85
+ # setup-uv doesn't publish floating major/minor tags, so use an exact full tag.
86
+ - uses: astral-sh/setup-uv@v10.3.0
87
+ with:
88
+ python-version: ${{ matrix.python-version }}
89
+ enable-cache: false # we don't have lock files during initial CI checkout, so can't use them as cache key
90
+
91
+ - uses: mxschmitt/action-tmate@v3
92
+ with:
93
+ limit-access-to-actor: true # restrict to the user who kicked off pipeline
94
+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }}
95
+
96
+ # Invoke Bash explicitly because Windows runners default to PowerShell.
97
+ - run: bash .ci/run
98
+ env:
99
+ RUFF_OUTPUT_FORMAT: github
100
+
101
+
102
+ pypi:
103
+ # Do not run it for PRs/cron schedule etc.
104
+ # NOTE: release tags are guarded by on: push: tags on the top.
105
+ if: github.event_name == 'push' && (github.ref_type == 'tag' || github.ref_name == github.event.repository.master_branch)
106
+ # Ugh, I tried using matrix or something to explicitly generate only test pypi or prod pypi pipelines.
107
+ # But github actions is so shit, it's impossible to do any logic at all, e.g. doesn't support conditional matrix, if/else statements for variables etc.
108
+
109
+ needs: [build] # add all other jobs here
110
+
111
+ runs-on: ubuntu-latest
112
+
113
+ permissions:
114
+ contents: read # necessary to repeat since 'permissions:' replace global ones, not augment
115
+ id-token: write # necessary for Trusted Publishing
116
+ env:
117
+ # always deploy merged master to test pypi
118
+ # always deploy tags to release pypi
119
+ TARGET: ${{ github.ref_type == 'tag' && 'pypi' || 'testpypi' }}
120
+ environment:
121
+ # for "deployments" tab on github
122
+ # sadly can't reuse env.TARGET here...
123
+ name: ${{ github.ref_type == 'tag' && 'pypi' || 'testpypi' }}
124
+ url: https://${{ github.ref_type == 'tag' && 'pypi.org' || 'test.pypi.org' }}/project/${{ steps.meta.outputs.pypi_name }}/
125
+ steps:
126
+ - uses: actions/checkout@v7
127
+ with:
128
+ persist-credentials: false
129
+ submodules: recursive
130
+ fetch-depth: 0 # pull all commits to correctly infer vcs version
131
+
132
+ # setup-uv doesn't publish floating major/minor tags, so use an exact full tag.
133
+ - uses: astral-sh/setup-uv@v10.3.0
134
+ with:
135
+ python-version: *minimum_python
136
+ enable-cache: false # we don't have lock files during initial CI checkout, so can't use them as cache key
137
+
138
+ - name: 'release to ${{ env.TARGET }}'
139
+ run: .ci/release ${{ env.TARGET == 'testpypi' && '--use-test-pypi' || '' }}
140
+
141
+ # NOTE: name/version are extracted from the built wheel -- build backend is the source of truth for name normalization.
142
+ # So this has to run after the release step (which builds the wheel),
143
+ # and the release step name above can't include the package name (step names render when the step starts).
144
+ - id: meta
145
+ name: 'release summary'
146
+ shell: bash
147
+ run: |
148
+ metadata=$(unzip -p dist/*.whl '*.dist-info/METADATA')
149
+ pypi_name=$(awk '/^Name:/ {print $2; exit}' <<< "$metadata")
150
+ version=$(awk '/^Version:/ {print $2; exit}' <<< "$metadata")
151
+ echo "pypi_name=$pypi_name" >> "$GITHUB_OUTPUT"
152
+ host=$([ "$TARGET" = 'pypi' ] && echo 'pypi.org' || echo 'test.pypi.org')
153
+ echo "released [$pypi_name $version](https://$host/project/$pypi_name/$version/) to $TARGET" >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,249 @@
1
+ # Created by https://www.toptal.com/developers/gitignore/api/vim,emacs,python
2
+ # Edit at https://www.toptal.com/developers/gitignore?templates=vim,emacs,python
3
+
4
+ ### Emacs ###
5
+ # -*- mode: gitignore; -*-
6
+ *~
7
+ \#*\#
8
+ /.emacs.desktop
9
+ /.emacs.desktop.lock
10
+ *.elc
11
+ auto-save-list
12
+ tramp
13
+ .\#*
14
+
15
+ # Org-mode
16
+ .org-id-locations
17
+ *_archive
18
+
19
+ # flymake-mode
20
+ *_flymake.*
21
+
22
+ # eshell files
23
+ /eshell/history
24
+ /eshell/lastdir
25
+
26
+ # elpa packages
27
+ /elpa/
28
+
29
+ # reftex files
30
+ *.rel
31
+
32
+ # AUCTeX auto folder
33
+ /auto/
34
+
35
+ # cask packages
36
+ .cask/
37
+ dist/
38
+
39
+ # Flycheck
40
+ flycheck_*.el
41
+
42
+ # server auth directory
43
+ /server/
44
+
45
+ # projectiles files
46
+ .projectile
47
+
48
+ # directory configuration
49
+ .dir-locals.el
50
+
51
+ # network security
52
+ /network-security.data
53
+
54
+
55
+ ### Python ###
56
+ # Byte-compiled / optimized / DLL files
57
+ __pycache__/
58
+ *.py[cod]
59
+ *$py.class
60
+
61
+ # C extensions
62
+ *.so
63
+
64
+ # Distribution / packaging
65
+ .Python
66
+ build/
67
+ develop-eggs/
68
+ downloads/
69
+ eggs/
70
+ .eggs/
71
+ lib/
72
+ lib64/
73
+ parts/
74
+ sdist/
75
+ var/
76
+ wheels/
77
+ share/python-wheels/
78
+ *.egg-info/
79
+ .installed.cfg
80
+ *.egg
81
+ MANIFEST
82
+
83
+ # PyInstaller
84
+ # Usually these files are written by a python script from a template
85
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
86
+ *.manifest
87
+ *.spec
88
+
89
+ # Installer logs
90
+ pip-log.txt
91
+ pip-delete-this-directory.txt
92
+
93
+ # Unit test / coverage reports
94
+ htmlcov/
95
+ .tox/
96
+ .nox/
97
+ .coverage
98
+ .coverage.*
99
+ .cache
100
+ nosetests.xml
101
+ coverage.xml
102
+ *.cover
103
+ *.py,cover
104
+ .hypothesis/
105
+ .pytest_cache/
106
+ cover/
107
+
108
+ # Translations
109
+ *.mo
110
+ *.pot
111
+
112
+ # Django stuff:
113
+ *.log
114
+ local_settings.py
115
+ db.sqlite3
116
+ db.sqlite3-journal
117
+
118
+ # Flask stuff:
119
+ instance/
120
+ .webassets-cache
121
+
122
+ # Scrapy stuff:
123
+ .scrapy
124
+
125
+ # Sphinx documentation
126
+ docs/_build/
127
+
128
+ # PyBuilder
129
+ .pybuilder/
130
+ target/
131
+
132
+ # Jupyter Notebook
133
+ .ipynb_checkpoints
134
+
135
+ # IPython
136
+ profile_default/
137
+ ipython_config.py
138
+
139
+ # pyenv
140
+ # For a library or package, you might want to ignore these files since the code is
141
+ # intended to run in multiple environments; otherwise, check them in:
142
+ # .python-version
143
+
144
+ # pipenv
145
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
146
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
147
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
148
+ # install all needed dependencies.
149
+ #Pipfile.lock
150
+
151
+ # poetry
152
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
153
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
154
+ # commonly ignored for libraries.
155
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
156
+ #poetry.lock
157
+
158
+ # pdm
159
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
160
+ #pdm.lock
161
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
162
+ # in version control.
163
+ # https://pdm.fming.dev/#use-with-ide
164
+ .pdm.toml
165
+
166
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
167
+ __pypackages__/
168
+
169
+ # Celery stuff
170
+ celerybeat-schedule
171
+ celerybeat.pid
172
+
173
+ # SageMath parsed files
174
+ *.sage.py
175
+
176
+ # Environments
177
+ .env
178
+ .venv
179
+ env/
180
+ venv/
181
+ ENV/
182
+ env.bak/
183
+ venv.bak/
184
+
185
+ # Spyder project settings
186
+ .spyderproject
187
+ .spyproject
188
+
189
+ # Rope project settings
190
+ .ropeproject
191
+
192
+ # mkdocs documentation
193
+ /site
194
+
195
+ # mypy
196
+ .mypy_cache/
197
+ .dmypy.json
198
+ dmypy.json
199
+
200
+ # Pyre type checker
201
+ .pyre/
202
+
203
+ # pytype static type analyzer
204
+ .pytype/
205
+
206
+ # Cython debug symbols
207
+ cython_debug/
208
+
209
+ # PyCharm
210
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
211
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
212
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
213
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
214
+ #.idea/
215
+
216
+ ### Python Patch ###
217
+ # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
218
+ poetry.toml
219
+
220
+ # ruff
221
+ .ruff_cache/
222
+
223
+ # LSP config files
224
+ pyrightconfig.json
225
+
226
+ ### Vim ###
227
+ # Swap
228
+ [._]*.s[a-v][a-z]
229
+ !*.svg # comment out if you don't need vector files
230
+ [._]*.sw[a-p]
231
+ [._]s[a-rt-v][a-z]
232
+ [._]ss[a-gi-z]
233
+ [._]sw[a-p]
234
+
235
+ # Session
236
+ Session.vim
237
+ Sessionx.vim
238
+
239
+ # Temporary
240
+ .netrwhist
241
+ # Auto-generated tag files
242
+ tags
243
+ # Persistent undo
244
+ [._]*.un~
245
+
246
+ # End of https://www.toptal.com/developers/gitignore/api/vim,emacs,python
247
+
248
+ # for now, deliberately ignoring so CI always runs against latest versions
249
+ /uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dima Gerasimov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.5
2
+ Name: koncurrent
3
+ Version: 0.0.20261015
4
+ Summary: Parallel map for libraries, through a process-wide executor that the app controls
5
+ Project-URL: Homepage, https://github.com/karlicoss/koncurrent
6
+ Author-email: "Dima Gerasimov (@karlicoss)" <karlicoss@gmail.com>
7
+ Maintainer-email: "Dima Gerasimov (@karlicoss)" <karlicoss@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Dima Gerasimov
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Requires-Python: >=3.12
31
+ Description-Content-Type: text/markdown
32
+
33
+ # koncurrent
34
+
35
+ Parallel map for libraries, through a process-wide executor that the app controls.
36
+
37
+ Work in progress, so far it only provides `koncurrent.SynchronousExecutor`:
38
+ an executor that runs each callable right away in the calling thread, and returns an already completed future.
@@ -0,0 +1,6 @@
1
+ # koncurrent
2
+
3
+ Parallel map for libraries, through a process-wide executor that the app controls.
4
+
5
+ Work in progress, so far it only provides `koncurrent.SynchronousExecutor`:
6
+ an executor that runs each callable right away in the calling thread, and returns an already completed future.
@@ -0,0 +1,356 @@
1
+ # see https://github.com/karlicoss/pymplate for up-to-date reference
2
+ [project]
3
+ dynamic = ["version"] # version is managed by build backend
4
+ name = "koncurrent"
5
+ dependencies = [
6
+ ]
7
+ requires-python = ">=3.12"
8
+
9
+ ## these are only useful for pypi
10
+ description = "Parallel map for libraries, through a process-wide executor that the app controls" # shows on project page/in search
11
+ readme = "README.md"
12
+ license = {file = "LICENSE"}
13
+ authors = [{name = "Dima Gerasimov (@karlicoss)", email = "karlicoss@gmail.com"}]
14
+ maintainers = [{name = "Dima Gerasimov (@karlicoss)", email = "karlicoss@gmail.com"}]
15
+
16
+ [project.urls] # pypi will fetch some github stats/link, so somewhat useful
17
+ Homepage = "https://github.com/karlicoss/koncurrent"
18
+ ##
19
+
20
+
21
+ [project.optional-dependencies]
22
+
23
+ [dependency-groups]
24
+ # TODO: not sure, on the one hand could just use 'standard' dev dependency group
25
+ # On the other hand, it's a bit annoying that it's always included by default?
26
+ # To make sure it's not included, need to use `uv run --exact --no-default-groups ...`
27
+ testing = [
28
+ "pytest",
29
+ "ruff",
30
+ ]
31
+ typecheck = [
32
+ "mypy",
33
+ "lxml", # not used by default but keeping for convenience of running mypy html coverage
34
+ "ty",
35
+
36
+ { include-group = "testing" },
37
+ ]
38
+
39
+
40
+ [build-system]
41
+ requires = ["hatchling", "hatch-vcs"]
42
+ build-backend = "hatchling.build"
43
+
44
+ # unfortunately have to duplicate project name here atm, see https://github.com/pypa/hatch/issues/1894
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/koncurrent"]
47
+
48
+ [tool.hatch.version]
49
+ source = "vcs"
50
+
51
+ [tool.hatch.version.raw-options]
52
+ version_scheme = "python-simplified-semver"
53
+ local_scheme = "dirty-tag"
54
+
55
+
56
+ [tool.mypy]
57
+ # default targets when mypy is run without explicit ones
58
+ packages = ["koncurrent"]
59
+
60
+ pretty = true
61
+ show_error_context = true
62
+ show_error_end = true
63
+
64
+ # see https://mypy.readthedocs.io/en/stable/error_code_list2.html
65
+ ## NOTE: strict implies all of these.. will gradually move onto it
66
+ strict = true
67
+ check_untyped_defs = true
68
+ warn_redundant_casts = true
69
+ strict_equality = true
70
+ warn_unused_ignores = true
71
+ ##
72
+ enable_error_code = [
73
+ "deprecated",
74
+ "redundant-expr",
75
+ "possibly-undefined",
76
+ "truthy-bool",
77
+ "truthy-iterable",
78
+ "ignore-without-code",
79
+ "unused-awaitable",
80
+ ]
81
+
82
+
83
+ [tool.ty.rules]
84
+ # suppressing rules/changing error level goes here
85
+ # Catch checks like `if value` on `int | None`, where 0 and None are both falsy.
86
+ truthiness-test-of-none-union = "error"
87
+
88
+
89
+ [tool.ruff]
90
+ line-length = 120 # impacts import sorting
91
+
92
+ lint.extend-select = [
93
+ "ALL",
94
+ ]
95
+
96
+ lint.ignore = [
97
+ "CPY", # copyright headers
98
+ "D", # annoying nags about docstrings
99
+ "N", # pep naming
100
+ "TC", # type checking rules, mostly just suggests moving imports under TYPE_CHECKING
101
+ "S", # bandit (security checks) -- tends to be not very useful, lots of nitpicks
102
+ "DTZ", # datetimes checks -- complaining about missing tz and mostly false positives
103
+ "FIX", # complains about fixmes/todos -- annoying
104
+ "TD", # complains about todo formatting -- too annoying
105
+ "ANN", # missing type annotations? seems way too strict though
106
+ "EM" , # suggests assigning all exception messages into a variable first... pretty annoying
107
+
108
+ ### too opinionated style checks
109
+ "E203", # whitespace before , -- sometimes used for readability, and ruff format should cover it anyway
110
+ "E221", # multiple spaces before operator -- also covered by ruff format
111
+ "E266", # too many leading '#' for block comment -- sometimes genuinely useful for visual separation
112
+ "E501", # too long lines
113
+ "E731", # assigning lambda instead of using def
114
+ "E741", # Ambiguous variable name: `l`
115
+ "E742", # Ambiguous class name: `O`
116
+ "E401", # Multiple imports on one line
117
+ "F403", # `import *` used; unable to detect undefined names
118
+ ###
119
+
120
+ ###
121
+ "E722", # Do not use bare `except` ## Sometimes it's useful for defensive imports and that sort of thing..
122
+ "F811", # Redefinition of unused # this gets in the way of pytest fixtures (e.g. in cachew)
123
+
124
+ ## might be nice .. but later and I don't wanna make it strict
125
+ "E402", # Module level import not at top of file
126
+
127
+ ### these are just nitpicky, we usually know better
128
+ "PLR0911", # too many return statements
129
+ "PLR0912", # too many branches
130
+ "PLR0913", # too many function arguments
131
+ "PLR0915", # too many statements
132
+ "PLR1714", # consider merging multiple comparisons
133
+ "PLR2044", # line with empty comment
134
+ "PLR5501", # use elif instead of else if
135
+ "PLR2004", # magic value in comparison -- super annoying in tests
136
+ ###
137
+ "PLR0402", # import X.Y as Y -- TODO maybe consider enabling it, but double check
138
+
139
+ "B009", # calling getattr with constant attribute -- this is useful to convince mypy
140
+ "B010", # same as above, but setattr
141
+ "B017", # pytest.raises(Exception)
142
+ "B023", # seems to result in false positives?
143
+
144
+ # complains about useless pass, but has sort of a false positive if the function has a docstring?
145
+ # this is common for click entrypoints (e.g. in __main__), so disable
146
+ "PIE790",
147
+
148
+ # a bit too annoying, offers to convert for loops to a list comprehension,
149
+ # which may hurt readability
150
+ "PERF401",
151
+
152
+ # suggests not using exceptions in for loops
153
+ # we do use this technique a lot, plus in 3.11 happy path exception handling is "zero-cost"
154
+ "PERF203",
155
+
156
+ "RET504", # unnecessary assignment before returning -- that can be useful for readability
157
+ "RET505", # unnecessary else after return -- can hurt readability
158
+
159
+ "PLC1901", # suggests using `if string` instead of `string == ""` -- dumb.
160
+
161
+ "PLW0603", # global variable update.. we usually know why we are doing this
162
+ "PLW2901", # for loop variable overwritten, usually this is intentional
163
+
164
+ "PT011", # pytest raises is too broad
165
+
166
+ "COM812", # trailing comma missing -- mostly just being annoying with long multiline strings
167
+
168
+ "TRY003", # suggests defining exception messages in exception class -- kinda annoying
169
+ "TRY201", # raise without specifying exception name -- sometimes hurts readability
170
+ "TRY400", # a bit dumb, and results in false positives (see https://github.com/astral-sh/ruff/issues/18070)
171
+ "TRY401", # redundant exception in logging.exception call? TODO double check, might result in excessive logging
172
+
173
+ "TID252", # Prefer absolute imports over relative imports from parent modules
174
+
175
+ ## too annoying
176
+ "T20", # just complains about prints and pprints (TODO maybe consider later?)
177
+ "Q", # flake quotes, too annoying
178
+ "C90", # some complexity checking
179
+ "G004", # logging statement uses f string
180
+ "ERA001", # commented out code
181
+ "SLF001", # private member accessed
182
+ "BLE001", # do not catch 'blind' Exception
183
+ "INP001", # complains about implicit namespace packages
184
+ "SIM102", # if statements collapsing, often hurts readability
185
+ "SIM103", # multiple conditions collapsing, often hurts readability
186
+ "SIM105", # suggests using contextlib.suppress instead of try/except -- this wouldn't be mypy friendly
187
+ "SIM108", # suggests using ternary operation instead of if -- hurts readability
188
+ "SIM110", # suggests using any(...) instead of for loop/return -- hurts readability
189
+ "SIM117", # suggests using single with statement instead of nested -- doesn't work in tests
190
+ "RSE102", # complains about missing parens in exceptions
191
+ ##
192
+
193
+ "PLC0415", # "imports should be at the top level" -- not realistic
194
+
195
+ "RUF067", # `__init__` module should only contain docstrings and re-exports -- might be nice to aim for, but dunno
196
+ ]
197
+
198
+
199
+ # NOTE: this is project specific.
200
+ # If you need local-only excludes for untracked scratch files, create ignored .ruff.toml:
201
+ # extend = "pyproject.toml"
202
+ # extend-exclude = [
203
+ # "scratch/**",
204
+ # ]
205
+ extend-exclude = [
206
+ ]
207
+
208
+ [tool.ruff.format]
209
+ quote-style = "preserve"
210
+
211
+
212
+ [tool.pytest.ini_options]
213
+ # discover files that don't follow test_ naming. Useful to keep tests along with the source code
214
+ python_files = ["*.py"]
215
+
216
+ # default targets when pytest is run without explicit ones (package names because of --pyargs below)
217
+ testpaths = ["koncurrent"]
218
+
219
+ # this is necessary for --pyargs to discover implicit namespace packages correctly
220
+ consider_namespace_packages = true
221
+
222
+ # see https://docs.pytest.org/en/stable/reference/reference.html#confval-strict
223
+ strict = true
224
+
225
+ addopts = [
226
+ # resolve targets as importable packages, so tests are collected from the installed package
227
+ "--pyargs",
228
+
229
+ # prevent pytest cache from being created... it craps into project dir and I never use it anyway
230
+ "-p", "no:cacheprovider",
231
+
232
+ # -rap to print tests summary even when they are successful
233
+ "-rap",
234
+ "--verbose",
235
+
236
+ # otherwise it won't discover doctests
237
+ "--doctest-modules",
238
+
239
+ # show all test durations (unless they are too short)
240
+ "--durations=0",
241
+ ]
242
+
243
+
244
+ [tool.tox]
245
+ min_version = "4"
246
+
247
+ # Relies on the correct version of Python installed.
248
+ # We rely on CI for the test matrix.
249
+ env_list = [
250
+ "ruff",
251
+ "format",
252
+ "tests",
253
+ "mypy",
254
+ "ty",
255
+ ]
256
+
257
+ # https://github.com/tox-dev/tox/issues/20#issuecomment-247788333
258
+ # Hack to prevent .tox from crapping into the project directory.
259
+ work_dir = "{env:TOXWORKDIR_BASE:}{tox_root}{/}.tox"
260
+
261
+ [tool.tox.env_run_base]
262
+ pass_env = [
263
+ # Useful for tests to know they are running under CI.
264
+ "CI",
265
+ "CI_*",
266
+
267
+ # Let CI use GitHub annotations for Ruff lint and formatting diagnostics.
268
+ "RUFF_OUTPUT_FORMAT",
269
+
270
+ # Respect user's cache dirs to prevent tox from crapping into project dir.
271
+ "PYTHONPYCACHEPREFIX",
272
+ "MYPY_CACHE_DIR",
273
+ "RUFF_CACHE_DIR",
274
+ ]
275
+
276
+ # Do not add current working directory to PYTHONPATH.
277
+ # Generally this is more robust and safer, and prevents weird issues later on.
278
+ set_env.PYTHONSAFEPATH = "1"
279
+
280
+ # Useful when a project defines multiple mypy environments that run in parallel.
281
+ # Separate caches avoid SQLite locking errors.
282
+ set_env.MYPY_CACHE_DIR = "{env:MYPY_CACHE_DIR:{tox_root}{/}.mypy_cache}{/}{env_name}"
283
+
284
+ # Relying on https://github.com/tox-dev/tox-uv for the runners here.
285
+ # This template deliberately defaults to the lock runner even though pymplate itself does not use tool.uv.sources.
286
+ # Projects based on it may use tool.uv.sources, and keeping one runner avoids changing tox semantics later.
287
+ runner = "uv-venv-lock-runner"
288
+ # The lock runner requires a lock file, which is slightly annoying since we're not tracking lock files (see .gitignore).
289
+ # By default it would also check that running tox won't change the lock file.
290
+ # With this setting the lock file is created/updated as needed instead:
291
+ uv_sync_locked = false
292
+ # I'm not sure what happens if two tests running in parallel result in different
293
+ # lock files, but I guess it is unlikely.
294
+ #
295
+ # If the project doesn't need tool.uv.sources, a fine alternative (no lock file involved at all) is:
296
+ # runner = "uv-venv-runner"
297
+ # package = "uv-editable" # otherwise the default is "editable", which builds a wheel first for some reason
298
+
299
+
300
+ [tool.tox.env.ruff]
301
+ package = "skip"
302
+ dependency_groups = ["testing"]
303
+ commands = [
304
+ [
305
+ "{env_python}", "-m", "ruff",
306
+ "check",
307
+ { replace = "posargs", default = [], extend = true },
308
+ ],
309
+ ]
310
+
311
+
312
+ [tool.tox.env.format]
313
+ package = "skip"
314
+ dependency_groups = ["testing"]
315
+ commands = [
316
+ [
317
+ "{env_python}", "-m", "ruff",
318
+ "format",
319
+ "--check",
320
+ { replace = "posargs", default = [], extend = true },
321
+ ],
322
+ ]
323
+
324
+
325
+ [tool.tox.env.tests]
326
+ dependency_groups = ["testing"]
327
+ commands = [
328
+ [
329
+ "{env_python}", "-m", "pytest",
330
+ { replace = "posargs", default = [], extend = true },
331
+ ],
332
+ ]
333
+
334
+
335
+ [tool.tox.env.mypy]
336
+ dependency_groups = ["typecheck"]
337
+ commands = [
338
+ [
339
+ "{env_python}", "-m", "mypy",
340
+ # uncomment if you need coverage
341
+ # "--txt-report" , ".coverage.mypy",
342
+ # "--html-report", ".coverage.mypy",
343
+ { replace = "posargs", default = [], extend = true },
344
+ ],
345
+ ]
346
+
347
+
348
+ [tool.tox.env.ty]
349
+ dependency_groups = ["typecheck"]
350
+ commands = [
351
+ [
352
+ "{env_python}", "-m", "ty",
353
+ "check",
354
+ { replace = "posargs", default = [], extend = true },
355
+ ],
356
+ ]
@@ -0,0 +1,9 @@
1
+ """
2
+ Parallel map for libraries, through a process-wide executor that the app controls.
3
+ """
4
+
5
+ from .synchronous import SynchronousExecutor
6
+
7
+ __all__ = [
8
+ 'SynchronousExecutor',
9
+ ]
File without changes
@@ -0,0 +1,48 @@
1
+ """
2
+ The stdlib doesn't have an executor like this, but it has been discussed:
3
+ - https://github.com/python/cpython/issues/80576 (bpo-36395): a request for a fake executor for tests,
4
+ which would only run the callable once result() is called.
5
+ The reporter's hand-rolled fake futures broke concurrent.futures.wait().
6
+ SynchronousExecutor runs callables during submit() and returns standard futures instead,
7
+ so wait() and as_completed() work with it.
8
+ - https://mail.python.org/archives/list/python-ideas@python.org/thread/LMTQ2AI6A7UXEFVHRGHKWD33H24FGM6G/:
9
+ a python-ideas proposal to add a SerialExecutor to concurrent.futures.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable
15
+ from concurrent.futures import Executor, Future
16
+ from typing import override
17
+
18
+
19
+ class SynchronousExecutor(Executor):
20
+ """
21
+ Runs each callable right away in the thread calling submit(), and returns an already completed Future.
22
+
23
+ Useful when an executor is required, but everything should run serially,
24
+ e.g. to opt out of the shared pool for one object, or for debugging and profiling.
25
+ Unlike pool executors, it lets BaseExceptions like KeyboardInterrupt propagate from submit() right away,
26
+ since they happen in the caller's thread.
27
+ """
28
+
29
+ def __init__(self) -> None:
30
+ self._shutdown = False
31
+
32
+ @override
33
+ def submit[**P, R](self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> Future[R]:
34
+ if self._shutdown:
35
+ raise RuntimeError('cannot schedule new futures after shutdown')
36
+ future: Future[R] = Future()
37
+ try:
38
+ result = fn(*args, **kwargs)
39
+ except Exception as e:
40
+ future.set_exception(e)
41
+ else:
42
+ future.set_result(result)
43
+ return future
44
+
45
+ @override
46
+ def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None:
47
+ # everything submitted has already completed, so there is nothing to wait for or cancel
48
+ self._shutdown = True
@@ -0,0 +1,107 @@
1
+ import threading
2
+ import traceback
3
+ from concurrent.futures import as_completed, wait
4
+
5
+ import pytest
6
+
7
+ from koncurrent import SynchronousExecutor
8
+
9
+
10
+ def thread_name() -> str:
11
+ return threading.current_thread().name
12
+
13
+
14
+ def test_runs_in_calling_thread() -> None:
15
+ """
16
+ The callable runs in the thread that called submit(), not in some worker thread.
17
+ """
18
+ future = SynchronousExecutor().submit(thread_name)
19
+ assert future.done()
20
+ assert future.result() == threading.current_thread().name
21
+
22
+
23
+ def test_runs_during_submit() -> None:
24
+ """
25
+ The callable runs during submit() itself, rather than later (e.g. when someone asks for the result).
26
+ So it behaves like a plain function call: side effects happen in order with the caller's own code,
27
+ and work submitted just for its side effects runs even if nobody calls result().
28
+ """
29
+ calls: list[int] = []
30
+ executor = SynchronousExecutor()
31
+ first = executor.submit(calls.append, 1)
32
+ calls.append(2)
33
+ second = executor.submit(calls.append, 3)
34
+ # nobody asked for any results, but both calls already happened, in order with the caller's own code
35
+ assert calls == [1, 2, 3]
36
+ assert first.done()
37
+ assert second.done()
38
+
39
+
40
+ def test_futures_are_done_when_submit_returns() -> None:
41
+ """
42
+ Code written for pool executors often waits via concurrent.futures.wait() or as_completed() rather than result().
43
+ With a SynchronousExecutor, nothing else would ever complete the futures,
44
+ so they have to be done by the time submit() returns, otherwise such code would block forever.
45
+ """
46
+ executor = SynchronousExecutor()
47
+ futures = [executor.submit(int, s) for s in ['1', '2']]
48
+ _, not_done = wait(futures, timeout=0)
49
+ assert not_done == set()
50
+ assert sorted(f.result() for f in as_completed(futures, timeout=0)) == [1, 2]
51
+
52
+
53
+ def test_exception_goes_into_future() -> None:
54
+ """
55
+ An exception raised by the callable doesn't escape from submit(), it's stored in the returned future instead.
56
+ Same as with pool executors, so callers handle errors the same way whichever executor they get.
57
+ """
58
+ future = SynchronousExecutor().submit(int, 'x')
59
+ assert future.done()
60
+ assert isinstance(future.exception(), ValueError)
61
+
62
+
63
+ def test_exception_keeps_traceback() -> None:
64
+ """
65
+ The stored exception keeps its traceback, so it still points at where it was raised.
66
+ result() reraises that same exception, adding its own frames on top of the original ones.
67
+ """
68
+
69
+ def fail() -> None:
70
+ raise ValueError('boom')
71
+
72
+ future = SynchronousExecutor().submit(fail)
73
+ assert future.done()
74
+ error = future.exception()
75
+ assert error is not None
76
+ assert traceback.extract_tb(error.__traceback__)[-1].name == 'fail'
77
+
78
+ with pytest.raises(ValueError) as excinfo:
79
+ future.result()
80
+ assert excinfo.value is error
81
+ frames = [frame.name for frame in traceback.extract_tb(error.__traceback__)]
82
+ assert frames[0] == 'test_exception_keeps_traceback'
83
+ assert frames[-1] == 'fail'
84
+
85
+
86
+ def test_keyboard_interrupt_propagates_right_away() -> None:
87
+ """
88
+ Unlike ordinary exceptions, BaseExceptions like KeyboardInterrupt propagate from submit() right away.
89
+ The callable runs in the caller's thread, so this is the caller's own Ctrl-C:
90
+ storing it in the future would delay it until someone calls result(), or swallow it if nobody does.
91
+ """
92
+
93
+ def interrupt() -> None:
94
+ raise KeyboardInterrupt
95
+
96
+ with pytest.raises(KeyboardInterrupt):
97
+ SynchronousExecutor().submit(interrupt)
98
+
99
+
100
+ def test_no_submit_after_shutdown() -> None:
101
+ """
102
+ Like other executors, it refuses new work with RuntimeError after shutdown(), e.g. after leaving a `with executor:` block.
103
+ """
104
+ executor = SynchronousExecutor()
105
+ executor.shutdown()
106
+ with pytest.raises(RuntimeError):
107
+ executor.submit(int, '1')