├── randrctl
├── py.typed
├── __init__.py
├── setup
│ ├── 99-randrctl.rules
│ └── config.yaml
├── __main__.py
├── exception.py
├── context.py
├── ctl.py
├── model.py
├── profile.py
├── xrandr.py
└── cli.py
├── requirements.txt
├── tests
├── __init__.py
├── simple_profile_example
├── profile_example
├── test_context.py
├── test_model.py
├── test_profile.py
└── test_xrandr.py
├── .pyproject.toml.swp
├── .gitignore
├── .github
└── workflows
│ └── ci.yml
├── pyproject.toml
├── CHANGELOG.md
├── README.md
├── LICENSE.txt
└── uv.lock
/randrctl/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | pyyaml==5.1
2 | argcomplete==1.9.4
3 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | logging.basicConfig()
4 |
--------------------------------------------------------------------------------
/randrctl/__init__.py:
--------------------------------------------------------------------------------
1 | XAUTHORITY = "XAUTHORITY"
2 | DISPLAY = "DISPLAY"
3 |
--------------------------------------------------------------------------------
/.pyproject.toml.swp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koiuo/randrctl/HEAD/.pyproject.toml.swp
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | *.iml
3 | dist
4 | build
5 | *.pyc
6 | .eggs
7 | *.egg-info
8 | .cache
9 |
--------------------------------------------------------------------------------
/randrctl/setup/99-randrctl.rules:
--------------------------------------------------------------------------------
1 | ACTION=="change", SUBSYSTEM=="drm", ENV{HOTPLUG}=="1", RUN+="/usr/bin/randrctl -d auto"
2 |
--------------------------------------------------------------------------------
/randrctl/__main__.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | from randrctl import cli
4 |
5 | if __name__ == '__main__':
6 | sys.exit(cli.main())
7 |
--------------------------------------------------------------------------------
/tests/simple_profile_example:
--------------------------------------------------------------------------------
1 | {
2 | "outputs": {
3 | "LVDS1": {
4 | "mode": "1366x768"
5 | }
6 | },
7 | "primary": "LVDS1"
8 | }
9 |
--------------------------------------------------------------------------------
/randrctl/setup/config.yaml:
--------------------------------------------------------------------------------
1 | hooks:
2 | # hooks are executed in user's shell
3 | # available environment variables:
4 | # randr_profile - name of the profile
5 | # randr_error - error message if happens
6 | prior_switch: {}
7 | post_switch: /usr/bin/notify-send -u low "randrctl" "switched to $randr_profile"
8 | post_fail: /usr/bin/notify-send -u critical "randrctl error" "can't switch to $randr_profile\n$randr_error"
9 |
--------------------------------------------------------------------------------
/tests/profile_example:
--------------------------------------------------------------------------------
1 | {
2 | "match": {
3 | "LVDS1" : { },
4 | "DP1" : {
5 | "edid": "d8578edf8458ce06fbc5bb76a58c5ca4",
6 | "prefers": "1920x1200",
7 | "supports": "1920x1080"
8 | }
9 | },
10 | "outputs": {
11 | "LVDS1": {
12 | "mode": "1366x768"
13 | },
14 | "DP1": {
15 | "mode": "1920x1080",
16 | "pos": "1366x0"
17 | },
18 | "VGA1": {
19 | "mode": "800x600",
20 | "pos": "3286x0",
21 | "panning": "800x1080",
22 | "rotate": "inverted",
23 | "rate": 80
24 | }
25 | },
26 | "primary": "LVDS1"
27 | }
28 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-24.04
12 | strategy:
13 | matrix:
14 | python-version: ["3.9", "3.10", "3.11"]
15 | steps:
16 | - uses: actions/checkout@v4
17 | with:
18 | fetch-depth: 0 # Fetch full history for version detection from git tags
19 |
20 | - name: Install xrandr
21 | run: sudo apt-get install -y x11-xserver-utils
22 |
23 | - name: Install uv
24 | uses: astral-sh/setup-uv@v3
25 | with:
26 | version: "latest"
27 |
28 | - name: Set up Python ${{ matrix.python-version }}
29 | run: uv python install ${{ matrix.python-version }}
30 |
31 | - name: Install dependencies
32 | run: |
33 | uv sync --dev
34 |
35 | - name: Run tests
36 | run: |
37 | uv run pytest
38 |
39 | - name: Build package
40 | run: |
41 | uv build
42 |
43 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling", "hatch-vcs"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "randrctl"
7 | description = "Profile based screen manager for X"
8 | authors = [
9 | { name = "Dmytro Kostiuchenko" }
10 | ]
11 | license = { text = "GPL-3.0" }
12 | readme = "README.md"
13 | requires-python = ">=3.7"
14 | classifiers = [
15 | "Development Status :: 4 - Beta",
16 | "Environment :: X11 Applications",
17 | "Intended Audience :: End Users/Desktop",
18 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
19 | "Operating System :: POSIX :: Linux",
20 | "Programming Language :: Python :: 3",
21 | "Programming Language :: Python :: 3.7",
22 | "Programming Language :: Python :: 3.8",
23 | "Programming Language :: Python :: 3.9",
24 | "Programming Language :: Python :: 3.10",
25 | "Programming Language :: Python :: 3.11",
26 | "Programming Language :: Python :: 3.12",
27 | "Topic :: Desktop Environment",
28 | "Topic :: Utilities"
29 | ]
30 | dependencies = [
31 | "argcomplete",
32 | "PyYAML"
33 | ]
34 | dynamic = ["version"]
35 |
36 | [project.urls]
37 | Homepage = "https://github.com/koiuo/randrctl"
38 | Repository = "https://github.com/koiuo/randrctl"
39 | Issues = "https://github.com/koiuo/randrctl/issues"
40 |
41 | [project.scripts]
42 | randrctl = "randrctl.cli:main"
43 |
44 | [tool.hatch.version]
45 | source = "vcs"
46 |
47 | [tool.hatch.build.targets.wheel]
48 | packages = ["randrctl"]
49 |
50 | [dependency-groups]
51 | dev = [
52 | "pytest>=7.4.4",
53 | ]
54 |
--------------------------------------------------------------------------------
/randrctl/exception.py:
--------------------------------------------------------------------------------
1 | class RandrCtlException(Exception):
2 | """
3 | Is thrown whenever expected exception occurs inside the app
4 | """
5 |
6 |
7 | class ValidationException(RandrCtlException):
8 | """
9 | Is thrown when some validation error occurs
10 | """
11 |
12 |
13 | class InvalidProfileException(RandrCtlException):
14 | """
15 | Is thrown when profile is invalid
16 | """
17 |
18 | def __init__(self, profile_path: str):
19 | self.profile_path = profile_path
20 | Exception.__init__(self, "Invalid profile {}".format(profile_path))
21 |
22 |
23 | class ParseException(RandrCtlException):
24 | """
25 | Is thrown when randrctl fails to parse some value into a domain object
26 | """
27 |
28 | def __init__(self, name: str, status: str, state: str):
29 | Exception.__init__(self, "Failed to parse '{}' for {} {}".format(state, status, name))
30 |
31 |
32 | class NoSuchProfileException(RandrCtlException):
33 | """
34 | Thrown when profile is referred by name, but no such exist
35 | """
36 |
37 | def __init__(self, name: str, search_locations: list):
38 | self.name = name
39 | self.search_locations = search_locations
40 | Exception.__init__(self, "No profile '{}' found under {}".format(self.name, self.search_locations))
41 |
42 |
43 | class XrandrException(RandrCtlException):
44 | """
45 | is thrown when call to xrandr fails
46 | """
47 |
48 | def __init__(self, err: str, args: list):
49 | self.args = args
50 | Exception.__init__(self, err)
51 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 1.11.0 - 2025-08-13
4 |
5 | ### Changed
6 |
7 | - use `xrandr` from `PATH` instead of hard-coded `/usr/bin` location
8 |
9 | ## 1.10.0 - 2024-02-10
10 |
11 | ### Changed
12 |
13 | - allow both `-l` and `-s` options in list command
14 |
15 | ### Fixed
16 |
17 | - an issue where xrandr output parsing hangs
18 |
19 | ## 1.9.0 - 2020-06-02
20 |
21 | ### Added
22 |
23 | - `/etc/randrctl` directory is now probed for config and profiles if there's no
24 | _randrctl_ config in home dir
25 |
26 | ### Fixed
27 |
28 | - deprecation/security warnings caused by outdated pyyaml dependency
29 |
30 | ## 1.8.2 - 2019-02-09
31 |
32 | ### Fixed
33 |
34 | - regression where hooks were not applied
35 |
36 | ## 1.8.1 - 2018-10-23
37 |
38 | ### Fixed
39 |
40 | - regression where profiles with no `primary` field were considered invalid
41 |
42 | ## 1.8.0 - 2018-09-01
43 |
44 | ### Added
45 |
46 | - `setup` command to assist in randrctl setup
47 | - new bash completion generated from application code
48 | - `-d` option to detect `DISPLAY` if executed by udev
49 |
50 | ### Fixed
51 |
52 | - python 3.7 compatibility
53 | - migrated from `packit` to `pbr` (should fix installation issues)
54 | - undesired rounding of refresh rate (#15)
55 |
56 | ### Removed
57 |
58 | - outdated zsh and bash completion files. _bash_ completion can be generated with
59 | ```
60 | randrctl setup completion
61 | ```
62 | _zsh_ users can enable bash completion support by adding to `.zshrc`
63 | ```
64 | autoload bashcompinit
65 | bashcompinit
66 | ```
67 | - obsolete `randrctl-auto` wrapper script. Use `randrctl -d` instead
68 |
69 | ## 1.7.1 - 2018-06-16
70 |
71 | ### Fixed
72 |
73 | - exception during `randrctl dump`
74 |
75 | ### Changed
76 |
77 | - `-v` option is replaced with `version` command. Use `randrctl version` instead of `randrctl -v`
78 |
79 | ## 1.7.0 - 2018-06-16
80 |
81 | ### Fixed
82 | - regression #13
83 |
84 | ### Removed
85 |
86 | - support for `config.ini`. Please migrate to `config.yaml`
87 | - support for configuration in `/etc/randrctl`
88 |
89 | ## 1.6.0 - 2018-04-15
90 |
91 | ### Added
92 |
93 | - support for configs in yaml format (#11)
94 |
95 | ### Changed
96 |
97 | - configs in INI format are now deprecated (#11)
98 |
99 | ### Fixed
100 |
101 | - overwriting existing config when there are no profiles (#9)
102 |
103 | ## 1.5.0 - 2018-01-24
104 |
105 | ### Added
106 |
107 | - `list -s` to print only matching profiles with their scores [#8](https://github.com/edio/randrctl/pull/8)
108 |
109 | ### Fixed
110 |
111 | - Bug where profiles without `match` section were considered for auto-matching
112 |
113 | ## 1.4.0 - 2017-11-18
114 |
115 | ### Added
116 |
117 | - This changelog file
118 |
119 | ### Changed
120 |
121 | - Profiles are now stored and displayed in YAML format.
122 |
123 | Conversion of existing profiles to new format is not required.
124 | There's also `-j` flag for `show` and `dump` to use JSON format.
125 |
--------------------------------------------------------------------------------
/tests/test_context.py:
--------------------------------------------------------------------------------
1 | import os
2 | import shutil
3 | import tempfile
4 | import unittest
5 |
6 | import yaml
7 |
8 | from randrctl.context import default_config_dirs, configs
9 |
10 |
11 | class TestDefaultConfigDirs(unittest.TestCase):
12 |
13 | def setUp(self):
14 | os.environ['HOME'] = '/home/user'
15 | os.environ['XDG_CONFIG_HOME'] = ''
16 |
17 | @unittest.skip("broken by PR #23")
18 | def test_should_use_xdg_conig_home_if_defined(self):
19 | os.environ['XDG_CONFIG_HOME'] = '/home/user/.xdgconfig'
20 | assert default_config_dirs() == ['/home/user/.xdgconfig/randrctl', '/home/user/.config/randrctl']
21 |
22 | @unittest.skip("broken by PR #23")
23 | def test_should_expand_nested_vars(self):
24 | os.environ['XDG_CONFIG_HOME'] = '$HOME/.xdgconfig'
25 | assert default_config_dirs() == ['/home/user/.xdgconfig/randrctl', '/home/user/.config/randrctl']
26 |
27 | @unittest.skip("broken by PR #23")
28 | def test_should_not_use_xdg_config_home_if_not_set(self):
29 | assert default_config_dirs() == ['/home/user/.config/randrctl']
30 |
31 |
32 | class TestConfigs(unittest.TestCase):
33 |
34 | def setUp(self):
35 | self.tmpdir = tempfile.mkdtemp(prefix="randrctl-test-")
36 |
37 | def tearDown(self):
38 | shutil.rmtree(self.tmpdir)
39 |
40 | def write_config(self, config: dict, dir: str = "."):
41 | config_dir = os.path.normpath(os.path.join(self.tmpdir, dir))
42 | os.makedirs(config_dir, exist_ok=True)
43 | with open(os.path.join(config_dir, dir, 'config.yaml'), 'w') as f:
44 | yaml.dump(config, f, default_flow_style=False)
45 |
46 | def write_config_str(self, content: str, dir: str = "."):
47 | config_dir = os.path.normpath(os.path.join(self.tmpdir, dir))
48 | os.makedirs(config_dir, exist_ok=True)
49 | with open(os.path.join(config_dir, dir, 'config.yaml'), 'w') as f:
50 | f.write(content)
51 |
52 | def test_should_skip_non_existing_config_dir(self):
53 | assert list(configs(["/doesnotexist"])) == []
54 |
55 | def test_should_skip_empty_config_dir(self):
56 | assert list(configs([self.tmpdir])) == []
57 |
58 | def test_should_skip_invalid_config(self):
59 | self.write_config_str("%")
60 | assert list(configs([self.tmpdir])) == []
61 |
62 | def test_should_parse_valid_config(self):
63 | # given
64 | config = {"hooks": {"post_switch": "/usr/bin/echo 42"}}
65 | self.write_config(config)
66 |
67 | # expect
68 | assert list(configs([self.tmpdir])) == [(self.tmpdir, config)]
69 |
70 | def test_should_pick_parse_multiple_locations(self):
71 | # given
72 | dir1 = os.path.join(self.tmpdir, "dir1")
73 | config1 = {"hooks": {"post_switch": "/usr/bin/echo 41"}}
74 | self.write_config(config1, dir1)
75 |
76 | dir2 = os.path.join(self.tmpdir, "dir2")
77 | config2 = {"hooks": {"post_switch": "/usr/bin/echo 42"}}
78 | self.write_config(config2, dir2)
79 |
80 | # expect
81 | assert list(configs([dir1, dir2])) == [(dir1, config1), (dir2, config2)]
82 |
83 |
84 | if __name__ == '__main__':
85 | unittest.main()
86 |
--------------------------------------------------------------------------------
/randrctl/context.py:
--------------------------------------------------------------------------------
1 | from os import path
2 |
3 | import logging
4 | import os
5 | import yaml
6 |
7 | from yaml import load, YAMLError
8 |
9 | from randrctl.ctl import Hooks, RandrCtl
10 | from randrctl.profile import ProfileManager
11 | from randrctl.xrandr import Xrandr
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 | CONFIG_NAME = "config.yaml"
16 | PROFILE_DIR_NAME = "profiles"
17 | DEFAULT_CONFIG_LOCATION = ".config/randrctl"
18 | SYS_CONFIG_DIR = "/etc/randrctl"
19 |
20 |
21 | def default_config_dirs(owner_home="$HOME"):
22 | """
23 | :return: default list of directories to look for a config in
24 | """
25 | # $HOME is guaranteed to exist on POSIX
26 | dirs = [
27 | _recursive_expand(path.join(owner_home, DEFAULT_CONFIG_LOCATION)),
28 | SYS_CONFIG_DIR,
29 | ]
30 |
31 | # if XDG_CONFIG_HOME is defined, use it too
32 | # TODO this won't work if executed by udev. Remove entirely?
33 | if os.environ.get('XDG_CONFIG_HOME'):
34 | dirs.insert(0, _recursive_expand('$XDG_CONFIG_HOME/randrctl'))
35 |
36 | return dirs
37 |
38 |
39 | def _recursive_expand(path: str):
40 | expanded = os.path.expandvars(path)
41 | while expanded != path:
42 | path = expanded
43 | expanded = os.path.expandvars(path)
44 | return expanded
45 |
46 |
47 | def configs(config_dirs: list):
48 | """
49 | Lazily visits specified directories and tries to parse a config file. If succeeds, yeilds a tuple (dir, config),
50 | where config is a dict
51 | :param config_dirs: list of directories that may contain configs
52 | :return: an iterator over tuples (config_dir, parsed_config), empty iterator if there are not valid configs
53 | """
54 | for randrctl_home in config_dirs:
55 | config_file = os.path.join(randrctl_home, CONFIG_NAME)
56 | if os.path.isfile(config_file):
57 | with open(config_file, 'r') as stream:
58 | try:
59 | logger.debug("reading configuration from %s", config_file)
60 | cfg = load(stream, Loader=yaml.FullLoader)
61 | if cfg:
62 | yield (randrctl_home, cfg)
63 | except YAMLError as e:
64 | logger.warning("error reading configuration file %s", config_file)
65 |
66 |
67 | def build(display: str, xauthority: str = None, config_dirs=None):
68 | """
69 | Builds a RandrCtl instance and all its dependencies given a list of config directories
70 | :param: display - display
71 | :return: new ready to use RandrCtl instance
72 | """
73 | if config_dirs is None:
74 | config_dirs = default_config_dirs()
75 |
76 | (primary_config_dir, config) = next(configs(config_dirs), (config_dirs[0], dict()))
77 |
78 | prior_switch = config.get('hooks', dict()).get('prior_switch', None)
79 | post_switch = config.get('hooks', dict()).get('post_switch', None)
80 | post_fail = config.get('hooks', dict()).get('post_fail', None)
81 | hooks = Hooks(prior_switch, post_switch, post_fail)
82 |
83 | profile_read_locations = [os.path.join(primary_config_dir, PROFILE_DIR_NAME)]
84 | profile_write_location = os.path.join(primary_config_dir, PROFILE_DIR_NAME)
85 | profile_manager = ProfileManager(profile_read_locations, profile_write_location)
86 |
87 | xrandr = Xrandr(display, xauthority)
88 |
89 | return RandrCtl(profile_manager, xrandr, hooks)
90 |
91 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/tests/test_model.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from randrctl.model import Serializable, Profile, Output, Rule
4 |
5 |
6 | class Node(Serializable):
7 | def __init__(self, name, children=None, named=None):
8 | self.name = name
9 | self.children = children
10 | self.named = named
11 |
12 |
13 | class TestSerializable(TestCase):
14 |
15 | def test_to_dict(self):
16 | # given
17 | n = Node("foo", [Node("foo1"), Node("foo2")], {"foo3": Node("foo3", [Node("foo3-1")])})
18 |
19 | # when
20 | d = n.to_dict()
21 |
22 | # then
23 | self.assertDictEqual(d, {
24 | 'name': "foo",
25 | 'children': [
26 | {
27 | 'name': "foo1"
28 | },
29 | {
30 | 'name': "foo2"
31 | },
32 | ],
33 | 'named': {
34 | 'foo3': {
35 | 'name': "foo3",
36 | 'children': [
37 | {
38 | 'name': "foo3-1"
39 | }
40 | ]
41 | }
42 | }
43 | })
44 |
45 |
46 | class TestProfile(TestCase):
47 |
48 | def test_profile_from_dict(self):
49 | # given
50 | data = [
51 | (
52 | Profile("no_rules", {"lvds1": Output("800x600")}, priority=100),
53 | {
54 | 'name': 'no_rules',
55 | 'outputs': {
56 | 'lvds1': {
57 | 'mode': '800x600',
58 | 'pos': '0x0',
59 | 'rotate': 'normal',
60 | 'panning': '0x0',
61 | 'scale': '1x1',
62 | }
63 | },
64 | 'priority': 100
65 | }
66 | ),
67 | (
68 | Profile(
69 | name="with_rules",
70 | outputs={
71 | "lvds1": Output("800x600", rate="60"),
72 | "vga1": Output("1024x768", pos="800x0", rate="75")
73 | },
74 | match={
75 | "lvds1": Rule("edid", "800x600", "800x600")
76 | },
77 | primary="lvds1",
78 | priority=100
79 | ),
80 | {
81 | 'name': 'with_rules',
82 | 'match': {
83 | 'lvds1': {
84 | 'edid': 'edid',
85 | 'supports': '800x600',
86 | 'prefers': '800x600'
87 | }
88 | },
89 | 'outputs': {
90 | 'lvds1': {
91 | 'mode': '800x600',
92 | 'pos': '0x0',
93 | 'rotate': 'normal',
94 | 'panning': '0x0',
95 | 'scale': '1x1',
96 | 'rate': '60'
97 | },
98 | 'vga1': {
99 | 'mode': '1024x768',
100 | 'pos': '800x0',
101 | 'rotate': 'normal',
102 | 'panning': '0x0',
103 | 'scale': '1x1',
104 | 'rate': 75
105 | }
106 | },
107 | 'primary': 'lvds1',
108 | 'priority': 100
109 | }
110 | )
111 | ]
112 |
113 | for expected_profile, dict in data:
114 | # when
115 | p = Profile.from_dict(dict)
116 |
117 | # then
118 | self.assertEqual(expected_profile, p)
119 | self.assertDictEqual(dict, p.to_dict())
120 |
--------------------------------------------------------------------------------
/randrctl/ctl.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import subprocess
4 |
5 | from randrctl.model import Profile
6 | from randrctl.profile import ProfileManager, ProfileMatcher
7 | from randrctl.xrandr import Xrandr
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 |
12 | class Hooks:
13 | """
14 | Intercepts calls to xrandr to support prior-, post-switch and post-fail hooks
15 | """
16 |
17 | def __init__(self, prior_switch: str, post_switch: str, post_fail: str):
18 | self._prior_switch = prior_switch
19 | self._post_switch = post_switch
20 | self._post_fail = post_fail
21 |
22 | def prior_switch(self, p: Profile):
23 | if self._prior_switch:
24 | self._hook(self._prior_switch, p)
25 |
26 | def post_switch(self, p: Profile):
27 | if self._post_switch:
28 | self._hook(self._post_switch, p)
29 |
30 | def post_fail(self, p: Profile, err: str):
31 | if self._post_fail:
32 | self._hook(self._post_fail, p, err)
33 |
34 | def _hook(self, hook: str, p: Profile, err: str = None):
35 | if hook is not None and len(hook.strip()) > 0:
36 | try:
37 | env = os.environ.copy()
38 | env["randr_profile"] = p.name
39 | if err:
40 | env["randr_error"] = err
41 | logger.debug("Calling '%s'", hook)
42 | subprocess.run(hook, env=env, shell=True)
43 | except Exception as e:
44 | logger.warning("Error while executing hook '%s': %s", hook, str(e))
45 |
46 |
47 | class RandrCtl:
48 | """
49 | Facade that ties all the classes together and provides simple interface
50 | """
51 |
52 | def __init__(self, profile_manager: ProfileManager, xrandr: Xrandr, hooks: Hooks):
53 | self.profile_manager = profile_manager
54 | self.xrandr = xrandr
55 | self.hooks = hooks
56 |
57 | def _apply(self, p: Profile):
58 | try:
59 | self.hooks.prior_switch(p)
60 | self.xrandr.apply(p)
61 | self.hooks.post_switch(p)
62 | except Exception as e:
63 | self.hooks.post_fail(p, str(e))
64 | raise e
65 |
66 | def switch_to(self, profile_name):
67 | """
68 | Apply profile settings by profile name
69 | """
70 | p = self.profile_manager.read_one(profile_name)
71 | self._apply(p)
72 |
73 | def switch_auto(self):
74 | """
75 | Try to find profile by display EDID and apply it
76 | """
77 | profiles = self.profile_manager.read_all()
78 | xrandr_outputs = self.xrandr.get_connected_outputs()
79 |
80 | profileMatcher = ProfileMatcher()
81 | matching = profileMatcher.find_best(profiles, xrandr_outputs)
82 |
83 | if matching is not None:
84 | self._apply(matching)
85 | else:
86 | logger.warning("No matching profile found")
87 |
88 | def dump_current(self, name: str, to_file: bool = False,
89 | include_supports_rule: bool = True,
90 | include_preferred_rule: bool = True,
91 | include_edid_rule: bool = True,
92 | include_refresh_rate: bool = True,
93 | priority: int = 100,
94 | json_compatible: bool = False):
95 | """
96 | Dump current profile under specified name. Only xrandr settings are dumped
97 | """
98 | xrandr_connections = self.xrandr.get_connected_outputs()
99 | profile = self.profile_manager.profile_from_xrandr(xrandr_connections, name)
100 | profile.priority = priority
101 |
102 | # TODO move logic to manager
103 | if not (include_edid_rule or include_supports_rule or include_preferred_rule):
104 | profile.match = None
105 | else:
106 | for rule in profile.match.values():
107 | if not include_supports_rule:
108 | rule.supports = None
109 | if not include_preferred_rule:
110 | rule.prefers = None
111 | if not include_edid_rule:
112 | rule.edid = None
113 |
114 | if not include_refresh_rate:
115 | for output in profile.outputs.values():
116 | output.rate = None
117 |
118 | if to_file:
119 | self.profile_manager.write(profile, yaml_flow_style=json_compatible)
120 | else:
121 | self.profile_manager.print(profile, yaml_flow_style=json_compatible)
122 |
123 | def print(self, name: str, json_compatible: bool = False):
124 | """
125 | Print specified profile to stdout
126 | """
127 | p = self.profile_manager.read_one(name)
128 | self.profile_manager.print(p, yaml_flow_style=json_compatible)
129 |
130 | def list_all(self):
131 | """
132 | List all available profiles
133 | """
134 | profiles = self.profile_manager.read_all()
135 | for p in profiles:
136 | print(p.name)
137 |
138 | def list_all_long(self):
139 | """
140 | List all available profiles along with some details
141 | """
142 | profiles = self.profile_manager.read_all()
143 | for p in profiles:
144 | print(p.name)
145 | for o in p.outputs:
146 | print(' ', o)
147 |
148 | def list_all_scored(self):
149 | """
150 | List matched profiles with scores
151 | """
152 | profiles = self.profile_manager.read_all()
153 | xrandr_outputs = self.xrandr.get_connected_outputs()
154 |
155 | profileMatcher = ProfileMatcher()
156 | matching = profileMatcher.match(profiles, xrandr_outputs)
157 |
158 | for score, p in matching:
159 | print(p.name, score)
160 |
161 |
162 |
163 |
--------------------------------------------------------------------------------
/randrctl/model.py:
--------------------------------------------------------------------------------
1 | class Display:
2 | """
3 | Display (i.e. physical device) connected to graphical adapter output
4 | """
5 |
6 | def __init__(self, supported_modes=None, preferred_mode: str = None, current_mode: str = None,
7 | current_rate: str = None, edid: str = None):
8 | if supported_modes is None:
9 | supported_modes = []
10 | self.mode = current_mode
11 | self.rate = current_rate
12 | self.preferred_mode = preferred_mode
13 | self.supported_modes = supported_modes
14 | self.edid = edid
15 |
16 | def is_on(self):
17 | return self.mode is not None
18 |
19 | def __repr__(self, *args, **kwargs):
20 | return str(self.__dict__)
21 |
22 |
23 | class Viewport:
24 | """
25 | Screen viewport
26 | """
27 |
28 | def __init__(self, size: str, pos: str = '0x0', rotate: str = 'normal', panning: str = '0x0', scale: str = '1x1'):
29 | self.size = size
30 | self.pos = pos
31 | self.rotate = rotate if rotate else "normal"
32 | self.panning = panning if panning else "0x0"
33 | self.scale = scale if scale else "1x1"
34 |
35 | def __repr__(self, *args, **kwargs):
36 | return str(self.__dict__)
37 |
38 |
39 | class XrandrConnection:
40 | """
41 | Connection between a graphic adapter output and a display with assigned viewport
42 | """
43 |
44 | def __init__(self, name: str, display: Display = None, current_geometry: Viewport = None, primary: bool = False,
45 | crtc: int = None):
46 | self.name = name
47 | self.display = display
48 | self.viewport = current_geometry
49 | self.primary = primary
50 | self.crtc = crtc
51 |
52 | def is_active(self):
53 | return self.viewport is not None
54 |
55 | def __repr__(self, *args, **kwargs):
56 | return str(self.__dict__)
57 |
58 |
59 | class Deserializable(object):
60 | # TODO implement deserialization
61 | pass
62 |
63 |
64 | class Serializable(Deserializable):
65 | def _traverse(self, child):
66 | if child is None:
67 | pass
68 | elif isinstance(child, Serializable):
69 | return child.to_dict()
70 | elif isinstance(child, dict):
71 | return dict(map(lambda kv: (kv[0], self._traverse(kv[1])), child.items()))
72 | elif isinstance(child, list):
73 | return list(map(lambda el: self._traverse(el), child))
74 | else:
75 | return child
76 |
77 | def to_dict(self):
78 | not_empty = lambda kv: (kv[1] is not None)
79 | return self._traverse(dict(filter(not_empty, self.__dict__.items())))
80 |
81 |
82 | class Profile(Serializable):
83 | def __init__(self, name: str, outputs: dict, match: dict = None, primary: str = None, priority: int = 100):
84 | """
85 | :param name: name of the profile
86 | :param outputs: list of Output objects (i.e. settings to apply for each output)
87 | :param match: dictionary of rules for match section. Keys of the dictionary are outputs names (e.g. "LVDS1"),
88 | values are Rule instances
89 | """
90 | self.name = name
91 | self.outputs = outputs
92 | self.match = match
93 | self.primary = primary
94 | self.priority = priority
95 |
96 | @staticmethod
97 | def from_dict(d: dict):
98 | outputs = d.get('outputs')
99 | match = d.get('match')
100 | return Profile(
101 | name=d.get('name'),
102 | outputs=dict(map(lambda kv: (kv[0], Output.from_dict(kv[1])), outputs.items())) if outputs else None,
103 | match=dict(map(lambda kv: (kv[0], Rule.from_dict(kv[1])), match.items())) if match else None,
104 | primary=d.get('primary'),
105 | priority=d.get('priority')
106 | )
107 |
108 | def __repr__(self):
109 | return str(self.__dict__)
110 |
111 | def __eq__(self, o: object):
112 | return isinstance(o, Profile) and self.__dict__ == o.__dict__
113 |
114 | def __hash__(self):
115 | return hash(self.name)
116 |
117 |
118 | class Rule(Serializable):
119 | """
120 | Rule to match profile to xrandr connections.
121 | Corresponds to a single entry in a match section in profile json.
122 | """
123 |
124 | def __init__(self, edid: str = None, prefers: str = None, supports: str = None):
125 | """
126 | Rule to match against edid, supported mode, preferred mode or any combination of them.
127 | Rule matches anything if nothing is passed
128 | :param edid: edid of a display to match
129 | :param prefers: preferred mode of a display to match
130 | :param supports: supported mode of a display to match
131 | """
132 | self.edid = edid
133 | self.prefers = prefers
134 | self.supports = supports
135 |
136 | @staticmethod
137 | def from_dict(d: dict):
138 | return Rule(**d)
139 |
140 | def __repr__(self):
141 | return str(self.__dict__)
142 |
143 | def __eq__(self, o: object):
144 | return isinstance(o, Rule) and self.__dict__ == o.__dict__
145 |
146 | def __hash__(self):
147 | return hash((self.edid, self.prefers, self.supports))
148 |
149 |
150 | class Output(Serializable):
151 | """
152 | Output in randrctl profile.
153 | """
154 |
155 | def __init__(self, mode: str, pos: str = "0x0", rotate: str = "normal", panning: str = "0x0",
156 | scale: str = "1x1", rate: str = None, crtc: int = None):
157 | self.mode = mode
158 | self.pos = pos
159 | self.rotate = rotate
160 | self.panning = panning
161 | self.scale = scale
162 | self.rate = rate
163 | self.crtc = crtc
164 |
165 | @staticmethod
166 | def from_dict(d: dict):
167 | # old json profiles may contain rate stored as int
168 | # TODO test how PyYaml handles json with numberic value as rate
169 | if d.get('rate'):
170 | d['rate'] = str(d['rate'])
171 | return Output(**d)
172 |
173 | @staticmethod
174 | def fromconnection(connection: XrandrConnection):
175 | return Output(connection.display.mode,
176 | connection.viewport.pos,
177 | connection.viewport.rotate,
178 | connection.viewport.panning,
179 | connection.viewport.scale,
180 | connection.display.rate,
181 | connection.crtc)
182 |
183 | def __repr__(self):
184 | return str(self.__dict__)
185 |
186 | def __eq__(self, o: object):
187 | return isinstance(o, Output) and self.__dict__ == o.__dict__
188 |
189 | def __hash__(self):
190 | return hash(self.mode)
191 |
192 |
--------------------------------------------------------------------------------
/tests/test_profile.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from unittest import TestCase
4 |
5 | from randrctl.model import Profile, Rule, Viewport, Output, XrandrConnection, Display
6 | from randrctl.profile import ProfileManager, ProfileMatcher, hash
7 |
8 |
9 | class ProfileManagerTest(TestCase):
10 | manager = ProfileManager(["."], ".")
11 |
12 | TEST_PROFILE_FILE = os.path.join(os.path.dirname(__file__), 'profile_example')
13 | TEST_SIMPLE_PROFILE_FILE = os.path.join(os.path.dirname(__file__), 'simple_profile_example')
14 |
15 | def test_read(self):
16 | with open(self.TEST_PROFILE_FILE) as f:
17 | p = self.manager.read_file(f)
18 |
19 | self.assertIsNotNone(p)
20 |
21 | expected = {
22 | "LVDS1": Output(mode="1366x768"),
23 | "DP1": Output(mode="1920x1080", pos="1366x0"),
24 | "VGA1": Output(mode="800x600", pos="3286x0", rotate="inverted", panning="800x1080", rate=80)
25 | }
26 | # self.assertDictEqual(expected, p.outputs)
27 | self.assertEqual(expected["LVDS1"], p.outputs["LVDS1"])
28 | self.assertDictEqual(Rule("d8578edf8458ce06fbc5bb76a58c5ca4", "1920x1200", "1920x1080").__dict__,
29 | p.match["DP1"].__dict__)
30 | self.assertDictEqual(Rule().__dict__, p.match["LVDS1"].__dict__)
31 |
32 | def test_simple_read(self):
33 | with open(self.TEST_SIMPLE_PROFILE_FILE) as f:
34 | p = self.manager.read_file(f)
35 |
36 | self.assertIsNotNone(p)
37 | self.assertDictEqual({"LVDS1": Output(mode="1366x768")}, p.outputs)
38 | self.assertIsNone(p.match)
39 |
40 | def test_profile_from_xrandr(self):
41 | xc = [XrandrConnection("LVDS1", Display(), Viewport("1366x768"), False),
42 | XrandrConnection("DP1", Display(), Viewport("1920x1080", pos="1366x0"), True),
43 | XrandrConnection("HDMI1", None, Viewport("1366x768"), False)]
44 |
45 | p = self.manager.profile_from_xrandr(xc)
46 |
47 | self.assertEqual("profile", p.name)
48 | self.assertEqual(2, len(p.outputs))
49 |
50 |
51 | class ProfileMatcherTest(TestCase):
52 | logging.basicConfig()
53 |
54 | matcher = ProfileMatcher()
55 |
56 | def test_should_match_profile_with_empty_rule(self):
57 | # given
58 | expected = profile("should_match", {"LVDS1": Rule()})
59 | profiles = [
60 | profile("different_output_in_rule", {"DP1": Rule(prefers="1920x1080")}),
61 | profile("no_rules"),
62 | expected
63 | ]
64 | outputs = [
65 | XrandrConnection("LVDS1", Display(preferred_mode="1920x1080"))
66 | ]
67 |
68 | # when
69 | best = self.matcher.find_best(profiles, outputs)
70 |
71 | # then
72 | self.assertEqual(expected, best)
73 |
74 | def test_should_not_match_profile_without_rules(self):
75 | # given
76 | profiles = [
77 | profile("no_rules1"),
78 | profile("no_rules2"),
79 | profile("no_rules3")
80 | ]
81 | outputs = [
82 | XrandrConnection("LVDS1", Display(preferred_mode="1920x1080"))
83 | ]
84 |
85 | # when
86 | best = self.matcher.find_best(profiles, outputs)
87 |
88 | # then
89 | self.assertIsNone(best)
90 |
91 | def test_should_prefer_edid_over_mode(self):
92 | # given
93 | edid = "some_edid"
94 | expected = profile("with_edid", {"LVDS1": Rule(hash(edid))})
95 | profiles = [
96 | expected,
97 | profile("with_supported_mode", {"LVDS1": Rule(supports="1920x1080")}),
98 | profile("with_preferred_mode", {"LVDS1": Rule(prefers="1920x1080")})
99 | ]
100 | outputs = [
101 | XrandrConnection("LVDS1", Display(["1920x1080"], "1920x1080", edid=edid))
102 | ]
103 |
104 | # when
105 | best = self.matcher.find_best(profiles, outputs)
106 |
107 | # then
108 | self.assertEqual(expected, best)
109 |
110 | def test_should_prefer_rule_prefers_over_supports(self):
111 | # given
112 | expected = profile("with_prefers", {"LVDS1": Rule(prefers="1920x1080")})
113 | profiles = [
114 | expected,
115 | profile("with_supports", {"LVDS1": Rule(supports="1920x1080")})
116 | ]
117 | outputs = [
118 | XrandrConnection("LVDS1", Display(["1920x1080"], "1920x1080"))
119 | ]
120 |
121 | # when
122 | best = self.matcher.find_best(profiles, outputs)
123 |
124 | # then
125 | self.assertEqual(expected, best)
126 |
127 | # TODO use-case of this is frankly not clear. We can set priority by file name. Clarify
128 | def test_should_pick_profile_with_higher_prio_if_same_score(self):
129 | # given
130 | expected = profile("highprio", {"LVDS1": Rule()}, prio=999)
131 | profiles = [
132 | profile("default", {"LVDS1": Rule()}),
133 | expected
134 | ]
135 | outputs = [
136 | XrandrConnection("LVDS1", Display()),
137 | ]
138 |
139 | # when
140 | best = self.matcher.find_best(profiles, outputs)
141 |
142 | # then
143 | self.assertEqual(expected, best)
144 |
145 | def test_should_pick_first_profile_if_same_score(self):
146 | # given
147 | edid = "office"
148 | edidhash = hash(edid)
149 | profiles = [
150 | profile("p1", {"LVDS1": Rule(), "DP1": Rule(edidhash)}),
151 | profile("p2", {"LVDS1": Rule(), "DP1": Rule(edidhash)})
152 | ]
153 | outputs = [
154 | XrandrConnection("LVDS1", Display()),
155 | XrandrConnection("DP1", Display(["1920x1080"], edid=edid))
156 | ]
157 |
158 | # when
159 | best = self.matcher.find_best(profiles, outputs)
160 |
161 | # then
162 | self.assertEqual(profiles[0], best)
163 |
164 | def test_should_match_profiles_and_list_descending(self):
165 | # given
166 | edid = "office"
167 | edidhash = hash(edid)
168 | profiles = [
169 | profile("match4", {"LVDS1": Rule(), "DP1": Rule()}),
170 | profile("match1", {"LVDS1": Rule(), "DP1": Rule(edidhash)}),
171 | profile("match3", {"LVDS1": Rule(), "DP1": Rule(supports="1920x1080")}),
172 | profile("match2", {"LVDS1": Rule(), "DP1": Rule(prefers="1920x1080")}),
173 | profile("match5", {"LVDS1": Rule()}),
174 | profile("missing_output", {"LVDS1": Rule(), "DP1": Rule(), "HDMI1": Rule()}),
175 | profile("no_rules")
176 | ]
177 | outputs = [
178 | XrandrConnection("LVDS1", Display()),
179 | XrandrConnection("DP1", Display(["1920x1080"], "1920x1080", edid=edid))
180 | ]
181 |
182 | # when
183 | matches = self.matcher.match(profiles, outputs)
184 |
185 | # then
186 | self.assertEqual(5, len(matches))
187 | self.assertEqual("match1", matches[0][1].name)
188 | self.assertEqual("match2", matches[1][1].name)
189 | self.assertEqual("match3", matches[2][1].name)
190 | self.assertEqual("match4", matches[3][1].name)
191 | self.assertEqual("match5", matches[4][1].name)
192 |
193 |
194 | def profile(name: str, match: dict = None, prio: int = 100):
195 | # we do not care about actual outputs in these tests, only rules matters
196 | return Profile(name, {}, match, priority=prio)
197 |
198 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/koiuo/randrctl/actions/workflows/ci.yml)
2 |
3 | # randrctl
4 |
5 | Screen profiles manager for X.org.
6 |
7 | _randrctl_ remembers your X.org screen configurations (position of displays, rotation, scaling, etc.) and switches
8 | between them automatically as displays are connected or manually, when necessary:
9 | ```
10 | randrctl switch-to home
11 | randrctl switch-to office
12 | ```
13 |
14 | ## Install
15 |
16 | _randrctl_ depends on `xrandr` utility and won't work without it. Please install it first.
17 |
18 | ### Archlinux
19 |
20 | https://aur.archlinux.org/packages/randrctl-git/
21 | https://aur.archlinux.org/packages/randrctl/
22 |
23 | ```
24 | $ randrctl setup config > ${XDG_CONFIG_HOME:-$HOME/.config}/randrctl/config.yaml
25 | ```
26 |
27 | ### PyPi
28 |
29 | ```
30 | # pip install randrctl
31 |
32 | # randrctl setup udev > /etc/udev/rules.d/99-randrctl.rules
33 | # randrctl setup completion > /usr/share/bash-completion/completions/randrctl
34 |
35 | $ randrctl setup config > ${XDG_CONFIG_HOME:-$HOME/.config}/randrctl/config.yaml
36 | ```
37 |
38 | ### Manually from sources
39 |
40 | ```
41 | $ git clone https://github.com/edio/randrctl.git
42 | $ cd randrctl
43 |
44 | # python setup.py install
45 |
46 | # randrctl setup udev > /etc/udev/rules.d/99-randrctl.rules
47 | # randrctl setup completion > /usr/share/bash-completion/completions/randrctl
48 |
49 | $ randrctl setup config > ${XDG_CONFIG_HOME:-$HOME/.config}/randrctl/config.yaml
50 | ```
51 |
52 | ## Usage
53 |
54 | Usage is very simple:
55 |
56 | 0. Setup your screen to suit your needs (randrctl does not handle that)
57 |
58 | 1. Dump settings with randrctl to a named profile
59 |
60 | ```randrctl dump -e home```
61 |
62 | 2. Re-apply those settings, whenever you need them
63 |
64 | ```randrctl switch-to home```
65 |
66 | 3. ... or let randrctl to inspect currently connected displays and choose profile that fits them best
67 |
68 | ```randrctl auto```
69 |
70 | Auto-switching will also happen automatically if provided udev rules are installed to the system.
71 |
72 | 4. For more info on usage refer to help
73 |
74 | ```randrctl --help```
75 |
76 | ### Auto-switching
77 |
78 | ```randrctl``` can associate profile with currently connected displays and switch to this profile automatically whenever
79 | same (or similar) set of displays is connected.
80 |
81 | Profile is matched to the set of connected displays by evaluating one or more of the following rules for every connected
82 | display:
83 |
84 | * list of supported modes of connected display includes the current mode
85 |
86 | ```randrctl dump -m profile1```
87 |
88 | You can use this to create profile that is activated whenever connected display supports the mode that is currently
89 | set for that output.
90 |
91 | * preferred mode of connected display is the current mode
92 |
93 | ```randrctl dump -p profile2```
94 |
95 | Display can support wide range of modes from 640x480 to 1920x1200, but prefer only one of those. When dumped this way,
96 | profile is considered a match if connected display prefers the mode, that is currently set for it.
97 |
98 | * unique identifier of connected display is exactly tha same
99 |
100 | ```randrctl dump -e profile3```
101 |
102 | Unique identifier (edid) of every display is dumped with the profile, so it matches, only if exactly same displays
103 | are connected.
104 |
105 | Naturally, the more specific the rule, the bigger weight it has, so in case if you invoked those 3 dump commands above
106 | with the same displays connected, `profile3` will be chosen as the best (i.e. the most specific) match.
107 |
108 | It is possible to specify any combination of `-m -p -e` keys to dump command. In this case randrctl will try to match
109 | all the rules combining them with logical AND (for example, display must support and at the same time prefer the mode).
110 | Although such combination of rules might seem redundant (because if the more specific rule matches, the more generic
111 | will do too), it might have sense if rule is edited manually.
112 |
113 | If `randrctl dump` is invoked without additional options, it dumps only screen setup, so profile won't be considered
114 | during auto-switching.
115 |
116 |
117 | ### Prior/Post hooks
118 |
119 | randrctl can execute custom commands (hooks) before and after switching to profile or if switching fails. Hooks are
120 | specified in config file `$XDG_CONFIG_HOME/randrctl/config.yaml`
121 |
122 | ```
123 | hooks:
124 | prior_switch: /usr/bin/killall -SIGSTOP i3
125 | post_switch: /usr/bin/killall -SIGCONT i3 && /usr/bin/notify-send -u low "randrctl" "switched to $randr_profile"
126 | post_fail: /usr/bin/killall -SIGCONT i3 && /usr/bin/notify-send -u critical "randrctl error" "$randr_error"
127 | ```
128 |
129 | The typical use-case of this is displaying desktop notification with libnotify.
130 |
131 | I also use it to pause i3 window manager as it was known to crash sometimes during the switch.
132 |
133 |
134 | ### Profile format
135 |
136 | Profile is a simple text file in YAML format. It can be edited manually, however it is rarely required in practice
137 | because `randrctl dump` handles most common cases.
138 |
139 | ```
140 | match:
141 | LVDS1: {}
142 | DP1:
143 | prefers: 1920x1080
144 | outputs:
145 | LVDS1:
146 | mode: 1366x768
147 | panning: 1366x1080
148 | DP1:
149 | mode: 1920x1080
150 | pos: 1366x0
151 | rotate: inverted
152 | primary: DP1
153 | ```
154 |
155 | Profile is required to contain 2 sections (`outputs` and `primary`). That is what dumped when `randrctl dump` is invoked
156 | without additional options.
157 |
158 | The `match` section is optional and is dumped only when one of the auto-switching rules is specified.
159 |
160 |
161 | #### Outputs
162 |
163 | Each property of `outputs` section references output as seen in xrandr (i.e. *DP1*, *HDMI2*, etc.). Meaning of the
164 | properties is the same as in the xrandr utility.
165 |
166 | `mode` is mandatory, the others may be omitted.
167 |
168 | ```
169 | DP1-2:
170 | mode: 1920x1200
171 | panning: 2496x1560+1920+0
172 | pos: 1920x0
173 | rate: 60
174 | rotate: normal
175 | scale: 1.3x1.3
176 | ```
177 |
178 |
179 | #### Primary
180 |
181 | Name of the primary output as seen in xrandr.
182 |
183 | ```
184 | primary: eDP1
185 | ```
186 |
187 | #### Match
188 |
189 | Set of rules for auto-switching.
190 |
191 | The minimum rule is
192 |
193 | ```
194 | HDMI1: {}
195 | ```
196 |
197 | which means, that something must be connected to that output.
198 |
199 | Rule corresponding to `randrctl dump -m` would be
200 |
201 | ```
202 | HDMI1:
203 | supports: 1920x1080
204 | ```
205 |
206 | `randrctl dump -p` is
207 |
208 | ```
209 | HDMI1:
210 | prefers: 1920x1080
211 | ```
212 |
213 | and `randrctl dump -e` is
214 |
215 | ```
216 | HDMI1:
217 | edid: efdbca373951c898c5775e1c9d26c77f
218 | ```
219 |
220 | `edid` is md5 hash of actual display's `edid`. To obtain that value, use `randrctl show`.
221 |
222 | As was mentioned, `prefers`, `supports` and `edid` can be combined in the same rule, so it is possible to manually
223 | create a more sophisticated rule
224 |
225 | ```
226 | match:
227 | LVDS1: {}
228 | HDMI1:
229 | prefers: 1600x1200
230 | supports: 800x600
231 | outputs:
232 | LVDS1:
233 | ...
234 | HDMI1:
235 | ...
236 | ```
237 |
238 | #### Priority
239 |
240 | When more than one profile matches current output configuration priority can be used to highlight preferred profile.
241 | ```
242 | priority: 100
243 | match:
244 | ...
245 | outputs:
246 | ...
247 | ```
248 | Default priority is `100`. To set profile priority use `-P ` with `dump` command. Like this:
249 | `randrctl dump -e default -P 50`
250 |
251 | ## Develop
252 |
253 | ### Run tests
254 |
255 | ```
256 | $ python setup.py test
257 | ```
258 |
259 |
--------------------------------------------------------------------------------
/randrctl/profile.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import logging
3 | import os
4 | from typing import List, Optional, Tuple
5 | import yaml
6 |
7 | from randrctl.exception import InvalidProfileException, NoSuchProfileException
8 | from randrctl.model import Profile, Rule, Output, XrandrConnection
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 |
13 | def hash(string: str):
14 | if string:
15 | return hashlib.md5(string.encode()).hexdigest()
16 | else:
17 | return None
18 |
19 |
20 | class ProfileManager:
21 | def __init__(self, read_locations: list, write_location: str):
22 | self.read_locations = list(filter(lambda location: os.path.isdir(location), read_locations))
23 | self.write_location = write_location
24 |
25 | def read_all(self) -> List[Profile]:
26 | profiles: List[Profile] = []
27 | for profile_dir in self.read_locations:
28 | for entry in os.listdir(profile_dir):
29 | path = os.path.join(profile_dir, entry)
30 | if os.path.isfile(path):
31 | try:
32 | with open(path) as profile_file:
33 | profiles.append(self.read_file(profile_file))
34 | except InvalidProfileException as e:
35 | logger.warning(e)
36 | return profiles
37 |
38 | def read_one(self, profile_name: str):
39 | # TODO handle missing profile
40 | profile = None
41 | for profile_dir in self.read_locations:
42 | profile_path = os.path.join(profile_dir, profile_name)
43 | if not os.path.isfile(profile_path):
44 | continue
45 | with open(profile_path) as profile_file:
46 | profile = self.read_file(profile_file)
47 | break
48 |
49 | if profile:
50 | return profile
51 | else:
52 | raise NoSuchProfileException(profile_name, self.read_locations)
53 |
54 | def read_file(self, profile_file_descriptor) -> Profile:
55 | try:
56 | result = yaml.load(profile_file_descriptor, Loader=yaml.FullLoader)
57 |
58 | rules = result.get('match')
59 | priority = int(result.get('priority', 100))
60 |
61 | if rules:
62 | for k, v in rules.items():
63 | # backward compatibility for match.mode
64 | if v.get('mode'):
65 | logger.warning("%s\n\tmatch.mode is deprecated"
66 | "\n\tConsider changing to 'supports' or 'prefers'", profile_file_descriptor.name)
67 | v['supports'] = v['mode']
68 | del v['mode']
69 | rules[k] = Rule(**v)
70 |
71 | primary = result.get('primary')
72 | outputs_raw = result['outputs']
73 | outputs = {}
74 | for name, mode_raw in outputs_raw.items():
75 | outputs[name] = Output(**mode_raw)
76 |
77 | name = os.path.basename(profile_file_descriptor.name)
78 |
79 | return Profile(name, outputs, rules, primary, priority)
80 | except (KeyError, ValueError):
81 | raise InvalidProfileException(profile_file_descriptor.name)
82 |
83 | def write(self, p: Profile, yaml_flow_style: bool=False):
84 | """
85 | Write profile to file into configured profile directory.
86 | Profile name becomes the name of the file. If name contains illegal characters, only safe part is used.
87 | For example, if name is my_home_vga/../../passwd, then file will be written as passwd under profile dir
88 | """
89 | os.makedirs(self.write_location, exist_ok=True)
90 | dict = p.to_dict()
91 | safename = os.path.basename(p.name)
92 | fullname = os.path.join(self.write_location, safename)
93 | if safename != p.name:
94 | logger.warning("Illegal name provided. Writing as %s", fullname)
95 | with open(fullname, 'w+') as fp:
96 | yaml.dump(dict, fp, default_flow_style=yaml_flow_style)
97 |
98 | def print(self, p: Profile, yaml_flow_style: bool=False):
99 | print(yaml.dump(p.to_dict(), default_flow_style=yaml_flow_style))
100 |
101 | def profile_from_xrandr(self, xrandr_connections: list, profile_name: str='profile'):
102 | outputs = {}
103 | rules = {}
104 | primary = None
105 | for connection in xrandr_connections:
106 | output_name = connection.name
107 | display = connection.display
108 | if not display or not connection.is_active():
109 | continue
110 | output = Output.fromconnection(connection)
111 | if connection.primary:
112 | primary = output_name
113 | outputs[output_name] = output
114 | rule = Rule(hash(display.edid), display.preferred_mode, display.mode)
115 | rules[output_name] = rule
116 |
117 | logger.debug("Extracted %d outputs from %d xrandr connections", len(outputs), len(xrandr_connections))
118 |
119 | return Profile(profile_name, outputs, rules, primary)
120 |
121 |
122 | class ProfileMatcher:
123 | """
124 | Matches profile to xrandr connections
125 | """
126 | def match(self, available_profiles: List[Profile], xrandr_outputs: List[XrandrConnection]) -> List[Tuple[int, Profile]]:
127 | """
128 | return a sorted list of matched profiles
129 | """
130 | output_names = set(map(lambda o: o.name, xrandr_outputs))
131 |
132 | # remove those with disconnected outputs
133 | with_rules = filter(lambda p: p.match and len(p.match) > 0, available_profiles)
134 | with_rules_covering_outputs = filter(lambda p: len(set(p.match) - output_names) == 0, with_rules)
135 | profiles = list(with_rules_covering_outputs)
136 |
137 | logger.debug("%d/%d profiles match outputs sets", len(profiles), len(available_profiles))
138 |
139 | matching: List[Tuple[int, Profile]] = []
140 | for p in profiles:
141 | score = self._calculate_profile_score(p, xrandr_outputs)
142 | if score >= 0:
143 | matching.append((score, p))
144 | return sorted(matching, key=lambda x: (x[0], x[1].priority), reverse=True)
145 |
146 | def find_best(self, available_profiles: List[Profile], xrandr_outputs: List[XrandrConnection]) -> Optional[Profile]:
147 | """
148 | Find first matching profile across availableProfiles for actualConnections
149 | """
150 | matching = self.match(available_profiles, xrandr_outputs)
151 |
152 | if not matching:
153 | return None
154 |
155 | max_score, p = matching[0]
156 | logger.debug("Found %d profiles with maximum score %d", len(matching), max_score)
157 | logger.debug("Selected profile %s with score %d and priority %d", p.name, max_score, p.priority)
158 | return p
159 |
160 | def _calculate_profile_score(self, p: Profile, xrandr_outputs: list):
161 | """
162 | Calculate how profile matches passed specific outputs.
163 | Return numeric score
164 | """
165 | score = 0
166 | logger.debug("Trying profile %s", p.name)
167 | for o in xrandr_outputs:
168 | rule = p.match.get(o.name)
169 | s = self._score_rule(rule, o) if rule is not None else 0
170 | logger.debug("%s scored %d for output %s", p.name, s, o.name)
171 | if s >= 0:
172 | score += s
173 | else:
174 | logger.debug("%s doesn't match %s", p.name, o.name)
175 | score = -1
176 | break
177 | logger.debug("%s total score: %d", p.name, score)
178 | return score
179 |
180 | def _score_rule(self, rule: Rule, xrandr_output: XrandrConnection):
181 | """
182 | Starting rule score is 0 (a rule without any additional criteria for a connection still triggers auto-matching).
183 | Criteria, if defined, are checked and resulting rule score increases with every matched criterion.
184 | If any of the defined criteria fails to match, -1 is immediately returned.
185 | """
186 | score = 0
187 | if rule.edid:
188 | if rule.edid == hash(xrandr_output.display.edid):
189 | score += 3
190 | else:
191 | return -1
192 |
193 | if rule.prefers:
194 | if xrandr_output.display.preferred_mode == rule.prefers:
195 | score += 2
196 | else:
197 | return -1
198 |
199 | if rule.supports:
200 | if xrandr_output.display.supported_modes.count(rule.supports) > 0:
201 | score += 1
202 | else:
203 | return -1
204 | return score
205 |
--------------------------------------------------------------------------------
/randrctl/xrandr.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from functools import reduce, lru_cache
4 | import logging
5 | import re
6 | import subprocess
7 | from typing import List, Optional
8 |
9 | from randrctl import DISPLAY, XAUTHORITY
10 | from randrctl.exception import XrandrException, ParseException
11 | from randrctl.model import Profile, Viewport, XrandrConnection, Display
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 |
16 | class Xrandr:
17 | """
18 | Interface for xrandr application. Provides methods for calling xrandr operating with python objects such as
19 | randrctl.profile.Profile
20 | """
21 | OUTPUT_KEY = "--output"
22 | MODE_KEY = "--mode"
23 | POS_KEY = "--pos"
24 | ROTATE_KEY = "--rotate"
25 | PANNING_KEY = "--panning"
26 | RATE_KEY = "--rate"
27 | SCALE_KEY = "--scale"
28 | PRIMARY_KEY = "--primary"
29 | CRTC_KEY = "--crtc"
30 | QUERY_KEY = "-q"
31 | VERBOSE_KEY = "--verbose"
32 | OFF_KEY = "--off"
33 | OUTPUT_DETAILS_REGEX = re.compile(
34 | r'(?Pprimary )?(?P[\dx\+]+) (?:(?P\w+) )?.*?(?:panning (?P[\dx\+]+))?$')
35 | MODE_REGEX = re.compile(r"(\d+x\d+)\+(\d+\+\d+)")
36 | CURRENT_MODE_REGEX = re.compile(r"\s*(\S+)\s+([0-9\.]+)(.*$)")
37 |
38 | def __init__(self, display: Optional[str], xauthority: Optional[str]):
39 | env = dict(os.environ)
40 | if display:
41 | env[DISPLAY] = display
42 | if xauthority:
43 | env[XAUTHORITY] = xauthority
44 | self.env = env
45 |
46 | def apply(self, profile: Profile):
47 | """
48 | Apply given profile by calling xrandr
49 | """
50 | logger.debug("Applying profile %s", profile.name)
51 |
52 | args = self._compose_mode_args(profile, self.get_all_outputs())
53 | self._xrandr(*args)
54 |
55 | @lru_cache()
56 | def _xrandr(self, *args):
57 | """
58 | Perform call to xrandr executable with passed arguments.
59 | Returns subprocess.Popen object
60 | """
61 | args = list(args)
62 | logger.debug("Calling xrandr with args %s", args)
63 | args.insert(0, "xrandr")
64 |
65 | p = subprocess.run(args, capture_output=True, shell=False, env=self.env)
66 | err = p.stderr
67 | if err:
68 | err_str = err.decode()
69 | raise XrandrException(err_str, args)
70 | out = list(map(lambda x: x.decode(), p.stdout.splitlines()))
71 | if out:
72 | out.pop(0) # remove first line. It describes Screen
73 | return out
74 |
75 | def _compose_mode_args(self, profile: Profile, xrandr_connections: list):
76 | """
77 | Composes list of arguments to xrandr to apply profile settings and disable the other outputs
78 | """
79 | args = []
80 | active_names = []
81 |
82 | for name, o in profile.outputs.items():
83 | active_names.append(name)
84 | args.append(self.OUTPUT_KEY)
85 | args.append(name)
86 | args.append(self.MODE_KEY)
87 | args.append(o.mode)
88 | args.append(self.POS_KEY)
89 | args.append(o.pos)
90 | args.append(self.ROTATE_KEY)
91 | args.append(o.rotate)
92 | args.append(self.PANNING_KEY)
93 | args.append(o.panning)
94 | args.append(self.SCALE_KEY)
95 | args.append(o.scale)
96 | if o.rate:
97 | args.append(self.RATE_KEY)
98 | args.append(str(o.rate))
99 | if name == profile.primary:
100 | args.append(self.PRIMARY_KEY)
101 | if o.crtc is not None:
102 | args.append(self.CRTC_KEY)
103 | args.append(str(o.crtc))
104 |
105 | # turn off the others
106 | for c in xrandr_connections:
107 | if active_names.count(c.name) == 0:
108 | args.append(self.OUTPUT_KEY)
109 | args.append(c.name)
110 | args.append(self.OFF_KEY)
111 |
112 | return args
113 |
114 | def get_all_outputs(self) -> List[XrandrConnection]:
115 | """
116 | Query xrandr for all supported outputs.
117 | Performs call to xrandr with -q key and parses output.
118 | Returns list of outputs with some properties missing (only name and status are guaranteed)
119 | """
120 | outputs = []
121 |
122 | items = self._xrandr(self.QUERY_KEY)
123 | items = self._group_query_result(items)
124 | logger.debug("Detected total %d outputs", len(items))
125 | crtcs = self._get_verbose_fields('CRTC')
126 |
127 | for i in items:
128 | o = self._parse_xrandr_connection(i)
129 | o.crtc = int(crtcs[o.name]) if o.name in crtcs and len(crtcs[o.name]) else None
130 | outputs.append(o)
131 |
132 | return outputs
133 |
134 | def get_connected_outputs(self) -> List[XrandrConnection]:
135 | """
136 | Query xrandr and return list of connected outputs.
137 | Performs call to xrandr with -q and --verbose keys.
138 | Returns list of connected outputs with all properties set
139 | """
140 | outputs = list(filter(lambda o: o.display is not None, self.get_all_outputs()))
141 | edids = self._get_verbose_fields('EDID')
142 | for o in outputs:
143 | o.display.edid = edids[o.name]
144 | if logger.isEnabledFor(logging.DEBUG):
145 | logger.debug("Connected outputs: %s", list(map(lambda o: o.name, outputs)))
146 | return outputs
147 |
148 | def _get_verbose_fields(self, field: str) -> dict:
149 | """
150 | Get particular field of all connected displays.
151 | Return dictionary of {"connection_name": field_value}
152 | """
153 | ret = dict()
154 |
155 | items = self._xrandr(self.QUERY_KEY, self.VERBOSE_KEY)
156 | items = self._group_query_result(items)
157 | items = filter(lambda x: x[0].find(' connected') > 0, items)
158 |
159 | for i in items:
160 | name_idx = i[0].find(' ')
161 | name = i[0][:name_idx]
162 | ret[name] = self._field_from_query_item(i, field)
163 |
164 | return ret
165 |
166 | def _field_from_query_item(self, item_lines: list, field: str) -> str:
167 | """
168 | Extracts display field from xrandr --verbose output
169 | """
170 | val = ''
171 | indent = ''
172 | in_field = False
173 | lines_collected = 0
174 | for i, line in enumerate(item_lines):
175 | m = re.match(r'(\s+)(.*):\s*(.*)$', line)
176 | if m and m.group(2).lower() == field.lower():
177 | indent = m.group(1)
178 | in_field = True
179 | val = m.group(3).strip()
180 | elif in_field and m and (len(indent) >= len(m.group(1)) or m.group(1) == indent):
181 | return val
182 | elif in_field and not line.startswith(indent):
183 | return val
184 | elif in_field:
185 | val += line.strip()
186 | lines_collected += 1
187 | if field == 'EDID' and lines_collected >= 8:
188 | return val
189 |
190 | return val
191 |
192 | def _parse_xrandr_connection(self, item_lines: list):
193 | """
194 | Creates XrandrConnection from lines returned by xrandr --query.
195 | Example:
196 | LVDS1 connected primary 1366x768+0+312 (normal left inverted right x axis y axis) 277mm x 156mm
197 | 1366x768 60.02*+
198 | 1024x768 60.00
199 | """
200 | connection_info = item_lines[0]
201 |
202 | name, status, state = connection_info.split(' ', 2)
203 |
204 | if status != 'connected':
205 | # We are not connected, do not parse the rest.
206 | return XrandrConnection(name)
207 |
208 | # We are connected parse connected display.
209 | display = self._parse_display(item_lines[1:])
210 |
211 | if not display.is_on():
212 | # inactive output
213 | return XrandrConnection(name, display)
214 |
215 | parsed = self.OUTPUT_DETAILS_REGEX.match(state)
216 | if parsed is None:
217 | raise ParseException(name, status, state)
218 |
219 | primary = parsed.group('primary') is not None
220 | rotate = parsed.group('rotate')
221 | panning = parsed.group('panning')
222 | geometry = parsed.group('geometry')
223 | size, pos = self._parse_geometry(geometry)
224 |
225 | is_rotated = rotate in ['left', 'right']
226 | if is_rotated:
227 | size = 'x'.join(size.split('x')[::-1])
228 |
229 | scale = '1x1'
230 | if size != display.mode:
231 | dw, dh = map(lambda s: int(s), display.mode.split('x'))
232 | vw, vh = map(lambda s: int(s), size.split('x'))
233 | sw, sh = vw / dw, vh / dh
234 | if is_rotated:
235 | sw, sh = sh, sw
236 | scale = "{}x{}".format(sw, sh)
237 |
238 | viewport = Viewport(size, pos, rotate, panning, scale)
239 |
240 | return XrandrConnection(name, display, viewport, primary)
241 |
242 | def _parse_display(self, lines: list):
243 | supported_modes = []
244 | preferred_mode = None
245 | current_mode = None
246 | current_rate = None
247 | for mode_line in lines:
248 | mode_line = mode_line.strip()
249 | (mode, rate, extra) = self.CURRENT_MODE_REGEX.match(mode_line).groups()
250 | current = (extra.find("*") >= 0)
251 | preferred = (extra.find("+") >= 0)
252 | supported_modes.append(mode)
253 | if current:
254 | current_mode = mode
255 | current_rate = rate
256 | if preferred:
257 | preferred_mode = mode
258 |
259 | return Display(supported_modes, preferred_mode, current_mode, current_rate)
260 |
261 | def _group_query_result(self, query_result: list):
262 | """
263 | Group input list of lines such that every line starting with a non-whitespace character is a start of a
264 | group, and every subsequent line starting with whitespace is a member of that group.
265 | :param query_result: list of lines
266 | :return: list of lists of lines
267 | """
268 |
269 | def group_fn(result, line):
270 | # We append
271 | if type(result) is str:
272 | if line.startswith(' ') or line.startswith('\t'):
273 | return [[result, line]]
274 | else:
275 | return [[result], [line]]
276 | else:
277 | if line.startswith(' ') or line.startswith('\t'):
278 | last = result[len(result) - 1]
279 | last.append(line)
280 | return result
281 | else:
282 | result.append([line])
283 | return result
284 |
285 | # TODO rewrite in imperative code
286 | grouped = reduce(lambda result, line: group_fn(result, line), query_result)
287 |
288 | return grouped
289 |
290 | def _parse_geometry(self, s: str):
291 | """
292 | Parses geometry string (i.e. 1111x2222+333+444) into tuple (widthxheight, leftxtop)
293 | """
294 | match = self.MODE_REGEX.match(s)
295 | mode = match.group(1)
296 | pos = match.group(2).replace('+', 'x')
297 | return mode, pos
298 |
--------------------------------------------------------------------------------
/randrctl/cli.py:
--------------------------------------------------------------------------------
1 | import pwd
2 | import sys
3 | from os import path
4 |
5 | import argcomplete
6 | import argparse
7 | import glob
8 | import logging
9 | import os
10 | import pkg_resources
11 | import re
12 | import shutil
13 | import subprocess
14 | import textwrap
15 |
16 | from randrctl import context, XAUTHORITY, DISPLAY
17 | from randrctl.ctl import RandrCtl
18 | from randrctl.exception import RandrCtlException
19 |
20 | AUTO = 'auto'
21 | DUMP = 'dump'
22 | LIST = 'list'
23 | SHOW = 'show'
24 | SWITCH_TO = 'switch-to'
25 | VERSION = 'version'
26 |
27 | SETUP = 'setup'
28 | SETUP_COMPLETION = 'completion'
29 | SETUP_UDEV = 'udev'
30 | SETUP_CONFIG = 'config'
31 |
32 |
33 | logger = logging.getLogger('randrctl')
34 |
35 |
36 | # CLI parser
37 |
38 |
39 | def potential_profiles(config_dirs: list):
40 | profile_dirs = map(lambda config_dir: os.path.join(config_dir, context.PROFILE_DIR_NAME), config_dirs)
41 | existing = filter(lambda profile_dir: os.path.isdir(profile_dir), profile_dirs)
42 | listings = map(lambda profile_dir: os.listdir(profile_dir), existing)
43 | flat_listing = [item for sublist in listings for item in sublist]
44 | return sorted(flat_listing)
45 |
46 |
47 | def complete_profiles(prefix, parsed_args, **kwargs):
48 | return (profile for profile in potential_profiles(context.default_config_dirs()) if profile.startswith(prefix))
49 |
50 |
51 | def args_parser():
52 | parser = argparse.ArgumentParser(prog='randrctl')
53 |
54 | parser.add_argument('-d', help='allow X display detection', default=False, action='store_const', const=True,
55 | dest='detect_display')
56 |
57 | parser.add_argument('-x', help='be verbose', default=False, action='store_const', const=True,
58 | dest='debug')
59 |
60 | parser.add_argument('-X', help='be even more verbose', default=False, action='store_const', const=True,
61 | dest='extended_debug')
62 |
63 | commands_parsers = parser.add_subparsers(title='Available commands',
64 | description='use "command -h" for details',
65 | dest='command')
66 |
67 | # switch-to
68 | command_switch_to = commands_parsers.add_parser(SWITCH_TO, help='switch to profile')
69 | command_switch_to.add_argument('profile_name',
70 | help='name of the profile to switch to').completer = complete_profiles
71 |
72 | # show
73 | command_show = commands_parsers.add_parser(SHOW, help='show profile')
74 | command_show.add_argument('-j', '--json', action='store_const', const=True, default=False,
75 | help='use JSON-compatible format', dest='json')
76 | command_show.add_argument('profile_name', help='name of the profile to show. Show current setup if omitted',
77 | default=None, nargs='?').completer = complete_profiles
78 |
79 | # list
80 | command_list = commands_parsers.add_parser(LIST, help='list available profiles')
81 | group = command_list.add_mutually_exclusive_group()
82 | group.add_argument('-l', action='store_const', const=True, default=False,
83 | help='long listing', dest='long_listing')
84 | group.add_argument('-s', action='store_const', const=True, default=False,
85 | help='scored listing', dest='scored_listing')
86 |
87 | # dump
88 | command_dump = commands_parsers.add_parser(DUMP,
89 | help='dump current screen setup')
90 | command_dump.add_argument('-m', action='store_const', const=True, default=False,
91 | help='dump with match by supported mode', dest='match_supports')
92 | command_dump.add_argument('-p', action='store_const', const=True, default=False,
93 | help='dump with match by preferred mode', dest='match_preferred')
94 | command_dump.add_argument('-e', action='store_const', const=True, default=False,
95 | help='dump with match by edid', dest='match_edid')
96 | command_dump.add_argument('-P', action='store', type=int, default=100, dest='priority',
97 | help='profile priority')
98 | command_dump.add_argument('-j', '--json', action='store_const', const=True, default=False,
99 | help='use JSON-compatible format', dest='json')
100 | command_dump.add_argument('profile_name', help='name of the profile to dump setup to').completer = complete_profiles
101 |
102 | # auto
103 | command_auto = commands_parsers.add_parser(AUTO,
104 | help='automatically switch to the best matching profile')
105 |
106 | # version
107 | command_version = commands_parsers.add_parser(VERSION, help='print version information and exit')
108 |
109 | # setup
110 | command_setup = commands_parsers.add_parser(SETUP, help='perform various setup tasks')
111 | command_setup_tasks = command_setup.add_subparsers(title='Setup tasks',
112 | help='use "task -h" for details',
113 | dest='task')
114 | command_setup_tasks.add_parser(
115 | SETUP_UDEV,
116 | usage="randrctl setup udev > /etc/udev/rules.d/99-randrctl.rules && udevadm control --reload-rules ",
117 | help="setup udev rule required for auto-switching",
118 | description='udev rule is required to notify randrctl about displays being attached or detached, so it can'
119 | ' react by applying appropriate profile.'
120 | )
121 | command_setup_tasks.add_parser(
122 | SETUP_COMPLETION, formatter_class=argparse.RawDescriptionHelpFormatter,
123 | help='setup bash completion',
124 | usage='randrctl setup completion > /usr/share/bash-completion/completions/randrctl',
125 | description=textwrap.dedent('''\
126 | or:
127 | randrctl setup completion > ~/.bashrc_randrctl
128 | echo "source ~/.bashrc_randrctl" >> ~/.bashrc
129 | ''')
130 | )
131 | command_setup_tasks.add_parser(
132 | SETUP_CONFIG,
133 | help="create exemplary config.yaml",
134 | usage="randrctl setup config > ${XDG_CONFIG_HOME:-$HOME/.config}/randrctl/config.yaml",
135 | )
136 |
137 | argcomplete.autocomplete(parser)
138 |
139 | return parser
140 |
141 |
142 | # Commands
143 |
144 |
145 | def cmd_list(randrctl: RandrCtl, args: argparse.Namespace):
146 | if args.long_listing:
147 | randrctl.list_all_long()
148 | elif args.scored_listing:
149 | randrctl.list_all_scored()
150 | else:
151 | randrctl.list_all()
152 | return 0
153 |
154 |
155 | def cmd_switch_to(randrctl: RandrCtl, args: argparse.Namespace):
156 | randrctl.switch_to(args.profile_name)
157 | return 0
158 |
159 |
160 | def cmd_show(randrctl: RandrCtl, args: argparse.Namespace):
161 | if args.profile_name:
162 | randrctl.print(args.profile_name, json_compatible=args.json)
163 | else:
164 | randrctl.dump_current('current', json_compatible=args.json)
165 | return 0
166 |
167 |
168 | def cmd_dump(randrctl: RandrCtl, args: argparse.Namespace):
169 | randrctl.dump_current(name=args.profile_name, to_file=True,
170 | include_supports_rule=args.match_supports,
171 | include_preferred_rule=args.match_preferred,
172 | include_edid_rule=args.match_edid,
173 | # TODO is this a bug?
174 | # edid defines rate
175 | include_refresh_rate=args.match_edid,
176 | priority=args.priority,
177 | json_compatible=args.json)
178 | return 0
179 |
180 |
181 | def cmd_auto(randrctl: RandrCtl, args: argparse.Namespace):
182 | randrctl.switch_auto()
183 | return 0
184 |
185 |
186 | def cmd_version(randrctl: RandrCtl, args: argparse.Namespace):
187 | print(pkg_resources.get_distribution("randrctl").version)
188 | return 0
189 |
190 |
191 | def cmd_setup(randrctl: RandrCtl, args: argparse.Namespace):
192 | if args.task is None:
193 | sys.stderr.write(f"Available subcommands: {SETUP_COMPLETION}, {SETUP_CONFIG}, {SETUP_UDEV}\n")
194 | return 1
195 |
196 | subcommands = {
197 | SETUP_COMPLETION: cmd_setup_completion,
198 | SETUP_CONFIG: cmd_setup_config,
199 | SETUP_UDEV: cmd_setup_udev,
200 | }
201 |
202 | try:
203 | return subcommands[args.task](args)
204 | except RandrCtlException as e:
205 | logger.error(e)
206 | return 1
207 |
208 |
209 | def cmd_setup_completion(args: argparse.Namespace):
210 | print(argcomplete.shellcode('randrctl', True, 'bash', None))
211 | return 0
212 |
213 |
214 | def cmd_setup_config(args: argparse.Namespace):
215 | with (open(pkg_resources.resource_filename('randrctl', 'setup/config.yaml'), 'r')) as f:
216 | shutil.copyfileobj(f, sys.stdout)
217 | return 0
218 |
219 |
220 | def cmd_setup_udev(args: argparse.Namespace):
221 | with (open(pkg_resources.resource_filename('randrctl', 'setup/99-randrctl.rules'), 'r')) as f:
222 | shutil.copyfileobj(f, sys.stdout)
223 | return 0
224 |
225 |
226 | # Main logic
227 |
228 |
229 | def find_display_owner(display: str):
230 | regex = f"\({display}[.\d]?\)"
231 | matcher = re.compile(regex)
232 | # run /usr/bin/who. It output current display and screen as (:DISPLAY.SCREEN)
233 | # TODO is there a better way to do this in python?
234 | for line in subprocess.run('/usr/bin/who', stdout=subprocess.PIPE).stdout.decode('utf-8').splitlines():
235 | if matcher.search(line):
236 | username = line[0:line.find(' ')]
237 | return pwd.getpwnam(username)
238 |
239 |
240 | def x_displays():
241 | # Find all local displays by inspecting X sockets. Return as :0, :1, etc.
242 | # https://stackoverflow.com/questions/11367354/obtaining-list-of-all-xorg-displays
243 | return list(map(lambda socket: ':' + socket[16:], glob.glob('/tmp/.X11-unix/X*')))
244 |
245 |
246 | def configure_logging(args: argparse.Namespace):
247 | level = logging.WARN
248 | log_format = '%(levelname)-5s %(message)s'
249 | if args.debug:
250 | level = logging.DEBUG
251 | if args.extended_debug:
252 | level = logging.DEBUG
253 | log_format = '%(levelname)-5s %(name)s: %(message)s'
254 | logging.basicConfig(format=log_format, level=level)
255 |
256 |
257 | def getenv(variable: str):
258 | value = os.environ.get(variable)
259 | logger.debug("%s%s", variable, "=" + value if value else " is not set")
260 | return value
261 |
262 |
263 | def main():
264 | parser = args_parser()
265 | args = parser.parse_args(sys.argv[1:])
266 |
267 | configure_logging(args)
268 |
269 | commands = {
270 | AUTO: cmd_auto,
271 | DUMP: cmd_dump,
272 | LIST: cmd_list,
273 | SHOW: cmd_show,
274 | SWITCH_TO: cmd_switch_to,
275 | VERSION: cmd_version,
276 | SETUP: cmd_setup,
277 | }
278 | cmd = commands.get(args.command)
279 | if cmd is None:
280 | parser.print_help()
281 | return 1
282 |
283 | display = getenv(DISPLAY)
284 | xauthority = getenv(XAUTHORITY)
285 |
286 | if not display and args.detect_display:
287 | # likely we are executed from UDEV rule
288 | displays = x_displays()
289 | for display in displays:
290 | logger.debug("Trying DISPLAY %s", display)
291 | owner = find_display_owner(display)
292 | logger.debug("%s owner is '%s' with HOME '%s'", display, owner.pw_name, owner.pw_dir)
293 | try:
294 | os.environ[DISPLAY] = display
295 | os.environ[XAUTHORITY] = path.join(owner.pw_dir, ".Xauthority")
296 | randrctl = context.build(
297 | display=display,
298 | xauthority=xauthority,
299 | config_dirs=context.default_config_dirs(owner_home=owner.pw_dir),
300 | )
301 | result = cmd(randrctl, args)
302 | # exit as soon as first execution succeeds
303 | if result == 0:
304 | return 0
305 | except RandrCtlException as e:
306 | logger.error(e)
307 | logger.error("Could not apply settings for any available display [%s]", displays)
308 | return 1
309 | else:
310 | try:
311 | randrctl = context.build(display, xauthority)
312 | return cmd(randrctl, args)
313 | except RandrCtlException as e:
314 | logger.error(e)
315 | return 1
316 |
--------------------------------------------------------------------------------
/tests/test_xrandr.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from randrctl.exception import XrandrException, ParseException
4 | from randrctl.model import Profile, Output, XrandrConnection
5 | from randrctl.xrandr import Xrandr
6 |
7 |
8 | class TestXrandr(TestCase):
9 | xrandr = Xrandr(":0", None)
10 |
11 | def test_compose_mode_args(self):
12 | xrandr = Xrandr(":0", None)
13 | xrandr.EXECUTABLE = "stub"
14 |
15 | outputs = {
16 | "LVDS1": Output(mode='1366x768'),
17 | "DP1": Output(mode='1920x1080', pos='1366x0', scale='1.5x1.5', panning='1920x1080'),
18 | "VGA1": Output(mode='800x600', pos='0x768')
19 | }
20 |
21 | p = Profile("default", outputs, primary="LVDS1")
22 |
23 | xrandr_connections = [XrandrConnection("HDMI1"), XrandrConnection("HDMI2")]
24 |
25 | command = xrandr._compose_mode_args(p, xrandr_connections)
26 |
27 | num_of_outputs = len(outputs) + len(xrandr_connections)
28 |
29 | self.assertEqual(num_of_outputs, command.count(xrandr.OUTPUT_KEY))
30 | self.assertEqual(len(outputs), command.count(xrandr.POS_KEY))
31 | self.assertEqual(len(outputs), command.count(xrandr.MODE_KEY))
32 | self.assertEqual(len(outputs), command.count(xrandr.PANNING_KEY))
33 | self.assertEqual(len(outputs), command.count(xrandr.ROTATE_KEY))
34 | self.assertEqual(len(outputs), command.count(xrandr.SCALE_KEY))
35 | self.assertEqual(len(xrandr_connections), command.count(xrandr.OFF_KEY))
36 | self.assertEqual(1, command.count(xrandr.PRIMARY_KEY))
37 | self.assertEqual(1, command.count("LVDS1"))
38 | self.assertEqual(1, command.count("DP1"))
39 | self.assertEqual(1, command.count("VGA1"))
40 | self.assertEqual(1, command.count("HDMI1"))
41 | self.assertEqual(1, command.count("HDMI2"))
42 |
43 | def test_compose_mode_args_exact_line(self):
44 | xrandr = Xrandr(":0", None)
45 | xrandr.EXECUTABLE = "stub"
46 |
47 | outputs = {"LVDS1": Output(mode='1366x768')}
48 |
49 | p = Profile("default", outputs, primary="LVDS1")
50 |
51 | xrandr_connections = [XrandrConnection("LVDS1"), XrandrConnection("HDMI1")]
52 |
53 | command = xrandr._compose_mode_args(p, xrandr_connections)
54 | self.assertListEqual([
55 | '--output', 'LVDS1', '--mode', '1366x768', '--pos', '0x0', '--rotate', 'normal', '--panning', '0x0',
56 | '--scale', '1x1', '--primary',
57 | '--output', 'HDMI1', '--off'
58 | ], command)
59 |
60 | def test_parse_xrandr_connection_not_connected(self):
61 | query_result = ["DP1 disconnected (normal left inverted right x axis y axis)"]
62 | connection = self.xrandr._parse_xrandr_connection(query_result)
63 |
64 | self.assertIsNotNone(connection)
65 | self.assertEqual(connection.name, "DP1")
66 | self.assertIsNone(connection.display)
67 | self.assertIsNone(connection.viewport)
68 | self.assertFalse(connection.primary)
69 |
70 | def test_parse_xrandr_connection_not_active(self):
71 | query_result = [
72 | "HDMI1 connected (normal left inverted right x axis y axis)",
73 | " 1920x1080 60.00 +",
74 | " 1280x1024 75.02 60.02",
75 | " 800x600 75.00 60.32",
76 | ]
77 | connection = self.xrandr._parse_xrandr_connection(query_result)
78 |
79 | self.assertIsNotNone(connection)
80 | self.assertEqual("HDMI1", connection.name)
81 |
82 | self.assertIsNotNone(connection.display)
83 | self.assertIsNone(connection.display.rate)
84 | self.assertIsNone(connection.display.mode)
85 | self.assertEqual("1920x1080", connection.display.preferred_mode)
86 | self.assertEqual(["1920x1080", "1280x1024", "800x600"], connection.display.supported_modes)
87 |
88 | self.assertIsNone(connection.viewport)
89 | self.assertFalse(connection.primary)
90 |
91 | def test_parse_xrandr_connection_invalid(self):
92 | query_result = [
93 | "HDMI1 connected (normal left inverted right x axis y axis)",
94 | " 1920x1080 60.00*+",
95 | " 1280x1024 75.02 60.02",
96 | " 800x600 75.00 60.32",
97 | ]
98 | with self.assertRaises(ParseException):
99 | self.xrandr._parse_xrandr_connection(query_result)
100 |
101 | def test_parse_xrandr_connection_simple_viewport(self):
102 | query_result = [
103 | "eDP1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 270mm x 150mm",
104 | " 1920x1080 60.00*+ 48.00",
105 | ]
106 | connection = self.xrandr._parse_xrandr_connection(query_result)
107 | self.assertIsNotNone(connection)
108 | self.assertEqual("eDP1", connection.name)
109 |
110 | self.assertIsNotNone(connection.display)
111 | self.assertEqual("60.00", connection.display.rate)
112 | self.assertEqual("1920x1080", connection.display.mode)
113 | self.assertEqual("1920x1080", connection.display.preferred_mode)
114 | self.assertEqual(["1920x1080"], connection.display.supported_modes)
115 |
116 | self.assertIsNotNone(connection.viewport)
117 | self.assertEqual("1920x1080", connection.viewport.size)
118 | self.assertEqual("0x0", connection.viewport.panning)
119 | self.assertEqual("normal", connection.viewport.rotate)
120 | self.assertEqual("0x0", connection.viewport.pos)
121 | self.assertEqual("1x1", connection.viewport.scale)
122 |
123 | self.assertTrue(connection.primary)
124 |
125 | def test_parse_xrandr_connection_not_primary(self):
126 | query_result = [
127 | "eDP1 connected 1920x1080+0+0 (normal left inverted right x axis y axis) 270mm x 150mm",
128 | " 1920x1080 60.00*+ 48.00",
129 | ]
130 | connection = self.xrandr._parse_xrandr_connection(query_result)
131 | self.assertIsNotNone(connection)
132 | self.assertEqual("eDP1", connection.name)
133 |
134 | self.assertIsNotNone(connection.display)
135 | self.assertEqual("60.00", connection.display.rate)
136 | self.assertEqual("1920x1080", connection.display.mode)
137 | self.assertEqual("1920x1080", connection.display.preferred_mode)
138 | self.assertEqual(["1920x1080"], connection.display.supported_modes)
139 |
140 | self.assertIsNotNone(connection.viewport)
141 | self.assertEqual("1920x1080", connection.viewport.size)
142 | self.assertEqual("0x0", connection.viewport.panning)
143 | self.assertEqual("normal", connection.viewport.rotate)
144 | self.assertEqual("0x0", connection.viewport.pos)
145 | self.assertEqual("1x1", connection.viewport.scale)
146 |
147 | self.assertFalse(connection.primary)
148 |
149 | def test_parse_xrandr_connection_primary_rotated_positioned(self):
150 | query_result = [
151 | "eDP1 connected 1920x1080+1280+800 left (normal left inverted right x axis y axis) 270mm x 150mm",
152 | " 1920x1080 60.00*+ 48.00",
153 | ]
154 | connection = self.xrandr._parse_xrandr_connection(query_result)
155 | self.assertIsNotNone(connection)
156 | self.assertEqual("eDP1", connection.name)
157 |
158 | self.assertIsNotNone(connection.display)
159 | self.assertEqual("60.00", connection.display.rate)
160 | self.assertEqual("1920x1080", connection.display.mode)
161 | self.assertEqual("1920x1080", connection.display.preferred_mode)
162 | self.assertEqual(["1920x1080"], connection.display.supported_modes)
163 |
164 | self.assertIsNotNone(connection.viewport)
165 | self.assertEqual("1080x1920", connection.viewport.size)
166 | self.assertEqual("0x0", connection.viewport.panning)
167 | self.assertEqual("left", connection.viewport.rotate)
168 | self.assertEqual("1280x800", connection.viewport.pos)
169 | self.assertEqual("1.7777777777777777x0.5625", connection.viewport.scale)
170 |
171 | self.assertFalse(connection.primary)
172 |
173 | def test_parse_xrandr_connection_primary_positioned_panned(self):
174 | query_result = [
175 | "eDP1 connected primary 1920x1080+1280+800 (normal left inverted right x axis y axis) 270mm x 150mm panning 1920x1080+1280+800",
176 | " 1920x1080 60.00*+ 48.00",
177 | ]
178 | connection = self.xrandr._parse_xrandr_connection(query_result)
179 | self.assertIsNotNone(connection)
180 | self.assertEqual("eDP1", connection.name)
181 |
182 | self.assertIsNotNone(connection.display)
183 | self.assertEqual("60.00", connection.display.rate)
184 | self.assertEqual("1920x1080", connection.display.mode)
185 | self.assertEqual("1920x1080", connection.display.preferred_mode)
186 | self.assertEqual(["1920x1080"], connection.display.supported_modes)
187 |
188 | self.assertIsNotNone(connection.viewport)
189 | self.assertEqual("1920x1080", connection.viewport.size)
190 | self.assertEqual("1920x1080+1280+800", connection.viewport.panning)
191 | self.assertEqual("normal", connection.viewport.rotate)
192 | self.assertEqual("1280x800", connection.viewport.pos)
193 | self.assertEqual("1x1", connection.viewport.scale)
194 |
195 | self.assertTrue(connection.primary)
196 |
197 | def test_parse_xrandr_connection_scaled_positioned(self):
198 | query_result = [
199 | "eDP1 connected primary 2496x1404+1920+1080 (normal left inverted right x axis y axis) 270mm x 150mm panning 2496x1404+1920+1080",
200 | " 1920x1080 60.00*+ 48.00",
201 | ]
202 | connection = self.xrandr._parse_xrandr_connection(query_result)
203 | self.assertIsNotNone(connection)
204 | self.assertEqual("eDP1", connection.name)
205 |
206 | self.assertIsNotNone(connection.display)
207 | self.assertEqual("60.00", connection.display.rate)
208 | self.assertEqual("1920x1080", connection.display.mode)
209 | self.assertEqual("1920x1080", connection.display.preferred_mode)
210 | self.assertEqual(["1920x1080"], connection.display.supported_modes)
211 |
212 | self.assertIsNotNone(connection.viewport)
213 | self.assertEqual("2496x1404", connection.viewport.size)
214 | self.assertEqual("2496x1404+1920+1080", connection.viewport.panning)
215 | self.assertEqual("normal", connection.viewport.rotate)
216 | self.assertEqual("1920x1080", connection.viewport.pos)
217 | self.assertEqual("1.3x1.3", connection.viewport.scale)
218 |
219 | self.assertTrue(connection.primary)
220 |
221 | def test_parse_geometry(self):
222 | m = self.xrandr._parse_geometry("1920x1080+100+200")
223 | expected = ("1920x1080", "100x200")
224 | self.assertEqual(expected, m)
225 |
226 | def test_xrandr_exception(self):
227 | try:
228 | self.xrandr._xrandr("--output", "FOOBAR", "--mode", "800x600+0+0")
229 | self.fail("exception expected")
230 | except XrandrException:
231 | pass
232 |
233 | def test_group_query_result(self):
234 | query_result = [
235 | "LVDS1 connected",
236 | " 1920x1080+*",
237 | " 1366x768",
238 | " 1280x800",
239 | "DP1 connected",
240 | " 1920x1080+*",
241 | "HDMI1 disconnected",
242 | "VGA1 disconnected"]
243 |
244 | grouped = self.xrandr._group_query_result(query_result)
245 |
246 | self.assertEqual(4, len(grouped))
247 | self.assertListEqual(query_result[0:4], grouped[0])
248 | self.assertListEqual(query_result[4:6], grouped[1])
249 | self.assertListEqual(query_result[6:7], grouped[2])
250 | self.assertListEqual(query_result[7:], grouped[3])
251 |
252 | def test_edid_from_query_item(self):
253 | query_result = ["LVDS1 connected foo bar",
254 | "\tIdentifier: 0x45",
255 | "\tTimestamp: 123456789",
256 | "\tEDID:",
257 | "\t\t0",
258 | "\t\t1",
259 | "\t\t2",
260 | "\t\t3",
261 | "\t\t4",
262 | "\t\t5",
263 | "\t\t6",
264 | "\t\t7",
265 | "\t\t8",
266 | "\t\t9",
267 | "\t\t10",
268 | "\tBroadcast RGB: Automatic",
269 | "\t\tsupported: Automatic, Full",
270 | "\taudio: auto",
271 | "\t\tsupported: auto, on"
272 | ]
273 |
274 | edid = self.xrandr._field_from_query_item(query_result, 'EDID')
275 | self.assertEqual("01234567", edid)
276 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/uv.lock:
--------------------------------------------------------------------------------
1 | version = 1
2 | revision = 3
3 | requires-python = ">=3.7"
4 | resolution-markers = [
5 | "python_full_version >= '3.9'",
6 | "python_full_version == '3.8.*'",
7 | "python_full_version < '3.8'",
8 | ]
9 |
10 | [[package]]
11 | name = "argcomplete"
12 | version = "3.1.2"
13 | source = { registry = "https://pypi.org/simple" }
14 | resolution-markers = [
15 | "python_full_version < '3.8'",
16 | ]
17 | dependencies = [
18 | { name = "importlib-metadata", marker = "python_full_version < '3.8'" },
19 | ]
20 | sdist = { url = "https://files.pythonhosted.org/packages/1b/c5/fb934dda06057e182f8247b2b13a281552cf55ba2b8b4450f6e003d0469f/argcomplete-3.1.2.tar.gz", hash = "sha256:d5d1e5efd41435260b8f85673b74ea2e883affcbec9f4230c582689e8e78251b", size = 89541, upload-time = "2023-09-16T20:40:27.04Z" }
21 | wheels = [
22 | { url = "https://files.pythonhosted.org/packages/1e/05/223116a4a5905d6b26bff334ffc7b74474fafe23fcb10532caf0ef86ca69/argcomplete-3.1.2-py3-none-any.whl", hash = "sha256:d97c036d12a752d1079f190bc1521c545b941fda89ad85d15afa909b4d1b9a99", size = 41514, upload-time = "2023-09-16T20:40:25.393Z" },
23 | ]
24 |
25 | [[package]]
26 | name = "argcomplete"
27 | version = "3.6.2"
28 | source = { registry = "https://pypi.org/simple" }
29 | resolution-markers = [
30 | "python_full_version >= '3.9'",
31 | "python_full_version == '3.8.*'",
32 | ]
33 | sdist = { url = "https://files.pythonhosted.org/packages/16/0f/861e168fc813c56a78b35f3c30d91c6757d1fd185af1110f1aec784b35d0/argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf", size = 73403, upload-time = "2025-04-03T04:57:03.52Z" }
34 | wheels = [
35 | { url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708, upload-time = "2025-04-03T04:57:01.591Z" },
36 | ]
37 |
38 | [[package]]
39 | name = "colorama"
40 | version = "0.4.6"
41 | source = { registry = "https://pypi.org/simple" }
42 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
43 | wheels = [
44 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
45 | ]
46 |
47 | [[package]]
48 | name = "exceptiongroup"
49 | version = "1.3.0"
50 | source = { registry = "https://pypi.org/simple" }
51 | dependencies = [
52 | { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" },
53 | { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" },
54 | { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" },
55 | ]
56 | sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
57 | wheels = [
58 | { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
59 | ]
60 |
61 | [[package]]
62 | name = "importlib-metadata"
63 | version = "6.7.0"
64 | source = { registry = "https://pypi.org/simple" }
65 | dependencies = [
66 | { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" },
67 | { name = "zipp", marker = "python_full_version < '3.8'" },
68 | ]
69 | sdist = { url = "https://files.pythonhosted.org/packages/a3/82/f6e29c8d5c098b6be61460371c2c5591f4a335923639edec43b3830650a4/importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4", size = 53569, upload-time = "2023-06-18T21:44:35.024Z" }
70 | wheels = [
71 | { url = "https://files.pythonhosted.org/packages/ff/94/64287b38c7de4c90683630338cf28f129decbba0a44f0c6db35a873c73c4/importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5", size = 22934, upload-time = "2023-06-18T21:44:33.441Z" },
72 | ]
73 |
74 | [[package]]
75 | name = "iniconfig"
76 | version = "2.0.0"
77 | source = { registry = "https://pypi.org/simple" }
78 | resolution-markers = [
79 | "python_full_version < '3.8'",
80 | ]
81 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" }
82 | wheels = [
83 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" },
84 | ]
85 |
86 | [[package]]
87 | name = "iniconfig"
88 | version = "2.1.0"
89 | source = { registry = "https://pypi.org/simple" }
90 | resolution-markers = [
91 | "python_full_version >= '3.9'",
92 | "python_full_version == '3.8.*'",
93 | ]
94 | sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
95 | wheels = [
96 | { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
97 | ]
98 |
99 | [[package]]
100 | name = "packaging"
101 | version = "24.0"
102 | source = { registry = "https://pypi.org/simple" }
103 | resolution-markers = [
104 | "python_full_version < '3.8'",
105 | ]
106 | sdist = { url = "https://files.pythonhosted.org/packages/ee/b5/b43a27ac7472e1818c4bafd44430e69605baefe1f34440593e0332ec8b4d/packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9", size = 147882, upload-time = "2024-03-10T09:39:28.33Z" }
107 | wheels = [
108 | { url = "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", size = 53488, upload-time = "2024-03-10T09:39:25.947Z" },
109 | ]
110 |
111 | [[package]]
112 | name = "packaging"
113 | version = "25.0"
114 | source = { registry = "https://pypi.org/simple" }
115 | resolution-markers = [
116 | "python_full_version >= '3.9'",
117 | "python_full_version == '3.8.*'",
118 | ]
119 | sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
120 | wheels = [
121 | { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
122 | ]
123 |
124 | [[package]]
125 | name = "pluggy"
126 | version = "1.2.0"
127 | source = { registry = "https://pypi.org/simple" }
128 | resolution-markers = [
129 | "python_full_version < '3.8'",
130 | ]
131 | dependencies = [
132 | { name = "importlib-metadata", marker = "python_full_version < '3.8'" },
133 | ]
134 | sdist = { url = "https://files.pythonhosted.org/packages/8a/42/8f2833655a29c4e9cb52ee8a2be04ceac61bcff4a680fb338cbd3d1e322d/pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3", size = 61613, upload-time = "2023-06-21T09:12:28.745Z" }
135 | wheels = [
136 | { url = "https://files.pythonhosted.org/packages/51/32/4a79112b8b87b21450b066e102d6608907f4c885ed7b04c3fdb085d4d6ae/pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", size = 17695, upload-time = "2023-06-21T09:12:27.397Z" },
137 | ]
138 |
139 | [[package]]
140 | name = "pluggy"
141 | version = "1.5.0"
142 | source = { registry = "https://pypi.org/simple" }
143 | resolution-markers = [
144 | "python_full_version == '3.8.*'",
145 | ]
146 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" }
147 | wheels = [
148 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" },
149 | ]
150 |
151 | [[package]]
152 | name = "pluggy"
153 | version = "1.6.0"
154 | source = { registry = "https://pypi.org/simple" }
155 | resolution-markers = [
156 | "python_full_version >= '3.9'",
157 | ]
158 | sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
159 | wheels = [
160 | { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
161 | ]
162 |
163 | [[package]]
164 | name = "pygments"
165 | version = "2.19.2"
166 | source = { registry = "https://pypi.org/simple" }
167 | sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
168 | wheels = [
169 | { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
170 | ]
171 |
172 | [[package]]
173 | name = "pytest"
174 | version = "7.4.4"
175 | source = { registry = "https://pypi.org/simple" }
176 | resolution-markers = [
177 | "python_full_version < '3.8'",
178 | ]
179 | dependencies = [
180 | { name = "colorama", marker = "python_full_version < '3.8' and sys_platform == 'win32'" },
181 | { name = "exceptiongroup", marker = "python_full_version < '3.8'" },
182 | { name = "importlib-metadata", marker = "python_full_version < '3.8'" },
183 | { name = "iniconfig", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" },
184 | { name = "packaging", version = "24.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" },
185 | { name = "pluggy", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" },
186 | { name = "tomli", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" },
187 | ]
188 | sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116, upload-time = "2023-12-31T12:00:18.035Z" }
189 | wheels = [
190 | { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287, upload-time = "2023-12-31T12:00:13.963Z" },
191 | ]
192 |
193 | [[package]]
194 | name = "pytest"
195 | version = "8.3.5"
196 | source = { registry = "https://pypi.org/simple" }
197 | resolution-markers = [
198 | "python_full_version == '3.8.*'",
199 | ]
200 | dependencies = [
201 | { name = "colorama", marker = "python_full_version == '3.8.*' and sys_platform == 'win32'" },
202 | { name = "exceptiongroup", marker = "python_full_version == '3.8.*'" },
203 | { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" },
204 | { name = "packaging", version = "25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" },
205 | { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" },
206 | { name = "tomli", version = "2.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" },
207 | ]
208 | sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" }
209 | wheels = [
210 | { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
211 | ]
212 |
213 | [[package]]
214 | name = "pytest"
215 | version = "8.4.1"
216 | source = { registry = "https://pypi.org/simple" }
217 | resolution-markers = [
218 | "python_full_version >= '3.9'",
219 | ]
220 | dependencies = [
221 | { name = "colorama", marker = "python_full_version >= '3.9' and sys_platform == 'win32'" },
222 | { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" },
223 | { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
224 | { name = "packaging", version = "25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
225 | { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
226 | { name = "pygments", marker = "python_full_version >= '3.9'" },
227 | { name = "tomli", version = "2.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" },
228 | ]
229 | sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
230 | wheels = [
231 | { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
232 | ]
233 |
234 | [[package]]
235 | name = "pyyaml"
236 | version = "6.0.1"
237 | source = { registry = "https://pypi.org/simple" }
238 | resolution-markers = [
239 | "python_full_version < '3.8'",
240 | ]
241 | sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/af35f7ea75cf72f2cd079c95ee16797de7cd71f29ea7c68ae5ce7be1eda0/PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", size = 125201, upload-time = "2023-07-18T00:00:23.308Z" }
242 | wheels = [
243 | { url = "https://files.pythonhosted.org/packages/96/06/4beb652c0fe16834032e54f0956443d4cc797fe645527acee59e7deaa0a2/PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", size = 189447, upload-time = "2023-07-17T23:57:04.325Z" },
244 | { url = "https://files.pythonhosted.org/packages/5b/07/10033a403b23405a8fc48975444463d3d10a5c2736b7eb2550b07b367429/PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f", size = 169264, upload-time = "2023-07-17T23:57:07.787Z" },
245 | { url = "https://files.pythonhosted.org/packages/f1/26/55e4f21db1f72eaef092015d9017c11510e7e6301c62a6cfee91295d13c6/PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", size = 677003, upload-time = "2023-07-17T23:57:13.144Z" },
246 | { url = "https://files.pythonhosted.org/packages/ba/91/090818dfa62e85181f3ae23dd1e8b7ea7f09684864a900cab72d29c57346/PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", size = 699070, upload-time = "2023-07-17T23:57:19.402Z" },
247 | { url = "https://files.pythonhosted.org/packages/29/61/bf33c6c85c55bc45a29eee3195848ff2d518d84735eb0e2d8cb42e0d285e/PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", size = 705525, upload-time = "2023-07-17T23:57:25.272Z" },
248 | { url = "https://files.pythonhosted.org/packages/07/91/45dfd0ef821a7f41d9d0136ea3608bb5b1653e42fd56a7970532cb5c003f/PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", size = 707514, upload-time = "2023-08-28T18:43:20.945Z" },
249 | { url = "https://files.pythonhosted.org/packages/b6/a0/b6700da5d49e9fed49dc3243d3771b598dad07abb37cc32e524607f96adc/PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", size = 130488, upload-time = "2023-07-17T23:57:28.144Z" },
250 | { url = "https://files.pythonhosted.org/packages/24/97/9b59b43431f98d01806b288532da38099cc6f2fea0f3d712e21e269c0279/PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", size = 145338, upload-time = "2023-07-17T23:57:31.118Z" },
251 | { url = "https://files.pythonhosted.org/packages/ec/0d/26fb23e8863e0aeaac0c64e03fd27367ad2ae3f3cccf3798ee98ce160368/PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", size = 187867, upload-time = "2023-07-17T23:57:34.35Z" },
252 | { url = "https://files.pythonhosted.org/packages/28/09/55f715ddbf95a054b764b547f617e22f1d5e45d83905660e9a088078fe67/PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", size = 167530, upload-time = "2023-07-17T23:57:36.975Z" },
253 | { url = "https://files.pythonhosted.org/packages/5e/94/7d5ee059dfb92ca9e62f4057dcdec9ac08a9e42679644854dc01177f8145/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", size = 732244, upload-time = "2023-07-17T23:57:43.774Z" },
254 | { url = "https://files.pythonhosted.org/packages/06/92/e0224aa6ebf9dc54a06a4609da37da40bb08d126f5535d81bff6b417b2ae/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", size = 752871, upload-time = "2023-07-17T23:57:51.921Z" },
255 | { url = "https://files.pythonhosted.org/packages/7b/5e/efd033ab7199a0b2044dab3b9f7a4f6670e6a52c089de572e928d2873b06/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", size = 757729, upload-time = "2023-07-17T23:57:59.865Z" },
256 | { url = "https://files.pythonhosted.org/packages/03/5c/c4671451b2f1d76ebe352c0945d4cd13500adb5d05f5a51ee296d80152f7/PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", size = 748528, upload-time = "2023-08-28T18:43:23.207Z" },
257 | { url = "https://files.pythonhosted.org/packages/73/9c/766e78d1efc0d1fca637a6b62cea1b4510a7fb93617eb805223294fef681/PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", size = 130286, upload-time = "2023-07-17T23:58:02.964Z" },
258 | { url = "https://files.pythonhosted.org/packages/b3/34/65bb4b2d7908044963ebf614fe0fdb080773fc7030d7e39c8d3eddcd4257/PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", size = 144699, upload-time = "2023-07-17T23:58:05.586Z" },
259 | { url = "https://files.pythonhosted.org/packages/bc/06/1b305bf6aa704343be85444c9d011f626c763abb40c0edc1cad13bfd7f86/PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", size = 178692, upload-time = "2023-08-28T18:43:24.924Z" },
260 | { url = "https://files.pythonhosted.org/packages/84/02/404de95ced348b73dd84f70e15a41843d817ff8c1744516bf78358f2ffd2/PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", size = 165622, upload-time = "2023-08-28T18:43:26.54Z" },
261 | { url = "https://files.pythonhosted.org/packages/c7/4c/4a2908632fc980da6d918b9de9c1d9d7d7e70b2672b1ad5166ed27841ef7/PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", size = 696937, upload-time = "2024-01-18T20:40:22.92Z" },
262 | { url = "https://files.pythonhosted.org/packages/b4/33/720548182ffa8344418126017aa1d4ab4aeec9a2275f04ce3f3573d8ace8/PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", size = 724969, upload-time = "2023-08-28T18:43:28.56Z" },
263 | { url = "https://files.pythonhosted.org/packages/4f/78/77b40157b6cb5f2d3d31a3d9b2efd1ba3505371f76730d267e8b32cf4b7f/PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", size = 712604, upload-time = "2023-08-28T18:43:30.206Z" },
264 | { url = "https://files.pythonhosted.org/packages/2e/97/3e0e089ee85e840f4b15bfa00e4e63d84a3691ababbfea92d6f820ea6f21/PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", size = 126098, upload-time = "2023-08-28T18:43:31.835Z" },
265 | { url = "https://files.pythonhosted.org/packages/2b/9f/fbade56564ad486809c27b322d0f7e6a89c01f6b4fe208402e90d4443a99/PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", size = 138675, upload-time = "2023-08-28T18:43:33.613Z" },
266 | { url = "https://files.pythonhosted.org/packages/c7/d1/02baa09d39b1bb1ebaf0d850d106d1bdcb47c91958557f471153c49dc03b/PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", size = 189627, upload-time = "2023-07-17T23:58:40.188Z" },
267 | { url = "https://files.pythonhosted.org/packages/e5/31/ba812efa640a264dbefd258986a5e4e786230cb1ee4a9f54eb28ca01e14a/PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", size = 658438, upload-time = "2023-07-17T23:58:48.34Z" },
268 | { url = "https://files.pythonhosted.org/packages/4d/f1/08f06159739254c8947899c9fc901241614195db15ba8802ff142237664c/PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", size = 680304, upload-time = "2023-07-17T23:58:57.396Z" },
269 | { url = "https://files.pythonhosted.org/packages/d7/8f/db62b0df635b9008fe90aa68424e99cee05e68b398740c8a666a98455589/PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", size = 670140, upload-time = "2023-07-17T23:59:04.291Z" },
270 | { url = "https://files.pythonhosted.org/packages/cc/5c/fcabd17918348c7db2eeeb0575705aaf3f7ab1657f6ce29b2e31737dd5d1/PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", size = 137577, upload-time = "2023-07-17T23:59:07.267Z" },
271 | { url = "https://files.pythonhosted.org/packages/1e/ae/964ccb88a938f20ece5754878f182cfbd846924930d02d29d06af8d4c69e/PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", size = 153248, upload-time = "2023-07-17T23:59:10.608Z" },
272 | { url = "https://files.pythonhosted.org/packages/7f/5d/2779ea035ba1e533c32ed4a249b4e0448f583ba10830b21a3cddafe11a4e/PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", size = 191734, upload-time = "2023-07-17T23:59:13.869Z" },
273 | { url = "https://files.pythonhosted.org/packages/e1/a1/27bfac14b90adaaccf8c8289f441e9f76d94795ec1e7a8f134d9f2cb3d0b/PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", size = 723767, upload-time = "2023-07-17T23:59:20.686Z" },
274 | { url = "https://files.pythonhosted.org/packages/c1/39/47ed4d65beec9ce07267b014be85ed9c204fa373515355d3efa62d19d892/PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", size = 749067, upload-time = "2023-07-17T23:59:28.747Z" },
275 | { url = "https://files.pythonhosted.org/packages/c8/6b/6600ac24725c7388255b2f5add93f91e58a5d7efaf4af244fdbcc11a541b/PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", size = 736569, upload-time = "2023-07-17T23:59:37.216Z" },
276 | { url = "https://files.pythonhosted.org/packages/0d/46/62ae77677e532c0af6c81ddd6f3dbc16bdcc1208b077457354442d220bfb/PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", size = 787738, upload-time = "2023-08-28T18:43:35.582Z" },
277 | { url = "https://files.pythonhosted.org/packages/d6/6a/439d1a6f834b9a9db16332ce16c4a96dd0e3970b65fe08cbecd1711eeb77/PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", size = 139797, upload-time = "2023-07-17T23:59:40.254Z" },
278 | { url = "https://files.pythonhosted.org/packages/29/0f/9782fa5b10152abf033aec56a601177ead85ee03b57781f2d9fced09eefc/PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", size = 157350, upload-time = "2023-07-17T23:59:42.94Z" },
279 | { url = "https://files.pythonhosted.org/packages/57/c5/5d09b66b41d549914802f482a2118d925d876dc2a35b2d127694c1345c34/PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", size = 197846, upload-time = "2023-07-17T23:59:46.424Z" },
280 | { url = "https://files.pythonhosted.org/packages/0e/88/21b2f16cb2123c1e9375f2c93486e35fdc86e63f02e274f0e99c589ef153/PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", size = 174396, upload-time = "2023-07-17T23:59:49.538Z" },
281 | { url = "https://files.pythonhosted.org/packages/ac/6c/967d91a8edf98d2b2b01d149bd9e51b8f9fb527c98d80ebb60c6b21d60c4/PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", size = 731824, upload-time = "2023-07-17T23:59:58.111Z" },
282 | { url = "https://files.pythonhosted.org/packages/4a/4b/c71ef18ef83c82f99e6da8332910692af78ea32bd1d1d76c9787dfa36aea/PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", size = 754777, upload-time = "2023-07-18T00:00:06.716Z" },
283 | { url = "https://files.pythonhosted.org/packages/7d/39/472f2554a0f1e825bd7c5afc11c817cd7a2f3657460f7159f691fbb37c51/PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", size = 738883, upload-time = "2023-07-18T00:00:14.423Z" },
284 | { url = "https://files.pythonhosted.org/packages/40/da/a175a35cf5583580e90ac3e2a3dbca90e43011593ae62ce63f79d7b28d92/PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", size = 750294, upload-time = "2023-08-28T18:43:37.153Z" },
285 | { url = "https://files.pythonhosted.org/packages/24/62/7fcc372442ec8ea331da18c24b13710e010c5073ab851ef36bf9dacb283f/PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", size = 136936, upload-time = "2023-07-18T00:00:17.167Z" },
286 | { url = "https://files.pythonhosted.org/packages/84/4d/82704d1ab9290b03da94e6425f5e87396b999fd7eb8e08f3a92c158402bf/PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", size = 152751, upload-time = "2023-07-18T00:00:19.939Z" },
287 | ]
288 |
289 | [[package]]
290 | name = "pyyaml"
291 | version = "6.0.2"
292 | source = { registry = "https://pypi.org/simple" }
293 | resolution-markers = [
294 | "python_full_version >= '3.9'",
295 | "python_full_version == '3.8.*'",
296 | ]
297 | sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
298 | wheels = [
299 | { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" },
300 | { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" },
301 | { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" },
302 | { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" },
303 | { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" },
304 | { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" },
305 | { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" },
306 | { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" },
307 | { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" },
308 | { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" },
309 | { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" },
310 | { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" },
311 | { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" },
312 | { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" },
313 | { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" },
314 | { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" },
315 | { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" },
316 | { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" },
317 | { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
318 | { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
319 | { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
320 | { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
321 | { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
322 | { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
323 | { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
324 | { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
325 | { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
326 | { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
327 | { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
328 | { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
329 | { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
330 | { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
331 | { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
332 | { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
333 | { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
334 | { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
335 | { url = "https://files.pythonhosted.org/packages/74/d9/323a59d506f12f498c2097488d80d16f4cf965cee1791eab58b56b19f47a/PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a", size = 183218, upload-time = "2024-08-06T20:33:06.411Z" },
336 | { url = "https://files.pythonhosted.org/packages/74/cc/20c34d00f04d785f2028737e2e2a8254e1425102e730fee1d6396f832577/PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5", size = 728067, upload-time = "2024-08-06T20:33:07.879Z" },
337 | { url = "https://files.pythonhosted.org/packages/20/52/551c69ca1501d21c0de51ddafa8c23a0191ef296ff098e98358f69080577/PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d", size = 757812, upload-time = "2024-08-06T20:33:12.542Z" },
338 | { url = "https://files.pythonhosted.org/packages/fd/7f/2c3697bba5d4aa5cc2afe81826d73dfae5f049458e44732c7a0938baa673/PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083", size = 746531, upload-time = "2024-08-06T20:33:14.391Z" },
339 | { url = "https://files.pythonhosted.org/packages/8c/ab/6226d3df99900e580091bb44258fde77a8433511a86883bd4681ea19a858/PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706", size = 800820, upload-time = "2024-08-06T20:33:16.586Z" },
340 | { url = "https://files.pythonhosted.org/packages/a0/99/a9eb0f3e710c06c5d922026f6736e920d431812ace24aae38228d0d64b04/PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a", size = 145514, upload-time = "2024-08-06T20:33:22.414Z" },
341 | { url = "https://files.pythonhosted.org/packages/75/8a/ee831ad5fafa4431099aa4e078d4c8efd43cd5e48fbc774641d233b683a9/PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff", size = 162702, upload-time = "2024-08-06T20:33:23.813Z" },
342 | { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" },
343 | { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" },
344 | { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" },
345 | { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" },
346 | { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" },
347 | { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" },
348 | { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" },
349 | { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" },
350 | { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" },
351 | ]
352 |
353 | [[package]]
354 | name = "randrctl"
355 | source = { editable = "." }
356 | dependencies = [
357 | { name = "argcomplete", version = "3.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" },
358 | { name = "argcomplete", version = "3.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" },
359 | { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" },
360 | { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" },
361 | ]
362 |
363 | [package.dev-dependencies]
364 | dev = [
365 | { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" },
366 | { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" },
367 | { name = "pytest", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
368 | ]
369 |
370 | [package.metadata]
371 | requires-dist = [
372 | { name = "argcomplete" },
373 | { name = "pyyaml" },
374 | ]
375 |
376 | [package.metadata.requires-dev]
377 | dev = [{ name = "pytest", specifier = ">=7.4.4" }]
378 |
379 | [[package]]
380 | name = "tomli"
381 | version = "2.0.1"
382 | source = { registry = "https://pypi.org/simple" }
383 | resolution-markers = [
384 | "python_full_version < '3.8'",
385 | ]
386 | sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164, upload-time = "2022-02-08T10:54:04.006Z" }
387 | wheels = [
388 | { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757, upload-time = "2022-02-08T10:54:02.017Z" },
389 | ]
390 |
391 | [[package]]
392 | name = "tomli"
393 | version = "2.2.1"
394 | source = { registry = "https://pypi.org/simple" }
395 | resolution-markers = [
396 | "python_full_version >= '3.9'",
397 | "python_full_version == '3.8.*'",
398 | ]
399 | sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" }
400 | wheels = [
401 | { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" },
402 | { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" },
403 | { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" },
404 | { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" },
405 | { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" },
406 | { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" },
407 | { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" },
408 | { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" },
409 | { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" },
410 | { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" },
411 | { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" },
412 | { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" },
413 | { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" },
414 | { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" },
415 | { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" },
416 | { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" },
417 | { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" },
418 | { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" },
419 | { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" },
420 | { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" },
421 | { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
422 | { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
423 | { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
424 | { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
425 | { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
426 | { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
427 | { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
428 | { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
429 | { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
430 | { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
431 | { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
432 | ]
433 |
434 | [[package]]
435 | name = "typing-extensions"
436 | version = "4.7.1"
437 | source = { registry = "https://pypi.org/simple" }
438 | resolution-markers = [
439 | "python_full_version < '3.8'",
440 | ]
441 | sdist = { url = "https://files.pythonhosted.org/packages/3c/8b/0111dd7d6c1478bf83baa1cab85c686426c7a6274119aceb2bd9d35395ad/typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2", size = 72876, upload-time = "2023-07-02T14:20:55.045Z" }
442 | wheels = [
443 | { url = "https://files.pythonhosted.org/packages/ec/6b/63cc3df74987c36fe26157ee12e09e8f9db4de771e0f3404263117e75b95/typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", size = 33232, upload-time = "2023-07-02T14:20:53.275Z" },
444 | ]
445 |
446 | [[package]]
447 | name = "typing-extensions"
448 | version = "4.13.2"
449 | source = { registry = "https://pypi.org/simple" }
450 | resolution-markers = [
451 | "python_full_version == '3.8.*'",
452 | ]
453 | sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
454 | wheels = [
455 | { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
456 | ]
457 |
458 | [[package]]
459 | name = "typing-extensions"
460 | version = "4.14.1"
461 | source = { registry = "https://pypi.org/simple" }
462 | resolution-markers = [
463 | "python_full_version >= '3.9'",
464 | ]
465 | sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
466 | wheels = [
467 | { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
468 | ]
469 |
470 | [[package]]
471 | name = "zipp"
472 | version = "3.15.0"
473 | source = { registry = "https://pypi.org/simple" }
474 | sdist = { url = "https://files.pythonhosted.org/packages/00/27/f0ac6b846684cecce1ee93d32450c45ab607f65c2e0255f0092032d91f07/zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", size = 18454, upload-time = "2023-02-25T02:17:22.503Z" }
475 | wheels = [
476 | { url = "https://files.pythonhosted.org/packages/5b/fa/c9e82bbe1af6266adf08afb563905eb87cab83fde00a0a08963510621047/zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556", size = 6758, upload-time = "2023-02-25T02:17:20.807Z" },
477 | ]
478 |
--------------------------------------------------------------------------------