Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +10 to +28
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
*.egg-info
.cache/
venv/
.venv/
.tox
test-*.xml
.coverage
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 8 additions & 1 deletion docs/source/CHANGES.rst
Original file line number Diff line number Diff line change
@@ -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
=====
Expand Down
3 changes: 3 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ Members
.. autoclass:: pystalk.BeanstalkError
:members:

.. autoclass:: pystalk.BeanstalkConnectionError
:members:

.. autoclass:: pystalk.client.Job
:members:

Expand Down
7 changes: 7 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools>61", "wheel"]
build-backend = "setuptools.build_meta"
8 changes: 4 additions & 4 deletions pystalk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from .client import BeanstalkClient, BeanstalkError
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 <oss@easypost.com>'


__all__ = ['BeanstalkClient', 'BeanstalkError', 'ProductionPool']
__all__ = ['BeanstalkClient', 'BeanstalkError', 'BeanstalkConnectionError', 'ProductionPool']
109 changes: 87 additions & 22 deletions pystalk/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from contextlib import contextmanager
from functools import wraps
import attr
import socket
import yaml
Expand All @@ -10,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()`).
Expand All @@ -23,11 +25,37 @@ class Job(object):
job_data = attr.ib()


@attr.s(frozen=True, hash=True, eq=True)
@attr.s(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')
self.message = message
super(BeanstalkError, self).__init__(message)


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:`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, err):
self.host = host
self.port = port
self.err = err
super(BeanstalkConnectionError, self).__init__(f"Failed to connect to beanstalkd at {host}:{port}: {err}")


def yaml_load(fo):
Expand All @@ -38,6 +66,22 @@ 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)
return wrapper
return decorator


@attr.s(frozen=True)
class BeanstalkInsertingProxy(object):
"""Proxy object yielded by :func:`BeanstalkClient.using()`"""
Expand All @@ -46,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
Expand All @@ -64,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
Expand All @@ -85,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
Expand All @@ -96,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

Expand Down Expand Up @@ -133,14 +182,16 @@ def __str__(self):
) # pragma: no cover

@property
@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._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.
"""
Expand All @@ -150,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.
Expand Down Expand Up @@ -246,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
"""
Expand All @@ -257,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
"""
Expand All @@ -268,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
Expand Down Expand Up @@ -474,15 +529,18 @@ 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:
self._send_message('delete {0}'.format(job_id), socket)
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
Expand All @@ -495,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()`.

Expand All @@ -512,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
Expand All @@ -521,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

Expand All @@ -536,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

Expand All @@ -561,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
Expand All @@ -572,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
Expand All @@ -589,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::

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading