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