pydocket 0.0.1__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.

Potentially problematic release.


This version of pydocket might be problematic. Click here for more details.

@@ -0,0 +1,41 @@
1
+ docket is a distributed background task system for Python functions with a focus
2
+ on the scheduling of future work as seamlessly and efficiency as immediate work.
3
+
4
+ docket is built in Python and uses Redis as the message broker and storage system.
5
+
6
+ docket integrates two modes of task execution:
7
+
8
+ 1. Immediate tasks are pushed onto a Redis stream and are available to be
9
+ picked up by any worker.
10
+ 2. Scheduled tasks are pushed onto a Redis sorted set with a schedule time.
11
+ A loop within each worker moves scheduled tasks onto the stream when their
12
+ schedule time has arrived. This move is performed as a Lua script to ensure
13
+ atomicity. Once a scheduled task is moved onto the stream, it is now an
14
+ immediate task and can't be rescheduled.
15
+
16
+ docket inherently understands self-perpetuating chains of tasks, where a task
17
+ will repeatedly reschedule itself until it is no longer needed. This is supported
18
+ directly in the developer API so that devs don't need to worry about the mechanics.
19
+
20
+ Tasks have unique identifiers that may be set by the caller in order to guarantee
21
+ idempotency of an execution.
22
+
23
+ A docket worker should be as easily usable in code as it is from the command line,
24
+ and should be a breeze to use with test suites.
25
+
26
+ # Code style
27
+
28
+ When generating production code, always use full parameter and return type hints
29
+ for every function. Never generate useless inline comments that just reiterate
30
+ what the code is doing. It's okay to include comments in the rare case there is
31
+ something tricky going on.
32
+
33
+ When generating tests, always use parameter type hints, but never include the
34
+ `-> None` return type hint for a test function. For `pytest` fixtures, always
35
+ generate both the parameter and return type hints.
36
+
37
+ When generating tests, favor smaller, focused tests that use fixtures for reuse.
38
+ Don't include extraneous comments in the test code unless something needs more
39
+ clarity. Always generate a docstring using "should" language to describe the
40
+ aspect of the system the test is checking. Use simple direct language and avoid
41
+ sounding stuffy, but make these complete sentences.
@@ -0,0 +1,70 @@
1
+ name: Docket CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_call:
9
+
10
+ jobs:
11
+ test:
12
+ name: Test Python ${{ matrix.python-version }}
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python-version: ["3.12", "3.13"]
17
+
18
+ services:
19
+ redis:
20
+ image: redis
21
+ ports:
22
+ - 6379:6379
23
+ options: >-
24
+ --health-cmd "redis-cli ping"
25
+ --health-interval 10s
26
+ --health-timeout 5s
27
+ --health-retries 5
28
+
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+
32
+ - name: Install uv and set Python version
33
+ uses: astral-sh/setup-uv@v5
34
+ with:
35
+ python-version: ${{ matrix.python-version }}
36
+ enable-cache: true
37
+ cache-dependency-glob: "pyproject.toml"
38
+
39
+ - name: Install dependencies
40
+ run: uv sync --dev
41
+
42
+ - name: Run tests
43
+ run: uv run pytest --cov-branch --cov-report=xml
44
+
45
+ - name: Upload coverage reports to Codecov
46
+ uses: codecov/codecov-action@v5
47
+ with:
48
+ token: ${{ secrets.CODECOV_TOKEN }}
49
+
50
+ pre-commit:
51
+ name: Pre-commit checks
52
+ runs-on: ubuntu-latest
53
+ steps:
54
+ - uses: actions/checkout@v4
55
+
56
+ - name: Install uv and set Python version
57
+ uses: astral-sh/setup-uv@v5
58
+ with:
59
+ python-version: "3.12"
60
+ enable-cache: true
61
+ cache-dependency-glob: "pyproject.toml"
62
+
63
+ - name: Install dependencies
64
+ run: |
65
+ uv sync --dev
66
+ uv pip install pip
67
+
68
+ - uses: pre-commit/action@v3.0.1
69
+ with:
70
+ extra_args: --all-files
@@ -0,0 +1,40 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [created]
6
+
7
+ jobs:
8
+ ci:
9
+ name: Run CI
10
+ uses: ./.github/workflows/ci.yml
11
+
12
+ publish:
13
+ name: Build and publish to PyPI
14
+ runs-on: ubuntu-latest
15
+ needs: ci
16
+ permissions:
17
+ id-token: write
18
+ contents: read
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ with:
23
+ # Need full history for proper versioning with hatch-vcs
24
+ fetch-depth: 0
25
+
26
+ - name: Install uv and set Python version
27
+ uses: astral-sh/setup-uv@v5
28
+ with:
29
+ python-version: "3.12"
30
+ enable-cache: true
31
+ cache-dependency-glob: "pyproject.toml"
32
+
33
+ - name: Install build dependencies
34
+ run: uv pip install build hatchling hatch-vcs
35
+
36
+ - name: Build package
37
+ run: uv build
38
+
39
+ - name: Publish to PyPI
40
+ run: uv publish
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ .coverage
12
+ .envrc
13
+ .python-version
@@ -0,0 +1,30 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v4.5.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-toml
9
+ - id: check-added-large-files
10
+
11
+ - repo: https://github.com/codespell-project/codespell
12
+ rev: v2.2.6
13
+ hooks:
14
+ - id: codespell
15
+
16
+ - repo: https://github.com/astral-sh/ruff-pre-commit
17
+ rev: v0.9.7
18
+ hooks:
19
+ - id: ruff
20
+ args: [--fix, --exit-non-zero-on-fix, --show-fixes]
21
+ - id: ruff-format
22
+
23
+ - repo: local
24
+ hooks:
25
+ - id: pyright
26
+ name: pyright
27
+ entry: uv run pyright --verifytypes docket --ignoreexternal
28
+ language: system
29
+ types: [python]
30
+ pass_filenames: false
pydocket-0.0.1/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ # Released under MIT License
2
+
3
+ Copyright (c) 2025 Chris Guidry.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydocket
3
+ Version: 0.0.1
4
+ Summary: A distributed background task system for Python functions
5
+ Project-URL: Homepage, https://github.com/chrisguidry/docket
6
+ Project-URL: Bug Tracker, https://github.com/chrisguidry/docket/issues
7
+ Author-email: Chris Guidry <guid@omg.lol>
8
+ License: # Released under MIT License
9
+
10
+ Copyright (c) 2025 Chris Guidry.
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
17
+ License-File: LICENSE
18
+ Classifier: Development Status :: 4 - Beta
19
+ Classifier: License :: OSI Approved :: MIT License
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Requires-Python: >=3.12
25
+ Requires-Dist: cloudpickle>=3.1.1
26
+ Requires-Dist: redis>=5.2.1
27
+ Requires-Dist: typer>=0.15.1
28
+ Description-Content-Type: text/markdown
29
+
30
+ docket is a distributed background task system for Python functions with a focus
31
+ on the scheduling of future work as seamlessly and efficiency as immediate work.
@@ -0,0 +1,2 @@
1
+ docket is a distributed background task system for Python functions with a focus
2
+ on the scheduling of future work as seamlessly and efficiency as immediate work.
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pydocket"
7
+ dynamic = ["version"]
8
+ description = "A distributed background task system for Python functions"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Chris Guidry", email = "guid@omg.lol" }]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ ]
21
+ dependencies = ["cloudpickle>=3.1.1", "redis>=5.2.1", "typer>=0.15.1"]
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "codespell>=2.4.1",
26
+ "mypy>=1.14.1",
27
+ "pre-commit>=4.1.0",
28
+ "pyright>=1.1.394",
29
+ "pytest>=8.3.4",
30
+ "pytest-asyncio>=0.25.3",
31
+ "pytest-cov>=6.0.0",
32
+ "pytest-xdist>=3.6.1",
33
+ "ruff>=0.9.7",
34
+ "testcontainers>=4.9.1",
35
+ ]
36
+
37
+ [project.scripts]
38
+ docket = "docket.__main__:app"
39
+
40
+ [project.urls]
41
+ "Homepage" = "https://github.com/chrisguidry/docket"
42
+ "Bug Tracker" = "https://github.com/chrisguidry/docket/issues"
43
+
44
+ [tool.hatch.version]
45
+ source = "vcs"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/docket"]
49
+
50
+ [tool.pytest.ini_options]
51
+ addopts = "--cov=src/docket --cov=tests --cov-report=term-missing --cov-branch"
52
+ asyncio_mode = "auto"
53
+ asyncio_default_fixture_loop_scope = "session"
54
+
55
+ [tool.pyright]
56
+ include = ["src", "tests"]
57
+ typeCheckingMode = "strict"
58
+ venvPath = "."
59
+ venv = ".venv"
@@ -0,0 +1,24 @@
1
+ """
2
+ docket - A distributed background task system for Python functions.
3
+
4
+ docket focuses on scheduling future work as seamlessly and efficiently as immediate work.
5
+ """
6
+
7
+ from importlib.metadata import version
8
+
9
+ __version__ = version("pydocket")
10
+
11
+ from .dependencies import CurrentDocket, CurrentWorker, Retry
12
+ from .docket import Docket
13
+ from .execution import Execution
14
+ from .worker import Worker
15
+
16
+ __all__ = [
17
+ "Docket",
18
+ "Worker",
19
+ "Execution",
20
+ "CurrentDocket",
21
+ "CurrentWorker",
22
+ "Retry",
23
+ "__version__",
24
+ ]
@@ -0,0 +1,3 @@
1
+ from docket.cli import app
2
+
3
+ app()
@@ -0,0 +1,23 @@
1
+ import typer
2
+
3
+ from docket import __version__
4
+
5
+ app: typer.Typer = typer.Typer(
6
+ help="Docket - A distributed background task system for Python functions",
7
+ add_completion=True,
8
+ no_args_is_help=True,
9
+ )
10
+
11
+
12
+ @app.command(
13
+ help="Start a worker to process tasks",
14
+ )
15
+ def worker() -> None:
16
+ print("TODO: start the worker")
17
+
18
+
19
+ @app.command(
20
+ help="Print the version of Docket",
21
+ )
22
+ def version() -> None:
23
+ print(__version__)
@@ -0,0 +1,77 @@
1
+ import abc
2
+ import inspect
3
+ from datetime import timedelta
4
+ from typing import Any, Awaitable, Callable, Counter, cast
5
+
6
+ from .docket import Docket
7
+ from .execution import Execution
8
+ from .worker import Worker
9
+
10
+
11
+ class Dependency(abc.ABC):
12
+ single: bool = False
13
+
14
+ @abc.abstractmethod
15
+ def __call__(
16
+ self, docket: Docket, worker: Worker, execution: Execution
17
+ ) -> Any: ... # pragma: no cover
18
+
19
+
20
+ class _CurrentWorker(Dependency):
21
+ def __call__(self, docket: Docket, worker: Worker, execution: Execution) -> Worker:
22
+ return worker
23
+
24
+
25
+ def CurrentWorker() -> Worker:
26
+ return cast(Worker, _CurrentWorker())
27
+
28
+
29
+ class _CurrentDocket(Dependency):
30
+ def __call__(self, docket: Docket, worker: Worker, execution: Execution) -> Docket:
31
+ return docket
32
+
33
+
34
+ def CurrentDocket() -> Docket:
35
+ return cast(Docket, _CurrentDocket())
36
+
37
+
38
+ class Retry(Dependency):
39
+ single: bool = True
40
+
41
+ def __init__(self, attempts: int = 1, delay: timedelta = timedelta(0)) -> None:
42
+ self.attempts = attempts
43
+ self.delay = delay
44
+ self.attempt = 1
45
+
46
+ def __call__(self, docket: Docket, worker: Worker, execution: Execution) -> "Retry":
47
+ retry = Retry(attempts=self.attempts, delay=self.delay)
48
+ retry.attempt = execution.attempt
49
+ return retry
50
+
51
+
52
+ def get_dependency_parameters(
53
+ function: Callable[..., Awaitable[Any]],
54
+ ) -> dict[str, Dependency]:
55
+ dependencies: dict[str, Any] = {}
56
+
57
+ signature = inspect.signature(function)
58
+
59
+ for param_name, param in signature.parameters.items():
60
+ if not isinstance(param.default, Dependency):
61
+ continue
62
+
63
+ dependencies[param_name] = param.default
64
+
65
+ return dependencies
66
+
67
+
68
+ def validate_dependencies(function: Callable[..., Awaitable[Any]]) -> None:
69
+ parameters = get_dependency_parameters(function)
70
+
71
+ counts = Counter(type(dependency) for dependency in parameters.values())
72
+
73
+ for dependency_type, count in counts.items():
74
+ if dependency_type.single and count > 1:
75
+ raise ValueError(
76
+ f"Only one {dependency_type.__name__} dependency is allowed per task"
77
+ )
@@ -0,0 +1,178 @@
1
+ from contextlib import asynccontextmanager
2
+ from datetime import datetime, timezone
3
+ from types import TracebackType
4
+ from typing import (
5
+ Any,
6
+ AsyncGenerator,
7
+ Awaitable,
8
+ Callable,
9
+ ParamSpec,
10
+ Self,
11
+ TypeVar,
12
+ overload,
13
+ )
14
+ from uuid import uuid4
15
+
16
+ from redis.asyncio import Redis
17
+
18
+ from .execution import Execution
19
+
20
+ P = ParamSpec("P")
21
+ R = TypeVar("R")
22
+
23
+
24
+ class Docket:
25
+ tasks: dict[str, Callable[..., Awaitable[Any]]]
26
+
27
+ def __init__(
28
+ self,
29
+ name: str = "docket",
30
+ host: str = "localhost",
31
+ port: int = 6379,
32
+ db: int = 0,
33
+ password: str | None = None,
34
+ ) -> None:
35
+ self.name = name
36
+ self.host = host
37
+ self.port = port
38
+ self.db = db
39
+ self.password = password
40
+
41
+ async def __aenter__(self) -> Self:
42
+ self.tasks = {}
43
+ return self
44
+
45
+ async def __aexit__(
46
+ self,
47
+ exc_type: type[BaseException] | None,
48
+ exc_value: BaseException | None,
49
+ traceback: TracebackType | None,
50
+ ) -> None:
51
+ pass
52
+
53
+ @asynccontextmanager
54
+ async def redis(self) -> AsyncGenerator[Redis, None]:
55
+ async with Redis(
56
+ host=self.host,
57
+ port=self.port,
58
+ db=self.db,
59
+ password=self.password,
60
+ single_connection_client=True,
61
+ ) as redis:
62
+ yield redis
63
+
64
+ def register(self, function: Callable[..., Awaitable[Any]]) -> None:
65
+ from .dependencies import validate_dependencies
66
+
67
+ validate_dependencies(function)
68
+
69
+ self.tasks[function.__name__] = function
70
+
71
+ @overload
72
+ def add(
73
+ self,
74
+ function: Callable[P, Awaitable[R]],
75
+ when: datetime | None = None,
76
+ key: str | None = None,
77
+ ) -> Callable[P, Awaitable[Execution]]: ... # pragma: no cover
78
+
79
+ @overload
80
+ def add(
81
+ self,
82
+ function: str,
83
+ when: datetime | None = None,
84
+ key: str | None = None,
85
+ ) -> Callable[..., Awaitable[Execution]]: ... # pragma: no cover
86
+
87
+ def add(
88
+ self,
89
+ function: Callable[P, Awaitable[R]] | str,
90
+ when: datetime | None = None,
91
+ key: str | None = None,
92
+ ) -> Callable[..., Awaitable[Execution]]:
93
+ if isinstance(function, str):
94
+ function = self.tasks[function]
95
+ else:
96
+ self.register(function)
97
+
98
+ if when is None:
99
+ when = datetime.now(timezone.utc)
100
+
101
+ if key is None:
102
+ key = f"{function.__name__}:{uuid4()}"
103
+
104
+ async def scheduler(*args: P.args, **kwargs: P.kwargs) -> Execution:
105
+ execution = Execution(function, args, kwargs, when, key, attempt=1)
106
+ await self.schedule(execution)
107
+ return execution
108
+
109
+ return scheduler
110
+
111
+ @overload
112
+ def replace(
113
+ self,
114
+ function: Callable[P, Awaitable[R]],
115
+ when: datetime,
116
+ key: str,
117
+ ) -> Callable[P, Awaitable[Execution]]: ... # pragma: no cover
118
+
119
+ @overload
120
+ def replace(
121
+ self,
122
+ function: str,
123
+ when: datetime,
124
+ key: str,
125
+ ) -> Callable[..., Awaitable[Execution]]: ... # pragma: no cover
126
+
127
+ def replace(
128
+ self,
129
+ function: Callable[P, Awaitable[R]] | str,
130
+ when: datetime,
131
+ key: str,
132
+ ) -> Callable[..., Awaitable[Execution]]:
133
+ if isinstance(function, str):
134
+ function = self.tasks[function]
135
+
136
+ async def scheduler(*args: P.args, **kwargs: P.kwargs) -> Execution:
137
+ execution = Execution(function, args, kwargs, when, key, attempt=1)
138
+ await self.cancel(key)
139
+ await self.schedule(execution)
140
+ return execution
141
+
142
+ return scheduler
143
+
144
+ @property
145
+ def queue_key(self) -> str:
146
+ return f"{self.name}:queue"
147
+
148
+ @property
149
+ def stream_key(self) -> str:
150
+ return f"{self.name}:stream"
151
+
152
+ def parked_task_key(self, key: str) -> str:
153
+ return f"{self.name}:{key}"
154
+
155
+ async def schedule(self, execution: Execution) -> None:
156
+ message: dict[bytes, bytes] = execution.as_message()
157
+ key = execution.key
158
+ when = execution.when
159
+
160
+ async with self.redis() as redis:
161
+ # if the task is already in the queue, retain it
162
+ if await redis.zscore(self.queue_key, key) is not None:
163
+ return
164
+
165
+ if when <= datetime.now(timezone.utc):
166
+ await redis.xadd(self.stream_key, message)
167
+ else:
168
+ async with redis.pipeline() as pipe:
169
+ pipe.hset(self.parked_task_key(key), mapping=message)
170
+ pipe.zadd(self.queue_key, {key: when.timestamp()})
171
+ await pipe.execute()
172
+
173
+ async def cancel(self, key: str) -> None:
174
+ async with self.redis() as redis:
175
+ async with redis.pipeline() as pipe:
176
+ pipe.delete(self.parked_task_key(key))
177
+ pipe.zrem(self.queue_key, key)
178
+ await pipe.execute()
@@ -0,0 +1,47 @@
1
+ from datetime import datetime
2
+ from typing import Any, Awaitable, Callable, Self
3
+
4
+ import cloudpickle
5
+
6
+ Message = dict[bytes, bytes]
7
+
8
+
9
+ class Execution:
10
+ def __init__(
11
+ self,
12
+ function: Callable[..., Awaitable[Any]],
13
+ args: tuple[Any, ...],
14
+ kwargs: dict[str, Any],
15
+ when: datetime,
16
+ key: str,
17
+ attempt: int,
18
+ ) -> None:
19
+ self.function = function
20
+ self.args = args
21
+ self.kwargs = kwargs
22
+ self.when = when
23
+ self.key = key
24
+ self.attempt = attempt
25
+
26
+ def as_message(self) -> Message:
27
+ return {
28
+ b"key": self.key.encode(),
29
+ b"when": self.when.isoformat().encode(),
30
+ b"function": self.function.__name__.encode(),
31
+ b"args": cloudpickle.dumps(self.args),
32
+ b"kwargs": cloudpickle.dumps(self.kwargs),
33
+ b"attempt": str(self.attempt).encode(),
34
+ }
35
+
36
+ @classmethod
37
+ def from_message(
38
+ cls, function: Callable[..., Awaitable[Any]], message: Message
39
+ ) -> Self:
40
+ return cls(
41
+ function=function,
42
+ args=cloudpickle.loads(message[b"args"]),
43
+ kwargs=cloudpickle.loads(message[b"kwargs"]),
44
+ when=datetime.fromisoformat(message[b"when"].decode()),
45
+ key=message[b"key"].decode(),
46
+ attempt=int(message[b"attempt"].decode()),
47
+ )
File without changes