pax_global_header00006660000000000000000000000064146707244360014527gustar00rootroot0000000000000052 comment=6523c74501cdb3ca85f32d502f6b4d7539782d50 gbulb-0.6.6/000077500000000000000000000000001467072443600126335ustar00rootroot00000000000000gbulb-0.6.6/.github/000077500000000000000000000000001467072443600141735ustar00rootroot00000000000000gbulb-0.6.6/.github/dependabot.yml000066400000000000000000000005701467072443600170250ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: # Check for updates on Sunday, 8PM UTC interval: "weekly" day: "sunday" time: "20:00" - package-ecosystem: "pip" directory: "/" schedule: # Check for updates on Sunday, 8PM UTC interval: "weekly" day: "sunday" time: "20:00" gbulb-0.6.6/.github/workflows/000077500000000000000000000000001467072443600162305ustar00rootroot00000000000000gbulb-0.6.6/.github/workflows/ci.yml000066400000000000000000000044411467072443600173510ustar00rootroot00000000000000name: CI on: pull_request: push: branches: - main workflow_call: inputs: attest-package: description: "Create GitHub provenance attestation for the package." default: "false" type: string outputs: artifact-name: description: "Name of the uploaded artifact; use for artifact retrieval." value: ${{ jobs.package.outputs.artifact-name }} # Cancel active CI runs for a PR before starting another run concurrency: group: ${{ github.ref }} cancel-in-progress: true env: FORCE_COLOR: "1" jobs: pre-commit: name: Pre-commit checks uses: beeware/.github/.github/workflows/pre-commit-run.yml@main with: pre-commit-source: "pre-commit" towncrier: name: Check towncrier uses: beeware/.github/.github/workflows/towncrier-run.yml@main with: tox-source: "tox" package: name: Package gbulb permissions: id-token: write contents: read attestations: write uses: beeware/.github/.github/workflows/python-package-create.yml@main with: attest: ${{ inputs.attest-package }} python-versions: name: Python compatibility test needs: [ pre-commit, towncrier, package ] runs-on: ubuntu-latest continue-on-error: ${{ matrix.experimental }} strategy: matrix: python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13-dev" ] include: - experimental: false - python-version: "3.13-dev" experimental: true steps: - name: Checkout uses: actions/checkout@v4.1.7 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5.2.0 with: python-version: ${{ matrix.python-version }} - name: Get Packages uses: actions/download-artifact@v4.1.8 with: name: ${{ needs.package.outputs.artifact-name }} path: dist - name: Install System Dependencies run: | sudo apt update -y sudo apt install -y pkg-config python3-dev libgirepository1.0-dev gir1.2-gtk-3.0 - name: Install Tox uses: beeware/.github/.github/actions/install-requirement@main with: requirements: tox extra: dev - name: Test run: tox -e py --installpkg dist/gbulb-*.whl gbulb-0.6.6/.github/workflows/config-file-deps-bump.yml000066400000000000000000000004311467072443600230250ustar00rootroot00000000000000name: Bump Config File Dependencies on: schedule: - cron: "0 20 * * SUN" # Sunday @ 2000 UTC workflow_dispatch: jobs: dep-bump-versions: name: Bump Config File Dependencies uses: beeware/.github/.github/workflows/dep-version-bump.yml@main secrets: inherit gbulb-0.6.6/.github/workflows/dependabot-changenote.yml000066400000000000000000000003461467072443600231740ustar00rootroot00000000000000name: Dependabot Change Note on: push: branches: - 'dependabot/**' jobs: changenote: name: Dependabot Change Note uses: beeware/.github/.github/workflows/dependabot-changenote.yml@main secrets: inherit gbulb-0.6.6/.github/workflows/pre-commit-update.yml000066400000000000000000000004621467072443600223110ustar00rootroot00000000000000name: Update pre-commit on: schedule: - cron: "0 20 * * SUN" # Sunday @ 2000 UTC workflow_dispatch: jobs: pre-commit-update: name: Update pre-commit uses: beeware/.github/.github/workflows/pre-commit-update.yml@main secrets: inherit with: pre-commit-source: "pre-commit" gbulb-0.6.6/.github/workflows/publish.yml000066400000000000000000000014161467072443600204230ustar00rootroot00000000000000name: Upload Python Package on: release: types: published jobs: deploy: runs-on: ubuntu-latest permissions: # This permission is required for trusted publishing. id-token: write steps: - uses: actions/checkout@v4.1.7 - name: Set up Python uses: actions/setup-python@v5.2.0 with: python-version: "3.x" - name: Install dependencies run: | sudo apt install -y pkg-config python3-dev libgirepository1.0-dev gir1.2-gtk-3.0 python -m pip install --upgrade pip python -m pip install --upgrade setuptools python -m pip install tox - name: Build release artefacts run: | tox -e package - name: Publish release uses: pypa/gh-action-pypi-publish@release/v1 gbulb-0.6.6/.github/workflows/release.yml000066400000000000000000000007741467072443600204030ustar00rootroot00000000000000name: Create Release on: push: tags: - 'v*' jobs: build: name: Create Release runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4.1.7 - name: Create Release id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} draft: true prerelease: false gbulb-0.6.6/.gitignore000066400000000000000000000005451467072443600146270ustar00rootroot00000000000000*.pyc *~ .*.sw[op] *.egg-info /dist /build docs/_build/ distribute-* .DS_Store .python-version .pytest_cache .coverage coverage.xml venv*/ .vscode/ .eggs/ .tox/ /local /pip-wheel-metadata # IntelliJ Idea family of suites .idea *.iml ## File-based project format: *.ipr *.iws ## mpeltonen/sbt-idea plugin .idea_modules/ # direnv .envrc # Wing IDE *.wp[ur] gbulb-0.6.6/.pre-commit-config.yaml000066400000000000000000000016651467072443600171240ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-toml - id: check-yaml - id: check-case-conflict - id: check-docstring-first - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/PyCQA/isort rev: 5.13.2 hooks: - id: isort additional_dependencies: [toml] - repo: https://github.com/asottile/pyupgrade rev: v3.17.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/psf/black-pre-commit-mirror rev: 24.8.0 hooks: - id: black language_version: python3 - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 hooks: - id: flake8 - repo: https://github.com/codespell-project/codespell rev: v2.3.0 hooks: - id: codespell # remove toml extra once Python 3.10 is no longer supported additional_dependencies: ['.[toml]'] gbulb-0.6.6/AUTHORS.rst000066400000000000000000000007701467072443600145160ustar00rootroot00000000000000Gbulb was originally created by Anthony Baire in September 2013. It was maintained by Nathan Hoad from 2015? until October 2021. It is currently maintained by Russell Keith-Magee, as part of the BeeWare project. And here is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS -- people who have submitted patches, reported bugs, added translations, helped answer newbie questions, and generally made gbulb that much better: * Jan Lübbe * Krzysztof Kotlenga * montag451 * Brecht De Vlieger gbulb-0.6.6/CHANGELOG.rst000066400000000000000000000125731467072443600146640ustar00rootroot00000000000000Change Log ========== .. towncrier release notes start 0.6.6 (2024-09-13) ================== Bugfixes -------- * Unix socket handling was corrected to support changes in Python 3.13. (`#176 `__) * PyGObject support was pinned to an upper version less than 3.50.0. (`#185 `__) Misc ---- * #138, #139, #140, #141, #142, #143, #144, #145, #146, #147, #148, #149, #150, #151, #152, #153, #154, #155, #156, #157, #158, #159, #160, #161, #162, #163, #164, #165, #166, #167, #168, #170, #172, #173, #175, #178, #180, #181, #182, #183, #184 0.6.5 (2024-05-05) ================== Features -------- * Support for driving ``BufferedProtocol`` instances using ``sock_recv_into`` was added. (`#58 `__) * Support for Python 3.12 was added. (`#76 `__) * Support for Python 3.13 was added. (`#76 `__) Bugfixes -------- * Support for using a generator as a co-routine has been removed, in line with the change in behavior in Python 3.12. Python 3.11 and earlier will still support this usage, but it is no longer verified as part of GBulb. (`#78 `__) Backward Incompatible Changes ----------------------------- * Support for Python 3.7 was removed. (`#137 `__) Documentation ------------- * The README badges were updated to display correctly on GitHub. (`#136 `__) Misc ---- * #68, #70, #71, #72, #74, #75, #77, #79, #80, #81, #82, #83, #84, #85, #86, #90, #91, #92, #93, #94, #95, #96, #97, #98, #99, #100, #101, #103, #104, #105, #106, #107, #108, #109, #112, #113, #114, #115, #118, #119, #120, #121, #122, #123, #124, #125, #126, #127, #128, #129, #130, #131, #132, #133, #134, #135 0.6.4 (2023-02-07) ------------------ Features -------- * Support for Python 3.11 was added. (`#61 `__) * Reverted the fix from #47; that change led to file descriptor leaks. (`#52 `__) 0.6.2 (2021-10-24) ------------------ Features ^^^^^^^^ * Added support for Python 3.10. (`#50 `__) Bugfixes ^^^^^^^^ * Corrects a problem where a socket isn't forgotten and causes 100% CPU load. (`#47 `__) Improved Documentation ^^^^^^^^^^^^^^^^^^^^^^ * #49 0.6.1 (2018-08-09) ------------------ Bug fixes ^^^^^^^^^ * Support for 3.7, for real this time. Thank you Philippe Normand! 0.6.0 (2018-08-06) ------------------ Bug fixes ^^^^^^^^^ * Support for 3.7. Features ^^^^^^^^ * Preliminary Windows support. Please note that using subprocesses is known not to work. Patches welcome. Removals ^^^^^^^^ * Support for 3.4 and below has been dropped. 0.5.3 (2017-01-27) ------------------ Bug fixes ^^^^^^^^^ * Implemented child watcher setters and getters to allow writing tests with asynctest for code using gbulb. * ``gbulb.install`` now monkey patches ``asyncio.SafeChildWatcher`` to ``gbulb.glib_events.GLibChildWatcher``, to ensure that any library code that uses it will use the correct child watcher. 0.5.2 (2017-01-21) ------------------ Bug fixes ^^^^^^^^^ * Fixed a sporadic test hang. 0.5.1 (2017-01-20) ------------------ Bug fixes ^^^^^^^^^ * Fixed breakage on Python versions older than 3.5.3, caused by 0.5.0. Thanks Brecht De Vlieger! 0.5 (2017-01-12) ---------------- Bug fixes ^^^^^^^^^ * Fixed issue with readers and writers not being added to the loop properly as a result of `Python Issue 28369 `__. 0.4 (2016-10-26) ---------------- Bug fixes ^^^^^^^^^ * gbulb will no longer allow you to schedule coroutines with ``call_at``, ``call_soon`` and ``call_later``, the same as asyncio. 0.3 (2016-09-13) ---------------- Bug fixes ^^^^^^^^^ * gbulb will no longer occasionally leak memory when used with threads. 0.2 (2016-03-20) ---------------- Features ^^^^^^^^ * ``gbulb.install`` to simplify installation of a GLib-based event loop in asyncio: - Connecting sockets now works as intended - Implement ``call_soon_threadsafe`` - Lots of tests * **API BREAKAGE** No implicit Gtk import anymore. ``GtkEventLoop`` and ``GtkEventLoopPolicy`` have been moved to ``gbulb.gtk`` * **API BREAKAGE** No more ``threads``, ``default`` or ``full`` parameters for event loop policy objects. gbulb now does nothing with threads. * **API BREAKAGE** ``gbulb.get_default_loop`` has been removed * Permit running event loops recursively via ``.run()`` Bug fixes ^^^^^^^^^ * Default signal handling of SIGINT * ``gbulb.wait_signal.cancel()`` now obeys the interface defined by ``asyncio.Future`` 0.1 2013-09-20 --------------- Features ^^^^^^^^ * Initial release gbulb-0.6.6/CONTRIBUTING.md000066400000000000000000000002651467072443600150670ustar00rootroot00000000000000# Contributing BeeWare <3's contributions! Please be aware, BeeWare operates under a Code of Conduct. See [CONTRIBUTING to BeeWare](http://beeware.org/contributing) for details. gbulb-0.6.6/LICENSE000066400000000000000000000011131467072443600136340ustar00rootroot00000000000000Copyright 2015 Nathan Hoad Copyright 2021 Russell Keith-Magee Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. gbulb-0.6.6/MANIFEST.in000066400000000000000000000005611467072443600143730ustar00rootroot00000000000000include AUTHORS.rst include CONTRIBUTING.md include CHANGELOG.rst include LICENSE include README.rst include tox.ini include .pre-commit-config.yaml recursive-include changes *.rst recursive-include examples * recursive-exclude examples *.pyc *.pyo recursive-include tests * recursive-exclude tests *.pyc *.pyo recursive-include src * recursive-exclude src *.pyc *.pyo gbulb-0.6.6/README.rst000066400000000000000000000157211467072443600143300ustar00rootroot00000000000000THIS PROJECT HAS BEEN ARCHIVED ============================== **With the release of PyGObject 3.50.0, GBulb is no longer required**. PyGObject now contains native integration with the Python event loop. If you require asyncio support in your GTK app, we advise upgrading your project to use the native asyncio API of `PyGObject 3.50.0+ `__. Example usage of PyGObject with native asyncio support:: import asyncio import gi gi.require_version("Gtk", "3.0") from gi.events import GLibEventLoopPolicy from gi.repository import Gtk asyncio.set_event_loop_policy(GlibEventLoopPolicy()) app = Gtk.Application(...) app.run() The last published version of this project (`v0.6.6 `__) is compatible with versions of PyGObject prior to 3.50.0, and CPython 3.8-3.13. .. |pyversions| image:: https://img.shields.io/pypi/pyversions/gbulb.svg :target: https://pypi.python.org/pypi/gbulb :alt: Python Versions .. |version| image:: https://img.shields.io/pypi/v/gbulb.svg :target: https://pypi.python.org/pypi/gbulb :alt: PyPI Version .. |maturity| image:: https://img.shields.io/pypi/status/gbulb.svg :target: https://pypi.python.org/pypi/gbulb :alt: Maturity .. |license| image:: https://img.shields.io/pypi/l/gbulb.svg :target: https://github.com/beeware/gbulb/blob/main/LICENSE :alt: BSD License .. |ci| image:: https://github.com/beeware/gbulb/workflows/CI/badge.svg?branch=main :target: https://github.com/beeware/gbulb/actions :alt: Build Status .. |social| image:: https://img.shields.io/discord/836455665257021440?label=Discord%20Chat&logo=discord&style=plastic :target: https://beeware.org/bee/chat/ :alt: Discord server gbulb ===== |pyversions| |version| |maturity| |license| |ci| |social| Gbulb is a Python library that implements a `PEP 3156 `__ interface for the `GLib main event loop `__ under UNIX-like systems. As much as possible, except where noted below, it mimics asyncio's interface. If you notice any differences, please report them. Requirements ------------ - python 3.8+ - pygobject - glib - gtk+3 (optional) Usage ----- GLib event loop ~~~~~~~~~~~~~~~ Example usage:: import asyncio, gbulb gbulb.install() asyncio.get_event_loop().run_forever() Gtk+ event loop *(suitable for GTK+ applications)* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Example usage:: import asyncio, gbulb gbulb.install(gtk=True) asyncio.get_event_loop().run_forever() GApplication/GtkApplication event loop ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Example usage:: import asyncio, gbulb gbulb.install(gtk=True) # only necessary if you're using GtkApplication loop = asyncio.get_event_loop() loop.run_forever(application=my_gapplication_object) Waiting on a signal asynchronously ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ See examples/wait_signal.py Known issues ------------ - Windows is not supported, sorry. If you are interested in this, please help me get it working! I don't have Windows so I can't test it. Divergences with PEP 3156 ------------------------- In GLib, the concept of event loop is split in two classes: GLib.MainContext and GLib.MainLoop. The event loop is mostly implemented by MainContext. MainLoop is just a wrapper that implements the run() and quit() functions. MainLoop.run() atomically acquires a MainContext and repeatedly calls MainContext.iteration() until MainLoop.quit() is called. A MainContext is not bound to a particular thread, however it cannot be used by multiple threads concurrently. If the context is owned by another thread, then MainLoop.run() will block until the context is released by the other thread. MainLoop.run() may be called recursively by the same thread (this is mainly used for implementing modal dialogs in Gtk). The issue: given a context, GLib provides no ways to know if there is an existing event loop running for that context. It implies the following divergences with PEP 3156: - ``.run_forever()`` and ``.run_until_complete()`` are not guaranteed to run immediately. If the context is owned by another thread, then they will block until the context is released by the other thread. - ``.stop()`` is relevant only when the currently running Glib.MainLoop object was created by this asyncio object (i.e. by calling ``.run_forever()`` or ``.run_until_complete()``). The event loop will quit only when it regains control of the context. This can happen in two cases: 1. when multiple event loop are enclosed (by creating new ``MainLoop`` objects and calling ``.run()`` recursively) 2. when the event loop has not even yet started because it is still trying to acquire the context It would be wiser not to use any recursion at all. ``GLibEventLoop`` will actually prevent you from doing that (in accordance with PEP 3156), however ``GtkEventLoop`` will allow you to call ``run()`` recursively. You should also keep in mind that enclosed loops may be started at any time by third-party code calling GLib's primitives. Testing ------- Testing GBulb requires a Linux environment that has GLib and GTK development libraries available. The tests folder contains a Dockerfile that defines a complete testing environment. To use the Docker environment, run the following from the root of the git checkout: $ docker buildx build --tag beeware/gbulb:latest --file ./tests/Dockerfile . $ docker run --rm --volume $(PWD):/home/brutus/gbulb:z -it beeware/gbulb:latest This will drop you into an Ubuntu 24.04 shell that has Python 3.8-3.13 installed, mounting the current working directory as `/home/brutus/gbulb`. You can use this to create virtual environments for each Python version. Once you have an active virtual environment, run: (venv) $ pip install -e .[dev] (venv) $ pytest to run the test suite. Alternatively, you can install tox, and then run: # To test a single Python version (venv) $ tox -e py # To test Python 3.10 specifically (venv) $ tox -e py310 # To test all versions (venv) $ tox Community --------- gbulb is part of the `BeeWare suite`_. You can talk to the community through: * `@pybeeware on Twitter `__ * `Discord `__ * The gbulb `Github Discussions forum `__ We foster a welcoming and respectful community as described in our `BeeWare Community Code of Conduct`_. Contributing ------------ If you experience problems with gbulb, `log them on GitHub`_. If you want to contribute code, please `fork the code`_ and `submit a pull request`_. .. _BeeWare suite: http://beeware.org .. _BeeWare Community Code of Conduct: http://beeware.org/community/behavior/ .. _log them on Github: https://github.com/beeware/gbulb/issues .. _fork the code: https://github.com/beeware/gbulb .. _submit a pull request: https://github.com/beeware/gbulb/pulls gbulb-0.6.6/changes/000077500000000000000000000000001467072443600142435ustar00rootroot00000000000000gbulb-0.6.6/changes/.gitignore000066400000000000000000000000141467072443600162260ustar00rootroot00000000000000!.gitignore gbulb-0.6.6/changes/template.rst000066400000000000000000000014301467072443600166060ustar00rootroot00000000000000{% for section, _ in sections.items() %} {% set underline = underlines[0] %}{% if section %}{{section}} {{ underline * section|length }}{% set underline = underlines[1] %} {% endif %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} {{ underline * definitions[category]['name']|length }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} * {{ text }} ({{ values|join(', ') }}) {% endfor %} {% else %} * {{ sections[section][category]['']|join(', ') }} {% endif %} {% if sections[section][category]|length == 0 %} No significant changes. {% else %} {% endif %} {% endfor %} {% else %} No significant changes. {% endif %} {% endfor %} gbulb-0.6.6/examples/000077500000000000000000000000001467072443600144515ustar00rootroot00000000000000gbulb-0.6.6/examples/gtk.py000066400000000000000000000012271467072443600156120ustar00rootroot00000000000000import asyncio from gi.repository import Gtk import gbulb @asyncio.coroutine def counter(label): i = 0 while True: label.set_text(str(i)) print("incrementing", i) yield from asyncio.sleep(1) i += 1 def main(): gbulb.install(gtk=True) loop = gbulb.get_event_loop() display = Gtk.Entry() vbox = Gtk.VBox() vbox.pack_start(display, True, True, 0) win = Gtk.Window(title="Counter window") win.connect("delete-event", lambda *args: loop.stop()) win.add(vbox) win.show_all() asyncio.ensure_future(counter(display)) loop.run_forever() if __name__ == "__main__": main() gbulb-0.6.6/examples/test-gtk.py000077500000000000000000000041341467072443600165720ustar00rootroot00000000000000#!/usr/bin/env python3 import asyncio from gi.repository import Gtk import gbulb import gbulb.gtk class ProgressBarWindow(Gtk.Window): def __init__(self): Gtk.Window.__init__(self, title="ProgressBar Demo") self.set_border_width(10) vbox = Gtk.VBox() self.add(vbox) self.progressbar = Gtk.ProgressBar() vbox.pack_start(self.progressbar, True, True, 0) button = Gtk.Button("Magic button") button.connect("clicked", self.on_magic) vbox.pack_start(button, True, True, 0) self._magic_button = button button = Gtk.Button("Stop button") button.connect("clicked", self.on_stop) vbox.pack_start(button, True, True, 0) self._stop_button = button self._running = False def on_magic(self, button): def coro(): try: yield from gbulb.wait_signal(self._magic_button, "clicked") self.progressbar.set_text("blah blah!") self.progressbar.set_fraction(0.50) yield from asyncio.sleep(1) self.progressbar.set_fraction(0.75) self.progressbar.set_text("pouet pouet!") yield from gbulb.wait_signal(self._magic_button, "clicked") self.progressbar.set_fraction(1.0) self.progressbar.set_text("done!") yield from asyncio.sleep(1) finally: self.progressbar.set_fraction(0.0) self.progressbar.set_show_text(False) self._running = False if not self._running: self.progressbar.set_fraction(0.25) self.progressbar.set_text("do some magic!") self.progressbar.set_show_text(True) self._running = asyncio.ensure_future(coro()) def on_stop(self, button): if self._running: self._running.cancel() asyncio.set_event_loop_policy(gbulb.gtk.GtkEventLoopPolicy()) win = ProgressBarWindow() win.connect("delete-event", lambda *args: loop.stop()) win.show_all() loop = asyncio.get_event_loop() loop.run_forever() gbulb-0.6.6/examples/wait_signal.py000066400000000000000000000015151467072443600173260ustar00rootroot00000000000000import asyncio from gi.repository import Gtk import gbulb @asyncio.coroutine def counter(label): i = 0 while True: label.set_text(str(i)) yield from asyncio.sleep(1) i += 1 @asyncio.coroutine def text_watcher(label): while True: yield from gbulb.wait_signal(label, "changed") print("label changed", label.get_text()) def main(): gbulb.install(gtk=True) loop = gbulb.get_event_loop() display = Gtk.Entry() vbox = Gtk.VBox() vbox.pack_start(display, True, True, 0) win = Gtk.Window(title="Counter window") win.connect("delete-event", lambda *args: loop.stop()) win.add(vbox) win.show_all() asyncio.ensure_future(text_watcher(display)) asyncio.ensure_future(counter(display)) loop.run_forever() if __name__ == "__main__": main() gbulb-0.6.6/pyproject.toml000066400000000000000000000063171467072443600155560ustar00rootroot00000000000000[build-system] requires = [ "setuptools==74.1.2", "setuptools_scm==8.1.0", ] build-backend = "setuptools.build_meta" [project] dynamic = ["version"] name = "gbulb" description = "GLib event loop for Python asyncio" readme = "README.rst" requires-python = ">= 3.8" license.text = "Apache 2.0" authors = [ {name="Russell Keith-Magee", email="russell@keith-magee.com"}, {name="Nathan Hoad", email="nathan@getoffmalawn.com"} ] maintainers = [ {name="Russell Keith-Magee", email="russell@keith-magee.com"}, ] keywords = [ "gtk", "glib", "gnome", "asyncio", "tulip", ] classifiers = [ "Development Status :: 7 - Inactive", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ # Dependencies required at runtime are set as ranges to ensure maximum # compatibility with the end-user's development environment. "pygobject >= 3.14.0, < 3.50.0", ] [project.optional-dependencies] # Extras used by developers *of* gbulb are pinned to specific versions to # ensure environment consistency. dev = [ "coverage[toml] == 7.6.1", # Pre-commit 3.6.0 deprecated support for Python 3.8 "pre-commit == 3.5.0 ; python_version < '3.9'", "pre-commit == 3.8.0 ; python_version >= '3.9'", "pytest == 8.3.2", "setuptools_scm == 8.1.0", "tox == 4.18.1", ] [project.urls] Homepage = "https://github.com/beeware/gbulb" Funding = "https://beeware.org/contributing/membership/" # Documentation = "http://gbulb.readthedocs.io/en/latest/" Tracker = "https://github.com/beeware/gbulb/issues" Source = "https://github.com/beeware/gbulb" [tool.isort] profile = "black" skip_glob = [ "venv*", "local", ] multi_line_output = 3 [tool.pytest.ini_options] testpaths = ["tests"] filterwarnings = [ # "error", # promote warnings to errors ] # need to ensure build directories aren't excluded from recursion norecursedirs = [] [tool.setuptools_scm] # To enable SCM versioning, we need an empty tool configuration for setuptools_scm [tool.towncrier] directory = "changes" package = "gbulb" package_dir = "src" filename = "CHANGELOG.rst" title_format = "{version} ({project_date})" issue_format = "`#{issue} `__" template = "changes/template.rst" type = [ { directory = "feature", name = "Features", showcontent = true }, { directory = "bugfix", name = "Bugfixes", showcontent = true }, { directory = "removal", name = "Backward Incompatible Changes", showcontent = true }, { directory = "doc", name = "Documentation", showcontent = true }, { directory = "misc", name = "Misc", showcontent = false }, ] [tool.codespell] skip = '.git,*.pdf,*.svg' # the way to make case sensitive skips of words etc ignore-regex = '\bNd\b' # case insensitive # ignore-words-list = '' gbulb-0.6.6/src/000077500000000000000000000000001467072443600134225ustar00rootroot00000000000000gbulb-0.6.6/src/gbulb/000077500000000000000000000000001467072443600145155ustar00rootroot00000000000000gbulb-0.6.6/src/gbulb/__init__.py000066400000000000000000000016411467072443600166300ustar00rootroot00000000000000from .glib_events import * # noqa: F401,F403 from .utils import * # noqa: F401,F403 __all__ = [ "__version__", ] try: # Read version from SCM metadata # This will only exist in a development environment from setuptools_scm import get_version # Excluded from coverage because a pure test environment (such as the one # used by tox in CI) won't have setuptools_scm __version__ = get_version("../..", relative_to=__file__) # pragma: no cover except (ModuleNotFoundError, LookupError): # pragma: no cover # If setuptools_scm isn't in the environment, the call to import will fail. # If it *is* in the environment, but the code isn't a git checkout (e.g., # it's been pip installed non-editable) the call to get_version() will fail. # If either of these occurs, read version from the installer metadata. from importlib.metadata import version __version__ = version("gbulb") gbulb-0.6.6/src/gbulb/glib_events.py000066400000000000000000001021441467072443600173720ustar00rootroot00000000000000"""PEP 3156 event loop based on GLib.""" import asyncio import os import signal import socket import sys import threading import warnings import weakref from asyncio import CancelledError, constants, events, sslproto, tasks try: from gi.repository import Gio, GLib except ImportError: # pragma: no cover GLib = None Gio = None from . import transports if hasattr(os, "set_blocking"): def _set_nonblocking(fd): os.set_blocking(fd, False) elif sys.platform == "win32": def _set_nonblocking(fd): pass else: import fcntl def _set_nonblocking(fd): flags = fcntl.fcntl(fd, fcntl.F_GETFL) flags = flags | os.O_NONBLOCK fcntl.fcntl(fd, fcntl.F_SETFL, flags) __all__ = ["GLibEventLoop", "GLibEventLoopPolicy"] # The Windows `asyncio` implementation doesn't actually use this, but # `glib` abstracts so nicely over this that we can use it on any platform if sys.platform == "win32": class AbstractChildWatcher: pass else: from asyncio.unix_events import AbstractChildWatcher with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) class GLibChildWatcher(AbstractChildWatcher): def __init__(self): self._sources = {} self._handles = {} # On windows on has to open a process handle for the given PID number # before it's possible to use GLib's `child_watch_add` on it if sys.platform == "win32": def _create_handle_for_pid(self, pid): import _winapi return _winapi.OpenProcess(0x00100400, 0, pid) def _close_process_handle(self, handle): import _winapi _winapi.CloseHandle(handle) else: def _create_handle_for_pid(self, pid): return pid def _close_process_handle(self, pid): return None def attach_loop(self, loop): # just ignored pass def add_child_handler(self, pid, callback, *args): self.remove_child_handler(pid) handle = self._create_handle_for_pid(pid) source = GLib.child_watch_add(0, handle, self.__callback__) self._sources[pid] = source, callback, args, handle self._handles[handle] = pid def remove_child_handler(self, pid): try: source, callback, args, handle = self._sources.pop(pid) assert self._handles.pop(handle) == pid except KeyError: return False self._close_process_handle(handle) GLib.source_remove(source) return True def close(self): for source, callback, args, handle in self._sources.values(): self._close_process_handle(handle) GLib.source_remove(source) self._sources = {} self._handles = {} def __enter__(self): return self def __exit__(self, a, b, c): pass def __callback__(self, handle, status): try: pid = self._handles.pop(handle) source, callback, args, handle = self._sources.pop(pid) except KeyError: return self._close_process_handle(handle) GLib.source_remove(source) if hasattr(os, "WIFSIGNALED") and os.WIFSIGNALED(status): returncode = -os.WTERMSIG(status) elif hasattr(os, "WIFEXITED") and os.WIFEXITED(status): returncode = os.WEXITSTATUS(status) # FIXME: Hack for adjusting invalid status returned by GLIB # Looks like there is a bug in glib or in pygobject if returncode > 128: returncode = 128 - returncode else: returncode = status callback(pid, returncode, *args) class GLibHandle(events.Handle): __slots__ = ("_source", "_repeat", "_context") def __init__(self, *, loop, source, repeat, callback, args, context=None): super().__init__(callback, args, loop) if sys.version_info[:2] >= (3, 7) and context is None: import contextvars context = contextvars.copy_context() self._context = context self._source = source self._repeat = repeat loop._handlers.add(self) source.set_callback(self.__callback__, self) source.attach(loop._context) def cancel(self): super().cancel() self._source.destroy() self._loop._handlers.discard(self) def __callback__(self, ignore_self): # __callback__ is called within the MainContext object, which is # important in case that code includes a `Gtk.main()` or some such. # Otherwise what happens is the loop is started recursively, but the # callbacks don't finish firing, so they can't be rescheduled. self._run() if not self._repeat: self._source.destroy() self._loop._handlers.discard(self) return self._repeat if sys.platform == "win32": class GLibBaseEventLoopPlatformExt: def __init__(self): pass def close(self): pass else: from asyncio import unix_events class GLibBaseEventLoopPlatformExt(unix_events.SelectorEventLoop): """Semi-hack that allows us to leverage the existing implementation of Unix domain sockets without having to actually implement a selector based event loop. Note that both `__init__` and `close` DO NOT and SHOULD NOT ever call their parent implementation! """ def __init__(self): self._sighandlers = {} self._unix_server_sockets = {} def close(self): for sig in list(self._sighandlers): self.remove_signal_handler(sig) def add_signal_handler(self, sig, callback, *args): self.remove_signal_handler(sig) s = GLib.unix_signal_source_new(sig) if s is None: # Show custom error messages for signal that are uncatchable if sig == signal.SIGKILL: raise RuntimeError("cannot catch SIGKILL") elif sig == signal.SIGSTOP: raise RuntimeError("cannot catch SIGSTOP") else: raise ValueError("signal not supported") assert sig not in self._sighandlers self._sighandlers[sig] = GLibHandle( loop=self, source=s, repeat=True, callback=callback, args=args ) def remove_signal_handler(self, sig): try: self._sighandlers.pop(sig).cancel() return True except KeyError: return False class _BaseEventLoop(asyncio.BaseEventLoop): """Extra inheritance step that needs to be inserted so that we only ever indirectly inherit from `asyncio.BaseEventLoop`. This is necessary as the Unix implementation will also indirectly inherit from that class (thereby creating diamond inheritance). Python permits and fully supports diamond inheritance so this is not a problem. However it is, on the other hand, not permitted to inherit from a class both directly *and* indirectly – hence we add this intermediate class to make sure that can never happen (see https://stackoverflow.com/q/29214888 for a minimal example a forbidden inheritance tree) and https://www.python.org/download/releases/2.3/mro/ for some extensive documentation of the allowed inheritance structures in python. """ class GLibBaseEventLoop(_BaseEventLoop, GLibBaseEventLoopPlatformExt): def __init__(self, context=None): self._handlers = set() self._accept_futures = {} self._context = context or GLib.MainContext() self._selector = self self._transports = weakref.WeakValueDictionary() self._readers = {} self._writers = {} self._channels = weakref.WeakValueDictionary() _BaseEventLoop.__init__(self) GLibBaseEventLoopPlatformExt.__init__(self) def close(self): for future in self._accept_futures.values(): future.cancel() self._accept_futures.clear() for s in list(self._handlers): s.cancel() self._handlers.clear() GLibBaseEventLoopPlatformExt.close(self) _BaseEventLoop.close(self) def select(self, timeout=None): self._context.acquire() try: if timeout is None: self._context.iteration(True) elif timeout <= 0: self._context.iteration(False) else: # Schedule fake callback that will trigger an event and cause the loop to terminate # after the given number of seconds handle = GLibHandle( loop=self, source=GLib.Timeout(timeout * 1000), repeat=False, callback=lambda: None, args=(), ) try: self._context.iteration(True) finally: handle.cancel() return () # Available events are dispatched immediately and not returned finally: self._context.release() def _make_socket_transport( self, sock, protocol, waiter=None, *, extra=None, server=None ): """Create socket transport.""" return transports.SocketTransport(self, sock, protocol, waiter, extra, server) def _make_ssl_transport( self, rawsock, protocol, sslcontext, waiter=None, *, server_side=False, server_hostname=None, extra=None, server=None, ssl_handshake_timeout=None, ssl_shutdown_timeout=None, ): """Create SSL transport.""" # sslproto._is_sslproto_available was removed from asyncio, starting from Python 3.7. if ( hasattr(sslproto, "_is_sslproto_available") and not sslproto._is_sslproto_available() ): raise NotImplementedError( "Proactor event loop requires Python 3.5" " or newer (ssl.MemoryBIO) to support " "SSL" ) extra_protocol_kwargs = {} # Support for the ssl_handshake_timeout keyword argument was added in Python 3.7. if sys.version_info[:2] >= (3, 7): extra_protocol_kwargs["ssl_handshake_timeout"] = ssl_handshake_timeout # Support for the ssl_shutdown_timeout keyword argument was added in Python 3.11. if sys.version_info[:2] >= (3, 11): extra_protocol_kwargs["ssl_shutdown_timeout"] = ssl_shutdown_timeout ssl_protocol = sslproto.SSLProtocol( self, protocol, sslcontext, waiter, server_side, server_hostname, **extra_protocol_kwargs, ) transports.SocketTransport( self, rawsock, ssl_protocol, extra=extra, server=server ) return ssl_protocol._app_transport def _make_datagram_transport( self, sock, protocol, address=None, waiter=None, extra=None ): """Create datagram transport.""" return transports.DatagramTransport( self, sock, protocol, address, waiter, extra ) def _make_read_pipe_transport(self, pipe, protocol, waiter=None, extra=None): """Create read pipe transport.""" channel = self._channel_from_fileobj(pipe) return transports.PipeReadTransport(self, channel, protocol, waiter, extra) def _make_write_pipe_transport(self, pipe, protocol, waiter=None, extra=None): """Create write pipe transport.""" channel = self._channel_from_fileobj(pipe) return transports.PipeWriteTransport(self, channel, protocol, waiter, extra) async def _make_subprocess_transport( self, protocol, args, shell, stdin, stdout, stderr, bufsize, extra=None, **kwargs, ): """Create subprocess transport.""" with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) watcher = events.get_child_watcher() with watcher: waiter = asyncio.Future(loop=self) transport = transports.SubprocessTransport( self, protocol, args, shell, stdin, stdout, stderr, bufsize, waiter=waiter, extra=extra, **kwargs, ) watcher.add_child_handler( transport.get_pid(), self._child_watcher_callback, transport ) try: await waiter except Exception as exc: err = exc else: err = None if err is not None: transport.close() await transport._wait() raise err return transport def _child_watcher_callback(self, pid, returncode, transport): self.call_soon_threadsafe(transport._process_exited, returncode) def _write_to_self(self): self._context.wakeup() def _process_events(self, event_list): """Process selector events.""" pass # This is already done in `.select()` def _start_serving( self, protocol_factory, sock, sslcontext=None, server=None, backlog=100, ssl_handshake_timeout=getattr(constants, "SSL_HANDSHAKE_TIMEOUT", 60.0), ssl_shutdown_timeout=getattr(constants, "SSL_SHUTDOWN_TIMEOUT", 60.0), ): self._transports[sock.fileno()] = server def server_loop(f=None): try: if f is not None: (conn, addr) = f.result() protocol = protocol_factory() if sslcontext is not None: self._make_ssl_transport( conn, protocol, sslcontext, server_side=True, extra={"peername": addr}, server=server, ssl_handshake_timeout=ssl_handshake_timeout, ssl_shutdown_timeout=ssl_shutdown_timeout, ) else: self._make_socket_transport( conn, protocol, extra={"peername": addr}, server=server ) if self.is_closed(): return f = self.sock_accept(sock) except OSError as exc: if sock.fileno() != -1: self.call_exception_handler( { "message": "Accept failed on a socket", "exception": exc, "socket": sock, } ) sock.close() except CancelledError: sock.close() else: self._accept_futures[sock.fileno()] = f f.add_done_callback(server_loop) self.call_soon(server_loop) def _stop_serving(self, sock): if sock.fileno() in self._accept_futures: self._accept_futures[sock.fileno()].cancel() sock.close() def _check_not_coroutine(self, callback, name): """Check whether the given callback is a coroutine or not.""" from asyncio import coroutines if coroutines.iscoroutine(callback) or coroutines.iscoroutinefunction(callback): raise TypeError(f"coroutines cannot be used with {name}()") def _ensure_fd_no_transport(self, fd): """Ensure that the given file descriptor is NOT used by any transport. Adding another reader to a fd that is already being waited for causes a hang on Windows. """ try: transport = self._transports[fd] except KeyError: pass else: if not hasattr(transport, "is_closing") or not transport.is_closing(): raise RuntimeError( "File descriptor {!r} is used by transport {!r}".format( fd, transport ) ) def _channel_from_socket(self, sock): """Create GLib IOChannel for the given file object. This function will cache weak references to `GLib.Channel` objects it previously has created to prevent weird issues that can occur when two GLib channels point to the same underlying socket resource. On windows this will only work for network sockets. """ fd = self._fileobj_to_fd(sock) sock_id = id(sock) try: channel = self._channels[sock_id] except KeyError: if sys.platform == "win32": channel = GLib.IOChannel.win32_new_socket(fd) else: channel = GLib.IOChannel.unix_new(fd) # disabling buffering requires setting the encoding to None channel.set_encoding(None) channel.set_buffered(False) self._channels[sock_id] = channel return channel def _channel_from_fileobj(self, fileobj): """Create GLib IOChannel for the given file object. On windows this will only work for files and pipes returned GLib's C library. """ fd = self._fileobj_to_fd(fileobj) # pipes have been shown to be blocking here, so we'll do someone # else's job for them. _set_nonblocking(fd) if sys.platform == "win32": channel = GLib.IOChannel.win32_new_fd(fd) else: channel = GLib.IOChannel.unix_new(fd) # disabling buffering requires setting the encoding to None channel.set_encoding(None) channel.set_buffered(False) return channel def _fileobj_to_fd(self, fileobj): """Obtain the raw file descriptor number for the given file object.""" if isinstance(fileobj, int): fd = fileobj else: try: fd = int(fileobj.fileno()) except (AttributeError, TypeError, ValueError): raise ValueError(f"Invalid file object: {fileobj!r}") if fd < 0: raise ValueError(f"Invalid file descriptor: {fd}") return fd def _delayed(self, source, callback=None, *args): """Create a future that will complete after the given GLib Source object has become ready and the data it tracks has been processed.""" future = None def handle_ready(*args): try: if callback: (done, result) = callback(*args) else: (done, result) = (True, None) if done: future.set_result(result) future.handle.cancel() except Exception as error: if not future.cancelled(): future.set_exception(error) future.handle.cancel() # Create future and properly wire up it's cancellation with the # handle's cancellation machinery future = asyncio.Future(loop=self) future.handle = GLibHandle( loop=self, source=source, repeat=True, callback=handle_ready, args=args ) return future def _socket_handle_errors(self, sock): """Raise exceptions for error states (SOL_ERROR) on the given socket object.""" errno = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) if errno != 0: if sys.platform == "win32": msg = socket.errorTab.get(errno, f"Error {errno}") raise OSError(errno, f"[WinError {errno}] {msg}", None, errno) else: raise OSError(errno, os.strerror(errno)) ############################### # Low-level socket operations # ############################### def sock_connect(self, sock, address): # Request connection on socket (it is expected that `sock` is already non-blocking) try: sock.connect(address) except BlockingIOError: pass # Create glib IOChannel for socket and wait for it to become writable channel = self._channel_from_socket(sock) source = GLib.io_create_watch(channel, GLib.IO_OUT) def sock_finish_connect(sock): self._socket_handle_errors(sock) return (True, sock) return self._delayed(source, sock_finish_connect, sock) def sock_accept(self, sock): channel = self._channel_from_socket(sock) source = GLib.io_create_watch(channel, GLib.IO_IN) def sock_connection_received(sock): return (True, sock.accept()) async def accept_coro(future, conn): # Coroutine closing the accept socket if the future is cancelled try: return await future except CancelledError: sock.close() raise future = self._delayed(source, sock_connection_received, sock) return self.create_task(accept_coro(future, sock)) def sock_recv(self, sock, nbytes, flags=0): channel = self._channel_from_socket(sock) def read_func(channel, nbytes): if not sock._closed: return sock.recv(nbytes, flags) return self._channel_read(channel, nbytes, read_func) def sock_recv_into(self, sock, buf, flags=0): channel = self._channel_from_socket(sock) def read_func(channel, nbytes): if not sock._closed: return sock.recv_into(buf, flags) return self._channel_read(channel, len(buf), read_func) def sock_recvfrom(self, sock, nbytes, flags=0): channel = self._channel_from_socket(sock) def read_func(channel, nbytes): if not sock._closed: return sock.recvfrom(nbytes, flags) return self._channel_read(channel, nbytes, read_func) def sock_sendall(self, sock, buf, flags=0): channel = self._channel_from_socket(sock) def write_func(channel, buf): if not sock._closed: return sock.send(buf, flags) return self._channel_write(channel, buf, write_func) def sock_sendallto(self, sock, buf, addr, flags=0): channel = self._channel_from_socket(sock) def write_func(channel, buf): if not sock._closed: return sock.sendto(buf, flags, addr) return self._channel_write(channel, buf, write_func) ##################################### # Low-level GLib.Channel operations # ##################################### def _channel_read(self, channel, nbytes, read_func=None): if read_func is None: def read_func(channel, nbytes): return channel.read(nbytes) source = GLib.io_create_watch(channel, GLib.IO_IN | GLib.IO_HUP) def channel_readable(read_func, channel, nbytes): return (True, read_func(channel, nbytes)) return self._delayed(source, channel_readable, read_func, channel, nbytes) def _channel_write(self, channel, buf, write_func=None): if write_func is None: # note: channel.write doesn't raise BlockingIOError, instead it # returns 0 # gi.overrides.GLib.write has an isinstance(buf, bytes) check, so # we can't give it a bytearray or a memoryview. def write_func(channel, buf): return channel.write(bytes(buf)) buflen = len(buf) # Fast-path: If there is enough room in the OS buffer all data can be written synchronously try: nbytes = write_func(channel, buf) except BlockingIOError: nbytes = 0 else: if nbytes >= len(buf): # All data was written synchronously in one go result = asyncio.Future(loop=self) result.set_result(nbytes) return result # Chop off the initially transmitted data and store result # as a bytearray for easier future modification buf = bytearray(buf[nbytes:]) # Send the remaining data asynchronously as the socket becomes writable source = GLib.io_create_watch(channel, GLib.IO_OUT) def channel_writable(buflen, write_func, channel, buf): nbytes = write_func(channel, buf) if nbytes >= len(buf): return (True, buflen) else: del buf[0:nbytes] return (False, buflen) return self._delayed(source, channel_writable, buflen, write_func, channel, buf) def add_reader(self, fileobj, callback, *args): fd = self._fileobj_to_fd(fileobj) self._ensure_fd_no_transport(fd) self.remove_reader(fd) channel = self._channel_from_socket(fd) source = GLib.io_create_watch( channel, GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR | GLib.IO_NVAL ) assert fd not in self._readers self._readers[fd] = GLibHandle( loop=self, source=source, repeat=True, callback=callback, args=args ) def remove_reader(self, fileobj): fd = self._fileobj_to_fd(fileobj) self._ensure_fd_no_transport(fd) try: self._readers.pop(fd).cancel() return True except KeyError: return False def add_writer(self, fileobj, callback, *args): fd = self._fileobj_to_fd(fileobj) self._ensure_fd_no_transport(fd) self.remove_writer(fd) channel = self._channel_from_socket(fd) source = GLib.io_create_watch(channel, GLib.IO_OUT | GLib.IO_ERR | GLib.IO_NVAL) assert fd not in self._writers self._writers[fd] = GLibHandle( loop=self, source=source, repeat=True, callback=callback, args=args ) def remove_writer(self, fileobj): fd = self._fileobj_to_fd(fileobj) self._ensure_fd_no_transport(fd) try: self._writers.pop(fd).cancel() return True except KeyError: return False class GLibEventLoop(GLibBaseEventLoop): def __init__(self, *, context=None, application=None): self._application = application self._running = False self._argv = None super().__init__(context) if application is None: self._mainloop = GLib.MainLoop(self._context) def is_running(self): return self._running def run(self): recursive = self.is_running() if ( not recursive and hasattr(events, "_get_running_loop") and events._get_running_loop() ): raise RuntimeError( "Cannot run the event loop while another loop is running" ) if not recursive: self._running = True if hasattr(events, "_set_running_loop"): events._set_running_loop(self) try: if self._application is not None: self._application.run(self._argv) else: self._mainloop.run() finally: if not recursive: self._running = False if hasattr(events, "_set_running_loop"): events._set_running_loop(None) def run_until_complete(self, future, **kw): """Run the event loop until a Future is done. Return the Future's result, or raise its exception. """ def stop(f): self.stop() future = tasks.ensure_future(future, loop=self) future.add_done_callback(stop) try: self.run_forever(**kw) finally: future.remove_done_callback(stop) if not future.done(): raise RuntimeError("Event loop stopped before Future completed.") return future.result() def run_forever(self, application=None, argv=None): """Run the event loop until stop() is called.""" if application is not None: self.set_application(application) if argv is not None: self.set_argv(argv) if self.is_running(): raise RuntimeError( "Recursively calling run_forever is forbidden. " "To recursively run the event loop, call run()." ) if hasattr(self, "_mainloop") and hasattr(self._mainloop, "_quit_by_sigint"): del self._mainloop._quit_by_sigint try: self.run() finally: self.stop() # Methods scheduling callbacks. All these return Handles. def call_soon(self, callback, *args, context=None): self._check_not_coroutine(callback, "call_soon") source = GLib.Idle() source.set_priority(GLib.PRIORITY_DEFAULT) return GLibHandle( loop=self, source=source, repeat=False, callback=callback, args=args, context=context, ) call_soon_threadsafe = call_soon def call_later(self, delay, callback, *args, context=None): self._check_not_coroutine(callback, "call_later") return GLibHandle( loop=self, source=GLib.Timeout(delay * 1000) if delay > 0 else GLib.Idle(), repeat=False, callback=callback, args=args, context=context, ) def call_at(self, when, callback, *args, context=None): self._check_not_coroutine(callback, "call_at") return self.call_later(when - self.time(), callback, *args, context=context) def time(self): return GLib.get_monotonic_time() / 1000000 def stop(self): """Stop the inner-most invocation of the event loop. Typically, this will mean stopping the event loop completely. Note that due to the nature of GLib's main loop, stopping may not be immediate. """ if self._application is not None: self._application.quit() else: self._mainloop.quit() def set_application(self, application): if not isinstance(application, Gio.Application): raise TypeError("application must be a Gio.Application object") if self._application is not None: raise ValueError("application is already set") if self.is_running(): raise RuntimeError( "You can't add the application to a loop that's already running." ) self._application = application self._policy._application = application del self._mainloop def set_argv(self, argv): """Sets argv to be passed to Gio.Application.run()""" self._argv = argv class GLibEventLoopPolicy(events.AbstractEventLoopPolicy): """Default GLib event loop policy. In this policy, each thread has its own event loop. However, we only automatically create an event loop by default for the main thread; other threads by default have no event loop. """ EventLoopCls = GLibEventLoop # TODO add a parameter to synchronise with GLib's thread default contexts # (i.e., g_main_context_push_thread_default()) def __init__(self, application=None): self._default_loop = None self._application = application self._watcher_lock = threading.Lock() self._watcher = None self._policy = asyncio.DefaultEventLoopPolicy() self._policy.new_event_loop = self.new_event_loop self.get_event_loop = self._policy.get_event_loop self.set_event_loop = self._policy.set_event_loop def get_child_watcher(self): if self._watcher is None: with self._watcher_lock: if self._watcher is None: self._watcher = GLibChildWatcher() return self._watcher def set_child_watcher(self, watcher): """Set a child watcher. Must be an an instance of GLibChildWatcher, as it ties in with GLib appropriately. """ if watcher is not None and not isinstance(watcher, GLibChildWatcher): raise TypeError("Only GLibChildWatcher is supported!") with self._watcher_lock: self._watcher = watcher def new_event_loop(self): """Create a new event loop and return it.""" if ( not self._default_loop and threading.main_thread().ident == threading.get_ident() ): loop = self.get_default_loop() else: loop = self.EventLoopCls() loop._policy = self return loop def get_default_loop(self): """Get the default event loop.""" if not self._default_loop: self._default_loop = self._new_default_loop() return self._default_loop def _new_default_loop(self): loop = self.EventLoopCls( context=GLib.main_context_default(), application=self._application ) loop._policy = self return loop gbulb-0.6.6/src/gbulb/gtk.py000066400000000000000000000027771467072443600156710ustar00rootroot00000000000000import threading from gi.repository import Gtk from .glib_events import GLibEventLoop, GLibEventLoopPolicy __all__ = ["GtkEventLoop", "GtkEventLoopPolicy"] class GtkEventLoop(GLibEventLoop): """Gtk-based event loop. This loop supports recursion in Gtk, for example for implementing modal windows. """ def __init__(self, **kwargs): self._recursive = 0 self._recurselock = threading.Lock() super().__init__(**kwargs) def run(self): """Run the event loop until Gtk.main_quit is called. May be called multiple times to recursively start it again. This is useful for implementing asynchronous-like dialogs in code that is otherwise not asynchronous, for example modal dialogs. """ if self.is_running(): with self._recurselock: self._recursive += 1 try: Gtk.main() finally: with self._recurselock: self._recursive -= 1 else: super().run() def stop(self): """Stop the inner-most event loop. If it's also the outer-most event loop, the event loop will stop. """ with self._recurselock: r = self._recursive if r > 0: Gtk.main_quit() else: super().stop() class GtkEventLoopPolicy(GLibEventLoopPolicy): """Gtk-based event loop policy. Use this if you are using Gtk. """ EventLoopCls = GtkEventLoop gbulb-0.6.6/src/gbulb/transports.py000066400000000000000000000354421467072443600173160ustar00rootroot00000000000000import asyncio import collections import io import socket import subprocess import sys from asyncio import CancelledError, InvalidStateError, base_subprocess, transports class BaseTransport(transports.BaseTransport): def __init__(self, loop, sock, protocol, waiter=None, extra=None, server=None): if hasattr(self, "_sock"): return # The joys of multiple inheritance transports.BaseTransport.__init__(self, extra) self._loop = loop self._sock = sock self._server = server self._closing = False self._closing_delayed = False self._closed = False self._cancelable = set() self.set_protocol(protocol) if sock is not None: self._loop._transports[sock.fileno()] = self if self._server is not None: if sys.version_info < (3, 13): self._server._attach() else: self._server._attach(self) def transport_async_init(): self._protocol.connection_made(self) if waiter is not None and not waiter.cancelled(): waiter.set_result(None) self._loop.call_soon(transport_async_init) def close(self): self._closing = True if not self._closing_delayed: self._force_close(None) def is_closing(self): return self._closing def set_protocol(self, protocol): self._protocol = protocol def get_protocol(self): return self._protocol def _fatal_error(self, exc, message="Fatal error on pipe transport"): self._loop.call_exception_handler( { "message": message, "exception": exc, "transport": self, "protocol": self._protocol, } ) self._force_close(exc) def _force_close(self, exc): if self._closed: return self._closed = True # Stop all running tasks for cancelable in self._cancelable: cancelable.cancel() self._cancelable.clear() self._loop.call_soon(self._force_close_async, exc) def _force_close_async(self, exc): try: self._protocol.connection_lost(exc) finally: if self._sock is not None: self._sock.close() self._sock = None if self._server is not None: if sys.version_info < (3, 13): self._server._detach() else: self._server._detach(self) self._server = None class ReadTransport(BaseTransport, transports.ReadTransport): max_size = io.DEFAULT_BUFFER_SIZE def __init__(self, *args, **kwargs): self._paused = False self._read_fut = None self._read_buffer = None self._alloc_read_buffers = False BaseTransport.__init__(self, *args, **kwargs) self._loop.call_soon(self._loop_reading) def set_protocol(self, protocol): self._alloc_read_buffers = isinstance(protocol, asyncio.BufferedProtocol) super().set_protocol(protocol) def pause_reading(self): if self._closing: raise RuntimeError("Cannot pause_reading() when closing") if self._paused: raise RuntimeError("Already paused") self._paused = True def resume_reading(self): if not self._paused: raise RuntimeError("Not paused") self._paused = False if self._closing: return self._loop.call_soon(self._loop_reading, self._read_fut) def _close_read(self): # Separate method to allow `Transport.close()` to call us without # us delegating to `BaseTransport.close()` if self._read_fut is not None: self._read_fut.cancel() self._read_fut = None def close(self): self._close_read() super().close() def _create_read_future(self, size): if self._alloc_read_buffers: self._read_buffer = self._protocol.get_buffer(size) return self._loop.sock_recv_into(self._sock, self._read_buffer) else: return self._loop.sock_recv(self._sock, size) def _submit_read_data(self, data): if data != b"" and data != 0: if self._alloc_read_buffers: assert isinstance(data, int) # Actually `nbytes` self._protocol.buffer_updated(data) self._read_buffer = None else: assert isinstance(data, bytes) self._protocol.data_received(data) else: self._read_buffer = None keep_open = self._protocol.eof_received() if not keep_open: self.close() def _loop_reading(self, fut=None): if self._paused: return try: data = None if fut is not None: assert self._read_fut is fut or ( self._read_fut is None and self._closing ) if self._read_fut in self._cancelable: self._cancelable.remove(self._read_fut) self._read_fut = None data = fut.result() # Deliver data later in "finally" clause if self._closing: # Since `.close()` has been called we ignore any read data data = None return if data is not None: self._submit_read_data(data) if data == b"" or data == 0: # No need to reschedule on end-of-file return # Reschedule a new read self._read_fut = self._create_read_future(self.max_size) self._cancelable.add(self._read_fut) except ConnectionAbortedError as exc: if not self._closing: self._fatal_error(exc, "Fatal read error on pipe transport") except ConnectionResetError as exc: self._force_close(exc) except OSError as exc: self._fatal_error(exc, "Fatal read error on pipe transport") except CancelledError: if not self._closing: raise except InvalidStateError: self._read_fut = fut self._cancelable.add(self._read_fut) else: self._read_fut.add_done_callback(self._loop_reading) class WriteTransport(BaseTransport, transports._FlowControlMixin): _buffer_factory = bytearray def __init__(self, loop, *args, **kwargs): transports._FlowControlMixin.__init__(self, None, loop) BaseTransport.__init__(self, loop, *args, **kwargs) self._write_buffer = self._buffer_factory() self._drained_callbacks = set() self._write_fut = None self._eof_written = False def abort(self): self._force_close(None) def can_write_eof(self): return True def get_write_buffer_size(self): return len(self._write_buffer) def _close_write(self): if self._write_fut is not None: self._closing_delayed = True def transport_write_done_callback(): self._closing_delayed = False self.close() self._drained_callbacks.add(transport_write_done_callback) def close(self): self._close_write() super().close() def write(self, data): if self._eof_written: raise RuntimeError("write_eof() already called") # Ignore empty data sets or requests to write to a dying connection if not data or self._closing: return if self._write_fut is None: # No data is currently buffered or being sent self._loop_writing(data=data) else: self._buffer_add_data(data) self._maybe_pause_protocol() # From _FlowControlMixin def _create_write_future(self, data): return self._loop.sock_sendall(self._sock, data) def _buffer_add_data(self, data): self._write_buffer.extend(data) def _buffer_pop_data(self): if len(self._write_buffer) > 0: data = self._write_buffer self._write_buffer = self._buffer_factory() return data else: return None def _loop_writing(self, fut=None, data=None): try: assert fut is self._write_fut if self._write_fut in self._cancelable: self._cancelable.remove(self._write_fut) self._write_fut = None # Raise possible exception stored in `fut` if fut: fut.result() # Use buffer as next data object if invoked from done callback if data is None: data = self._buffer_pop_data() if not data: if len(self._drained_callbacks) > 0: for callback in self._drained_callbacks: callback() self._drained_callbacks.clear() self._maybe_resume_protocol() else: self._write_fut = self._create_write_future(data) self._cancelable.add(self._write_fut) if not self._write_fut.done(): self._write_fut.add_done_callback(self._loop_writing) self._maybe_pause_protocol() else: self._write_fut.add_done_callback(self._loop_writing) except ConnectionResetError as exc: self._force_close(exc) except OSError as exc: self._fatal_error(exc, "Fatal write error on pipe transport") def write_eof(self): self.close() class Transport(ReadTransport, WriteTransport): def __init__(self, *args, **kwargs): ReadTransport.__init__(self, *args, **kwargs) WriteTransport.__init__(self, *args, **kwargs) # Set expected extra attributes (available through `.get_extra_info()`) self._extra["socket"] = self._sock try: self._extra["sockname"] = self._sock.getsockname() except (OSError, AttributeError): pass if "peername" not in self._extra: try: self._extra["peername"] = self._sock.getpeername() except (OSError, AttributeError): pass def close(self): # Need to invoke both the read's and the write's part of the transport `close` function self._close_read() self._close_write() BaseTransport.close(self) class SocketTransport(Transport): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def write_eof(self): if self._closing or self._eof_written: return self._eof_written = True if self._write_fut is None: self._sock.shutdown(socket.SHUT_WR) else: def transport_write_eof_callback(): if not self._closing: self._sock.shutdown(socket.SHUT_WR) self._drained_callbacks.add(transport_write_eof_callback) class DatagramTransport(Transport, transports.DatagramTransport): _buffer_factory = collections.deque def __init__(self, loop, sock, protocol, address=None, *args, **kwargs): self._address = address super().__init__(loop, sock, protocol, *args, **kwargs) def _create_read_future(self, size): return self._loop.sock_recvfrom(self._sock, size) def _submit_read_data(self, args): (data, addr) = args self._protocol.datagram_received(data, addr) def _create_write_future(self, args): (data, addr) = args if self._address: return self._loop.sock_sendall(self._sock, data) else: return self._loop.sock_sendallto(self._sock, data, addr) def _buffer_add_data(self, args): (data, addr) = args self._write_buffer.append((bytes(data), addr)) def _buffer_pop_data(self): if len(self._write_buffer) > 0: return self._write_buffer.popleft() else: return None def write(self, data, addr=None): if not isinstance(data, (bytes, bytearray, memoryview)): raise TypeError( "data argument must be a bytes-like object, " "not {!r}".format(type(data).__name__) ) if not data or self.is_closing(): return if self._address and addr not in (None, self._address): raise ValueError(f"Invalid address: must be None or {self._address}") # Do not copy the data yet, as we might be able to send it synchronously super().write((data, addr)) sendto = write class PipeReadTransport(ReadTransport): def __init__(self, loop, channel, protocol, waiter, extra): self._channel = channel self._channel.set_close_on_unref(True) super().__init__(loop, None, protocol, waiter, extra) def _create_read_future(self, size): if self._alloc_read_buffers: self._read_buffer = self._protocol.get_buffer(size) size = len(self._read_buffer) return self._loop._channel_read(self._channel, size) def _submit_read_data(self, data): assert isinstance(data, bytes) if data != b"" and data != 0: if self._alloc_read_buffers: # FIXME: GLib does not actually expose the equivalent to # `recv_into` in its channel interface, so we have to # add an extra copy here rather than avoiding one self._read_buffer[0 : len(data)] = data self._protocol.buffer_updated(len(data)) self._read_buffer = None else: self._protocol.data_received(data) else: self._read_buffer = None keep_open = self._protocol.eof_received() if not keep_open: self.close() def _force_close_async(self, exc): try: super()._force_close_async(exc) finally: self._channel.shutdown(True) class PipeWriteTransport(WriteTransport): def __init__(self, loop, channel, protocol, waiter, extra): self._channel = channel self._channel.set_close_on_unref(True) super().__init__(loop, None, protocol, waiter, extra) def _create_write_future(self, data): return self._loop._channel_write(self._channel, data) def _force_close_async(self, exc): try: super()._force_close_async(exc) finally: self._channel.shutdown(True) class SubprocessTransport(base_subprocess.BaseSubprocessTransport): def _start(self, args, shell, stdin, stdout, stderr, bufsize, **kwargs): self._proc = subprocess.Popen( args, shell=shell, stdin=stdin, stdout=stdout, stderr=stderr, bufsize=bufsize, **kwargs, ) gbulb-0.6.6/src/gbulb/utils.py000066400000000000000000000037151467072443600162350ustar00rootroot00000000000000import asyncio import sys import weakref if sys.version_info < (3, 14): from .glib_events import GLibChildWatcher __all__ = ["install", "get_event_loop", "new_event_loop", "wait_signal"] def install(gtk=False): """Set the default event loop policy. Call this as early as possible to ensure everything has a reference to the correct event loop. Set ``gtk`` to True if you intend to use Gtk in your application. If ``gtk`` is True and Gtk is not available, will raise `ValueError`. Note that this class performs some monkey patching of asyncio to ensure correct functionality. """ if gtk: from .gtk import GtkEventLoopPolicy policy = GtkEventLoopPolicy() else: from .glib_events import GLibEventLoopPolicy policy = GLibEventLoopPolicy() if sys.version_info < (3, 14): # There are some libraries that use SafeChildWatcher directly (which is # completely reasonable), so we have to ensure that it is our version. asyncio.SafeChildWatcher = GLibChildWatcher asyncio.set_event_loop_policy(policy) def get_event_loop(): """Alias to asyncio.get_event_loop().""" return asyncio.get_event_loop() def new_event_loop(): """Alias to asyncio.new_event_loop().""" return asyncio.new_event_loop() class wait_signal(asyncio.Future): """A future for waiting for a given signal to occur.""" def __init__(self, obj, name, *, loop=None): super().__init__(loop=loop) self._obj = weakref.ref(obj, lambda s: self.cancel()) self._hnd = obj.connect(name, self._signal_callback) def _signal_callback(self, *k): obj = self._obj() if obj is not None: obj.disconnect(self._hnd) self.set_result(k) def cancel(self): if self.cancelled(): return False super().cancel() obj = self._obj() if obj is not None: obj.disconnect(self._hnd) return True gbulb-0.6.6/tests/000077500000000000000000000000001467072443600137755ustar00rootroot00000000000000gbulb-0.6.6/tests/Dockerfile000066400000000000000000000023571467072443600157760ustar00rootroot00000000000000FROM ubuntu:24.04 # Disable pip's warnings and SDL audio ENV PIP_ROOT_USER_ACTION=ignore \ PIP_NO_WARN_SCRIPT_LOCATION=0 \ SDL_AUDIODRIVER=dummy # Run apt non-interactively; use ARG so this only applies while building the image ARG DEBIAN_FRONTEND="noninteractive" # Add deadsnakes RUN apt-get update -y && \ apt-get install --no-install-recommends -y software-properties-common && \ add-apt-repository ppa:deadsnakes/ppa # Install System python RUN apt-get update -y && \ apt-get install --no-install-recommends -y \ libgirepository1.0-dev \ gir1.2-gtk-3.0 \ libcairo2-dev \ build-essential \ git \ python3.8-dev \ python3.8-venv \ python3.9-dev \ python3.9-venv \ python3.10-dev \ python3.10-venv \ python3.11-dev \ python3.11-venv \ python3-dev \ python3-venv \ python3.13-dev \ python3.13-venv \ python3-pip RUN groupadd beeware && \ useradd brutus -g beeware --home /home/brutus && \ mkdir -p /home/brutus && chown brutus:beeware /home/brutus # Use the brutus user for operations in the container USER brutus # Set the working directory WORKDIR /home/brutus/gbulb CMD /bin/bash gbulb-0.6.6/tests/conftest.py000066400000000000000000000017271467072443600162030ustar00rootroot00000000000000import pytest def fail_test(loop, context): # pragma: no cover loop.test_failure = context def setup_test_loop(loop): loop.set_exception_handler(fail_test) loop.test_failure = None def check_loop_failures(loop): # pragma: no cover if loop.test_failure is not None: pytest.fail("{message}: {exception}".format(**loop.test_failure)) @pytest.fixture def glib_policy(): from gbulb.glib_events import GLibEventLoopPolicy return GLibEventLoopPolicy() @pytest.fixture def gtk_policy(): from gbulb.gtk import GtkEventLoopPolicy return GtkEventLoopPolicy() @pytest.fixture(scope="function") def glib_loop(glib_policy): loop = glib_policy.new_event_loop() setup_test_loop(loop) yield loop check_loop_failures(loop) loop.close() @pytest.fixture(scope="function") def gtk_loop(gtk_policy): loop = gtk_policy.new_event_loop() setup_test_loop(loop) yield loop check_loop_failures(loop) loop.close() gbulb-0.6.6/tests/test_glib_events.py000066400000000000000000000372511467072443600177170ustar00rootroot00000000000000import asyncio import os import sys import tempfile from unittest import mock, skipIf import pytest from gi.repository import Gio, GLib is_windows = sys.platform == "win32" class TestGLibEventLoopPolicy: def test_set_child_watcher(self, glib_policy): from gbulb.glib_events import GLibChildWatcher with pytest.raises(TypeError): glib_policy.set_child_watcher(5) glib_policy.set_child_watcher(None) assert isinstance(glib_policy.get_child_watcher(), GLibChildWatcher) g = GLibChildWatcher() glib_policy.set_child_watcher(g) assert glib_policy.get_child_watcher() is g def test_new_event_loop(self, glib_policy): a = glib_policy.new_event_loop() b = glib_policy.new_event_loop() assert a == glib_policy.get_default_loop() assert b != glib_policy.get_default_loop() def test_new_event_loop_application(self, glib_policy): a = glib_policy.new_event_loop() a.set_application(Gio.Application()) b = glib_policy.new_event_loop() assert b._application is None class TestGLibHandle: def test_attachment_order(self, glib_loop): call_manager = mock.Mock() from gbulb.glib_events import GLibHandle # stub this out, we don't care if it gets called or not call_manager.loop.get_debug = lambda: True h = GLibHandle( loop=call_manager.loop, source=call_manager.source, repeat=True, callback=call_manager.callback, args=(), ) print(call_manager.mock_calls) expected_calls = [ mock.call.loop._handlers.add(h), mock.call.source.set_callback(h.__callback__, h), mock.call.source.attach(call_manager.loop._context), ] assert call_manager.mock_calls == expected_calls async def no_op_coro(): pass class TestBaseGLibEventLoop: @skipIf(is_windows, "Unix signal handlers are not supported on Windows") def test_add_signal_handler(self, glib_loop): import os import signal called = False def handler(): nonlocal called called = True glib_loop.stop() glib_loop.add_signal_handler(signal.SIGHUP, handler) assert signal.SIGHUP in glib_loop._sighandlers glib_loop.call_later(0.01, os.kill, os.getpid(), signal.SIGHUP) glib_loop.run_forever() assert called, "signal handler didn't fire" @skipIf(is_windows, "Unix signal handlers are not supported on Windows") def test_remove_signal_handler(self, glib_loop): import signal glib_loop.add_signal_handler(signal.SIGHUP, None) assert signal.SIGHUP in glib_loop._sighandlers assert glib_loop.remove_signal_handler(signal.SIGHUP) assert signal.SIGHUP not in glib_loop._sighandlers # FIXME: it'd be great if we could actually try signalling the process @skipIf(is_windows, "Unix signal handlers are not supported on Windows") def test_remove_signal_handler_unhandled(self, glib_loop): import signal assert not glib_loop.remove_signal_handler(signal.SIGHUP) @skipIf(is_windows, "Unix signal handlers are not supported on Windows") @pytest.mark.filterwarnings("ignore:g_unix_signal_source_new") def test_remove_signal_handler_sigkill(self, glib_loop): import signal with pytest.raises(RuntimeError): glib_loop.add_signal_handler(signal.SIGKILL, None) @skipIf(is_windows, "Unix signal handlers are not supported on Windows") @pytest.mark.filterwarnings("ignore:g_unix_signal_source_new") def test_remove_signal_handler_sigill(self, glib_loop): import signal with pytest.raises(ValueError): glib_loop.add_signal_handler(signal.SIGILL, None) def test_run_until_complete_early_stop(self, glib_loop): import asyncio async def coro(): glib_loop.call_soon(glib_loop.stop) await asyncio.sleep(5) with pytest.raises(RuntimeError): glib_loop.run_until_complete(coro()) @skipIf( is_windows, "Waiting on raw file descriptors only works for sockets on Windows" ) def test_add_writer(self, glib_loop): import os rfd, wfd = os.pipe() called = False def callback(*args): nonlocal called called = True glib_loop.stop() glib_loop.add_writer(wfd, callback) glib_loop.run_forever() os.close(rfd) os.close(wfd) assert called, "callback handler didn't fire" @skipIf( is_windows, "Waiting on raw file descriptors only works for sockets on Windows" ) def test_add_reader(self, glib_loop): import os rfd, wfd = os.pipe() called = False def callback(*args): nonlocal called called = True glib_loop.stop() glib_loop.add_reader(rfd, callback) os.write(wfd, b"hey") glib_loop.run_forever() os.close(rfd) os.close(wfd) assert called, "callback handler didn't fire" @skipIf( is_windows, "Waiting on raw file descriptors only works for sockets on Windows" ) def test_add_reader_file(self, glib_loop): import os rfd, wfd = os.pipe() f = os.fdopen(rfd, "r") glib_loop.add_reader(f, None) os.close(rfd) os.close(wfd) @skipIf( is_windows, "Waiting on raw file descriptors only works for sockets on Windows" ) def test_add_writer_file(self, glib_loop): import os rfd, wfd = os.pipe() f = os.fdopen(wfd, "r") glib_loop.add_writer(f, None) os.close(rfd) os.close(wfd) @skipIf( is_windows, "Waiting on raw file descriptors only works for sockets on Windows" ) def test_remove_reader(self, glib_loop): import os rfd, wfd = os.pipe() f = os.fdopen(wfd, "r") glib_loop.add_reader(f, None) os.close(rfd) os.close(wfd) assert glib_loop.remove_reader(f) assert not glib_loop.remove_reader(f.fileno()) @skipIf( is_windows, "Waiting on raw file descriptors only works for sockets on Windows" ) def test_remove_writer(self, glib_loop): import os rfd, wfd = os.pipe() f = os.fdopen(wfd, "r") glib_loop.add_writer(f, None) os.close(rfd) os.close(wfd) assert glib_loop.remove_writer(f) assert not glib_loop.remove_writer(f.fileno()) def test_time(self, glib_loop): import time SLEEP_TIME = 0.125 s = glib_loop.time() time.sleep(SLEEP_TIME) e = glib_loop.time() diff = e - s assert SLEEP_TIME + 0.01 >= diff >= SLEEP_TIME def test_call_at(self, glib_loop): called = False def handler(): nonlocal called called = True now = glib_loop.time() glib_loop.stop() print(now, s) assert now - s <= 0.2 s = glib_loop.time() glib_loop.call_at(s + 0.1, handler) glib_loop.run_forever() assert called, "call_at handler didn't fire" def test_call_soon_no_coroutine(self, glib_loop): with pytest.raises(TypeError): glib_loop.call_soon(no_op_coro) def test_call_later_no_coroutine(self, glib_loop): with pytest.raises(TypeError): glib_loop.call_later(1, no_op_coro) def test_call_at_no_coroutine(self, glib_loop): with pytest.raises(TypeError): glib_loop.call_at(1, no_op_coro) def test_call_soon_priority_order(self, glib_loop): items = [] def handler(i): items.append(i) for i in range(10): glib_loop.call_soon(handler, i) glib_loop.call_soon(glib_loop.stop) glib_loop.run_forever() assert items assert items == sorted(items) def test_call_soon_priority(self, glib_loop): h = glib_loop.call_soon(lambda: None) assert h._source.get_priority() == GLib.PRIORITY_DEFAULT h.cancel() @skipIf( is_windows, "Waiting on raw file descriptors only works for sockets on Windows" ) def test_add_writer_multiple_calls(self, glib_loop): import os rfd, wfd = os.pipe() timeout_occurred = False expected_i = 10 i = 0 def callback(): nonlocal i i += 1 if i == expected_i: glib_loop.stop() def timeout(): nonlocal timeout_occurred timeout_occurred = True glib_loop.stop() try: glib_loop.add_writer(wfd, callback) glib_loop.call_later(0.1, timeout) glib_loop.run_forever() finally: os.close(rfd) os.close(wfd) assert not timeout_occurred assert i == expected_i def test_call_soon_threadsafe(self, glib_loop): called = False def handler(): nonlocal called called = True glib_loop.stop() glib_loop.call_soon_threadsafe(handler) glib_loop.run_forever() assert called, "call_soon_threadsafe handler didn't fire" class TestGLibEventLoop: def test_run_forever_recursion(self, glib_loop): def play_it_again_sam(): with pytest.raises(RuntimeError): glib_loop.run_forever() glib_loop.call_soon(play_it_again_sam) glib_loop.call_soon(glib_loop.stop) glib_loop.run_forever() def test_run_recursion(self, glib_loop): passed = False def first(): assert glib_loop._running glib_loop.call_soon(second) glib_loop.run() assert glib_loop._running def second(): nonlocal passed assert glib_loop._running glib_loop.stop() assert glib_loop._running passed = True assert not glib_loop._running glib_loop.call_soon(first) glib_loop.run() assert not glib_loop._running assert passed def test_run(self, glib_loop): with mock.patch.object(glib_loop, "_mainloop") as ml: glib_loop.run() ml.run.assert_any_call() glib_loop.set_application(Gio.Application()) with mock.patch.object(glib_loop, "_application") as app: glib_loop.run() app.run.assert_any_call(None) def test_stop(self, glib_loop): with mock.patch.object(glib_loop, "_mainloop") as ml: glib_loop.stop() ml.quit.assert_any_call() glib_loop.set_application(Gio.Application()) with mock.patch.object(glib_loop, "_application") as app: glib_loop.stop() app.quit.assert_any_call() def test_set_application(self, glib_loop): assert glib_loop._application is None assert glib_loop._policy._application is None app = Gio.Application() glib_loop.set_application(app) assert glib_loop._application == app assert glib_loop._policy._application == app def test_set_application_invalid_type(self, glib_loop): with pytest.raises(TypeError): glib_loop.set_application(None) def test_set_application_invalid_repeat_calls(self, glib_loop): app = Gio.Application() glib_loop.set_application(app) with pytest.raises(ValueError): glib_loop.set_application(app) def test_set_application_invalid_when_running(self, glib_loop): app = Gio.Application() with pytest.raises(RuntimeError): with mock.patch.object(glib_loop, "is_running", return_value=True): glib_loop.set_application(app) @skipIf(is_windows, "Unix signal handlers are not supported on Windows") def test_signal_handling_with_multiple_invocations(glib_loop): import os import signal glib_loop.call_later(0.01, os.kill, os.getpid(), signal.SIGINT) with pytest.raises(KeyboardInterrupt): glib_loop.run_forever() glib_loop.run_until_complete(asyncio.sleep(0)) @skipIf(is_windows, "Unix signal handlers are not supported on Windows") def test_default_signal_handling(glib_loop): import os import signal glib_loop.call_later(0.01, os.kill, os.getpid(), signal.SIGINT) with pytest.raises(KeyboardInterrupt): glib_loop.run_forever() def test_subprocesses_read_after_closure(glib_loop): import asyncio import subprocess # needed to ensure events.get_child_watcher() returns the right object import gbulb gbulb.install() async def coro(): proc = await asyncio.create_subprocess_exec( "cat", stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE, ) proc.stdin.write(b"hey\n") await proc.stdin.drain() proc.stdin.close() out = await proc.stdout.read() assert out == b"hey\n" await proc.wait() glib_loop.run_until_complete(coro()) def test_subprocesses_readline_without_closure(glib_loop): # needed to ensure events.get_child_watcher() returns the right object import gbulb gbulb.install() async def run(): proc = await asyncio.create_subprocess_exec( "cat", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, ) try: proc.stdin.write(b"test line\n") await proc.stdin.drain() line = await asyncio.wait_for(proc.stdout.readline(), timeout=5) assert line == b"test line\n" proc.stdin.close() line = await asyncio.wait_for(proc.stdout.readline(), timeout=5) assert line == b"" finally: await proc.wait() glib_loop.run_until_complete(run()) def test_sockets(glib_loop): server_done = asyncio.Event() server_done._loop = glib_loop server_success = False async def cb(reader, writer): nonlocal server_success writer.write(b"cool data\n") await writer.drain() print("reading") d = await reader.readline() print("hrm", d) server_success = d == b"thank you\n" writer.close() server_done.set() async def run(): s = await asyncio.start_server(cb, "127.0.0.1", 0) reader, writer = await asyncio.open_connection( "127.0.0.1", s.sockets[0].getsockname()[-1] ) d = await reader.readline() assert d == b"cool data\n" writer.write(b"thank you\n") await writer.drain() writer.close() await server_done.wait() assert server_success glib_loop.run_until_complete(run()) def test_unix_sockets(glib_loop): server_done = asyncio.Event() server_done._loop = glib_loop server_success = False async def cb(reader, writer): nonlocal server_success writer.write(b"cool data\n") await writer.drain() d = await reader.readline() server_success = d == b"thank you\n" writer.close() server_done.set() async def run(): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "socket") await asyncio.start_unix_server(cb, path) reader, writer = await asyncio.open_unix_connection(path) d = await reader.readline() assert d == b"cool data\n" writer.write(b"thank you\n") await writer.drain() writer.close() await server_done.wait() assert server_success glib_loop.run_until_complete(run()) gbulb-0.6.6/tests/test_gtk.py000066400000000000000000000026051467072443600161760ustar00rootroot00000000000000import pytest try: import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk except ImportError: # pragma: no cover Gtk = None @pytest.mark.skipif(not Gtk, reason="Gtk is not available") class TestGtkEventLoopPolicy: def test_new_event_loop(self, gtk_policy): from gbulb.gtk import GtkEventLoop a = gtk_policy.new_event_loop() b = gtk_policy.new_event_loop() assert isinstance(a, GtkEventLoop) assert isinstance(b, GtkEventLoop) assert a != b assert a == gtk_policy.get_default_loop() def test_new_event_loop_application(self, gtk_policy): a = gtk_policy.new_event_loop() a.set_application(Gtk.Application()) b = gtk_policy.new_event_loop() assert b._application is None def test_event_loop_recursion(self, gtk_loop): loop_count = 0 def inner(): nonlocal loop_count i = loop_count print("starting loop", loop_count) loop_count += 1 if loop_count == 10: print(f"loop {i} stopped") gtk_loop.stop() else: gtk_loop.call_soon(inner) gtk_loop.run() print(f"loop {i} stopped") gtk_loop.stop() gtk_loop.call_soon(inner) gtk_loop.run_forever() assert loop_count == 10 gbulb-0.6.6/tests/test_utils.py000066400000000000000000000064761467072443600165630ustar00rootroot00000000000000from unittest import mock import pytest @pytest.mark.parametrize( "gtk,gtk_available", [ (False, False), (False, True), (True, False), (True, True), ], ) def test_install(gtk, gtk_available): import sys from gbulb import install called = False def set_event_loop_policy(pol): nonlocal called called = True cls_name = pol.__class__.__name__ if gtk: assert cls_name == "GtkEventLoopPolicy" else: assert cls_name == "GLibEventLoopPolicy" if gtk and "gbulb.gtk" in sys.modules: del sys.modules["gbulb.gtk"] mock_repository = mock.Mock() if not gtk_available: del mock_repository.Gtk with mock.patch.dict("sys.modules", {"gi.repository": mock_repository}): with mock.patch("asyncio.set_event_loop_policy", set_event_loop_policy): import_error = gtk and not gtk_available try: install(gtk=gtk) except ImportError: assert import_error else: assert not import_error assert called def test_get_event_loop(): import asyncio import gbulb try: loop = gbulb.new_event_loop() asyncio.set_event_loop(loop) assert asyncio.get_event_loop() is gbulb.get_event_loop() finally: loop.close() def test_wait_signal(glib_loop): import asyncio from gi.repository import GObject from gbulb import wait_signal class TestObject(GObject.GObject): __gsignals__ = { "foo": (GObject.SignalFlags.RUN_LAST, None, (str,)), } t = TestObject() async def emitter(): t.emit("foo", "frozen brains tell no tales") called = False async def waiter(): nonlocal called r = await wait_signal(t, "foo") assert r == (t, "frozen brains tell no tales") called = True glib_loop.run_until_complete( asyncio.wait( [ glib_loop.create_task(waiter()), glib_loop.create_task(emitter()), ], timeout=1, ) ) assert called def test_wait_signal_cancel(glib_loop): import asyncio from gi.repository import GObject from gbulb import wait_signal class TestObject(GObject.GObject): __gsignals__ = { "foo": (GObject.SignalFlags.RUN_LAST, None, (str,)), } t = TestObject() async def emitter(): t.emit("foo", "frozen brains tell no tales") called = False cancelled = False async def waiter(): nonlocal cancelled # Yield to the event loop await asyncio.sleep(0) r = wait_signal(t, "foo") @r.add_done_callback def caller(r): nonlocal called called = True r.cancel() assert r.cancelled() cancelled = True glib_loop.run_until_complete( asyncio.wait( [ glib_loop.create_task(waiter()), glib_loop.create_task(emitter()), ], timeout=1, ) ) assert cancelled assert called def test_wait_signal_cancel_state(): from gbulb import wait_signal m = wait_signal(mock.Mock(), "anything") assert m.cancel() assert not m.cancel() gbulb-0.6.6/tox.ini000066400000000000000000000014761467072443600141560ustar00rootroot00000000000000# Flake8 doesn't believe in pyproject.toml, so we put the configuration here. [flake8] # https://flake8.readthedocs.org/en/latest/ exclude=\ venv*/*,\ local/*,\ docs/*,\ build/*,\ tests/apps/*,\ .eggs/*,\ .tox/* max-line-length = 119 extend-ignore = # whitespace before : # See https://github.com/PyCQA/pycodestyle/issues/373 E203, [tox] envlist = towncrier-check,py{38,39,310,311,312,313} skip_missing_interpreters = true [testenv:py{,38,39,310,311,312,313}] setenv = PYTHONPATH = {toxinidir}/src extras = dev commands = python -m coverage run -m pytest {posargs:-vv --color yes} [testenv:towncrier{,-check}] skip_install = True deps = towncrier==24.8.0 commands = check : python -m towncrier.check --compare-with origin/main !check : python -m towncrier {posargs}