queueio 0.7.0__tar.gz → 0.8.0__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.
Files changed (109) hide show
  1. {queueio-0.7.0 → queueio-0.8.0}/.devcontainer/docker-compose.yml +4 -1
  2. queueio-0.8.0/.github/workflows/build.yml +92 -0
  3. {queueio-0.7.0 → queueio-0.8.0}/.github/workflows/docs.yml +1 -1
  4. {queueio-0.7.0 → queueio-0.8.0}/.gitignore +2 -0
  5. {queueio-0.7.0 → queueio-0.8.0}/CHANGELOG.md +20 -1
  6. {queueio-0.7.0 → queueio-0.8.0}/PKG-INFO +13 -4
  7. {queueio-0.7.0 → queueio-0.8.0}/README.md +8 -0
  8. {queueio-0.7.0 → queueio-0.8.0}/pyproject.toml +11 -6
  9. {queueio-0.7.0 → queueio-0.8.0}/pyrightconfig.json +1 -1
  10. {queueio-0.7.0 → queueio-0.8.0}/queueio/__main__.py +1 -1
  11. {queueio-0.7.0 → queueio-0.8.0}/queueio/broker_test.py +20 -8
  12. queueio-0.8.0/queueio/conftest.py +98 -0
  13. {queueio-0.7.0 → queueio-0.8.0}/queueio/monitor.py +1 -1
  14. {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/broker.py +11 -0
  15. queueio-0.8.0/queueio/pika/broker_test.py +38 -0
  16. {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/journal_test.py +2 -1
  17. {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/threadsafe.py +40 -1
  18. queueio-0.8.0/queueio/pika/threadsafe_test.py +20 -0
  19. {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/broker-queries.md +10 -4
  20. {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/journal-queries.md +1 -1
  21. {queueio-0.7.0 → queueio-0.8.0}/queueio/queueio.py +5 -4
  22. {queueio-0.7.0 → queueio-0.8.0}/queueio/queueio_test.py +31 -10
  23. {queueio-0.7.0 → queueio-0.8.0}/queueio/queuespec.md +4 -4
  24. {queueio-0.7.0 → queueio-0.8.0}/queueio/queuevar.md +1 -0
  25. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django_test.py +3 -1
  26. {queueio-0.7.0 → queueio-0.8.0}/queueio/stream.py +2 -1
  27. {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/broker.py +2 -1
  28. {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/journal.py +3 -2
  29. {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/receiver.py +5 -4
  30. {queueio-0.7.0 → queueio-0.8.0}/queueio/worker.py +3 -2
  31. queueio-0.7.0/.github/workflows/build.yml +0 -42
  32. queueio-0.7.0/queueio/conftest.py +0 -32
  33. queueio-0.7.0/queueio/pika/broker_test.py +0 -18
  34. queueio-0.7.0/queueio/queue.py +0 -183
  35. queueio-0.7.0/queueio/queue_test.py +0 -729
  36. queueio-0.7.0/queueio/select.py +0 -170
  37. {queueio-0.7.0 → queueio-0.8.0}/.devcontainer/devcontainer.json +0 -0
  38. {queueio-0.7.0 → queueio-0.8.0}/.github/workflows/publish.yml +0 -0
  39. {queueio-0.7.0 → queueio-0.8.0}/CNAME +0 -0
  40. {queueio-0.7.0 → queueio-0.8.0}/LICENSE +0 -0
  41. {queueio-0.7.0 → queueio-0.8.0}/dill.pyi +0 -0
  42. {queueio-0.7.0 → queueio-0.8.0}/docs_overrides/assets/images/favicon.svg +0 -0
  43. {queueio-0.7.0 → queueio-0.8.0}/docs_overrides/assets/images/logo.svg +0 -0
  44. {queueio-0.7.0 → queueio-0.8.0}/docs_overrides/assets/stylesheets/extra.css +0 -0
  45. {queueio-0.7.0 → queueio-0.8.0}/docs_overrides/partials/footer.html +0 -0
  46. {queueio-0.7.0 → queueio-0.8.0}/docs_overrides/partials/javascripts/palette.html +0 -0
  47. {queueio-0.7.0 → queueio-0.8.0}/logo.svg +0 -0
  48. {queueio-0.7.0 → queueio-0.8.0}/mkdocs.yml +0 -0
  49. {queueio-0.7.0 → queueio-0.8.0}/philosophy.md +0 -0
  50. {queueio-0.7.0 → queueio-0.8.0}/queueio/__init__.py +0 -0
  51. {queueio-0.7.0 → queueio-0.8.0}/queueio/backend.py +0 -0
  52. {queueio-0.7.0 → queueio-0.8.0}/queueio/broker.py +0 -0
  53. {queueio-0.7.0 → queueio-0.8.0}/queueio/consumer.py +0 -0
  54. {queueio-0.7.0 → queueio-0.8.0}/queueio/continuation.py +0 -0
  55. {queueio-0.7.0 → queueio-0.8.0}/queueio/django/README.md +0 -0
  56. {queueio-0.7.0 → queueio-0.8.0}/queueio/django/__init__.py +0 -0
  57. {queueio-0.7.0 → queueio-0.8.0}/queueio/django/apps.py +0 -0
  58. {queueio-0.7.0 → queueio-0.8.0}/queueio/django/djp.py +0 -0
  59. {queueio-0.7.0 → queueio-0.8.0}/queueio/django/management/__init__.py +0 -0
  60. {queueio-0.7.0 → queueio-0.8.0}/queueio/django/management/commands/__init__.py +0 -0
  61. {queueio-0.7.0 → queueio-0.8.0}/queueio/django/management/commands/runserver.py +0 -0
  62. {queueio-0.7.0 → queueio-0.8.0}/queueio/django/management/commands/runserver_plus.py +0 -0
  63. {queueio-0.7.0 → queueio-0.8.0}/queueio/django/runserver_test.py +0 -0
  64. {queueio-0.7.0 → queueio-0.8.0}/queueio/event.py +0 -0
  65. {queueio-0.7.0 → queueio-0.8.0}/queueio/freethreading_test.py +0 -0
  66. {queueio-0.7.0 → queueio-0.8.0}/queueio/gather.py +0 -0
  67. {queueio-0.7.0 → queueio-0.8.0}/queueio/id.py +0 -0
  68. {queueio-0.7.0 → queueio-0.8.0}/queueio/invocation.md +0 -0
  69. {queueio-0.7.0 → queueio-0.8.0}/queueio/invocation.py +0 -0
  70. {queueio-0.7.0 → queueio-0.8.0}/queueio/journal.py +0 -0
  71. {queueio-0.7.0 → queueio-0.8.0}/queueio/journal_test.py +0 -0
  72. {queueio-0.7.0 → queueio-0.8.0}/queueio/message.py +0 -0
  73. {queueio-0.7.0 → queueio-0.8.0}/queueio/pause.py +0 -0
  74. {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/__init__.py +0 -0
  75. {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/journal.py +0 -0
  76. {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/receiver.py +0 -0
  77. {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/README.md +0 -0
  78. {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/__init__.py +0 -0
  79. {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/journal.py +0 -0
  80. {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/journal_test.py +0 -0
  81. {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/receiver-queries.md +0 -0
  82. {queueio-0.7.0 → queueio-0.8.0}/queueio/queuespec.py +0 -0
  83. {queueio-0.7.0 → queueio-0.8.0}/queueio/queuespec_test.py +0 -0
  84. {queueio-0.7.0 → queueio-0.8.0}/queueio/queuevar.py +0 -0
  85. {queueio-0.7.0 → queueio-0.8.0}/queueio/receiver.py +0 -0
  86. {queueio-0.7.0 → queueio-0.8.0}/queueio/registry.py +0 -0
  87. {queueio-0.7.0 → queueio-0.8.0}/queueio/result.py +0 -0
  88. {queueio-0.7.0 → queueio-0.8.0}/queueio/routine.py +0 -0
  89. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/__init__.py +0 -0
  90. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/basic.py +0 -0
  91. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/basic_test.py +0 -0
  92. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/__init__.py +0 -0
  93. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/apps.py +0 -0
  94. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/job.py +0 -0
  95. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/models.py +0 -0
  96. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/settings.py +0 -0
  97. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/settings_djp.py +0 -0
  98. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/urls.py +0 -0
  99. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/expanded.py +0 -0
  100. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/expanded_test.py +0 -0
  101. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/priority.py +0 -0
  102. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/priority_test.py +0 -0
  103. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/queuevar.py +0 -0
  104. {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/queuevar_test.py +0 -0
  105. {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/__init__.py +0 -0
  106. {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/broker_test.py +0 -0
  107. {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/journal_test.py +0 -0
  108. {queueio-0.7.0 → queueio-0.8.0}/queueio/suspension.py +0 -0
  109. {queueio-0.7.0 → queueio-0.8.0}/queueio/thread.py +0 -0
@@ -6,5 +6,8 @@ services:
6
6
  command: sleep infinity
7
7
  rabbitmq:
8
8
  image: rabbitmq:management
9
+ environment:
10
+ RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: "-rabbit permit_deprecated_features #{global_qos=>true}"
9
11
  ports:
10
- - 15672:15672
12
+ - 127.0.0.1:5672:5672
13
+ - 15672:15672
@@ -0,0 +1,92 @@
1
+ name: Build
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ build:
7
+ runs-on: ubuntu-latest
8
+ timeout-minutes: 5
9
+ permissions:
10
+ contents: read
11
+ id-token: write
12
+ services:
13
+ rabbitmq:
14
+ image: rabbitmq
15
+ env:
16
+ RABBITMQ_DEFAULT_USER: guest
17
+ RABBITMQ_DEFAULT_PASS: guest
18
+ RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: "-rabbit permit_deprecated_features #{global_qos=>true}"
19
+ ports:
20
+ - 5672/tcp
21
+ options: >-
22
+ --health-cmd "rabbitmq-diagnostics -q ping"
23
+ --health-interval 10s
24
+ --health-timeout 5s
25
+ --health-retries 5
26
+ rabbitmq_no_global_qos:
27
+ image: rabbitmq
28
+ env:
29
+ RABBITMQ_DEFAULT_USER: guest
30
+ RABBITMQ_DEFAULT_PASS: guest
31
+ RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: "-rabbit permit_deprecated_features #{global_qos=>false}"
32
+ ports:
33
+ - 5672/tcp
34
+ options: >-
35
+ --health-cmd "rabbitmq-diagnostics -q ping"
36
+ --health-interval 10s
37
+ --health-timeout 5s
38
+ --health-retries 5
39
+ lavinmq:
40
+ # 2.10.3 and later starve consumers sharing a channel with global prefetch.
41
+ # https://github.com/cloudamqp/lavinmq/issues/2628
42
+ image: cloudamqp/lavinmq:2.10.2
43
+ ports:
44
+ - 5672/tcp
45
+ options: >-
46
+ --health-cmd "lavinmqctl status"
47
+ --health-interval 10s
48
+ --health-timeout 5s
49
+ --health-retries 5
50
+ steps:
51
+ - uses: actions/checkout@v4
52
+ - name: Install uv
53
+ uses: astral-sh/setup-uv@v3
54
+ - name: Set up Python
55
+ run: uv python install 3.15t
56
+ - name: Install dependencies
57
+ run: uv sync --all-groups
58
+ - name: Check formatting
59
+ run: uv run ruff format --check
60
+ - name: Run ruff linting
61
+ run: uv run ruff check
62
+ - name: Run basedpyright
63
+ run: uv run basedpyright
64
+ - name: Run tests
65
+ env:
66
+ QUEUEIO_BROKER: amqp://guest:guest@localhost:${{ job.services.rabbitmq.ports['5672'] }}/
67
+ run: uv run pytest --cov --cov-report=
68
+ timeout-minutes: 2
69
+ - name: Test broker without channel-wide QoS
70
+ env:
71
+ QUEUEIO_BROKER: amqp://guest:guest@localhost:${{ job.services.rabbitmq_no_global_qos.ports['5672'] }}/
72
+ run: uv run pytest -m no_global_qos --cov --cov-append --cov-report=
73
+ - name: Test LavinMQ
74
+ env:
75
+ QUEUEIO_BROKER: amqp://guest:guest@localhost:${{ job.services.lavinmq.ports['5672'] }}/
76
+ run: uv run pytest --cov --cov-append --cov-report=
77
+ timeout-minutes: 2
78
+ - name: Report coverage
79
+ run: |
80
+ uv run coverage report
81
+ uv run coverage xml
82
+ - name: Upload coverage to Codecov
83
+ uses: codecov/codecov-action@v5
84
+ with:
85
+ files: coverage.xml
86
+ disable_search: true
87
+ use_oidc: true
88
+ fail_ci_if_error: true
89
+ - name: Verify built package
90
+ run: |
91
+ uv build
92
+ uvx --from dist/queueio-*.whl queueio --help
@@ -21,7 +21,7 @@ jobs:
21
21
  steps:
22
22
  - uses: actions/checkout@v4
23
23
  - uses: astral-sh/setup-uv@v5
24
- - run: uv python install 3.14t
24
+ - run: uv python install 3.15t
25
25
  - run: uv sync --group dev
26
26
  - run: uv run mkdocs build --strict
27
27
  - uses: actions/upload-pages-artifact@v3
@@ -6,6 +6,8 @@ dist/
6
6
  wheels/
7
7
  *.egg-info
8
8
  .coverage*
9
+ coverage.xml
10
+ htmlcov/
9
11
 
10
12
  # Virtual environments
11
13
  .venv
@@ -8,6 +8,24 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com).
8
8
  [Unreleased]
9
9
  ------------
10
10
 
11
+ [0.8.0] - 2026-11-04
12
+ --------------------
13
+
14
+ ### Breaking Changes
15
+
16
+ - Python 3.15 or later is now required; Python 3.14 is no longer supported.
17
+
18
+ ### Changed
19
+
20
+ - Load the monitor UI lazily so other CLI commands avoid importing Textual.
21
+ - Use [noio](https://pypi.org/project/noio/) for internal queues and selection,
22
+ replacing `queueio.queue` and `queueio.select`.
23
+
24
+ ### Fixed
25
+
26
+ - A failed connection to the broker no longer leaves a thread running
27
+ that prevents the process from exiting.
28
+
11
29
  [0.7.0] - 2026-03-09
12
30
  --------------------
13
31
 
@@ -125,7 +143,8 @@ Thank you to Nick Anderegg for allowing me to use the queueio name for this proj
125
143
  - The queuespec syntax to `queue run` to consume multiple queues with shared capacity.
126
144
  - `queueio monitor` command to monitor activity in the queueio system.
127
145
 
128
- [Unreleased]: https://github.com/ryanhiebert/queueio/compare/tag/0.7.0...HEAD
146
+ [Unreleased]: https://github.com/ryanhiebert/queueio/compare/tag/0.8.0...HEAD
147
+ [0.8.0]: https://github.com/ryanhiebert/queueio/compare/tag/0.7.0...tag/0.8.0
129
148
  [0.7.0]: https://github.com/ryanhiebert/queueio/compare/tag/0.6.0...tag/0.7.0
130
149
  [0.6.0]: https://github.com/ryanhiebert/queueio/compare/tag/0.5.0...tag/0.6.0
131
150
  [0.5.0]: https://github.com/ryanhiebert/queueio/compare/tag/0.4.0...tag/0.5.0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: queueio
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Python background queues with an async twist
5
5
  Author-email: Ryan Hiebert <ryan@ryanhiebert.com>
6
6
  License-Expression: MIT
@@ -10,11 +10,12 @@ Classifier: Development Status :: 3 - Alpha
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Programming Language :: Python :: 3.15
14
14
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
15
  Classifier: Topic :: System :: Distributed Computing
16
- Requires-Python: >=3.14
16
+ Requires-Python: >=3.15
17
17
  Requires-Dist: dill>=0.3.9
18
+ Requires-Dist: noio>=0.3
18
19
  Requires-Dist: pika>=1.3.2
19
20
  Requires-Dist: textual>=3.2.0
20
21
  Requires-Dist: typer>=0.15.4
@@ -86,6 +87,14 @@ to allow a project to be deployed in multiple environments.
86
87
  QUEUEIO_BROKER='amqp://guest:guest@localhost:5672/'
87
88
  ```
88
89
 
90
+ QueueIO requires RabbitMQ's global QoS for shared concurrency limits and
91
+ suspending tasks. On RabbitMQ 4.3, enable it in `rabbitmq.conf` and restart
92
+ the broker:
93
+
94
+ ```ini
95
+ deprecated_features.permit.global_qos = true
96
+ ```
97
+
89
98
  Sync the queues to the broker:
90
99
 
91
100
  ```python
@@ -59,6 +59,14 @@ to allow a project to be deployed in multiple environments.
59
59
  QUEUEIO_BROKER='amqp://guest:guest@localhost:5672/'
60
60
  ```
61
61
 
62
+ QueueIO requires RabbitMQ's global QoS for shared concurrency limits and
63
+ suspending tasks. On RabbitMQ 4.3, enable it in `rabbitmq.conf` and restart
64
+ the broker:
65
+
66
+ ```ini
67
+ deprecated_features.permit.global_qos = true
68
+ ```
69
+
62
70
  Sync the queues to the broker:
63
71
 
64
72
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "queueio"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Python background queues with an async twist"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -15,13 +15,14 @@ classifiers = [
15
15
  "Intended Audience :: Developers",
16
16
  "License :: OSI Approved :: MIT License",
17
17
  "Programming Language :: Python :: 3",
18
- "Programming Language :: Python :: 3.14",
18
+ "Programming Language :: Python :: 3.15",
19
19
  "Topic :: Software Development :: Libraries :: Python Modules",
20
20
  "Topic :: System :: Distributed Computing",
21
21
  ]
22
- requires-python = ">=3.14"
22
+ requires-python = ">=3.15"
23
23
  dependencies = [
24
24
  "dill>=0.3.9",
25
+ "noio>=0.3",
25
26
  "pika>=1.3.2",
26
27
  "textual>=3.2.0",
27
28
  "typer>=0.15.4",
@@ -39,7 +40,7 @@ queueio = "queueio.__main__:app"
39
40
 
40
41
  [dependency-groups]
41
42
  dev = [
42
- "basedpyright>=1.32.1",
43
+ "basedpyright>=1.40.2",
43
44
  "django-extensions",
44
45
  "djp",
45
46
  "queueio[django]",
@@ -47,9 +48,9 @@ dev = [
47
48
  "mkdocs-material>=9.7.1",
48
49
  "mkdocs-same-dir>=0.1.3",
49
50
  "pytest>=8.4.1",
50
- "pytest-cov>=6.0.0",
51
+ "pytest-cov>=7.0.0",
51
52
  "pytest-timeout>=2.4.0",
52
- "ruff>=0.9.6",
53
+ "ruff>=0.17.0",
53
54
  "textual-dev>=1.7.0",
54
55
  "types-pika>=1.2.0b1",
55
56
  "psycopg>=3.3.3",
@@ -71,12 +72,16 @@ register = [
71
72
  broker = "amqp://localhost:5672"
72
73
 
73
74
  [tool.pytest.ini_options]
75
+ markers = ["no_global_qos: requires a broker without channel-wide QoS"]
76
+ addopts = "-m 'not no_global_qos'"
74
77
  timeout = 30
75
78
  norecursedirs = ["site"]
76
79
 
77
80
  [tool.coverage.run]
78
81
  branch = true
79
82
  source = ["queueio"]
83
+ patch = ["subprocess"]
84
+ sigterm = true
80
85
 
81
86
  [build-system]
82
87
  requires = ["hatchling"]
@@ -1,5 +1,5 @@
1
1
  {
2
- "pythonVersion": "3.14",
2
+ "pythonVersion": "3.15",
3
3
  "typeCheckingMode": "standard",
4
4
  "exclude": ["site", ".venv"]
5
5
  }
@@ -4,10 +4,10 @@ import typer
4
4
  from typer import Argument
5
5
  from typer import Typer
6
6
 
7
- from .monitor import Monitor
8
7
  from .queueio import QueueIO
9
8
  from .queuespec import QueueSpec
10
9
  from .worker import Worker
10
+ lazy from .monitor import Monitor
11
11
 
12
12
  app = Typer()
13
13
 
@@ -25,6 +25,7 @@ class TestMyBroker(BaseBrokerTest):
25
25
  """
26
26
 
27
27
  import threading
28
+ import time
28
29
  from functools import wraps
29
30
 
30
31
  import pytest
@@ -47,6 +48,18 @@ def skip_if_unsupported(feature_attr):
47
48
  return decorator
48
49
 
49
50
 
51
+ def wait_for(predicate, timeout=1.0, settle=0.05):
52
+ """Poll until predicate() is true, or until the timeout expires.
53
+
54
+ Then wait briefly, so that a receiver exceeding its limit has time to
55
+ deliver the excess message before the test asserts an exact count.
56
+ """
57
+ deadline = time.monotonic() + timeout
58
+ while not predicate() and time.monotonic() < deadline:
59
+ time.sleep(0.001)
60
+ time.sleep(settle)
61
+
62
+
50
63
  class BaseBrokerTest:
51
64
  """Base class with common tests for all broker implementations."""
52
65
 
@@ -80,7 +93,7 @@ class BaseBrokerTest:
80
93
  thread.start()
81
94
 
82
95
  # Wait for receiver to reach prefetch limit or timeout
83
- thread.join(timeout=0.1)
96
+ wait_for(lambda: len(received_messages) >= prefetch_limit)
84
97
 
85
98
  # Should have received exactly prefetch_limit messages and be blocked
86
99
  assert len(received_messages) == prefetch_limit
@@ -113,13 +126,13 @@ class BaseBrokerTest:
113
126
  thread.start()
114
127
 
115
128
  # Wait for initial consumption up to prefetch limit
116
- thread.join(timeout=0.1)
129
+ wait_for(lambda: len(received_messages) >= prefetch_limit)
117
130
  assert len(received_messages) == prefetch_limit
118
131
  assert thread.is_alive() # Should be blocked
119
132
 
120
133
  # Pause first message - should free capacity for third message
121
134
  receiver.pause(received_messages[0])
122
- thread.join(timeout=0.1)
135
+ wait_for(lambda: len(received_messages) >= 3)
123
136
  assert len(received_messages) == 3
124
137
 
125
138
  # Unpause first message - should receive capacity again
@@ -151,18 +164,18 @@ class BaseBrokerTest:
151
164
  thread.start()
152
165
 
153
166
  # Initial consumption up to prefetch limit
154
- thread.join(timeout=0.1)
167
+ wait_for(lambda: len(received_messages) >= prefetch_limit)
155
168
  assert len(received_messages) == prefetch_limit
156
169
  assert thread.is_alive() # Should be blocked
157
170
 
158
171
  # Finish first message - should allow consuming third message
159
172
  receiver.finish(received_messages[0])
160
- thread.join(timeout=0.1)
173
+ wait_for(lambda: len(received_messages) >= 3)
161
174
  assert len(received_messages) == 3
162
175
 
163
176
  # Finish second message - should allow consuming fourth message
164
177
  receiver.finish(received_messages[1])
165
- thread.join(timeout=0.1)
178
+ wait_for(lambda: len(received_messages) >= 4)
166
179
  assert len(received_messages) == 4
167
180
 
168
181
  broker.shutdown()
@@ -200,8 +213,7 @@ class BaseBrokerTest:
200
213
  thread2.start()
201
214
 
202
215
  # Allow both receivers to receive up to their limits
203
- thread1.join(timeout=0.1)
204
- thread2.join(timeout=0.1)
216
+ wait_for(lambda: len(receiver1_messages) + len(receiver2_messages) >= 5)
205
217
 
206
218
  # Both receivers should respect their prefetch limits and be blocked
207
219
  total_received = len(receiver1_messages) + len(receiver2_messages)
@@ -0,0 +1,98 @@
1
+ import faulthandler
2
+ import os
3
+ import threading
4
+ from contextlib import nullcontext
5
+ from typing import TextIO
6
+
7
+ import pytest
8
+ from _pytest.capture import CaptureManager
9
+
10
+ # Grace beyond the per-test timeout for pytest-timeout to fail the test.
11
+ WATCHDOG_MARGIN = 15
12
+ # Inactivity limit when per-test timeouts are disabled.
13
+ WATCHDOG_DEFAULT = 60
14
+ # How long the process may take to exit after pytest finishes.
15
+ WATCHDOG_EXIT = 10
16
+
17
+ _watchdog_file: TextIO | None = None
18
+ _watchdog_limit: float = WATCHDOG_DEFAULT
19
+
20
+
21
+ def _watchdog(seconds: float):
22
+ """Exit with all thread stacks if no progress is made within ``seconds``.
23
+
24
+ Each call resets the deadline. The timer runs in a C-level thread,
25
+ so it fires even if Python code is stuck.
26
+ """
27
+ if _watchdog_file is not None:
28
+ faulthandler.dump_traceback_later(seconds, exit=True, file=_watchdog_file)
29
+
30
+
31
+ def pytest_configure(config: pytest.Config):
32
+ """Ensure the test suite always exits, even when nobody is watching.
33
+
34
+ pytest-timeout only covers individual tests. The watchdog also covers
35
+ collection and interpreter exit, and dumps every thread's stack before
36
+ exiting so that the hang can be diagnosed afterwards.
37
+ """
38
+ global _watchdog_file, _watchdog_limit
39
+
40
+ try:
41
+ timeout = float(config.getoption("timeout", None) or config.getini("timeout"))
42
+ except ValueError:
43
+ timeout = 0
44
+ _watchdog_limit = timeout + WATCHDOG_MARGIN if timeout > 0 else WATCHDOG_DEFAULT
45
+
46
+ # Write to the real stderr, not pytest's capture of it.
47
+ capman = config.pluginmanager.getplugin("capturemanager")
48
+ if isinstance(capman, CaptureManager):
49
+ suspended = capman.global_and_fixture_disabled()
50
+ else:
51
+ suspended = nullcontext()
52
+ with suspended:
53
+ _watchdog_file = os.fdopen(os.dup(2), "w")
54
+ _watchdog(_watchdog_limit)
55
+
56
+
57
+ def pytest_collectstart():
58
+ _watchdog(_watchdog_limit)
59
+
60
+
61
+ def pytest_collectreport():
62
+ _watchdog(_watchdog_limit)
63
+
64
+
65
+ def pytest_runtest_logstart():
66
+ _watchdog(_watchdog_limit)
67
+
68
+
69
+ def pytest_runtest_logreport():
70
+ _watchdog(_watchdog_limit)
71
+
72
+
73
+ def pytest_sessionfinish():
74
+ _watchdog(_watchdog_limit)
75
+
76
+
77
+ def pytest_unconfigure():
78
+ _watchdog(WATCHDOG_EXIT)
79
+
80
+
81
+ @pytest.fixture(autouse=True)
82
+ def check_thread_cleanup():
83
+ """Ensure that threads are not left running."""
84
+ initial_threads = set(threading.enumerate())
85
+ yield
86
+ final_threads = {t for t in threading.enumerate() if t.is_alive()}
87
+ new_threads = final_threads - initial_threads
88
+
89
+ if new_threads:
90
+ thread_info = []
91
+ for thread in new_threads:
92
+ daemon = "daemon" if thread.daemon else "non-daemon"
93
+ thread_info.append(f" - {thread.name} ({daemon})")
94
+
95
+ pytest.fail(
96
+ f"Test left {len(new_threads)} thread(s) running:\n"
97
+ + "\n".join(thread_info)
98
+ )
@@ -1,3 +1,4 @@
1
+ from noio import ShutDown
1
2
  from textual.app import App
2
3
  from textual.app import ComposeResult
3
4
  from textual.widgets import DataTable
@@ -5,7 +6,6 @@ from textual.widgets import Footer
5
6
  from textual.widgets import Header
6
7
 
7
8
  from .invocation import Invocation
8
- from .queue import ShutDown
9
9
  from .queueio import QueueIO
10
10
  from .result import Err
11
11
  from .result import Ok
@@ -19,6 +19,17 @@ class PikaBroker(Broker):
19
19
  @classmethod
20
20
  @contextmanager
21
21
  def connect(cls, connection: ThreadsafeConnection) -> Generator[PikaBroker]:
22
+ channel = connection.channel()
23
+ try:
24
+ if not channel.supports_global_qos():
25
+ raise RuntimeError(
26
+ "Could not verify channel-wide QoS support. QueueIO requires "
27
+ "it for concurrency limits and task suspension. "
28
+ "On RabbitMQ, set deprecated_features.permit.global_qos = true in "
29
+ "rabbitmq.conf and restart RabbitMQ."
30
+ )
31
+ finally:
32
+ channel.close()
22
33
  broker = cls(connection)
23
34
  try:
24
35
  yield broker
@@ -0,0 +1,38 @@
1
+ import pytest
2
+
3
+ from queueio.broker_test import BaseBrokerTest
4
+ from queueio.queueio import QueueIO
5
+
6
+ from . import PikaBackend
7
+
8
+
9
+ def test_global_qos_supported():
10
+ with PikaBackend.connect(QueueIO.broker_uri()) as backend, backend.broker():
11
+ pass
12
+
13
+
14
+ @pytest.mark.no_global_qos
15
+ def test_global_qos_required():
16
+ with (
17
+ PikaBackend.connect(QueueIO.broker_uri()) as backend,
18
+ pytest.raises(RuntimeError) as caught,
19
+ backend.broker(),
20
+ ):
21
+ pytest.fail("Broker opened without channel-wide QoS support")
22
+
23
+ assert "Could not verify channel-wide QoS support" in str(caught.value)
24
+ assert "On RabbitMQ, set" in str(caught.value)
25
+ assert "deprecated_features.permit.global_qos = true" in str(caught.value)
26
+
27
+
28
+ class TestPikaBroker(BaseBrokerTest):
29
+ supports_multiple_queues = True
30
+ supports_weighted_queue_subscriptions = False
31
+
32
+ @pytest.fixture
33
+ def broker(self):
34
+ with (
35
+ PikaBackend.connect(QueueIO.broker_uri()) as backend,
36
+ backend.broker() as broker,
37
+ ):
38
+ yield broker
@@ -1,6 +1,7 @@
1
1
  import pytest
2
2
 
3
3
  from queueio.journal_test import BaseJournalTest
4
+ from queueio.queueio import QueueIO
4
5
 
5
6
  from . import PikaBackend
6
7
 
@@ -9,7 +10,7 @@ class TestPikaJournal(BaseJournalTest):
9
10
  @pytest.fixture
10
11
  def journal(self):
11
12
  with (
12
- PikaBackend.connect("amqp://localhost:5672") as backend,
13
+ PikaBackend.connect(QueueIO.broker_uri()) as backend,
13
14
  backend.journal() as journal,
14
15
  ):
15
16
  yield journal
@@ -5,6 +5,7 @@ from collections.abc import Mapping
5
5
  from concurrent.futures import Future
6
6
  from contextlib import contextmanager
7
7
  from contextlib import suppress
8
+ from queue import Empty
8
9
  from queue import Queue
9
10
  from queue import ShutDown
10
11
  from threading import Event
@@ -33,7 +34,13 @@ class ThreadsafeConnection:
33
34
  )
34
35
  self.__thread = Thread(target=self.__connection.ioloop.start)
35
36
  self.__thread.start()
36
- opened.result()
37
+ try:
38
+ opened.result()
39
+ except BaseException:
40
+ # Stop the ioloop so a failed connection doesn't leak its thread.
41
+ self.__connection.ioloop.stop()
42
+ self.__thread.join()
43
+ raise
37
44
 
38
45
  def __wait[T](self, fn: Callable[[], T]):
39
46
  # TODO: Throw connection exceptions on all waiters
@@ -95,6 +102,38 @@ class ThreadsafeChannel:
95
102
  finally:
96
103
  self.__pending.remove(future)
97
104
 
105
+ def supports_global_qos(self) -> bool:
106
+ """Probe global QoS on a fresh, disposable channel.
107
+
108
+ Global QoS shares a concurrency limit across a receiver's queues.
109
+ Updating that limit lets suspended tasks free capacity for other tasks.
110
+ """
111
+ queues = []
112
+ try:
113
+ self.qos(prefetch_count=1, global_qos=True)
114
+ for _ in range(2):
115
+ result = self.queue_declare("", exclusive=True, auto_delete=True)
116
+ queue = cast(str, result.method.queue)
117
+ queues.append(queue)
118
+ self.publish("", queue, b"")
119
+ self.consume(queue)
120
+ try:
121
+ self.__messages.get(timeout=5)
122
+ try:
123
+ self.__messages.get(timeout=0.2)
124
+ except Empty:
125
+ pass
126
+ else:
127
+ return False
128
+ self.qos(prefetch_count=2, global_qos=True)
129
+ self.__messages.get(timeout=5)
130
+ except Empty:
131
+ return False
132
+ return True
133
+ finally:
134
+ for queue in queues:
135
+ self.delete(queue)
136
+
98
137
  def queue_declare(
99
138
  self,
100
139
  queue: str,
@@ -0,0 +1,20 @@
1
+ import socket
2
+
3
+ import pytest
4
+ from pika import ConnectionParameters
5
+ from pika.exceptions import AMQPConnectionError
6
+
7
+ from .threadsafe import ThreadsafeConnection
8
+
9
+
10
+ def test_failed_connection_stops_ioloop_thread():
11
+ # Find a local port with nothing listening on it.
12
+ with socket.socket() as sock:
13
+ sock.bind(("127.0.0.1", 0))
14
+ port = sock.getsockname()[1]
15
+
16
+ # The autouse check_thread_cleanup fixture fails if the ioloop thread leaks.
17
+ with pytest.raises(AMQPConnectionError):
18
+ ThreadsafeConnection(
19
+ ConnectionParameters(host="127.0.0.1", port=port, connection_attempts=1)
20
+ )