5 # Copyright (c) 2015, Phillip Berndt
7 # Autorandr rewrite in Python
9 # This script aims to be fully compatible with the original autorandr.
11 # This program is free software: you can redistribute it and/or modify
12 # it under the terms of the GNU General Public License as published by
13 # the Free Software Foundation, either version 3 of the License, or
14 # (at your option) any later version.
16 # This program is distributed in the hope that it will be useful,
17 # but WITHOUT ANY WARRANTY; without even the implied warranty of
18 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 # GNU General Public License for more details.
21 # You should have received a copy of the GNU General Public License
22 # along with this program. If not, see <http://www.gnu.org/licenses/>.
25 from __future__ import print_function
41 from collections import OrderedDict
42 from functools import reduce
43 from itertools import chain
46 from packaging.version import Version
47 except ModuleNotFoundError:
48 from distutils.version import LooseVersion as Version
50 if sys.version_info.major == 2:
51 import ConfigParser as configparser
63 # (name, description, callback)
64 ("off", "Disable all outputs", None),
65 ("common", "Clone all connected outputs at the largest common resolution", None),
66 ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
67 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
68 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
72 Usage: autorandr [options]
74 -h, --help get this small help
75 -c, --change automatically load the first detected profile
76 -d, --default <profile> make profile <profile> the default profile
77 -l, --load <profile> load profile <profile>
78 -s, --save <profile> save your current setup to profile <profile>
79 -r, --remove <profile> remove profile <profile>
80 --batch run autorandr for all users with active X11 sessions
81 --current only list current (active) configuration(s)
82 --config dump your current xrandr setup
83 --cycle automatically load the next detected profile
84 --debug enable verbose output
85 --detected only list detected (available) configuration(s)
86 --dry-run don't change anything, only print the xrandr commands
87 --fingerprint fingerprint your current hardware setup
88 --match-edid match diplays based on edid instead of name
89 --force force (re)loading of a profile / overwrite exiting files
90 --list list configurations
91 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
92 to skip both in detecting changes and applying a profile
93 --version show version information and exit
95 If no suitable profile can be identified, the current configuration is kept.
96 To change this behaviour and switch to a fallback configuration, specify
99 autorandr supports a set of per-profile and global hooks. See the documentation
102 The following virtual configurations are available:
106 def is_closed_lid(output):
107 if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
109 lids = glob.glob("/proc/acpi/button/lid/*/state")
112 with open(state_file) as f:
114 return "close" in content
118 class AutorandrException(Exception):
119 def __init__(self, message, original_exception=None, report_bug=False):
120 self.message = message
121 self.report_bug = report_bug
122 if original_exception:
123 self.original_exception = original_exception
124 trace = sys.exc_info()[2]
126 trace = trace.tb_next
127 self.line = trace.tb_lineno
128 self.file_name = trace.tb_frame.f_code.co_filename
132 frame = inspect.currentframe().f_back
133 self.line = frame.f_lineno
134 self.file_name = frame.f_code.co_filename
137 self.file_name = None
138 self.original_exception = None
140 if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
141 self.file_name = None
144 retval = [self.message]
146 retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
147 if self.original_exception:
148 retval.append(":\n ")
149 retval.append(str(self.original_exception).replace("\n", "\n "))
151 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
152 "\nhttps://github.com/phillipberndt/autorandr/issues"
153 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
154 return "".join(retval)
157 class XrandrOutput(object):
158 "Represents an XRandR output"
160 # This regular expression is used to parse an output in `xrandr --verbose'
161 XRANDR_OUTPUT_REGEXP = """(?x)
162 ^\s*(?P<output>\S[^ ]*)\s+ # Line starts with output name
163 (?: # Differentiate disconnected and connected
164 disconnected | # in first line
165 unknown\ connection |
166 (?P<connected>connected)
169 (?P<primary>primary\ )? # Might be primary screen
171 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
172 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
173 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
174 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
175 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
176 )? # .. but only if the screen is in use.
177 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
178 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
179 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
180 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
181 (?:\s*(?: # Properties of the output
182 Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) | # Gamma value
183 CRTC:\s*(?P<crtc>[0-9]) | # CRTC value
184 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
185 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
186 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
190 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution:
191 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+ # Extract rate
192 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
193 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
197 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
198 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
199 h:\s+width\s+(?P<width>[0-9]+).+\s+
200 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
203 XRANDR_13_DEFAULTS = {
204 "transform": "1,0,0,0,1,0,0,0,1",
208 XRANDR_12_DEFAULTS = {
211 "gamma": "1.0:1.0:1.0",
214 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
216 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
219 return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
222 def short_edid(self):
223 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
226 def options_with_defaults(self):
227 "Return the options dictionary, augmented with the default values that weren't set"
228 if "off" in self.options:
231 if xrandr_version() >= Version("1.3"):
232 options.update(self.XRANDR_13_DEFAULTS)
233 if xrandr_version() >= Version("1.2"):
234 options.update(self.XRANDR_12_DEFAULTS)
235 options.update(self.options)
236 return {a: b for a, b in options.items() if a not in self.ignored_options}
239 def filtered_options(self):
240 "Return a dictionary of options without ignored options"
241 return {a: b for a, b in self.options.items() if a not in self.ignored_options}
244 def option_vector(self):
245 "Return the command line parameters for XRandR for this instance"
246 args = ["--output", self.output]
247 for option, arg in sorted(self.options_with_defaults.items()):
248 args.append("--%s" % option)
254 def option_string(self):
255 "Return the command line parameters in the configuration file format"
256 options = ["output %s" % self.output]
257 for option, arg in sorted(self.filtered_options.items()):
259 options.append("%s %s" % (option, arg))
261 options.append(option)
262 return "\n".join(options)
266 "Return a key to sort the outputs for xrandr invocation"
269 if "off" in self.options:
271 if "pos" in self.options:
272 x, y = map(float, self.options["pos"].split("x"))
277 def __init__(self, output, edid, options):
278 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
281 self.options = options
282 self.ignored_options = []
283 self.remove_default_option_values()
285 def set_ignored_options(self, options):
286 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
287 self.ignored_options = list(options)
289 def remove_default_option_values(self):
290 "Remove values from the options dictionary that are superflous"
291 if "off" in self.options and len(self.options.keys()) > 1:
292 self.options = {"off": None}
294 for option, default_value in self.XRANDR_DEFAULTS.items():
295 if option in self.options and self.options[option] == default_value:
296 del self.options[option]
299 def from_xrandr_output(cls, xrandr_output):
300 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
302 This method also returns a list of modes supported by the output.
305 xrandr_output = xrandr_output.replace("\r\n", "\n")
306 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
308 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
311 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
312 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
314 remainder = xrandr_output[len(match_object.group(0)):]
316 raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
317 "regular expression, starting at byte %d with ..'%s'." %
318 (len(remainder), len(match_object.group(0)), remainder[:10]),
321 match = match_object.groupdict()
326 for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
327 if mode_match.group("name"):
328 modes.append(mode_match.groupdict())
330 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
333 if not match["connected"]:
336 edid = "".join(match["edid"].strip().split())
338 edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
340 # An output can be disconnected but still have a mode configured. This can only happen
341 # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
344 # This code needs to be careful not to mix the two. An output should only be configured to
345 # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
346 if not match["width"]:
347 options["off"] = None
349 if match["mode_name"]:
350 options["mode"] = match["mode_name"]
351 elif match["mode_width"]:
352 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
354 if match["rotate"] not in ("left", "right"):
355 options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
357 options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
359 options["rotate"] = match["rotate"]
361 options["primary"] = None
362 if match["reflect"] == "X":
363 options["reflect"] = "x"
364 elif match["reflect"] == "Y":
365 options["reflect"] = "y"
366 elif match["reflect"] == "X and Y":
367 options["reflect"] = "xy"
368 if match["x"] or match["y"]:
369 options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
371 panning = [match["panning"]]
372 if match["tracking"]:
373 panning += ["/", match["tracking"]]
375 panning += ["/", match["border"]]
376 options["panning"] = "".join(panning)
377 if match["transform"]:
378 transformation = ",".join(match["transform"].strip().split())
379 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
380 options["transform"] = transformation
381 if not match["mode_name"]:
382 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
383 # I doubt that this special case is actually required.
384 print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
385 "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
387 gamma = match["gamma"].strip()
388 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
389 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
390 # so we approximate by 1e-10.
391 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
392 options["gamma"] = gamma
394 options["crtc"] = match["crtc"]
396 options["rate"] = match["rate"]
398 return XrandrOutput(match["output"], edid, options), modes
401 def from_config_file(cls, edid_map, configuration):
402 "Instanciate an XrandrOutput from the contents of a configuration file"
404 for line in configuration.split("\n"):
406 line = line.split(None, 1)
407 if line and line[0].startswith("#"):
409 options[line[0]] = line[1] if len(line) > 1 else None
413 if options["output"] in edid_map:
414 edid = edid_map[options["output"]]
416 # This fuzzy matching is for legacy autorandr that used sysfs output names
417 fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
418 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
419 if fuzzy_output in fuzzy_edid_map:
420 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
421 elif "off" not in options:
422 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
423 "is not off in config file." % (options["output"], options["output"]))
424 output = options["output"]
425 del options["output"]
427 return XrandrOutput(output, edid, options)
429 def edid_equals(self, other):
430 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
431 if self.edid and other.edid:
432 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
433 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
434 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
435 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
437 return match_asterisk(self.edid, other.edid) > 0
438 elif "*" in other.edid:
439 return match_asterisk(other.edid, self.edid) > 0
440 return self.edid == other.edid
442 def __ne__(self, other):
443 return not (self == other)
445 def __eq__(self, other):
446 return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
448 def verbose_diff(self, other):
449 "Compare to another XrandrOutput and return a list of human readable differences"
451 if not self.edid_equals(other):
452 diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
453 if self.output != other.output:
454 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
455 if "off" in self.options and "off" not in other.options:
456 diffs.append("The output is disabled currently, but active in the new configuration")
457 elif "off" in other.options and "off" not in self.options:
458 diffs.append("The output is currently enabled, but inactive in the new configuration")
460 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
461 if name not in other.options:
462 diffs.append("Option --%s %sis not present in the new configuration" %
463 (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
464 elif name not in self.options:
465 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
466 (name, other.options[name]))
467 elif self.options[name] != other.options[name]:
468 diffs.append("Option --%s %sis `%s' in the new configuration" %
469 (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
473 def xrandr_version():
474 "Return the version of XRandR that this system uses"
475 if getattr(xrandr_version, "version", False) is False:
476 version_string = os.popen("xrandr -v").read()
478 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
479 xrandr_version.version = Version(version)
480 except AttributeError:
481 xrandr_version.version = Version("1.3.0")
483 return xrandr_version.version
486 def debug_regexp(pattern, string):
487 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
490 bounds = (0, len(string))
491 while bounds[0] != bounds[1]:
492 half = int((bounds[0] + bounds[1]) / 2)
493 if half == bounds[0]:
495 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
496 partial_length = bounds[0]
497 return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
498 (partial_length, string[max(0, partial_length - 20):partial_length],
499 string[partial_length:partial_length + 10]))
502 return "Debug information would be available if the `regex' module was installed."
505 def parse_xrandr_output():
506 "Parse the output of `xrandr --verbose' into a list of outputs"
507 xrandr_output = os.popen("xrandr -q --verbose").read()
508 if not xrandr_output:
509 raise AutorandrException("Failed to run xrandr")
511 # We are not interested in screens
512 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
514 # Split at output boundaries and instanciate an XrandrOutput per output
515 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
516 if len(split_xrandr_output) < 2:
517 raise AutorandrException("No output boundaries found", report_bug=True)
518 outputs = OrderedDict()
519 modes = OrderedDict()
520 for i in range(1, len(split_xrandr_output), 2):
521 output_name = split_xrandr_output[i].split()[0]
522 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
523 outputs[output_name] = output
525 modes[output_name] = output_modes
527 # consider a closed lid as disconnected if other outputs are connected
528 if sum(o.edid != None for o in outputs.values()) > 1:
529 for output_name in outputs.keys():
530 if is_closed_lid(output_name):
531 outputs[output_name].edid = None
533 return outputs, modes
536 def load_profiles(profile_path):
537 "Load the stored profiles"
540 for profile in os.listdir(profile_path):
541 config_name = os.path.join(profile_path, profile, "config")
542 setup_name = os.path.join(profile_path, profile, "setup")
543 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
546 edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
550 for line in chain(open(config_name).readlines(), ["output"]):
551 if line[:6] == "output" and buffer:
552 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
557 for output_name in list(config.keys()):
558 if config[output_name].edid is None:
559 del config[output_name]
561 profiles[profile] = {
563 "path": os.path.join(profile_path, profile),
564 "config-mtime": os.stat(config_name).st_mtime,
570 def get_symlinks(profile_path):
571 "Load all symlinks from a directory"
574 for link in os.listdir(profile_path):
575 file_name = os.path.join(profile_path, link)
576 if os.path.islink(file_name):
577 symlinks[link] = os.readlink(file_name)
582 def match_asterisk(pattern, data):
583 """Match data against a pattern
585 The difference to fnmatch is that this function only accepts patterns with a single
586 asterisk and that it returns a "closeness" number, which is larger the better the match.
587 Zero indicates no match at all.
589 if "*" not in pattern:
590 return 1 if pattern == data else 0
591 parts = pattern.split("*")
593 raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
594 if not data.startswith(parts[0]):
596 if not data.endswith(parts[1]):
598 matched = len(pattern)
599 total = len(data) + 1
600 return matched * 1. / total
603 def update_profiles_edid(profiles, config):
606 if config[c].edid is not None:
607 edid_map[config[c].edid] = c
610 profile_config = profiles[p]["config"]
612 for edid in edid_map:
613 for c in profile_config.keys():
614 if profile_config[c].edid != edid or c == edid_map[edid]:
617 print("%s: renaming display %s to %s" % (p, c, edid_map[edid]))
619 tmp_disp = profile_config[c]
621 if edid_map[edid] in profile_config:
622 # Swap the two entries
623 profile_config[c] = profile_config[edid_map[edid]]
624 profile_config[c].output = c
626 # Object is reassigned to another key, drop this one
627 del profile_config[c]
629 profile_config[edid_map[edid]] = tmp_disp
630 profile_config[edid_map[edid]].output = edid_map[edid]
633 def find_profiles(current_config, profiles):
634 "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
635 detected_profiles = []
636 for profile_name, profile in profiles.items():
637 config = profile["config"]
639 for name, output in config.items():
642 if name not in current_config or not output.edid_equals(current_config[name]):
645 if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].edid)):
648 closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(current_config[name].edid, output.edid))
649 detected_profiles.append((closeness, profile_name))
650 detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
651 return detected_profiles
654 def profile_blocked(profile_path, meta_information=None):
655 """Check if a profile is blocked.
657 meta_information is expected to be an dictionary. It will be passed to the block scripts
658 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
660 return not exec_scripts(profile_path, "block", meta_information)
663 def check_configuration_pre_save(configuration):
664 "Check that a configuration is safe for saving."
665 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
666 for output in outputs:
667 if "off" not in configuration[output].options and not configuration[output].edid:
668 return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
669 "This typically means that it has been recently unplugged and then not properly disabled\n"
670 "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
671 "this command.") % {"o": output}
674 def output_configuration(configuration, config):
675 "Write a configuration file"
676 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
677 for output in outputs:
678 print(configuration[output].option_string, file=config)
681 def output_setup(configuration, setup):
682 "Write a setup (fingerprint) file"
683 outputs = sorted(configuration.keys())
684 for output in outputs:
685 if configuration[output].edid:
686 print(output, configuration[output].edid, file=setup)
689 def save_configuration(profile_path, profile_name, configuration, forced=False):
690 "Save a configuration into a profile"
691 if not os.path.isdir(profile_path):
692 os.makedirs(profile_path)
693 config_path = os.path.join(profile_path, "config")
694 setup_path = os.path.join(profile_path, "setup")
695 if os.path.isfile(config_path) and not forced:
696 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
697 if os.path.isfile(setup_path) and not forced:
698 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
700 with open(config_path, "w") as config:
701 output_configuration(configuration, config)
702 with open(setup_path, "w") as setup:
703 output_setup(configuration, setup)
706 def update_mtime(filename):
707 "Update a file's mtime"
709 os.utime(filename, None)
715 def call_and_retry(*args, **kwargs):
716 """Wrapper around subprocess.call that retries failed calls.
718 This function calls subprocess.call and on non-zero exit states,
719 waits a second and then retries once. This mitigates #47,
720 a timing issue with some drivers.
722 if "dry_run" in kwargs:
723 dry_run = kwargs["dry_run"]
724 del kwargs["dry_run"]
727 kwargs_redirected = dict(kwargs)
729 if hasattr(subprocess, "DEVNULL"):
730 kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
732 kwargs_redirected["stdout"] = open(os.devnull, "w")
733 kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
734 retval = subprocess.call(*args, **kwargs_redirected)
737 retval = subprocess.call(*args, **kwargs)
741 def get_fb_dimensions(configuration):
744 for output in configuration.values():
745 if "off" in output.options or not output.edid:
747 # This won't work with all modes -- but it's a best effort.
748 match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
751 o_mode = match.group(0)
752 o_width, o_height = map(int, o_mode.split("x"))
753 if "transform" in output.options:
754 a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
755 w = (g * o_width + h * o_height + i)
756 x = (a * o_width + b * o_height + c) / w
757 y = (d * o_width + e * o_height + f) / w
758 o_width, o_height = x, y
759 if "rotate" in output.options:
760 if output.options["rotate"] in ("left", "right"):
761 o_width, o_height = o_height, o_width
762 if "pos" in output.options:
763 o_left, o_top = map(int, output.options["pos"].split("x"))
766 if "panning" in output.options:
767 match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
769 detail = match.groupdict(default="0")
770 o_width = int(detail.get("w")) + int(detail.get("x"))
771 o_height = int(detail.get("h")) + int(detail.get("y"))
772 width = max(width, o_width)
773 height = max(height, o_height)
774 return int(width), int(height)
777 def apply_configuration(new_configuration, current_configuration, dry_run=False):
778 "Apply a configuration"
779 found_top_left_monitor = False
780 found_left_monitor = False
781 found_top_monitor = False
782 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
784 base_argv = ["echo", "xrandr"]
786 base_argv = ["xrandr"]
788 # There are several xrandr / driver bugs we need to take care of here:
789 # - We cannot enable more than two screens at the same time
790 # See https://github.com/phillipberndt/autorandr/pull/6
791 # and commits f4cce4d and 8429886.
792 # - We cannot disable all screens
793 # See https://github.com/phillipberndt/autorandr/pull/20
794 # - We should disable screens before enabling others, because there's
795 # a limit on the number of enabled screens
796 # - We must make sure that the screen at 0x0 is activated first,
797 # or the other (first) screen to be activated would be moved there.
798 # - If an active screen already has a transformation and remains active,
799 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
800 # Update the configuration in 3 passes in that case. (On Haswell graphics,
802 # - Some implementations can not handle --transform at all, so avoid it unless
803 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
804 # - Some implementations can not handle --panning without specifying --fb
805 # explicitly, so avoid it unless necessary.
806 # (See https://github.com/phillipberndt/autorandr/issues/72)
808 fb_dimensions = get_fb_dimensions(new_configuration)
810 base_argv += ["--fb", "%dx%d" % fb_dimensions]
812 # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
815 auxiliary_changes_pre = []
818 remain_active_count = 0
819 for output in outputs:
820 if not new_configuration[output].edid or "off" in new_configuration[output].options:
821 disable_outputs.append(new_configuration[output].option_vector)
823 if output not in current_configuration:
824 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
825 "Don't know how to proceed." % output)
826 if "off" not in current_configuration[output].options:
827 remain_active_count += 1
829 option_vector = new_configuration[output].option_vector
830 if xrandr_version() >= Version("1.3.0"):
831 for option, off_value in (("transform", "none"), ("panning", "0x0")):
832 if option in current_configuration[output].options:
833 auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
836 option_index = option_vector.index("--%s" % option)
837 if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
838 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
841 if not found_top_left_monitor:
842 position = new_configuration[output].options.get("pos", "0x0")
843 if position == "0x0":
844 found_top_left_monitor = True
845 enable_outputs.insert(0, option_vector)
846 elif not found_left_monitor and position.startswith("0x"):
847 found_left_monitor = True
848 enable_outputs.insert(0, option_vector)
849 elif not found_top_monitor and position.endswith("x0"):
850 found_top_monitor = True
851 enable_outputs.insert(0, option_vector)
853 enable_outputs.append(option_vector)
855 enable_outputs.append(option_vector)
857 # Perform pe-change auxiliary changes
858 if auxiliary_changes_pre:
859 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
860 if call_and_retry(argv, dry_run=dry_run) != 0:
861 raise AutorandrException("Command failed: %s" % " ".join(argv))
863 # Disable unused outputs, but make sure that there always is at least one active screen
864 disable_keep = 0 if remain_active_count else 1
865 if len(disable_outputs) > disable_keep:
866 argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
867 if call_and_retry(argv, dry_run=dry_run) != 0:
868 # Disabling the outputs failed. Retry with the next command:
869 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
870 # This does not occur if simultaneously the primary screen is reset.
873 disable_outputs = disable_outputs[-1:] if disable_keep else []
875 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
876 # disable the last two screens. This is a problem, so if this would happen, instead disable only
877 # one screen in the first call below.
878 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
879 # In the context of a xrandr call that changes the display state, `--query' should do nothing
880 disable_outputs.insert(0, ['--query'])
882 # If we did not find a candidate, we might need to inject a call
883 # If there is no output to disable, we will enable 0x and x0 at the same time
884 if not found_top_left_monitor and len(disable_outputs) > 0:
885 # If the call to 0x and x0 is splitted, inject one of them
886 if found_top_monitor and found_left_monitor:
887 enable_outputs.insert(0, enable_outputs[0])
889 # Enable the remaining outputs in pairs of two operations
890 operations = disable_outputs + enable_outputs
891 for index in range(0, len(operations), 2):
892 argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
893 if call_and_retry(argv, dry_run=dry_run) != 0:
894 raise AutorandrException("Command failed: %s" % " ".join(argv))
897 def is_equal_configuration(source_configuration, target_configuration):
899 Check if all outputs from target are already configured correctly in source and
900 that no other outputs are active.
902 for output in target_configuration.keys():
903 if "off" in target_configuration[output].options:
904 if (output in source_configuration and "off" not in source_configuration[output].options):
907 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
909 for output in source_configuration.keys():
910 if "off" in source_configuration[output].options:
911 if output in target_configuration and "off" not in target_configuration[output].options:
914 if output not in target_configuration:
919 def add_unused_outputs(source_configuration, target_configuration):
920 "Add outputs that are missing in target to target, in 'off' state"
921 for output_name, output in source_configuration.items():
922 if output_name not in target_configuration:
923 target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
926 def remove_irrelevant_outputs(source_configuration, target_configuration):
927 "Remove outputs from target that ought to be 'off' and already are"
928 for output_name, output in source_configuration.items():
929 if "off" in output.options:
930 if output_name in target_configuration:
931 if "off" in target_configuration[output_name].options:
932 del target_configuration[output_name]
935 def generate_virtual_profile(configuration, modes, profile_name):
936 "Generate one of the virtual profiles"
937 configuration = copy.deepcopy(configuration)
938 if profile_name == "common":
940 for output, output_modes in modes.items():
942 if configuration[output].edid:
943 for mode in output_modes:
944 mode_set.add((mode["width"], mode["height"]))
945 mode_sets.append(mode_set)
946 common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
947 common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
948 if common_resolution:
949 for output in configuration:
950 configuration[output].options = {}
951 if output in modes and configuration[output].edid:
952 modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
953 modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
954 mode = modes_filtered[0]
955 configuration[output].options["mode"] = mode['name']
956 configuration[output].options["pos"] = "0x0"
958 configuration[output].options["off"] = None
959 elif profile_name in ("horizontal", "vertical"):
961 if profile_name == "horizontal":
962 shift_index = "width"
963 pos_specifier = "%sx0"
965 shift_index = "height"
966 pos_specifier = "0x%s"
968 for output in configuration:
969 configuration[output].options = {}
970 if output in modes and configuration[output].edid:
972 score = int(a["width"]) * int(a["height"])
976 output_modes = sorted(modes[output], key=key)
977 mode = output_modes[-1]
978 configuration[output].options["mode"] = mode["name"]
979 configuration[output].options["rate"] = mode["rate"]
980 configuration[output].options["pos"] = pos_specifier % shift
981 shift += int(mode[shift_index])
983 configuration[output].options["off"] = None
984 elif profile_name == "clone-largest":
985 modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
986 modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
987 biggest_resolution = modes_sorted[0]
988 for output in configuration:
989 configuration[output].options = {}
990 if output in modes and configuration[output].edid:
992 score = int(a["width"]) * int(a["height"])
996 output_modes = sorted(modes[output], key=key)
997 mode = output_modes[-1]
998 configuration[output].options["mode"] = mode["name"]
999 configuration[output].options["rate"] = mode["rate"]
1000 configuration[output].options["pos"] = "0x0"
1001 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
1002 float(biggest_resolution["height"]) / float(mode["height"]))
1003 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
1004 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
1005 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
1007 configuration[output].options["off"] = None
1008 elif profile_name == "off":
1009 for output in configuration:
1010 for key in list(configuration[output].options.keys()):
1011 del configuration[output].options[key]
1012 configuration[output].options["off"] = None
1013 return configuration
1016 def print_profile_differences(one, another):
1017 "Print the differences between two profiles for debugging"
1020 print("| Differences between the two profiles:")
1021 for output in set(chain.from_iterable((one.keys(), another.keys()))):
1022 if output not in one:
1023 if "off" not in another[output].options:
1024 print("| Output `%s' is missing from the active configuration" % output)
1025 elif output not in another:
1026 if "off" not in one[output].options:
1027 print("| Output `%s' is missing from the new configuration" % output)
1029 for line in one[output].verbose_diff(another[output]):
1030 print("| [Output %s] %s" % (output, line))
1035 "Print help and exit"
1037 for profile in virtual_profiles:
1038 name, description = profile[:2]
1039 description = [description]
1041 while len(description[0]) > max_width + 1:
1042 left_over = description[0][max_width:]
1043 description[0] = description[0][:max_width] + "-"
1044 description.insert(1, " %-15s %s" % ("", left_over))
1045 description = "\n".join(description)
1046 print(" %-15s %s" % (name, description))
1050 def exec_scripts(profile_path, script_name, meta_information=None):
1053 This will run all executables from the profile folder, and global per-user
1054 and system-wide configuration folders, named script_name or residing in
1055 subdirectories named script_name.d.
1057 If profile_path is None, only global scripts will be invoked.
1059 meta_information is expected to be an dictionary. It will be passed to the block scripts
1060 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
1062 Returns True unless any of the scripts exited with non-zero exit status.
1065 env = os.environ.copy()
1066 if meta_information:
1067 for key, value in meta_information.items():
1068 env["AUTORANDR_{}".format(key.upper())] = str(value)
1070 # If there are multiple candidates, the XDG spec tells to only use the first one.
1073 user_profile_path = os.path.expanduser("~/.autorandr")
1074 if not os.path.isdir(user_profile_path):
1075 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1077 candidate_directories = []
1079 candidate_directories.append(profile_path)
1080 candidate_directories.append(user_profile_path)
1081 for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
1082 candidate_directories.append(os.path.join(config_dir, "autorandr"))
1084 for folder in candidate_directories:
1085 if script_name not in ran_scripts:
1086 script = os.path.join(folder, script_name)
1087 if os.access(script, os.X_OK | os.F_OK):
1089 all_ok &= subprocess.call(script, env=env) != 0
1091 raise AutorandrException("Failed to execute user command: %s" % (script,))
1092 ran_scripts.add(script_name)
1094 script_folder = os.path.join(folder, "%s.d" % script_name)
1095 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
1096 for file_name in os.listdir(script_folder):
1097 check_name = "d/%s" % (file_name,)
1098 if check_name not in ran_scripts:
1099 script = os.path.join(script_folder, file_name)
1100 if os.access(script, os.X_OK | os.F_OK):
1102 all_ok &= subprocess.call(script, env=env) != 0
1104 raise AutorandrException("Failed to execute user command: %s" % (script,))
1105 ran_scripts.add(check_name)
1110 def dispatch_call_to_sessions(argv):
1111 """Invoke autorandr for each open local X11 session with the given options.
1113 The function iterates over all processes not owned by root and checks
1114 whether they have DISPLAY and XAUTHORITY variables set. It strips the
1115 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1116 this display has been handled already. If it has not, it forks, changes
1117 uid/gid to the user owning the process, reuses the process's environment
1118 and runs autorandr with the parameters from argv.
1120 This function requires root permissions. It only works for X11 servers that
1121 have at least one non-root process running. It is susceptible for attacks
1122 where one user runs a process with another user's DISPLAY variable - in
1123 this case, it might happen that autorandr is invoked for the other user,
1124 which won't work. Since no other harm than prevention of automated
1125 execution of autorandr can be done this way, the assumption is that in this
1126 situation, the local administrator will handle the situation."""
1128 X11_displays_done = set()
1130 autorandr_binary = os.path.abspath(argv[0])
1131 backup_candidates = {}
1133 def fork_child_autorandr(pwent, process_environ):
1134 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1135 child_pid = os.fork()
1137 # This will throw an exception if any of the privilege changes fails,
1138 # so it should be safe. Also, note that since the environment
1139 # is taken from a process owned by the user, reusing it should
1140 # not leak any information.
1142 os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
1143 except AttributeError:
1144 # Python 2 doesn't have getgrouplist
1146 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1147 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1148 os.chdir(pwent.pw_dir)
1150 os.environ.update(process_environ)
1151 if sys.executable != "" and sys.executable != None:
1152 os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
1154 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1156 os.waitpid(child_pid, 0)
1158 for directory in os.listdir("/proc"):
1159 directory = os.path.join("/proc/", directory)
1160 if not os.path.isdir(directory):
1162 environ_file = os.path.join(directory, "environ")
1163 if not os.path.isfile(environ_file):
1165 uid = os.stat(environ_file).st_uid
1167 # The following line assumes that user accounts start at 1000 and that
1168 # no one works using the root or another system account. This is rather
1169 # restrictive, but de facto default. Alternatives would be to use the
1170 # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
1171 # but effectively, both values aren't binding in any way.
1172 # If this breaks your use case, please file a bug on Github.
1176 process_environ = {}
1177 for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
1179 environ_entry = environ_entry.decode("ascii")
1180 except UnicodeDecodeError:
1182 name, sep, value = environ_entry.partition("=")
1184 if name == "DISPLAY" and "." in value:
1185 value = value[:value.find(".")]
1186 process_environ[name] = value
1188 if "DISPLAY" not in process_environ:
1189 # Cannot work with this environment, skip.
1192 # To allow scripts to detect batch invocation (especially useful for predetect)
1193 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1194 process_environ["UID"] = str(uid)
1196 display = process_environ["DISPLAY"]
1198 if "XAUTHORITY" not in process_environ:
1199 # It's very likely that we cannot work with this environment either,
1200 # but keep it as a backup just in case we don't find anything else.
1201 backup_candidates[display] = process_environ
1204 if display not in X11_displays_done:
1206 pwent = pwd.getpwuid(uid)
1208 # User has no pwd entry
1211 fork_child_autorandr(pwent, process_environ)
1212 X11_displays_done.add(display)
1214 # Run autorandr for any users/displays which didn't have a process with
1216 for display, process_environ in backup_candidates.items():
1217 if display not in X11_displays_done:
1219 pwent = pwd.getpwuid(int(process_environ["UID"]))
1221 # User has no pwd entry
1224 fork_child_autorandr(pwent, process_environ)
1225 X11_displays_done.add(display)
1228 def enabled_monitors(config):
1230 for monitor in config:
1231 if "--off" in config[monitor].option_vector:
1233 monitors.append(monitor)
1237 def read_config(options, directory):
1238 """Parse a configuration config.ini from directory and merge it into
1239 the options dictionary"""
1240 config = configparser.ConfigParser()
1241 config.read(os.path.join(directory, "settings.ini"))
1242 if config.has_section("config"):
1243 for key, value in config.items("config"):
1244 options.setdefault("--%s" % key, value)
1248 opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
1249 ["batch", "dry-run", "change", "cycle", "default=", "save=", "remove=", "load=",
1250 "force", "fingerprint", "config", "debug", "skip-options=", "help",
1251 "list", "current", "detected", "version", "match-edid"])
1252 except getopt.GetoptError as e:
1253 print("Failed to parse options: {0}.\n"
1254 "Use --help to get usage information.".format(str(e)),
1256 sys.exit(posix.EX_USAGE)
1258 options = dict(opts)
1260 if "-h" in options or "--help" in options:
1263 if "--version" in options:
1264 print("autorandr " + __version__)
1267 if "--current" in options and "--detected" in options:
1268 print("--current and --detected are mutually exclusive.", file=sys.stderr)
1269 sys.exit(posix.EX_USAGE)
1272 if "--batch" in options:
1273 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1274 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1276 print("--batch mode can only be used by root and if $DISPLAY is unset")
1278 if "AUTORANDR_BATCH_PID" in os.environ:
1279 user = pwd.getpwuid(os.getuid())
1280 user = user.pw_name if user else "#%d" % os.getuid()
1281 print("autorandr running as user %s (started from batch instance)" % user)
1284 profile_symlinks = {}
1286 # Load profiles from each XDG config directory
1287 # The XDG spec says that earlier entries should take precedence, so reverse the order
1288 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1289 system_profile_path = os.path.join(directory, "autorandr")
1290 if os.path.isdir(system_profile_path):
1291 profiles.update(load_profiles(system_profile_path))
1292 profile_symlinks.update(get_symlinks(system_profile_path))
1293 read_config(options, system_profile_path)
1294 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1295 # profile_path is also used later on to store configurations
1296 profile_path = os.path.expanduser("~/.autorandr")
1297 if not os.path.isdir(profile_path):
1298 # Elsewise, follow the XDG specification
1299 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1300 if os.path.isdir(profile_path):
1301 profiles.update(load_profiles(profile_path))
1302 profile_symlinks.update(get_symlinks(profile_path))
1303 read_config(options, profile_path)
1304 except Exception as e:
1305 raise AutorandrException("Failed to load profiles", e)
1307 exec_scripts(None, "predetect")
1308 config, modes = parse_xrandr_output()
1310 if "--match-edid" in options:
1311 update_profiles_edid(profiles, config)
1315 if "--cycle" in options:
1316 # When cycling through profiles, put the profile least recently used to the top of the list
1318 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
1319 profile_symlinks = {k: v for k, v in profile_symlinks.items() if v in (x[0] for x in virtual_profiles) or v in profiles}
1321 if "--fingerprint" in options:
1322 output_setup(config, sys.stdout)
1325 if "--config" in options:
1326 output_configuration(config, sys.stdout)
1329 if "--skip-options" in options:
1330 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1331 for profile in profiles.values():
1332 for output in profile["config"].values():
1333 output.set_ignored_options(skip_options)
1334 for output in config.values():
1335 output.set_ignored_options(skip_options)
1338 options["--save"] = options["-s"]
1339 if "--save" in options:
1340 if options["--save"] in (x[0] for x in virtual_profiles):
1341 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1342 "This configuration name is a reserved virtual configuration." % options["--save"])
1343 error = check_configuration_pre_save(config)
1345 print("Cannot save current configuration as profile '%s':" % options["--save"])
1349 profile_folder = os.path.join(profile_path, options["--save"])
1350 save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
1351 exec_scripts(profile_folder, "postsave", {
1352 "CURRENT_PROFILE": options["--save"],
1353 "PROFILE_FOLDER": profile_folder,
1354 "MONITORS": ":".join(enabled_monitors(config)),
1356 except AutorandrException as e:
1358 except Exception as e:
1359 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1360 print("Saved current configuration as profile '%s'" % options["--save"])
1364 options["--remove"] = options["-r"]
1365 if "--remove" in options:
1366 if options["--remove"] in (x[0] for x in virtual_profiles):
1367 raise AutorandrException("Cannot remove profile '%s':\n"
1368 "This configuration name is a reserved virtual configuration." % options["--remove"])
1369 if options["--remove"] not in profiles.keys():
1370 raise AutorandrException("Cannot remove profile '%s':\n"
1371 "This profile does not exist." % options["--remove"])
1374 profile_folder = os.path.join(profile_path, options["--remove"])
1375 profile_dirlist = os.listdir(profile_folder)
1376 profile_dirlist.remove("config")
1377 profile_dirlist.remove("setup")
1379 print("Profile folder '%s' contains the following additional files:\n"
1380 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1381 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1382 if response != "yes":
1385 shutil.rmtree(profile_folder)
1386 print("Removed profile '%s'" % options["--remove"])
1388 print("Profile '%s' was not removed" % options["--remove"])
1389 except Exception as e:
1390 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1393 detected_profiles = find_profiles(config, profiles)
1394 load_profile = False
1397 options["--load"] = options["-l"]
1398 if "--load" in options:
1399 load_profile = options["--load"]
1400 elif len(args) == 1:
1401 load_profile = args[0]
1403 # Find the active profile(s) first, for the block script (See #42)
1404 current_profiles = []
1405 for profile_name in profiles.keys():
1406 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1407 if configs_are_equal:
1408 current_profiles.append(profile_name)
1409 block_script_metadata = {
1410 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1411 "CURRENT_PROFILES": ":".join(current_profiles)
1415 for profile_name in profiles.keys():
1416 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1417 if not any(opt in options for opt in ("--current", "--detected", "--list")):
1418 print("%s (blocked)" % profile_name)
1421 is_current_profile = profile_name in current_profiles
1422 if profile_name in detected_profiles:
1423 if len(detected_profiles) == 1:
1425 props.append("(detected)")
1427 index = detected_profiles.index(profile_name) + 1
1428 props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
1429 if index < best_index:
1430 if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
1431 load_profile = profile_name
1433 elif "--detected" in options:
1435 if is_current_profile:
1436 props.append("(current)")
1437 elif "--current" in options:
1439 if any(opt in options for opt in ("--current", "--detected", "--list")):
1440 print("%s" % (profile_name, ))
1442 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1443 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1444 print_profile_differences(config, profiles[profile_name]["config"])
1447 options["--default"] = options["-d"]
1448 if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
1449 load_profile = options["--default"]
1452 if load_profile in profile_symlinks:
1453 if "--debug" in options:
1454 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1455 load_profile = profile_symlinks[load_profile]
1457 if load_profile in (x[0] for x in virtual_profiles):
1458 load_config = generate_virtual_profile(config, modes, load_profile)
1459 scripts_path = os.path.join(profile_path, load_profile)
1462 profile = profiles[load_profile]
1463 load_config = profile["config"]
1464 scripts_path = profile["path"]
1466 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1467 if "--dry-run" not in options:
1468 update_mtime(os.path.join(scripts_path, "config"))
1469 add_unused_outputs(config, load_config)
1470 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1471 print("Config already loaded", file=sys.stderr)
1473 if "--debug" in options and load_config != dict(config):
1474 print("Loading profile '%s'" % load_profile)
1475 print_profile_differences(config, load_config)
1477 remove_irrelevant_outputs(config, load_config)
1480 if "--dry-run" in options:
1481 apply_configuration(load_config, config, True)
1484 "CURRENT_PROFILE": load_profile,
1485 "PROFILE_FOLDER": scripts_path,
1486 "MONITORS": ":".join(enabled_monitors(load_config)),
1488 exec_scripts(scripts_path, "preswitch", script_metadata)
1489 if "--debug" in options:
1490 print("Going to run:")
1491 apply_configuration(load_config, config, True)
1492 apply_configuration(load_config, config, False)
1493 exec_scripts(scripts_path, "postswitch", script_metadata)
1494 except AutorandrException as e:
1495 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1496 except Exception as e:
1497 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1499 if "--dry-run" not in options and "--debug" in options:
1500 new_config, _ = parse_xrandr_output()
1501 if not is_equal_configuration(new_config, load_config):
1502 print("The configuration change did not go as expected:")
1503 print_profile_differences(new_config, load_config)
1508 def exception_handled_main(argv=sys.argv):
1511 except AutorandrException as e:
1512 print(e, file=sys.stderr)
1514 except Exception as e:
1515 if not len(str(e)): # BdbQuit
1516 print("Exception: {0}".format(e.__class__.__name__))
1519 print("Unhandled exception ({0}). Please report this as a bug at "
1520 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1525 if __name__ == '__main__':
1526 exception_handled_main()