Skip to content

fix: heal null location values when merging message user profiles - #565

Merged
NeonDaniel merged 5 commits into
NeonGeckoCom:devfrom
mikejgray:FIX_HealNullProfileLocation
Aug 18, 2026
Merged

NeonDaniel merged 5 commits into
NeonGeckoCom:devfrom
mikejgray:FIX_HealNullProfileLocation

Conversation

@mikejgray

@mikejgray mikejgray commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

get_user_prefs merges an inbound message profile against the defaults with dict_update_keys (neon_utils/user_utils.py:107). That helper fills absent keys only (configuration_utils.py:757). A key that is present with the value None never inherits a default.

Clients that serialize a full profile skeleton send location: {"lat": None, "lng": None, ...}. These nulls mask the configured location of the core for every message. Skills that need a location then fail. skill-fallback_wolfram_alpha serializes lat=None to the string "None" and gets a 422 from the /proxy/wolframalpha endpoint.

This change applies the null-coalescing that get_user_config_from_mycroft_conf already uses (configuration_utils.py:797-810) after the merge, scoped to location.

Why here and not in dict_update_keys

Three skill callers use dict_update_keys: skills/neon_skill.py:646, skills/mycroft_skill.py:88, and skills/neon_fallback_skill.py:537. Each one merges settings.json against the settingsmeta defaults and writes the result to disk. If the helper replaced None, a skill setting that a user set to null on purpose would be overwritten on disk.

dict_make_equal_keys (configuration_utils.py:716) also sends the tts, stt, hotwords, and language subtrees through the same helper. In those sections, None means "use the plugin default". The change is therefore scoped to the one call site in the message path.

Why not call get_user_config_from_mycroft_conf directly

That function assigns into user_config["speech"] and user_config["units"] without a guard, so a partial profile raises KeyError. It also overwrites the speech and unit preferences of the user from the core config on every message. This change reuses the idiom of that function, not the function.

location is the only block in default_user_conf.yml that uses None as the unset marker. All other fields use '' or {}. Inside location, this change cannot tell a deliberate None from an unset key, and treats both as unset.

Test plan

Four new tests in tests/user_util_tests.py:

  • test_get_user_prefs_heals_null_location — a profile with all location values set to None inherits the configured location. This test fails without the source change.
  • test_get_user_prefs_keeps_configured_location — a profile with a real location keeps it.
  • test_get_user_prefs_keeps_null_outside_location — a null outside location (user.email) stays null. This test fails if the change applies to all sections.
  • test_dict_update_keys_preserves_explicit_null — a skill setting set to null does not inherit the metadata default.

Results:

  • tests/user_util_tests.py: 10 passed.
  • tests/configuration_util_tests.py: 50 passed, 4 failed. The 4 failures also occur on clean dev: test_make_loaded_config_safe, test_migrate_ngi_config, test_added_module_config, and test_simultaneous_config_updates.

This change is not yet tested against a live hub.

Replaces NeonGeckoCom/neon-hana#59, which corrected this on the client side.

🤖 Generated with Claude Code

@mikejgray

Copy link
Copy Markdown
Contributor Author

#566 should address the failing tests

NeonDaniel pushed a commit that referenced this pull request Aug 7, 2026
## Summary
The `unit_tests` workflow fails on `dev` at the Test Parse Utils step,
on Python 3.10, 3.11, and 3.12. The failed test is
`tests/parse_util_tests.py::ParseUtilTests::test_get_phonemes`.

The cause is a change in nltk 3.10, not a change in this repository.
nltk added `nltk/pathsec.py`, which authorizes a download target against
the directories in `nltk.data.path`. `get_phonemes`
(`neon_utils/parse_utils.py:115`) registered its download directory one
line after the download call:

```python
nltk.download('cmudict', download_dir=download_path)
nltk.data.path.append(download_path)
```

The download ran against a path that was not yet registered, so nltk
refused it:

```
[nltk_data] Error downloading 'cmudict' ... Security Violation
[nltk_data] [Downloader._download_package]: Unauthorized path
[nltk_data] /home/runner/.local/share/neon/corpora/cmudict.zip.tmp
```

`cmudict` did not install. The corpus lookup then raised `LookupError`.

`requirements/requirements.txt` pins `nltk~=3.5`, which permits 3.10.2.
The last `dev` run that passed was 2026-06-08, before this nltk release.

## Change
Register the download path before the download. The `in` test prevents
duplicate entries in `nltk.data.path` on repeat calls.

## Test plan
- Reproduced the fault alone: `nltk.download('cmudict',
download_dir=...)` returns `False` with the security violation above
when the directory is absent from `nltk.data.path`. It returns `True`
when the directory is present.
- Deleted the local corpus cache to get the same cold-cache state as CI.
`test_get_phonemes` fails before this change and passes after it.
- `tests/parse_util_tests.py`: 15 passed from a cold cache.

## Note
This change is independent of #565. #565 starts from the same commit and
shows the same CI fault, which this change corrects.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Comment thread neon_utils/user_utils.py Outdated
@NeonDaniel

Copy link
Copy Markdown
Member

#566 should address the failing tests

Merged that one 🙂 This PR needs a rebase now

@mikejgray
mikejgray force-pushed the FIX_HealNullProfileLocation branch from 290ceed to 6157088 Compare August 7, 2026 17:21
@mikejgray
mikejgray requested a review from NeonDaniel August 7, 2026 17:50
mikejgray and others added 5 commits August 18, 2026 16:31
`dict_update_keys` fills absent keys only, so a profile that carries
location keys explicitly set to null can never inherit the configured
default. Clients that serialize a full profile skeleton send
`location: {"lat": None, ...}`, which permanently masks the core's
configured location and breaks skills that require one.

Apply the same null-coalescing `get_user_config_from_mycroft_conf` uses,
scoped to the merge in `get_user_prefs`. `dict_update_keys` itself is
left unchanged: its skill-settings callers persist merged results to
disk, where replacing null would overwrite deliberately-cleared values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the cases that guard the scoping decision: a null outside `location`
is left alone, and `dict_update_keys` still preserves a deliberately
nulled skill setting rather than inheriting the metadata default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scoping the null-coalescing to `location` fixed only the case that
surfaced first. `units` and `speech` carry real defaults that a profile
serializing them as null masks in exactly the same way, so a client
sending a full skeleton loses the configured measure, date format, and
languages too.

Drop null-valued keys from the profile before the merge and let
`dict_update_keys` fill them, which is equivalent to coalescing after
the merge but removes the special case. `dict_update_keys` itself is
still unchanged: its skill-settings callers persist merged results to
disk, where replacing null would overwrite deliberately-cleared values.

Note the profile is modified in place rather than copied. `get_user_prefs`
back-fills the message context profile with merged defaults, and callers
depend on that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test kept the name it had when healing was scoped to `location`,
so it read as asserting the opposite of what it checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mutation testing found a gap: replacing `value is None` with `not value`
in `_drop_null_values` left the whole suite green. That mutation drops
False, 0, and empty strings before the merge, so a profile with
`privacy.save_audio: False` inherits the default True and silently
reverts a user's opt-out.

Add a test that pins False, 0, and '' as deliberate values. Also
strengthen the null-healing assertions: `user.email` defaults to an
empty string, so comparing against the default alone passed too easily.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mikejgray
mikejgray force-pushed the FIX_HealNullProfileLocation branch from ba8609e to 7f3ca9d Compare August 18, 2026 21:33

@NeonDaniel NeonDaniel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good; tests indicate backwards-compat. with all cases previously covered

@NeonDaniel
NeonDaniel merged commit c8daaad into NeonGeckoCom:dev Aug 18, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants