Repository: glotzerlab/signac
Pull request #1030 (2025-04-18 19:37:33)

Fix schema on state point that is list of dicts that contain lists
<!-- Provide a general summary of your changes in the title above. -->

I had a state point containing "dislocations" specified as a list of dictionaries each containing location and Burgers vectors which failed on schema detection.

I am not sure if this case means that we missed other complex nested state points.

## Description
<!-- Describe your changes in detail. -->
<!-- Please indicate if the changes may break existing functionality. -->

The fix is to also recursively call `_to_hashable` when creating a `_hashable_dict`.

I added a test for the schema case that fails before my fix. I added a test in `test_job` that doesn't catch anything from before.

## Motivation and Context
<!-- Why is this change required? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here. -->

This code used to fail with `TypeError: unhashable type: 'list'`

```python
import signac

project = signac.init_project()

minimal = {"a": [{"a": []}]}
job = project.open_job(minimal).init()
print(job, job.sp) # works up to here

schem = project.detect_schema()
```

## Checklist:
<!-- This checklist must be complete before merging the pull request. -->
<!-- If you are unsure about any of these items, do not hesitate to ask! -->
- [x] I am familiar with the [Contributing Guidelines](https://github.com/glotzerlab/signac/blob/main/CONTRIBUTING.md).
- [x] I agree with the terms of the [Contributor Agreement](https://github.com/glotzerlab/signac/blob/main/ContributorAgreement.md).
- [x] My name is on the [list of contributors](https://github.com/glotzerlab/signac/blob/main/contributors.yaml).
- [x] The changes introduced by this pull request are covered by existing or newly introduced tests.
- [x] The [package documentation](https://github.com/glotzerlab/signac/tree/main/doc) and [framework documentation](https://docs.signac.io/) in [signac-docs](https://github.com/glotzerlab/signac-docs) are up to date with these changes.
- [x] I have updated the [changelog](https://github.com/glotzerlab/signac/blob/main/changelog.txt) and added any related issue and pull request numbers for future reference.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/glotzerlab/signac/pull/1030?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 86.01%. Comparing base [(`6071136`)](https://app.codecov.io/gh/glotzerlab/signac/commit/607113634e070ca46ae473f5f5f2ac3c551829a6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab) to head [(`5d2a552`)](https://app.codecov.io/gh/glotzerlab/signac/commit/5d2a552bc28653514a906ce9cec86381bf0b717d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1030      +/-   ##
==========================================
- Coverage   86.18%   86.01%   -0.17%     
==========================================
  Files          20       20              
  Lines        3511     3512       +1     
  Branches      664      665       +1     
==========================================
- Hits         3026     3021       -5     
- Misses        334      338       +4     
- Partials      151      153       +2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/glotzerlab/signac/pull/1030?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
bdice: Hmm. I wonder why the automerge isn't working. Is there an unexpected branch protection on `main`? 
![image](https://github.com/user-attachments/assets/5ca6806b-88e7-43e2-9c08-12d0a9a41416)


Patch:
diff --git a/changelog.txt b/changelog.txt
index e1679b8e..9dd951c3 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -7,6 +7,14 @@ The **signac** package follows `semantic versioning <https://semver.org/>`_.
 Version 2
 =========
 
+[2.4.0] -- 2025-xx-xx
+---------------------
+
+Fixed
++++++
+
+ - Schema detection when a state point value is a list of dictionaries having lists (#1030).
+
 [2.3.0] -- 2025-04-10
 ---------------------
 
diff --git a/signac/_utility.py b/signac/_utility.py
index b302875f..d9aa3842 100644
--- a/signac/_utility.py
+++ b/signac/_utility.py
@@ -122,7 +122,7 @@ def _dotted_dict_to_nested_dicts(dotted_dict, delimiter_nested="."):
 
 class _hashable_dict(dict):
     def __hash__(self):
-        return hash(tuple(sorted(self.items())))
+        return hash(tuple(sorted((k, _to_hashable(v)) for k, v in self.items())))
 
 
 def _to_hashable(obj):


Test patch:
diff --git a/tests/test_job.py b/tests/test_job.py
index 20cf7bf6..628b741c 100644
--- a/tests/test_job.py
+++ b/tests/test_job.py
@@ -54,6 +54,8 @@ BUILTINS_HASH = "7a80b58db53bbc544fc27fcaaba2ce44"
 
 NESTED_HASH = "bd6f5828f4410b665bffcec46abeb8f3"
 
+LIST_HASH = "e5a2c3bf962d0b886728c93825b1cc97"
+
 
 class TestJobBase:
     project_class = signac.Project
@@ -80,6 +82,12 @@ class TestJobBase:
         d["g"] = builtins_dict()
         return d
 
+    @classmethod
+    def dict_of_list_of_dict(self):
+        d = dict(builtins_dict())
+        d["h"] = [builtins_dict()]
+        return d
+
 
 class TestJobID(TestJobBase):
     def test_builtins(self):
@@ -95,6 +103,10 @@ class TestJobID(TestJobBase):
         for i in range(10):
             assert str(self.project.open_job(self.nested_dict())) == NESTED_HASH
 
+    def test_dict_of_list_of_dict(self):
+        for _ in range(10):
+            assert str(self.project.open_job(self.dict_of_list_of_dict())) == LIST_HASH
+
     def test_sequences_identity(self):
         job1 = self.project.open_job({"a": [1.0, "1.0", 1, True]})
         job2 = self.project.open_job({"a": (1.0, "1.0", 1, True)})
diff --git a/tests/test_project.py b/tests/test_project.py
index 96f3588c..46957a39 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -713,11 +713,12 @@ class TestProject(TestProjectBase):
                     "d": [[i, 0, 0]],
                     "e": {"e2": [i, 0, 0]} if i % 2 else 0,  # heterogeneous!
                     "f": {"f2": [[i, 0, 0]]},
+                    "g": [{"h": [1, 2]}],
                 }
             ).init()
 
         s = self.project.detect_schema()
-        assert len(s) == 10
+        assert len(s) == 11
         for k in (
             "const1",
             "const2.const3",