refactor: new pyRevit C# config service - #3450
Conversation
Port the config abstraction from pyrevitlabs#2482 (dosymep), scoped to INI only; Json/Yaml backends omitted. Net-new assemblies plus wiring: - Directory.Build.targets: map net8.0 -> netcore. - pyRevitLabs.sln: register both projects. - .gitignore: un-ignore Configurations.Ini/ (matched by *.ini on case-insensitive filesystems).
Ports the ConfigurationService and IniConfiguration test projects from pyrevitlabs#2482. Json/Yaml test projects omitted with their backends.
Rewire the CLI and pyRevitLabs.PyRevit config consumers onto the new configuration service; remove the bespoke CLI config reader. - PyRevitConfigs: rewritten over IConfigurationService / typed sections. - Remove pyRevitLabs.PyRevit/PyRevitConfig.cs (superseded). - Port PyRevitAttachments, PyRevitCaches, PyRevitClones, PyRevitExtensions. - csproj: reference the Configurations assemblies (Json backend dropped), keep develop's LibGit2Sharp 0.31.0. Reconciled against current develop (3-way merge, not a straight port): - Preserve develop's install-scope ConfigFilePath (IsInstallAllUsers marker) over the PR's simplification. - Re-apply develop's attachment session cache (GetAttachedCached / ClearAttachmentCache) and clone bin-artifact install + --skip-bin. - Add close-output config (GetCloseOutputMode/GetCloseOtherOutputs + OutputCloseMode enum + CoreSection keys) consumed by ScriptConsole.
Replace the Python-side config reader with a thin wrapper over the shared C# configuration service. - userconfig.py: PyRevitConfig now wraps IConfigurationService; typed Core/Routes/Telemetry section access; _SectionCompatWrapper preserves get_option/set_option for extensions. Module init no longer runs the upgrade normalize loop or an unconditional save_changes(): opening Revit no longer rewrites the ini. - configparser.py: ConfigSection/ConfigSections over the service; the JSON-over-INI fixup chain moves to the C# IniConfiguration backend. - labs.py: drop MadMilkman.Ini (package removed); reference pyRevitLabs.Configurations / ConfigurationService. - revit/tabs.py: tolerate non-string/malformed config values. - loader/sessioninfo.py: drop config_type/config_file log line. The PR's unrelated runtime DLL-resolution change is intentionally not ported. Settings reconciliation (new_loader, read_script_metadata, output close mode) follows in the next commit.
…bs#2482 base Re-add config surface the PR predated, so existing consumers (sessionmgr, Settings dialog) keep working: - CoreSection: new_loader, read_script_metadata typed keys (close-output keys were added with the C# migration commit). - userconfig: new_loader, read_script_metadata, output_close_others, output_close_mode_enum properties (typed Core access). - userconfig.get_thirdparty_ext_root_dirs: restore pyrevitlabs#3193 deterministic ordering (default path first) over the PR's set-based version. - userconfig.get_current_attachment: restore the cached lookup (GetAttachedCached) the PR regressed. - script.py: docstring class rename (ConfigSection).
GetConfigFile resolved the user config via the install-scope ConfigFilePath,
which points to ProgramData when the all-users marker is present. A
non-elevated Revit session then tried to write there ("access denied") and
ignored the real per-user APPDATA config.
- Resolve the writable user config from APPDATA (PyRevitPath) directly, so an
existing per-user config always wins (matches pyRevit's historical Python
discovery).
- When only an admin (ProgramData) config exists, probe actual writability
(FileInfo.IsReadOnly misses ACL denials) and open it read-only instead of
writing; otherwise seed it into the per-user location.
A value that fails JSON deserialization (e.g. escape-doubling corruption in older configs, such as a backslash-mangled environment.clones dict) threw out of GetValueOrDefault and aborted the entire config/section load. Fall back to the default instead, matching pyRevit's historical read tolerance. GetValue (non-default) still throws.
- ConfigurationService.SaveSection: stop RemoveOption on null properties so a partial-section save (PyRevitConfigs.Set* single-field records) no longer strips the other keys in that section. - ConfigurationService.GetSectionKeyValueOrDefault: return GetValueOrDefault instead of GetValue (was throwing on missing keys despite its name). - PyRevitConfigs.SetUTCStamps: write TelemetryUseUtcTimeStamps, not TelemetryStatus (copy-paste toggled telemetry on/off). - userconfig.apptelemetry_event_flags: guard None (hex(None) crashed at telemetry startup). - userconfig.reload(): restore the method (get_config(reload=True) raised TypeError); re-reads from disk. - configparser.get_option: tolerate non-JSON/legacy values instead of letting json.loads crash config reads.
Golden-file corpus + read/round-trip assertions over real-world config shapes (populated, corrupted clones, legacy formats, empty). Acceptance_* tests encode the Phase 2.1 symmetric-JSON target and are RED until it lands (C# string reads must be decoded, not JSON-quoted); the rest assert current Phase 1 behavior and pass. - Deploy pyRevitLabs.Json (Private=false in the Ini backend) to the test output so the suite runs standalone. - .gitignore: un-ignore the test .ini fixtures (matched by *.ini).
Comments authored during the port/fixes were describing why a change was made or referencing prior code. Reword them to state what the current code does (SaveSection null handling, GetConfigFile discovery, IsFileWritable, GetValueOrDefault fallback, ext-dir ordering, get_option non-JSON handling).
Strings are JSON-encoded once and decoded on read on both sides, so C# typed string access and the typed POCO sections no longer read back quoted. - IniConfiguration.GetValueImpl: deserialize strings (drop the raw-return special case). - IConfiguration/ConfigurationBase: add GetRawValueOrDefault/SetRawValue raw accessors; IniConfiguration stores/returns the value text unchanged. - configparser.ConfigSection and userconfig._SectionCompatWrapper: read/write via the raw accessors (json.loads / json.dumps once), removing the double-encode that fed escape-doubling. Golden-file string-contract tests now pass (18/18).
Repairs an existing config on first writable load: drops typed-section values that no longer parse to their declared type (e.g. an escape-doubled environment.clones dict), resets telemetry fields blown up by escape-doubling, then stamps [core] config_version so it runs once. - ConfigurationMigrator (version-gated, backs up before mutating). - PyRevitConfigs.GetConfigFile runs it on the writable user config only; admin/read-only configs are left untouched. - Remove the orphaned Python upgrade_user_config / heal_bloated_telemetry_fields; that work now lives in the migrator. - Golden-file tests: corrupt-value repair + idempotency.
…eError - TelemetrySection.AppTelemetryEventFlags int -> string: the field is a 128-bit hex string and overflowed Int32. PyRevitConfigs and userconfig pass it through as text (no hex parse/format). - ConfigSection.__getattr__ raises AttributeError for an absent option (was returning None), restoring hasattr / presence detection. - Golden-file fixture/test: large hex flags round-trip as a string.
- ConfigurationMigrator.Migrate returns a result (reset keys, backup path, backup-failed flag) and aborts when an existing file cannot be backed up, so it never mutates without a recoverable copy. - PyRevitConfigs logs the migration (Info) and each reset key (Warn), warns when migration is skipped for lack of a backup, and reports use of a read-only admin config (Info) since user changes are not saved.
GetValueOrDefault silently returned the default when a stored value failed to deserialize. Add a static ConfigurationDiagnostics sink (the assembly has no logger of its own); PyRevitConfigs routes it to logger.Warn, so a silently-defaulted value now leaves a trace even on read-only configs the migration cannot repair.
The migrator scanned and stamped a version once; a value corrupted after that stamp warned on every read but was never removed. Now the repair runs whenever an unreadable value is present on a writable config, independent of the version stamp. A config with nothing to fix performs no write, so a clean load still does not rewrite the ini.
Every config getter/setter rebuilt the service and re-ran migration on each call, and each engine re-read and re-saved the file. Introduce a process-wide cache so the loader, CLI, and script engines read one in-process instance, and hoist config discovery into the lightweight Configurations.Ini layer so the loader can share it without depending on the heavier pyRevitLabs.PyRevit or pyRevitLabs.Common assemblies. - Add PyRevitConfigStore: caches the built service by configuration name (default-name variants collapse to one instance); Reload invalidates. - Add PyRevitConfigService and PyRevitConfigPaths in Configurations.Ini: the default build factory (discovery, all-users fallback, seeding, migration) and file-location helpers, with no dependency on pyRevitLabs.Common. - PyRevitConfigs delegates GetConfigFile/ReloadConfig to the shared service and routes Configurations diagnostics to the pyRevit log. - ConfigurationService.SaveSection refreshes the typed section snapshots after a save so readers of the shared instance observe the write; userconfig save_changes captures the section snapshots before the sequential save so the refresh cannot drop pending edits. Tests: shared-store caching/reload, config-path discovery, and snapshot refresh on a real INI-backed service.
Point the loader's PyRevitConfig at the process-wide PyRevitConfigService instead of its own INI reader, so the loader, CLI, and Python engines share one in-process instance and one discovery path. PyRevitConfig becomes a thin adapter over IConfiguration with the same public surface and parsing semantics, using JSON-or-raw tolerant decoding so both migrated (JSON-encoded) and legacy (bare) values read correctly. ParseExtensionByName reads per-extension sections through the configuration; custom-path Load() stays non-shared for tests; ClearCache also drops the shared service cache. The old IniFile reader is left in place (now unused) next to the still-used PythonListParser, pending the in-Revit smoke test.
The loader now depends on pyRevitLabs.Configurations and .Configurations.Ini.
Exclude them and their INIFileParser dependency from the per-engine-folder
deploy so they load once from the bin/{netcore,netfx} root via
LoadAssembliesInFolder, matching how pyRevitLabs.Common is handled and avoiding
a duplicate/skewed load from two paths.
A per-key setter builds a sparse section POCO (e.g. new CoreSection { RocketMode
= x }), but SaveSection wrote every non-null property -- so the section's field-
initializer defaults (rocketmode, userextensions, port, sources, clones, ...)
overwrote or wiped sibling keys the caller never touched.
Move section read-defaults off field initializers: declare scalar defaults via
[DefaultValue] and supply an empty instance for collection properties, applied by
CreateSection on read. Unset properties are now null, so SaveSection's existing
null-skip writes only the keys a caller set -- and setting a property to its own
default value still persists, since it is explicitly non-null. Read-time default
values are unchanged.
…ervice Add ConfigParityTests: the loader's PyRevitConfig adapter and the shared ConfigurationService must decode the same canonical config file into identical values. The CLI and Python engines read through the same ConfigurationService, so loader/service parity transitively covers all readers and guards against a fourth reader ever drifting. Runs on net48 and net8.0-windows.
PyRevitConfig now reads through the shared configuration service, so the Win32 INI reader is unused. Remove it and move the still-used PythonListParser into its own file.
|
Unable to trigger custom agent "Code Reviewer". You have run out of credits 😔 |
There was a problem hiding this comment.
Pull request overview
This PR introduces a new shared configuration abstraction (pyRevitLabs.Configurations + pyRevitLabs.Configurations.Ini) and migrates the CLI + Python config layer to use it, with the goal of unifying INI parsing/defaults and reducing duplicated readers across components.
Changes:
- Added
pyRevitLabs.Configurationsabstractions + typed section POCOs, and an INI backend implemented onini-parser-netstandard. - Migrated CLI (
pyRevitLabs.PyRevit,pyRevitCLI) and Python config adapter (pyrevitlib/pyrevit/userconfig.py,coreutils/configparser.py) off the bespoke INI readers. - Added new xUnit test projects for the abstraction and the INI backend, and updated solution/build wiring.
Reviewed changes
Copilot reviewed 43 out of 45 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| pyrevitlib/pyrevit/userconfig.py | Refactors Python user_config to wrap the C# configuration service and typed sections. |
| pyrevitlib/pyrevit/script.py | Updates get_config() docstring type to the new ConfigSection wrapper. |
| pyrevitlib/pyrevit/revit/tabs.py | Hardens tab-coloring config reads against malformed types/values. |
| pyrevitlib/pyrevit/labs.py | Removes MadMilkman.Ini dependency and references the new Configurations assembly. |
| pyrevitlib/pyrevit/coreutils/configparser.py | Replaces Python-side INI parsing with a thin adapter over the C# configuration service. |
| dev/pyRevitLabs/tests/pyRevitLabs.Configurations.Tests/pyRevitLabs.Configurations.Tests.csproj | Adds new xUnit project for configuration abstraction tests. |
| dev/pyRevitLabs/tests/pyRevitLabs.Configurations.Tests/ConfigurationTests.cs | Adds baseline tests for IConfiguration behaviors. |
| dev/pyRevitLabs/tests/pyRevitLabs.Configurations.Tests/ConfigurationServiceUnitTests.cs | Adds (currently empty) unit test harness for ConfigurationService. |
| dev/pyRevitLabs/tests/pyRevitLabs.Configurations.Tests/ConfigurationServiceFixture.cs | Adds fixture for constructing ConfigurationService in tests. |
| dev/pyRevitLabs/tests/pyRevitLabs.Configurations.Ini.Tests/pyRevitLabs.Configurations.Ini.Tests.csproj | Adds xUnit project for INI backend tests. |
| dev/pyRevitLabs/tests/pyRevitLabs.Configurations.Ini.Tests/IniCreateFixture.cs | Adds test fixture that creates/deletes a temp INI file. |
| dev/pyRevitLabs/tests/pyRevitLabs.Configurations.Ini.Tests/IniConfigurationUnitTests.cs | Adds unit tests for INI configuration creation/builder validation. |
| dev/pyRevitLabs/pyRevitLabs.sln | Registers new projects and adds Any CPU/x86 configs and solution folders. |
| dev/pyRevitLabs/pyRevitLabs.PyRevit/pyRevitLabs.PyRevit.csproj | Removes MadMilkman.Ini packages and references the new Configurations projects; deploys INIFileParser if present. |
| dev/pyRevitLabs/pyRevitLabs.PyRevit/PyRevitExtensions.cs | Migrates extension enable/disable and extension path handling to IConfigurationService and section POCO saves. |
| dev/pyRevitLabs/pyRevitLabs.PyRevit/PyRevitConsts.cs | Minor whitespace/comment formatting changes only. |
| dev/pyRevitLabs/pyRevitLabs.PyRevit/PyRevitConfigs.cs | Replaces the old config reader with ConfigurationBuilder + INI configuration sources; adds read-only detection via write-probe; threads optional “revitYear” layer through setters. |
| dev/pyRevitLabs/pyRevitLabs.PyRevit/PyRevitConfig.cs | Deletes the old MadMilkman-backed config reader. |
| dev/pyRevitLabs/pyRevitLabs.PyRevit/PyRevitClones.cs | Migrates clone registry persistence to typed EnvironmentSection. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Sections/TelemetrySection.cs | Adds typed telemetry section definition. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Sections/RoutesSection.cs | Adds typed routes section definition. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Sections/EnvironmentSection.cs | Adds typed environment section definition. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Sections/CoreSection.cs | Adds typed core section definition and defaults. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/pyRevitLabs.Configurations.csproj | Adds multi-targeted Configurations project (net48/net8.0) and InternalsVisibleTo for tests. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Extensions/ConfigurationExtensions.cs | Introduces placeholder extension class (currently empty). |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Exceptions/ConfigurationSectionNotFoundException.cs | Adds custom exception for missing sections. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Exceptions/ConfigurationSectionKeyNotFoundException.cs | Adds custom exception for missing keys. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Exceptions/ConfigurationException.cs | Adds base configuration exception type. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Constants.cs | Adds internal constants for env-related section/key names. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/ConfigurationService.cs | Adds the configuration service for layering + section (de)serialization via attributes. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/ConfigurationName.cs | Adds internal record to track layered config names/order. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/ConfigurationBuilder.cs | Adds builder for composing layered IConfiguration sources into a service. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/ConfigurationBase.cs | Adds base class implementing common IConfiguration behaviors + tolerant reads. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Attributes/SectionNameAttribute.cs | Adds attribute for binding POCOs to INI section names. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Attributes/KeyNameAttribute.cs | Adds attribute for binding POCO properties to INI key names. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Abstractions/IConfigurationService.cs | Adds service contract for layered configurations + typed sections. |
| dev/pyRevitLabs/pyRevitLabs.Configurations/Abstractions/IConfiguration.cs | Adds configuration backend contract (read/write/serialize). |
| dev/pyRevitLabs/pyRevitLabs.Configurations.Ini/pyRevitLabs.Configurations.Ini.csproj | Adds INI backend project targeting net48/net8.0 with ini-parser and pyRevitLabs.Json reference. |
| dev/pyRevitLabs/pyRevitLabs.Configurations.Ini/IniConfiguration.cs | Implements IConfiguration using ini-parser with JSON-based value serialization and some legacy parsing logic. |
| dev/pyRevitLabs/pyRevitLabs.Configurations.Ini/Extensions/IniConfigurationExtensions.cs | Adds builder extension for registering INI configurations. |
| dev/pyRevitLabs/pyRevitCLI/Resources/UsagePatterns.txt | Extends pyrevit configs usage patterns to accept optional <revit_year>. |
| dev/pyRevitLabs/pyRevitCLI/PyRevitCLIExtensionCmds.cs | Threads revit version layer into extension enable/disable calls. |
| dev/pyRevitLabs/pyRevitCLI/PyRevitCLI.cs | Threads <revit_year> through config setters and extension toggle path. |
| dev/Directory.Build.targets | Adds NetFolder mapping for net8.0 to netcore output folder. |
| .gitignore | Un-ignores the new pyRevitLabs.Configurations.Ini directory from the existing *.ini ignore rule. |
- userconfig: config_file reports the service-resolved path, not the fixed install-scope path - userconfig: _SectionCompatWrapper.get_option tolerates non-JSON values instead of raising - userconfig: fix remove_section body indentation - versionmgr/upgrade: remove dead upgrade_user_config + telemetry-heal helpers (and the now-unused constants/imports) - tabs: resolve tab-style index through a tolerant fallback to the default index on malformed/out-of-range config - Configurations: make the IConfiguration Type-based overloads public - Configurations.Ini: parse hex integers as Int64 so long targets do not overflow - Configurations: pass (keyName, sectionName) to ConfigurationSectionKeyNotFoundException so its fields are correct - Configurations.Ini: fix conigurationName parameter typo - tests: drop duplicate apptelemetry_event_flags SetValue in fixture
- Config-file discovery regex: `.*[pyrevit|config].*\.ini` used a character clas,. Use `(pyrevit|config)` instead. Fixed in both resolvers (PyRevitConsts and PyRevitConfigPaths). - extension.json bool parsing: BuiltIn/DefaultEnabled/ RocketModeCompatible called bool.Parse directly, throwing on a missing field or non-"true"/"false" string. Parse defensively with per-field defaults (builtin=false, default_enabled=true, rocket_mode_compatible=false)
Reconcile the config-store rewrite with develop's install-scope/config-split (pyrevitlabs#3452) and loader-auth (pyrevitlabs#3461) work. Kept the branch's config-store architecture; ported seedshippeddefaults to the new IConfigurationService API and re-added parser PyRevitConfig.ExtensionLookupSources for the auth path. Dropped the pyrevitlabs#3441 admin split-config migration (depends on the deleted PyRevitConfig class) as a tracked follow-up. Builds on net48+net8.0; config and parity test suites pass.
|
@ChrisCrosley got a couple of things updated and resolved the merge conflict |
|
Did someone tested it using pyrevit and it's UI and config? |
|
@jmcouffin I did some smoke tests in Revit 2021-2025 and it worked |
|
@jmcouffin @romangolev @dosymep I saw that many edits in this PR are just formatting changes.
|
That was mostly my initiative, we have here mostly formatting regarding to docstrings and in-line comments
Anticipating your wishes, I had created a new draft PR addressing this topic |
sanzoghenzo
left a comment
There was a problem hiding this comment.
I'm halfway the review (in terms of number of files viewed, and I started from the smaller ones) and it's already taking too long 😅
As I stated in my previous comment, the many changes in formatting makes it hard to focus on the actual changed code.
| - name: Run configuration unit tests | ||
| run: | | ||
| dotnet test dev/pyRevitLabs/tests/pyRevitLabs.Configurations.Tests/pyRevitLabs.Configurations.Tests.csproj -c Release | ||
| dotnet test dev/pyRevitLabs/tests/pyRevitLabs.Configurations.Ini.Tests/pyRevitLabs.Configurations.Ini.Tests.csproj -c Release | ||
|
|
||
| - name: Run config parity tests | ||
| run: > | ||
| dotnet test dev/pyRevitLoader/pyRevitExtensionParserTester/pyRevitExtensionParserTest.csproj | ||
| -c Release --no-build -f net8.0-windows | ||
| --filter "FullyQualifiedName~ConfigParityTests|FullyQualifiedName~PyRevitConfigsFacadeTests" |
There was a problem hiding this comment.
Instead of adding things in the ci, It would be better to define the job in the build project via modular pipelines, so that it can also be run locally
| /// A config option that is not set reaches this dictionary as a null, so | ||
| /// every field read below goes through a type-matched <c>GetXxx</c> helper | ||
| /// rather than an unboxing cast, which would throw and take down session | ||
| /// load over a single absent option. |
There was a problem hiding this comment.
What? As I said multiple times before, the documentation should declare what a method does, not how.
And should not refer previous behaviour.
Also, keep it in simple terms.
| configuration.SkipInvalidLines = true; | ||
| configuration.AllowDuplicateSections = true; | ||
| configuration.AllowDuplicateKeys = true; | ||
| configuration.OverrideDuplicateKeys = true; |
There was a problem hiding this comment.
Are we sure we want this?
I get that this is for the migration to follow through, but a malformed INI file shouldn't be allowed to prevent user confusion.
For example, the duplicate keys override could be an oversight and the user might expect the first value to win; if we don't signal the issue in any way, the user is clueless on why things don't work as he/she intended.
| /// Parsing never fails on a malformed file: unparseable lines are dropped | ||
| /// and the last value of a repeated key wins. A config that refused to load | ||
| /// would take down the loader, CLI, and script engines at once, and would | ||
| /// never reach the migrator that repairs it. |
There was a problem hiding this comment.
We're not writing novels here, but technical documentation.
Rephrase to highlight what it does, not the advantages of the implementation choice
| protected override void SaveConfigurationImpl(string configurationPath) | ||
| { | ||
| string? directory = Path.GetDirectoryName(configurationPath); | ||
| if (!string.IsNullOrEmpty(directory)) |
There was a problem hiding this comment.
Should we worry (= throw error) about the directory being null or empty?
| /// Parses a JSON list literal, retrying with backslash-escaping when the | ||
| /// first attempt fails, since a legacy Windows path stored unescaped carries | ||
| /// backslashes JSON treats as bad escapes. Returns false for a value with no | ||
| /// double quote at all, since that is the Python single-quoted form rather |
There was a problem hiding this comment.
I surely am biased against AI generated docs, but I feel that the ", since ..." form should not be accepted in code documentation.
| The flat snake_case accessors (e.g. `user_config.rocket_mode`) are convenience | ||
| aliases over these sections and are considered legacy: prefer the typed | ||
| `section.name` form in new code. The aliases are expected to be deprecated in a | ||
| future release once callers have migrated, so avoid adding new ones. |
There was a problem hiding this comment.
Has this been already decided?
being a python guy, I would prefer snake_case attributes 😅
There was a problem hiding this comment.
This is definitely open to discussion! Just looking at it from the perspective that much of the file can be deleted if we can live without them.
|
@sanzoghenzo 2 commits are marked as the formating ones, I'm sure you could review and using some git mojo to not have that in the way: |
Not possible on GitHub web and mobile app, and I don't know how to do reviews locally 😅 I'll try to learn it as soon as I have time |
|
@romangolev could you just revert these two commits for convenience for now? |
Co-authored-by: Andrea Ghensi <andrea.ghensi@gmail.com>
Co-authored-by: Andrea Ghensi <andrea.ghensi@gmail.com>
…n.cs Co-authored-by: Andrea Ghensi <andrea.ghensi@gmail.com>
|
@jmcouffin, I know you made several fixes to config parsing/writing in the past couple releases. I believe I ported them over successfully, but probably worth a second opinion. |
Honestly, the quantity of code and refactor is overwhelming. This would probably take me a week to review properly and test. I'll need a a Claude max licence and automated UI testing to validate qprity and code. |
|
Oh, no rush at all. I just wanted to call that item out specifically because it's the sort of thing that might fall through the cracks. |
|
@jmcouffin this one is really big PR but it mostly follows the effort of We may ask a help of @dosymep as well to get an opinion of the initial contributor to this issue |
|
@romangolev |
|
I was removed as the author of these commits? hehe |
|
@dosymep I believe it was easier to start off a new branch. The other one was quite outdated, no hard feelings! |
|
You should have pulled my branch and pushed it, then my authorship would have remained, but it's so offensive |
Tested with Revit 2021 - 2027 @jmcouffin @ChrisCrosley Tier 1 — Automated suites
Ran for real via the DevTools button: 60/60 passed, ~0.9s, no Tier 2 — Manual smoke tests
Tier 3 — Edge cases from the PR body
Tier 4 — Regression checkPR claims 2 of 25 bundled extensions touch the config service directly ✅ Found one, confirmed identity: ✅ Exercised live — FilterLegend button and its Shift-click |
Edit: The original description outlined 3 phases. All work is complete and I've rewritten this description.
Summary
pyRevit_config.iniis read by three independent parsers: the Python layer (pyrevit.coreutils.configparser), the CLI (pyRevitLabs.PyRevit), and the C# loader (pyRevitExtensionParser). Each has its own defaults and quirks, and they have drifted apart. None share a parsed result, so the file is re-read for every startup script and smartbutton engine — at least five reads on a basic install, 200–500ms each.This branch replaces all three with one C#-owned configuration store shared by the loader, CLI, IronPython, and CPython. Legacy INI parsing moves into a version-stamped migrator that repairs a config in place.
Port of @dosymep's #2482 onto current
develop, reconciled with what has changed since, plus install-scope awareness, migration, and loader integration.The service
New
pyRevitLabs.Configurationsassembly:IConfigurationService/IConfigurationwith attribute-bound typed sections ([SectionName]/[KeyName]/[DefaultValue]) for[core],[routes],[telemetry],[environment], and per-extension sections. Defaults live on the section schema instead of being restated by each reader.GetValueOrDefault).EnsureSnapshots), so a reader never sees state older than the last write.EnsureWritable) rather than dropping them at flush time.The INI backend
pyRevitLabs.Configurations.Inireads and writes UTF-8 without a BOM so Python'sconfigparsercan read the same file.True/False, hex integers, bare unquoted strings and Windows paths, single-quoted list literals. The list form (LegacyListFormat) and the clone-registry dict form (LegacyDictFormat) are each defined once and shared by the read path and the migrator, so both interpret them identically.Migration
ConfigurationMigratorruns on any writable load, stamped with[core] config_version, so corruption introduced after first run still gets repaired. Several recent issues trace back to config parsing; this gives those fixes one home."", and legacy bare values are left alone.Migrate).Consumers
CLI (
PyRevitConfigs.cs) — all access goes throughIConfigurationService; the bespoke CLI parser (PyRevitConfig.cs, −176 lines) is deleted.Loader (
PyRevitConfig.cs) — now a read-only facade overIConfiguration; the standalone Win32IniFile.csreader is deleted (−460 lines). Values decode through the service, so the loader agrees with the CLI and Python. Its cached instance is held only while it still wraps the configuration the shared store hands out, so a reload from any host is observed (Load).Python (
userconfig.py,coreutils/configparser.py) — thin adapters over the same service; theMadMilkman.Inidependency is gone. Typed section properties write through on assignment, andsave_changesflushes once.versionmgr/upgrade.pydrops to the 4.8.5 temp-file cleanup. 34 ofuserconfig.py's 40 accessors are now one-line delegations and could be deprecated once extensions move to the typedsection.nameform.Behavior changes
apptelemetry_event_flagsis stored as a string, not an int — the 128-bit hex bitmask overflowsint(TelemetrySection). Legacy0x…values still read.[core] config_versionand, on first repair, leave a.v0.<timestamp>.bakalongside.%ProgramData%is the writable target only for an elevated process. Standard users resolve to a per-user config under%APPDATA%, seeded from the machine config — the policydevelopsettled in #3512 and #3523. A machine config a process can't write is not treated as a lockdown; only the DOS ReadOnly attribute is (pyrevit configs seed --lock).%APPDATA%config is their own active config. When it moves the clone registry or a missing extension section into the machine config, the per-user file is renamed topyRevit_config.ini.split-admin.<timestamp>.bak; one with nothing to contribute is left in place. Only the clone registry and extension sections are merged, so that user's[core],[routes], and[telemetry]values stay in the retired copy.Breaking changes
The surface extensions actually use is unchanged:
user_config,CONSTS, the snake_case accessors,get_section/add_section/has_section/remove_section,save_changes,reload, andget_option/set_option/has_option/remove_option. Nothing in this repo uses a removed name, and none of the 25 extensions inextensions.jsondo either — only two touch the config service at all.Internals removed:
coreutils.configparser—PyRevitConfigParser/PyRevitConfigSectionParser, replaced byConfigSections/ConfigSection. For a tool keeping settings in its own ini,open_config_file(path)returnsConfigSectionsover any ini path.userconfigmodule level — the always-empty path placeholders (CONFIG_FILE,USER_CONFIG_FILE,ADMIN_CONFIG_FILE,LOCAL_CONFIG_FILE; useuser_config.config_file), andfind_config_file/verify_configs.PyRevitConfig—get_config_version()(unrelated to the new migration stamp) andget_config_file_hash(). Its constructor now takes the service rather than a file path.versionmgr.upgrade—upgrade_user_config/heal_bloated_telemetry_fields, now the migrator's job;upgrade_existing_pyrevit()is unchanged. Two deliberate differences: the decode/re-encode sweep over every option is not ported, since re-encoding an already-mangled value is the escape-doubling mechanism itself — the migrator rewrites only keys it has positively identified. And a healed telemetry field is now removed so it falls back to its section default, where the Python version wrote''; identical except forapptelemetry_server_url, which declares no default and reads back null.Testing
The .NET suites run in CI now (
Run configuration unit tests,Run config parity tests).pyRevitLabs.Configurations.Tests(xUnit, 14) — extension-section resolution;PyRevitConfigStorecaching, concurrency, failed-build eviction, reset.pyRevitLabs.Configurations.Ini.Tests(xUnit, 104) — golden-file round-trips over four real config shapes, list decoding across every historical encoding, malformed-file tolerance, read-only guards, migration of both legacy lists and the clone dict, telemetry repair, snapshot freshness, discovery. Selection is covered across install scope and elevation, including that a machine config a standard user can't write resolves to a writable per-user copy (#3504). Split-admin repair is driven end to end, including an inert second pass.ConfigParityTests/PyRevitConfigsFacadeTests(NUnit, 17, run by filter) — CLI get-after-set verified against a fresh read of the file, so a write is proven to reach disk; loader surface checked against the service over a fixture where every value contradicts its section default.test_config_roundtrip.py(66) — runs under IPY2, IPY3, and CPython. 56 are hermetic against a dict-backed fakeIConfiguration, including container values whose Windows paths carry unescaped backslashes (JSON rejects them, so both readers retry escaped) asserted down to the original failure: a path list decoding to a string and iterating one character at a time. The other 10 need the real INI backend and skip when the labs assemblies aren't loadable — 7 re-run the key assertions so the fake can't drift, 3 coveropen_config_file.test_config_*module inside a live Revit session.Out of scope
YamlDotNetdependency, no dangling format dispatch.IConfigurationstays, so another backend can be added through it later.dotnet test tests/Build.Tests.csproj, which covers the build pipeline, not product code.pyRevitExtensionParserTester(~297 NUnit tests) builds with its results discarded — this PR runs 17 of them — andpyRevitLabs.UnitTests(31 MSTest) is solution-referenced only and touches install-scope and addon paths, so it needs an audit for which cases are hermetic.