]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Batch mode: Prefer processes with XAUTHORITY set as environment templates (See #81)
[deb_pkgs/autorandr.git] / autorandr.py
1 #!/usr/bin/env python
2 # encoding: utf-8
3 #
4 # autorandr.py
5 # Copyright (c) 2015, Phillip Berndt
6 #
7 # Autorandr rewrite in Python
8 #
9 # This script aims to be fully compatible with the original autorandr.
10 #
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.
15 #
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.
20 #
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/>.
23 #
24
25 from __future__ import print_function
26
27 import binascii
28 import copy
29 import getopt
30 import hashlib
31 import os
32 import posix
33 import pwd
34 import re
35 import subprocess
36 import sys
37 import shutil
38 import time
39
40 from collections import OrderedDict
41 from distutils.version import LooseVersion as Version
42 from functools import reduce
43 from itertools import chain
44
45 try:
46     input = raw_input
47 except NameError:
48     pass
49
50 virtual_profiles = [
51     # (name, description, callback)
52     ("common", "Clone all connected outputs at the largest common resolution", None),
53     ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
54     ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
55     ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
56 ]
57
58 help_text = """
59 Usage: autorandr [options]
60
61 -h, --help              get this small help
62 -c, --change            reload current setup
63 -s, --save <profile>    save your current setup to profile <profile>
64 -r, --remove <profile>  remove profile <profile>
65 -l, --load <profile>    load profile <profile>
66 -d, --default <profile> make profile <profile> the default profile
67 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
68                         to skip both in detecting changes and applying a profile
69 --force                 force (re)loading of a profile
70 --fingerprint           fingerprint your current hardware setup
71 --config                dump your current xrandr setup
72 --dry-run               don't change anything, only print the xrandr commands
73 --debug                 enable verbose output
74 --batch                 run autorandr for all users with active X11 sessions
75
76  To prevent a profile from being loaded, place a script called "block" in its
77  directory. The script is evaluated before the screen setup is inspected, and
78  in case of it returning a value of 0 the profile is skipped. This can be used
79  to query the status of a docking station you are about to leave.
80
81  If no suitable profile can be identified, the current configuration is kept.
82  To change this behaviour and switch to a fallback configuration, specify
83  --default <profile>.
84
85  Another script called "postswitch" can be placed in the directory
86  ~/.config/autorandr (or ~/.autorandr if you have an old installation) as well
87  as in any profile directories: The scripts are executed after a mode switch
88  has taken place and can notify window managers.
89
90  The following virtual configurations are available:
91 """.strip()
92
93 class AutorandrException(Exception):
94     def __init__(self, message, original_exception=None, report_bug=False):
95         self.message = message
96         self.report_bug = report_bug
97         if original_exception:
98             self.original_exception = original_exception
99             trace = sys.exc_info()[2]
100             while trace.tb_next:
101                 trace = trace.tb_next
102             self.line = trace.tb_lineno
103             self.file_name = trace.tb_frame.f_code.co_filename
104         else:
105             try:
106                 import inspect
107                 frame = inspect.currentframe().f_back
108                 self.line = frame.f_lineno
109                 self.file_name = frame.f_code.co_filename
110             except:
111                 self.line = None
112                 self.file_name = None
113             self.original_exception = None
114
115         if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
116             self.file_name = None
117
118     def __str__(self):
119         retval = [ self.message ]
120         if self.line:
121             retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
122         if self.original_exception:
123             retval.append(":\n  ")
124             retval.append(str(self.original_exception).replace("\n", "\n  "))
125         if self.report_bug:
126             retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
127                           "\nhttps://github.com/phillipberndt/autorandr/issues"
128                          "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
129         return "".join(retval)
130
131 class XrandrOutput(object):
132     "Represents an XRandR output"
133
134     # This regular expression is used to parse an output in `xrandr --verbose'
135     XRANDR_OUTPUT_REGEXP = """(?x)
136         ^(?P<output>[^ ]+)\s+                                                           # Line starts with output name
137         (?:                                                                             # Differentiate disconnected and connected in first line
138             disconnected |
139             unknown\ connection |
140             (?P<connected>connected)
141         )
142         \s*
143         (?P<primary>primary\ )?                                                         # Might be primary screen
144         (?:\s*
145             (?P<width>[0-9]+)x(?P<height>[0-9]+)                                        # Resolution (might be overridden below!)
146             \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+                                       # Position
147             (?:\(0x[0-9a-fA-F]+\)\s+)?                                                  # XID
148             (?P<rotate>(?:normal|left|right|inverted))\s+                               # Rotation
149             (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)?                                       # Reflection
150         )?                                                                              # .. but everything of the above only if the screen is in use.
151         (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
152         (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?                 # Panning information
153         (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?               # Tracking information
154         (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))?                            # Border information
155         (?:\s*(?:                                                                       # Properties of the output
156             Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) |                                     # Gamma value
157             Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) |                           # Transformation matrix
158             EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) |                               # EDID of the output
159             (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)*                                 # Other properties
160         ))+
161         \s*
162         (?P<modes>(?:
163             (?P<mode_name>\S+).+?\*current.*\s+                                         # Interesting (current) resolution: Extract rate
164              h:\s+width\s+(?P<mode_width>[0-9]+).+\s+
165              v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
166             \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s*                                     # Other resolutions
167         )*)
168     """
169
170     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
171         (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
172          h:\s+width\s+(?P<width>[0-9]+).+\s+
173          v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
174     """
175
176     XRANDR_13_DEFAULTS = {
177         "transform": "1,0,0,0,1,0,0,0,1",
178         "panning": "0x0",
179     }
180
181     XRANDR_12_DEFAULTS = {
182         "reflect": "normal",
183         "rotate": "normal",
184         "gamma": "1.0:1.0:1.0",
185     }
186
187     XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
188
189     EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
190
191     def __repr__(self):
192         return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
193
194     @property
195     def short_edid(self):
196         return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
197
198     @property
199     def options_with_defaults(self):
200         "Return the options dictionary, augmented with the default values that weren't set"
201         if "off" in self.options:
202             return self.options
203         options = {}
204         if xrandr_version() >= Version("1.3"):
205             options.update(self.XRANDR_13_DEFAULTS)
206         if xrandr_version() >= Version("1.2"):
207             options.update(self.XRANDR_12_DEFAULTS)
208         options.update(self.options)
209         return { a: b for a, b in options.items() if a not in self.ignored_options }
210
211     @property
212     def filtered_options(self):
213         "Return a dictionary of options without ignored options"
214         return { a: b for a, b in self.options.items() if a not in self.ignored_options }
215
216     @property
217     def option_vector(self):
218         "Return the command line parameters for XRandR for this instance"
219         return sum([["--%s" % option[0], option[1]] if option[1] else ["--%s" % option[0]] for option in chain((("output", self.output),), sorted(self.options_with_defaults.items()))], [])
220
221     @property
222     def option_string(self):
223         "Return the command line parameters in the configuration file format"
224         return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), sorted(self.filtered_options.items()))])
225
226     @property
227     def sort_key(self):
228         "Return a key to sort the outputs for xrandr invocation"
229         if not self.edid:
230             return -2
231         if "off" in self.options:
232             return -1
233         if "pos" in self.options:
234             x, y = map(float, self.options["pos"].split("x"))
235         else:
236             x, y = 0, 0
237         return x + 10000 * y
238
239     def __init__(self, output, edid, options):
240         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
241         self.output = output
242         self.edid = edid
243         self.options = options
244         self.ignored_options = []
245         self.remove_default_option_values()
246
247     def set_ignored_options(self, options):
248         "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
249         self.ignored_options = list(options)
250
251     def remove_default_option_values(self):
252         "Remove values from the options dictionary that are superflous"
253         if "off" in self.options and len(self.options.keys()) > 1:
254             self.options = { "off": None }
255             return
256         for option, default_value in self.XRANDR_DEFAULTS.items():
257             if option in self.options and self.options[option] == default_value:
258                 del self.options[option]
259
260     @classmethod
261     def from_xrandr_output(cls, xrandr_output):
262         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
263
264         This method also returns a list of modes supported by the output.
265         """
266         try:
267             xrandr_output = xrandr_output.replace("\r\n", "\n")
268             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
269         except:
270             raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.", report_bug = True)
271         if not match_object:
272             debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
273             raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug, report_bug = True)
274         remainder = xrandr_output[len(match_object.group(0)):]
275         if remainder:
276             raise AutorandrException(("Parsing XRandR output failed, %d bytes left unmatched after regular expression, "
277                                 "starting at byte %d with ..'%s'.") % (len(remainder), len(match_object.group(0)), remainder[:10]), report_bug=True)
278
279         match = match_object.groupdict()
280
281         modes = []
282         if match["modes"]:
283             modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) if x.group("name") ]
284             if not modes:
285                 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
286
287         options = {}
288         if not match["connected"]:
289             edid = None
290         else:
291             edid = "".join(match["edid"].strip().split()) if match["edid"] else "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
292
293         if not match["width"]:
294             options["off"] = None
295         else:
296             if match["mode_name"]:
297                 options["mode"] = match["mode_name"]
298             elif match["mode_width"]:
299                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
300             else:
301                 if match["rotate"] not in ("left", "right"):
302                     options["mode"] = "%sx%s" % (match["width"], match["height"])
303                 else:
304                     options["mode"] = "%sx%s" % (match["height"], match["width"])
305             options["rotate"] = match["rotate"]
306             if match["primary"]:
307                 options["primary"] = None
308             if match["reflect"] == "X":
309                 options["reflect"] = "x"
310             elif match["reflect"] == "Y":
311                 options["reflect"] = "y"
312             elif match["reflect"] == "X and Y":
313                 options["reflect"] = "xy"
314             options["pos"] = "%sx%s" % (match["x"], match["y"])
315             if match["panning"]:
316                 panning = [ match["panning"] ]
317                 if match["tracking"]:
318                     panning += [ "/", match["tracking"] ]
319                     if match["border"]:
320                         panning += [ "/", match["border"] ]
321                 options["panning"] = "".join(panning)
322             if match["transform"]:
323                 transformation = ",".join(match["transform"].strip().split())
324                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
325                     options["transform"] = transformation
326                     if not match["mode_name"]:
327                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
328                         # special case is actually required.
329                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
330             if match["gamma"]:
331                 gamma = match["gamma"].strip()
332                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
333                 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
334                 # so we approximate by 1e-10.
335                 gamma = ":".join([ str(max(1e-10, round(1./float(x), 3))) for x in gamma.split(":") ])
336                 options["gamma"] = gamma
337             if match["rate"]:
338                 options["rate"] = match["rate"]
339
340         return XrandrOutput(match["output"], edid, options), modes
341
342     @classmethod
343     def from_config_file(cls, edid_map, configuration):
344         "Instanciate an XrandrOutput from the contents of a configuration file"
345         options = {}
346         for line in configuration.split("\n"):
347             if line:
348                 line = line.split(None, 1)
349                 if line and line[0].startswith("#"):
350                     continue
351                 options[line[0]] = line[1] if len(line) > 1 else None
352
353         edid = None
354
355         if options["output"] in edid_map:
356             edid = edid_map[options["output"]]
357         else:
358             # This fuzzy matching is for legacy autorandr that used sysfs output names
359             fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
360             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
361             if fuzzy_output in fuzzy_edid_map:
362                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
363             elif "off" not in options:
364                 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' is not off in config file." % (options["output"], options["output"]))
365         output = options["output"]
366         del options["output"]
367
368         return XrandrOutput(output, edid, options)
369
370     def edid_equals(self, other):
371         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
372         if self.edid and other.edid:
373             if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
374                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
375             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
376                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
377         return self.edid == other.edid
378
379     def __ne__(self, other):
380         return not (self == other)
381
382     def __eq__(self, other):
383         return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
384
385     def verbose_diff(self, other):
386         "Compare to another XrandrOutput and return a list of human readable differences"
387         diffs = []
388         if not self.edid_equals(other):
389             diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
390         if self.output != other.output:
391             diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
392         if "off" in self.options and "off" not in other.options:
393             diffs.append("The output is disabled currently, but active in the new configuration")
394         elif "off" in other.options and "off" not in self.options:
395             diffs.append("The output is currently enabled, but inactive in the new configuration")
396         else:
397             for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
398                 if name not in other.options:
399                     diffs.append("Option --%s %sis not present in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
400                 elif name not in self.options:
401                     diffs.append("Option --%s (`%s' in the new configuration) is not present currently" % (name, other.options[name]))
402                 elif self.options[name] != other.options[name]:
403                     diffs.append("Option --%s %sis `%s' in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
404         return diffs
405
406 def xrandr_version():
407     "Return the version of XRandR that this system uses"
408     if getattr(xrandr_version, "version", False) is False:
409         version_string = os.popen("xrandr -v").read()
410         try:
411             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
412             xrandr_version.version = Version(version)
413         except AttributeError:
414             xrandr_version.version = Version("1.3.0")
415
416     return xrandr_version.version
417
418 def debug_regexp(pattern, string):
419     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
420     try:
421         import regex
422         bounds = ( 0, len(string) )
423         while bounds[0] != bounds[1]:
424             half = int((bounds[0] + bounds[1]) / 2)
425             if half == bounds[0]:
426                 break
427             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
428         partial_length = bounds[0]
429         return ("Regular expression matched until position "
430               "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
431                                                              string[partial_length:partial_length+10]))
432     except ImportError:
433         pass
434     return "Debug information would be available if the `regex' module was installed."
435
436 def parse_xrandr_output():
437     "Parse the output of `xrandr --verbose' into a list of outputs"
438     xrandr_output = os.popen("xrandr -q --verbose").read()
439     if not xrandr_output:
440         raise AutorandrException("Failed to run xrandr")
441
442     # We are not interested in screens
443     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
444
445     # Split at output boundaries and instanciate an XrandrOutput per output
446     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
447     if len(split_xrandr_output) < 2:
448         raise AutorandrException("No output boundaries found", report_bug=True)
449     outputs = OrderedDict()
450     modes = OrderedDict()
451     for i in range(1, len(split_xrandr_output), 2):
452         output_name = split_xrandr_output[i].split()[0]
453         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
454         outputs[output_name] = output
455         if output_modes:
456             modes[output_name] = output_modes
457
458     return outputs, modes
459
460 def load_profiles(profile_path):
461     "Load the stored profiles"
462
463     profiles = {}
464     for profile in os.listdir(profile_path):
465         config_name = os.path.join(profile_path, profile, "config")
466         setup_name  = os.path.join(profile_path, profile, "setup")
467         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
468             continue
469
470         edids = dict([ x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#" ])
471
472         config = {}
473         buffer = []
474         for line in chain(open(config_name).readlines(), ["output"]):
475             if line[:6] == "output" and buffer:
476                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
477                 buffer = [ line ]
478             else:
479                 buffer.append(line)
480
481         for output_name in list(config.keys()):
482             if config[output_name].edid is None:
483                 del config[output_name]
484
485         profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
486
487     return profiles
488
489 def get_symlinks(profile_path):
490     "Load all symlinks from a directory"
491
492     symlinks = {}
493     for link in os.listdir(profile_path):
494         file_name = os.path.join(profile_path, link)
495         if os.path.islink(file_name):
496             symlinks[link] = os.readlink(file_name)
497
498     return symlinks
499
500 def find_profiles(current_config, profiles):
501     "Find profiles matching the currently connected outputs"
502     detected_profiles = []
503     for profile_name, profile in profiles.items():
504         config = profile["config"]
505         matches = True
506         for name, output in config.items():
507             if not output.edid:
508                 continue
509             if name not in current_config or not output.edid_equals(current_config[name]):
510                 matches = False
511                 break
512         if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
513             continue
514         if matches:
515             detected_profiles.append(profile_name)
516     return detected_profiles
517
518 def profile_blocked(profile_path, meta_information=None):
519     """Check if a profile is blocked.
520
521     meta_information is expected to be an dictionary. It will be passed to the block scripts
522     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
523     """
524     return not exec_scripts(profile_path, "block", meta_information)
525
526 def output_configuration(configuration, config):
527     "Write a configuration file"
528     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
529     for output in outputs:
530         print(configuration[output].option_string, file=config)
531
532 def output_setup(configuration, setup):
533     "Write a setup (fingerprint) file"
534     outputs = sorted(configuration.keys())
535     for output in outputs:
536         if configuration[output].edid:
537             print(output, configuration[output].edid, file=setup)
538
539 def save_configuration(profile_path, configuration):
540     "Save a configuration into a profile"
541     if not os.path.isdir(profile_path):
542         os.makedirs(profile_path)
543     with open(os.path.join(profile_path, "config"), "w") as config:
544         output_configuration(configuration, config)
545     with open(os.path.join(profile_path, "setup"), "w") as setup:
546         output_setup(configuration, setup)
547
548 def update_mtime(filename):
549     "Update a file's mtime"
550     try:
551         os.utime(filename, None)
552         return True
553     except:
554         return False
555
556 def call_and_retry(*args, **kwargs):
557     """Wrapper around subprocess.call that retries failed calls.
558
559     This function calls subprocess.call and on non-zero exit states,
560     waits a second and then retries once. This mitigates #47,
561     a timing issue with some drivers.
562     """
563     if "dry_run" in kwargs:
564         dry_run = kwargs["dry_run"]
565         del kwargs["dry_run"]
566     else:
567         dry_run = False
568     kwargs_redirected = dict(kwargs)
569     if not dry_run:
570         if hasattr(subprocess, "DEVNULL"):
571             kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
572         else:
573             kwargs_redirected["stdout"] = open(os.devnull, "w")
574         kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
575     retval = subprocess.call(*args, **kwargs_redirected)
576     if retval != 0:
577         time.sleep(1)
578         retval = subprocess.call(*args, **kwargs)
579     return retval
580
581 def apply_configuration(new_configuration, current_configuration, dry_run=False):
582     "Apply a configuration"
583     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
584     if dry_run:
585         base_argv = [ "echo", "xrandr" ]
586     else:
587         base_argv = [ "xrandr" ]
588
589     # There are several xrandr / driver bugs we need to take care of here:
590     # - We cannot enable more than two screens at the same time
591     #   See https://github.com/phillipberndt/autorandr/pull/6
592     #   and commits f4cce4d and 8429886.
593     # - We cannot disable all screens
594     #   See https://github.com/phillipberndt/autorandr/pull/20
595     # - We should disable screens before enabling others, because there's
596     #   a limit on the number of enabled screens
597     # - We must make sure that the screen at 0x0 is activated first,
598     #   or the other (first) screen to be activated would be moved there.
599     # - If an active screen already has a transformation and remains active,
600     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
601     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
602     #   at least.)
603     # - Some implementations can not handle --transform at all, so avoid it unless
604     #   necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
605     # - Some implementations can not handle --panning without specifying --fb
606     #   explicitly, so avoid it unless necessary.
607     #   (See https://github.com/phillipberndt/autorandr/issues/72)
608
609     auxiliary_changes_pre = []
610     disable_outputs = []
611     enable_outputs = []
612     remain_active_count = 0
613     for output in outputs:
614         if not new_configuration[output].edid or "off" in new_configuration[output].options:
615             disable_outputs.append(new_configuration[output].option_vector)
616         else:
617             if "off" not in current_configuration[output].options:
618                 remain_active_count += 1
619
620             option_vector = new_configuration[output].option_vector
621             if xrandr_version() >= Version("1.3.0"):
622                 for option in ("transform", "panning"):
623                     if option in current_configuration[output].options:
624                         auxiliary_changes_pre.append(["--output", output, "--%s" % option, "none"])
625                     else:
626                         try:
627                             option_index = option_vector.index("--%s" % option)
628                             if option_vector[option_index+1] == XrandrOutput.XRANDR_DEFAULTS[option]:
629                                 option_vector = option_vector[:option_index] + option_vector[option_index+2:]
630                         except ValueError:
631                             pass
632
633             enable_outputs.append(option_vector)
634
635     # Perform pe-change auxiliary changes
636     if auxiliary_changes_pre:
637         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
638         if call_and_retry(argv, dry_run=dry_run) != 0:
639             raise AutorandrException("Command failed: %s" % " ".join(argv))
640
641     # Disable unused outputs, but make sure that there always is at least one active screen
642     disable_keep = 0 if remain_active_count else 1
643     if len(disable_outputs) > disable_keep:
644         if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs)), dry_run=dry_run) != 0:
645             # Disabling the outputs failed. Retry with the next command:
646             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
647             # This does not occur if simultaneously the primary screen is reset.
648             pass
649         else:
650             disable_outputs = disable_outputs[-1:] if disable_keep else []
651
652     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
653     # disable the last two screens. This is a problem, so if this would happen, instead disable only
654     # one screen in the first call below.
655     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
656         # In the context of a xrandr call that changes the display state, `--query' should do nothing
657         disable_outputs.insert(0, ['--query'])
658
659     # Enable the remaining outputs in pairs of two operations
660     operations = disable_outputs + enable_outputs
661     for index in range(0, len(operations), 2):
662         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
663         if call_and_retry(argv, dry_run=dry_run) != 0:
664             raise AutorandrException("Command failed: %s" % " ".join(argv))
665
666 def is_equal_configuration(source_configuration, target_configuration):
667     "Check if all outputs from target are already configured correctly in source"
668     for output in target_configuration.keys():
669         if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
670             return False
671     return True
672
673 def add_unused_outputs(source_configuration, target_configuration):
674     "Add outputs that are missing in target to target, in 'off' state"
675     for output_name, output in source_configuration.items():
676         if output_name not in target_configuration:
677             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
678
679 def remove_irrelevant_outputs(source_configuration, target_configuration):
680     "Remove outputs from target that ought to be 'off' and already are"
681     for output_name, output in source_configuration.items():
682         if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
683             del target_configuration[output_name]
684
685 def generate_virtual_profile(configuration, modes, profile_name):
686     "Generate one of the virtual profiles"
687     configuration = copy.deepcopy(configuration)
688     if profile_name == "common":
689         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output_modes )) for output, output_modes in modes.items() if configuration[output].edid ]
690         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
691         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
692         if common_resolution:
693             for output in configuration:
694                 configuration[output].options = {}
695                 if output in modes and configuration[output].edid:
696                     configuration[output].options["mode"] = [ x["name"] for x in sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1) if x["width"] == common_resolution[-1][0] and x["height"] == common_resolution[-1][1] ][0]
697                     configuration[output].options["pos"] = "0x0"
698                 else:
699                     configuration[output].options["off"] = None
700     elif profile_name in ("horizontal", "vertical"):
701         shift = 0
702         if profile_name == "horizontal":
703             shift_index = "width"
704             pos_specifier = "%sx0"
705         else:
706             shift_index = "height"
707             pos_specifier = "0x%s"
708
709         for output in configuration:
710             configuration[output].options = {}
711             if output in modes and configuration[output].edid:
712                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
713                 configuration[output].options["mode"] = mode["name"]
714                 configuration[output].options["rate"] = mode["rate"]
715                 configuration[output].options["pos"] = pos_specifier % shift
716                 shift += int(mode[shift_index])
717             else:
718                 configuration[output].options["off"] = None
719     elif profile_name == "clone-largest":
720         biggest_resolution = sorted([output_modes[0] for output, output_modes in modes.items()], key=lambda x: int(x["width"])*int(x["height"]), reverse=True)[0]
721         for output in configuration:
722             configuration[output].options = {}
723             if output in modes and configuration[output].edid:
724                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
725                 configuration[output].options["mode"] = mode["name"]
726                 configuration[output].options["rate"] = mode["rate"]
727                 configuration[output].options["pos"] = "0x0"
728                 scale = max(float(biggest_resolution["width"]) / float(mode["width"]) ,float(biggest_resolution["height"]) / float(mode["height"]))
729                 mov_x = (float(mode["width"])*scale-float(biggest_resolution["width"]))/-2
730                 mov_y = (float(mode["height"])*scale-float(biggest_resolution["height"]))/-2
731                 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
732             else:
733                 configuration[output].options["off"] = None
734     return configuration
735
736 def print_profile_differences(one, another):
737     "Print the differences between two profiles for debugging"
738     if one == another:
739         return
740     print("| Differences between the two profiles:", file=sys.stderr)
741     for output in set(chain.from_iterable((one.keys(), another.keys()))):
742         if output not in one:
743             if "off" not in another[output].options:
744                 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
745         elif output not in another:
746             if "off" not in one[output].options:
747                 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
748         else:
749             for line in one[output].verbose_diff(another[output]):
750                 print("| [Output %s] %s" % (output, line), file=sys.stderr)
751     print ("\\-", file=sys.stderr)
752
753 def exit_help():
754     "Print help and exit"
755     print(help_text)
756     for profile in virtual_profiles:
757         name, description = profile[:2]
758         description = [ description ]
759         max_width = 78-18
760         while len(description[0]) > max_width + 1:
761             left_over = description[0][max_width:]
762             description[0] = description[0][:max_width] + "-"
763             description.insert(1, "  %-15s %s" % ("", left_over))
764         description = "\n".join(description)
765         print("  %-15s %s" % (name, description))
766     sys.exit(0)
767
768 def exec_scripts(profile_path, script_name, meta_information=None):
769     """"Run userscripts
770
771     This will run all executables from the profile folder, and global per-user
772     and system-wide configuration folders, named script_name or residing in
773     subdirectories named script_name.d.
774
775     If profile_path is None, only global scripts will be invoked.
776
777     meta_information is expected to be an dictionary. It will be passed to the block scripts
778     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
779
780     Returns True unless any of the scripts exited with non-zero exit status.
781     """
782     all_ok = True
783     if meta_information:
784         env = os.environ.copy()
785         env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
786     else:
787         env = os.environ.copy()
788
789     # If there are multiple candidates, the XDG spec tells to only use the first one.
790     ran_scripts = set()
791
792     user_profile_path = os.path.expanduser("~/.autorandr")
793     if not os.path.isdir(user_profile_path):
794         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
795
796     candidate_directories = chain((user_profile_path,), (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")))
797     if profile_path:
798         candidate_directories = chain((profile_path,), candidate_directories)
799
800     for folder in candidate_directories:
801
802         if script_name not in ran_scripts:
803             script = os.path.join(folder, script_name)
804             if os.access(script, os.X_OK | os.F_OK):
805                 try:
806                     all_ok &= subprocess.call(script, env=env) != 0
807                 except:
808                     raise AutorandrException("Failed to execute user command: %s" % (script,))
809                 ran_scripts.add(script_name)
810
811         script_folder = os.path.join(folder, "%s.d" % script_name)
812         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
813             for file_name in os.listdir(script_folder):
814                 check_name = "d/%s" % (file_name,)
815                 if check_name not in ran_scripts:
816                     script = os.path.join(script_folder, file_name)
817                     if os.access(script, os.X_OK | os.F_OK):
818                         try:
819                             all_ok &= subprocess.call(script, env=env) != 0
820                         except:
821                             raise AutorandrException("Failed to execute user command: %s" % (script,))
822                         ran_scripts.add(check_name)
823
824     return all_ok
825
826 def dispatch_call_to_sessions(argv):
827     """Invoke autorandr for each open local X11 session with the given options.
828
829     The function iterates over all processes not owned by root and checks
830     whether they have DISPLAY and XAUTHORITY variables set. It strips the
831     screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
832     this display has been handled already. If it has not, it forks, changes
833     uid/gid to the user owning the process, reuses the process's environment
834     and runs autorandr with the parameters from argv.
835
836     This function requires root permissions. It only works for X11 servers that
837     have at least one non-root process running. It is susceptible for attacks
838     where one user runs a process with another user's DISPLAY variable - in
839     this case, it might happen that autorandr is invoked for the other user,
840     which won't work. Since no other harm than prevention of automated
841     execution of autorandr can be done this way, the assumption is that in this
842     situation, the local administrator will handle the situation."""
843
844     X11_displays_done = set()
845
846     autorandr_binary = os.path.abspath(argv[0])
847     backup_candidates = {}
848
849     def fork_child_autorandr(pwent, process_environ):
850         print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
851         child_pid = os.fork()
852         if child_pid == 0:
853             # This will throw an exception if any of the privilege changes fails,
854             # so it should be safe. Also, note that since the environment
855             # is taken from a process owned by the user, reusing it should
856             # not leak any information.
857             os.setgroups([])
858             os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
859             os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
860             os.chdir(pwent.pw_dir)
861             os.environ.clear()
862             os.environ.update(process_environ)
863             os.execl(autorandr_binary, autorandr_binary, *argv[1:])
864             os.exit(1)
865         os.waitpid(child_pid, 0)
866
867     for directory in os.listdir("/proc"):
868         directory = os.path.join("/proc/", directory)
869         if not os.path.isdir(directory):
870             continue
871         environ_file = os.path.join(directory, "environ")
872         if not os.path.isfile(environ_file):
873             continue
874         uid = os.stat(environ_file).st_uid
875
876         # The following line assumes that user accounts start at 1000 and that
877         # no one works using the root or another system account. This is rather
878         # restrictive, but de facto default. Alternatives would be to use the
879         # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
880         # but effectively, both values aren't binding in any way.
881         # If this breaks your use case, please file a bug on Github.
882         if uid < 1000:
883             continue
884
885         process_environ = {}
886         for environ_entry in open(environ_file).read().split("\0"):
887             if "=" in environ_entry:
888                 name, value = environ_entry.split("=", 1)
889                 if name == "DISPLAY" and "." in value:
890                     value = value[:value.find(".")]
891                 process_environ[name] = value
892
893         if "DISPLAY" not in process_environ:
894             # Cannot work with this environment, skip.
895             continue
896
897         # To allow scripts to detect batch invocation (especially useful for predetect)
898         process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
899         process_environ["UID"] = str(uid)
900
901         display = process_environ["DISPLAY"]
902
903         if "XAUTHORITY" not in process_environ:
904             # It's very likely that we cannot work with this environment either,
905             # but keep it as a backup just in case we don't find anything else.
906             backup_candidates[display] = process_environ
907             continue
908
909         if display not in X11_displays_done:
910             try:
911                 pwent = pwd.getpwuid(uid)
912             except KeyError:
913                 # User has no pwd entry
914                 continue
915
916             fork_child_autorandr(pwent, process_environ)
917             X11_displays_done.add(display)
918
919     # Run autorandr for any users/displays which didn't have a process with
920     # XAUTHORITY set.
921     for display, process_environ in backup_candidates.items():
922         if display not in X11_displays_done:
923             try:
924                 pwent = pwd.getpwuid(int(process_environ["UID"]))
925             except KeyError:
926                 # User has no pwd entry
927                 continue
928
929             fork_child_autorandr(pwent, process_environ)
930             X11_displays_done.add(display)
931
932 def main(argv):
933     try:
934         options = dict(getopt.getopt(argv[1:], "s:r:l:d:cfh", [ "batch", "dry-run", "change", "default=", "save=", "remove=", "load=", "force", "fingerprint", "config", "debug", "skip-options=", "help" ])[0])
935     except getopt.GetoptError as e:
936         print("Failed to parse options: {0}.\n"
937               "Use --help to get usage information.".format(str(e)),
938               file=sys.stderr)
939         sys.exit(posix.EX_USAGE)
940
941     if "-h" in options or "--help" in options:
942         exit_help()
943
944     # Batch mode
945     if "--batch" in options:
946         if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
947             dispatch_call_to_sessions([ x for x in argv if x != "--batch" ])
948         else:
949             print("--batch mode can only be used by root and if $DISPLAY is unset")
950         return
951     if "AUTORANDR_BATCH_PID" in os.environ:
952         user = pwd.getpwuid(os.getuid())
953         user = user.pw_name if user else "#%d" % os.getuid()
954         print("autorandr running as user %s (started from batch instance)" % user)
955
956     profiles = {}
957     profile_symlinks = {}
958     try:
959         # Load profiles from each XDG config directory
960         # The XDG spec says that earlier entries should take precedence, so reverse the order
961         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
962             system_profile_path = os.path.join(directory, "autorandr")
963             if os.path.isdir(system_profile_path):
964                 profiles.update(load_profiles(system_profile_path))
965                 profile_symlinks.update(get_symlinks(system_profile_path))
966         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
967         # profile_path is also used later on to store configurations
968         profile_path = os.path.expanduser("~/.autorandr")
969         if not os.path.isdir(profile_path):
970             # Elsewise, follow the XDG specification
971             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
972         if os.path.isdir(profile_path):
973             profiles.update(load_profiles(profile_path))
974             profile_symlinks.update(get_symlinks(profile_path))
975         # Sort by descending mtime
976         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
977     except Exception as e:
978         raise AutorandrException("Failed to load profiles", e)
979
980     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 }
981
982     exec_scripts(None, "predetect")
983     config, modes = parse_xrandr_output()
984
985     if "--fingerprint" in options:
986         output_setup(config, sys.stdout)
987         sys.exit(0)
988
989     if "--config" in options:
990         output_configuration(config, sys.stdout)
991         sys.exit(0)
992
993     if "--skip-options" in options:
994         skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
995         for profile in profiles.values():
996             for output in profile["config"].values():
997                 output.set_ignored_options(skip_options)
998         for output in config.values():
999             output.set_ignored_options(skip_options)
1000
1001     if "-s" in options:
1002         options["--save"] = options["-s"]
1003     if "--save" in options:
1004         if options["--save"] in ( x[0] for x in virtual_profiles ):
1005             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
1006         try:
1007             profile_folder = os.path.join(profile_path, options["--save"])
1008             save_configuration(profile_folder, config)
1009             exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
1010         except Exception as e:
1011             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1012         print("Saved current configuration as profile '%s'" % options["--save"])
1013         sys.exit(0)
1014
1015     if "-r" in options:
1016         options["--remove"] = options["-r"]
1017     if "--remove" in options:
1018         if options["--remove"] in ( x[0] for x in virtual_profiles ):
1019             raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
1020         if options["--remove"] not in profiles.keys():
1021             raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
1022         try:
1023             remove = True
1024             profile_folder = os.path.join(profile_path, options["--remove"])
1025             profile_dirlist = os.listdir(profile_folder)
1026             profile_dirlist.remove("config")
1027             profile_dirlist.remove("setup")
1028             if profile_dirlist:
1029                 print("Profile folder '%s' contains the following additional files:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1030                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1031                 if response != "yes":
1032                     remove = False
1033             if remove is True:
1034                 shutil.rmtree(profile_folder)
1035                 print("Removed profile '%s'" % options["--remove"])
1036             else:
1037                 print("Profile '%s' was not removed" % options["--remove"])
1038         except Exception as e:
1039             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1040         sys.exit(0)
1041
1042     detected_profiles = find_profiles(config, profiles)
1043     load_profile = False
1044
1045     if "-l" in options:
1046         options["--load"] = options["-l"]
1047     if "--load" in options:
1048         load_profile = options["--load"]
1049     else:
1050         # Find the active profile(s) first, for the block script (See #42)
1051         current_profiles = []
1052         for profile_name in profiles.keys():
1053             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1054             if configs_are_equal:
1055                 current_profiles.append(profile_name)
1056         block_script_metadata = {
1057             "CURRENT_PROFILE":  "".join(current_profiles[:1]),
1058             "CURRENT_PROFILES": ":".join(current_profiles)
1059         }
1060
1061         for profile_name in profiles.keys():
1062             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1063                 print("%s (blocked)" % profile_name, file=sys.stderr)
1064                 continue
1065             props = []
1066             if profile_name in detected_profiles:
1067                 props.append("(detected)")
1068                 if ("-c" in options or "--change" in options) and not load_profile:
1069                     load_profile = profile_name
1070             if profile_name in current_profiles:
1071                 props.append("(current)")
1072             print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
1073             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1074                 print_profile_differences(config, profiles[profile_name]["config"])
1075
1076     if "-d" in options:
1077         options["--default"] = options["-d"]
1078     if not load_profile and "--default" in options:
1079         load_profile = options["--default"]
1080
1081     if load_profile:
1082         if load_profile in profile_symlinks:
1083             if "--debug" in options:
1084                 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1085             load_profile = profile_symlinks[load_profile]
1086
1087         if load_profile in ( x[0] for x in virtual_profiles ):
1088             load_config = generate_virtual_profile(config, modes, load_profile)
1089             scripts_path = os.path.join(profile_path, load_profile)
1090         else:
1091             try:
1092                 profile = profiles[load_profile]
1093                 load_config = profile["config"]
1094                 scripts_path = profile["path"]
1095             except KeyError:
1096                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1097             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1098                 update_mtime(os.path.join(scripts_path, "config"))
1099         add_unused_outputs(config, load_config)
1100         if load_config == dict(config) and not "-f" in options and not "--force" in options:
1101             print("Config already loaded", file=sys.stderr)
1102             sys.exit(0)
1103         if "--debug" in options and load_config != dict(config):
1104             print("Loading profile '%s'" % load_profile)
1105             print_profile_differences(config, load_config)
1106
1107         remove_irrelevant_outputs(config, load_config)
1108
1109         try:
1110             if "--dry-run" in options:
1111                 apply_configuration(load_config, config, True)
1112             else:
1113                 script_metadata = {
1114                     "CURRENT_PROFILE": load_profile,
1115                     "PROFILE_FOLDER": scripts_path,
1116                 }
1117                 exec_scripts(scripts_path, "preswitch", script_metadata)
1118                 if "--debug" in options:
1119                     print("Going to run:")
1120                     apply_configuration(load_config, config, True)
1121                 apply_configuration(load_config, config, False)
1122                 exec_scripts(scripts_path, "postswitch", script_metadata)
1123         except AutorandrException as e:
1124             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1125         except Exception as e:
1126             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1127
1128         if "--dry-run" not in options and "--debug" in options:
1129             new_config, _ = parse_xrandr_output()
1130             if not is_equal_configuration(new_config, load_config):
1131                 print("The configuration change did not go as expected:")
1132                 print_profile_differences(new_config, load_config)
1133
1134     sys.exit(0)
1135
1136 def exception_handled_main(argv=sys.argv):
1137     try:
1138         main(sys.argv)
1139     except AutorandrException as e:
1140         print(e, file=sys.stderr)
1141         sys.exit(1)
1142     except Exception as e:
1143         if not len(str(e)):  # BdbQuit
1144             print("Exception: {0}".format(e.__class__.__name__))
1145             sys.exit(2)
1146
1147         print("Unhandled exception ({0}). Please report this as a bug at https://github.com/phillipberndt/autorandr/issues.".format(e), file=sys.stderr)
1148         raise
1149
1150 if __name__ == '__main__':
1151     exception_handled_main()