diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..73ba172 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,44 @@ +name: Linters + +on: [push] + +jobs: + lint-version-python: + strategy: + matrix: + os: [ubuntu-latest] + python-version: ["3.11", "3.12"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint flake8 pyright + - name: Analysing the code with pylint + run: | + scripts/lint.sh + + lint-os-python: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.10"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint flake8 pyright + - name: Analysing the code with pylint + run: | + scripts/lint.sh diff --git a/.github/workflows/theme.yml b/.github/workflows/theme.yml new file mode 100644 index 0000000..893d6a5 --- /dev/null +++ b/.github/workflows/theme.yml @@ -0,0 +1,27 @@ +name: Theme + +on: [push] + +jobs: + theme-python: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.10"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + shell: bash + run: | + python -m pip install --upgrade pip + if [[ "$RUNNER_OS" == "Windows" ]]; then + python -m pip install winrt-Windows.UI.ViewManagement winrt-Windows.UI + fi + - name: Checking our Python imports. + run: | + scripts/theme.sh diff --git a/ci/theme.sh b/ci/theme.sh deleted file mode 100755 index 63ec3ce..0000000 --- a/ci/theme.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# -# Use scripts to check if the theme determination works. - -set -eux pipefail - -ci_home="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" -project_home="$(dirname "${ci_home}")" -cd "${project_home}/example" - -if [[ ! -v PYTHON ]]; then - PYTHON=python -fi -theme=$("${PYTHON}" -c "import breeze_theme; print(breeze_theme.get_theme())") -if [[ "${theme}" != Theme.* ]]; then - >&2 echo "Unable to get the correct theme." - exit 1 -fi -"${PYTHON}" -c "import breeze_theme; print(breeze_theme.is_light())" -"${PYTHON}" -c "import breeze_theme; print(breeze_theme.is_dark())" diff --git a/configure.py b/configure.py index e1ed4ee..786631e 100644 --- a/configure.py +++ b/configure.py @@ -29,12 +29,7 @@ def parse_args(argv=None): '''Parse the command-line options.''' parser = argparse.ArgumentParser(description='Styles to configure for a Qt application.') - parser.add_argument( - '-v', - '--version', - action='version', - version=f'%(prog)s {__version__}' - ) + parser.add_argument('-v', '--version', action='version', version=f'%(prog)s {__version__}') parser.add_argument( '--styles', help='comma-separate list of styles to configure. pass `all` to build all themes', @@ -68,12 +63,10 @@ def parse_args(argv=None): 'Note: building for PyQt6 requires PySide6-rcc to be installed.' ), choices=['pyqt5', 'pyqt6', 'pyside2', 'pyside6'], - default='pyqt5' + default='pyqt5', ) parser.add_argument( - '--clean', - help='clean dist directory prior to configuring themes.', - action='store_true' + '--clean', help='clean dist directory prior to configuring themes.', action='store_true' ) parser.add_argument( '--rcc', @@ -81,7 +74,7 @@ def parse_args(argv=None): 'path to the rcc executable. ' 'Overrides rcc of chosen framework. ' 'Only use if system cannot find the rcc exe.' - ) + ), ) parser.add_argument( '--compiled-resource', @@ -102,7 +95,7 @@ def load_json(path): # we don't want to prevent code from working without # a complex parser, so we do something very simple: # only remove lines starting with '//'. - with open(path) as file: + with open(path, encoding='utf-8') as file: lines = file.read().splitlines() lines = [i for i in lines if not i.strip().startswith('//')] return json.loads('\n'.join(lines)) @@ -115,7 +108,8 @@ def read_template_dir(directory): stylesheet = '' stylesheet_path = f'{directory}/stylesheet.qss.in' if os.path.exists(stylesheet_path): - stylesheet = open(f'{directory}/stylesheet.qss.in').read() + with open(f'{directory}/stylesheet.qss.in', encoding='utf-8') as style_file: + stylesheet = style_file.read() data = { 'stylesheet': stylesheet, 'icons': [], @@ -125,7 +119,8 @@ def read_template_dir(directory): else: icon_data = {} for file in glob.glob(f'{directory}/*.svg.in'): - svg = open(file).read() + with open(file, encoding='utf-8') as svg_file: + svg = svg_file.read() name = os.path.splitext(os.path.splitext(os.path.basename(file))[0])[0] if name in icon_data: replacements = icon_data[name] @@ -133,11 +128,13 @@ def read_template_dir(directory): # Need to find all the values inside the image. keys = re.findall(r'\^[0-9a-zA-Z_-]+\^', svg) replacements = [i[1:-1] for i in keys] - data['icons'].append({ - 'name': name, - 'svg': svg, - 'replacements': replacements, - }) + data['icons'].append( + { + 'name': name, + 'svg': svg, + 'replacements': replacements, + } + ) return data @@ -216,7 +213,7 @@ def parse_color(color): if color.startswith('#'): return parse_hexcolor(color) - elif color.startswith('rgb'): + if color.startswith('rgb'): return parse_rgba(color) raise NotImplementedError @@ -260,11 +257,11 @@ def replace_by_index(contents, theme, colors): # parse the color, get the correct value, and use only that # for the replacement. if key.endswith(':hex'): - color = theme[key[:-len(':hex')]] - rgb = [f"{i:02x}" for i in parse_color(color)[:3]] + color = theme[key[: -len(':hex')]] + rgb = [f'{i:02x}' for i in parse_color(color)[:3]] value = f'#{"".join(rgb)}' elif key.endswith(':opacity'): - color = theme[key[:-len(':opacity')]] + color = theme[key[: -len(':opacity')]] value = str(parse_color(color)[3]) else: value = theme[key] @@ -288,7 +285,7 @@ def configure_icons(config, style, qt_dist): for ext, colors in replacements.items(): contents = replace_by_index(icon['svg'], theme, colors) filename = f'{qt_dist}/{style}/{icon_basename(name, ext)}.svg' - with open(filename, 'w') as file: + with open(filename, 'w', encoding='utf-8') as file: file.write(contents) else: # Then we just have a list of replacements for the @@ -297,7 +294,7 @@ def configure_icons(config, style, qt_dist): assert isinstance(replacements, list) contents = replace_by_name(icon['svg'], theme, replacements) filename = f'{qt_dist}/{style}/{name}.svg' - with open(filename, 'w') as file: + with open(filename, 'w', encoding='utf-8') as file: file.write(contents) @@ -308,7 +305,7 @@ def configure_stylesheet(config, style, qt_dist, style_prefix): contents = replace_by_name(contents, config['themes'][style]) contents = contents.replace('^style^', style_prefix) - with open(f'{qt_dist}/{style}/stylesheet.qss', 'w') as file: + with open(f'{qt_dist}/{style}/stylesheet.qss', 'w', encoding='utf-8') as file: file.write(contents) @@ -338,7 +335,7 @@ def write_qrc(config, qt_dist): qrc_path = config['resource'] if not os.path.isabs(qrc_path): qrc_path = f'{qt_dist}/{qrc_path}' - with open(qrc_path, 'w') as file: + with open(qrc_path, 'w', encoding='utf-8') as file: print('', file=file) print(' ', file=file) for resource in sorted(resources): @@ -347,6 +344,51 @@ def write_qrc(config, qt_dist): print('', file=file) +def compile_resource(args): + '''Compile our resource file to a standalone Python file.''' + + rcc = parse_rcc(args) + resource_path = args.resource + compiled_resource_path = args.compiled_resource + if not os.path.isabs(resource_path): + resource_path = f'{args.output_dir}/{resource_path}' + if not os.path.isabs(compiled_resource_path): + compiled_resource_path = f'{resources_dir}/{compiled_resource_path}' + + command = [rcc, resource_path, '-o', compiled_resource_path] + try: + subprocess.check_output( + command, + stdin=subprocess.DEVNULL, + stderr=subprocess.PIPE, + shell=False, + ) + except subprocess.CalledProcessError as error: + if b'File does not exist' in error.stderr: + print('ERROR: Ensure qrc file exists or deselect "no-qrc" option!', file=sys.stderr) + else: + print(f'ERROR: Got an unknown error of "{error.stderr.decode("utf-8")}"!', file=sys.stderr) + raise SystemExit from error + except FileNotFoundError as error: + if args.rcc: + print('ERROR: rcc path invalid!', file=sys.stderr) + else: + print('ERROR: Ensure rcc executable exists for chosen framework!', file=sys.stderr) + print( + 'Required rcc for PyQt5: pyrcc5', + 'Required rcc for PySide6 & PyQt6: PySide6-rcc', + 'Required rcc for PySide2: PySide2-rcc', + '', + 'if using venv, activate it or provide path to rcc.', + sep='\n', + file=sys.stderr, + ) + raise SystemExit from error + + if args.qt_framework == 'pyqt6': + fix_qt6_import(compiled_resource_path) + + def configure(args): '''Configure all styles and write the files to a QRC file.''' @@ -354,12 +396,7 @@ def configure(args): shutil.rmtree(args.output_dir, ignore_errors=True) # Need to convert our styles accordingly. - config = { - 'themes': {}, - 'templates': [], - 'no_qrc': args.no_qrc, - 'resource': args.resource - } + config = {'themes': {}, 'templates': [], 'no_qrc': args.no_qrc, 'resource': args.resource} config['templates'].append(read_template_dir(template_dir)) for style in args.styles: config['themes'][style] = load_json(f'{theme_dir}/{style}.json') @@ -367,59 +404,23 @@ def configure(args): config['templates'].append(read_template_dir(f'{extension_dir}/{extension}')) args.output_dir.mkdir(parents=True, exist_ok=True) - for style in config['themes'].keys(): + for style in config['themes']: configure_style(config, style, str(args.output_dir)) # Create and compile our resource files. if not args.no_qrc: write_qrc(config, str(args.output_dir)) if args.compiled_resource is not None: - rcc = parse_rcc(args) - resource_path = args.resource - compiled_resource_path = args.compiled_resource - if not os.path.isabs(resource_path): - resource_path = f'{args.output_dir}/{resource_path}' - if not os.path.isabs(compiled_resource_path): - compiled_resource_path = f'{resources_dir}/{compiled_resource_path}' - - command = [rcc, resource_path, '-o', compiled_resource_path] - try: - subprocess.check_output( - command, - stdin=subprocess.DEVNULL, - stderr=subprocess.PIPE, - shell=False, - ) - except subprocess.CalledProcessError as error: - if b'File does not exist' in error.stderr: - print('ERROR: Ensure qrc file exists or deselect "no-qrc" option!', file=sys.stderr) - else: - print(f'ERROR: Got an unknown errir of "{error.stderr.decode("utf-8")}"!', file=sys.stderr) - raise SystemExit - except FileNotFoundError: - if args.rcc: - print("ERROR: rcc path invalid!", file=sys.stderr) - else: - print('ERROR: Ensure rcc executable exists for chosen framework!', file=sys.stderr) - print( - 'Required rcc for PyQt5: pyrcc5', - 'Required rcc for PySide6 & PyQt6: PySide6-rcc', - 'Required rcc for PySide2: PySide2-rcc', - '', - 'if using venv, activate it or provide path to rcc.', sep='\n', file=sys.stderr) - raise SystemExit - - if args.qt_framework == "pyqt6": - fix_qt6_import(compiled_resource_path) + compile_resource(args) def fix_qt6_import(compiled_file): '''Fix import after using PySide6-rcc to compile for PyQt6''' - with open(compiled_file, "r") as file: + with open(compiled_file, 'r', encoding='utf-8') as file: text = file.read() - text = text.replace("PySide6", "PyQt6") - with open(compiled_file, "w") as file: + text = text.replace('PySide6', 'PyQt6') + with open(compiled_file, 'w', encoding='utf-8') as file: file.write(text) @@ -427,16 +428,15 @@ def parse_rcc(args): '''Get rcc required for chosen framework''' if args.rcc: - rcc = args.rcc - else: - if args.qt_framework == 'pyqt6' or args.qt_framework == 'pyside6': - rcc = 'pyside6-rcc' - elif args.qt_framework == "pyqt5": - rcc = 'pyrcc5' - elif args.qt_framework == 'pyside2': - rcc = 'pyside2-rcc' - - return rcc + return args.rcc + if args.qt_framework in ('pyqt6', 'pyside6'): + return 'pyside6-rcc' + if args.qt_framework == 'pyqt5': + return 'pyrcc5' + if args.qt_framework == 'pyside2': + return 'pyside2-rcc' + + raise ValueError(f'Got an unsupported Qt framework of "{args.qt_framework}".') def main(argv=None): diff --git a/example/advanced-dock.py b/example/advanced-dock.py index 687606c..29b585b 100644 --- a/example/advanced-dock.py +++ b/example/advanced-dock.py @@ -29,20 +29,21 @@ Simple PyQt application using the advanced-docking-system. ''' -import shared +# pylint: disable=no-name-in-module,import-error + import sys +import shared + parser = shared.create_parser() parser.add_argument( - '--use-internal', - help='''use the dock manager internal stylesheet.''', - action='store_true' + '--use-internal', help='''use the dock manager internal stylesheet.''', action='store_true' ) # https://github.com/githubuser0xFFFF/Qt-Advanced-Docking-System/blob/master/doc/user-guide.md#configuration-flags parser.add_argument( '--focus-highlighting', help='''use the focus highlighting (and other configuration flags).''', - action='store_true' + action='store_true', ) # setConfigFlag args, unknown = shared.parse_args(parser) @@ -114,7 +115,7 @@ def main(): # run window.setWindowState(compat.WindowMaximized) shared.set_stylesheet(args, app, compat) - return shared.exec_app(args, app, window, compat) + return shared.exec_app(args, app, window) if __name__ == '__main__': diff --git a/example/branchless/application.py b/example/branchless/application.py index deccd06..94fbeef 100644 --- a/example/branchless/application.py +++ b/example/branchless/application.py @@ -22,23 +22,30 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +''' + branchless + ========== + + Simple PyQt application without branches for our QTreeViews. +''' + import os import sys HOME = os.path.dirname(os.path.realpath(__file__)) sys.path.insert(0, os.path.dirname(HOME)) -import widgets -import shared +import shared # noqa # pylint: disable=wrong-import-position,import-error +import widgets # noqa # pylint: disable=wrong-import-position,import-error parser = shared.create_parser() args, unknown = shared.parse_args(parser) QtCore, QtGui, QtWidgets = shared.import_qt(args) compat = shared.get_compat_definitions(args) -ICON_MAP = shared.get_icon_map(args, compat) +ICON_MAP = shared.get_icon_map(compat) -def set_stylesheet(args, app, compat): +def set_stylesheet(app): '''Set the application stylesheet.''' if args.stylesheet != 'native': @@ -46,11 +53,13 @@ def set_stylesheet(args, app, compat): qt_path = shared.get_stylesheet(resource_format) ext_path = os.path.join(HOME, 'stylesheet.qss.in') stylesheet = shared.read_qtext_file(qt_path, compat) - stylesheet += '\n' + open(ext_path, 'r').read() + with open(ext_path, 'r', encoding='utf-8') as file: + stylesheet += '\n' + file.read() app.setStyleSheet(stylesheet) def get_treeviews(parent, depth=1000): + '''Recursively get all tree views.''' for child in parent.children(): if isinstance(child, QtWidgets.QTreeView): yield child @@ -68,18 +77,9 @@ def main(): # setup ui ui = widgets.Ui() ui.setup(window) - ui.bt_delay_popup.addActions([ - ui.actionAction, - ui.actionAction_C - ]) - ui.bt_instant_popup.addActions([ - ui.actionAction, - ui.actionAction_C - ]) - ui.bt_menu_button_popup.addActions([ - ui.actionAction, - ui.actionAction_C - ]) + ui.bt_delay_popup.addActions([ui.actionAction, ui.actionAction_C]) + ui.bt_instant_popup.addActions([ui.actionAction, ui.actionAction_C]) + ui.bt_menu_button_popup.addActions([ui.actionAction, ui.actionAction_C]) window.setWindowTitle('Sample BreezeStyleSheets application.') # Add event triggers @@ -93,8 +93,8 @@ def main(): for tree in get_treeviews(window): tree.setObjectName("branchless") - set_stylesheet(args, app, compat) - return shared.exec_app(args, app, window, compat) + set_stylesheet(app) + return shared.exec_app(args, app, window) if __name__ == '__main__': diff --git a/example/breeze_theme.py b/example/breeze_theme.py index a2230a4..817af58 100644 --- a/example/breeze_theme.py +++ b/example/breeze_theme.py @@ -41,6 +41,8 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ''' +# pylint: disable=import-error,no-member + import typing import ctypes import ctypes.util @@ -75,7 +77,7 @@ def from_string(value: str | None) -> 'Theme': value = value.lower() if value == 'dark': return Theme.DARK - elif value == 'light': + if value == 'light': return Theme.LIGHT raise ValueError(f'Got an invalid theme value of "{value}".') @@ -85,9 +87,9 @@ def to_string(self) -> str: # NOTE: This is for Py3.10 and earlier support. if self == Theme.DARK: return 'Dark' - elif self == Theme.LIGHT: + if self == Theme.LIGHT: return 'Light' - elif self == Theme.UNKNOWN: + if self == Theme.UNKNOWN: return 'Unknown' raise ValueError(f'Got an invalid theme value of "{self}".') @@ -104,7 +106,8 @@ def is_light_color(r: int, g: int, b: int) -> bool: Returns: If the color is perceived as light. ''' - return (((5 * g) + (2 * r) + b) > (8 * 128)) + return ((5 * g) + (2 * r) + b) > (8 * 128) + # region windows @@ -112,7 +115,7 @@ def is_light_color(r: int, g: int, b: int) -> bool: def _get_theme_windows() -> Theme: '''Get the current theme, as light or dark, for the system on Windows.''' - from winreg import HKEY_CURRENT_USER, OpenKey, QueryValueEx + from winreg import HKEY_CURRENT_USER, OpenKey, QueryValueEx # pyright: ignore[reportAttributeAccessIssue] # In HKEY_CURRENT_USER, get the Personalisation Key. try: @@ -125,10 +128,10 @@ def _get_theme_windows() -> Theme: # some headless Windows instances (e.g. GitHub Actions or Docker images) do not have this key # this is also not present if the user has never set the value. however, more recent Windows # installs will have this, starting at `10.0.10240.0`: - # https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/ui/apply-windows-themes#know-when-dark-mode-is-enabled + # https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/ui/apply-windows-themes#know-when-dark-mode-is-enabled # noqa # pylint: disable=line-too-long # # Note that the documentation is inverted: if the foreground is light, we are using DARK mode. - winver = sys.getwindowsversion() + winver = sys.getwindowsversion() # pyright: ignore[reportAttributeAccessIssue] if winver[:4] < (10, 0, 10240, 0): return Theme.UNKNOWN try: @@ -144,7 +147,7 @@ def _get_theme_windows() -> Theme: if use_light == 0: return Theme.DARK - elif use_light == 1: + if use_light == 1: return Theme.LIGHT return Theme.UNKNOWN @@ -152,13 +155,14 @@ def _get_theme_windows() -> Theme: def _listener_windows(callback: CallbackFn) -> None: '''Register an event listener for dark/light theme changes.''' - import ctypes.wintypes # pyright: ignore[reportMissingImports] + import ctypes.wintypes # pyright: ignore[reportMissingImports] # pylint: disable=redefined-outer-name global _advapi32 if _advapi32 is None: _advapi32 = _initialize_advapi32() advapi32 = _advapi32 + assert advapi32 is not None hkey = ctypes.wintypes.HKEY() advapi32.RegOpenKeyExA( @@ -169,16 +173,16 @@ def _listener_windows(callback: CallbackFn) -> None: ctypes.byref(hkey), ) - dwSize = ctypes.wintypes.DWORD(ctypes.sizeof(ctypes.wintypes.DWORD)) - queryValueLast = ctypes.wintypes.DWORD() - queryValue = ctypes.wintypes.DWORD() + size = ctypes.wintypes.DWORD(ctypes.sizeof(ctypes.wintypes.DWORD)) + query_last_value = ctypes.wintypes.DWORD() + query_value = ctypes.wintypes.DWORD() advapi32.RegQueryValueExA( hkey, ctypes.wintypes.LPCSTR(b'AppsUseLightTheme'), ctypes.wintypes.LPDWORD(), ctypes.wintypes.LPDWORD(), - ctypes.cast(ctypes.byref(queryValueLast), ctypes.wintypes.LPBYTE), - ctypes.byref(dwSize), + ctypes.cast(ctypes.byref(query_last_value), ctypes.wintypes.LPBYTE), + ctypes.byref(size), ) while True: @@ -194,20 +198,20 @@ def _listener_windows(callback: CallbackFn) -> None: ctypes.wintypes.LPCSTR(b'AppsUseLightTheme'), ctypes.wintypes.LPDWORD(), ctypes.wintypes.LPDWORD(), - ctypes.cast(ctypes.byref(queryValue), ctypes.wintypes.LPBYTE), - ctypes.byref(dwSize), + ctypes.cast(ctypes.byref(query_value), ctypes.wintypes.LPBYTE), + ctypes.byref(size), ) - if queryValueLast.value != queryValue.value: - queryValueLast.value = queryValue.value - callback(Theme.LIGHT if queryValue.value else Theme.DARK) + if query_last_value.value != query_value.value: + query_last_value.value = query_value.value + callback(Theme.LIGHT if query_value.value else Theme.DARK) -def _initialize_advapi32() -> 'ctypes.WinDLL': +def _initialize_advapi32() -> ctypes.CDLL: '''Initialize our advapi32 library.''' - import ctypes.wintypes # pyright: ignore[reportMissingImports] + import ctypes.wintypes # pyright: ignore[reportMissingImports] # pylint: disable=redefined-outer-name - advapi32 = ctypes.windll.advapi32 + advapi32 = ctypes.windll.advapi32 # pyright: ignore[reportAttributeAccessIssue] # LSTATUS RegOpenKeyExA( # HKEY hKey, @@ -262,7 +266,7 @@ def _initialize_advapi32() -> 'ctypes.WinDLL': return advapi32 -_advapi32: typing.Optional['ctypes.WinDLL'] = None +_advapi32: typing.Optional['ctypes.CDLL'] = None # endregion @@ -277,7 +281,7 @@ def macos_supported_version() -> bool: major = int(sysver.split('.')[0]) if major < 10: return False - elif major >= 11: + if major >= 11: return True # have a macOS10 version @@ -288,29 +292,25 @@ def macos_supported_version() -> bool: def _get_theme_macos() -> Theme: '''Get the current theme, as light or dark, for the system on macOS.''' - global _theme_macos_impl - if _theme_macos_impl is None: - _theme_macos_impl = _get_theme_macos_impl() - return _theme_macos_impl() - - -def _as_utf8(value: bytes | str) -> bytes: - '''Encode a value to UTF-8''' - return value if isinstance(value, bytes) else value.encode('utf-8') - - -def _register_name(objc: ctypes.CDLL, name: bytes | str) -> None: - '''Register a name within our DLLs.''' - return objc.sel_registerName(_as_utf8(name)) - - -def _get_class(objc: ctypes.CDLL, name: bytes | str) -> 'ctypes._NamedFuncPointer': - '''Get a class by the registered name.''' - return objc.objc_getClass(_as_utf8(name)) - - -def _get_theme_macos_impl() -> ThemeFn: - '''Create the theme callback for macOS.''' + # NOTE: This can segfault on M1 and M2 Macs on Big Sur 11.4+. So, we also + # try reading directly using subprocess. + try: + command = ['defaults', 'read', '-globalDomain', 'AppleInterfaceStyle'] + process = subprocess.run(command, capture_output=True, check=True) + try: + result = process.stdout.decode('utf-8').strip() + return Theme.DARK if result == 'Dark' else Theme.LIGHT + except UnicodeDecodeError: + return Theme.LIGHT + except subprocess.CalledProcessError as error: + # If this keypair does not exist, then it's a specific error because the style + # hasn't been set before, so then it specifically is a light theme. this can + # affect no-UI systems like CI. + not_exist = b'does not exist' in error.stderr + any_app = b'kCFPreferencesAnyApplication' in error.stderr + interface_style = b'AppleInterfaceStyle' in error.stderr + if not_exist and any_app and interface_style: + return Theme.LIGHT # NOTE: We do this so we don't need imports at the global level. try: @@ -318,36 +318,55 @@ def _get_theme_macos_impl() -> ThemeFn: objc = ctypes.cdll.LoadLibrary('libobjc.dylib') except OSError: # revert to full path for older OS versions and hardened programs - objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library('objc')) + obc_name = ctypes.util.find_library('objc') + assert obc_name is not None + objc = ctypes.cdll.LoadLibrary(obc_name) # See https://docs.python.org/3/library/ctypes.html#function-prototypes for arguments description msg_prototype = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p) msg = msg_prototype(('objc_msgSend', objc), ((1, '', None), (1, '', None), (1, '', None))) - auto_release_pool = _get_class(objc, 'NSAutoreleasePool') + user_defaults = _get_class(objc, 'NSUserDefaults') + ns_string = _get_class(objc, 'NSString') + pool = msg(auto_release_pool, _register_name(objc, 'alloc')) pool = msg(pool, _register_name(objc, 'init')) - - user_defaults = _get_class(objc, 'NSUserDefaults') std_user_defaults = msg(user_defaults, _register_name(objc, 'standardUserDefaults')) - ns_string = _get_class(objc, 'NSString') key = msg(ns_string, _register_name(objc, "stringWithUTF8String:"), _as_utf8('AppleInterfaceStyle')) appearance_ns = msg(std_user_defaults, _register_name(objc, 'stringForKey:'), ctypes.c_void_p(key)) appearance_c = msg(appearance_ns, _register_name(objc, 'UTF8String')) out = ctypes.string_at(appearance_c) if appearance_c is not None else None msg(pool, _register_name(objc, 'release')) + return Theme.from_string(out.decode('utf-8')) if out is not None else Theme.LIGHT +def _as_utf8(value: bytes | str) -> bytes: + '''Encode a value to UTF-8''' + return value if isinstance(value, bytes) else value.encode('utf-8') + + +def _register_name(objc: ctypes.CDLL, name: bytes | str) -> None: + '''Register a name within our DLLs.''' + return objc.sel_registerName(_as_utf8(name)) + + +def _get_class(objc: ctypes.CDLL, name: bytes | str) -> 'ctypes._NamedFuncPointer': + '''Get a class by the registered name.''' + return objc.objc_getClass(_as_utf8(name)) + + def _listener_macos(callback: CallbackFn) -> None: '''Register an event listener for dark/light theme changes.''' try: - from Foundation import NSKeyValueObservingOptionNew as _ # noqa # pyright: ignore[reportMissingImports] - except (ImportError, ModuleNotFoundError): - raise RuntimeError('Missing the required Foundation modules: cannot listen.') + from Foundation import ( # noqa # pyright: ignore[reportMissingImports] # pylint: disable + NSKeyValueObservingOptionNew as _, + ) + except (ImportError, ModuleNotFoundError) as error: + raise RuntimeError('Missing the required Foundation modules: cannot listen.') from error # now need to register a child event path = Path(__file__) @@ -358,7 +377,7 @@ def _listener_macos(callback: CallbackFn) -> None: universal_newlines=True, cwd=path.parent, ) as process: - for line in process.stdout: + for line in typing.cast(str, process.stdout): callback(Theme.from_string(line.strip())) @@ -368,20 +387,26 @@ def _listen_child_macos() -> None: # NOTE: We do this so we don't need imports at the global level. try: from Foundation import ( # pyright: ignore[reportMissingImports] - NSObject, NSKeyValueObservingOptionNew, NSKeyValueChangeNewKey, NSUserDefaults + NSKeyValueChangeNewKey, + NSKeyValueObservingOptionNew, + NSObject, + NSUserDefaults, ) from PyObjCTools import AppHelper # pyright: ignore[reportMissingImports] - except ModuleNotFoundError: - raise RuntimeError('Missing the required Foundation modules: cannot listen.') + except ModuleNotFoundError as error: + raise RuntimeError('Missing the required Foundation modules: cannot listen.') from error signal.signal(signal.SIGINT, signal.SIG_IGN) class Observer(NSObject): - def observeValueForKeyPath_ofObject_change_context_( - self, path, object, changeDescription, context + '''Custom namespace key observer.''' + + def observeValueForKeyPath_ofObject_change_context_( # pylint: disable=invalid-name + self, path, obj, changeDescription, context ): + '''Observe our key to detect the light/dark status.''' _ = path - _ = object + _ = obj _ = context result = changeDescription[NSKeyValueChangeNewKey] try: @@ -403,8 +428,6 @@ def observeValueForKeyPath_ofObject_change_context_( AppHelper.runConsoleEventLoop() -_theme_macos_impl: ThemeFn | None = None - # endregion # region linux @@ -431,7 +454,7 @@ def _listener_linux(callback: CallbackFn) -> None: command = [gsettings, 'monitor', 'org.gnome.desktop.interface', schema] # this has rhe same restrictions as above with subprocess.Popen(command, stdout=subprocess.PIPE, universal_newlines=True) as process: - for line in process.stdout: + for line in typing.cast(str, process.stdout): value = line.removeprefix(f"{schema}: '").removesuffix("'") callback(Theme.DARK if '-dark' in value.lower() else Theme.LIGHT) @@ -439,23 +462,22 @@ def _listener_linux(callback: CallbackFn) -> None: def _get_gsettings_schema() -> tuple[str, str]: '''Get the schema to use when monitoring via gsettings.''' # This follows the gsettings followed here: - # https://github.com/GNOME/gsettings-desktop-schemas/blob/master/schemas/org.gnome.desktop.interface.gschema.xml.in + # https://github.com/GNOME/gsettings-desktop-schemas/blob/master/schemas/org.gnome.desktop.interface.gschema.xml.in # noqa # pylint: disable=line-too-long gsettings = _get_gsettings() command = [gsettings, 'get', 'org.gnome.desktop.interface'] # using the freedesktop specifications for checking dark mode # this will return something like `prefer-dark`, which is the true value. # valid values are 'default', 'prefer-dark', 'prefer-light'. - process = subprocess.run(command + ['color-scheme'], capture_output=True) + process = subprocess.run(command + ['color-scheme'], capture_output=True, check=False) if process.returncode == 0: return ('color-scheme', process.stdout.decode('utf-8')) - elif b'No such key' not in process.stderr: + if b'No such key' not in process.stderr: raise RuntimeError('Unable to get our color-scheme from our gsettings.') # if not found then trying older gtk-theme method # this relies on the theme not lying to you: if the theme is dark, it ends in `-dark`. - process = subprocess.run(command + ['gtk-theme'], capture_output=True) - process.check_returncode() + process = subprocess.run(command + ['gtk-theme'], capture_output=True, check=True) return ('gtk-theme', process.stdout.decode('utf-8')) @@ -489,6 +511,7 @@ def _listener_dummy(callback: CallbackFn) -> None: '''Register an event listener for dark/light theme changes (always unimplemented).''' _ = callback + # endregion @@ -517,19 +540,17 @@ def register_functions() -> tuple[ThemeFn, ListenerFn]: if sys.platform == 'darwin' and macos_supported_version(): return (_get_theme_macos, _listener_macos) - elif sys.platform == 'win32' and platform.release().isdigit() and int(platform.release()) >= 10: + if sys.platform == 'win32' and platform.release().isdigit() and int(platform.release()) >= 10: # Checks if running Windows 10 version 10.0.14393 (Anniversary Update) OR HIGHER. # The getwindowsversion method returns a tuple. The third item is the build number # that we can use to check if the user has a new enough version of Windows. winver = int(platform.version().split('.')[2]) if winver >= 14393: return (_get_theme_windows, _listener_windows) - else: - return (_get_theme_dummy, _listener_dummy) - elif sys.platform == "linux": - return (_get_theme_linux, _listener_linux) - else: return (_get_theme_dummy, _listener_dummy) + if sys.platform == "linux": + return (_get_theme_linux, _listener_linux) + return (_get_theme_dummy, _listener_dummy) # register these callbacks once diff --git a/example/dial.py b/example/dial.py index 71f3b95..3a385cb 100644 --- a/example/dial.py +++ b/example/dial.py @@ -32,14 +32,13 @@ ''' import math -import shared import sys +import shared + parser = shared.create_parser() parser.add_argument( - '--no-align', - help='''allow larger widgets without forcing alignment.''', - action='store_true' + '--no-align', help='''allow larger widgets without forcing alignment.''', action='store_true' ) args, unknown = shared.parse_args(parser) QtCore, QtGui, QtWidgets = shared.import_qt(args) @@ -71,11 +70,11 @@ def circle_percent(dial): return offset / distance -def circle_position(dial, groove_rect, position, r): +def circle_position(dial, rect, position, r): '''Calculate the (x, y) coordinates based on the position on a circle.''' # Get our center and the percent we've gone alone the dial. - center = groove_rect.center() + center = rect.center() x0 = center.x() y0 = center.y() distance = dial.maximum - dial.minimum @@ -100,9 +99,9 @@ def circle_position(dial, groove_rect, position, r): return x0 - r * math.cos(theta), y0 - r * math.sin(theta) -def handle_position(dial, groove_rect, r): +def handle_position(dial, rect, r): '''Calculate the position of the handle.''' - return circle_position(dial, groove_rect, dial.sliderPosition, r) + return circle_position(dial, rect, dial.sliderPosition, r) def default_pen(color, width): @@ -160,7 +159,7 @@ def __init__(self, widget=None): self.handle = (0, 0) self.is_hovered = False - def paintEvent(self, event): + def paintEvent(self, event): # pylint: disable=too-many-locals '''Override the paint event to ensure the ticks are painted.''' if args.stylesheet == 'native': @@ -240,6 +239,8 @@ def paintEvent(self, event): handle_pos = QtCore.QPointF(hx, hy) painter.drawEllipse(handle_pos, self.handle_radius, self.handle_radius) + return None + def eventFilter(self, obj, event): '''Override the color when we have a hover event.''' @@ -275,6 +276,8 @@ class Ui: '''Main class for the user interface.''' def setup(self, MainWindow): + '''Setup our main window for the UI.''' + MainWindow.setObjectName('MainWindow') MainWindow.resize(1068, 824) self.centralwidget = QtWidgets.QWidget(MainWindow) @@ -308,7 +311,7 @@ def main(): window.setWindowTitle('QDial') shared.set_stylesheet(args, app, compat) - return shared.exec_app(args, app, window, compat) + return shared.exec_app(args, app, window) if __name__ == '__main__': diff --git a/example/lcd.py b/example/lcd.py index aa3b9a7..98a2ad7 100644 --- a/example/lcd.py +++ b/example/lcd.py @@ -31,14 +31,13 @@ supports highlighting the handle on the active or hovered dial. ''' -import shared import sys +import shared + parser = shared.create_parser() parser.add_argument( - '--no-align', - help='''allow larger widgets without forcing alignment.''', - action='store_true' + '--no-align', help='''allow larger widgets without forcing alignment.''', action='store_true' ) args, unknown = shared.parse_args(parser) QtCore, QtGui, QtWidgets = shared.import_qt(args) @@ -72,6 +71,8 @@ class Ui: '''Main class for the user interface.''' def setup(self, MainWindow): + '''Setup our main window for the UI.''' + MainWindow.setObjectName('MainWindow') MainWindow.resize(1068, 824) self.centralwidget = QtWidgets.QWidget(MainWindow) @@ -118,7 +119,7 @@ def main(): window.setWindowTitle('QLCDNumber') shared.set_stylesheet(args, app, compat) - return shared.exec_app(args, app, window, compat) + return shared.exec_app(args, app, window) if __name__ == '__main__': diff --git a/example/placeholder_text.py b/example/placeholder_text.py index 8e435f6..a1cec31 100644 --- a/example/placeholder_text.py +++ b/example/placeholder_text.py @@ -33,19 +33,20 @@ and palette edits correctly affect styles in Qt5, but not Qt6. ''' -import shared +# pylint: disable=duplicate-code + import sys +import shared + parser = shared.create_parser() parser.add_argument( - '--set-app-palette', - help='''set the placeholder text palette globally.''', - action='store_true' + '--set-app-palette', help='''set the placeholder text palette globally.''', action='store_true' ) parser.add_argument( '--set-widget-palette', help='''set the placeholder text palette for the affected widgets.''', - action='store_true' + action='store_true', ) args, unknown = shared.parse_args(parser) QtCore, QtGui, QtWidgets = shared.import_qt(args) @@ -54,7 +55,7 @@ def set_palette(widget, role, color): - '''Set the palette for the placeholder text. This only works in Qt5.''' + '''Set the palette for a widget.''' palette = widget.palette() palette.setColor(role, color) @@ -62,6 +63,7 @@ def set_palette(widget, role, color): def set_placeholder_palette(widget): + '''Set the palette for the placeholder text. This only works in Qt5.''' set_palette(widget, compat.PlaceholderText, colors.PlaceholderColor) @@ -69,6 +71,8 @@ class Ui: '''Main class for the user interface.''' def setup(self, MainWindow): + '''Setup our main window for the UI.''' + MainWindow.setObjectName('MainWindow') MainWindow.resize(1068, 824) self.centralwidget = QtWidgets.QWidget(MainWindow) @@ -112,7 +116,7 @@ def main(): window.setWindowTitle('Stylized Placeholder Text.') shared.set_stylesheet(args, app, compat) - return shared.exec_app(args, app, window, compat) + return shared.exec_app(args, app, window) if __name__ == '__main__': diff --git a/example/shared.py b/example/shared.py index f40d27a..a305385 100644 --- a/example/shared.py +++ b/example/shared.py @@ -5,6 +5,8 @@ Shared imports and compatibility definitions between Qt5 and Qt6. ''' +# pylint: disable=import-error + import argparse import importlib import logging @@ -22,9 +24,7 @@ def create_parser(): '''Create an argparser with the base settings for all Qt applications.''' - parser = argparse.ArgumentParser( - description='Configurations for the Qt5 application.' - ) + parser = argparse.ArgumentParser(description='Configurations for the Qt5 application.') parser.add_argument( '--stylesheet', help='stylesheet name (`dark`, `light`, `native`, `auto`, ...)', @@ -38,16 +38,8 @@ def create_parser(): help='application style (`Fusion`, `Windows`, `native`, ...)', default='native', ) - parser.add_argument( - '--font-size', - help='font size for the application', - type=float, - default=-1 - ) - parser.add_argument( - '--font-family', - help='the font family' - ) + parser.add_argument('--font-size', help='font size for the application', type=float, default=-1) + parser.add_argument('--font-family', help='the font family') parser.add_argument( '--scale', help='scale factor for the UI', @@ -61,14 +53,10 @@ def create_parser(): 'Note: building for PyQt6 requires PySide6-rcc to be installed.' ), choices=['pyqt5', 'pyqt6', 'pyside2', 'pyside6'], - default='pyqt5' + default='pyqt5', ) # Linux or Unix-like only. - parser.add_argument( - '--use-x11', - help='force the use of x11 on compatible systems.', - action='store_true' - ) + parser.add_argument('--use-x11', help='force the use of x11 on compatible systems.', action='store_true') return parser @@ -102,7 +90,7 @@ def parse_args(parser): def is_qt6(args): '''Get if we're using Qt6 and not Qt5.''' - return args.qt_framework == 'pyqt6' or args.qt_framework == 'pyside6' + return args.qt_framework in ('pyqt6', 'pyside6') def import_qt(args, load_resources=True): @@ -116,6 +104,8 @@ def import_qt(args, load_resources=True): from PyQt5 import QtCore, QtGui, QtWidgets # pyright: ignore[reportMissingImports] elif args.qt_framework == 'pyside2': from PySide2 import QtCore, QtGui, QtWidgets # pyright: ignore[reportMissingImports] + else: + raise ValueError(f'Got an invalid Qt framework of "{args.qt_framework}".') if load_resources: sys.path.insert(0, f'{home}/resources') @@ -135,16 +125,16 @@ def get_stylesheet(resource_format): def get_version(args): + '''Get the current version of the Qt library.''' QtCore, _, __ = import_qt(args, load_resources=False) - if args.qt_framework == 'pyqt5' or args.qt_framework == 'pyqt6': + if args.qt_framework in ('pyqt5', 'pyqt6'): # QT_VERSION is stored in 0xMMmmpp, each in 8 bit pairs. # Goes major, minor, patch. 393984 is "6.3.0" return (QtCore.QT_VERSION >> 16, (QtCore.QT_VERSION >> 8) & 0xFF, QtCore.QT_VERSION & 0xFF) - else: - return QtCore.__version_info__[:3] + return QtCore.__version_info__[:3] -def get_compat_definitions(args): +def get_compat_definitions(args): # pylint: disable=too-many-statements '''Create our compatibility definitions.''' ns = argparse.Namespace() @@ -154,7 +144,7 @@ def get_compat_definitions(args): ns.QtWidgets = QtWidgets # ensure we store the QT_VERSION - if args.qt_framework == 'pyqt5' or args.qt_framework == 'pyqt6': + if args.qt_framework in ('pyqt5', 'pyqt6'): # QT_VERSION is stored in 0xMMmmpp, each in 8 bit pairs. # Goes major, minor, patch. 393984 is "6.3.0" ns.QT_VERSION = (QtCore.QT_VERSION >> 16, (QtCore.QT_VERSION >> 8) & 0xFF, QtCore.QT_VERSION & 0xFF) @@ -854,7 +844,7 @@ def get_colors(args, compat): return ns -def get_icon_map(args, compat): +def get_icon_map(compat): '''Create a map of standard icons to resource paths.''' icon_map = { @@ -1006,6 +996,8 @@ def is_dark_mode(compat, reinitialize=False): def read_qtext_file(path, compat): + '''Read the Qt text resource.''' + file = compat.QtCore.QFile(path) file.open(compat.ReadOnly | compat.Text) stream = compat.QtCore.QTextStream(file) @@ -1021,7 +1013,7 @@ def set_stylesheet(args, app, compat): app.setStyleSheet(read_qtext_file(stylesheet, compat)) -def exec_app(args, app, window, compat): +def exec_app(args, app, window): '''Show and execute the Qt application.''' window.show() diff --git a/example/slider.py b/example/slider.py index 0e28b74..33c9aa3 100644 --- a/example/slider.py +++ b/example/slider.py @@ -31,9 +31,10 @@ get customized styling behavior with a QSlider. ''' -import shared import sys +import shared + parser = shared.create_parser() args, unknown = shared.parse_args(parser) QtCore, QtGui, QtWidgets = shared.import_qt(args) @@ -44,10 +45,10 @@ class Slider(QtWidgets.QSlider): '''QSlider with a custom paint event.''' - def __init__(self, *args, **kwds): + def __init__(self, *args, **kwds): # pylint: disable=useless-parent-delegation,redefined-outer-name super().__init__(*args, **kwds) - def paintEvent(self, event): + def paintEvent(self, event): # pylint: disable=unused-argument,(too-many-locals '''Override the paint event to ensure the ticks are painted.''' painter = QtWidgets.QStylePainter(self) @@ -73,10 +74,10 @@ def paintEvent(self, event): width = (self.width() - handle.width()) + handle.width() / 2 x = int(percent * width) h = 4 - if position == compat.TicksBothSides or position == compat.TicksAbove: + if position in (compat.TicksBothSides, compat.TicksAbove): y = self.rect().top() painter.drawLine(x, y, x, y + h) - if position == compat.TicksBothSides or position == compat.TicksBelow: + if position in (compat.TicksBothSides, compat.TicksBelow): y = self.rect().bottom() painter.drawLine(x, y, x, y - h) @@ -91,6 +92,8 @@ class Ui: '''Main class for the user interface.''' def setup(self, MainWindow): + '''Setup our main window for the UI.''' + MainWindow.setObjectName('MainWindow') MainWindow.resize(1068, 824) self.centralwidget = QtWidgets.QWidget(MainWindow) @@ -119,7 +122,7 @@ def main(): window.setWindowTitle('QSlider with Ticks.') shared.set_stylesheet(args, app, compat) - return shared.exec_app(args, app, window, compat) + return shared.exec_app(args, app, window) if __name__ == '__main__': diff --git a/example/standard_icons.py b/example/standard_icons.py index 85cd13b..b00db10 100644 --- a/example/standard_icons.py +++ b/example/standard_icons.py @@ -29,21 +29,25 @@ Example overriding QCommonStyle for custom standard icons. ''' -import shared import sys +import shared + parser = shared.create_parser() args, unknown = shared.parse_args(parser) QtCore, QtGui, QtWidgets = shared.import_qt(args) compat = shared.get_compat_definitions(args) -ICON_MAP = shared.get_icon_map(args, compat) +ICON_MAP = shared.get_icon_map(compat) def style_icon(style, icon, option=None, widget=None): + '''Helper to provide arguments for setting a style icon.''' return shared.style_icon(args, style, icon, ICON_MAP, option, widget) class ApplicationStyle(QtWidgets.QCommonStyle): + '''A custom application style overriding standard icons.''' + def __init__(self, style): super().__init__() self.style = style @@ -74,6 +78,7 @@ def add_standard_button(ui, layout, icon, index): def add_standard_buttons(ui, page, icons): '''Create and add QToolButtons with standard icons to the UI.''' + _ = ui for icon_name in icons: icon_enum = getattr(compat, icon_name) icon = style_icon(page.style(), icon_enum, widget=page) @@ -84,7 +89,9 @@ def add_standard_buttons(ui, page, icons): class Ui: '''Main class for the user interface.''' - def setup(self, MainWindow): + def setup(self, MainWindow): # pylint: disable=too-many-statements + '''Setup our main window for the UI.''' + MainWindow.setObjectName('MainWindow') MainWindow.resize(1068, 824) self.centralwidget = QtWidgets.QWidget(MainWindow) @@ -99,22 +106,26 @@ def setup(self, MainWindow): self.tool_box.addItem(self.page1, 'Overwritten Icons') self.layout.addWidget(self.tool_box) - add_standard_buttons(self, self.page1, [ - 'SP_ArrowLeft', - 'SP_ArrowDown', - 'SP_ArrowRight', - 'SP_ArrowUp', - 'SP_DockWidgetCloseButton', - 'SP_DialogCancelButton', - 'SP_DialogCloseButton', - 'SP_DialogDiscardButton', - 'SP_DialogHelpButton', - 'SP_DialogNoButton', - 'SP_DialogOkButton', - 'SP_DialogOpenButton', - 'SP_DialogResetButton', - 'SP_DialogSaveButton', - ]) + add_standard_buttons( + self, + self.page1, + [ + 'SP_ArrowLeft', + 'SP_ArrowDown', + 'SP_ArrowRight', + 'SP_ArrowUp', + 'SP_DockWidgetCloseButton', + 'SP_DialogCancelButton', + 'SP_DialogCloseButton', + 'SP_DialogDiscardButton', + 'SP_DialogHelpButton', + 'SP_DialogNoButton', + 'SP_DialogOkButton', + 'SP_DialogOpenButton', + 'SP_DialogResetButton', + 'SP_DialogSaveButton', + ], + ) self.page2 = QtWidgets.QListWidget() self.tool_box.addItem(self.page2, 'Default Icons') @@ -248,6 +259,8 @@ def setup(self, MainWindow): self.retranslateUi(MainWindow) def retranslateUi(self, MainWindow): + '''Retranslate our UI after initializing some of our base modules.''' + _translate = QtCore.QCoreApplication.translate MainWindow.setWindowTitle(_translate('MainWindow', 'MainWindow')) self.menuMenu.setTitle(_translate('MainWindow', '&Menu')) @@ -255,9 +268,11 @@ def retranslateUi(self, MainWindow): self.actionAction_C.setText(_translate('MainWindow', 'Action &C')) def about(self): + '''Load our Qt about window.''' QtWidgets.QMessageBox.aboutQt(self.centralwidget, 'About Menu') def critical(self): + '''Launch a critical message box.''' QtWidgets.QMessageBox.critical(self.centralwidget, 'Error', 'Critical Error') @@ -276,7 +291,7 @@ def main(): ui.actionAction_C.triggered.connect(ui.critical) shared.set_stylesheet(args, app, compat) - return shared.exec_app(args, app, window, compat) + return shared.exec_app(args, app, window) if __name__ == '__main__': diff --git a/example/titlebar.py b/example/titlebar.py index 1ea126c..95db87b 100644 --- a/example/titlebar.py +++ b/example/titlebar.py @@ -107,13 +107,15 @@ - Windows 10 ''' +# pylint: disable=protected-access + import enum import os -import shared import sys - from pathlib import Path +import shared + parser = shared.create_parser() parser.add_argument( '--minimize-location', @@ -157,7 +159,7 @@ QtCore, QtGui, QtWidgets = shared.import_qt(args) compat = shared.get_compat_definitions(args) colors = shared.get_colors(args, compat) -ICON_MAP = shared.get_icon_map(args, compat) +ICON_MAP = shared.get_icon_map(compat) # 100ms between repaints, so we avoid over-repainting. # Allows us to avoid glitchy motion during drags/ REPAINT_TIMER = 100 @@ -262,6 +264,7 @@ def close_icon(widget): def transparent_icon(widget): '''Create a transparent icon.''' + _ = widget return QtGui.QIcon() @@ -286,6 +289,7 @@ def size_less(x, y): '''Compare 2 sizes, determining if any bounds of x are less than y.''' return x.width() < y.width() or x.height() < y.height() + # UI WIDGETS # These are just to populate the views: these could be anything. @@ -341,7 +345,7 @@ def __init__(self, parent=None): class SettingTabs(QtWidgets.QTabWidget): '''Sample setting widget with a tab view.''' - def __init__(self, parent=None): + def __init__(self, parent=None): # pylint: disable=too-many-statements super().__init__(parent) self.setTabPosition(compat.North) @@ -421,12 +425,14 @@ def launch_filedialog(self): self.data_folder.setText(dialog.selectedFiles()[0]) def launch_fontdialog(self, edit): + '''Launch our font selection disablog.''' initial = QtGui.QFont() initial.setFamily(edit.text()) font, ok = QtWidgets.QFontDialog.getFont(initial) if ok: edit.setText(font.family()) + # RESIZE HELPERS @@ -587,7 +593,7 @@ def start_resize(self, window, window_type): # and track hover events outside the app. This doesn't # work on Wayland or on macOS. # https://doc.qt.io/qt-5/qwidget.html#grabMouse - if not IS_TRUE_WAYLAND and not sys.platform == 'darwin': + if not IS_TRUE_WAYLAND and sys.platform != 'darwin': self.window().grabMouse() @@ -605,7 +611,7 @@ def end_resize(self, window_type): setattr(self, f'_{window_type}_resize', None) window.window().unsetCursor() - if not IS_TRUE_WAYLAND and not sys.platform == 'darwin': + if not IS_TRUE_WAYLAND and sys.platform != 'darwin': self.window().releaseMouse() @@ -634,6 +640,7 @@ def end_frame(self, window_type): '''End the window frame resize state.''' setattr(self, f'_{window_type}_frame', None) + # EVENT HANDLES @@ -679,13 +686,12 @@ def window_mouse_double_click_event(self, event): if not widget.underMouse() or event.button() != compat.LeftButton: return super(type(self), self).mouseDoubleClickEvent(event) if widget._is_shaded: - widget.unshade() - elif widget.isMinimized() or widget.isMaximized(): - widget.restore() - elif widget._has_shade: - widget.shade() - else: - widget.maximize() + return widget.unshade() + if widget.isMinimized() or widget.isMaximized(): + return widget.restore() + if widget._has_shade: + return widget.shade() + return widget.maximize() def window_mouse_press_event(self, event, window, window_type): @@ -714,7 +720,7 @@ def window_mouse_move_event(self, event, window, window_type): if getattr(window, f'_{window_type}_frame') is not None: end_drag(window, window_type) if getattr(window, f'_{window_type}_drag') is not None: - handle_drag(window, event, self, window_type) + handle_drag(self, event, window, window_type) return super(type(self), self).mouseMoveEvent(event) @@ -724,6 +730,7 @@ def window_mouse_release_event(self, event, window, window_type): end_drag(window, window_type) return super(type(self), self).mouseReleaseEvent(event) + # WINDOW WIDGETS @@ -763,6 +770,7 @@ def elideMode(self): return self._elide def setElideMode(self, elide): + '''Set the elide mode for the label.''' self._elide = elide def elide(self): @@ -783,6 +791,7 @@ class TitleButton(QtWidgets.QToolButton): def __init__(self, icon, parent=None): super().__init__() + _ = parent self.setIcon(icon) self.setAutoRaise(True) @@ -790,7 +799,7 @@ def __init__(self, icon, parent=None): class TitleBar(QtWidgets.QFrame): '''Custom instance of a QTitlebar''' - def __init__(self, window, parent=None, flags=None): + def __init__(self, window, parent=None, flags=None): # pylint: disable=(too-many-statements super().__init__(parent) # Get and set some properties. @@ -847,14 +856,16 @@ def __init__(self, window, parent=None, flags=None): self._top_action.toggled.connect(self.toggle_keep_above) self._close_action = action('&Close', self, close_icon(self)) self._close_action.triggered.connect(self._window.close) - self._main_menu.addActions([ - self._restore_action, - self._move_action, - self._size_action, - self._min_action, - self._max_action, - self._top_action, - ]) + self._main_menu.addActions( + [ + self._restore_action, + self._move_action, + self._size_action, + self._min_action, + self._max_action, + self._top_action, + ] + ) self._main_menu.addSeparator() self._main_menu.addAction(self._close_action) self._menu.setMenu(self._main_menu) @@ -1248,93 +1259,93 @@ def is_active(self): def is_on_top(self, pos, rect): '''Determine if the cursor is on the top of the widget.''' return ( - pos.x() >= rect.x() + self._border_width and - pos.x() <= rect.x() + rect.width() - self._border_width and - pos.y() >= rect.y() and - pos.y() <= rect.y() + self._border_width + pos.x() >= rect.x() + self._border_width + and pos.x() <= rect.x() + rect.width() - self._border_width + and pos.y() >= rect.y() + and pos.y() <= rect.y() + self._border_width ) def is_on_bottom(self, pos, rect): '''Determine if the cursor is on the bottom of the widget.''' return ( - pos.x() >= rect.x() + self._border_width and - pos.x() <= rect.x() + rect.width() - self._border_width and - pos.y() >= rect.y() + rect.height() - self._border_width and - pos.y() <= rect.y() + rect.height() + pos.x() >= rect.x() + self._border_width + and pos.x() <= rect.x() + rect.width() - self._border_width + and pos.y() >= rect.y() + rect.height() - self._border_width + and pos.y() <= rect.y() + rect.height() ) def is_on_left(self, pos, rect): '''Determine if the cursor is on the left of the widget.''' return ( - pos.x() >= rect.x() - self._border_width and - pos.x() <= rect.x() + self._border_width and - pos.y() >= rect.y() + self._border_width and - pos.y() <= rect.y() + rect.height() - self._border_width + pos.x() >= rect.x() - self._border_width + and pos.x() <= rect.x() + self._border_width + and pos.y() >= rect.y() + self._border_width + and pos.y() <= rect.y() + rect.height() - self._border_width ) def is_on_right(self, pos, rect): '''Determine if the cursor is on the right of the widget.''' return ( - pos.x() >= rect.x() + rect.width() - self._border_width and - pos.x() <= rect.x() + rect.width() and - pos.y() >= rect.y() + self._border_width and - pos.y() <= rect.y() + rect.height() - self._border_width + pos.x() >= rect.x() + rect.width() - self._border_width + and pos.x() <= rect.x() + rect.width() + and pos.y() >= rect.y() + self._border_width + and pos.y() <= rect.y() + rect.height() - self._border_width ) def is_on_top_left(self, pos, rect): '''Determine if the cursor is on the top left of the widget.''' return ( - pos.x() >= rect.x() and - pos.x() <= rect.x() + self._border_width and - pos.y() >= rect.y() and - pos.y() <= rect.y() + self._border_width + pos.x() >= rect.x() + and pos.x() <= rect.x() + self._border_width + and pos.y() >= rect.y() + and pos.y() <= rect.y() + self._border_width ) def is_on_top_right(self, pos, rect): '''Determine if the cursor is on the top right of the widget.''' return ( - pos.x() >= rect.x() + rect.width() - self._border_width and - pos.x() <= rect.x() + rect.width() and - pos.y() >= rect.y() and - pos.y() <= rect.y() + self._border_width + pos.x() >= rect.x() + rect.width() - self._border_width + and pos.x() <= rect.x() + rect.width() + and pos.y() >= rect.y() + and pos.y() <= rect.y() + self._border_width ) def is_on_bottom_left(self, pos, rect): '''Determine if the cursor is on the bottom left of the widget.''' return ( - pos.x() >= rect.x() and - pos.x() <= rect.x() + self._border_width and - pos.y() >= rect.y() + rect.height() - self._border_width and - pos.y() <= rect.y() + rect.height() + pos.x() >= rect.x() + and pos.x() <= rect.x() + self._border_width + and pos.y() >= rect.y() + rect.height() - self._border_width + and pos.y() <= rect.y() + rect.height() ) def is_on_bottom_right(self, pos, rect): '''Determine if the cursor is on the bottom right of the widget.''' return ( - pos.x() >= rect.x() + rect.width() - self._border_width and - pos.x() <= rect.x() + rect.width() and - pos.y() >= rect.y() + rect.height() - self._border_width and - pos.y() <= rect.y() + rect.height() + pos.x() >= rect.x() + rect.width() - self._border_width + and pos.x() <= rect.x() + rect.width() + and pos.y() >= rect.y() + rect.height() - self._border_width + and pos.y() <= rect.y() + rect.height() ) - def cursor_position(self, pos, rect): + def cursor_position(self, pos, rect): # pylint: disable=too-many-return-statements) '''Calculate the cursor position inside the window.''' if self.is_on_left(pos, rect): return WindowEdge.Left - elif self.is_on_right(pos, rect): + if self.is_on_right(pos, rect): return WindowEdge.Right - elif self.is_on_bottom(pos, rect): + if self.is_on_bottom(pos, rect): return WindowEdge.Bottom - elif self.is_on_top(pos, rect): + if self.is_on_top(pos, rect): return WindowEdge.Top - elif self.is_on_bottom_left(pos, rect): + if self.is_on_bottom_left(pos, rect): return WindowEdge.BottomLeft - elif self.is_on_bottom_right(pos, rect): + if self.is_on_bottom_right(pos, rect): return WindowEdge.BottomRight - elif self.is_on_top_right(pos, rect): + if self.is_on_top_right(pos, rect): return WindowEdge.TopRight - elif self.is_on_top_left(pos, rect): + if self.is_on_top_left(pos, rect): return WindowEdge.TopLeft return WindowEdge.NoEdge @@ -1390,27 +1401,27 @@ def update_cursor(self, position): self._window.setCursor(self._cursor) - def resize(self, position, rect): + def resize(self, position, rect): # pylint: disable=too-many-branches '''Resize our window to the adjusted dimensions.''' # Get our new frame dimensions. if self._press_edge == WindowEdge.NoEdge: return - elif self._press_edge == WindowEdge.Top: + if self._press_edge == WindowEdge.Top: rect.setTop(position.y()) - elif self._press_edge == WindowEdge.Bottom: + if self._press_edge == WindowEdge.Bottom: rect.setBottom(position.y()) - elif self._press_edge == WindowEdge.Left: + if self._press_edge == WindowEdge.Left: rect.setLeft(position.x()) - elif self._press_edge == WindowEdge.Right: + if self._press_edge == WindowEdge.Right: rect.setRight(position.x()) - elif self._press_edge == WindowEdge.TopLeft: + if self._press_edge == WindowEdge.TopLeft: rect.setTopLeft(position) - elif self._press_edge == WindowEdge.TopRight: + if self._press_edge == WindowEdge.TopRight: rect.setTopRight(position) - elif self._press_edge == WindowEdge.BottomLeft: + if self._press_edge == WindowEdge.BottomLeft: rect.setBottomLeft(position) - elif self._press_edge == WindowEdge.BottomRight: + if self._press_edge == WindowEdge.BottomRight: rect.setBottomRight(position) # Ensure we don't drag the widgets if we go below min sizes. @@ -1507,7 +1518,7 @@ def enter(self, event): def leave(self, event): '''Handle the leaveEvent of the window.''' - + _ = event if not self._pressed: self.unset_cursor() @@ -1564,6 +1575,7 @@ def __init__( flags=QtCore.Qt.WindowType(0), sizegrip=False, ): + _ = sizegrip super().__init__(parent, flags=flags) @@ -1810,7 +1822,7 @@ def unminimize(self, subwindow): self._minimized.remove(subwindow) self.move_minimized() - def move_minimized(self): + def move_minimized(self): # pylint: disable=too-many-locals '''Move the minimized windows.''' # No need to set the geometry of our minimized windows. @@ -1863,9 +1875,8 @@ def move_minimized(self): # Now, need to place them accordingly. # Need to handle unshifts, if they occur, due to the - for index in range(len(self._minimized)): + for index, window in enumerate(self._minimized): # Calculate our new column, only storing if it is a new column. - window = self._minimized[index] is_new_column = index % row_count == 0 if index != 0 and is_new_column: point = new_column(point) @@ -2023,6 +2034,7 @@ def move_event(self, _, event, window_type): def resize_event(self, obj, event, window_type): '''Handle window resize events.''' + _ = obj # NOTE: If we're on Wayland, we cant' track hover events outside the # main widget, and we can't guess intermittently since if the mouse # doesn't move, we won't get an `Enter` or `HoverEnter` event, and @@ -2245,7 +2257,8 @@ def restore(self, _): '''Restore the window, showing the main widget and size grip.''' self.showNormal() - def showNormal(self): + def showNormal(self): # pylint: disable=useless-parent-delegation + '''Show the normal titlebar view.''' super().showNormal() def shade(self, size): @@ -2321,7 +2334,7 @@ def main(): app.installEventFilter(window) shared.set_stylesheet(args, app, compat) - return shared.exec_app(args, app, window, compat) + return shared.exec_app(args, app, window) if __name__ == '__main__': diff --git a/example/url.py b/example/url.py index 910694e..4256744 100644 --- a/example/url.py +++ b/example/url.py @@ -30,19 +30,20 @@ cannot be modified in stylesheets. ''' -import shared +# pylint: disable=duplicate-code + import sys +import shared + parser = shared.create_parser() parser.add_argument( - '--set-app-palette', - help='''set the placeholder text palette globally.''', - action='store_true' + '--set-app-palette', help='''set the placeholder text palette globally.''', action='store_true' ) parser.add_argument( '--set-widget-palette', help='''set the placeholder text palette for the affected widgets.''', - action='store_true' + action='store_true', ) args, unknown = shared.parse_args(parser) QtCore, QtGui, QtWidgets = shared.import_qt(args) @@ -59,6 +60,7 @@ def set_palette(widget, role, color): def set_link_palette(widget): + '''Set the palette for a link type.''' set_palette(widget, compat.Link, colors.LinkColor) set_palette(widget, compat.LinkVisited, colors.LinkVisitedColor) @@ -67,6 +69,8 @@ class Ui: '''Main class for the user interface.''' def setup(self, MainWindow): + '''Setup our main window for the UI.''' + url = 'https://github.com/Alexhuszagh/BreezeStyleSheets' MainWindow.setObjectName('MainWindow') MainWindow.resize(400, 200) @@ -121,7 +125,7 @@ def main(): window.setWindowTitle('Stylized URL colors.') shared.set_stylesheet(args, app, compat) - return shared.exec_app(args, app, window, compat) + return shared.exec_app(args, app, window) if __name__ == '__main__': diff --git a/example/whatsthis.py b/example/whatsthis.py index 153f816..1d7c78e 100644 --- a/example/whatsthis.py +++ b/example/whatsthis.py @@ -30,9 +30,10 @@ since it cannot be modified via stylesheets. ''' -import shared import sys +import shared + parser = shared.create_parser() args, unknown = shared.parse_args(parser) QtCore, QtGui, QtWidgets = shared.import_qt(args) @@ -44,6 +45,8 @@ class Ui: '''Main class for the user interface.''' def setup(self, MainWindow): + '''Setup our main window for the UI.''' + MainWindow.setObjectName('MainWindow') MainWindow.resize(1068, 824) self.centralwidget = QtWidgets.QWidget(MainWindow) @@ -78,7 +81,7 @@ def main(): window.setWindowTitle('Stylized QWhatsThis.') shared.set_stylesheet(args, app, compat) - return shared.exec_app(args, app, window, compat) + return shared.exec_app(args, app, window) if __name__ == '__main__': diff --git a/example/widgets.py b/example/widgets.py index dcfab3f..3705b3d 100644 --- a/example/widgets.py +++ b/example/widgets.py @@ -30,14 +30,17 @@ Simple example showing numerous built-in widgets. ''' -import shared +# pylint: disable=duplicate-code + import sys +import shared + parser = shared.create_parser() args, unknown = shared.parse_args(parser) QtCore, QtGui, QtWidgets = shared.import_qt(args) compat = shared.get_compat_definitions(args) -ICON_MAP = shared.get_icon_map(args, compat) +ICON_MAP = shared.get_icon_map(compat) def close_icon(widget): @@ -48,7 +51,10 @@ def close_icon(widget): class Ui: '''Main class for the user interface.''' - def setup(self, MainWindow): + def setup(self, MainWindow): # pylint: disable=too-many-statements,too-many-locals + '''Setup our main window for the UI.''' + + # pylint: disable=unused-variable MainWindow.setObjectName('MainWindow') MainWindow.resize(1068, 824) self.centralwidget = QtWidgets.QWidget(MainWindow) @@ -421,8 +427,11 @@ def setup(self, MainWindow): MainWindow.setTabOrder(self.verticalSlider, self.tabWidget) MainWindow.setTabOrder(self.tabWidget, self.lineEdit) MainWindow.setTabOrder(self.lineEdit, self.listWidget) + # pylint: enable=unused-variable + + def retranslateUi(self, MainWindow): # pylint: disable=too-many-statements + '''Retranslate our UI after initializing some of our base modules.''' - def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate MainWindow.setWindowTitle(_translate('MainWindow', 'MainWindow')) self.groupBox.setTitle(_translate('MainWindow', 'ToolBox')) @@ -502,9 +511,11 @@ def retranslateUi(self, MainWindow): self.actionAction_C.setText(_translate('MainWindow', 'Action &C')) def about(self): + '''Load our Qt about window.''' QtWidgets.QMessageBox.aboutQt(self.centralwidget, 'About Menu') def critical(self): + '''Launch a critical message box.''' QtWidgets.QMessageBox.critical(self.centralwidget, 'Error', 'Critical Error') @@ -516,18 +527,9 @@ def main(): # setup ui ui = Ui() ui.setup(window) - ui.bt_delay_popup.addActions([ - ui.actionAction, - ui.actionAction_C - ]) - ui.bt_instant_popup.addActions([ - ui.actionAction, - ui.actionAction_C - ]) - ui.bt_menu_button_popup.addActions([ - ui.actionAction, - ui.actionAction_C - ]) + ui.bt_delay_popup.addActions([ui.actionAction, ui.actionAction_C]) + ui.bt_instant_popup.addActions([ui.actionAction, ui.actionAction_C]) + ui.bt_menu_button_popup.addActions([ui.actionAction, ui.actionAction_C]) window.setWindowTitle('Sample BreezeStyleSheets application.') # Add event triggers @@ -538,7 +540,7 @@ def main(): window.tabifyDockWidget(ui.dockWidget1, ui.dockWidget2) shared.set_stylesheet(args, app, compat) - return shared.exec_app(args, app, window, compat) + return shared.exec_app(args, app, window) if __name__ == '__main__': diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fdc39fa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,581 @@ +# NOTE: This is not a true pyproject.toml, this is for configurations with black. + +[tool.black] +target-version = ["py310"] +line-length = 110 +color = true +skip-string-normalization = true + +exclude = ''' +/( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + | env + | venv +)/ +''' + +[tool.isort] +py_version = 310 +line_length = 110 +include_trailing_comma = true +profile = "black" +multi_line_output = 3 +indent = 4 +color_output = true +known_typing = ["typing", "types", "typing_extensions", "mypy", "mypy_extensions"] +sections = ["FUTURE", "TYPING", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] +no_lines_before = ["TYPING", "STDLIB", "FIRSTPARTY", "LOCALFOLDER"] + +[tool.pylint.main] +# Analyse import fallback blocks. This can be used to support both Python 2 and 3 +# compatible code, which means that the block might have code that exists only in +# one or another interpreter, leading to false positives when analysed. +# analyse-fallback-blocks = + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint in +# a server-like mode. +# clear-cache-post-run = + +# Always return a 0 (non-error) status code, even if lint errors are found. This +# is primarily useful in continuous integration scripts. +# exit-zero = + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +# extension-pkg-allow-list = + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +# extension-pkg-whitelist = + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +# fail-on = + +# Specify a score threshold under which the program will exit with error. +fail-under = 10.0 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +# from-stdin = + +# Files or directories to be skipped. They should be base names, not paths. +ignore = ["CVS"] + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, it +# can't be used as an escape character. +# ignore-paths = + +# Files or directories matching the regular expression patterns are skipped. The +# regex matches against base names, not paths. The default value ignores Emacs +# file locks +ignore-patterns = ["^\\.#"] + +# List of module names for which member attributes should not be checked and will +# not be imported (useful for modules/projects where namespaces are manipulated +# during runtime and thus existing member attributes cannot be deduced by static +# analysis). It supports qualified module names, as well as Unix pattern +# matching. +# ignored-modules = + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +# init-hook = + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs = 1 + +# Control the amount of potential inferred values when inferring a single object. +# This can help the performance when dealing with large functions or complex, +# nested conditions. +limit-inference-results = 100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +# load-plugins = + +# Pickle collected data for later comparisons. +persistent = true + +# Resolve imports to .pyi stubs if available. May reduce no-member messages and +# increase not-an-iterable messages. +# prefer-stubs = + +# Minimum Python version to use for version dependent checks. Will default to the +# version used to run pylint. +py-version = "3.10" + +# Discover python modules and packages in the file system subtree. +# recursive = + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +# source-roots = + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode = true + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +# unsafe-load-any-extension = + +[tool.pylint.basic] +# Naming style matching correct argument names. +argument-naming-style = "snake_case" + +# Regular expression matching correct argument names. Overrides argument-naming- +# style. If left empty, argument names will be checked with the set naming style. +# argument-rgx = + +# Naming style matching correct attribute names. +attr-naming-style = "snake_case" + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +# attr-rgx = + +# Bad variable names which should always be refused, separated by a comma. +bad-names = ["foo", "bar", "baz", "toto", "tutu", "tata"] + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +# bad-names-rgxs = + +# Naming style matching correct class attribute names. +class-attribute-naming-style = "any" + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +# class-attribute-rgx = + +# Naming style matching correct class constant names. +class-const-naming-style = "UPPER_CASE" + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +# class-const-rgx = + +# Naming style matching correct class names. +class-naming-style = "PascalCase" + +# Regular expression matching correct class names. Overrides class-naming-style. +# If left empty, class names will be checked with the set naming style. +# class-rgx = + +# Naming style matching correct constant names. +const-naming-style = "UPPER_CASE" + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming style. +# const-rgx = + +# Minimum line length for functions/classes that require docstrings, shorter ones +# are exempt. +docstring-min-length = -1 + +# Naming style matching correct function names. +function-naming-style = "snake_case" + +# Regular expression matching correct function names. Overrides function-naming- +# style. If left empty, function names will be checked with the set naming style. +# function-rgx = + +# Good variable names which should always be accepted, separated by a comma. +good-names = ["i", "j", "k", "ex", "Run", "_"] + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +# good-names-rgxs = + +# Include a hint for the correct naming format with invalid-name. +# include-naming-hint = + +# Naming style matching correct inline iteration names. +inlinevar-naming-style = "any" + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +# inlinevar-rgx = + +# Naming style matching correct method names. +method-naming-style = "snake_case" + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +# method-rgx = + +# Naming style matching correct module names. +module-naming-style = "snake_case" + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +# module-rgx = + +# Colon-delimited sets of names that determine each other's naming style when the +# name regexes allow several styles. +# name-group = + +# Regular expression which should only match function or class names that do not +# require a docstring. +no-docstring-rgx = "^_" + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. These +# decorators are taken in consideration only for invalid-name. +property-classes = ["abc.abstractproperty"] + +# Regular expression matching correct type alias names. If left empty, type alias +# names will be checked with the set naming style. +# typealias-rgx = + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +# typevar-rgx = + +# Naming style matching correct variable names. +variable-naming-style = "snake_case" + +# Regular expression matching correct variable names. Overrides variable-naming- +# style. If left empty, variable names will be checked with the set naming style. +# variable-rgx = + +[tool.pylint.classes] +# Warn about protected attribute access inside special methods +# check-protected-access-in-special-methods = + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods = ["__init__", "__new__", "setUp", "asyncSetUp", "__post_init__"] + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected = ["_asdict", "_fields", "_replace", "_source", "_make", "os._exit"] + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg = ["cls"] + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg = ["mcs"] + +[tool.pylint.design] +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +# exclude-too-few-public-methods = + +# List of qualified class names to ignore when counting class parents (see R0901) +# ignored-parents = + +# Maximum number of arguments for function / method. +max-args = 15 + +# Maximum number of attributes for a class (see R0902). +max-attributes = 12 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr = 5 + +# Maximum number of branch for function / method body. +max-branches = 12 + +# Maximum number of locals for function / method body. +max-locals = 20 + +# Maximum number of parents for a class (see R0901). +max-parents = 7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods = 40 + +# Maximum number of return / yield for function / method body. +max-returns = 6 + +# Maximum number of statements in function / method body. +max-statements = 50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods = 1 + +[tool.pylint.exceptions] +# Exceptions that will emit a warning when caught. +overgeneral-exceptions = ["builtins.BaseException", "builtins.Exception"] + +[tool.pylint.format] +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +# expected-line-ending-format = + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines = "^\\s*(# )??$" + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren = 4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string = " " + +# Maximum number of characters on a single line. +max-line-length = 110 + +# Maximum number of lines in a module. +max-module-lines = 2500 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +# single-line-class-stmt = + +# Allow the body of an if to be on the same line as the test if there is no else. +# single-line-if-stmt = + +[tool.pylint.imports] +# List of modules that can be imported at any level, not just the top level one. +# allow-any-import-level = + +# Allow explicit reexports by alias from a package __init__. +# allow-reexport-from-package = + +# Allow wildcard imports from modules that define __all__. +# allow-wildcard-with-all = + +# Deprecated modules which should not be used, separated by a comma. +# deprecated-modules = + +# Output a graph (.gv or any supported image format) of external dependencies to +# the given file (report RP0402 must not be disabled). +# ext-import-graph = + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be disabled). +# import-graph = + +# Output a graph (.gv or any supported image format) of internal dependencies to +# the given file (report RP0402 must not be disabled). +# int-import-graph = + +# Force import order to recognize a module as part of the standard compatibility +# libraries. +# known-standard-library = + +# Force import order to recognize a module as part of a third party library. +known-third-party = ["enchant"] + +# Couples of modules and preferred modules, separated by a comma. +# preferred-modules = + +[tool.pylint.logging] +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style = "old" + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules = ["logging"] + +[tool.pylint."messages control"] +# Only show warnings with the listed confidence levels. Leave empty to show all. +# Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence = ["HIGH", "CONTROL_FLOW", "INFERENCE", "INFERENCE_FAILURE", "UNDEFINED"] + +# Disable the message, report, category or checker with the given id(s). You can +# either give multiple identifiers separated by comma (,) or put this option +# multiple times (only on the command line, not in the configuration file where +# it should appear only once). You can also use "--disable=all" to disable +# everything first and then re-enable specific checks. For example, if you want +# to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable = ["raw-checker-failed", "bad-inline-option", "locally-disabled", "file-ignored", "suppressed-message", "useless-suppression", "deprecated-pragma", "use-symbolic-message-instead", "use-implicit-booleaness-not-comparison-to-string", "use-implicit-booleaness-not-comparison-to-zero", "import-outside-toplevel", "broad-exception-caught", "too-few-public-methods", "global-statement", "c-extension-no-member", "too-many-instance-attributes", "invalid-name", "attribute-defined-outside-init", "unnecessary-lambda-assignment"] + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where it +# should appear only once). See also the "--disable" option for examples. +# enable = + +[tool.pylint.method_args] +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods = ["requests.api.delete", "requests.api.get", "requests.api.head", "requests.api.options", "requests.api.patch", "requests.api.post", "requests.api.put", "requests.api.request"] + +[tool.pylint.miscellaneous] +# List of note tags to take in consideration, separated by a comma. +notes = ["FIXME", "XXX", "TODO"] + +# Regular expression of note tags to take in consideration. +# notes-rgx = + +[tool.pylint.refactoring] +# Maximum number of nested blocks for function / method body +max-nested-blocks = 5 + +# Complete name of functions that never returns. When checking for inconsistent- +# return-statements if a never returning function is called then it will be +# considered as an explicit return statement and no message will be printed. +never-returning-functions = ["sys.exit", "argparse.parse_error"] + +# Let 'consider-using-join' be raised when the separator to join on would be non- +# empty (resulting in expected fixes of the type: ``"- " + " - ".join(items)``) +suggest-join-with-non-empty-separator = true + +[tool.pylint.reports] +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each category, +# as well as 'statement' which is the total number of statements analyzed. This +# score is used by the global evaluation report (RP0004). +evaluation = "max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))" + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +# msg-template = + +# Set the output format. Available formats are: text, parseable, colorized, json2 +# (improved json format), json (old json format) and msvs (visual studio). You +# can also give a reporter class, e.g. mypackage.mymodule.MyReporterClass. +# output-format = + +# Tells whether to display a full report or only the messages. +# reports = + +# Activate the evaluation score. +score = true + +[tool.pylint.similarities] +# Comments are removed from the similarity computation +ignore-comments = true + +# Docstrings are removed from the similarity computation +ignore-docstrings = true + +# Imports are removed from the similarity computation +ignore-imports = true + +# Signatures are removed from the similarity computation +ignore-signatures = true + +# Minimum lines number of a similarity. +min-similarity-lines = 15 + +[tool.pylint.spelling] +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions = 4 + +# Spelling dictionary name. No available dictionaries : You need to install both +# the python package and the system dependency for enchant to work. +# spelling-dict = + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives = "fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:" + +# List of comma separated words that should not be checked. +# spelling-ignore-words = + +# A path to a file that contains the private dictionary; one word per line. +# spelling-private-dict-file = + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +# spelling-store-unknown-words = + +[tool.pylint.typecheck] +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators = ["contextlib.contextmanager"] + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +# generated-members = + +# Tells whether missing members accessed in mixin class should be ignored. A +# class is considered mixin if its name matches the mixin-class-rgx option. +# Tells whether to warn about missing members when the owner of the attribute is +# inferred to be None. +ignore-none = true + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference can +# return multiple potential results while evaluating a Python object, but some +# branches might not be evaluated, which results in partial inference. In that +# case, it might be useful to still emit no-member and other checks for the rest +# of the inferred objects. +ignore-on-opaque-inference = true + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins = ["no-member", "not-async-context-manager", "not-context-manager", "attribute-defined-outside-init"] + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes = ["optparse.Values", "thread._local", "_thread._local", "argparse.Namespace"] + +# Show a hint with possible names when a member name was not found. The aspect of +# finding the hint is based on edit distance. +missing-member-hint = true + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance = 1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices = 1 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx = ".*[Mm]ixin" + +# List of decorators that change the signature of a decorated function. +# signature-mutators = + +[tool.pylint.variables] +# List of additional names supposed to be defined in builtins. Remember that you +# should avoid defining new builtins when possible. +# additional-builtins = + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables = true + +# List of names allowed to shadow builtins +# allowed-redefined-builtins = + +# List of strings which can identify a callback function by name. A callback name +# must start or end with one of those strings. +callbacks = ["cb_", "_cb"] + +# A regular expression matching the name of dummy variables (i.e. expected to not +# be used). +dummy-variables-rgx = "_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_" + +# Argument names that match this expression will be ignored. +ignored-argument-names = "_.*|^ignored_|^unused_" + +# Tells whether we should check for unused import in __init__ files. +# init-import = + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules = ["six.moves", "past.builtins", "future.builtins", "builtins", "io"] diff --git a/ci/configure_all.sh b/scripts/configure.sh similarity index 71% rename from ci/configure_all.sh rename to scripts/configure.sh index b80ff8f..230c683 100755 --- a/ci/configure_all.sh +++ b/scripts/configure.sh @@ -10,23 +10,25 @@ set -eux pipefail -ci_home="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" -project_home="$(dirname "${ci_home}")" +scripts_home="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +project_home="$(dirname "${scripts_home}")" mkdir -p "${project_home}/dist/ci" cd "${project_home}" +# shellcheck source=/dev/null +. "${scripts_home}/shared.sh" # pop them into dist since it's ignored anyway -if [[ ! -v PYTHON ]]; then +if ! is-set PYTHON; then PYTHON=python fi frameworks=("pyqt5" "pyqt6" "pyside6") -have_pyside=$(PYTHON -c 'import sys; print(sys.version_info < (3, 11))') +have_pyside=$(${PYTHON} -c 'import sys; print(sys.version_info < (3, 11))') if [[ "${have_pyside}" == "True" ]]; then frameworks+=("pyside2") fi # NOTE: We need to make sure the scripts directory is added to the path -python_home=$(PYTHON -c 'import site; print(site.getsitepackages()[0])') +python_home=$(${PYTHON} -c 'import site; print(site.getsitepackages()[0])') scripts_dir="${python_home}/scripts" uname_s="$(uname -s)" if [[ "${uname_s}" == MINGW* ]]; then @@ -35,7 +37,7 @@ if [[ "${uname_s}" == MINGW* ]]; then fi export PATH="${scripts_dir}:${PATH}" for framework in "${frameworks[@]}"; do - "${PYTHON}" "${project_home}/configure.py" \ + ${PYTHON} "${project_home}/configure.py" \ --styles=all \ --extensions=all \ --qt-framework "${framework}" \ @@ -43,5 +45,5 @@ for framework in "${frameworks[@]}"; do --resource "breeze_${framework}.qrc" \ --compiled-resource "${project_home}/dist/ci/breeze_${framework}.py" # this will auto-fail due to pipefail, checks the imports work - "${PYTHON}" -c "import os; os.chdir('dist/ci'); import breeze_${framework}" -done \ No newline at end of file + ${PYTHON} -c "import os; os.chdir('dist/ci'); import breeze_${framework}" +done diff --git a/scripts/fmt.sh b/scripts/fmt.sh new file mode 100755 index 0000000..8a8cda6 --- /dev/null +++ b/scripts/fmt.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# Run our automatic code formatters. +# +# This requires black and isort to be installed. + +set -eux pipefail + +scripts_home="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +project_home="$(dirname "${scripts_home}")" +cd "${project_home}" + +isort ./*.py example/*.py example/**/*.py +black --config pyproject.toml example/ ./*.py \ No newline at end of file diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..2793f1a --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# +# Run our code linters, including type checking. +# Since we have 0 dependencies, we don't use securit checks. + +set -eux pipefail + +scripts_home="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +project_home="$(dirname "${scripts_home}")" +cd "${project_home}" + +# run our python lint checks +pylint ./*.py example/*.py example/**/*.py +pyright example/breeze_theme.py +flake8 + +# run our C++ lint checks \ No newline at end of file diff --git a/scripts/shared.sh b/scripts/shared.sh new file mode 100755 index 0000000..3dc5d14 --- /dev/null +++ b/scripts/shared.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Shared logic for our bash scripts. + +# We have to use this because macOS does not have bash 4.2 support. +is-set() +{ + declare -p "${1}" &>/dev/null +} diff --git a/scripts/theme.sh b/scripts/theme.sh new file mode 100755 index 0000000..a3794da --- /dev/null +++ b/scripts/theme.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# +# Use scripts to check if the theme determination works. + +set -eux pipefail + +scripts_home="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +project_home="$(dirname "${scripts_home}")" +cd "${project_home}/example" +# shellcheck source=/dev/null +. "${scripts_home}/shared.sh" + +if ! is-set PYTHON; then + PYTHON=python +fi +# Check the import first, then calling the function for easier debugging. +${PYTHON} -c "import breeze_theme" +theme=$(${PYTHON} -c "import breeze_theme; print(breeze_theme.get_theme())") +if [[ "${theme}" != Theme.* ]]; then + >&2 echo "Unable to get the correct theme." + exit 1 +fi +${PYTHON} -c "import breeze_theme; print(breeze_theme.is_light())" +${PYTHON} -c "import breeze_theme; print(breeze_theme.is_dark())" diff --git a/setup.cfg b/setup.cfg index 00ed8e1..c7c01a4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,8 +2,10 @@ exclude = .git, __pycache__, build, dist max-line-length = 110 per-file-ignores = - # Widgets need to be created but not used. + # widgets need to be created but not used. example/widgets.py: F841 test/ui.py: F841 - # Lambdas are way cleaner here + # lambdas are way cleaner here example/titlebar.py: E731 + # these are auto-generated files + resources/*.py: E302 E305 \ No newline at end of file diff --git a/test/ui.py b/test/ui.py index a993b52..3b35c4f 100644 --- a/test/ui.py +++ b/test/ui.py @@ -81,7 +81,7 @@ args, unknown = shared.parse_args(parser) QtCore, QtGui, QtWidgets = shared.import_qt(args) compat = shared.get_compat_definitions(args) -ICON_MAP = shared.get_icon_map(args, compat) +ICON_MAP = shared.get_icon_map(compat) layout = { 'vertical': QtWidgets.QVBoxLayout, diff --git a/vcs.py b/vcs.py index 93486d5..2a747b2 100644 --- a/vcs.py +++ b/vcs.py @@ -233,12 +233,7 @@ def parse_args(argv=None): '''Parse the command-line options.''' parser = argparse.ArgumentParser(description='Git configuration changes.') - parser.add_argument( - '-v', - '--version', - action='version', - version=f'%(prog)s {__version__}' - ) + parser.add_argument('-v', '--version', action='version', version=f'%(prog)s {__version__}') dist = parser.add_mutually_exclusive_group() dist.add_argument( '--track-dist', @@ -278,35 +273,41 @@ def call(command, ignore_errors=True): except subprocess.CalledProcessError as error: if b'Unable to mark file' not in error.stderr or not ignore_errors: raise + return None def assume_unchanged(git, file): '''Assume a version-controlled file is unchanged.''' - return call([ - git, - 'update-index', - '--assume-unchanged', - file, - ]) + return call( + [ + git, + 'update-index', + '--assume-unchanged', + file, + ] + ) def no_assume_unchanged(git, file): '''No longer assume a version-controlled file is unchanged.''' - return call([ - git, - 'update-index', - '--no-assume-unchanged', - file, - ]) + return call( + [ + git, + 'update-index', + '--no-assume-unchanged', + file, + ] + ) def write_gitignore(entries): '''Write to ignore ignore file using the provided entries.''' - with open(os.path.join(home, '.gitignore'), 'w') as file: - file.write(f'{"\n".join(entries)}\n{PYTHON_GITIGNORE}\n{CPP_GITIGNORE}\n') + with open(os.path.join(home, '.gitignore'), 'w', encoding='utf-8') as file: + custom = '\n'.join(entries) + file.write(f'{custom}\n{PYTHON_GITIGNORE}\n{CPP_GITIGNORE}\n') def main(argv=None): @@ -361,7 +362,7 @@ def update_dist_index(file): dist_files = [] dist_dirs = [f'{home}/dist', f'{home}/resources'] for dist_dir in dist_dirs: - for root, dirs, files in os.walk(dist_dir): + for root, _, files in os.walk(dist_dir): relpath = os.path.relpath(root, home) for file in files: dist_files.append(f'{relpath}/{file}')