# 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 copy
import hashlib
import itertools
import os
import re
import string
import unittest

import pytest
from mozfile.mozfile import NamedTemporaryFile
from mozunit import main

from mozbuild.util import (
    EnumString,
    EnumStringComparisonError,
    ForwardingArgumentParser,
    HierarchicalStringList,
    MozbuildDeletionError,
    ReadOnlyDict,
    StrictOrderingOnAppendList,
    StrictOrderingOnAppendListWithAction,
    StrictOrderingOnAppendListWithFlagsFactory,
    TypedList,
    TypedNamedTuple,
    UnsortedError,
    expand_variables,
    group_unified_files,
    hash_file,
    hexdump,
    pair,
    resolve_target_to_make,
)

str_type = "str"

data_path = os.path.abspath(os.path.dirname(__file__))
data_path = os.path.join(data_path, "data")


class TestHashing(unittest.TestCase):
    def test_hash_file_known_hash(self):
        """Ensure a known hash value is recreated."""
        data = b"The quick brown fox jumps over the lazy cog"
        expected = "de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b3"

        temp = NamedTemporaryFile()
        temp.write(data)
        temp.flush()

        actual = hash_file(temp.name)

        self.assertEqual(actual, expected)

    def test_hash_file_large(self):
        """Ensure that hash_file seems to work with a large file."""
        data = b"x" * 1048576

        hasher = hashlib.sha1()
        hasher.update(data)
        expected = hasher.hexdigest()

        temp = NamedTemporaryFile()
        temp.write(data)
        temp.flush()

        actual = hash_file(temp.name)

        self.assertEqual(actual, expected)


class TestResolveTargetToMake(unittest.TestCase):
    def setUp(self):
        self.topobjdir = data_path

    def assertResolve(self, path, expected):
        # Handle Windows path separators.
        (reldir, target) = resolve_target_to_make(self.topobjdir, path)
        if reldir is not None:
            reldir = reldir.replace(os.sep, "/")
        if target is not None:
            target = target.replace(os.sep, "/")
        self.assertEqual((reldir, target), expected)

    def test_root_path(self):
        self.assertResolve("/test-dir", ("test-dir", None))
        self.assertResolve("/test-dir/with", ("test-dir/with", None))
        self.assertResolve("/test-dir/without", ("test-dir", None))
        self.assertResolve("/test-dir/without/with", ("test-dir/without/with", None))

    def test_dir(self):
        self.assertResolve("test-dir", ("test-dir", None))
        self.assertResolve("test-dir/with", ("test-dir/with", None))
        self.assertResolve("test-dir/with", ("test-dir/with", None))
        self.assertResolve("test-dir/without", ("test-dir", None))
        self.assertResolve("test-dir/without/with", ("test-dir/without/with", None))

    def test_top_level(self):
        self.assertResolve("package", (None, "package"))
        # Makefile handling shouldn't affect top-level targets.
        self.assertResolve("Makefile", (None, "Makefile"))

    def test_regular_file(self):
        self.assertResolve("test-dir/with/file", ("test-dir/with", "file"))
        self.assertResolve(
            "test-dir/with/without/file", ("test-dir/with", "without/file")
        )
        self.assertResolve(
            "test-dir/with/without/with/file", ("test-dir/with/without/with", "file")
        )

        self.assertResolve("test-dir/without/file", ("test-dir", "without/file"))
        self.assertResolve(
            "test-dir/without/with/file", ("test-dir/without/with", "file")
        )
        self.assertResolve(
            "test-dir/without/with/without/file",
            ("test-dir/without/with", "without/file"),
        )

    def test_Makefile(self):
        self.assertResolve("test-dir/with/Makefile", ("test-dir", "with/Makefile"))
        self.assertResolve(
            "test-dir/with/without/Makefile", ("test-dir/with", "without/Makefile")
        )
        self.assertResolve(
            "test-dir/with/without/with/Makefile",
            ("test-dir/with", "without/with/Makefile"),
        )

        self.assertResolve(
            "test-dir/without/Makefile", ("test-dir", "without/Makefile")
        )
        self.assertResolve(
            "test-dir/without/with/Makefile", ("test-dir", "without/with/Makefile")
        )
        self.assertResolve(
            "test-dir/without/with/without/Makefile",
            ("test-dir/without/with", "without/Makefile"),
        )


class TestHierarchicalStringList(unittest.TestCase):
    def setUp(self):
        self.EXPORTS = HierarchicalStringList()

    def test_exports_append(self):
        self.assertEqual(self.EXPORTS._strings, [])
        self.EXPORTS += ["foo.h"]
        self.assertEqual(self.EXPORTS._strings, ["foo.h"])
        self.EXPORTS += ["bar.h"]
        self.assertEqual(self.EXPORTS._strings, ["foo.h", "bar.h"])

    def test_exports_subdir(self):
        self.assertEqual(self.EXPORTS._children, {})
        self.EXPORTS.foo += ["foo.h"]
        self.assertCountEqual(self.EXPORTS._children, {"foo": True})
        self.assertEqual(self.EXPORTS.foo._strings, ["foo.h"])
        self.EXPORTS.bar += ["bar.h"]
        self.assertCountEqual(self.EXPORTS._children, {"foo": True, "bar": True})
        self.assertEqual(self.EXPORTS.foo._strings, ["foo.h"])
        self.assertEqual(self.EXPORTS.bar._strings, ["bar.h"])

    def test_exports_multiple_subdir(self):
        self.EXPORTS.foo.bar = ["foobar.h"]
        self.assertCountEqual(self.EXPORTS._children, {"foo": True})
        self.assertCountEqual(self.EXPORTS.foo._children, {"bar": True})
        self.assertCountEqual(self.EXPORTS.foo.bar._children, {})
        self.assertEqual(self.EXPORTS._strings, [])
        self.assertEqual(self.EXPORTS.foo._strings, [])
        self.assertEqual(self.EXPORTS.foo.bar._strings, ["foobar.h"])

    def test_invalid_exports_append(self):
        with self.assertRaises(ValueError) as ve:
            self.EXPORTS += "foo.h"
        self.assertRegex(
            str(ve.exception),
            "Expected a list of strings, not <(?:type|class) '%s'>" % str_type,
        )

    def test_invalid_exports_set(self):
        with self.assertRaises(ValueError) as ve:
            self.EXPORTS.foo = "foo.h"

        self.assertRegex(
            str(ve.exception),
            "Expected a list of strings, not <(?:type|class) '%s'>" % str_type,
        )

    def test_invalid_exports_append_base(self):
        with self.assertRaises(ValueError) as ve:
            self.EXPORTS += "foo.h"

        self.assertRegex(
            str(ve.exception),
            "Expected a list of strings, not <(?:type|class) '%s'>" % str_type,
        )

    def test_invalid_exports_bool(self):
        with self.assertRaises(ValueError) as ve:
            self.EXPORTS += [True]

        self.assertRegex(
            str(ve.exception),
            "Expected a list of strings, not an element of <(?:type|class) 'bool'>",
        )

    def test_del_exports(self):
        with self.assertRaises(MozbuildDeletionError):
            self.EXPORTS.foo += ["bar.h"]
            del self.EXPORTS.foo

    def test_unsorted(self):
        with self.assertRaises(UnsortedError):
            self.EXPORTS += ["foo.h", "bar.h"]

        with self.assertRaises(UnsortedError):
            self.EXPORTS.foo = ["foo.h", "bar.h"]

        with self.assertRaises(UnsortedError):
            self.EXPORTS.foo += ["foo.h", "bar.h"]

    def test_reassign(self):
        self.EXPORTS.foo = ["foo.h"]

        with self.assertRaises(KeyError):
            self.EXPORTS.foo = ["bar.h"]

    def test_walk(self):
        l = HierarchicalStringList()
        l += ["root1", "root2", "root3"]
        l.child1 += ["child11", "child12", "child13"]
        l.child1.grandchild1 += ["grandchild111", "grandchild112"]
        l.child1.grandchild2 += ["grandchild121", "grandchild122"]
        l.child2.grandchild1 += ["grandchild211", "grandchild212"]
        l.child2.grandchild1 += ["grandchild213", "grandchild214"]

        els = list((path, list(seq)) for path, seq in l.walk())
        self.assertEqual(
            els,
            [
                ("", ["root1", "root2", "root3"]),
                ("child1", ["child11", "child12", "child13"]),
                ("child1/grandchild1", ["grandchild111", "grandchild112"]),
                ("child1/grandchild2", ["grandchild121", "grandchild122"]),
                (
                    "child2/grandchild1",
                    [
                        "grandchild211",
                        "grandchild212",
                        "grandchild213",
                        "grandchild214",
                    ],
                ),
            ],
        )

    def test_merge(self):
        l1 = HierarchicalStringList()
        l1 += ["root1", "root2", "root3"]
        l1.child1 += ["child11", "child12", "child13"]
        l1.child1.grandchild1 += ["grandchild111", "grandchild112"]
        l1.child1.grandchild2 += ["grandchild121", "grandchild122"]
        l1.child2.grandchild1 += ["grandchild211", "grandchild212"]
        l1.child2.grandchild1 += ["grandchild213", "grandchild214"]
        l2 = HierarchicalStringList()
        l2.child1 += ["child14", "child15"]
        l2.child1.grandchild2 += ["grandchild123"]
        l2.child3 += ["child31", "child32"]

        l1 += l2
        els = list((path, list(seq)) for path, seq in l1.walk())
        self.assertEqual(
            els,
            [
                ("", ["root1", "root2", "root3"]),
                ("child1", ["child11", "child12", "child13", "child14", "child15"]),
                ("child1/grandchild1", ["grandchild111", "grandchild112"]),
                (
                    "child1/grandchild2",
                    ["grandchild121", "grandchild122", "grandchild123"],
                ),
                (
                    "child2/grandchild1",
                    [
                        "grandchild211",
                        "grandchild212",
                        "grandchild213",
                        "grandchild214",
                    ],
                ),
                ("child3", ["child31", "child32"]),
            ],
        )


class TestStrictOrderingOnAppendList(unittest.TestCase):
    def test_init(self):
        l = StrictOrderingOnAppendList()
        self.assertEqual(len(l), 0)

        l = StrictOrderingOnAppendList(["a", "b", "c"])
        self.assertEqual(len(l), 3)

        with self.assertRaises(UnsortedError):
            StrictOrderingOnAppendList(["c", "b", "a"])

        self.assertEqual(len(l), 3)

    def test_extend(self):
        l = StrictOrderingOnAppendList()
        l.extend(["a", "b"])
        self.assertEqual(len(l), 2)
        self.assertIsInstance(l, StrictOrderingOnAppendList)

        with self.assertRaises(UnsortedError):
            l.extend(["d", "c"])

        self.assertEqual(len(l), 2)

    def test_slicing(self):
        l = StrictOrderingOnAppendList()
        l[:] = ["a", "b"]
        self.assertEqual(len(l), 2)
        self.assertIsInstance(l, StrictOrderingOnAppendList)

        with self.assertRaises(UnsortedError):
            l[:] = ["b", "a"]

        self.assertEqual(len(l), 2)

    def test_add(self):
        l = StrictOrderingOnAppendList()
        l2 = l + ["a", "b"]
        self.assertEqual(len(l), 0)
        self.assertEqual(len(l2), 2)
        self.assertIsInstance(l2, StrictOrderingOnAppendList)

        with self.assertRaises(UnsortedError):
            l2 = l + ["b", "a"]

        self.assertEqual(len(l), 0)

    def test_iadd(self):
        l = StrictOrderingOnAppendList()
        l += ["a", "b"]
        self.assertEqual(len(l), 2)
        self.assertIsInstance(l, StrictOrderingOnAppendList)

        with self.assertRaises(UnsortedError):
            l += ["b", "a"]

        self.assertEqual(len(l), 2)

    def test_add_after_iadd(self):
        l = StrictOrderingOnAppendList(["b"])
        l += ["a"]
        l2 = l + ["c", "d"]
        self.assertEqual(len(l), 2)
        self.assertEqual(len(l2), 4)
        self.assertIsInstance(l2, StrictOrderingOnAppendList)
        with self.assertRaises(UnsortedError):
            l2 = l + ["d", "c"]

        self.assertEqual(len(l), 2)

    def test_add_StrictOrderingOnAppendList(self):
        l = StrictOrderingOnAppendList()
        l += ["c", "d"]
        l += ["a", "b"]
        l2 = StrictOrderingOnAppendList()
        with self.assertRaises(UnsortedError):
            l2 += list(l)
        # Adding a StrictOrderingOnAppendList to another shouldn't throw
        l2 += l


class TestStrictOrderingOnAppendListWithAction(unittest.TestCase):
    def setUp(self):
        self.action = lambda a: (a, id(a))

    def assertSameList(self, expected, actual):
        self.assertEqual(len(expected), len(actual))
        for idx, item in enumerate(actual):
            self.assertEqual(item, expected[idx])

    def test_init(self):
        l = StrictOrderingOnAppendListWithAction(action=self.action)
        self.assertEqual(len(l), 0)
        original = ["a", "b", "c"]
        l = StrictOrderingOnAppendListWithAction(["a", "b", "c"], action=self.action)
        expected = [self.action(i) for i in original]
        self.assertSameList(expected, l)

        with self.assertRaises(ValueError):
            StrictOrderingOnAppendListWithAction("abc", action=self.action)

        with self.assertRaises(ValueError):
            StrictOrderingOnAppendListWithAction()

    def test_extend(self):
        l = StrictOrderingOnAppendListWithAction(action=self.action)
        original = ["a", "b"]
        l.extend(original)
        expected = [self.action(i) for i in original]
        self.assertSameList(expected, l)

        with self.assertRaises(ValueError):
            l.extend("ab")

    def test_slicing(self):
        l = StrictOrderingOnAppendListWithAction(action=self.action)
        original = ["a", "b"]
        l[:] = original
        expected = [self.action(i) for i in original]
        self.assertSameList(expected, l)

        with self.assertRaises(ValueError):
            l[:] = "ab"

    def test_add(self):
        l = StrictOrderingOnAppendListWithAction(action=self.action)
        original = ["a", "b"]
        l2 = l + original
        expected = [self.action(i) for i in original]
        self.assertSameList(expected, l2)

        with self.assertRaises(ValueError):
            l + "abc"

    def test_iadd(self):
        l = StrictOrderingOnAppendListWithAction(action=self.action)
        original = ["a", "b"]
        l += original
        expected = [self.action(i) for i in original]
        self.assertSameList(expected, l)

        with self.assertRaises(ValueError):
            l += "abc"


class TestStrictOrderingOnAppendListWithFlagsFactory(unittest.TestCase):
    def test_strict_ordering_on_append_list_with_flags_factory(self):
        cls = StrictOrderingOnAppendListWithFlagsFactory({
            "foo": bool,
            "bar": int,
        })

        l = cls()
        l += ["a", "b"]

        with self.assertRaises(Exception):
            l["a"] = "foo"

        with self.assertRaises(Exception):
            l["c"]

        self.assertEqual(l["a"].foo, False)
        l["a"].foo = True
        self.assertEqual(l["a"].foo, True)

        with self.assertRaises(TypeError):
            l["a"].bar = "bar"

        self.assertEqual(l["a"].bar, 0)
        l["a"].bar = 42
        self.assertEqual(l["a"].bar, 42)

        l["b"].foo = True
        self.assertEqual(l["b"].foo, True)

        with self.assertRaises(AttributeError):
            l["b"].baz = False

        l["b"].update(foo=False, bar=12)
        self.assertEqual(l["b"].foo, False)
        self.assertEqual(l["b"].bar, 12)

        with self.assertRaises(AttributeError):
            l["b"].update(xyz=1)

    def test_strict_ordering_on_append_list_with_flags_factory_extend(self):
        FooList = StrictOrderingOnAppendListWithFlagsFactory({"foo": bool, "bar": str})
        foo = FooList(["a", "b", "c"])
        foo["a"].foo = True
        foo["b"].bar = "bar"

        # Don't allow extending lists with different flag definitions.
        BarList = StrictOrderingOnAppendListWithFlagsFactory({"foo": str, "baz": bool})
        bar = BarList(["d", "e", "f"])
        bar["d"].foo = "foo"
        bar["e"].baz = True
        with self.assertRaises(ValueError):
            foo + bar
        with self.assertRaises(ValueError):
            bar + foo

        # It's not obvious what to do with duplicate list items with possibly
        # different flag values, so don't allow that case.
        with self.assertRaises(ValueError):
            foo + foo

        def assertExtended(l):
            self.assertEqual(len(l), 6)
            self.assertEqual(l["a"].foo, True)
            self.assertEqual(l["b"].bar, "bar")
            self.assertTrue("c" in l)
            self.assertEqual(l["d"].foo, True)
            self.assertEqual(l["e"].bar, "bar")
            self.assertTrue("f" in l)

        # Test extend.
        zot = FooList(["d", "e", "f"])
        zot["d"].foo = True
        zot["e"].bar = "bar"
        zot.extend(foo)
        assertExtended(zot)

        # Test __add__.
        zot = FooList(["d", "e", "f"])
        zot["d"].foo = True
        zot["e"].bar = "bar"
        assertExtended(foo + zot)
        assertExtended(zot + foo)

        # Test __iadd__.
        foo += zot
        assertExtended(foo)

        # Test __setitem__.
        foo[3:] = []
        self.assertEqual(len(foo), 3)
        foo[3:] = zot
        assertExtended(foo)


class TestTypedList(unittest.TestCase):
    def test_init(self):
        cls = TypedList(int)
        l = cls()
        self.assertEqual(len(l), 0)

        l = cls([1, 2, 3])
        self.assertEqual(len(l), 3)

        with self.assertRaises(ValueError):
            cls([1, 2, "c"])

    def test_extend(self):
        cls = TypedList(int)
        l = cls()
        l.extend([1, 2])
        self.assertEqual(len(l), 2)
        self.assertIsInstance(l, cls)

        with self.assertRaises(ValueError):
            l.extend([3, "c"])

        self.assertEqual(len(l), 2)

    def test_slicing(self):
        cls = TypedList(int)
        l = cls()
        l[:] = [1, 2]
        self.assertEqual(len(l), 2)
        self.assertIsInstance(l, cls)

        with self.assertRaises(ValueError):
            l[:] = [3, "c"]

        self.assertEqual(len(l), 2)

    def test_add(self):
        cls = TypedList(int)
        l = cls()
        l2 = l + [1, 2]
        self.assertEqual(len(l), 0)
        self.assertEqual(len(l2), 2)
        self.assertIsInstance(l2, cls)

        with self.assertRaises(ValueError):
            l2 = l + [3, "c"]

        self.assertEqual(len(l), 0)

    def test_iadd(self):
        cls = TypedList(int)
        l = cls()
        l += [1, 2]
        self.assertEqual(len(l), 2)
        self.assertIsInstance(l, cls)

        with self.assertRaises(ValueError):
            l += [3, "c"]

        self.assertEqual(len(l), 2)

    def test_add_coercion(self):
        objs = []

        class Foo:
            def __init__(self, obj):
                objs.append(obj)

        cls = TypedList(Foo)
        l = cls()
        l += [1, 2]
        self.assertEqual(len(objs), 2)
        self.assertEqual(type(l[0]), Foo)
        self.assertEqual(type(l[1]), Foo)

        # Adding a TypedList to a TypedList shouldn't trigger coercion again
        l2 = cls()
        l2 += l
        self.assertEqual(len(objs), 2)
        self.assertEqual(type(l2[0]), Foo)
        self.assertEqual(type(l2[1]), Foo)

        # Adding a TypedList to a TypedList shouldn't even trigger the code
        # that does coercion at all.
        l2 = cls()
        list.__setitem__(l, slice(0, -1), [1, 2])
        l2 += l
        self.assertEqual(len(objs), 2)
        self.assertEqual(type(l2[0]), int)
        self.assertEqual(type(l2[1]), int)

    def test_memoized(self):
        cls = TypedList(int)
        cls2 = TypedList(str)
        self.assertEqual(TypedList(int), cls)
        self.assertNotEqual(cls, cls2)


class TypedTestStrictOrderingOnAppendList(unittest.TestCase):
    def test_init(self):
        class Unicode(str):
            def __new__(cls, other):
                if not isinstance(other, str):
                    raise ValueError()
                return str.__new__(cls, other)

        cls = TypedList(Unicode, StrictOrderingOnAppendList)
        l = cls()
        self.assertEqual(len(l), 0)

        l = cls(["a", "b", "c"])
        self.assertEqual(len(l), 3)

        with self.assertRaises(UnsortedError):
            cls(["c", "b", "a"])

        with self.assertRaises(ValueError):
            cls(["a", "b", 3])

        self.assertEqual(len(l), 3)


class TestTypedNamedTuple(unittest.TestCase):
    def test_simple(self):
        FooBar = TypedNamedTuple("FooBar", [("foo", str), ("bar", int)])

        t = FooBar(foo="foo", bar=2)
        self.assertEqual(type(t), FooBar)
        self.assertEqual(t.foo, "foo")
        self.assertEqual(t.bar, 2)
        self.assertEqual(t[0], "foo")
        self.assertEqual(t[1], 2)

        FooBar("foo", 2)

        with self.assertRaises(TypeError):
            FooBar("foo", "not integer")
        with self.assertRaises(TypeError):
            FooBar(2, 4)

        # Passing a tuple as the first argument is the same as passing multiple
        # arguments.
        t1 = ("foo", 3)
        t2 = FooBar(t1)
        self.assertEqual(type(t2), FooBar)
        self.assertEqual(FooBar(t1), FooBar("foo", 3))


class TestGroupUnifiedFiles(unittest.TestCase):
    FILES = ["%s.cpp" % letter for letter in string.ascii_lowercase]

    def test_multiple_files(self):
        mapping = list(group_unified_files(self.FILES, "Unified", "cpp", 5))

        def check_mapping(index, expected_num_source_files):
            (unified_file, source_files) = mapping[index]

            self.assertEqual(unified_file, "Unified%d.cpp" % index)
            self.assertEqual(len(source_files), expected_num_source_files)

        all_files = list(itertools.chain(*[files for (_, files) in mapping]))
        self.assertEqual(len(all_files), len(self.FILES))
        self.assertEqual(set(all_files), set(self.FILES))

        expected_amounts = [5, 5, 5, 5, 5, 1]
        for i, amount in enumerate(expected_amounts):
            check_mapping(i, amount)


class TestMisc(unittest.TestCase):
    def test_pair(self):
        self.assertEqual(list(pair([1, 2, 3, 4, 5, 6])), [(1, 2), (3, 4), (5, 6)])

        self.assertEqual(
            list(pair([1, 2, 3, 4, 5, 6, 7])), [(1, 2), (3, 4), (5, 6), (7, None)]
        )

    def test_expand_variables(self):
        self.assertEqual(expand_variables("$(var)", {"var": "value"}), "value")

        self.assertEqual(
            expand_variables("$(a) and $(b)", {"a": "1", "b": "2"}), "1 and 2"
        )

        self.assertEqual(
            expand_variables("$(a) and $(undefined)", {"a": "1", "b": "2"}), "1 and "
        )

        self.assertEqual(
            expand_variables(
                "before $(string) between $(list) after",
                {"string": "abc", "list": ["a", "b", "c"]},
            ),
            "before abc between a b c after",
        )


class TestEnumString(unittest.TestCase):
    def test_string(self):
        class CompilerType(EnumString):
            POSSIBLE_VALUES = ("gcc", "clang", "clang-cl")

        type = CompilerType("gcc")
        self.assertEqual(type, "gcc")
        self.assertNotEqual(type, "clang")
        self.assertNotEqual(type, "clang-cl")
        self.assertIn(type, ("gcc", "clang-cl"))
        self.assertNotIn(type, ("clang", "clang-cl"))

        with self.assertRaises(EnumStringComparisonError):
            self.assertEqual(type, "foo")

        with self.assertRaises(EnumStringComparisonError):
            self.assertNotEqual(type, "foo")

        with self.assertRaises(EnumStringComparisonError):
            self.assertIn(type, ("foo", "gcc"))

        with self.assertRaises(ValueError):
            type = CompilerType("foo")


class TestHexDump(unittest.TestCase):
    def test_hexdump(self):
        self.assertEqual(
            hexdump("abcdef123💩ZYXWVU".encode()),
            [
                "00  61 62 63 64 65 66 31 32  33 f0 9f 92 a9 5a 59 58  |abcdef123....ZYX|\n",
                "10  57 56 55                                          |WVU             |\n",
            ],
        )


def test_read_only_dict():
    d = ReadOnlyDict(foo="bar")
    with pytest.raises(Exception):
        d["foo"] = "baz"

    with pytest.raises(Exception):
        d.update({"foo": "baz"})

    with pytest.raises(Exception):
        del d["foo"]

    # ensure copy still works
    d_copy = d.copy()
    assert d == d_copy
    # TODO Returning a dict here feels like a bug, but there are places in-tree
    # relying on this behaviour.
    assert isinstance(d_copy, dict)

    d_copy = copy.copy(d)
    assert d == d_copy
    assert isinstance(d_copy, ReadOnlyDict)

    d_copy = copy.deepcopy(d)
    assert d == d_copy
    assert isinstance(d_copy, ReadOnlyDict)


def test_forwarding_argument_parser():
    # init
    parser = ForwardingArgumentParser()
    parser.add_forwarding_group(
        title="my forwarding group",
        dest="fwd",
        help="some program",
        forwarding_help="some help",
    )

    # test help format
    formatted_help = parser.format_help()
    ref_help = """\
usage: test_util.py \\[-h\\] ...

option.*:
  -h, --help   show this help message and exit

my forwarding group:
  -- --help some help

  \\[--\\] fwd...  some program
"""
    assert re.match(ref_help, formatted_help)

    # test argument parsing
    forwarded_args = parser.parse_args(["1", "2"])
    assert forwarded_args.fwd == ["1", "2"]

    forwarded_help = parser.parse_args(["--", "--help"])
    assert forwarded_help.fwd == ["--", "--help"]


if __name__ == "__main__":
    main()
