Skip to content
Open
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
27 changes: 18 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,16 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: [
["3.8", "py38"],
["3.9", "py39"],
["3.10", "py310"],
["3.11", "py311"],
["3.12", "py312"],
["3.13", "py313"],
["3.14", "py314"],
["3.14t", "py314t"],
]
steps:
- name: Set git to use LF on Windows
if: runner.os == 'Windows'
Expand All @@ -34,12 +43,12 @@ jobs:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
python-version: ${{ matrix.python-version[0] }}
allow-prereleases: true
- name: Run tests
run: |
python -m pip install tox
tox --skip-missing-interpreters
tox -e ${{ matrix.python-version[1] }}

package-sdist:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -80,21 +89,21 @@ jobs:
# run: choco install vcpython27 -f -y
- name: Install QEMU
if: runner.os == 'Linux'
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v3
with:
platforms: all

- name: Build wheels
run: python -m cibuildwheel --output-dir wheelhouse
env:
CIBW_BUILD: cp38-* pp*-*
CIBW_BUILD: "cp38-* pp*-* cp314t-*"
CIBW_SKIP: "*musllinux*"
CIBW_ENABLE: pypy
CIBW_ARCHS_LINUX: auto aarch64
CIBW_BEFORE_BUILD_LINUX: yum install -y libffi-devel
- uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.os }}
name: wheels-${{ matrix.os }}-${{ matrix.wheel-tag }}
path: ./wheelhouse/*.whl

publish:
Expand All @@ -108,15 +117,15 @@ jobs:
path: dist/
- uses: actions/download-artifact@v6
with:
name: wheels-windows-latest
pattern: wheels-windows-latest-*
path: dist/
- uses: actions/download-artifact@v6
with:
name: wheels-macos-latest
pattern: wheels-macos-latest-*
path: dist/
- uses: actions/download-artifact@v6
with:
name: wheels-ubuntu-latest
pattern: wheels-ubuntu-latest-*
path: dist/
- name: Publish to PyPI
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/')
Expand Down
14 changes: 9 additions & 5 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
import platform
import sys
import sysconfig
from setuptools import find_packages, setup
from setuptools.command.build_ext import build_ext

Expand Down Expand Up @@ -74,11 +75,13 @@ def run(self):
except ImportError:
pass
else:
class BDistWheel(wheel.bdist_wheel.bdist_wheel):
def finalize_options(self):
self.py_limited_api = "cp3{}".format(sys.version_info[1])
wheel.bdist_wheel.bdist_wheel.finalize_options(self)
cmdclass['bdist_wheel'] = BDistWheel
# the limited API is only supported on GIL builds as of Python 3.14
if not bool(sysconfig.get_config_var("Py_GIL_DISABLED")):
class BDistWheel(wheel.bdist_wheel.bdist_wheel):
def finalize_options(self):
self.py_limited_api = "cp3{}".format(sys.version_info[1])
wheel.bdist_wheel.bdist_wheel.finalize_options(self)
cmdclass['bdist_wheel'] = BDistWheel

setup(
name="brotlicffi",
Expand Down Expand Up @@ -122,5 +125,6 @@ def finalize_options(self):
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]
)
124 changes: 85 additions & 39 deletions src/brotlicffi/_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
import math
import enum
import threading

from ._brotlicffi import ffi, lib

Expand Down Expand Up @@ -249,6 +250,7 @@ def __init__(self,
quality=lib.BROTLI_DEFAULT_QUALITY,
lgwin=lib.BROTLI_DEFAULT_WINDOW,
lgblock=0):
self.lock = threading.RLock()
enc = lib.BrotliEncoderCreateInstance(
ffi.NULL, ffi.NULL, ffi.NULL
)
Expand All @@ -271,28 +273,34 @@ def _compress(self, data, operation):
because almost all of the code uses the exact same setup. It wouldn't
have to, but it doesn't hurt at all.
"""
# The 'algorithm' for working out how big to make this buffer is from
# the Brotli source code, brotlimodule.cc.
original_output_size = int(
math.ceil(len(data) + (len(data) >> 2) + 10240)
)
available_out = ffi.new("size_t *")
available_out[0] = original_output_size
output_buffer = ffi.new("uint8_t []", available_out[0])
ptr_to_output_buffer = ffi.new("uint8_t **", output_buffer)
input_size = ffi.new("size_t *", len(data))
input_buffer = ffi.new("uint8_t []", data)
ptr_to_input_buffer = ffi.new("uint8_t **", input_buffer)

rc = lib.BrotliEncoderCompressStream(
self._encoder,
operation,
input_size,
ptr_to_input_buffer,
available_out,
ptr_to_output_buffer,
ffi.NULL
)
if not self.lock.acquire(blocking=False):
raise error(
"Concurrently sharing Compressor objects is not allowed")
try:
# The 'algorithm' for working out how big to make this buffer is
# from the Brotli source code, brotlimodule.cc.
original_output_size = int(
math.ceil(len(data) + (len(data) >> 2) + 10240)
)
available_out = ffi.new("size_t *")
available_out[0] = original_output_size
output_buffer = ffi.new("uint8_t []", available_out[0])
ptr_to_output_buffer = ffi.new("uint8_t **", output_buffer)
input_size = ffi.new("size_t *", len(data))
input_buffer = ffi.new("uint8_t []", data)
ptr_to_input_buffer = ffi.new("uint8_t **", input_buffer)

rc = lib.BrotliEncoderCompressStream(
self._encoder,
operation,
input_size,
ptr_to_input_buffer,
available_out,
ptr_to_output_buffer,
ffi.NULL
)
finally:
self.lock.release()
if rc != lib.BROTLI_TRUE: # pragma: no cover
raise error("Error encountered compressing data.")

Expand Down Expand Up @@ -320,11 +328,17 @@ def flush(self):
will not destroy the compressor. It can be used, for example, to ensure
that given chunks of content will decompress immediately.
"""
chunks = [self._compress(b'', lib.BROTLI_OPERATION_FLUSH)]

while lib.BrotliEncoderHasMoreOutput(self._encoder) == lib.BROTLI_TRUE:
chunks.append(self._compress(b'', lib.BROTLI_OPERATION_FLUSH))

if not self.lock.acquire(blocking=False):
raise error(
"Concurrently sharing Compressor objects is not allowed")
try:
chunks = [self._compress(b'', lib.BROTLI_OPERATION_FLUSH)]

while ((lib.BrotliEncoderHasMoreOutput(self._encoder) ==
lib.BROTLI_TRUE)):
chunks.append(self._compress(b'', lib.BROTLI_OPERATION_FLUSH))
finally:
self.lock.release()
return b''.join(chunks)

def finish(self):
Expand All @@ -333,10 +347,16 @@ def finish(self):
transition the compressor to a completed state. The compressor cannot
be used again after this point, and must be replaced.
"""
chunks = []
while lib.BrotliEncoderIsFinished(self._encoder) == lib.BROTLI_FALSE:
chunks.append(self._compress(b'', lib.BROTLI_OPERATION_FINISH))

if not self.lock.acquire(blocking=False):
raise error(
"Concurrently sharing Compressor objects is not allowed")
try:
chunks = []
while ((lib.BrotliEncoderIsFinished(self._encoder) ==
lib.BROTLI_FALSE)):
chunks.append(self._compress(b'', lib.BROTLI_OPERATION_FINISH))
finally:
self.lock.release()
return b''.join(chunks)


Expand All @@ -362,6 +382,7 @@ class Decompressor(object):
_unconsumed_data = None

def __init__(self, dictionary=b''):
self.lock = threading.Lock()
dec = lib.BrotliDecoderCreateInstance(ffi.NULL, ffi.NULL, ffi.NULL)
self._decoder = ffi.gc(dec, lib.BrotliDecoderDestroyInstance)
self._unconsumed_data = b''
Expand Down Expand Up @@ -409,6 +430,16 @@ def decompress(self, data, output_buffer_limit=None):
:type output_buffer_limit: ``int`` or ``None``
:returns: A bytestring containing the decompressed data.
"""
if not self.lock.acquire(blocking=False):
raise error(
"Concurrently sharing Decompressor instances is not allowed")
try:
chunks = self._decompress(data, output_buffer_limit)
finally:
self.lock.release()
return b''.join(chunks)

def _decompress(self, data, output_buffer_limit):
if self._unconsumed_data and data:
raise error(
"brotli: decoder process called with data when "
Expand Down Expand Up @@ -486,8 +517,7 @@ def decompress(self, data, output_buffer_limit=None):
else:
# It's cool if we need more output, we just loop again.
assert rc == lib.BROTLI_DECODER_RESULT_NEEDS_MORE_OUTPUT

return b''.join(chunks)
return chunks

process = decompress

Expand Down Expand Up @@ -527,7 +557,15 @@ def is_finished(self):
Returns ``True`` if the decompression stream
is complete, ``False`` otherwise
"""
return lib.BrotliDecoderIsFinished(self._decoder) == lib.BROTLI_TRUE
if not self.lock.acquire(blocking=False):
raise error(
"Concurrently sharing Decompressor instances is not allowed")
try:
ret = (
lib.BrotliDecoderIsFinished(self._decoder) == lib.BROTLI_TRUE)
finally:
self.lock.release()
return ret

def can_accept_more_data(self):
"""
Expand All @@ -550,8 +588,16 @@ def can_accept_more_data(self):
more compressed data.
:rtype: ``bool``
"""
if len(self._unconsumed_data) > 0:
return False
if lib.BrotliDecoderHasMoreOutput(self._decoder) == lib.BROTLI_TRUE:
return False
return True
if not self.lock.acquire(blocking=False):
raise error(
"Concurrently sharing Decompressor instances is not allowed")
try:
ret = True
if len(self._unconsumed_data) > 0:
ret = False
if ((lib.BrotliDecoderHasMoreOutput(self._decoder) ==
lib.BROTLI_TRUE)):
ret = False
finally:
self.lock.release()
return ret
Loading
Loading