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
42 from collections import OrderedDict
43 from functools import reduce
44 from itertools import chain
47 from packaging.version import Version
48 except ModuleNotFoundError:
49 from distutils.version import LooseVersion as Version
51 if sys.version_info.major == 2:
52 import ConfigParser as configparser
56 __version__ = "1.12.1"
64 # (name, description, callback)
65 ("off", "Disable all outputs", None),
66 ("common", "Clone all connected outputs at the largest common resolution", None),
67 ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
68 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
69 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
87 Usage: autorandr [options]
89 -h, --help get this small help
90 -c, --change automatically load the first detected profile
91 -d, --default <profile> make profile <profile> the default profile
92 -l, --load <profile> load profile <profile>
93 -s, --save <profile> save your current setup to profile <profile>
94 -r, --remove <profile> remove profile <profile>
95 --batch run autorandr for all users with active X11 sessions
96 --current only list current (active) configuration(s)
97 --config dump your current xrandr setup
98 --cycle automatically load the next detected profile
99 --debug enable verbose output
100 --detected only list detected (available) configuration(s)
101 --dry-run don't change anything, only print the xrandr commands
102 --fingerprint fingerprint your current hardware setup
103 --ignore-lid treat outputs as connected even if their lids are closed
104 --match-edid match diplays based on edid instead of name
105 --force force (re)loading of a profile / overwrite exiting files
106 --list list configurations
107 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
108 to skip both in detecting changes and applying a profile
109 --version show version information and exit
111 If no suitable profile can be identified, the current configuration is kept.
112 To change this behaviour and switch to a fallback configuration, specify
115 autorandr supports a set of per-profile and global hooks. See the documentation
118 The following virtual configurations are available:
122 def is_closed_lid(output):
123 if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
125 lids = glob.glob("/proc/acpi/button/lid/*/state")
128 with open(state_file) as f:
130 return "close" in content
134 class AutorandrException(Exception):
135 def __init__(self, message, original_exception=None, report_bug=False):
136 self.message = message
137 self.report_bug = report_bug
138 if original_exception:
139 self.original_exception = original_exception
140 trace = sys.exc_info()[2]
142 trace = trace.tb_next
143 self.line = trace.tb_lineno
144 self.file_name = trace.tb_frame.f_code.co_filename
148 frame = inspect.currentframe().f_back
149 self.line = frame.f_lineno
150 self.file_name = frame.f_code.co_filename
153 self.file_name = None
154 self.original_exception = None
156 if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
157 self.file_name = None
160 retval = [self.message]
162 retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
163 if self.original_exception:
164 retval.append(":\n ")
165 retval.append(str(self.original_exception).replace("\n", "\n "))
167 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
168 "\nhttps://github.com/phillipberndt/autorandr/issues"
169 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
170 return "".join(retval)
173 class XrandrOutput(object):
174 "Represents an XRandR output"
176 XRANDR_PROPERTIES_REGEXP = "|".join(
177 [r"{}:\s*(?P<{}>[\S ]*\S+)"
178 .format(re.sub(r"\s", r"\\\g<0>", p), re.sub(r"\W+", "_", p.lower()))
179 for p in properties])
181 # This regular expression is used to parse an output in `xrandr --verbose'
182 XRANDR_OUTPUT_REGEXP = """(?x)
183 ^\s*(?P<output>\S[^ ]*)\s+ # Line starts with output name
184 (?: # Differentiate disconnected and connected
185 disconnected | # in first line
186 unknown\ connection |
187 (?P<connected>connected)
190 (?P<primary>primary\ )? # Might be primary screen
192 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
193 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
194 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
195 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
196 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
197 )? # .. but only if the screen is in use.
198 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
199 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
200 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
201 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
202 (?:\s*(?: # Properties of the output
203 Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) | # Gamma value
204 CRTC:\s*(?P<crtc>[0-9]) | # CRTC value
205 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
206 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
207 """ + XRANDR_PROPERTIES_REGEXP + """ | # Properties to include in the profile
208 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
212 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution:
213 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+ # Extract rate
214 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
215 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
219 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
220 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
221 h:\s+width\s+(?P<width>[0-9]+).+\s+
222 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
225 XRANDR_13_DEFAULTS = {
226 "transform": "1,0,0,0,1,0,0,0,1",
230 XRANDR_12_DEFAULTS = {
233 "gamma": "1.0:1.0:1.0",
236 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
238 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
241 return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
244 def short_edid(self):
245 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
248 def options_with_defaults(self):
249 "Return the options dictionary, augmented with the default values that weren't set"
250 if "off" in self.options:
253 if xrandr_version() >= Version("1.3"):
254 options.update(self.XRANDR_13_DEFAULTS)
255 if xrandr_version() >= Version("1.2"):
256 options.update(self.XRANDR_12_DEFAULTS)
257 options.update(self.options)
258 return {a: b for a, b in options.items() if a not in self.ignored_options}
261 def filtered_options(self):
262 "Return a dictionary of options without ignored options"
263 return {a: b for a, b in self.options.items() if a not in self.ignored_options}
266 def option_vector(self):
267 "Return the command line parameters for XRandR for this instance"
268 args = ["--output", self.output]
269 for option, arg in sorted(self.options_with_defaults.items()):
270 if option.startswith("x-prop-"):
272 for prop, xrandr_prop in [(re.sub(r"\W+", "_", p.lower()), p) for p in properties]:
273 if prop == option[7:]:
275 args.append(xrandr_prop)
279 print("Warning: Unknown property `%s' in config file. Skipping." % option[7:], file=sys.stderr)
281 elif option.startswith("x-"):
282 print("Warning: Unknown option `%s' in config file. Skipping." % option, file=sys.stderr)
285 args.append("--%s" % option)
291 def option_string(self):
292 "Return the command line parameters in the configuration file format"
293 options = ["output %s" % self.output]
294 for option, arg in sorted(self.filtered_options.items()):
296 options.append("%s %s" % (option, arg))
298 options.append(option)
299 return "\n".join(options)
303 "Return a key to sort the outputs for xrandr invocation"
306 if "off" in self.options:
308 if "pos" in self.options:
309 x, y = map(float, self.options["pos"].split("x"))
314 def __init__(self, output, edid, options):
315 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
318 self.options = options
319 self.ignored_options = []
320 self.remove_default_option_values()
322 def set_ignored_options(self, options):
323 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
324 self.ignored_options = list(options)
326 def remove_default_option_values(self):
327 "Remove values from the options dictionary that are superflous"
328 if "off" in self.options and len(self.options.keys()) > 1:
329 self.options = {"off": None}
331 for option, default_value in self.XRANDR_DEFAULTS.items():
332 if option in self.options and self.options[option] == default_value:
333 del self.options[option]
336 def from_xrandr_output(cls, xrandr_output):
337 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
339 This method also returns a list of modes supported by the output.
342 xrandr_output = xrandr_output.replace("\r\n", "\n")
343 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
345 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
348 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
349 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
351 remainder = xrandr_output[len(match_object.group(0)):]
353 raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
354 "regular expression, starting at byte %d with ..'%s'." %
355 (len(remainder), len(match_object.group(0)), remainder[:10]),
358 match = match_object.groupdict()
363 for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
364 if mode_match.group("name"):
365 modes.append(mode_match.groupdict())
367 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
370 if not match["connected"]:
373 edid = "".join(match["edid"].strip().split())
375 edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
377 # An output can be disconnected but still have a mode configured. This can only happen
378 # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
381 # This code needs to be careful not to mix the two. An output should only be configured to
382 # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
383 if not match["width"]:
384 options["off"] = None
386 if match["mode_name"]:
387 options["mode"] = match["mode_name"]
388 elif match["mode_width"]:
389 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
391 if match["rotate"] not in ("left", "right"):
392 options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
394 options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
396 options["rotate"] = match["rotate"]
398 options["primary"] = None
399 if match["reflect"] == "X":
400 options["reflect"] = "x"
401 elif match["reflect"] == "Y":
402 options["reflect"] = "y"
403 elif match["reflect"] == "X and Y":
404 options["reflect"] = "xy"
405 if match["x"] or match["y"]:
406 options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
408 panning = [match["panning"]]
409 if match["tracking"]:
410 panning += ["/", match["tracking"]]
412 panning += ["/", match["border"]]
413 options["panning"] = "".join(panning)
414 if match["transform"]:
415 transformation = ",".join(match["transform"].strip().split())
416 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
417 options["transform"] = transformation
418 if not match["mode_name"]:
419 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
420 # I doubt that this special case is actually required.
421 print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
422 "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
424 gamma = match["gamma"].strip()
425 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
426 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
427 # so we approximate by 1e-10.
428 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
429 options["gamma"] = gamma
431 options["crtc"] = match["crtc"]
433 options["rate"] = match["rate"]
434 for prop in [re.sub(r"\W+", "_", p.lower()) for p in properties]:
436 options["x-prop-" + prop] = match[prop]
438 return XrandrOutput(match["output"], edid, options), modes
441 def from_config_file(cls, edid_map, configuration):
442 "Instanciate an XrandrOutput from the contents of a configuration file"
444 for line in configuration.split("\n"):
446 line = line.split(None, 1)
447 if line and line[0].startswith("#"):
449 options[line[0]] = line[1] if len(line) > 1 else None
453 if options["output"] in edid_map:
454 edid = edid_map[options["output"]]
456 # This fuzzy matching is for legacy autorandr that used sysfs output names
457 fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
458 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
459 if fuzzy_output in fuzzy_edid_map:
460 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
461 elif "off" not in options:
462 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
463 "is not off in config file." % (options["output"], options["output"]))
464 output = options["output"]
465 del options["output"]
467 return XrandrOutput(output, edid, options)
469 def edid_equals(self, other):
470 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
471 if self.edid and other.edid:
472 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
473 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
474 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
475 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
477 return match_asterisk(self.edid, other.edid) > 0
478 elif "*" in other.edid:
479 return match_asterisk(other.edid, self.edid) > 0
480 return self.edid == other.edid
482 def __ne__(self, other):
483 return not (self == other)
485 def __eq__(self, other):
486 return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
488 def verbose_diff(self, other):
489 "Compare to another XrandrOutput and return a list of human readable differences"
491 if not self.edid_equals(other):
492 diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
493 if self.output != other.output:
494 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
495 if "off" in self.options and "off" not in other.options:
496 diffs.append("The output is disabled currently, but active in the new configuration")
497 elif "off" in other.options and "off" not in self.options:
498 diffs.append("The output is currently enabled, but inactive in the new configuration")
500 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
501 if name not in other.options:
502 diffs.append("Option --%s %sis not present in the new configuration" %
503 (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
504 elif name not in self.options:
505 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
506 (name, other.options[name]))
507 elif self.options[name] != other.options[name]:
508 diffs.append("Option --%s %sis `%s' in the new configuration" %
509 (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
513 def xrandr_version():
514 "Return the version of XRandR that this system uses"
515 if getattr(xrandr_version, "version", False) is False:
516 version_string = os.popen("xrandr -v").read()
518 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
519 xrandr_version.version = Version(version)
520 except AttributeError:
521 xrandr_version.version = Version("1.3.0")
523 return xrandr_version.version
526 def debug_regexp(pattern, string):
527 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
530 bounds = (0, len(string))
531 while bounds[0] != bounds[1]:
532 half = int((bounds[0] + bounds[1]) / 2)
533 if half == bounds[0]:
535 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
536 partial_length = bounds[0]
537 return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
538 (partial_length, string[max(0, partial_length - 20):partial_length],
539 string[partial_length:partial_length + 10]))
542 return "Debug information would be available if the `regex' module was installed."
545 def parse_xrandr_output(
549 "Parse the output of `xrandr --verbose' into a list of outputs"
550 xrandr_output = os.popen("xrandr -q --verbose").read()
551 if not xrandr_output:
552 raise AutorandrException("Failed to run xrandr")
554 # We are not interested in screens
555 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
557 # Split at output boundaries and instanciate an XrandrOutput per output
558 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
559 if len(split_xrandr_output) < 2:
560 raise AutorandrException("No output boundaries found", report_bug=True)
561 outputs = OrderedDict()
562 modes = OrderedDict()
563 for i in range(1, len(split_xrandr_output), 2):
564 output_name = split_xrandr_output[i].split()[0]
565 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
566 outputs[output_name] = output
568 modes[output_name] = output_modes
570 # consider a closed lid as disconnected if other outputs are connected
571 if not ignore_lid and sum(
576 for output_name in outputs.keys():
577 if is_closed_lid(output_name):
578 outputs[output_name].edid = None
580 return outputs, modes
583 def load_profiles(profile_path):
584 "Load the stored profiles"
587 for profile in os.listdir(profile_path):
588 config_name = os.path.join(profile_path, profile, "config")
589 setup_name = os.path.join(profile_path, profile, "setup")
590 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
593 edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
597 for line in chain(open(config_name).readlines(), ["output"]):
598 if line[:6] == "output" and buffer:
599 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
604 for output_name in list(config.keys()):
605 if config[output_name].edid is None:
606 del config[output_name]
608 profiles[profile] = {
610 "path": os.path.join(profile_path, profile),
611 "config-mtime": os.stat(config_name).st_mtime,
617 def get_symlinks(profile_path):
618 "Load all symlinks from a directory"
621 for link in os.listdir(profile_path):
622 file_name = os.path.join(profile_path, link)
623 if os.path.islink(file_name):
624 symlinks[link] = os.readlink(file_name)
629 def match_asterisk(pattern, data):
630 """Match data against a pattern
632 The difference to fnmatch is that this function only accepts patterns with a single
633 asterisk and that it returns a "closeness" number, which is larger the better the match.
634 Zero indicates no match at all.
636 if "*" not in pattern:
637 return 1 if pattern == data else 0
638 parts = pattern.split("*")
640 raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
641 if not data.startswith(parts[0]):
643 if not data.endswith(parts[1]):
645 matched = len(pattern)
646 total = len(data) + 1
647 return matched * 1. / total
650 def update_profiles_edid(profiles, config):
653 if config[c].edid is not None:
654 edid_map[config[c].edid] = c
657 profile_config = profiles[p]["config"]
659 for edid in edid_map:
660 for c in list(profile_config.keys()):
661 if profile_config[c].edid != edid or c == edid_map[edid]:
664 print("%s: renaming display %s to %s" % (p, c, edid_map[edid]))
666 tmp_disp = profile_config[c]
668 if edid_map[edid] in profile_config:
669 # Swap the two entries
670 profile_config[c] = profile_config[edid_map[edid]]
671 profile_config[c].output = c
673 # Object is reassigned to another key, drop this one
674 del profile_config[c]
676 profile_config[edid_map[edid]] = tmp_disp
677 profile_config[edid_map[edid]].output = edid_map[edid]
680 def find_profiles(current_config, profiles):
681 "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
682 detected_profiles = []
683 for profile_name, profile in profiles.items():
684 config = profile["config"]
686 for name, output in config.items():
689 if name not in current_config or not output.edid_equals(current_config[name]):
692 if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].edid)):
695 closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(
696 current_config[name].edid, output.edid))
697 detected_profiles.append((closeness, profile_name))
698 detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
699 return detected_profiles
702 def profile_blocked(profile_path, meta_information=None):
703 """Check if a profile is blocked.
705 meta_information is expected to be an dictionary. It will be passed to the block scripts
706 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
708 return not exec_scripts(profile_path, "block", meta_information)
711 def check_configuration_pre_save(configuration):
712 "Check that a configuration is safe for saving."
713 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
714 for output in outputs:
715 if "off" not in configuration[output].options and not configuration[output].edid:
716 return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
717 "This typically means that it has been recently unplugged and then not properly disabled\n"
718 "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
719 "this command.") % {"o": output}
722 def output_configuration(configuration, config):
723 "Write a configuration file"
724 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
725 for output in outputs:
726 print(configuration[output].option_string, file=config)
729 def output_setup(configuration, setup):
730 "Write a setup (fingerprint) file"
731 outputs = sorted(configuration.keys())
732 for output in outputs:
733 if configuration[output].edid:
734 print(output, configuration[output].edid, file=setup)
737 def save_configuration(profile_path, profile_name, configuration, forced=False):
738 "Save a configuration into a profile"
739 if not os.path.isdir(profile_path):
740 os.makedirs(profile_path)
741 config_path = os.path.join(profile_path, "config")
742 setup_path = os.path.join(profile_path, "setup")
743 if os.path.isfile(config_path) and not forced:
744 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
745 if os.path.isfile(setup_path) and not forced:
746 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
748 with open(config_path, "w") as config:
749 output_configuration(configuration, config)
750 with open(setup_path, "w") as setup:
751 output_setup(configuration, setup)
754 def update_mtime(filename):
755 "Update a file's mtime"
757 os.utime(filename, None)
763 def call_and_retry(*args, **kwargs):
764 """Wrapper around subprocess.call that retries failed calls.
766 This function calls subprocess.call and on non-zero exit states,
767 waits a second and then retries once. This mitigates #47,
768 a timing issue with some drivers.
770 if kwargs.pop("dry_run", False):
772 print(shlex.quote(arg), end=" ")
776 if hasattr(subprocess, "DEVNULL"):
777 kwargs["stdout"] = getattr(subprocess, "DEVNULL")
779 kwargs["stdout"] = open(os.devnull, "w")
780 kwargs["stderr"] = kwargs["stdout"]
781 retval = subprocess.call(*args, **kwargs)
784 retval = subprocess.call(*args, **kwargs)
788 def get_fb_dimensions(configuration):
791 for output in configuration.values():
792 if "off" in output.options or not output.edid:
794 # This won't work with all modes -- but it's a best effort.
795 match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
798 o_mode = match.group(0)
799 o_width, o_height = map(int, o_mode.split("x"))
800 if "transform" in output.options:
801 a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
802 w = (g * o_width + h * o_height + i)
803 x = (a * o_width + b * o_height + c) / w
804 y = (d * o_width + e * o_height + f) / w
805 o_width, o_height = x, y
806 if "rotate" in output.options:
807 if output.options["rotate"] in ("left", "right"):
808 o_width, o_height = o_height, o_width
809 if "pos" in output.options:
810 o_left, o_top = map(int, output.options["pos"].split("x"))
813 if "panning" in output.options:
814 match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
816 detail = match.groupdict(default="0")
817 o_width = int(detail.get("w")) + int(detail.get("x"))
818 o_height = int(detail.get("h")) + int(detail.get("y"))
819 width = max(width, o_width)
820 height = max(height, o_height)
821 return int(width), int(height)
824 def apply_configuration(new_configuration, current_configuration, dry_run=False):
825 "Apply a configuration"
826 found_top_left_monitor = False
827 found_left_monitor = False
828 found_top_monitor = False
829 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
830 base_argv = ["xrandr"]
832 # There are several xrandr / driver bugs we need to take care of here:
833 # - We cannot enable more than two screens at the same time
834 # See https://github.com/phillipberndt/autorandr/pull/6
835 # and commits f4cce4d and 8429886.
836 # - We cannot disable all screens
837 # See https://github.com/phillipberndt/autorandr/pull/20
838 # - We should disable screens before enabling others, because there's
839 # a limit on the number of enabled screens
840 # - We must make sure that the screen at 0x0 is activated first,
841 # or the other (first) screen to be activated would be moved there.
842 # - If an active screen already has a transformation and remains active,
843 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
844 # Update the configuration in 3 passes in that case. (On Haswell graphics,
846 # - Some implementations can not handle --transform at all, so avoid it unless
847 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
848 # - Some implementations can not handle --panning without specifying --fb
849 # explicitly, so avoid it unless necessary.
850 # (See https://github.com/phillipberndt/autorandr/issues/72)
852 fb_dimensions = get_fb_dimensions(new_configuration)
854 base_argv += ["--fb", "%dx%d" % fb_dimensions]
856 # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
859 auxiliary_changes_pre = []
862 remain_active_count = 0
863 for output in outputs:
864 if not new_configuration[output].edid or "off" in new_configuration[output].options:
865 disable_outputs.append(new_configuration[output].option_vector)
867 if output not in current_configuration:
868 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
869 "Don't know how to proceed." % output)
870 if "off" not in current_configuration[output].options:
871 remain_active_count += 1
873 option_vector = new_configuration[output].option_vector
874 if xrandr_version() >= Version("1.3.0"):
875 for option, off_value in (("transform", "none"), ("panning", "0x0")):
876 if option in current_configuration[output].options:
877 auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
880 option_index = option_vector.index("--%s" % option)
881 if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
882 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
885 if not found_top_left_monitor:
886 position = new_configuration[output].options.get("pos", "0x0")
887 if position == "0x0":
888 found_top_left_monitor = True
889 enable_outputs.insert(0, option_vector)
890 elif not found_left_monitor and position.startswith("0x"):
891 found_left_monitor = True
892 enable_outputs.insert(0, option_vector)
893 elif not found_top_monitor and position.endswith("x0"):
894 found_top_monitor = True
895 enable_outputs.insert(0, option_vector)
897 enable_outputs.append(option_vector)
899 enable_outputs.append(option_vector)
901 # Perform pe-change auxiliary changes
902 if auxiliary_changes_pre:
903 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
904 if call_and_retry(argv, dry_run=dry_run) != 0:
905 raise AutorandrException("Command failed: %s" % " ".join(argv))
907 # Disable unused outputs, but make sure that there always is at least one active screen
908 disable_keep = 0 if remain_active_count else 1
909 if len(disable_outputs) > disable_keep:
910 argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
911 if call_and_retry(argv, dry_run=dry_run) != 0:
912 # Disabling the outputs failed. Retry with the next command:
913 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
914 # This does not occur if simultaneously the primary screen is reset.
917 disable_outputs = disable_outputs[-1:] if disable_keep else []
919 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
920 # disable the last two screens. This is a problem, so if this would happen, instead disable only
921 # one screen in the first call below.
922 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
923 # In the context of a xrandr call that changes the display state, `--query' should do nothing
924 disable_outputs.insert(0, ['--query'])
926 # If we did not find a candidate, we might need to inject a call
927 # If there is no output to disable, we will enable 0x and x0 at the same time
928 if not found_top_left_monitor and len(disable_outputs) > 0:
929 # If the call to 0x and x0 is splitted, inject one of them
930 if found_top_monitor and found_left_monitor:
931 enable_outputs.insert(0, enable_outputs[0])
933 # Enable the remaining outputs in pairs of two operations
934 operations = disable_outputs + enable_outputs
935 for index in range(0, len(operations), 2):
936 argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
937 if call_and_retry(argv, dry_run=dry_run) != 0:
938 raise AutorandrException("Command failed: %s" % " ".join(argv))
941 def is_equal_configuration(source_configuration, target_configuration):
943 Check if all outputs from target are already configured correctly in source and
944 that no other outputs are active.
946 for output in target_configuration.keys():
947 if "off" in target_configuration[output].options:
948 if (output in source_configuration and "off" not in source_configuration[output].options):
951 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
953 for output in source_configuration.keys():
954 if "off" in source_configuration[output].options:
955 if output in target_configuration and "off" not in target_configuration[output].options:
958 if output not in target_configuration:
963 def add_unused_outputs(source_configuration, target_configuration):
964 "Add outputs that are missing in target to target, in 'off' state"
965 for output_name, output in source_configuration.items():
966 if output_name not in target_configuration:
967 target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
970 def remove_irrelevant_outputs(source_configuration, target_configuration):
971 "Remove outputs from target that ought to be 'off' and already are"
972 for output_name, output in source_configuration.items():
973 if "off" in output.options:
974 if output_name in target_configuration:
975 if "off" in target_configuration[output_name].options:
976 del target_configuration[output_name]
979 def generate_virtual_profile(configuration, modes, profile_name):
980 "Generate one of the virtual profiles"
981 configuration = copy.deepcopy(configuration)
982 if profile_name == "common":
984 for output, output_modes in modes.items():
986 if configuration[output].edid:
987 for mode in output_modes:
988 mode_set.add((mode["width"], mode["height"]))
989 mode_sets.append(mode_set)
990 common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
991 common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
992 if common_resolution:
993 for output in configuration:
994 configuration[output].options = {}
995 if output in modes and configuration[output].edid:
996 modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
997 modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
998 mode = modes_filtered[0]
999 configuration[output].options["mode"] = mode['name']
1000 configuration[output].options["pos"] = "0x0"
1002 configuration[output].options["off"] = None
1003 elif profile_name in ("horizontal", "vertical"):
1005 if profile_name == "horizontal":
1006 shift_index = "width"
1007 pos_specifier = "%sx0"
1009 shift_index = "height"
1010 pos_specifier = "0x%s"
1012 for output in configuration:
1013 configuration[output].options = {}
1014 if output in modes and configuration[output].edid:
1016 score = int(a["width"]) * int(a["height"])
1020 output_modes = sorted(modes[output], key=key)
1021 mode = output_modes[-1]
1022 configuration[output].options["mode"] = mode["name"]
1023 configuration[output].options["rate"] = mode["rate"]
1024 configuration[output].options["pos"] = pos_specifier % shift
1025 shift += int(mode[shift_index])
1027 configuration[output].options["off"] = None
1028 elif profile_name == "clone-largest":
1029 modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
1030 modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
1031 biggest_resolution = modes_sorted[0]
1032 for output in configuration:
1033 configuration[output].options = {}
1034 if output in modes and configuration[output].edid:
1036 score = int(a["width"]) * int(a["height"])
1040 output_modes = sorted(modes[output], key=key)
1041 mode = output_modes[-1]
1042 configuration[output].options["mode"] = mode["name"]
1043 configuration[output].options["rate"] = mode["rate"]
1044 configuration[output].options["pos"] = "0x0"
1045 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
1046 float(biggest_resolution["height"]) / float(mode["height"]))
1047 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
1048 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
1049 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
1051 configuration[output].options["off"] = None
1052 elif profile_name == "off":
1053 for output in configuration:
1054 for key in list(configuration[output].options.keys()):
1055 del configuration[output].options[key]
1056 configuration[output].options["off"] = None
1057 return configuration
1060 def print_profile_differences(one, another):
1061 "Print the differences between two profiles for debugging"
1064 print("| Differences between the two profiles:")
1065 for output in set(chain.from_iterable((one.keys(), another.keys()))):
1066 if output not in one:
1067 if "off" not in another[output].options:
1068 print("| Output `%s' is missing from the active configuration" % output)
1069 elif output not in another:
1070 if "off" not in one[output].options:
1071 print("| Output `%s' is missing from the new configuration" % output)
1073 for line in one[output].verbose_diff(another[output]):
1074 print("| [Output %s] %s" % (output, line))
1079 "Print help and exit"
1081 for profile in virtual_profiles:
1082 name, description = profile[:2]
1083 description = [description]
1085 while len(description[0]) > max_width + 1:
1086 left_over = description[0][max_width:]
1087 description[0] = description[0][:max_width] + "-"
1088 description.insert(1, " %-15s %s" % ("", left_over))
1089 description = "\n".join(description)
1090 print(" %-15s %s" % (name, description))
1094 def exec_scripts(profile_path, script_name, meta_information=None):
1097 This will run all executables from the profile folder, and global per-user
1098 and system-wide configuration folders, named script_name or residing in
1099 subdirectories named script_name.d.
1101 If profile_path is None, only global scripts will be invoked.
1103 meta_information is expected to be an dictionary. It will be passed to the block scripts
1104 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
1106 Returns True unless any of the scripts exited with non-zero exit status.
1109 env = os.environ.copy()
1110 if meta_information:
1111 for key, value in meta_information.items():
1112 env["AUTORANDR_{}".format(key.upper())] = str(value)
1114 # If there are multiple candidates, the XDG spec tells to only use the first one.
1117 user_profile_path = os.path.expanduser("~/.autorandr")
1118 if not os.path.isdir(user_profile_path):
1119 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1121 candidate_directories = []
1123 candidate_directories.append(profile_path)
1124 candidate_directories.append(user_profile_path)
1125 for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
1126 candidate_directories.append(os.path.join(config_dir, "autorandr"))
1128 for folder in candidate_directories:
1129 if script_name not in ran_scripts:
1130 script = os.path.join(folder, script_name)
1131 if os.access(script, os.X_OK | os.F_OK):
1133 all_ok &= subprocess.call(script, env=env) != 0
1135 raise AutorandrException("Failed to execute user command: %s" % (script,))
1136 ran_scripts.add(script_name)
1138 script_folder = os.path.join(folder, "%s.d" % script_name)
1139 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
1140 for file_name in os.listdir(script_folder):
1141 check_name = "d/%s" % (file_name,)
1142 if check_name not in ran_scripts:
1143 script = os.path.join(script_folder, file_name)
1144 if os.access(script, os.X_OK | os.F_OK):
1146 all_ok &= subprocess.call(script, env=env) != 0
1148 raise AutorandrException("Failed to execute user command: %s" % (script,))
1149 ran_scripts.add(check_name)
1154 def dispatch_call_to_sessions(argv):
1155 """Invoke autorandr for each open local X11 session with the given options.
1157 The function iterates over all processes not owned by root and checks
1158 whether they have DISPLAY and XAUTHORITY variables set. It strips the
1159 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1160 this display has been handled already. If it has not, it forks, changes
1161 uid/gid to the user owning the process, reuses the process's environment
1162 and runs autorandr with the parameters from argv.
1164 This function requires root permissions. It only works for X11 servers that
1165 have at least one non-root process running. It is susceptible for attacks
1166 where one user runs a process with another user's DISPLAY variable - in
1167 this case, it might happen that autorandr is invoked for the other user,
1168 which won't work. Since no other harm than prevention of automated
1169 execution of autorandr can be done this way, the assumption is that in this
1170 situation, the local administrator will handle the situation."""
1172 X11_displays_done = set()
1174 autorandr_binary = os.path.abspath(argv[0])
1175 backup_candidates = {}
1177 def fork_child_autorandr(pwent, process_environ):
1178 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1179 child_pid = os.fork()
1181 # This will throw an exception if any of the privilege changes fails,
1182 # so it should be safe. Also, note that since the environment
1183 # is taken from a process owned by the user, reusing it should
1184 # not leak any information.
1186 os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
1187 except AttributeError:
1188 # Python 2 doesn't have getgrouplist
1190 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1191 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1192 os.chdir(pwent.pw_dir)
1194 os.environ.update(process_environ)
1195 if sys.executable != "" and sys.executable != None:
1196 os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
1198 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1200 os.waitpid(child_pid, 0)
1202 # The following line assumes that user accounts start at 1000 and that no
1203 # one works using the root or another system account. This is rather
1204 # restrictive, but de facto default. If this breaks your use case, set the
1205 # env var AUTORANDR_UID_MIN as appropriate. (Alternatives would be to use
1206 # the UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf; but
1207 # effectively, both values aren't binding in any way.)
1209 if 'AUTORANDR_UID_MIN' in os.environ:
1210 uid_min = int(os.environ['AUTORANDR_UID_MIN'])
1212 for directory in os.listdir("/proc"):
1213 directory = os.path.join("/proc/", directory)
1214 if not os.path.isdir(directory):
1216 environ_file = os.path.join(directory, "environ")
1217 if not os.path.isfile(environ_file):
1219 uid = os.stat(environ_file).st_uid
1224 process_environ = {}
1225 for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
1227 environ_entry = environ_entry.decode("ascii")
1228 except UnicodeDecodeError:
1230 name, sep, value = environ_entry.partition("=")
1232 if name == "DISPLAY" and "." in value:
1233 value = value[:value.find(".")]
1234 process_environ[name] = value
1236 if "DISPLAY" not in process_environ:
1237 # Cannot work with this environment, skip.
1240 # To allow scripts to detect batch invocation (especially useful for predetect)
1241 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1242 process_environ["UID"] = str(uid)
1244 display = process_environ["DISPLAY"]
1246 if "XAUTHORITY" not in process_environ:
1247 # It's very likely that we cannot work with this environment either,
1248 # but keep it as a backup just in case we don't find anything else.
1249 backup_candidates[display] = process_environ
1252 if display not in X11_displays_done:
1254 pwent = pwd.getpwuid(uid)
1256 # User has no pwd entry
1259 fork_child_autorandr(pwent, process_environ)
1260 X11_displays_done.add(display)
1262 # Run autorandr for any users/displays which didn't have a process with
1264 for display, process_environ in backup_candidates.items():
1265 if display not in X11_displays_done:
1267 pwent = pwd.getpwuid(int(process_environ["UID"]))
1269 # User has no pwd entry
1272 fork_child_autorandr(pwent, process_environ)
1273 X11_displays_done.add(display)
1276 def enabled_monitors(config):
1278 for monitor in config:
1279 if "--off" in config[monitor].option_vector:
1281 monitors.append(monitor)
1285 def read_config(options, directory):
1286 """Parse a configuration config.ini from directory and merge it into
1287 the options dictionary"""
1288 config = configparser.ConfigParser()
1289 config.read(os.path.join(directory, "settings.ini"))
1290 if config.has_section("config"):
1291 for key, value in config.items("config"):
1292 options.setdefault("--%s" % key, value)
1296 opts, args = getopt.getopt(
1322 except getopt.GetoptError as e:
1323 print("Failed to parse options: {0}.\n"
1324 "Use --help to get usage information.".format(str(e)),
1326 sys.exit(posix.EX_USAGE)
1328 options = dict(opts)
1330 if "-h" in options or "--help" in options:
1333 if "--version" in options:
1334 print("autorandr " + __version__)
1337 if "--current" in options and "--detected" in options:
1338 print("--current and --detected are mutually exclusive.", file=sys.stderr)
1339 sys.exit(posix.EX_USAGE)
1342 if "--batch" in options:
1343 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1344 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1346 print("--batch mode can only be used by root and if $DISPLAY is unset")
1348 if "AUTORANDR_BATCH_PID" in os.environ:
1349 user = pwd.getpwuid(os.getuid())
1350 user = user.pw_name if user else "#%d" % os.getuid()
1351 print("autorandr running as user %s (started from batch instance)" % user)
1354 profile_symlinks = {}
1356 # Load profiles from each XDG config directory
1357 # The XDG spec says that earlier entries should take precedence, so reverse the order
1358 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1359 system_profile_path = os.path.join(directory, "autorandr")
1360 if os.path.isdir(system_profile_path):
1361 profiles.update(load_profiles(system_profile_path))
1362 profile_symlinks.update(get_symlinks(system_profile_path))
1363 read_config(options, system_profile_path)
1364 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1365 # profile_path is also used later on to store configurations
1366 profile_path = os.path.expanduser("~/.autorandr")
1367 if not os.path.isdir(profile_path):
1368 # Elsewise, follow the XDG specification
1369 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1370 if os.path.isdir(profile_path):
1371 profiles.update(load_profiles(profile_path))
1372 profile_symlinks.update(get_symlinks(profile_path))
1373 read_config(options, profile_path)
1374 except Exception as e:
1375 raise AutorandrException("Failed to load profiles", e)
1377 exec_scripts(None, "predetect")
1379 ignore_lid = "--ignore-lid" in options
1381 config, modes = parse_xrandr_output(
1382 ignore_lid=ignore_lid,
1385 if "--match-edid" in options:
1386 update_profiles_edid(profiles, config)
1390 if "--cycle" in options:
1391 # When cycling through profiles, put the profile least recently used to the top of the list
1393 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
1394 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}
1396 if "--fingerprint" in options:
1397 output_setup(config, sys.stdout)
1400 if "--config" in options:
1401 output_configuration(config, sys.stdout)
1404 if "--skip-options" in options:
1405 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1406 for profile in profiles.values():
1407 for output in profile["config"].values():
1408 output.set_ignored_options(skip_options)
1409 for output in config.values():
1410 output.set_ignored_options(skip_options)
1413 options["--save"] = options["-s"]
1414 if "--save" in options:
1415 if options["--save"] in (x[0] for x in virtual_profiles):
1416 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1417 "This configuration name is a reserved virtual configuration." % options["--save"])
1418 error = check_configuration_pre_save(config)
1420 print("Cannot save current configuration as profile '%s':" % options["--save"])
1424 profile_folder = os.path.join(profile_path, options["--save"])
1425 save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
1426 exec_scripts(profile_folder, "postsave", {
1427 "CURRENT_PROFILE": options["--save"],
1428 "PROFILE_FOLDER": profile_folder,
1429 "MONITORS": ":".join(enabled_monitors(config)),
1431 except AutorandrException as e:
1433 except Exception as e:
1434 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1435 print("Saved current configuration as profile '%s'" % options["--save"])
1439 options["--remove"] = options["-r"]
1440 if "--remove" in options:
1441 if options["--remove"] in (x[0] for x in virtual_profiles):
1442 raise AutorandrException("Cannot remove profile '%s':\n"
1443 "This configuration name is a reserved virtual configuration." % options["--remove"])
1444 if options["--remove"] not in profiles.keys():
1445 raise AutorandrException("Cannot remove profile '%s':\n"
1446 "This profile does not exist." % options["--remove"])
1449 profile_folder = os.path.join(profile_path, options["--remove"])
1450 profile_dirlist = os.listdir(profile_folder)
1451 profile_dirlist.remove("config")
1452 profile_dirlist.remove("setup")
1454 print("Profile folder '%s' contains the following additional files:\n"
1455 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1456 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1457 if response != "yes":
1460 shutil.rmtree(profile_folder)
1461 print("Removed profile '%s'" % options["--remove"])
1463 print("Profile '%s' was not removed" % options["--remove"])
1464 except Exception as e:
1465 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1468 detected_profiles = find_profiles(config, profiles)
1469 load_profile = False
1472 options["--load"] = options["-l"]
1473 if "--load" in options:
1474 load_profile = options["--load"]
1475 elif len(args) == 1:
1476 load_profile = args[0]
1478 # Find the active profile(s) first, for the block script (See #42)
1479 current_profiles = []
1480 for profile_name in profiles.keys():
1481 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1482 if configs_are_equal:
1483 current_profiles.append(profile_name)
1484 block_script_metadata = {
1485 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1486 "CURRENT_PROFILES": ":".join(current_profiles)
1490 for profile_name in profiles.keys():
1491 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1492 if not any(opt in options for opt in ("--current", "--detected", "--list")):
1493 print("%s (blocked)" % profile_name)
1496 is_current_profile = profile_name in current_profiles
1497 if profile_name in detected_profiles:
1498 if len(detected_profiles) == 1:
1500 props.append("(detected)")
1502 index = detected_profiles.index(profile_name) + 1
1503 props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
1504 if index < best_index:
1505 if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
1506 load_profile = profile_name
1508 elif "--detected" in options:
1510 if is_current_profile:
1511 props.append("(current)")
1512 elif "--current" in options:
1514 if any(opt in options for opt in ("--current", "--detected", "--list")):
1515 print("%s" % (profile_name, ))
1517 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1518 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1519 print_profile_differences(config, profiles[profile_name]["config"])
1522 options["--default"] = options["-d"]
1523 if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
1524 load_profile = options["--default"]
1527 if load_profile in profile_symlinks:
1528 if "--debug" in options:
1529 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1530 load_profile = profile_symlinks[load_profile]
1532 if load_profile in (x[0] for x in virtual_profiles):
1533 load_config = generate_virtual_profile(config, modes, load_profile)
1534 scripts_path = os.path.join(profile_path, load_profile)
1537 profile = profiles[load_profile]
1538 load_config = profile["config"]
1539 scripts_path = profile["path"]
1541 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1542 if "--dry-run" not in options:
1543 update_mtime(os.path.join(scripts_path, "config"))
1544 add_unused_outputs(config, load_config)
1545 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1546 print("Config already loaded", file=sys.stderr)
1548 if "--debug" in options and load_config != dict(config):
1549 print("Loading profile '%s'" % load_profile)
1550 print_profile_differences(config, load_config)
1552 remove_irrelevant_outputs(config, load_config)
1555 if "--dry-run" in options:
1556 apply_configuration(load_config, config, True)
1559 "CURRENT_PROFILE": load_profile,
1560 "PROFILE_FOLDER": scripts_path,
1561 "MONITORS": ":".join(enabled_monitors(load_config)),
1563 exec_scripts(scripts_path, "preswitch", script_metadata)
1564 if "--debug" in options:
1565 print("Going to run:")
1566 apply_configuration(load_config, config, True)
1567 apply_configuration(load_config, config, False)
1568 exec_scripts(scripts_path, "postswitch", script_metadata)
1569 except AutorandrException as e:
1570 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1571 except Exception as e:
1572 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1574 if "--dry-run" not in options and "--debug" in options:
1575 new_config, _ = parse_xrandr_output(
1576 ignore_lid=ignore_lid,
1578 if not is_equal_configuration(new_config, load_config):
1579 print("The configuration change did not go as expected:")
1580 print_profile_differences(new_config, load_config)
1585 def exception_handled_main(argv=sys.argv):
1588 except AutorandrException as e:
1589 print(e, file=sys.stderr)
1591 except Exception as e:
1592 if not len(str(e)): # BdbQuit
1593 print("Exception: {0}".format(e.__class__.__name__))
1596 print("Unhandled exception ({0}). Please report this as a bug at "
1597 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1602 if __name__ == '__main__':
1603 exception_handled_main()