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.
- koncurrent-0.0.20261015/.ci/cliff.toml +67 -0
- koncurrent-0.0.20261015/.ci/prepare-github-release +155 -0
- koncurrent-0.0.20261015/.ci/release +49 -0
- koncurrent-0.0.20261015/.ci/run +39 -0
- koncurrent-0.0.20261015/.github/workflows/main.yml +153 -0
- koncurrent-0.0.20261015/.gitignore +249 -0
- koncurrent-0.0.20261015/LICENSE +21 -0
- koncurrent-0.0.20261015/PKG-INFO +38 -0
- koncurrent-0.0.20261015/README.md +6 -0
- koncurrent-0.0.20261015/pyproject.toml +356 -0
- koncurrent-0.0.20261015/src/koncurrent/__init__.py +9 -0
- koncurrent-0.0.20261015/src/koncurrent/py.typed +0 -0
- koncurrent-0.0.20261015/src/koncurrent/synchronous.py +48 -0
- koncurrent-0.0.20261015/src/koncurrent/tests/test_synchronous.py +107 -0
|
@@ -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
|
+
]
|
|
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')
|