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