Repository: xonsh/xonsh

Issue:
problems importing case_insensitive_dict
## Current Behavior
<!---
The Regolith package imports Xonsh in its __init__ and when I rebuilt my env I go an import error:
-->

Traceback (if applicable):

<details>

```xsh
Traceback (most recent call last):
  File "C:\Users\simon\miniconda3\envs\r312\Scripts\regolith", line 2, in <module>
    from regolith.main import main
  File "C:\Users\simon\dev\regolith\src\regolith\__init__.py", line 6, in <module>
    XSH.load(execer=Execer())
  File "C:\Users\simon\miniconda3\envs\r312\Lib\site-packages\xonsh\built_ins.py", line 633, in load
    from xonsh.commands_cache import CommandsCache
  File "C:\Users\simon\miniconda3\envs\r312\Lib\site-packages\xonsh\commands_cache.py", line 27, in <module>
    from case_insensitive_dict import CaseInsensitiveDict as CacheDict
ModuleNotFoundError: No module named 'case_insensitive_dict'
```

I was able to install `case_insensitive_dict` from pip (it wasn't found in conda-forge or conda defaults) but then I still got an import error:

```xsh
$ regolith --version
C:\Users\simon\dev\regolith\src\regolith\version.py:38: UserWarning: Package metadata not found.
  warn("Package metadata not found.")
Traceback (most recent call last):
  File "C:\Users\simon\miniconda3\envs\r312\Scripts\regolith", line 2, in <module>
    from regolith.main import main
  File "C:\Users\simon\dev\regolith\src\regolith\__init__.py", line 14, in <module>
    XSH.load(execer=Execer())
  File "C:\Users\simon\miniconda3\envs\r312\Lib\site-packages\xonsh\built_ins.py", line 633, in load
    from xonsh.commands_cache import CommandsCache
  File "C:\Users\simon\miniconda3\envs\r312\Lib\site-packages\xonsh\commands_cache.py", line 27, in <module>
    from case_insensitive_dict import CaseInsensitiveDict as CacheDict
ImportError: cannot import name 'CaseInsensitiveDict' from 'case_insensitive_dict' (C:\Users\simon\miniconda3\envs\r312\Lib\site-packages\case_insensitive_dict\__init__.py)
```
Any help with this would be appreciated.

</details>

## Expected Behavior
<!--- I would like regolith to run again without this importerror -->

## xonfig

<details>

```xsh
bash: xonfig: command not found
```

</details>

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**

Hints from issue comments:
@sbillinge: sorry, some stuff got cut off there in my back and forth.

I recently created a new conda env and got xonsh 0.18.2 py312h2e8e312_0 and got this error.  There was no such error in my previous env (which was also python 3.12 but built some time before)@anki-code: Hey! I'm not the Windows user but I see there is `case_insensitive_dict` package in pypi. What if you install it? (https://pypi.org/project/case-insensitive-dictionary/)@sbillinge: Thanks @anki-code .  I tried that and then I got the import error (please see the second stack dump in the post for the full stack dump).
```
    from case_insensitive_dict import CaseInsensitiveDict as CacheDict
ImportError: cannot import name 'CaseInsensitiveDict' from 'case_insensitive_dict' (C:\Users\simon\miniconda3\envs\r312\Lib\site-packages\case_insensitive_dict\__init__.py)```@anki-code: If you install xonsh 0.16.0 is it solves the issue?

```xsh
xpip install git+https://github.com/xonsh/xonsh@0.16.0
# restart xonsh
```

I just see no any installations of `case_insensitive_dict` in the xonsh code. Just [import](https://github.com/search?q=repo%3Axonsh%2Fxonsh%20case_insensitive_dict&type=code). It's interesting how it worked before...@sbillinge: @anki-code yes, I can confirm, going back to 0.16.0 cured the problem and allowed it to run.@anki-code: It will be cool to catch the difference. PR with fix is welcome!@sbillinge: I will look into it.  All I know atm is that 0.16.0 it ran, and it gave the error in 0.17.0 and 0.18.0.  But I have a starting point for digging in to it.@jnoortheen: We can remove this external dependency. @anki-code: Really, https://github.com/xonsh/xonsh/blob/31e7c4204ad41368aa0b47c18292f77c823c575b/xonsh/commands_cache.py#L26-L29

We can remove this completely and replace `CacheDict` to `dict` in the code.

@sbillinge if you can prepare the PR it will be cool.@sbillinge: > Really,
> 
> https://github.com/xonsh/xonsh/blob/31e7c4204ad41368aa0b47c18292f77c823c575b/xonsh/commands_cache.py#L26-L29
> 
> We can remove this completely and replace `CacheDict` to `dict` in the code.
> 
> @sbillinge if you can prepare the PR it will be cool.

Yes, I am happy to do that.  I can do it tonight or tomorrow. Meetings today...:(@nova-nowiz: Thank you for the temporary fix of using th 0.16.0 release.
It seems that the PR is a bit stuck at the moment, are there some blocking points where I could help out ?
I would love to use the latest version@sbillinge: > Thank you for the temporary fix of using th 0.16.0 release. It seems that the PR is a bit stuck at the moment, are there some blocking points where I could help out ? I would love to use the latest version

Thanks for the offer of help.  I did run into difficulty with it.  I need to go back and remember what the block was, but some help may be just what is needed....@sbillinge: One fix maybe just to import `CaseInsensitiveDict` from `requests.structures` when we are on Windows,   The downside is that we lose one dependency, but gain another.   

If we want this functionality in xonch itself, with a bit of help from chatGPT we came p with this code:
```
class CaseInsensitiveDict(dict):
    def __init__(self, *args, **kwargs):
        super().__init__()
        self._store = {}
        self.update(*args, **kwargs)

    def __setitem__(self, key, value):
        # Store the key in lowercase but preserve the original case for display
        self._store[key.lower()] = key
        super().__setitem__(key.lower(), value)

    def __getitem__(self, key):
        return super().__getitem__(key.lower())

    def __delitem__(self, key):
        del self._store[key.lower()]
        super().__delitem__(key.lower())

    def __contains__(self, key):
        return key.lower() in self._store

    def get(self, key, default=None):
        return super().get(key.lower(), default)

    def update(self, *args, **kwargs):
        for k, v in dict(*args, **kwargs).items():
            self[k] = v

    def keys(self):
        # Return the original keys with their original casing
        return (self._store[k] for k in self._store)

    def items(self):
        return ((self._store[k], self[k]) for k in self._store)

    def __repr__(self):
        return f"{self.__class__.__name__}({dict(self.items())})"

    def copy(self):
        return CaseInsensitiveDict(self.items())
```@nova-nowiz: Woaw, that was fast 👀
Here if you still need help, I did not get if this was a temporary fix or the full solution you wanted to implement in the end
Anyhow, thank you so much @sbillinge ! ❤️ @sbillinge: > Woaw, that was fast 👀 Here if you still need help, I did not get if this was a temporary fix or the full solution you wanted to implement in the end Anyhow, thank you so much @sbillinge ! ❤️

no, that was the full fix in the end.... I just had some time to do it.  It was nagging me that it was sitting unfinished, so this feels good.  And xonsh officially has no required dependencies now, so this won't happen again in the future.....@sbillinge: it will go out in the next xonsh release.  We pinged the maintainers so hopefully it will be soon.

Solution patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 88633d5d..93e75155 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -38,7 +38,7 @@ jobs:
           - "3.10"
           - "3.11"
           - "3.12"
-#          - "3.13-dev"
+          - "3.13"
     name: Test Python ${{ matrix.python-version }} ${{ matrix.os }}
     steps:
       - uses: actions/checkout@v4
diff --git a/news/remove-caseinsdict.rst b/news/remove-caseinsdict.rst
new file mode 100644
index 00000000..196e04f0
--- /dev/null
+++ b/news/remove-caseinsdict.rst
@@ -0,0 +1,24 @@
+**Added:**
+
+* <news item>
+
+**Changed:**
+
+* replaced `case_insensitive_dictionary` dependency with local
+  `CaseInsensitiveDict` class
+
+**Deprecated:**
+
+* <news item>
+
+**Removed:**
+
+* <news item>
+
+**Fixed:**
+
+* <news item>
+
+**Security:**
+
+* <news item>
diff --git a/pyproject.toml b/pyproject.toml
index fa6e2688..63232369 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,9 +15,7 @@ authors = [{ name = "Anthony Scopatz" }, { email = "scopatz@gmail.com" }]
 maintainers = [{ name = "Xonsh Community" }, { email = "xonsh@gil.forsyth.dev" }]
 license = { text = "BSD 2-Clause License" }
 requires-python = ">=3.9"
-dependencies = [
-    "case-insensitive-dictionary; platform_system=='Windows'",
-]
+dependencies = []
 
 [tool.setuptools.dynamic]
 version = {attr = "xonsh.__version__"}
diff --git a/xonsh/commands_cache.py b/xonsh/commands_cache.py
index 0e819158..bfd50f85 100644
--- a/xonsh/commands_cache.py
+++ b/xonsh/commands_cache.py
@@ -23,8 +23,52 @@ from xonsh.procs.executables import (
     is_executable_in_windows,
 )
 
+
+class CaseInsensitiveDict(dict[tp.Any, tp.Any]):
+    def __init__(self, *args, **kwargs):
+        super().__init__()
+        self._store = {}
+        self.update(*args, **kwargs)
+
+    def __setitem__(self, key, value):
+        # Store the key in lowercase but preserve the original case for display
+        self._store[key.casefold()] = key
+        super().__setitem__(key.casefold(), value)
+
+    def __getitem__(self, key):
+        return super().__getitem__(key.casefold())
+
+    def __delitem__(self, key):
+        del self._store[key.casefold()]
+        super().__delitem__(key.casefold())
+
+    def __contains__(self, key):
+        return key.casefold() in self._store
+
+    def get(self, key, default=None):
+        return super().get(key.casefold(), default)
+
+    def update(self, *args, **kwargs):
+        for k, v in dict(*args, **kwargs).items():
+            self[k] = v
+
+    def keys(self):
+        # Return the original keys with their original casing
+        return (self._store[k] for k in self._store)
+
+    def items(self):
+        return ((self._store[k], self[k]) for k in self._store)
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}({dict(self.items())})"
+
+    def copy(self):
+        return CaseInsensitiveDict(self.items())
+
+
+CacheDict: tp.Union[type[CaseInsensitiveDict], type[dict]]
 if ON_WINDOWS:
-    from case_insensitive_dict import CaseInsensitiveDict as CacheDict
+    CacheDict = CaseInsensitiveDict
 else:
     CacheDict = dict
 


Test patch:
diff --git a/tests/test_commands_cache.py b/tests/test_commands_cache.py
index b8825ba1..d90dec63 100644
--- a/tests/test_commands_cache.py
+++ b/tests/test_commands_cache.py
@@ -8,6 +8,7 @@ import pytest
 
 from xonsh.commands_cache import (
     SHELL_PREDICTOR_PARSER,
+    CaseInsensitiveDict,
     CommandsCache,
     _Commands,
     executables_in,
@@ -306,3 +307,77 @@ def test_executables_in(xession):
             else:
                 result = set(executables_in(test_path))
     assert expected == result
+
+
+def test_caseinsdict_constructor():
+    actual = CaseInsensitiveDict({"key1": "val1", "Key2": "Val2"})
+    assert isinstance(actual, CaseInsensitiveDict)
+    assert actual["key1"] == "val1"
+    assert actual["Key2"] == "Val2"
+
+
+def test_caseinsdict_getitem():
+    actual = CaseInsensitiveDict({"Key1": "Val1"})
+    assert actual["Key1"] == "Val1"
+    assert actual["key1"] == "Val1"
+
+
+def test_caseinsdict_setitem():
+    actual = CaseInsensitiveDict({"Key1": "Val1"})
+    actual["Key1"] = "Val2"
+    assert actual["Key1"] == "Val2"
+    assert actual["key1"] == "Val2"
+    actual["key1"] = "Val3"
+    assert actual["Key1"] == "Val3"
+    assert actual["key1"] == "Val3"
+
+
+def test_caseinsdict_delitem():
+    actual = CaseInsensitiveDict({"Key1": "Val1", "Key2": "Val2"})
+    del actual["Key1"]
+    assert actual == CaseInsensitiveDict({"Key2": "Val2"})
+    del actual["key2"]
+    assert actual == CaseInsensitiveDict({})
+
+
+def test_caseinsdict_contains():
+    actual = CaseInsensitiveDict({"Key1": "Val1"})
+    assert actual.__contains__("Key1")
+    assert actual.__contains__("key1")
+    assert not actual.__contains__("key2")
+
+
+def test_caseinsdict_get():
+    actual = CaseInsensitiveDict({"Key1": "Val1"})
+    assert actual.get("Key1") == "Val1"
+    assert actual.get("key1") == "Val1"
+    assert actual.get("key2", "no val") == "no val"
+    assert actual.get("key1", "no val") == "Val1"
+
+
+def test_caseinsdict_update():
+    actual = CaseInsensitiveDict({"Key1": "Val1"})
+    actual.update({"Key2": "Val2"})
+    assert actual["key2"] == "Val2"
+
+
+def test_caseinsdict_keys():
+    actual = CaseInsensitiveDict({"Key1": "Val1"})
+    assert next(actual.keys()) == "Key1"
+
+
+def test_caseinsdict_items():
+    actual = CaseInsensitiveDict({"Key1": "Val1"})
+    assert next(actual.items()) == ("Key1", "Val1")
+
+
+def test_caseinsdict_repr():
+    actual = CaseInsensitiveDict({"Key1": "Val1"})
+    assert actual.__repr__() == "CaseInsensitiveDict({'Key1': 'Val1'})"
+
+
+def test_caseinsdict_copy():
+    initial = CaseInsensitiveDict({"Key1": "Val1"})
+    actual = initial.copy()
+    assert actual == initial
+    assert id(actual) != id(initial)
diff --git a/tests/test_integrations.py b/tests/test_integrations.py
index 8609920f..b9dc4b5e 100644
--- a/tests/test_integrations.py
+++ b/tests/test_integrations.py
@@ -17,6 +17,7 @@ from xonsh.pytest.tools import (
     ON_DARWIN,
     ON_TRAVIS,
     ON_WINDOWS,
+    VER_FULL,
     skip_if_on_darwin,
     skip_if_on_msys,
     skip_if_on_unix,
@@ -610,8 +611,9 @@ first
     ),
     # testing alias stack: parallel threaded callable aliases.
     # This breaks if the __ALIAS_STACK variables leak between threads.
-    (
-        """
+    pytest.param(
+        (
+            """
 from time import sleep
 aliases['a'] = lambda: print(1, end="") or sleep(0.2) or print(1, end="")
 aliases['b'] = 'a'
@@ -620,8 +622,14 @@ a | a
 a | b | a
 a | a | b | b
 """,
-        "1" * 2 * 4,
-        0,
+            "1" * 2 * 4,
+            0,
+        ),
+        # TODO: investigate errors on Python 3.13
+        marks=pytest.mark.skipif(
+            VER_FULL > (3, 12) and not ON_WINDOWS,
+            reason="broken pipes on Python 3.13 likely due to changes in threading behavior",
+        ),
     ),
     # test $SHLVL
     (
@@ -739,7 +747,7 @@ if not ON_WINDOWS:
 
 @skip_if_no_xonsh
 @pytest.mark.parametrize("case", ALL_PLATFORMS)
-@pytest.mark.flaky(reruns=3, reruns_delay=2)
+@pytest.mark.flaky(reruns=4, reruns_delay=2)
 def test_script(case):
     script, exp_out, exp_rtn = case
     if ON_DARWIN: