├── .editorconfig ├── .flake8 ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── bin └── python_qt_live_coding ├── docs ├── live_runner_example.png └── live_runner_example2.png ├── examples ├── integrated │ ├── live.qml │ ├── main.py │ ├── main.qml │ └── myapp │ │ ├── MainPanel.qml │ │ └── qmldir └── standalone │ ├── MainScreen.qml │ ├── module │ ├── __init__.py │ └── calculator.py │ └── qmldir ├── misc └── icon.svg ├── setup.py └── src └── livecoding ├── FileSelectionDialog.qml ├── LiveCodingPanel.qml ├── __init__.py ├── filewatcher.py ├── gui.py ├── icon.png ├── live.qml ├── live_coding_helper.py ├── moduleloader.py ├── projectbrowser.py ├── pythonreloader.py ├── qmldir ├── register_qml_types.py └── tests ├── __init__.py ├── test_filewatcher.py └── test_moduleloader.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | trim_trailing_whitespace = true 12 | charset = utf-8 13 | 14 | # 4 space indentation, PEP8 15 | [*.py] 16 | indent_size = 4 17 | max_line_length = 88 18 | 19 | [*.{qml, yaml}] 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # E501: line-length checking, handled by black 3 | # E203: space after :, not PEP8 compliant 4 | # W503: no operator after line break, not PEP8 compliant 5 | ignore = E501, E203, W503 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | docs/_build 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | coverage.xml 30 | htmlcov/ 31 | 32 | # Translations 33 | *.mo 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | .idea 40 | 41 | # virtualenv 42 | env 43 | .env/ 44 | .venv/ 45 | 46 | # autogenerated by distutils 47 | MANIFEST 48 | 49 | # Other stuff 50 | .cache/ 51 | 52 | # Linux 53 | .directory 54 | core 55 | !core/ 56 | 57 | # QML 58 | *.qmlc 59 | 60 | # Pytest 61 | .pytest_cache 62 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.0.0 4 | hooks: 5 | - id: check-byte-order-marker 6 | - id: trailing-whitespace 7 | - id: check-docstring-first 8 | - id: check-executables-have-shebangs 9 | - id: check-merge-conflict 10 | - id: check-symlinks 11 | - id: check-yaml 12 | - id: end-of-file-fixer 13 | - id: fix-encoding-pragma 14 | 15 | - repo: local 16 | hooks: 17 | - id: black 18 | name: Run black code formatter 19 | description: This hook formats Python code. 20 | entry: env LC_ALL=C.UTF-8 black -q 21 | language: system 22 | args: [-S] 23 | types: [python] 24 | 25 | - repo: https://github.com/pre-commit/pre-commit-hooks 26 | rev: v2.0.0 27 | hooks: 28 | - id: flake8 29 | # note: ignores and excluded files in .flake8 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - 3.6 5 | #env: 6 | # - TOX_ENV=py 7 | # - TOX_ENV=flake8 8 | install: 9 | - pip install -I -e .[dev] 10 | - pip install PySide2 11 | script: 12 | - pytest -v . 13 | #after_success: 14 | #coveralls 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Alexander Rössler 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include examples *.py *.qml qmldir 3 | include src/livecoding/*.qml 4 | include src/livecoding/*.png 5 | include src/livecoding/qmldir 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Live Coding Environment for Python, Qt and QML 2 | [![PyPI version](https://badge.fury.io/py/python-qt-live-coding.svg)](https://badge.fury.io/py/python-qt-live-coding) 3 | [![Build Status](https://travis-ci.org/machinekoder/python-qt-live-coding.svg?branch=master)](https://travis-ci.org/machinekoder/python-qt-live-coding) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/machinekoderpython-qt-live-coding/blob/master/LICENSE) 5 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 6 | 7 | ![Logo](./src/livecoding/icon.png) 8 | 9 | This project provides a live coding environment for Python and Qt. It supports both [PyQt](https://riverbankcomputing.com/software/pyqt/intro) and [Qt for Python (PySide2)](http://wiki.qt.io/Qt_for_Python) 10 | via the [python_qt_binding](https://pypi.org/project/python_qt_binding/). 11 | 12 | **See also**: 13 | 14 | * [My blog post about Qt/QML live coding](https://machinekoder.com/speed-up-your-gui-development-with-python-qt-and-qml-live-coding/) 15 | * [cpp-qt-live-coding](https://github.com/machinekoder/cpp-qt-live-coding): The C++ version of this project. 16 | * [Lightning Talk from QtDay.it 19](https://youtu.be/jbOPWncKE1I?t=1856) 17 | 18 | ## Install 19 | 20 | To install the live coding environment run: 21 | 22 | ```bash 23 | python setup.py install 24 | ``` 25 | 26 | or install it via pip 27 | 28 | ```bash 29 | pip install python-qt-live-coding 30 | ``` 31 | 32 | You also need to install PyQt or PySide2 for this application to work. The quickest way to 33 | achieve this is to use pip. 34 | 35 | ```bash 36 | pip install PyQt5 37 | ``` 38 | 39 | or 40 | 41 | ```bash 42 | pip install pyside2 43 | ``` 44 | 45 | ## Use 46 | 47 | The live coding environment comes with a live runner which enables your to live 48 | code Qt GUIs quickly. 49 | 50 | Run following to test drive the example: 51 | 52 | ```bash 53 | python_qt_live_coding examples 54 | ``` 55 | 56 | Your will instantly see the example project in the live runner. 57 | 58 | ![Live Runner Example](./docs/live_runner_example2.png) 59 | 60 | Now you can either select the `MainScreen.qml` file or type `MainScreen` in the filter. 61 | 62 | When you type, the file will be automatically selected. 63 | 64 | When loaded you will see following. 65 | 66 | ![Live Runner Example](./docs/live_runner_example.png) 67 | 68 | This is the example GUI inside the live runner. 69 | 70 | Now press the `Edit` button. Your favorite text editor should open promptly. 71 | 72 | Edit the code inside the editor und you will see the GUI updates instantly when you save the document. 73 | 74 | ### Integrate in your application 75 | 76 | Alternatively, you can integrate live coding into your Python Qt application. 77 | 78 | This especially useful if you want to customize the live coding GUI for your needs. 79 | 80 | For this purpose you need to do following things: 81 | 82 | 1. Integrate the `start_live_coding` function into your `main.py`. 83 | 2. Add a command line argument for live coding. 84 | 3. Optionally, add a custom `live.qml`. 85 | 86 | To learn more about how this works please take a look the [*integrated* example](./examples/integrated). 87 | 88 | ## Python QML module support 89 | 90 | The live coding environment has built in support for Python QML modules. 91 | 92 | The idea is to place QML and Python code in the same directory, similar to how you would create a Qt/C++ application. 93 | Additionally, with Python we have the advantage of being able to discover modules automatically. 94 | 95 | For this purpose add `register_qml_types` function to the `__init__.py` of your Python QML module. 96 | See the example in [examples/standalone/module/\_\_init__.py](./examples/standalone/module/__init__.py). 97 | 98 | However, so far automatic reloading of Python code is not support. 99 | When you work on a Python module please use the `Restart` button which restarts the live coding application instead. 100 | 101 | ## Forcing PyQt or Python for Qt (PySide2) usage 102 | 103 | In some cases you might want to force which Python Qt binding `python_qt_live_coding` is using. 104 | This can be done by passing the optional `--binding` argument via the command line. 105 | 106 | 107 | ```bash 108 | python_qt_live_coding -b pyside . 109 | ``` 110 | 111 | ## PyCharm Support 112 | 113 | For this application to work with PyCharm and other IntelliJ IDEs please disable the "safe write" feature. 114 | The feature writes a temporary file before saving any file, which can confuse the file change watcher. 115 | -------------------------------------------------------------------------------- /bin/python_qt_live_coding: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import argparse 4 | import sys 5 | 6 | import livecoding.gui 7 | 8 | 9 | if __name__ == '__main__': 10 | parser = argparse.ArgumentParser(description="Live Coding GUI") 11 | parser.add_argument( 12 | 'path', 13 | help='Path where the live coding environment should be executed.', 14 | nargs='?', 15 | default='.', 16 | ) 17 | parser.add_argument( 18 | '-b', '--binding', help='Force the Qt binding to be used.', default='' 19 | ) 20 | arguments, unknown = parser.parse_known_args() 21 | 22 | if arguments.binding: 23 | sys.SELECT_QT_BINDING = arguments.binding 24 | 25 | livecoding.gui.main(__file__, arguments) 26 | -------------------------------------------------------------------------------- /docs/live_runner_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinekoder/python-qt-live-coding/c877bb7fe2ec23a5bf0034289c09f715b964cc0a/docs/live_runner_example.png -------------------------------------------------------------------------------- /docs/live_runner_example2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinekoder/python-qt-live-coding/c877bb7fe2ec23a5bf0034289c09f715b964cc0a/docs/live_runner_example2.png -------------------------------------------------------------------------------- /examples/integrated/live.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.9 2 | import QtQuick.Controls 2.2 3 | import QtQuick.Layouts 1.3 4 | import QtQuick.Window 2.0 5 | import Qt.labs.settings 1.0 6 | import livecoding 1.0 7 | 8 | ApplicationWindow { 9 | id: root 10 | visible: true 11 | title: qsTr("My AppLive Coding") 12 | width: 1024 13 | height: 800 14 | flags: liveCoding.flags
 15 | visibility: liveCoding.visibility 16 | 17 | Component.onCompleted: { 18 | for (var i = 0; i < Qt.application.screens.length; ++i) { 19 | let screen = Qt.application.screens[i] 20 | if (screen.serialNumber === windowSettings.screen) { 21 | root.screen = screen 22 | return 23 | } 24 | } 25 | } 26 | 27 | Component.onDestruction: { 28 | windowSettings.screen = root.screen.serialNumber 29 | } 30 | 31 | LiveCodingPanel { 32 | id: liveCoding 33 | anchors.fill: parent 34 | } 35 | 36 | Settings { 37 | id: windowSettings 38 | category: "window" 39 | property alias width: root.width 40 | property alias height: root.height 41 | property alias x: root.x 42 | property alias y: root.y 43 | property alias visibility: liveCoding.visibility 44 | property alias flags: liveCoding.flags 45 | property alias hideToolBar: liveCoding.hideToolBar 46 | property string screen: "" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/integrated/main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | import signal 5 | import argparse 6 | 7 | from PyQt5.QtGui import QGuiApplication 8 | from PyQt5.QtCore import QObject, QTimer 9 | from PyQt5.QtQml import QQmlApplicationEngine 10 | 11 | from livecoding import start_livecoding_gui 12 | 13 | PROJECT_PATH = os.path.dirname(os.path.realpath(__name__)) 14 | 15 | 16 | class MyApp(QObject, object): 17 | def __init__(self, live, parent=None): 18 | super(MyApp, self).__init__(parent) 19 | 20 | self._engine = QQmlApplicationEngine() 21 | self._engine.addImportPath(PROJECT_PATH) 22 | if live: 23 | start_livecoding_gui( 24 | self._engine, PROJECT_PATH, __file__, live_qml='./live.qml' 25 | ) # live_qml is optional and can be used to customize the live coding environment 26 | else: 27 | qml_main = os.path.join(PROJECT_PATH, 'main.qml') 28 | self._engine.load(qml_main) 29 | 30 | self._start_check_timer() 31 | 32 | def _start_check_timer(self): 33 | self._timer = QTimer() 34 | self._timer.timeout.connect(lambda: None) 35 | self._timer.start(100) 36 | 37 | 38 | if __name__ == '__main__': 39 | signal.signal(signal.SIGINT, lambda *args: QGuiApplication.quit()) 40 | 41 | parser = argparse.ArgumentParser( 42 | description=""" 43 | Example App 44 | """ 45 | ) 46 | parser.add_argument( 47 | '-l', 48 | '--live', 49 | help='The live coding version of this application', 50 | action='store_true', 51 | ) 52 | args = parser.parse_args() 53 | 54 | app = QGuiApplication(sys.argv) 55 | 56 | gui = MyApp(live=args.live) 57 | 58 | sys.exit(app.exec_()) 59 | -------------------------------------------------------------------------------- /examples/integrated/main.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Controls 2.0 3 | import myapp 1.0 4 | 5 | ApplicationWindow { 6 | id: root 7 | width: 300 8 | height: 300 9 | visible: true 10 | title: qsTr("Example App") 11 | 12 | MainPanel { 13 | id: main 14 | anchors.fill: parent 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/integrated/myapp/MainPanel.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | 3 | Item { 4 | id: root 5 | 6 | Text { 7 | anchors.centerIn: parent 8 | text: "Main Panel" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/integrated/myapp/qmldir: -------------------------------------------------------------------------------- 1 | MainPanel 1.0 MainPanel.qml 2 | -------------------------------------------------------------------------------- /examples/standalone/MainScreen.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Controls 2.0 3 | import QtQuick.Layouts 1.0 4 | import QtQuick.Window 2.0 5 | import example.module 1.0 6 | 7 | Item { 8 | id: root 9 | 10 | Calculator { 11 | id: calc 12 | in1: Number(in1Input.text) 13 | in2: Number(in2Input.text) 14 | } 15 | 16 | Rectangle { 17 | anchors.fill: parent 18 | anchors.margins: 10 19 | color: "green" 20 | 21 | Row { 22 | anchors.centerIn: parent 23 | spacing: 10 24 | 25 | TextInput { 26 | id: in1Input 27 | text: "5" 28 | } 29 | 30 | Text { 31 | text: "+" 32 | } 33 | 34 | TextInput { 35 | id: in2Input 36 | text: "4" 37 | } 38 | 39 | Text { 40 | text: "=" 41 | } 42 | 43 | Text { 44 | text: calc.out 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/standalone/module/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from qtpy.QtQml import qmlRegisterType 3 | 4 | from .calculator import Calculator 5 | 6 | MODULE_NAME = 'example.module' 7 | 8 | 9 | def register_types(): 10 | qmlRegisterType(Calculator, MODULE_NAME, 1, 0, Calculator.__name__) 11 | -------------------------------------------------------------------------------- /examples/standalone/module/calculator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from qtpy.QtCore import QObject, Signal, Property 3 | 4 | 5 | class Calculator(QObject): 6 | 7 | in1Changed = Signal(float) 8 | in2Changed = Signal(float) 9 | outChanged = Signal(float) 10 | 11 | def __init__(self, parent=None): 12 | super(Calculator, self).__init__(parent) 13 | 14 | self._in1 = 0.0 15 | self._in2 = 0.0 16 | self._out = 0.0 17 | 18 | self.in1Changed.connect(lambda _: self._calculate()) 19 | self.in2Changed.connect(lambda _: self._calculate()) 20 | 21 | @Property(float, notify=in1Changed) 22 | def in1(self): 23 | return self._in1 24 | 25 | @in1.setter 26 | def in1(self, value): 27 | if value == self._in1: 28 | return 29 | self._in1 = value 30 | self.in1Changed.emit(value) 31 | 32 | @Property(float, notify=in2Changed) 33 | def in2(self): 34 | return self._in2 35 | 36 | @in2.setter 37 | def in2(self, value): 38 | if value == self._in2: 39 | return 40 | self._in2 = value 41 | self.in2Changed.emit(value) 42 | 43 | @Property(float, notify=outChanged) 44 | def out(self): 45 | return self._out 46 | 47 | def _calculate(self): 48 | self._out = self._in1 + self._in2 49 | self.outChanged.emit(self._out) 50 | -------------------------------------------------------------------------------- /examples/standalone/qmldir: -------------------------------------------------------------------------------- 1 | MainScreen 1.0 MainScreen.qml 2 | -------------------------------------------------------------------------------- /misc/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 44 | 46 | 47 | 49 | image/svg+xml 50 | 52 | 53 | 54 | 55 | 56 | 61 | 64 | [ ] 75 | 77 | 85 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup 3 | 4 | # read the contents of your README file 5 | from os import path 6 | 7 | this_directory = path.abspath(path.dirname(__file__)) 8 | with open(path.join(this_directory, 'README.md')) as f: 9 | long_description = f.read() 10 | 11 | setup( 12 | name='python-qt-live-coding', 13 | version='0.4.2', 14 | packages=['livecoding'], 15 | package_dir={'': 'src'}, 16 | url='https://github.com/machinekoder/python-qt-live-coding/', 17 | license='MIT', 18 | author='Alexander Rössler', 19 | author_email='alex@machinekoder.com', 20 | description='Live coding for Python, Qt and QML', 21 | long_description=long_description, 22 | long_description_content_type='text/markdown', 23 | install_requires=['six', 'qtpy'], 24 | extras_require={ 25 | 'dev': [ 26 | 'pytest', 27 | 'pytest-pep8', 28 | 'pytest-cov', 29 | 'pytest-qt', 30 | 'black', 31 | 'pre-commit', 32 | ] 33 | }, 34 | scripts=['bin/python_qt_live_coding'], 35 | include_package_data=True, 36 | ) 37 | -------------------------------------------------------------------------------- /src/livecoding/FileSelectionDialog.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Controls 2.0 3 | import QtQuick.Layouts 1.3 4 | import Qt.labs.settings 1.0 5 | 6 | Item { 7 | property bool selected: false 8 | property string folder: "" 9 | property string file: "" 10 | property bool autoSelect: true 11 | property var model: [] 12 | 13 | id: root 14 | 15 | QtObject { 16 | id: d 17 | readonly property var filteredModel: filterModel(root.model) 18 | 19 | function filterModel(model) { 20 | var newModel = [] 21 | for (var key in model) { 22 | var item = model[key] 23 | if (item.toLowerCase().indexOf(filterInput.text.toLowerCase()) !== -1) { 24 | newModel.push(item) 25 | } 26 | } 27 | return newModel 28 | } 29 | 30 | function select(file) { 31 | root.file = "file://" + file 32 | root.folder = "file://" + new String(file).substring( 33 | 0, file.lastIndexOf('/')) 34 | root.selected = true 35 | } 36 | } 37 | 38 | Settings { 39 | category: "live_coding" 40 | property alias filterText: filterInput.text 41 | } 42 | 43 | ColumnLayout { 44 | anchors.fill: parent 45 | 46 | TextField { 47 | id: filterInput 48 | Layout.fillWidth: true 49 | placeholderText: "filter" 50 | 51 | onFocusChanged: { 52 | if (focus) { 53 | selectAll() 54 | } 55 | } 56 | } 57 | 58 | ListView { 59 | id: listView 60 | Layout.fillWidth: true 61 | Layout.fillHeight: true 62 | clip: true 63 | model: d.filteredModel 64 | 65 | delegate: Button { 66 | readonly property string data: d.filteredModel[index] 67 | text: data 68 | onClicked: d.select(text) 69 | height: visible ? 30 : 0 70 | } 71 | 72 | onCountChanged: { 73 | if (root.autoSelect && (count == 1) && !root.selected) { 74 | selectTimer.start() 75 | } 76 | } 77 | } 78 | } 79 | 80 | Timer { 81 | id: selectTimer 82 | interval: 10 83 | onTriggered: d.select(d.filteredModel[0]) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/livecoding/LiveCodingPanel.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Controls 2.0 3 | import QtQuick.Layouts 1.3 4 | import QtQuick.Window 2.0 5 | import Qt.labs.settings 1.0 6 | import livecoding 1.0 7 | 8 | Item { 9 | id: root 10 | property alias hideToolBar: hideToolBarCheck.checked 11 | property int flags: Qt.Window 12 | property int visibility: Window.AutomaticVisibility 13 | 14 | readonly property var defaultNameFilters: ["*.qmlc", "*.jsc", "*.pyc", ".#*", ".*", "*~", "__pycache__", "*___jb_tmp___", // PyCharm safe write 15 | "*___jb_old___"] 16 | property var additionalNameFilters: [] 17 | 18 | QtObject { 19 | id: d 20 | 21 | function reload() { 22 | loader.source = "" 23 | helper.clearQmlComponentCache() 24 | loader.source = fileDialog.file 25 | } 26 | 27 | function openWithSystemEditor() { 28 | helper.openUrlWithDefaultApplication(fileDialog.file) 29 | } 30 | 31 | function unload() { 32 | loader.source = "" 33 | fileDialog.selected = false 34 | browser.update() 35 | } 36 | 37 | function restart() { 38 | PythonReloader.restart() 39 | } 40 | } 41 | 42 | LiveCodingHelper { 43 | id: helper 44 | } 45 | 46 | Settings { 47 | id: windowSettings 48 | category: "window" 49 | property alias width: root.width 50 | property alias height: root.height 51 | property alias x: root.x 52 | property alias y: root.y 53 | property alias visibility: root.visibility 54 | property alias hideToolBar: hideToolBarCheck.checked 55 | } 56 | 57 | MouseArea { 58 | id: smallArea 59 | anchors.top: parent.top 60 | anchors.left: parent.left 61 | anchors.right: parent.right 62 | height: 10 63 | width: height 64 | z: 10 65 | visible: contentItem.loaded && !fullArea.delayedVisible 66 | hoverEnabled: true 67 | propagateComposedEvents: true 68 | 69 | onClicked: mouse.accepted = false 70 | onEntered: fullArea.visible = true 71 | } 72 | 73 | MouseArea { 74 | property bool delayedVisible: false 75 | 76 | id: fullArea 77 | anchors.top: parent.top 78 | anchors.right: parent.right 79 | anchors.left: parent.left 80 | height: 40 81 | z: 9 82 | hoverEnabled: true 83 | propagateComposedEvents: true 84 | visible: false 85 | 86 | onClicked: mouse.accepted = false 87 | onPressed: mouse.accepted = false 88 | onReleased: mouse.accepted = false 89 | onExited: visible = false 90 | onVisibleChanged: delayTimer.start() 91 | 92 | Timer { 93 | id: delayTimer 94 | interval: 10 95 | onTriggered: fullArea.delayedVisible = fullArea.visible // break binding loop 96 | } 97 | } 98 | 99 | ColumnLayout { 100 | anchors.fill: parent 101 | anchors.topMargin: menuBar.visible ? 5 : 0 102 | 103 | RowLayout { 104 | id: menuBar 105 | visible: !hideToolBarCheck.checked || (smallArea.containsMouse 106 | || fullArea.containsMouse 107 | || !contentItem.loaded) 108 | 109 | Button { 110 | Layout.preferredHeight: 30 111 | enabled: fileDialog.selected 112 | text: qsTr("Edit") 113 | onClicked: d.openWithSystemEditor() 114 | } 115 | 116 | Button { 117 | Layout.preferredHeight: 30 118 | enabled: fileDialog.selected 119 | text: qsTr("Reload") 120 | onClicked: d.reload() 121 | } 122 | 123 | Button { 124 | Layout.preferredHeight: 30 125 | text: qsTr("Unload") 126 | onClicked: d.unload() 127 | } 128 | 129 | Button { 130 | Layout.preferredHeight: 30 131 | text: qsTr("Restart") 132 | onClicked: d.restart() 133 | } 134 | 135 | Item { 136 | Layout.fillWidth: true 137 | } 138 | 139 | CheckBox { 140 | id: hideToolBarCheck 141 | text: qsTr("Hide Tool Bar") 142 | checked: false 143 | } 144 | 145 | CheckBox { 146 | text: qsTr("Fullscreen") 147 | checked: root.visibility === Window.FullScreen 148 | 149 | onClicked: { 150 | if (checked) { 151 | root.visibility = Window.FullScreen 152 | } else { 153 | root.visibility = Window.AutomaticVisibility 154 | } 155 | } 156 | } 157 | 158 | CheckBox { 159 | text: qsTr("On Top") 160 | checked: root.flags & Qt.WindowStaysOnTopHint 161 | 162 | onClicked: { 163 | if (checked) { 164 | root.flags = root.flags | Qt.WindowStaysOnTopHint 165 | } else { 166 | root.flags = root.flags & ~Qt.WindowStaysOnTopHint 167 | } 168 | } 169 | } 170 | } 171 | 172 | Item { 173 | id: contentItem 174 | Layout.fillWidth: true 175 | Layout.fillHeight: true 176 | property bool loaded: loader.status !== Loader.Null 177 | 178 | Loader { 179 | id: loader 180 | anchors.fill: parent 181 | 182 | onStatusChanged: { 183 | if (status !== Loader.Error) { 184 | return 185 | } 186 | 187 | var msg = loader.sourceComponent.errorString() 188 | errorLabel.text = qsTr("QML Error: Loading QML file failed:\n") + msg 189 | } 190 | } 191 | 192 | Label { 193 | id: errorLabel 194 | anchors.left: parent.left 195 | anchors.right: parent.right 196 | anchors.verticalCenter: parent.verticalCenter 197 | horizontalAlignment: Text.AlignHCenter 198 | wrapMode: Text.Wrap 199 | visible: loader.status === Loader.Error 200 | } 201 | 202 | FileSelectionDialog { 203 | id: fileDialog 204 | anchors.fill: parent 205 | model: browser.qmlFiles 206 | visible: !contentItem.loaded 207 | 208 | onSelectedChanged: { 209 | if (selected) { 210 | d.reload() 211 | } 212 | } 213 | } 214 | } 215 | } 216 | 217 | ProjectBrowser { 218 | id: browser 219 | projectPath: userProjectPath 220 | extensions: ['qml', 'ui.qml'] 221 | } 222 | 223 | FileWatcher { 224 | id: fileWatcher 225 | fileUrl: browser.projectPath 226 | recursive: true 227 | enabled: fileDialog.selected 228 | onFileChanged: { 229 | d.reload() 230 | } 231 | nameFilters: root.defaultNameFilters.concat(root.additionalNameFilters) 232 | } 233 | 234 | // add additional components that should only be loaded once here. 235 | } 236 | -------------------------------------------------------------------------------- /src/livecoding/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .register_qml_types import register_types # noqa: F401 3 | from .pythonreloader import PythonReloader # noqa: F401 4 | from .moduleloader import recursively_register_types # noqa: F401 5 | from .filewatcher import FileWatcher # noqa: F401 6 | from .gui import start_livecoding_gui # noqa: F401 7 | -------------------------------------------------------------------------------- /src/livecoding/filewatcher.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from fnmatch import fnmatch 4 | 5 | from qtpy.QtCore import ( 6 | QObject, 7 | Property, 8 | Signal, 9 | QFileSystemWatcher, 10 | QUrl, 11 | QDirIterator, 12 | qWarning, 13 | ) 14 | 15 | 16 | class FileWatcher(QObject): 17 | 18 | fileUrlChanged = Signal(QUrl) 19 | enabledChanged = Signal(bool) 20 | recursiveChanged = Signal(bool) 21 | nameFiltersChanged = Signal('QStringList') 22 | fileChanged = Signal() 23 | 24 | def __init__(self, parent=None): 25 | super(FileWatcher, self).__init__(parent) 26 | 27 | self._file_url = QUrl() 28 | self._enabled = True 29 | self._recursive = False 30 | self._name_filters = [] 31 | 32 | self._file_system_watcher = QFileSystemWatcher() 33 | 34 | self.fileUrlChanged.connect(self._update_watched_file) 35 | self.enabledChanged.connect(self._update_watched_file) 36 | self.recursiveChanged.connect(self._update_watched_file) 37 | self.nameFiltersChanged.connect(self._update_watched_file) 38 | self._file_system_watcher.fileChanged.connect(self._on_watched_file_changed) 39 | self._file_system_watcher.directoryChanged.connect( 40 | self._on_watched_directory_changed 41 | ) 42 | 43 | @Property(QUrl, notify=fileUrlChanged) 44 | def fileUrl(self): 45 | return self._file_url 46 | 47 | @fileUrl.setter 48 | def fileUrl(self, value): 49 | if self._file_url == value: 50 | return 51 | self._file_url = value 52 | self.fileUrlChanged.emit(value) 53 | 54 | @Property(bool, notify=enabledChanged) 55 | def enabled(self): 56 | return self._enabled 57 | 58 | @enabled.setter 59 | def enabled(self, value): 60 | if self._enabled == value: 61 | return 62 | self._enabled = value 63 | self.enabledChanged.emit(value) 64 | 65 | @Property(bool, notify=recursiveChanged) 66 | def recursive(self): 67 | return self._recursive 68 | 69 | @recursive.setter 70 | def recursive(self, value): 71 | if self._recursive == value: 72 | return 73 | self._recursive = value 74 | self.recursiveChanged.emit(value) 75 | 76 | @Property('QStringList', notify=nameFiltersChanged) 77 | def nameFilters(self): 78 | return self._name_filters 79 | 80 | @nameFilters.setter 81 | def nameFilters(self, value): 82 | if ( 83 | self._name_filters == value 84 | ): # note: we compare the reference here, not the actual list 85 | return 86 | self._name_filters = value 87 | self.nameFiltersChanged.emit(value) 88 | 89 | def _update_watched_file(self): 90 | files = self._file_system_watcher.files() 91 | if files: 92 | self._file_system_watcher.removePaths(files) 93 | directories = self._file_system_watcher.directories() 94 | if directories: 95 | self._file_system_watcher.removePaths(directories) 96 | 97 | if not self._file_url.isValid() or not self._enabled: 98 | return False 99 | 100 | if not self._file_url.isLocalFile(): 101 | qWarning('Can only watch local files') 102 | return False 103 | 104 | local_file = self._file_url.toLocalFile() 105 | if local_file == '': 106 | return False 107 | 108 | if self._recursive and os.path.isdir(local_file): 109 | new_paths = {local_file} 110 | self._file_system_watcher.addPath(local_file) 111 | 112 | it = QDirIterator( 113 | local_file, QDirIterator.Subdirectories | QDirIterator.FollowSymlinks 114 | ) 115 | while it.hasNext(): 116 | filepath = it.next() 117 | filename = os.path.basename(filepath) 118 | filtered = False 119 | for wildcard in self._name_filters: 120 | if fnmatch(filename, wildcard): 121 | filtered = True 122 | break 123 | if filename == '..' or filename == '.' or filtered: 124 | continue 125 | self._file_system_watcher.addPath(filepath) 126 | new_paths.add(filepath) 127 | 128 | return new_paths != set(files).union(set(directories)) 129 | 130 | elif os.path.exists(local_file): 131 | self._file_system_watcher.addPath(local_file) 132 | 133 | else: 134 | qWarning('File to watch does not exist') 135 | return False 136 | 137 | def _on_watched_file_changed(self): 138 | if self._enabled: 139 | self.fileChanged.emit() 140 | 141 | def _on_watched_directory_changed(self, _): 142 | if self._update_watched_file(): 143 | self._on_watched_file_changed() 144 | -------------------------------------------------------------------------------- /src/livecoding/gui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import os 4 | import signal 5 | import traceback 6 | 7 | from qtpy.QtCore import QTimer, QObject, QUrl 8 | from qtpy.QtGui import QIcon 9 | from qtpy.QtWidgets import QApplication 10 | from qtpy.QtQml import QQmlApplicationEngine 11 | 12 | from .register_qml_types import register_types 13 | from .pythonreloader import PythonReloader 14 | from .moduleloader import recursively_register_types 15 | 16 | MODULE_PATH = os.path.dirname(os.path.abspath(__file__)) 17 | 18 | 19 | def start_livecoding_gui(engine, project_path, main_file, live_qml=''): 20 | """ 21 | Starts the live coding GUI. 22 | :param engine: The QML engine. 23 | :param project_path: Path where the projects QML file are located. 24 | :param main_file: The main application file of the project. 25 | :param live_qml: Optional live window QML file. 26 | :return: 27 | """ 28 | register_types() 29 | recursively_register_types(project_path) 30 | 31 | global reloader # necessary to make reloading work, prevents garbage collection 32 | reloader = PythonReloader(main_file) 33 | engine.rootContext().setContextProperty(PythonReloader.__name__, reloader) 34 | engine.rootContext().setContextProperty( 35 | 'userProjectPath', QUrl.fromLocalFile(project_path) 36 | ) 37 | 38 | if live_qml: 39 | qml_main = live_qml 40 | engine.addImportPath(os.path.join(MODULE_PATH, '..')) 41 | else: 42 | qml_main = os.path.join(MODULE_PATH, 'live.qml') 43 | engine.load(qml_main) 44 | 45 | 46 | class LiveCodingGui(QObject): 47 | def __init__(self, args, main_file, parent=None): 48 | super(LiveCodingGui, self).__init__(parent) 49 | sys.excepthook = self._display_error 50 | 51 | project_path = os.path.realpath(args.path) 52 | 53 | self._engine = QQmlApplicationEngine() 54 | self._engine.addImportPath(project_path) 55 | start_livecoding_gui(self._engine, project_path, main_file) 56 | 57 | self._start_check_timer() 58 | 59 | def _start_check_timer(self): 60 | self._timer = QTimer() 61 | self._timer.timeout.connect(lambda: None) 62 | self._timer.start(100) 63 | 64 | @staticmethod 65 | def shutdown(): 66 | QApplication.quit() 67 | 68 | @staticmethod 69 | def _display_error(etype, evalue, etraceback): 70 | tb = ''.join(traceback.format_exception(etype, evalue, etraceback)) 71 | sys.stderr.write( 72 | "FATAL ERROR: An unexpected error occurred:\n{}\n\n{}\n".format(evalue, tb) 73 | ) 74 | 75 | 76 | def main(main_file, arguments): 77 | signal.signal(signal.SIGINT, lambda *args: LiveCodingGui.shutdown()) 78 | 79 | app = QApplication(sys.argv) 80 | app.setOrganizationName('machinekoder.com') 81 | app.setOrganizationDomain('machinekoder.com') 82 | app.setApplicationName('Python Qt Live Coding') 83 | app.setWindowIcon(QIcon(os.path.join(MODULE_PATH, 'icon.png'))) 84 | 85 | _gui = LiveCodingGui(arguments, main_file) # noqa: F841 86 | 87 | sys.exit(app.exec_()) 88 | -------------------------------------------------------------------------------- /src/livecoding/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinekoder/python-qt-live-coding/c877bb7fe2ec23a5bf0034289c09f715b964cc0a/src/livecoding/icon.png -------------------------------------------------------------------------------- /src/livecoding/live.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.9 2 | import QtQuick.Controls 2.2 3 | import QtQuick.Layouts 1.3 4 | import QtQuick.Window 2.0 5 | import Qt.labs.settings 1.0 6 | import livecoding 1.0 7 | 8 | ApplicationWindow { 9 | id: root 10 | visible: true 11 | title: qsTr("Python Qt Live Coding") 12 | width: 1024 13 | height: 800 14 | flags: liveCoding.flags 15 | visibility: liveCoding.visibility 16 | 17 | Component.onCompleted: { 18 | for (var i = 0; i < Qt.application.screens.length; ++i) { 19 | let screen = Qt.application.screens[i] 20 | if (screen.serialNumber === windowSettings.screen) { 21 | root.screen = screen 22 | return 23 | } 24 | } 25 | } 26 | 27 | Component.onDestruction: { 28 | windowSettings.screen = root.screen.serialNumber 29 | } 30 | 31 | LiveCodingPanel { 32 | id: liveCoding 33 | anchors.fill: parent 34 | } 35 | 36 | Settings { 37 | id: windowSettings 38 | category: "window" 39 | property alias width: root.width 40 | property alias height: root.height 41 | property alias x: root.x 42 | property alias y: root.y 43 | property alias visibility: liveCoding.visibility 44 | property alias flags: liveCoding.flags 45 | property alias hideToolBar: liveCoding.hideToolBar 46 | property string screen: "" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/livecoding/live_coding_helper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from qtpy.QtQuick import QQuickItem 5 | from qtpy.QtQml import QQmlEngine 6 | from qtpy.QtCore import Slot, QUrl 7 | from qtpy.QtGui import QDesktopServices 8 | 9 | 10 | class LiveCodingHelper(QQuickItem): 11 | _engine = None 12 | 13 | def __init__(self, parent=None): 14 | super(LiveCodingHelper, self).__init__(parent) 15 | 16 | @Slot(QUrl, result=bool) 17 | def openUrlWithDefaultApplication(self, url): 18 | return QDesktopServices.openUrl(url) 19 | 20 | @Slot() 21 | def clearQmlComponentCache(self): 22 | context = QQmlEngine.contextForObject(self) 23 | context.engine().clearComponentCache() 24 | # maybe qmlClearTypeRegistrations 25 | 26 | @Slot(str, result=QUrl) 27 | def localPathToUrl(self, path): 28 | abspath = os.path.abspath(os.path.expanduser(path)) 29 | return QUrl.fromLocalFile(abspath) 30 | -------------------------------------------------------------------------------- /src/livecoding/moduleloader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | 5 | from importlib import import_module 6 | from six.moves import reload_module 7 | 8 | 9 | def recursively_register_types(root_path): 10 | if root_path not in sys.path: 11 | sys.path.insert(0, root_path) 12 | 13 | for root, dirs, files in os.walk(root_path): 14 | for file in files: 15 | if file != '__init__.py': 16 | continue 17 | path = os.path.join(root, file) 18 | with open(path, 'rt') as f: 19 | data = f.read() 20 | if 'def register_types()' not in data: 21 | continue 22 | _register_module(path, root_path) 23 | 24 | 25 | def _register_module(file_path, root_path): 26 | path = os.path.relpath(file_path, root_path) 27 | name = os.path.dirname(path).replace('/', '.') 28 | try: 29 | if name in sys.modules: 30 | reload_module(sys.modules[name]) 31 | module = sys.modules[name] 32 | else: 33 | module = import_module(name) 34 | module.register_types() 35 | except Exception as e: 36 | print('Error importing %s: %s' % (name, e)) 37 | -------------------------------------------------------------------------------- /src/livecoding/projectbrowser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from qtpy.QtCore import QObject, Property, Signal, QUrl, QDir, Slot 5 | 6 | 7 | class ProjectBrowser(QObject): 8 | projectPathChanged = Signal(QUrl) 9 | qmlFilesChanged = Signal() 10 | extensionsChanged = Signal() 11 | 12 | def __init__(self, parent=None): 13 | super(ProjectBrowser, self).__init__(parent) 14 | 15 | path = os.path.dirname(os.path.abspath(__file__)) 16 | self._project_path = QUrl.fromLocalFile( 17 | os.path.realpath(os.path.join(path, '..')) 18 | ) 19 | self._qml_files = [] 20 | self._extensions = [] 21 | 22 | self.projectPathChanged.connect(self._update_files) 23 | self.extensionsChanged.connect(self._update_files) 24 | 25 | @Property(QUrl, notify=projectPathChanged) 26 | def projectPath(self): 27 | return self._project_path 28 | 29 | @projectPath.setter 30 | def projectPath(self, value): 31 | if self._project_path == value: 32 | return 33 | self._project_path = value 34 | self.projectPathChanged.emit(value) 35 | 36 | @Property('QStringList', notify=qmlFilesChanged) 37 | def qmlFiles(self): 38 | return self._qml_files 39 | 40 | @Property('QStringList', notify=extensionsChanged) 41 | def extensions(self): 42 | return self._extensions 43 | 44 | @extensions.setter 45 | def extensions(self, value): 46 | if self._extensions == value: 47 | return 48 | self._extensions = value 49 | self.extensionsChanged.emit() 50 | 51 | @Slot() 52 | def update(self): 53 | self._update_files() 54 | 55 | def _update_files(self): 56 | file_list = [] 57 | root = QDir.toNativeSeparators(self._project_path.toLocalFile()) 58 | for subdir, dirs, files in os.walk(root): 59 | for f in files: 60 | path = os.path.join(root, subdir, f) 61 | _, ext = os.path.splitext(path) 62 | if ext[1:].lower() in self._extensions: 63 | # convert file separators to consistent style 64 | url = QUrl.fromLocalFile(path).toLocalFile() 65 | if not url.startswith('/'): 66 | url = '/' + url 67 | file_list.append(url) 68 | self._qml_files = file_list 69 | self.qmlFilesChanged.emit() 70 | -------------------------------------------------------------------------------- /src/livecoding/pythonreloader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | import signal 5 | import inspect 6 | 7 | from qtpy.QtCore import QObject, Slot 8 | 9 | 10 | class PythonReloader(QObject): 11 | def __init__(self, main, parent=None): 12 | super(PythonReloader, self).__init__(parent) 13 | self._main = main 14 | 15 | @Slot() 16 | def restart(self): 17 | import_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..') 18 | python_path = os.environ.get('PYTHONPATH', '') 19 | if import_dir not in python_path: 20 | python_path += ':{}'.format(import_dir) 21 | os.environ['PYTHONPATH'] = python_path 22 | args = [sys.executable, self._main] + sys.argv[1:] 23 | handler = signal.getsignal(signal.SIGTERM) 24 | if handler: 25 | handler(signal.SIGTERM, inspect.currentframe()) 26 | os.execv(sys.executable, args) 27 | -------------------------------------------------------------------------------- /src/livecoding/qmldir: -------------------------------------------------------------------------------- 1 | FileSelectionDialog 1.0 FileSelectionDialog.qml 2 | LiveCodingPanel 1.0 LiveCodingPanel.qml 3 | -------------------------------------------------------------------------------- /src/livecoding/register_qml_types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from qtpy.QtQml import qmlRegisterType 3 | 4 | from .projectbrowser import ProjectBrowser 5 | from .filewatcher import FileWatcher 6 | from .live_coding_helper import LiveCodingHelper 7 | 8 | MODULE_NAME = 'livecoding' 9 | 10 | 11 | def register_types(): 12 | qmlRegisterType(ProjectBrowser, MODULE_NAME, 1, 0, ProjectBrowser.__name__) 13 | qmlRegisterType(FileWatcher, MODULE_NAME, 1, 0, FileWatcher.__name__) 14 | qmlRegisterType(LiveCodingHelper, MODULE_NAME, 1, 0, LiveCodingHelper.__name__) 15 | -------------------------------------------------------------------------------- /src/livecoding/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinekoder/python-qt-live-coding/c877bb7fe2ec23a5bf0034289c09f715b964cc0a/src/livecoding/tests/__init__.py -------------------------------------------------------------------------------- /src/livecoding/tests/test_filewatcher.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | import shutil 4 | import os 5 | from qtpy.QtCore import QUrl 6 | from qtpy.QtTest import QSignalSpy 7 | 8 | 9 | SIGNAL_WAIT_TIMEOUT = 50 10 | 11 | 12 | @pytest.fixture 13 | def watcher(): 14 | from livecoding import FileWatcher 15 | 16 | return FileWatcher() 17 | 18 | 19 | def test_creating_and_writing_file_in_directory_emits_signal(qtbot, tmpdir, watcher): 20 | watcher.fileUrl = QUrl('file://' + str(tmpdir)) 21 | watcher.enabled = True 22 | watcher.recursive = True 23 | spy = QSignalSpy(watcher.fileChanged) 24 | 25 | f = tmpdir.join('test.txt') 26 | f.write('foo') 27 | 28 | spy.wait(SIGNAL_WAIT_TIMEOUT) 29 | assert len(spy) == 1 30 | 31 | 32 | def test_changing_file_emits_signal(qtbot, tmpdir, watcher): 33 | f = tmpdir.join('test.txt') 34 | f.write('foo') 35 | watcher.recursive = False 36 | watcher.fileUrl = QUrl('file://' + str(f)) 37 | watcher.enabled = True 38 | spy = QSignalSpy(watcher.fileChanged) 39 | 40 | f.write('bar') 41 | 42 | spy.wait(SIGNAL_WAIT_TIMEOUT) 43 | assert len(spy) == 1 44 | 45 | 46 | def test_creating_and_writing_file_on_filter_list_doesnt_emit_signal( 47 | qtbot, tmpdir, watcher 48 | ): 49 | watcher.nameFilters = ['.#*'] 50 | watcher.fileUrl = QUrl('file://' + str(tmpdir)) 51 | watcher.enabled = True 52 | watcher.recursive = True 53 | spy = QSignalSpy(watcher.fileChanged) 54 | 55 | f = tmpdir.join('.#test.txt') 56 | f.write('foo') 57 | 58 | spy.wait(SIGNAL_WAIT_TIMEOUT) 59 | assert len(spy) == 0 60 | 61 | 62 | def test_renaming_file_emits_signal(qtbot, tmpdir, watcher): 63 | f = tmpdir.join('supp') 64 | f.write('pncp0A') 65 | watcher.recursive = True 66 | watcher.fileUrl = QUrl('file://' + str(tmpdir)) 67 | watcher.enabled = True 68 | spy = QSignalSpy(watcher.fileChanged) 69 | 70 | os.rename(str(f), os.path.join(str(tmpdir), 'energist')) 71 | 72 | spy.wait(SIGNAL_WAIT_TIMEOUT) 73 | assert len(spy) > 0 74 | 75 | 76 | def test_deleting_file_emits_signal(qtbot, tmpdir, watcher): 77 | f = tmpdir.join('lowered') 78 | f.write('pncp0A') 79 | watcher.recursive = True 80 | watcher.fileUrl = QUrl('file://' + str(tmpdir)) 81 | watcher.enabled = True 82 | spy = QSignalSpy(watcher.fileChanged) 83 | 84 | os.remove(str(f)) 85 | 86 | spy.wait(SIGNAL_WAIT_TIMEOUT) 87 | assert len(spy) == 1 88 | 89 | 90 | def test_deleting_directory_emits_signal(qtbot, tmpdir, watcher): 91 | subdir = tmpdir.mkdir('flukily') 92 | f = subdir.join("yeasts") 93 | f.write("Wlb2Msh") # need to create a file inside the tmpdir to force creation 94 | watcher.recursive = True 95 | watcher.fileUrl = QUrl('file://' + str(tmpdir)) 96 | watcher.enabled = True 97 | spy = QSignalSpy(watcher.fileChanged) 98 | 99 | shutil.rmtree(str(subdir)) 100 | 101 | spy.wait(SIGNAL_WAIT_TIMEOUT) 102 | assert len(spy) == 1 103 | 104 | 105 | def test_creating_file_in_subdirectory_emits_signal(qtbot, tmpdir, watcher): 106 | subdir = tmpdir.mkdir('sub') 107 | watcher.recursive = True 108 | watcher.fileUrl = QUrl('file://' + str(tmpdir)) 109 | watcher.enabled = True 110 | spy = QSignalSpy(watcher.fileChanged) 111 | 112 | f = subdir.join('hagglers.foo') 113 | f.write('DNsqu') 114 | 115 | spy.wait(SIGNAL_WAIT_TIMEOUT) 116 | assert len(spy) == 1 117 | -------------------------------------------------------------------------------- /src/livecoding/tests/test_moduleloader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | 4 | import pytest 5 | 6 | from livecoding import recursively_register_types 7 | 8 | 9 | @pytest.fixture 10 | def project_dir(tmpdir): 11 | path = tmpdir.mkdir('module1') 12 | file = path.join('__init__.py') 13 | file.write( 14 | '''\ 15 | def register_types(): 16 | print('registered module1') 17 | ''' 18 | ) 19 | file = path.join('bla.py') 20 | file.write('# nothing here') 21 | 22 | path = path.mkdir('module2') 23 | file = path.join('__init__.py') 24 | file.write( 25 | '''\ 26 | def register_types(): 27 | print('registered module2') 28 | ''' 29 | ) 30 | 31 | path = tmpdir.mkdir('other_dir') 32 | file = path.join('__init__.py') 33 | file.write( 34 | '''\ 35 | print('bar') 36 | ''' 37 | ) 38 | return str(tmpdir) 39 | 40 | 41 | def test_loader_registers_all_modules_in_subdir_correctly(project_dir): 42 | recursively_register_types(project_dir) 43 | 44 | assert 'module1' in sys.modules.keys() 45 | assert 'module1.module2' in sys.modules.keys() 46 | --------------------------------------------------------------------------------