From 7948253de16ba4f8501c783ee94855d0eae10c89 Mon Sep 17 00:00:00 2001 From: Adonis-Diaz Date: Wed, 15 Jul 2026 14:39:41 -0400 Subject: [PATCH 1/9] 010 - pystalk: wrap raw connection errors in BeanstalkConnectionError --- docs/source/index.rst | 3 +++ pystalk/__init__.py | 4 ++-- pystalk/client.py | 34 +++++++++++++++++++++++++++++++++- tests/unit/test_pystalk.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 3a1155f..cecba9b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -29,6 +29,9 @@ Members .. autoclass:: pystalk.BeanstalkError :members: +.. autoclass:: pystalk.BeanstalkConnectionError + :members: + .. autoclass:: pystalk.client.Job :members: diff --git a/pystalk/__init__.py b/pystalk/__init__.py index e290c0f..0a2ba9f 100644 --- a/pystalk/__init__.py +++ b/pystalk/__init__.py @@ -1,4 +1,4 @@ -from .client import BeanstalkClient, BeanstalkError +from .client import BeanstalkClient, BeanstalkError, BeanstalkConnectionError from .pool import ProductionPool version_info = (0, 7, 0) @@ -6,4 +6,4 @@ __author__ = 'EasyPost ' -__all__ = ['BeanstalkClient', 'BeanstalkError', 'ProductionPool'] +__all__ = ['BeanstalkClient', 'BeanstalkError', 'BeanstalkConnectionError', 'ProductionPool'] diff --git a/pystalk/client.py b/pystalk/client.py index 07423e8..4ed1e18 100644 --- a/pystalk/client.py +++ b/pystalk/client.py @@ -30,6 +30,31 @@ class BeanstalkError(Exception): message: str = attr.ib(converter=lambda m: m.decode('ascii')) +class BeanstalkConnectionError(BeanstalkError): + """Raised when the underlying socket connection to beanstalkd fails. + + Distinguishes connection-level failures (e.g. connection refused, timeout, + DNS errors) from beanstalk protocol errors. Carries the ``host`` and + ``port`` that were being connected to, and preserves the original socket + error both on :attr:`original` and via ``__cause__`` (``raise ... from``). + + This is a subclass of :class:`BeanstalkError` so that existing + ``except BeanstalkError`` callers continue to catch connection failures. + """ + + def __init__(self, host, port, original): + # BeanstalkError is an attr.s(frozen=True) class whose generated + # __init__ expects a bytes ``message`` (it calls .decode('ascii')). + # We bypass it entirely: set our fields via object.__setattr__ (the + # class is frozen) and initialize the Exception base with a plain str. + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'port', port) + object.__setattr__(self, 'original', original) + msg = 'Failed to connect to beanstalkd at {0}:{1}: {2}'.format(host, port, original) + object.__setattr__(self, 'message', msg) + Exception.__init__(self, msg) + + def yaml_load(fo): # yaml.safe_load will never use the C loader; we have to detect it ourselves if hasattr(yaml, 'CSafeLoader'): @@ -135,7 +160,14 @@ def __str__(self): @property def _socket(self): if self.socket is None: - self.socket = socket.create_connection((self.host, self.port), timeout=self.socket_timeout) + try: + self.socket = socket.create_connection( + (self.host, self.port), timeout=self.socket_timeout + ) + except OSError as e: + # OSError covers ConnectionRefusedError, socket.timeout, + # socket.error, and socket.gaierror in Python 3. + raise BeanstalkConnectionError(self.host, self.port, e) from e self._re_establish_use_watch() return self.socket diff --git a/tests/unit/test_pystalk.py b/tests/unit/test_pystalk.py index db10268..72fc50a 100644 --- a/tests/unit/test_pystalk.py +++ b/tests/unit/test_pystalk.py @@ -58,3 +58,35 @@ def test_from_uri(uri, expected_host, expected_port): def test_invalid_uri_fails(uri): with pytest.raises(ValueError): pystalk.BeanstalkClient.from_uri(uri) + + +def test_connection_error_is_wrapped(monkeypatch): + def boom(*args, **kwargs): + raise ConnectionRefusedError('refused') + monkeypatch.setattr('pystalk.client.socket.create_connection', boom) + + client = pystalk.BeanstalkClient('pystalk.example.com', 11300) + # ensure fresh (no injected mock socket) + client.socket = None + + with pytest.raises(pystalk.BeanstalkConnectionError) as ei: + _ = client._socket + assert 'pystalk.example.com' in str(ei.value) + assert '11300' in str(ei.value) + # host/port context is exposed on the exception + assert ei.value.host == 'pystalk.example.com' + assert ei.value.port == 11300 + # original socket error is preserved via __cause__ + assert isinstance(ei.value.__cause__, ConnectionRefusedError) + assert isinstance(ei.value.original, ConnectionRefusedError) + + +def test_connection_error_is_a_beanstalk_error(monkeypatch): + # Success criterion: existing `except BeanstalkError` still catches it + def boom(*args, **kwargs): + raise OSError('nope') + monkeypatch.setattr('pystalk.client.socket.create_connection', boom) + client = pystalk.BeanstalkClient('h', 1) + client.socket = None + with pytest.raises(pystalk.BeanstalkError): # must still be caught here + _ = client._socket From 38128785e0a8f293d0f74c7b38af2aa3ccbe39ce Mon Sep 17 00:00:00 2001 From: Adonis-Diaz Date: Thu, 16 Jul 2026 11:20:03 -0400 Subject: [PATCH 2/9] address review feedback: use catch_and_raise decorator, un-freeze subclass, rename original to err --- pystalk/client.py | 55 ++++++++++++++++++++++++-------------- tests/unit/test_pystalk.py | 2 +- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/pystalk/client.py b/pystalk/client.py index 4ed1e18..87f5c70 100644 --- a/pystalk/client.py +++ b/pystalk/client.py @@ -1,4 +1,5 @@ from contextlib import contextmanager +from functools import wraps import attr import socket import yaml @@ -36,22 +37,23 @@ class BeanstalkConnectionError(BeanstalkError): Distinguishes connection-level failures (e.g. connection refused, timeout, DNS errors) from beanstalk protocol errors. Carries the ``host`` and ``port`` that were being connected to, and preserves the original socket - error both on :attr:`original` and via ``__cause__`` (``raise ... from``). + error both on :attr:`err` and via ``__cause__`` (``raise ... from``). This is a subclass of :class:`BeanstalkError` so that existing ``except BeanstalkError`` callers continue to catch connection failures. """ - def __init__(self, host, port, original): - # BeanstalkError is an attr.s(frozen=True) class whose generated - # __init__ expects a bytes ``message`` (it calls .decode('ascii')). - # We bypass it entirely: set our fields via object.__setattr__ (the - # class is frozen) and initialize the Exception base with a plain str. - object.__setattr__(self, 'host', host) - object.__setattr__(self, 'port', port) - object.__setattr__(self, 'original', original) - msg = 'Failed to connect to beanstalkd at {0}:{1}: {2}'.format(host, port, original) - object.__setattr__(self, 'message', msg) + # BeanstalkError is an attr.s(frozen=True) class, and frozen __setattr__ + # is inherited here too. Un-freeze this subclass so we can use plain + # attribute assignment below instead of object.__setattr__. + __setattr__ = object.__setattr__ + + def __init__(self, host, port, err): + self.host = host + self.port = port + self.err = err + msg = 'Failed to connect to beanstalkd at {0}:{1}: {2}'.format(host, port, err) + self.message = msg Exception.__init__(self, msg) @@ -63,6 +65,20 @@ def yaml_load(fo): return yaml.safe_load(fo) +def catch_and_raise(*errors, raise_as=BeanstalkConnectionError): + """Catches specified errors on an instance method and re-raises + as ``raise_as(self.host, self.port, err)``, preserving the chain.""" + def decorator(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except errors as e: + raise raise_as(self.host, self.port, e) from e + return wrapper + return decorator + + @attr.s(frozen=True) class BeanstalkInsertingProxy(object): """Proxy object yielded by :func:`BeanstalkClient.using()`""" @@ -157,18 +173,17 @@ def __str__(self): repr(self), self._watchlist, self.current_tube # pragma: no cover ) # pragma: no cover + @catch_and_raise(ConnectionRefusedError, socket.timeout, socket.error, socket.gaierror) + def _connect(self): + self.socket = socket.create_connection( + (self.host, self.port), timeout=self.socket_timeout + ) + self._re_establish_use_watch() + @property def _socket(self): if self.socket is None: - try: - self.socket = socket.create_connection( - (self.host, self.port), timeout=self.socket_timeout - ) - except OSError as e: - # OSError covers ConnectionRefusedError, socket.timeout, - # socket.error, and socket.gaierror in Python 3. - raise BeanstalkConnectionError(self.host, self.port, e) from e - self._re_establish_use_watch() + self._connect() return self.socket def _re_establish_use_watch(self): diff --git a/tests/unit/test_pystalk.py b/tests/unit/test_pystalk.py index 72fc50a..1043af4 100644 --- a/tests/unit/test_pystalk.py +++ b/tests/unit/test_pystalk.py @@ -78,7 +78,7 @@ def boom(*args, **kwargs): assert ei.value.port == 11300 # original socket error is preserved via __cause__ assert isinstance(ei.value.__cause__, ConnectionRefusedError) - assert isinstance(ei.value.original, ConnectionRefusedError) + assert isinstance(ei.value.err, ConnectionRefusedError) def test_connection_error_is_a_beanstalk_error(monkeypatch): From 6adfceaa4ae8327651d5073bf5e89383eb8ef043 Mon Sep 17 00:00:00 2001 From: Adonis-Diaz Date: Thu, 16 Jul 2026 11:25:55 -0400 Subject: [PATCH 3/9] address remaining review feedback: BeanstalkError gets explicit init, use super().__init__, f-string message --- pystalk/client.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/pystalk/client.py b/pystalk/client.py index 87f5c70..5ef7110 100644 --- a/pystalk/client.py +++ b/pystalk/client.py @@ -24,11 +24,17 @@ class Job(object): job_data = attr.ib() -@attr.s(frozen=True, hash=True, eq=True) +@attr.s(frozen=True, hash=True, eq=True, init=False) class BeanstalkError(Exception): """Common error raised when something goes wrong with beanstalk""" - message: str = attr.ib(converter=lambda m: m.decode('ascii')) + message: str = attr.ib() + + def __init__(self, message: Union[str, bytes]): + if isinstance(message, bytes): + message = message.decode('ascii') + object.__setattr__(self, 'message', message) + Exception.__init__(self, message) class BeanstalkConnectionError(BeanstalkError): @@ -52,9 +58,8 @@ def __init__(self, host, port, err): self.host = host self.port = port self.err = err - msg = 'Failed to connect to beanstalkd at {0}:{1}: {2}'.format(host, port, err) - self.message = msg - Exception.__init__(self, msg) + msg = f'Failed to connect to beanstalkd at {host}:{port}: {err}' + super().__init__(msg) def yaml_load(fo): From 9df6fe75b3e19696776603c48d13a6a377b7d1e4 Mon Sep 17 00:00:00 2001 From: Adonis-Diaz Date: Tue, 21 Jul 2026 09:36:25 -0400 Subject: [PATCH 4/9] Updated reviewer comments --- pystalk/client.py | 28 ++++++++++------------------ tests/unit/test_pystalk.py | 8 ++++---- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/pystalk/client.py b/pystalk/client.py index 5ef7110..f1bed42 100644 --- a/pystalk/client.py +++ b/pystalk/client.py @@ -24,17 +24,16 @@ class Job(object): job_data = attr.ib() -@attr.s(frozen=True, hash=True, eq=True, init=False) +@attr.s(hash=True, eq=True, init=False) class BeanstalkError(Exception): - """Common error raised when something goes wrong with beanstalk""" message: str = attr.ib() def __init__(self, message: Union[str, bytes]): if isinstance(message, bytes): message = message.decode('ascii') - object.__setattr__(self, 'message', message) - Exception.__init__(self, message) + self.message = message + super().__init__(message) class BeanstalkConnectionError(BeanstalkError): @@ -49,11 +48,7 @@ class BeanstalkConnectionError(BeanstalkError): ``except BeanstalkError`` callers continue to catch connection failures. """ - # BeanstalkError is an attr.s(frozen=True) class, and frozen __setattr__ - # is inherited here too. Un-freeze this subclass so we can use plain - # attribute assignment below instead of object.__setattr__. - __setattr__ = object.__setattr__ - + def __init__(self, host, port, err): self.host = host self.port = port @@ -79,7 +74,7 @@ def wrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) except errors as e: - raise raise_as(self.host, self.port, e) from e + raise raise_as(self.host, self.port, e) return wrapper return decorator @@ -178,17 +173,14 @@ def __str__(self): repr(self), self._watchlist, self.current_tube # pragma: no cover ) # pragma: no cover - @catch_and_raise(ConnectionRefusedError, socket.timeout, socket.error, socket.gaierror) - def _connect(self): - self.socket = socket.create_connection( - (self.host, self.port), timeout=self.socket_timeout - ) - self._re_establish_use_watch() - @property + @catch_and_raise(ConnectionRefusedError, socket.timeout, socket.error, socket.gaierror) def _socket(self): if self.socket is None: - self._connect() + self.socket = socket.create_connection( + (self.host, self.port), timeout=self.socket_timeout + ) + self._re_establish_use_watch() return self.socket def _re_establish_use_watch(self): diff --git a/tests/unit/test_pystalk.py b/tests/unit/test_pystalk.py index 1043af4..dba4645 100644 --- a/tests/unit/test_pystalk.py +++ b/tests/unit/test_pystalk.py @@ -76,17 +76,17 @@ def boom(*args, **kwargs): # host/port context is exposed on the exception assert ei.value.host == 'pystalk.example.com' assert ei.value.port == 11300 - # original socket error is preserved via __cause__ - assert isinstance(ei.value.__cause__, ConnectionRefusedError) + # original socket error is preserved via implicit context chaining + assert isinstance(ei.value.__context__, ConnectionRefusedError) assert isinstance(ei.value.err, ConnectionRefusedError) def test_connection_error_is_a_beanstalk_error(monkeypatch): # Success criterion: existing `except BeanstalkError` still catches it def boom(*args, **kwargs): - raise OSError('nope') + raise pystalk.BeanstalkError('nope') monkeypatch.setattr('pystalk.client.socket.create_connection', boom) client = pystalk.BeanstalkClient('h', 1) client.socket = None - with pytest.raises(pystalk.BeanstalkError): # must still be caught here + with pytest.raises(pystalk.BeanstalkError): _ = client._socket From 7e696dfbf07aae07d1e0ebcc943e4a4c3be722dd Mon Sep 17 00:00:00 2001 From: Adonis-Diaz Date: Tue, 21 Jul 2026 10:59:42 -0400 Subject: [PATCH 5/9] Reviewer comments corrections 2 --- pystalk/client.py | 77 ++++++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/pystalk/client.py b/pystalk/client.py index f1bed42..7ccd60f 100644 --- a/pystalk/client.py +++ b/pystalk/client.py @@ -11,7 +11,8 @@ @attr.s(frozen=True) class Job(object): - """Structure holding a job returned from Beanstalk + """ + Structure holding a job returned from Beanstalk :ivar job_id: Opaque identifier for the job (to be passed to :func:`BeanstalkClient.release_job()` or :func:`BeanstalkClient.stats_job()`). @@ -33,11 +34,12 @@ def __init__(self, message: Union[str, bytes]): if isinstance(message, bytes): message = message.decode('ascii') self.message = message - super().__init__(message) + super(BeanstalkError, self).__init__(message) class BeanstalkConnectionError(BeanstalkError): - """Raised when the underlying socket connection to beanstalkd fails. + """ + Raised when the underlying socket connection to beanstalkd fails. Distinguishes connection-level failures (e.g. connection refused, timeout, DNS errors) from beanstalk protocol errors. Carries the ``host`` and @@ -53,8 +55,7 @@ def __init__(self, host, port, err): self.host = host self.port = port self.err = err - msg = f'Failed to connect to beanstalkd at {host}:{port}: {err}' - super().__init__(msg) + super(BeanstalkConnectionError, self).__init__(f"Failed to connect to beanstalkd at {host}:{port}: {err}") def yaml_load(fo): @@ -66,8 +67,10 @@ def yaml_load(fo): def catch_and_raise(*errors, raise_as=BeanstalkConnectionError): - """Catches specified errors on an instance method and re-raises - as ``raise_as(self.host, self.port, err)``, preserving the chain.""" + """ + Catches specified errors on an instance method and re-raises + as ``raise_as(self.host, self.port, err)``, preserving the chain. + """ def decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): @@ -87,7 +90,8 @@ class BeanstalkInsertingProxy(object): tube: str = attr.ib() def put_job(self, data: Union[str, bytes], pri: int = 65536, delay: int = 0, ttr: int = 120): - """Method to insert a job into the tube selected with :func:`BeanstalkClient.using`. + """ + Method to insert a job into the tube selected with :func:`BeanstalkClient.using`. :param data: Job body :type data: Text (either str which will be encoded as utf-8, or bytes which are already utf-8 @@ -105,7 +109,8 @@ def put_job(self, data: Union[str, bytes], pri: int = 65536, delay: int = 0, ttr class BeanstalkClient(object): - """Simple wrapper around the Beanstalk API. + """ + Simple wrapper around the Beanstalk API. :param host: Hostname or IP address to connect to :type host: str @@ -126,7 +131,9 @@ class BeanstalkClient(object): """ def __init__(self, host: str, port: int = 11300, socket_timeout: Optional[float] = None, auto_decode: bool = False): - """Construct a synchronous Beanstalk Client. Does not connect!""" + """ + Construct a synchronous Beanstalk Client. Does not connect! + """ self.host = host self.port = port self.socket_timeout = socket_timeout @@ -137,7 +144,8 @@ def __init__(self, host: str, port: int = 11300, socket_timeout: Optional[float] @classmethod def from_uri(cls, uri, socket_timeout=None, auto_decode=False): - """Construct a synchronous Beanstalk Client from a URI. + """ + Construct a synchronous Beanstalk Client from a URI. The URI may be of the form beanstalk://host:port or beanstalkd://host:port @@ -177,14 +185,13 @@ def __str__(self): @catch_and_raise(ConnectionRefusedError, socket.timeout, socket.error, socket.gaierror) def _socket(self): if self.socket is None: - self.socket = socket.create_connection( - (self.host, self.port), timeout=self.socket_timeout - ) + self.socket = socket.create_connection((self.host, self.port), timeout=self.socket_timeout) self._re_establish_use_watch() return self.socket def _re_establish_use_watch(self): - """Call after a close/re-connect. + """ + Call after a close/re-connect. Automatically re-establishes the USE and WATCH configs previously setup. """ @@ -194,7 +201,8 @@ def _re_establish_use_watch(self): self.watchlist = self.desired_watchlist def close(self): - """Close any open connection to the Beanstalk server. + """ + Close any open connection to the Beanstalk server. This object is still safe to use after calling :func:`close()` ; it will automatically reconnect and re-establish any open watches / uses. @@ -290,7 +298,8 @@ def _send_message(self, message, sock): return sock.sendall(message.encode('utf-8')) def list_tubes(self): - """Return a list of tubes that this beanstalk instance knows about + """ + Return a list of tubes that this beanstalk instance knows about :rtype: list of tubes """ @@ -301,7 +310,8 @@ def list_tubes(self): return tubes def stats(self): - """Return a dictionary with a bunch of instance-wide statistics + """ + Return a dictionary with a bunch of instance-wide statistics :rtype: dict """ @@ -312,7 +322,8 @@ def stats(self): return stats def put_job(self, data: Union[str, bytes], pri: int = 65536, delay: int = 0, ttr: int = 120): - """Insert a new job into whatever queue is currently USEd + """ + Insert a new job into whatever queue is currently USEd :param data: Job body :type data: Text (either str which will be encoded as utf-8, or bytes which are already utf-8 @@ -518,7 +529,9 @@ def peek_buried_iter(self): return self._common_iter(self.peek_buried, 'NOT_FOUND') def delete_job(self, job_id): - """Delete the given job id. The job must have been previously reserved by this connection""" + """ + Delete the given job id. The job must have been previously reserved by this connection + """ if hasattr(job_id, 'job_id'): job_id = job_id.job_id with self._sock_ctx() as socket: @@ -526,7 +539,8 @@ def delete_job(self, job_id): self._receive_word(socket, b'DELETED') def bury_job(self, job_id, pri=65536): - """Mark the given job_id as buried. The job must have been previously reserved by this connection + """ + Mark the given job_id as buried. The job must have been previously reserved by this connection :param job_id: Job to bury :param pri: Priority for the newly-buried job. If not passed, will keep its current priority @@ -539,7 +553,8 @@ def bury_job(self, job_id, pri=65536): return self._receive_word(socket, b'BURIED') def release_job(self, job_id, pri=65536, delay=0): - """Put a job back on the queue to be processed (indicating that you've aborted it) + """ + Put a job back on the queue to be processed (indicating that you've aborted it) You can only release a job which you have reserved using :func:`reserve_job()` or :func:`reserve_iter()`. @@ -556,7 +571,8 @@ def release_job(self, job_id, pri=65536, delay=0): return self._receive_word(socket, b'RELEASED', b'BURIED') def kick_job(self, job_id): - """Kick the given job id. The job must either be in the DELAYED or BURIED state and will be immediately moved to + """ + Kick the given job id. The job must either be in the DELAYED or BURIED state and will be immediately moved to the READY state.""" if hasattr(job_id, 'job_id'): job_id = job_id.job_id @@ -565,7 +581,8 @@ def kick_job(self, job_id): self._receive_word(socket, b'KICKED') def use(self, tube): - """Start producing jobs into the given tube. + """ + Start producing jobs into the given tube. :param tube: Name of the tube to USE @@ -580,7 +597,8 @@ def use(self, tube): @contextmanager def using(self, tube): - """Context-manager to insert jobs into a specific tube + """ + Context-manager to insert jobs into a specific tube :param tube: Tube to insert to @@ -605,7 +623,8 @@ def using(self, tube): self.use(current_tube) def kick_jobs(self, num_jobs): - """Kick some number of jobs from the buried queue onto the ready queue. + """ + Kick some number of jobs from the buried queue onto the ready queue. :param num_jobs: Number of jobs to kick :type num_jobs: int @@ -616,7 +635,8 @@ def kick_jobs(self, num_jobs): return self._receive_id(socket) def pause_tube(self, tube, delay=3600): - """Pause a tube for some number of seconds, preventing it from issuing jobs. + """ + Pause a tube for some number of seconds, preventing it from issuing jobs. :param delay: Time to pause for, in seconds :type delay: int @@ -633,7 +653,8 @@ def pause_tube(self, tube, delay=3600): return self._receive_word(socket, b'PAUSED') def unpause_tube(self, tube): - """Unpause a tube which was previously paused with :func:`pause_tube()`. + """ + Unpause a tube which was previously paused with :func:`pause_tube()`. .. seealso:: From a9b2768aa145ceaa565da159000483b433773732 Mon Sep 17 00:00:00 2001 From: Adonis-Diaz Date: Tue, 21 Jul 2026 13:47:12 -0400 Subject: [PATCH 6/9] Version change --- pystalk/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pystalk/__init__.py b/pystalk/__init__.py index 0a2ba9f..580379f 100644 --- a/pystalk/__init__.py +++ b/pystalk/__init__.py @@ -1,8 +1,8 @@ from .client import BeanstalkClient, BeanstalkError, BeanstalkConnectionError from .pool import ProductionPool -version_info = (0, 7, 0) -__version__ = '.'.join(str(s) for s in version_info) +__version__ = '0.8.0' + __author__ = 'EasyPost ' From 2e4ba372ba7d255edea72c43783db0bbe4edb74d Mon Sep 17 00:00:00 2001 From: Adonis-Diaz Date: Wed, 22 Jul 2026 10:36:22 -0400 Subject: [PATCH 7/9] Fix release tooling setup: justfile, pyproject.toml, release.yml, gitignore - justfile: add missing 'install' recipe header (was a bare unindented command with no recipe name, breaking the whole file's parsing), and rename 'Build' -> 'build' to match idiomatic naming and the call site in release.yml. - pyproject.toml: fix 'requies' -> 'requires' typo (mandatory PEP 518 key) and 'setup-tools' -> 'setuptools' (correct PyPI package name). - release.yml: fix invalid YAML (missing space after 'uses:', and the 'Upload assets to release' step was mis-indented as a nested item under the previous step instead of a sibling step). - .gitignore: add .venv/ alongside the existing venv/ entry so the new uv-based tooling's virtualenv isn't accidentally committed. --- .github/workflows/release.yml | 28 ++++++++++++++++++++++++++++ .gitignore | 1 + justfile | 7 +++++++ pyproject.toml | 3 +++ 4 files changed, 39 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 justfile create mode 100644 pyproject.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..55b6b06 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: release + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: extractions/setup-just@v3 + - uses: astral-sh/setup-uv@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + - name: Build package + run: just install build + - name: Publish to PyPi + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + - name: Upload assets to release + uses: AButler/upload-release-assets@v3.0.1 + with: + files: 'dist/*' + repo-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 24396df..647c58c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.egg-info .cache/ venv/ +.venv/ .tox test-*.xml .coverage diff --git a/justfile b/justfile new file mode 100644 index 0000000..71bf406 --- /dev/null +++ b/justfile @@ -0,0 +1,7 @@ +# Install the project and build its dependencies. +install: + uv pip install --system -e . -r requirements-tests.txt + +# build sdist and wheel into dist/ +build: + uv build diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7e1aec0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>61", "wheel"] +build-backend = "setuptools.build_meta" From 342fee691a4b2582cdd4db461b4da0bfe8016e22 Mon Sep 17 00:00:00 2001 From: Adonis-Diaz Date: Wed, 22 Jul 2026 11:27:47 -0400 Subject: [PATCH 8/9] Bump version to 0.8.0 and update changelog --- docs/source/CHANGES.rst | 9 ++++++++- setup.py | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/source/CHANGES.rst b/docs/source/CHANGES.rst index facf818..bbe9581 100644 --- a/docs/source/CHANGES.rst +++ b/docs/source/CHANGES.rst @@ -1,7 +1,14 @@ ################# pystalk ChangeLog ################# - +===== +0.8.0 +===== +* Add `pystalk.BeanstalkConnectionError`, a subclass of `BeanstalkError` raised when the + underlying socket connection to beanstalkd fails (connection refused, timeout, DNS errors, + etc.), distinguishing connection-level failures from beanstalk protocol errors while + remaining catchable via `except BeanstalkError` +* Preserve the original socket error via `__cause__` (`raise ... from`) on connection failures ===== 0.7.0 ===== diff --git a/setup.py b/setup.py index a823f59..b81f006 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="pystalk", - version="0.7.0", + version="0.8.0", author="EasyPost", author_email="oss@easypost.com", url="https://github.com/easypost/pystalk", From cae89534864d4021a9f8f5217b6393dfb2935430 Mon Sep 17 00:00:00 2001 From: Adonis-Diaz Date: Wed, 22 Jul 2026 11:35:30 -0400 Subject: [PATCH 9/9] Readme update to release version --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1bc94ae..5eb2298 100644 --- a/README.md +++ b/README.md @@ -135,10 +135,9 @@ Pretty straightforward. Develop in branches, send PRs, land on master. All tests ### Releasing a new version 1. Land all requisite changes - 1. Bump the version in `setup.py` and `pystalk/__init__.py` to the stable version (e.g., `0.2`) + 1. Bump the version in `setup.py` and `pystalk/__init__.py` to the stable version (e.g., `0.9.0`) 1. Update [`CHANGES.rst`](docs/source/CHANGES.rst) with the changes and the new version number 1. Update [`conf.py`](docs/source/conf.py) with the new version number - 1. Commit - 1. Tag the version (e.g., `git tag -s pystalk-0.2`) - 1. Push up to Github - 1. Upload to PyPI with `python setup.py sdist upload` + 1. Commit and push up to Github + 1. Draft a [new GitHub Release](https://github.com/EasyPost/pystalk/releases/new), targeting a new tag matching the version (e.g., `pystalk-0.9.0`) + 1. Publishing the release triggers the `release` GitHub Actions workflow, which builds the sdist/wheel and publishes them to PyPI automatically