summaryrefslogtreecommitdiffstats
path: root/python/mozbuild/mozbuild/base.py
blob: 9822a9b76e5388364c65a12fa76edc69ae6d9b8f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

import errno
import io
import json
import logging
import multiprocessing
import os
import subprocess
import sys
from pathlib import Path

import mozpack.path as mozpath
import six
from mach.mixin.process import ProcessExecutionMixin
from mozboot.mozconfig import MozconfigFindException
from mozfile import which
from mozversioncontrol import (
    GitRepository,
    HgRepository,
    InvalidRepoPath,
    MissingConfigureInfo,
    MissingVCSTool,
    get_repository_from_build_config,
    get_repository_object,
)

from .backend.configenvironment import ConfigEnvironment, ConfigStatusFailure
from .configure import ConfigureSandbox
from .controller.clobber import Clobberer
from .mozconfig import MozconfigLoader, MozconfigLoadException
from .util import memoize, memoized_property

try:
    import psutil
except Exception:
    psutil = None


class BadEnvironmentException(Exception):
    """Base class for errors raised when the build environment is not sane."""


class BuildEnvironmentNotFoundException(BadEnvironmentException, AttributeError):
    """Raised when we could not find a build environment."""


class ObjdirMismatchException(BadEnvironmentException):
    """Raised when the current dir is an objdir and doesn't match the mozconfig."""

    def __init__(self, objdir1, objdir2):
        self.objdir1 = objdir1
        self.objdir2 = objdir2

    def __str__(self):
        return "Objdir mismatch: %s != %s" % (self.objdir1, self.objdir2)


class BinaryNotFoundException(Exception):
    """Raised when the binary is not found in the expected location."""

    def __init__(self, path):
        self.path = path

    def __str__(self):
        return "Binary expected at {} does not exist.".format(self.path)

    def help(self):
        return "It looks like your program isn't built. You can run |./mach build| to build it."


class MozbuildObject(ProcessExecutionMixin):
    """Base class providing basic functionality useful to many modules.

    Modules in this package typically require common functionality such as
    accessing the current config, getting the location of the source directory,
    running processes, etc. This classes provides that functionality. Other
    modules can inherit from this class to obtain this functionality easily.
    """

    def __init__(
        self,
        topsrcdir,
        settings,
        log_manager,
        topobjdir=None,
        mozconfig=MozconfigLoader.AUTODETECT,
        virtualenv_name=None,
    ):
        """Create a new Mozbuild object instance.

        Instances are bound to a source directory, a ConfigSettings instance,
        and a LogManager instance. The topobjdir may be passed in as well. If
        it isn't, it will be calculated from the active mozconfig.
        """
        self.topsrcdir = mozpath.realpath(topsrcdir)
        self.settings = settings

        self.populate_logger()
        self.log_manager = log_manager

        self._make = None
        self._topobjdir = mozpath.realpath(topobjdir) if topobjdir else topobjdir
        self._mozconfig = mozconfig
        self._config_environment = None
        self._virtualenv_name = virtualenv_name or "common"
        self._virtualenv_manager = None

    @classmethod
    def from_environment(cls, cwd=None, detect_virtualenv_mozinfo=True, **kwargs):
        """Create a MozbuildObject by detecting the proper one from the env.

        This examines environment state like the current working directory and
        creates a MozbuildObject from the found source directory, mozconfig, etc.

        The role of this function is to identify a topsrcdir, topobjdir, and
        mozconfig file.

        If the current working directory is inside a known objdir, we always
        use the topsrcdir and mozconfig associated with that objdir.

        If the current working directory is inside a known srcdir, we use that
        topsrcdir and look for mozconfigs using the default mechanism, which
        looks inside environment variables.

        If the current Python interpreter is running from a virtualenv inside
        an objdir, we use that as our objdir.

        If we're not inside a srcdir or objdir, an exception is raised.

        detect_virtualenv_mozinfo determines whether we should look for a
        mozinfo.json file relative to the virtualenv directory. This was
        added to facilitate testing. Callers likely shouldn't change the
        default.
        """

        cwd = os.path.realpath(cwd or os.getcwd())
        topsrcdir = None
        topobjdir = None
        mozconfig = MozconfigLoader.AUTODETECT

        def load_mozinfo(path):
            info = json.load(io.open(path, "rt", encoding="utf-8"))
            topsrcdir = info.get("topsrcdir")
            topobjdir = os.path.dirname(path)
            mozconfig = info.get("mozconfig")
            return topsrcdir, topobjdir, mozconfig

        for dir_path in [str(path) for path in [cwd] + list(Path(cwd).parents)]:
            # If we find a mozinfo.json, we are in the objdir.
            mozinfo_path = os.path.join(dir_path, "mozinfo.json")
            if os.path.isfile(mozinfo_path):
                topsrcdir, topobjdir, mozconfig = load_mozinfo(mozinfo_path)
                break

        if not topsrcdir:
            # See if we're running from a Python virtualenv that's inside an objdir.
            # sys.prefix would look like "$objdir/_virtualenvs/$virtualenv/".
            # Note that virtualenv-based objdir detection work for instrumented builds,
            # because they aren't created in the scoped "instrumentated" objdir.
            # However, working-directory-ancestor-based objdir resolution should fully
            # cover that case.
            mozinfo_path = os.path.join(sys.prefix, "..", "..", "mozinfo.json")
            if detect_virtualenv_mozinfo and os.path.isfile(mozinfo_path):
                topsrcdir, topobjdir, mozconfig = load_mozinfo(mozinfo_path)

        if not topsrcdir:
            topsrcdir = str(Path(__file__).parent.parent.parent.parent.resolve())

        topsrcdir = mozpath.realpath(topsrcdir)
        if topobjdir:
            topobjdir = mozpath.realpath(topobjdir)

            if topsrcdir == topobjdir:
                raise BadEnvironmentException(
                    "The object directory appears "
                    "to be the same as your source directory (%s). This build "
                    "configuration is not supported." % topsrcdir
                )

        # If we can't resolve topobjdir, oh well. We'll figure out when we need
        # one.
        return cls(
            topsrcdir, None, None, topobjdir=topobjdir, mozconfig=mozconfig, **kwargs
        )

    def resolve_mozconfig_topobjdir(self, default=None):
        topobjdir = self.mozconfig.get("topobjdir") or default
        if not topobjdir:
            return None

        if "@CONFIG_GUESS@" in topobjdir:
            topobjdir = topobjdir.replace("@CONFIG_GUESS@", self.resolve_config_guess())

        if not os.path.isabs(topobjdir):
            topobjdir = os.path.abspath(os.path.join(self.topsrcdir, topobjdir))

        return mozpath.normsep(os.path.normpath(topobjdir))

    def build_out_of_date(self, output, dep_file):
        if not os.path.isfile(output):
            print(" Output reference file not found: %s" % output)
            return True
        if not os.path.isfile(dep_file):
            print(" Dependency file not found: %s" % dep_file)
            return True

        deps = []
        with io.open(dep_file, "r", encoding="utf-8", newline="\n") as fh:
            deps = fh.read().splitlines()

        mtime = os.path.getmtime(output)
        for f in deps:
            try:
                dep_mtime = os.path.getmtime(f)
            except OSError as e:
                if e.errno == errno.ENOENT:
                    print(" Input not found: %s" % f)
                    return True
                raise
            if dep_mtime > mtime:
                print(" %s is out of date with respect to %s" % (output, f))
                return True
        return False

    def backend_out_of_date(self, backend_file):
        if not os.path.isfile(backend_file):
            return True

        # Check if any of our output files have been removed since
        # we last built the backend, re-generate the backend if
        # so.
        outputs = []
        with io.open(backend_file, "r", encoding="utf-8", newline="\n") as fh:
            outputs = fh.read().splitlines()
        for output in outputs:
            if not os.path.isfile(mozpath.join(self.topobjdir, output)):
                return True

        dep_file = "%s.in" % backend_file
        return self.build_out_of_date(backend_file, dep_file)

    @property
    def topobjdir(self):
        if self._topobjdir is None:
            self._topobjdir = self.resolve_mozconfig_topobjdir(
                default="obj-@CONFIG_GUESS@"
            )

        return self._topobjdir

    @property
    def virtualenv_manager(self):
        from mach.site import CommandSiteManager
        from mozboot.util import get_state_dir

        if self._virtualenv_manager is None:
            self._virtualenv_manager = CommandSiteManager.from_environment(
                self.topsrcdir,
                lambda: get_state_dir(
                    specific_to_topsrcdir=True, topsrcdir=self.topsrcdir
                ),
                self._virtualenv_name,
                os.path.join(self.topobjdir, "_virtualenvs"),
            )

        return self._virtualenv_manager

    @staticmethod
    @memoize
    def get_base_mozconfig_info(topsrcdir, path, env_mozconfig):
        # env_mozconfig is only useful for unittests, which change the value of
        # the environment variable, which has an impact on autodetection (when
        # path is MozconfigLoader.AUTODETECT), and memoization wouldn't account
        # for it without the explicit (unused) argument.
        out = six.StringIO()
        env = os.environ
        if path and path != MozconfigLoader.AUTODETECT:
            env = dict(env)
            env["MOZCONFIG"] = path

        # We use python configure to get mozconfig content and the value for
        # --target (from mozconfig if necessary, guessed otherwise).

        # Modified configure sandbox that replaces '--help' dependencies with
        # `always`, such that depends functions with a '--help' dependency are
        # not automatically executed when including files. We don't want all of
        # those from init.configure to execute, only a subset.
        class ReducedConfigureSandbox(ConfigureSandbox):
            def depends_impl(self, *args, **kwargs):
                args = tuple(
                    a
                    if not isinstance(a, six.string_types) or a != "--help"
                    else self._always.sandboxed
                    for a in args
                )
                return super(ReducedConfigureSandbox, self).depends_impl(
                    *args, **kwargs
                )

        # This may be called recursively from configure itself for $reasons,
        # so avoid logging to the same logger (configure uses "moz.configure")
        logger = logging.getLogger("moz.configure.reduced")
        handler = logging.StreamHandler(out)
        logger.addHandler(handler)
        # If this were true, logging would still propagate to "moz.configure".
        logger.propagate = False
        sandbox = ReducedConfigureSandbox(
            {},
            environ=env,
            argv=["mach"],
            logger=logger,
        )
        base_dir = os.path.join(topsrcdir, "build", "moz.configure")
        try:
            sandbox.include_file(os.path.join(base_dir, "init.configure"))
            # Force mozconfig options injection before getting the target.
            sandbox._value_for(sandbox["mozconfig_options"])
            return {
                "mozconfig": sandbox._value_for(sandbox["mozconfig"]),
                "target": sandbox._value_for(sandbox["real_target"]),
                "project": sandbox._value_for(sandbox._options["project"]),
                "artifact-builds": sandbox._value_for(
                    sandbox._options["artifact-builds"]
                ),
            }
        except SystemExit:
            print(out.getvalue())
            raise

    @property
    def base_mozconfig_info(self):
        return self.get_base_mozconfig_info(
            self.topsrcdir, self._mozconfig, os.environ.get("MOZCONFIG")
        )

    @property
    def mozconfig(self):
        """Returns information about the current mozconfig file.

        This a dict as returned by MozconfigLoader.read_mozconfig()
        """
        return self.base_mozconfig_info["mozconfig"]

    @property
    def config_environment(self):
        """Returns the ConfigEnvironment for the current build configuration.

        This property is only available once configure has executed.

        If configure's output is not available, this will raise.
        """
        if self._config_environment:
            return self._config_environment

        config_status = os.path.join(self.topobjdir, "config.status")

        if not os.path.exists(config_status) or not os.path.getsize(config_status):
            raise BuildEnvironmentNotFoundException(
                "config.status not available. Run configure."
            )

        try:
            self._config_environment = ConfigEnvironment.from_config_status(
                config_status
            )
        except ConfigStatusFailure as e:
            six.raise_from(
                BuildEnvironmentNotFoundException(
                    "config.status is outdated or broken. Run configure."
                ),
                e,
            )

        return self._config_environment

    @property
    def defines(self):
        return self.config_environment.defines

    @property
    def substs(self):
        return self.config_environment.substs

    @property
    def distdir(self):
        return os.path.join(self.topobjdir, "dist")

    @property
    def bindir(self):
        return os.path.join(self.topobjdir, "dist", "bin")

    @property
    def includedir(self):
        return os.path.join(self.topobjdir, "dist", "include")

    @property
    def statedir(self):
        return os.path.join(self.topobjdir, ".mozbuild")

    @property
    def platform(self):
        """Returns current platform and architecture name"""
        import mozinfo

        platform_name = None
        bits = str(mozinfo.info["bits"])
        if mozinfo.isLinux:
            platform_name = "linux" + bits
        elif mozinfo.isWin:
            platform_name = "win" + bits
        elif mozinfo.isMac:
            platform_name = "macosx" + bits

        return platform_name, bits + "bit"

    @memoized_property
    def repository(self):
        """Get a `mozversioncontrol.Repository` object for the
        top source directory."""
        # We try to obtain a repo using the configured VCS info first.
        # If we don't have a configure context, fall back to auto-detection.
        try:
            return get_repository_from_build_config(self)
        except (
            BuildEnvironmentNotFoundException,
            MissingConfigureInfo,
            MissingVCSTool,
        ):
            pass

        return get_repository_object(self.topsrcdir)

    def reload_config_environment(self):
        """Force config.status to be re-read and return the new value
        of ``self.config_environment``.
        """
        self._config_environment = None
        return self.config_environment

    def mozbuild_reader(
        self, config_mode="build", vcs_revision=None, vcs_check_clean=True
    ):
        """Obtain a ``BuildReader`` for evaluating moz.build files.

        Given arguments, returns a ``mozbuild.frontend.reader.BuildReader``
        that can be used to evaluate moz.build files for this repo.

        ``config_mode`` is either ``build`` or ``empty``. If ``build``,
        ``self.config_environment`` is used. This requires a configured build
        system to work. If ``empty``, an empty config is used. ``empty`` is
        appropriate for file-based traversal mode where ``Files`` metadata is
        read.

        If ``vcs_revision`` is defined, it specifies a version control revision
        to use to obtain files content. The default is to use the filesystem.
        This mode is only supported with Mercurial repositories.

        If ``vcs_revision`` is not defined and the version control checkout is
        sparse, this implies ``vcs_revision='.'``.

        If ``vcs_revision`` is ``.`` (denotes the parent of the working
        directory), we will verify that the working directory is clean unless
        ``vcs_check_clean`` is False. This prevents confusion due to uncommitted
        file changes not being reflected in the reader.
        """
        from mozpack.files import MercurialRevisionFinder

        from mozbuild.frontend.reader import BuildReader, EmptyConfig, default_finder

        if config_mode == "build":
            config = self.config_environment
        elif config_mode == "empty":
            config = EmptyConfig(self.topsrcdir)
        else:
            raise ValueError("unknown config_mode value: %s" % config_mode)

        try:
            repo = self.repository
        except InvalidRepoPath:
            repo = None

        if (
            repo
            and repo != "SOURCE"
            and not vcs_revision
            and repo.sparse_checkout_present()
        ):
            vcs_revision = "."

        if vcs_revision is None:
            finder = default_finder
        else:
            # If we failed to detect the repo prior, check again to raise its
            # exception.
            if not repo:
                self.repository
                assert False

            if repo.name != "hg":
                raise Exception("do not support VCS reading mode for %s" % repo.name)

            if vcs_revision == "." and vcs_check_clean:
                with repo:
                    if not repo.working_directory_clean():
                        raise Exception(
                            "working directory is not clean; "
                            "refusing to use a VCS-based finder"
                        )

            finder = MercurialRevisionFinder(
                self.topsrcdir, rev=vcs_revision, recognize_repo_paths=True
            )

        return BuildReader(config, finder=finder)

    def is_clobber_needed(self):
        if not os.path.exists(self.topobjdir):
            return False
        return Clobberer(self.topsrcdir, self.topobjdir).clobber_needed()

    def get_binary_path(self, what="app", validate_exists=True, where="default"):
        """Obtain the path to a compiled binary for this build configuration.

        The what argument is the program or tool being sought after. See the
        code implementation for supported values.

        If validate_exists is True (the default), we will ensure the found path
        exists before returning, raising an exception if it doesn't.

        If where is 'staged-package', we will return the path to the binary in
        the package staging directory.

        If no arguments are specified, we will return the main binary for the
        configured XUL application.
        """

        if where not in ("default", "staged-package"):
            raise Exception("Don't know location %s" % where)

        substs = self.substs

        stem = self.distdir
        if where == "staged-package":
            stem = os.path.join(stem, substs["MOZ_APP_NAME"])

        if substs["OS_ARCH"] == "Darwin" and "MOZ_MACBUNDLE_NAME" in substs:
            stem = os.path.join(stem, substs["MOZ_MACBUNDLE_NAME"], "Contents", "MacOS")
        elif where == "default":
            stem = os.path.join(stem, "bin")

        leaf = None

        leaf = (substs["MOZ_APP_NAME"] if what == "app" else what) + substs[
            "BIN_SUFFIX"
        ]
        path = os.path.join(stem, leaf)

        if validate_exists and not os.path.exists(path):
            raise BinaryNotFoundException(path)

        return path

    def resolve_config_guess(self):
        return self.base_mozconfig_info["target"].alias

    def notify(self, msg):
        """Show a desktop notification with the supplied message

        On Linux and Mac, this will show a desktop notification with the message,
        but on Windows we can only flash the screen.
        """
        if "MOZ_NOSPAM" in os.environ or "MOZ_AUTOMATION" in os.environ:
            return

        try:
            if sys.platform.startswith("darwin"):
                notifier = which("terminal-notifier")
                if not notifier:
                    raise Exception(
                        "Install terminal-notifier to get "
                        "a notification when the build finishes."
                    )
                self.run_process(
                    [
                        notifier,
                        "-title",
                        "Mozilla Build System",
                        "-group",
                        "mozbuild",
                        "-message",
                        msg,
                    ],
                    ensure_exit_code=False,
                )
            elif sys.platform.startswith("win"):
                from ctypes import POINTER, WINFUNCTYPE, Structure, sizeof, windll
                from ctypes.wintypes import BOOL, DWORD, HANDLE, UINT

                class FLASHWINDOW(Structure):
                    _fields_ = [
                        ("cbSize", UINT),
                        ("hwnd", HANDLE),
                        ("dwFlags", DWORD),
                        ("uCount", UINT),
                        ("dwTimeout", DWORD),
                    ]

                FlashWindowExProto = WINFUNCTYPE(BOOL, POINTER(FLASHWINDOW))
                FlashWindowEx = FlashWindowExProto(("FlashWindowEx", windll.user32))
                FLASHW_CAPTION = 0x01
                FLASHW_TRAY = 0x02
                FLASHW_TIMERNOFG = 0x0C

                # GetConsoleWindows returns NULL if no console is attached. We
                # can't flash nothing.
                console = windll.kernel32.GetConsoleWindow()
                if not console:
                    return

                params = FLASHWINDOW(
                    sizeof(FLASHWINDOW),
                    console,
                    FLASHW_CAPTION | FLASHW_TRAY | FLASHW_TIMERNOFG,
                    3,
                    0,
                )
                FlashWindowEx(params)
            else:
                notifier = which("notify-send")
                if not notifier:
                    raise Exception(
                        "Install notify-send (usually part of "
                        "the libnotify package) to get a notification when "
                        "the build finishes."
                    )
                self.run_process(
                    [
                        notifier,
                        "--app-name=Mozilla Build System",
                        "Mozilla Build System",
                        msg,
                    ],
                    ensure_exit_code=False,
                )
        except Exception as e:
            self.log(
                logging.WARNING,
                "notifier-failed",
                {"error": str(e)},
                "Notification center failed: {error}",
            )

    def _ensure_objdir_exists(self):
        if os.path.isdir(self.statedir):
            return

        os.makedirs(self.statedir)

    def _ensure_state_subdir_exists(self, subdir):
        path = os.path.join(self.statedir, subdir)

        if os.path.isdir(path):
            return

        os.makedirs(path)

    def _get_state_filename(self, filename, subdir=None):
        path = self.statedir

        if subdir:
            path = os.path.join(path, subdir)

        return os.path.join(path, filename)

    def _wrap_path_argument(self, arg):
        return PathArgument(arg, self.topsrcdir, self.topobjdir)

    def _run_make(
        self,
        directory=None,
        filename=None,
        target=None,
        log=True,
        srcdir=False,
        line_handler=None,
        append_env=None,
        explicit_env=None,
        ignore_errors=False,
        ensure_exit_code=0,
        silent=True,
        print_directory=True,
        pass_thru=False,
        num_jobs=0,
        job_size=0,
        keep_going=False,
    ):
        """Invoke make.

        directory -- Relative directory to look for Makefile in.
        filename -- Explicit makefile to run.
        target -- Makefile target(s) to make. Can be a string or iterable of
            strings.
        srcdir -- If True, invoke make from the source directory tree.
            Otherwise, make will be invoked from the object directory.
        silent -- If True (the default), run make in silent mode.
        print_directory -- If True (the default), have make print directories
        while doing traversal.
        """
        self._ensure_objdir_exists()

        args = [self.substs["GMAKE"]]

        if directory:
            args.extend(["-C", directory.replace(os.sep, "/")])

        if filename:
            args.extend(["-f", filename])

        if num_jobs == 0 and self.mozconfig["make_flags"]:
            flags = iter(self.mozconfig["make_flags"])
            for flag in flags:
                if flag == "-j":
                    try:
                        flag = flags.next()
                    except StopIteration:
                        break
                    try:
                        num_jobs = int(flag)
                    except ValueError:
                        args.append(flag)
                elif flag.startswith("-j"):
                    try:
                        num_jobs = int(flag[2:])
                    except (ValueError, IndexError):
                        break
                else:
                    args.append(flag)

        if num_jobs == 0:
            if job_size == 0:
                job_size = 2.0 if self.substs.get("CC_TYPE") == "gcc" else 1.0  # GiB

            cpus = multiprocessing.cpu_count()
            if not psutil or not job_size:
                num_jobs = cpus
            else:
                mem_gb = psutil.virtual_memory().total / 1024 ** 3
                from_mem = round(mem_gb / job_size)
                num_jobs = max(1, min(cpus, from_mem))
                print(
                    "  Parallelism determined by memory: using %d jobs for %d cores "
                    "based on %.1f GiB RAM and estimated job size of %.1f GiB"
                    % (num_jobs, cpus, mem_gb, job_size)
                )

        args.append("-j%d" % num_jobs)

        if ignore_errors:
            args.append("-k")

        if silent:
            args.append("-s")

        # Print entering/leaving directory messages. Some consumers look at
        # these to measure progress.
        if print_directory:
            args.append("-w")

        if keep_going:
            args.append("-k")

        if isinstance(target, list):
            args.extend(target)
        elif target:
            args.append(target)

        fn = self._run_command_in_objdir

        if srcdir:
            fn = self._run_command_in_srcdir

        append_env = dict(append_env or ())
        append_env["MACH"] = "1"

        params = {
            "args": args,
            "line_handler": line_handler,
            "append_env": append_env,
            "explicit_env": explicit_env,
            "log_level": logging.INFO,
            "require_unix_environment": False,
            "ensure_exit_code": ensure_exit_code,
            "pass_thru": pass_thru,
            # Make manages its children, so mozprocess doesn't need to bother.
            # Having mozprocess manage children can also have side-effects when
            # building on Windows. See bug 796840.
            "ignore_children": True,
        }

        if log:
            params["log_name"] = "make"

        return fn(**params)

    def _run_command_in_srcdir(self, **args):
        return self.run_process(cwd=self.topsrcdir, **args)

    def _run_command_in_objdir(self, **args):
        return self.run_process(cwd=self.topobjdir, **args)

    def _is_windows(self):
        return os.name in ("nt", "ce")

    def _is_osx(self):
        return "darwin" in str(sys.platform).lower()

    def _spawn(self, cls):
        """Create a new MozbuildObject-derived class instance from ourselves.

        This is used as a convenience method to create other
        MozbuildObject-derived class instances. It can only be used on
        classes that have the same constructor arguments as us.
        """

        return cls(
            self.topsrcdir, self.settings, self.log_manager, topobjdir=self.topobjdir
        )

    def activate_virtualenv(self):
        self.virtualenv_manager.activate()

    def _set_log_level(self, verbose):
        self.log_manager.terminal_handler.setLevel(
            logging.INFO if not verbose else logging.DEBUG
        )

    def _ensure_zstd(self):
        try:
            import zstandard  # noqa: F401
        except (ImportError, AttributeError):
            self.activate_virtualenv()
            self.virtualenv_manager.install_pip_requirements(
                os.path.join(self.topsrcdir, "build", "zstandard_requirements.txt")
            )


class MachCommandBase(MozbuildObject):
    """Base class for mach command providers that wish to be MozbuildObjects.

    This provides a level of indirection so MozbuildObject can be refactored
    without having to change everything that inherits from it.
    """

    def __init__(self, context, virtualenv_name=None, metrics=None, no_auto_log=False):
        # Attempt to discover topobjdir through environment detection, as it is
        # more reliable than mozconfig when cwd is inside an objdir.
        topsrcdir = context.topdir
        topobjdir = None
        detect_virtualenv_mozinfo = True
        if hasattr(context, "detect_virtualenv_mozinfo"):
            detect_virtualenv_mozinfo = getattr(context, "detect_virtualenv_mozinfo")
        try:
            dummy = MozbuildObject.from_environment(
                cwd=context.cwd, detect_virtualenv_mozinfo=detect_virtualenv_mozinfo
            )
            topsrcdir = dummy.topsrcdir
            topobjdir = dummy._topobjdir
            if topobjdir:
                # If we're inside a objdir and the found mozconfig resolves to
                # another objdir, we abort. The reasoning here is that if you
                # are inside an objdir you probably want to perform actions on
                # that objdir, not another one. This prevents accidental usage
                # of the wrong objdir when the current objdir is ambiguous.
                config_topobjdir = dummy.resolve_mozconfig_topobjdir()

                if config_topobjdir and not Path(topobjdir).samefile(
                    Path(config_topobjdir)
                ):
                    raise ObjdirMismatchException(topobjdir, config_topobjdir)
        except BuildEnvironmentNotFoundException:
            pass
        except ObjdirMismatchException as e:
            print(
                "Ambiguous object directory detected. We detected that "
                "both %s and %s could be object directories. This is "
                "typically caused by having a mozconfig pointing to a "
                "different object directory from the current working "
                "directory. To solve this problem, ensure you do not have a "
                "default mozconfig in searched paths." % (e.objdir1, e.objdir2)
            )
            sys.exit(1)

        except MozconfigLoadException as e:
            print(e)
            sys.exit(1)

        MozbuildObject.__init__(
            self,
            topsrcdir,
            context.settings,
            context.log_manager,
            topobjdir=topobjdir,
            virtualenv_name=virtualenv_name,
        )

        self._mach_context = context
        self.metrics = metrics

        # Incur mozconfig processing so we have unified error handling for
        # errors. Otherwise, the exceptions could bubble back to mach's error
        # handler.
        try:
            self.mozconfig

        except MozconfigFindException as e:
            print(e)
            sys.exit(1)

        except MozconfigLoadException as e:
            print(e)
            sys.exit(1)

        # Always keep a log of the last command, but don't do that for mach
        # invokations from scripts (especially not the ones done by the build
        # system itself).
        try:
            fileno = getattr(sys.stdout, "fileno", lambda: None)()
        except io.UnsupportedOperation:
            fileno = None
        if fileno and os.isatty(fileno) and not no_auto_log:
            self._ensure_state_subdir_exists(".")
            logfile = self._get_state_filename("last_log.json")
            try:
                fd = open(logfile, "wt")
                self.log_manager.add_json_handler(fd)
            except Exception as e:
                self.log(
                    logging.WARNING,
                    "mach",
                    {"error": str(e)},
                    "Log will not be kept for this command: {error}.",
                )

    def _sub_mach(self, argv):
        return subprocess.call(
            [sys.executable, os.path.join(self.topsrcdir, "mach")] + argv
        )


class MachCommandConditions(object):
    """A series of commonly used condition functions which can be applied to
    mach commands with providers deriving from MachCommandBase.
    """

    @staticmethod
    def is_firefox(cls):
        """Must have a Firefox build."""
        if hasattr(cls, "substs"):
            return cls.substs.get("MOZ_BUILD_APP") == "browser"
        return False

    @staticmethod
    def is_jsshell(cls):
        """Must have a jsshell build."""
        if hasattr(cls, "substs"):
            return cls.substs.get("MOZ_BUILD_APP") == "js"
        return False

    @staticmethod
    def is_thunderbird(cls):
        """Must have a Thunderbird build."""
        if hasattr(cls, "substs"):
            return cls.substs.get("MOZ_BUILD_APP") == "comm/mail"
        return False

    @staticmethod
    def is_firefox_or_thunderbird(cls):
        """Must have a Firefox or Thunderbird build."""
        return MachCommandConditions.is_firefox(
            cls
        ) or MachCommandConditions.is_thunderbird(cls)

    @staticmethod
    def is_android(cls):
        """Must have an Android build."""
        if hasattr(cls, "substs"):
            return cls.substs.get("MOZ_WIDGET_TOOLKIT") == "android"
        return False

    @staticmethod
    def is_not_android(cls):
        """Must not have an Android build."""
        if hasattr(cls, "substs"):
            return cls.substs.get("MOZ_WIDGET_TOOLKIT") != "android"
        return False

    @staticmethod
    def is_firefox_or_android(cls):
        """Must have a Firefox or Android build."""
        return MachCommandConditions.is_firefox(
            cls
        ) or MachCommandConditions.is_android(cls)

    @staticmethod
    def has_build(cls):
        """Must have a build."""
        return MachCommandConditions.is_firefox_or_android(
            cls
        ) or MachCommandConditions.is_thunderbird(cls)

    @staticmethod
    def has_build_or_shell(cls):
        """Must have a build or a shell build."""
        return MachCommandConditions.has_build(cls) or MachCommandConditions.is_jsshell(
            cls
        )

    @staticmethod
    def is_hg(cls):
        """Must have a mercurial source checkout."""
        try:
            return isinstance(cls.repository, HgRepository)
        except InvalidRepoPath:
            return False

    @staticmethod
    def is_git(cls):
        """Must have a git source checkout."""
        try:
            return isinstance(cls.repository, GitRepository)
        except InvalidRepoPath:
            return False

    @staticmethod
    def is_artifact_build(cls):
        """Must be an artifact build."""
        if hasattr(cls, "substs"):
            return getattr(cls, "substs", {}).get("MOZ_ARTIFACT_BUILDS")
        return False

    @staticmethod
    def is_non_artifact_build(cls):
        """Must not be an artifact build."""
        if hasattr(cls, "substs"):
            return not MachCommandConditions.is_artifact_build(cls)
        return False

    @staticmethod
    def is_buildapp_in(cls, apps):
        """Must have a build for one of the given app"""
        for app in apps:
            attr = getattr(MachCommandConditions, "is_{}".format(app), None)
            if attr and attr(cls):
                return True
        return False


class PathArgument(object):
    """Parse a filesystem path argument and transform it in various ways."""

    def __init__(self, arg, topsrcdir, topobjdir, cwd=None):
        self.arg = arg
        self.topsrcdir = topsrcdir
        self.topobjdir = topobjdir
        self.cwd = os.getcwd() if cwd is None else cwd

    def relpath(self):
        """Return a path relative to the topsrcdir or topobjdir.

        If the argument is a path to a location in one of the base directories
        (topsrcdir or topobjdir), then strip off the base directory part and
        just return the path within the base directory."""

        abspath = os.path.abspath(os.path.join(self.cwd, self.arg))

        # If that path is within topsrcdir or topobjdir, return an equivalent
        # path relative to that base directory.
        for base_dir in [self.topobjdir, self.topsrcdir]:
            if abspath.startswith(os.path.abspath(base_dir)):
                return mozpath.relpath(abspath, base_dir)

        return mozpath.normsep(self.arg)

    def srcdir_path(self):
        return mozpath.join(self.topsrcdir, self.relpath())

    def objdir_path(self):
        return mozpath.join(self.topobjdir, self.relpath())


class ExecutionSummary(dict):
    """Helper for execution summaries."""

    def __init__(self, summary_format, **data):
        self._summary_format = ""
        assert "execution_time" in data
        self.extend(summary_format, **data)

    def extend(self, summary_format, **data):
        self._summary_format += summary_format
        self.update(data)

    def __str__(self):
        return self._summary_format.format(**self)

    def __getattr__(self, key):
        return self[key]