refactor: Unify pyRevit configuration handling#3450
Draft
ChrisCrosley wants to merge 10 commits into
Draft
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.
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).
Contributor
|
Unable to trigger custom agent "Code Reviewer". You have run out of credits 😔 |
Contributor
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
pyRevit currently has three independent configuration readers for
pyrevit_config.ini, each with its own parsing, defaults, and quirks that have drifted apart over time. Also, there is no sharing of parsed configs between them, so on load the C# loader parses and then each python engine also parses. This leads to the configs being read from disk once by the C# loader, then again for every startup script and smartbutton engine (at least 5 times for a basic pyRevit install):pyrevit.coreutils.configparser),pyRevitLabs.PyRevit),pyRevitExtensionParser).This PR starts with a clean port of @dosymep's #2482 ("refactor: Rewrite pyRevit configurations") onto current
developand reconciling it with everything that changed since that PR was opened, and proposes 2 additional phases of work to complete the feature.Whats in this PR (Phase 1)
New
pyRevitLabs.Configurationsassembly family ported from #2482:IConfigurationService/IConfigurationwith attribute-bound typed POCO sections ([SectionName]/[KeyName]) for[core],[routes],[telemetry], and[environment], with defaults centralized on the sections instead of scattered across readers.pyRevitLabs.Configurations.Ini) — the only backend ported (see below re: the dropped JSON/YAML backends).CLI migrated onto the service (
pyRevitLabs.PyRevit/PyRevitConfigs): all config access now goes throughIConfigurationService; the bespoke CLI parser is removed.Python config layer migrated (
userconfig.py,coreutils/configparser.py,labs.py): the Python layer is now a thin adapterover the same service and drops the
MadMilkman.Inidependency. The publicuser_configAPI is unchanged.Reconciliation and hardening (deltas applied on top of the #2482 base so the port matches current
developbehavior):developsince refactor: Rewrite pyRevit configurations #2482 forked: close-output modes,new_loader,read_script_metadata, cached attachment lookup, and the [Bug]: Install Extensions Path not changeable #3193 third-party-extension ordering.%APPDATA%) file and falls back to a read-only all-users (%ProgramData%) config, detected via a real write-probe rather than the read-only file attribute alone.What this PR intentionally does not include
pyRevitExtensionParser) onto the shared service - and serving one process-wide instance to every engine - is Phase 3.Configurations.Ini,Configurations.Json, andConfigurations.Yaml(a ~150-lineYamlConfigurationbuilt onYamlDotNet). pyRevit's configuration is INI, so this PR ports only the INI backend and omits the JSON and YAML projects entirely: no source, no project, no format-dispatch left dangling, and no newYamlDotNet/JSON-format dependency added. The pluggable-backend abstraction (IConfiguration) is kept, so a backend could be re-introduced later if there were ever a reason to. Let me know if this is a feature we want to include after phase 3.Per-Revit-version overrides - ported write-path only
#2482 introduced the ability to override a setting for a specific Revit version (e.g.
pyrevit configs rocketmode enable 2025, writing to a versioned filepyRevit_config.2025.inithat takes effect when running in that Revit version). That feature is ported here partially - the write path and the layering plumbing, but not the read path - so it is not yet functional end-to-end:configsCLI commands accept the optional[<revit_year>]argument; ~35 setters take arevitVersionand save into a per-version config layer;ConfigurationServicereads layered sources with the override winning.GetConfigFile()reads vs 35 versionedGetConfigFile(revitVersion)writes — no read passes a version). The loader, which is the component that actually runs inside a known Revit version, hard-codes the default layer; Python reads the default; and the CLI has no way to read a version-specific value back. Migration also operates on the base config only.Net: a versioned
pyrevit configswrite produces apyRevit_config.{year}.inithat nothing currently reads. Completing the read-side wiring is deferred to Phase 3 (have the loader resolve its Revit version and request the matching layer; add versioned read overloads), where everything already routes through one shared service keyed by configuration name. It is called out here so reviewers don't mistake the CLI surface for a working feature.Known limitations
This branch is developed and reviewed in phases but ships as a whole - none of it is released until all phases land. Two defects inherited from #2482 are present at this phase and fixed in later ones; they are called out here so the
gaps are tracked rather than hidden:
pyRevitLabs.Configurations.Iniround-trip tests are intentionally left red as a canary. Fixed in Phase 2 (symmetric-JSON value contract), which flips them green.pyrevit configs <x>CLI command, which passes a one-fieldCoreSection) rewrites the whole section and wipes sibling keys such asuserextensions. Fixed in Phase 3 by moving section read-defaults to[DefaultValue]/read-time so a sparse save only writes the fields the caller set. Do not runpyrevit configscommands against a config you care about until that phase lands.Testing
pyRevitLabs.PyRevitand the CLI, bothnet48andnet8.0.py_compileis clean across the migrated modules.Future work (separate, incremental PRs)
Phase 2 - fidelity & migration
Goal: make the new store byte-faithful to existing config files and self-healing, so adopting it neither loses nor corrupts data - and fix the string-encoding defect inherited from #2482 (turning the two red canary tests green).
Main changes:
Iniround-trip tests green.[core] config_versionkey - backs up first, then repairs over-escaped/corrupt values and drops unreadable keys, folding the legacy Python fixup chain into one place.apptelemetry_event_flagsas a string (128-bit hex overflowsint);ConfigSection.__getattr__raisesAttributeErrorwhen an option is absent (restores presence detection).These fixes address:
Phase 3 — loader integration & one shared instance
Goal: retire the third (loader) reader and serve one process-wide config instance to the loader, CLI, and all engines - eliminating the per-engine re-read/re-save of the config file that originally motivated this work - and fix the sparse-save clobber.
Main changes:
ConfigurationService- discovered and migrated once, shared by the loader, CLI, IronPython, and CPython; "reload" becomes an explicit cache invalidation rather than a re-parse on every engine startup.Configurations.Inilayer so the loader can use the shared service without taking heavy dependencies.pyRevitExtensionParser's config becomes a thin adapter overIConfigurationand the standalone Win32 INI reader is deleted.[DefaultValue]/read-time, so a single-field save writes only the fields the caller set (no more wipinguserextensions).Future features - if wanted