From 6eb8da9816b0b7953526a72ce090dc12624986ca Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 22 Oct 2025 10:49:08 +0200 Subject: [PATCH 01/19] [Experimental] Generic file providers --- Package.swift | 2 +- README.md | 12 +- Sources/Configuration/ConfigReader.swift | 2 +- .../Documentation.docc/Documentation.md | 35 ++- .../Guides/Choosing-access-patterns.md | 2 +- .../Guides/Configuring-applications.md | 4 +- .../Guides/Example-use-cases.md | 8 +- .../Guides/Handling-secrets-correctly.md | 4 +- .../Guides/Troubleshooting.md | 4 +- .../Guides/Using-reloading-providers.md | 14 +- .../Reference/FileConfigSnapshotProtocol.md | 12 + .../Reference/FileParsingOptionsProtocol.md | 11 + .../Reference/FileProvider.md | 14 + .../Reference/JSONProvider.md | 8 - .../Reference/JSONSnapshot.md | 13 + .../Reference/ReloadingFileProvider.md | 18 ++ .../Reference/ReloadingJSONprovider.md | 8 - .../Reference/ReloadingYAMLprovider.md | 8 - .../Reference/YAMLProvider.md | 8 - .../Reference/YAMLSnapshot.md | 13 + .../CommonProviderFileSystem.swift | 0 .../Providers/Files/FileProvider.swift | 201 ++++++++++++ .../Files/FileProviderSnapshot.swift | 100 ++++++ .../JSONSnapshot.swift} | 158 ++++++---- .../ReloadingFileProvider.swift} | 291 +++++++++++++----- .../ReloadingFileProviderMetrics.swift | 2 +- .../YAMLSnapshot.swift} | 159 ++++++---- .../Providers/JSON/JSONProvider.swift | 252 --------------- .../JSON/ReloadingJSONProvider.swift | 276 ----------------- .../Wrappers/KeyMappingProvider.swift | 2 +- .../YAML/ReloadingYAMLProvider.swift | 285 ----------------- .../Providers/YAML/YAMLProvider.swift | 260 ---------------- Sources/Configuration/SecretsSpecifier.swift | 2 +- ...ests.swift => JSONFileProviderTests.swift} | 25 +- ...t => JSONReloadingFileProviderTests.swift} | 18 +- ...swift => ReloadingFileProviderTests.swift} | 94 +++--- ...ests.swift => YAMLFileProviderTests.swift} | 25 +- ...t => YAMLReloadingFileProviderTests.swift} | 18 +- 38 files changed, 940 insertions(+), 1428 deletions(-) create mode 100644 Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md create mode 100644 Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md create mode 100644 Sources/Configuration/Documentation.docc/Reference/FileProvider.md delete mode 100644 Sources/Configuration/Documentation.docc/Reference/JSONProvider.md create mode 100644 Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md create mode 100644 Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md delete mode 100644 Sources/Configuration/Documentation.docc/Reference/ReloadingJSONprovider.md delete mode 100644 Sources/Configuration/Documentation.docc/Reference/ReloadingYAMLprovider.md delete mode 100644 Sources/Configuration/Documentation.docc/Reference/YAMLProvider.md create mode 100644 Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md rename Sources/Configuration/Providers/{Common => Files}/CommonProviderFileSystem.swift (100%) create mode 100644 Sources/Configuration/Providers/Files/FileProvider.swift create mode 100644 Sources/Configuration/Providers/Files/FileProviderSnapshot.swift rename Sources/Configuration/Providers/{JSON/JSONProviderSnapshot.swift => Files/JSONSnapshot.swift} (78%) rename Sources/Configuration/Providers/{Common/ReloadingFileProviderCore.swift => Files/ReloadingFileProvider.swift} (65%) rename Sources/Configuration/Providers/{Common => Files}/ReloadingFileProviderMetrics.swift (99%) rename Sources/Configuration/Providers/{YAML/YAMLProviderSnapshot.swift => Files/YAMLSnapshot.swift} (70%) delete mode 100644 Sources/Configuration/Providers/JSON/JSONProvider.swift delete mode 100644 Sources/Configuration/Providers/JSON/ReloadingJSONProvider.swift delete mode 100644 Sources/Configuration/Providers/YAML/ReloadingYAMLProvider.swift delete mode 100644 Sources/Configuration/Providers/YAML/YAMLProvider.swift rename Tests/ConfigurationTests/{JSONProviderTests.swift => JSONFileProviderTests.swift} (71%) rename Tests/ConfigurationTests/{ReloadingJSONProviderTests.swift => JSONReloadingFileProviderTests.swift} (62%) rename Tests/ConfigurationTests/{ReloadingFileProviderCoreTests.swift => ReloadingFileProviderTests.swift} (79%) rename Tests/ConfigurationTests/{YAMLProviderTests.swift => YAMLFileProviderTests.swift} (72%) rename Tests/ConfigurationTests/{ReloadingYAMLProviderTests.swift => YAMLReloadingFileProviderTests.swift} (62%) diff --git a/Package.swift b/Package.swift index 71e126b..27afe6e 100644 --- a/Package.swift +++ b/Package.swift @@ -29,7 +29,7 @@ var traits: Set = [ .trait( name: "ReloadingSupport", description: - "Adds support for reloading built-in provider variants, such as ReloadingJSONProvider and ReloadingYAMLProvider (when their respective traits are enabled).", + "Adds support for reloading built-in provider variants, such as ReloadingFileProvider.", enabledTraits: [ "LoggingSupport" ] diff --git a/README.md b/README.md index 76e441c..5cce529 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The example code below creates the two relevant providers, and resolves them in ```swift let config = ConfigReader(providers: [ EnvironmentVariablesProvider(), - try await JSONProvider(filePath: "/etc/config.json") + try await FileProvider(filePath: "/etc/config.json") ]) let httpTimeout = config.int(forKey: "http.timeout", default: 60) print(httpTimeout) // prints 15 @@ -100,11 +100,11 @@ To enable an additional trait on the package, update the package dependency: ``` Available traits: -- **`JSONSupport`** (default): Adds support for `JSONProvider`, a `ConfigProvider` for reading JSON files. +- **`JSONSupport`** (default): Adds support for `FileProvider`, a `ConfigProvider` for reading JSON files. - **`LoggingSupport`** (opt-in): Adds support for `AccessLogger`, a way to emit access events into a `SwiftLog.Logger`. -- **`ReloadingSupport`** (opt-in): Adds support for auto-reloading variants of file providers, such as `ReloadingJSONProvider` (when `JSONSupport` is enabled) and `ReloadingYAMLProvider` (when `YAMLSupport` is enabled). +- **`ReloadingSupport`** (opt-in): Adds support for auto-reloading variants of file providers, such as `ReloadingFileProvider` (when `JSONSupport` is enabled) and `ReloadingFileProvider` (when `YAMLSupport` is enabled). - **`CommandLineArgumentsSupport`** (opt-in): Adds support for `CommandLineArgumentsProvider` for parsing command line arguments. -- **`YAMLSupport`** (opt-in): Adds support for `YAMLProvider`, a `ConfigProvider` for reading YAML files. +- **`YAMLSupport`** (opt-in): Adds support for `FileProvider`, a `ConfigProvider` for reading YAML files. ## Supported platforms and minimum versions @@ -120,8 +120,8 @@ The library includes comprehensive built-in provider support: - Environment variables: [`EnvironmentVariablesProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/environmentvariablesprovider) - Command-line arguments: [`CommandLineArgumentsProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/commandlineargumentsprovider) -- JSON file: [`JSONProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/jsonprovider) and [`ReloadingJSONProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/reloadingjsonprovider) -- YAML file: [`YAMLProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/yamlprovider) and [`ReloadingYAMLProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/reloadingyamlprovider) +- JSON file: [`FileProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/fileprovider) and [`ReloadingFileProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/reloadingfileprovider) +- YAML file: [`FileProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/fileprovider) and [`ReloadingFileProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/reloadingfileprovider) - Directory of files: [`DirectoryFilesProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/directoryfilesprovider) - In-memory: [`InMemoryProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/inmemoryprovider) and [`MutableInMemoryProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/mutableinmemoryprovider) - Key transforming: [`KeyMappingProvider`](https://swiftpackageindex.com/apple/swift-configuration/documentation/configuration/keymappingprovider) diff --git a/Sources/Configuration/ConfigReader.swift b/Sources/Configuration/ConfigReader.swift index fc5b41b..9ea01a5 100644 --- a/Sources/Configuration/ConfigReader.swift +++ b/Sources/Configuration/ConfigReader.swift @@ -44,7 +44,7 @@ import Foundation /// // First, check environment variables /// EnvironmentVariablesProvider(), /// // Then, check a JSON config file -/// try await JSONProvider(filePath: "/etc/config.json"), +/// try await FileProvider(filePath: "/etc/config.json"), /// // Finally, fall back to in-memory defaults /// InMemoryProvider(values: [ /// "http.timeout": 60, diff --git a/Sources/Configuration/Documentation.docc/Documentation.md b/Sources/Configuration/Documentation.docc/Documentation.md index b10b114..92f47b3 100644 --- a/Sources/Configuration/Documentation.docc/Documentation.md +++ b/Sources/Configuration/Documentation.docc/Documentation.md @@ -44,7 +44,7 @@ For example, to read the timeout configuration value for an HTTP client, check o } ``` ```swift - let provider = try await JSONProvider( + let provider = try await FileProvider( filePath: "/etc/config.json" ) let config = ConfigReader(provider: provider) @@ -61,7 +61,7 @@ For example, to read the timeout configuration value for an HTTP client, check o } ``` ```swift - let provider = try await ReloadingJSONProvider( + let provider = try await ReloadingFileProvider( filePath: "/etc/config.json" ) // Omitted: Add `provider` to a ServiceGroup @@ -76,7 +76,7 @@ For example, to read the timeout configuration value for an HTTP client, check o timeout: 30 ``` ```swift - let provider = try await YAMLProvider( + let provider = try await FileProvider( filePath: "/etc/config.yaml" ) let config = ConfigReader(provider: provider) @@ -90,7 +90,7 @@ For example, to read the timeout configuration value for an HTTP client, check o timeout: 30 ``` ```swift - let provider = try await ReloadingYAMLProvider( + let provider = try await ReloadingFileProvider( filePath: "/etc/config.yaml" ) // Omitted: Add `provider` to a ServiceGroup @@ -187,11 +187,11 @@ To enable an additional trait on the package, update the package dependency: ``` Available traits: -- **`JSONSupport`** (default): Adds support for ``JSONProvider``, a ``ConfigProvider`` for reading JSON files. +- **`JSONSupport`** (default): Adds support for ``JSONSnapshot``, which enables using ``FileProvider`` and ``ReloadingFileProvider`` with JSON files. - **`LoggingSupport`** (opt-in): Adds support for ``AccessLogger``, a way to emit access events into a `SwiftLog.Logger`. -- **`ReloadingSupport`** (opt-in): Adds support for auto-reloading variants of file providers, such as ``ReloadingJSONProvider`` (when `JSONSupport` is enabled) and ``ReloadingYAMLProvider`` (when `YAMLSupport` is enabled). +- **`ReloadingSupport`** (opt-in): Adds support for ``ReloadingFileProvider``, which provides auto-reloading capability for file-based configuration. - **`CommandLineArgumentsSupport`** (opt-in): Adds support for ``CommandLineArgumentsProvider`` for parsing command line arguments. -- **`YAMLSupport`** (opt-in): Adds support for ``YAMLProvider``, a ``ConfigProvider`` for reading YAML files. +- **`YAMLSupport`** (opt-in): Adds support for ``YAMLSnapshot``, which enables using ``FileProvider`` and ``ReloadingFileProvider`` with YAML files. ### Supported platforms and minimum versions @@ -235,8 +235,8 @@ The library includes comprehensive built-in provider support: - Environment variables: ``EnvironmentVariablesProvider`` - Command-line arguments: ``CommandLineArgumentsProvider`` -- JSON file: ``JSONProvider`` and ``ReloadingJSONProvider`` -- YAML file: ``YAMLProvider`` and ``ReloadingYAMLProvider`` +- JSON file: ``FileProvider`` and ``ReloadingFileProvider`` with ``JSONSnapshot`` +- YAML file: ``FileProvider`` and ``ReloadingFileProvider`` with ``YAMLSnapshot`` - Directory of files: ``DirectoryFilesProvider`` - In-memory: ``InMemoryProvider`` and ``MutableInMemoryProvider`` - Key transforming: ``KeyMappingProvider`` @@ -259,7 +259,7 @@ let config = ConfigReader(providers: [ // Then, check command-line options. CommandLineArgumentsProvider(), // Then, check a JSON config file. - try await JSONProvider(filePath: "/etc/config.json"), + try await FileProvider(filePath: "/etc/config.json"), // Finally, fall back to in-memory defaults. InMemoryProvider(values: [ "http.timeout": 60, @@ -272,10 +272,10 @@ let timeout = config.int(forKey: "http.timeout", default: 15) #### Hot reloading -Long-running services can periodically reload configuration with ``ReloadingJSONProvider`` and ``ReloadingYAMLProvider``: +Long-running services can periodically reload configuration with ``ReloadingFileProvider``: ```swift -let provider = try await ReloadingJSONProvider(filePath: "/etc/config.json") +let provider = try await ReloadingFileProvider(filePath: "/etc/config.json") // Omitted: add provider to a ServiceGroup let config = ConfigReader(provider: provider) @@ -407,7 +407,6 @@ Any package can implement a ``ConfigProvider``, making the ecosystem extensible - ``ConfigReader`` - ``ConfigProvider`` - ``ConfigSnapshotReader`` -- ``ConfigSnapshotProtocol`` - - - @@ -415,10 +414,10 @@ Any package can implement a ``ConfigProvider``, making the ecosystem extensible ### Built-in providers - ``EnvironmentVariablesProvider`` - ``CommandLineArgumentsProvider`` -- ``JSONProvider`` -- ``YAMLProvider`` -- ``ReloadingJSONProvider`` -- ``ReloadingYAMLProvider`` +- ``FileProvider`` +- ``ReloadingFileProvider`` +- ``JSONSnapshot`` +- ``YAMLSnapshot`` - - ``DirectoryFilesProvider`` - @@ -427,6 +426,8 @@ Any package can implement a ``ConfigProvider``, making the ecosystem extensible - ``KeyMappingProvider`` ### Creating a custom provider +- ``ConfigSnapshotProtocol`` +- ``FileParsingOptionsProtocol`` - ``ConfigProvider`` - ``ConfigContent`` - ``ConfigValue`` diff --git a/Sources/Configuration/Documentation.docc/Guides/Choosing-access-patterns.md b/Sources/Configuration/Documentation.docc/Guides/Choosing-access-patterns.md index 032a326..8ea2bce 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Choosing-access-patterns.md +++ b/Sources/Configuration/Documentation.docc/Guides/Choosing-access-patterns.md @@ -122,7 +122,7 @@ Use the "watch" pattern when: - Immediately emits the initial value, then subsequent updates. - Continues monitoring until the task is cancelled. -- Works with providers like ``ReloadingJSONProvider`` and ``ReloadingYAMLProvider``. +- Works with providers like ``ReloadingFileProvider``. For details on reloading providers, check out . diff --git a/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md b/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md index d1e0feb..b2ca2de 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md +++ b/Sources/Configuration/Documentation.docc/Guides/Configuring-applications.md @@ -35,8 +35,8 @@ let logger: Logger = ... let config = ConfigReader( providers: [ - EnvironmentVariablesProvider(), - try await JSONProvider(filePath: "/etc/myapp/config.json"), + EnvironmentVariablesProvider(), + try await FileProvider(filePath: "/etc/myapp/config.json"), InMemoryProvider(values: [ "http.server.port": 8080, "http.server.host": "127.0.0.1", diff --git a/Sources/Configuration/Documentation.docc/Guides/Example-use-cases.md b/Sources/Configuration/Documentation.docc/Guides/Example-use-cases.md index 342594f..1afda76 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Example-use-cases.md +++ b/Sources/Configuration/Documentation.docc/Guides/Example-use-cases.md @@ -24,7 +24,7 @@ variable name above `SERVER_PORT`. ### Reading from a JSON configuration file -You can store multiple configuration values together in a JSON file and read them from the fileystem using ``JSONProvider``. +You can store multiple configuration values together in a JSON file and read them from the fileystem using ``FileProvider`` with ``JSONSnapshot``. The following example creates a ``ConfigReader`` for a JSON file at the path `/etc/config.json`, and reads a url and port number collected as properties of the `database` JSON object: @@ -32,7 +32,7 @@ number collected as properties of the `database` JSON object: import Configuration let config = ConfigReader( - provider: try await JSONProvider(filePath: "/etc/config.json") + provider: try await FileProvider(filePath: "/etc/config.json") ) // Access nested values using dot notation. @@ -92,7 +92,7 @@ let config = ConfigReader(providers: [ // First check environment variables. EnvironmentVariablesProvider(), // Then check the config file. - try await JSONProvider(filePath: "/etc/config.json"), + try await FileProvider(filePath: "/etc/config.json"), // Finally, use hardcoded defaults. InMemoryProvider(values: [ "app.name": "MyApp", @@ -131,7 +131,7 @@ import Configuration import ServiceLifecycle // Create a reloading YAML provider -let provider = try await ReloadingYAMLProvider( +let provider = try await ReloadingFileProvider( filePath: "/etc/app-config.yaml", pollInterval: .seconds(30) ) diff --git a/Sources/Configuration/Documentation.docc/Guides/Handling-secrets-correctly.md b/Sources/Configuration/Documentation.docc/Guides/Handling-secrets-correctly.md index 8a42874..ec70ff4 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Handling-secrets-correctly.md +++ b/Sources/Configuration/Documentation.docc/Guides/Handling-secrets-correctly.md @@ -81,7 +81,7 @@ The following example marks keys as secret based on the closure you provide. In this case, keys that contain `password`, `secret`, or `token` are all marked as secret: ```swift -let provider = JSONProvider( +let provider = FileProvider( filePath: "/etc/config.json", secretsSpecifier: .dynamic { key, value in key.lowercased().contains("password") || @@ -96,7 +96,7 @@ let provider = JSONProvider( The following example asserts that none of the values returned from the provider are considered secret: ```swift -let provider = JSONProvider( +let provider = FileProvider( filePath: "/etc/config.json", secretsSpecifier: .none ) diff --git a/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md b/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md index c425980..a13aea5 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md +++ b/Sources/Configuration/Documentation.docc/Guides/Troubleshooting.md @@ -99,12 +99,12 @@ Common ServiceGroup problems: ```swift // Incorrect: Provider not included in ServiceGroup -let provider = try await ReloadingJSONProvider(filePath: "/etc/config.json") +let provider = try await ReloadingFileProvider(filePath: "/etc/config.json") let config = ConfigReader(provider: provider) // File monitoring won't work // Correct: Provider runs in ServiceGroup -let provider = try await ReloadingJSONProvider(filePath: "/etc/config.json") +let provider = try await ReloadingFileProvider(filePath: "/etc/config.json") let serviceGroup = ServiceGroup(services: [provider], logger: logger) try await serviceGroup.run() ``` diff --git a/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md b/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md index e914f24..c1c381d 100644 --- a/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md +++ b/Sources/Configuration/Documentation.docc/Guides/Using-reloading-providers.md @@ -6,8 +6,8 @@ Automatically reload configuration from files when they change. A reloading provider monitors configuration files for changes and automatically updates your application's configuration without requiring restarts. Swift Configuration provides: -- ``ReloadingJSONProvider`` for JSON configuration files. -- ``ReloadingYAMLProvider`` for YAML configuration files. +- ``ReloadingFileProvider`` with ``JSONSnapshot`` for JSON configuration files. +- ``ReloadingFileProvider`` with ``YAMLSnapshot`` for YAML configuration files. ### Basic usage @@ -18,7 +18,7 @@ Reloading providers run in a [`ServiceGroup`](https://swiftpackageindex.com/swif ```swift import ServiceLifecycle -let provider = try await ReloadingJSONProvider( +let provider = try await ReloadingFileProvider( filePath: "/etc/config.json", pollInterval: .seconds(15) ) @@ -109,8 +109,8 @@ and interval to watch for a JSON file that contains the configuration for your a let envProvider = EnvironmentVariablesProvider() let envConfig = ConfigReader(provider: envProvider) -let jsonProvider = try await ReloadingJSONProvider( - config: envConfig.scoped(to: "json") +let jsonProvider = try await ReloadingFileProvider( + config: envConfig.scoped(to: "json") // Reads JSON_FILE_PATH and JSON_POLL_INTERVAL_SECONDS ) ``` @@ -120,10 +120,10 @@ let jsonProvider = try await ReloadingJSONProvider( 1. **Replace initialization**: ```swift // Before - let provider = try await JSONProvider(filePath: "/etc/config.json") + let provider = try await FileProvider(filePath: "/etc/config.json") // After - let provider = try await ReloadingJSONProvider(filePath: "/etc/config.json") + let provider = try await ReloadingFileProvider(filePath: "/etc/config.json") ``` 2. **Add the provider to a ServiceGroup**: diff --git a/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md b/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md new file mode 100644 index 0000000..f15bd6a --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md @@ -0,0 +1,12 @@ +# ``Configuration/FileConfigSnapshotProtocol`` + +## Topics + +### Required methods + +- ``init(data:providerName:parsingOptions:)`` +- ``ParsingOptions`` + +### Protocol requirements + +- ``ConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md b/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md new file mode 100644 index 0000000..432db59 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md @@ -0,0 +1,11 @@ +# ``Configuration/FileParsingOptionsProtocol`` + +## Topics + +### Required properties + +- ``default`` + +### Parsing options + +- ``FileConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Documentation.docc/Reference/FileProvider.md b/Sources/Configuration/Documentation.docc/Reference/FileProvider.md new file mode 100644 index 0000000..921d194 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/FileProvider.md @@ -0,0 +1,14 @@ +# ``Configuration/FileProvider`` + +## Topics + +### Creating a file provider + +- ``init(snapshotType:parsingOptions:filePath:)`` +- ``init(snapshotType:parsingOptions:config:)`` + +### Reading configuration files + +- ``FileConfigSnapshotProtocol`` +- ``JSONSnapshot`` +- ``YAMLSnapshot`` diff --git a/Sources/Configuration/Documentation.docc/Reference/JSONProvider.md b/Sources/Configuration/Documentation.docc/Reference/JSONProvider.md deleted file mode 100644 index 7497c87..0000000 --- a/Sources/Configuration/Documentation.docc/Reference/JSONProvider.md +++ /dev/null @@ -1,8 +0,0 @@ -# ``Configuration/JSONProvider`` - -## Topics - -### Creating a JSON provider - -- ``init(config:bytesDecoder:secretsSpecifier:)`` -- ``init(filePath:bytesDecoder:secretsSpecifier:)`` diff --git a/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md b/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md new file mode 100644 index 0000000..8e08e48 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md @@ -0,0 +1,13 @@ +# ``Configuration/JSONSnapshot`` + +## Topics + +### Creating a JSON snapshot + +- ``init(data:providerName:parsingOptions:)`` +- ``ParsingOptions`` + +### Snapshot configuration + +- ``FileConfigSnapshotProtocol`` +- ``ConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md b/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md new file mode 100644 index 0000000..35074c5 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md @@ -0,0 +1,18 @@ +# ``Configuration/ReloadingFileProvider`` + +## Topics + +### Creating a reloading file provider + +- ``init(snapshotType:parsingOptions:filePath:pollInterval:logger:metrics:)`` +- ``init(snapshotType:parsingOptions:config:logger:metrics:)`` + +### Service lifecycle + +- ``run()`` + +### Monitoring file changes + +- ``FileConfigSnapshotProtocol`` +- ``JSONSnapshot`` +- ``YAMLSnapshot`` diff --git a/Sources/Configuration/Documentation.docc/Reference/ReloadingJSONprovider.md b/Sources/Configuration/Documentation.docc/Reference/ReloadingJSONprovider.md deleted file mode 100644 index 8c1fcbf..0000000 --- a/Sources/Configuration/Documentation.docc/Reference/ReloadingJSONprovider.md +++ /dev/null @@ -1,8 +0,0 @@ -# ``Configuration/ReloadingJSONProvider`` - -## Topics - -### Creating a reloading JSON provider - -- ``init(config:bytesDecoder:secretsSpecifier:logger:metrics:)`` -- ``init(filePath:pollInterval:bytesDecoder:secretsSpecifier:logger:metrics:)`` diff --git a/Sources/Configuration/Documentation.docc/Reference/ReloadingYAMLprovider.md b/Sources/Configuration/Documentation.docc/Reference/ReloadingYAMLprovider.md deleted file mode 100644 index 72ff05f..0000000 --- a/Sources/Configuration/Documentation.docc/Reference/ReloadingYAMLprovider.md +++ /dev/null @@ -1,8 +0,0 @@ -# ``Configuration/ReloadingYAMLProvider`` - -## Topics - -### Creating a reloading YAML provider - -- ``init(config:bytesDecoder:secretsSpecifier:logger:metrics:)`` -- ``init(filePath:pollInterval:bytesDecoder:secretsSpecifier:logger:metrics:)`` diff --git a/Sources/Configuration/Documentation.docc/Reference/YAMLProvider.md b/Sources/Configuration/Documentation.docc/Reference/YAMLProvider.md deleted file mode 100644 index cc0fe8e..0000000 --- a/Sources/Configuration/Documentation.docc/Reference/YAMLProvider.md +++ /dev/null @@ -1,8 +0,0 @@ -# ``Configuration/YAMLProvider`` - -## Topics - -### Creating a YAML provider - -- ``init(config:bytesDecoder:secretsSpecifier:)`` -- ``init(filePath:bytesDecoder:secretsSpecifier:)`` diff --git a/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md b/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md new file mode 100644 index 0000000..618fff5 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md @@ -0,0 +1,13 @@ +# ``Configuration/YAMLSnapshot`` + +## Topics + +### Creating a YAML snapshot + +- ``init(data:providerName:parsingOptions:)`` +- ``ParsingOptions`` + +### Snapshot configuration + +- ``FileConfigSnapshotProtocol`` +- ``ConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Providers/Common/CommonProviderFileSystem.swift b/Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift similarity index 100% rename from Sources/Configuration/Providers/Common/CommonProviderFileSystem.swift rename to Sources/Configuration/Providers/Files/CommonProviderFileSystem.swift diff --git a/Sources/Configuration/Providers/Files/FileProvider.swift b/Sources/Configuration/Providers/Files/FileProvider.swift new file mode 100644 index 0000000..a4c3665 --- /dev/null +++ b/Sources/Configuration/Providers/Files/FileProvider.swift @@ -0,0 +1,201 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public import SystemPackage + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// A configuration provider that reads from a file on disk using a configurable snapshot type. +/// +/// `FileProvider` is a generic file-based configuration provider that works with different +/// file formats by using different snapshot types that conform to ``FileConfigSnapshotProtocol``. +/// This allows for a unified interface for reading JSON, YAML, or other structured configuration files. +/// +/// ## Usage +/// +/// Create a provider by specifying the snapshot type and file path: +/// +/// ```swift +/// // Using with JSON snapshot +/// let jsonProvider = try await FileProvider( +/// filePath: "/etc/config.json" +/// ) +/// +/// // Using with YAML snapshot +/// let yamlProvider = try await FileProvider( +/// filePath: "/etc/config.yaml" +/// ) +/// ``` +/// +/// The provider reads the file once during initialization and creates an immutable snapshot +/// of the configuration values. For auto-reloading behavior, use ``ReloadingFileProvider``. +/// +/// ## Configuration from a reader +/// +/// You can also initialize the provider using a configuration reader that specifies +/// the file path through environment variables or other configuration sources: +/// +/// ```swift +/// let envConfig = ConfigReader(provider: EnvironmentVariablesProvider()) +/// let provider = try await FileProvider(config: envConfig) +/// ``` +/// +/// This expects a `filePath` key in the configuration that specifies the path to the file. +/// For a full list of read configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. +@available(Configuration 1.0, *) +public struct FileProvider: Sendable { + + /// A snapshot of the internal state. + private let _snapshot: SnapshotType + + /// Creates a file provider that reads from the specified file path. + /// + /// This initializer reads the file at the given path and creates a snapshot using the + /// specified snapshot type. The file is read once during initialization. + /// + /// - Parameters: + /// - snapshotType: The type of snapshot to create from the file contents. + /// - parsingOptions: Options used by the snapshot to parse the file data. + /// - filePath: The path to the configuration file to read. + /// - Throws: If the file cannot be read or if snapshot creation fails. + public init( + snapshotType: SnapshotType.Type = SnapshotType.self, + parsingOptions: SnapshotType.ParsingOptions = .default, + filePath: FilePath + ) async throws { + try await self.init( + snapshotType: snapshotType, + parsingOptions: parsingOptions, + filePath: filePath, + fileSystem: LocalCommonProviderFileSystem() + ) + } + + /// Creates a file provider using a file path from a configuration reader. + /// + /// This initializer reads the file path from the provided configuration reader + /// and creates a snapshot from that file. + /// + /// ## Configuration keys + /// - `filePath` (string, required): The path to the configuration file to read. + /// + /// - Parameters: + /// - snapshotType: The type of snapshot to create from the file contents. + /// - parsingOptions: Options used by the snapshot to parse the file data. + /// - config: A configuration reader that contains the required configuration keys. + /// - Throws: If the `filePath` key is missing, if the file cannot be read, or if snapshot creation fails. + public init( + snapshotType: SnapshotType.Type = SnapshotType.self, + parsingOptions: SnapshotType.ParsingOptions = .default, + config: ConfigReader + ) async throws { + try await self.init( + snapshotType: snapshotType, + parsingOptions: parsingOptions, + filePath: config.requiredString(forKey: "filePath", as: FilePath.self) + ) + } + + /// Creates a file provider. + /// + /// This internal initializer allows specifying a custom file system implementation, + /// which is primarily used for testing and internal operations. + /// + /// - Parameters: + /// - snapshotType: The type of snapshot to create from the file contents. + /// - parsingOptions: Options used by the snapshot to parse the file data. + /// - filePath: The path to the configuration file to read. + /// - fileSystem: The file system implementation to use for reading the file. + /// - Throws: If the file cannot be read or if snapshot creation fails. + internal init( + snapshotType: SnapshotType.Type, + parsingOptions: SnapshotType.ParsingOptions, + filePath: FilePath, + fileSystem: some CommonProviderFileSystem + ) async throws { + let fileContents = try await fileSystem.fileContents(atPath: filePath) + self._snapshot = try snapshotType.init( + data: fileContents, + providerName: "FileProvider<\(SnapshotType.self)>", + parsingOptions: parsingOptions + ) + } +} + +@available(Configuration 1.0, *) +extension FileProvider: CustomStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var description: String { + _snapshot.description + } +} + +@available(Configuration 1.0, *) +extension FileProvider: CustomDebugStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var debugDescription: String { + _snapshot.debugDescription + } +} + +@available(Configuration 1.0, *) +extension FileProvider: ConfigProvider { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var providerName: String { + _snapshot.providerName + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func value( + forKey key: AbsoluteConfigKey, + type: ConfigType + ) throws -> LookupResult { + try _snapshot.value(forKey: key, type: type) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func fetchValue( + forKey key: AbsoluteConfigKey, + type: ConfigType + ) async throws -> LookupResult { + try value(forKey: key, type: type) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func watchSnapshot( + updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return + ) async throws -> Return { + try await watchSnapshotFromSnapshot(updatesHandler: updatesHandler) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func watchValue( + forKey key: AbsoluteConfigKey, + type: ConfigType, + updatesHandler: ( + ConfigUpdatesAsyncSequence, Never> + ) async throws -> Return + ) async throws -> Return { + try await watchValueFromValue(forKey: key, type: type, updatesHandler: updatesHandler) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func snapshot() -> any ConfigSnapshotProtocol { + _snapshot + } +} diff --git a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift new file mode 100644 index 0000000..8ecc05b --- /dev/null +++ b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import SystemPackage +#if canImport(FoundationEssentials) +public import FoundationEssentials +#else +public import Foundation +#endif + +/// A type that provides parsing options for file configuration snapshots. +/// +/// This protocol defines the requirements for parsing options types used when creating +/// file-based configuration snapshots. Types conforming to this protocol can provide +/// additional configuration or processing parameters that affect how file data is +/// interpreted and parsed. +/// +/// ## Usage +/// +/// Implement this protocol to provide parsing options: +/// +/// ```swift +/// struct MyParsingOptions: FileParsingOptionsProtocol { +/// let encoding: String.Encoding +/// let dateFormat: String? +/// +/// static let `default` = MyParsingOptions( +/// encoding: .utf8, +/// dateFormat: nil +/// ) +/// } +/// ``` +@available(Configuration 1.0, *) +public protocol FileParsingOptionsProtocol: Sendable { + /// The default instance of this options type. + /// + /// This property provides a default configuration that can be used when + /// no parsing options are specified. + static var `default`: Self { get } +} + +/// A protocol for configuration snapshots created from file data. +/// +/// This protocol extends ``ConfigSnapshotProtocol`` to provide file-specific functionality +/// for creating configuration snapshots from raw file data. Types conforming to this protocol +/// can parse various file formats (such as JSON and YAML) and convert them into configuration values. +/// +/// Commonly used with ``FileProvider`` and ``ReloadingFileProvider``. +/// +/// ## Implementation +/// +/// To create a custom file configuration snapshot: +/// +/// ```swift +/// struct MyFormatSnapshot: FileConfigSnapshotProtocol { +/// typealias ParsingOptions = MyParsingOptions +/// +/// let values: [String: ConfigValue] +/// let providerName: String +/// +/// init(data: Data, providerName: String, parsingOptions: MyParsingOptions) throws { +/// self.providerName = providerName +/// // Parse the data according to your format +/// self.values = try parseMyFormat(data, using: parsingOptions) +/// } +/// } +/// ``` +/// +/// The snapshot is responsible for parsing the file data and converting it into a +/// representation of configuration values that can be queried by the configuration system. +@available(Configuration 1.0, *) +public protocol FileConfigSnapshotProtocol: ConfigSnapshotProtocol, CustomStringConvertible, + CustomDebugStringConvertible +{ + /// The parsing options type used for parsing this snapshot. + associatedtype ParsingOptions: FileParsingOptionsProtocol + + /// Creates a new snapshot from file data. + /// + /// This initializer parses the provided file data and creates a snapshot + /// containing the configuration values found in the file. + /// + /// - Parameters: + /// - data: The raw file data to parse. + /// - providerName: The name of the provider creating this snapshot. + /// - parsingOptions: Parsing options that affect parsing behavior. + /// - Throws: If the file data cannot be parsed or contains invalid configuration. + init(data: Data, providerName: String, parsingOptions: ParsingOptions) throws +} diff --git a/Sources/Configuration/Providers/JSON/JSONProviderSnapshot.swift b/Sources/Configuration/Providers/Files/JSONSnapshot.swift similarity index 78% rename from Sources/Configuration/Providers/JSON/JSONProviderSnapshot.swift rename to Sources/Configuration/Providers/Files/JSONSnapshot.swift index 07253f4..e4ec2b0 100644 --- a/Sources/Configuration/Providers/JSON/JSONProviderSnapshot.swift +++ b/Sources/Configuration/Providers/Files/JSONSnapshot.swift @@ -17,14 +17,49 @@ import SystemPackage // Needs full Foundation for JSONSerialization. -import Foundation +public import Foundation /// A snapshot of configuration values parsed from JSON data. /// /// This structure represents a point-in-time view of configuration values. It handles /// the conversion from JSON types to configuration value types. +/// +/// Commonly used with ``FileProvider`` and ``ReloadingFileProvider``. @available(Configuration 1.0, *) -internal struct JSONProviderSnapshot { +public struct JSONSnapshot { + + /// Parsing options for JSON snapshot creation. + /// + /// This struct provides configuration options for parsing JSON data into configuration snapshots, + /// including byte decoding and secrets specification. + public struct ParsingOptions: FileParsingOptionsProtocol { + /// A decoder of bytes from a string. + public var bytesDecoder: any ConfigBytesFromStringDecoder + + /// A specifier for determining which configuration values should be treated as secrets. + public var secretsSpecifier: SecretsSpecifier + + /// Creates parsing options for JSON snapshots. + /// + /// - Parameters: + /// - bytesDecoder: The decoder to use for converting string values to byte arrays. + /// - secretsSpecifier: The specifier for identifying secret values. + public init( + bytesDecoder: some ConfigBytesFromStringDecoder = .base64, + secretsSpecifier: SecretsSpecifier = .none + ) { + self.bytesDecoder = bytesDecoder + self.secretsSpecifier = secretsSpecifier + } + + /// The default parsing options. + /// + /// Uses base64 byte decoding and treats no values as secrets. + public static var `default`: Self { + .init() + } + } + /// The key encoder for JSON. static let keyEncoder: SeparatorKeyEncoder = .dotSeparated @@ -109,7 +144,7 @@ internal struct JSONProviderSnapshot { internal enum JSONConfigError: Error, CustomStringConvertible { /// The top level JSON value was not an object. - case topLevelJSONValueIsNotObject(FilePath) + case topLevelJSONValueIsNotObject /// The primitive type returned by JSONSerialization is not supported. case unsupportedPrimitiveValue([String], String) @@ -119,8 +154,8 @@ internal struct JSONProviderSnapshot { var description: String { switch self { - case .topLevelJSONValueIsNotObject(let path): - return "The top-level value of the JSON file must be an object. File: \(path)" + case .topLevelJSONValueIsNotObject: + return "The top-level value of the JSON file must be an object." case .unsupportedPrimitiveValue(let keyPath, let typeName): return "Unsupported primitive value type: \(typeName) at \(keyPath.joined(separator: "."))" case .unexpectedValueInArray(let keyPath, let typeName): @@ -129,59 +164,14 @@ internal struct JSONProviderSnapshot { } } - /// A decoder of bytes from a string. - var bytesDecoder: any ConfigBytesFromStringDecoder - /// The underlying config values. var values: [String: ValueWrapper] - /// Creates a snapshot with pre-parsed values. - /// - /// - Parameters: - /// - values: The configuration values. - /// - bytesDecoder: The decoder for converting string values to bytes. - init( - values: [String: ValueWrapper], - bytesDecoder: some ConfigBytesFromStringDecoder, - ) { - self.values = values - self.bytesDecoder = bytesDecoder - } + /// The name of the provider that created this snapshot. + public let providerName: String - /// Creates a snapshot by parsing JSON data from a file. - /// - /// This initializer reads JSON data from the specified file, parses it using - /// `JSONSerialization`, and converts the parsed values into the internal - /// configuration format. The top-level JSON value must be an object. - /// - /// - Parameters: - /// - filePath: The path of the JSON file to read. - /// - fileSystem: The file system interface for reading the file. - /// - bytesDecoder: The decoder for converting string values to bytes. - /// - secretsSpecifier: The specifier for identifying secret values. - /// - Throws: An error if the JSON root is not an object, or any error from - /// file reading or JSON parsing. - init( - filePath: FilePath, - fileSystem: some CommonProviderFileSystem, - bytesDecoder: some ConfigBytesFromStringDecoder, - secretsSpecifier: SecretsSpecifier - ) async throws { - let fileContents = try await fileSystem.fileContents(atPath: filePath) - guard let parsedDictionary = try JSONSerialization.jsonObject(with: fileContents) as? [String: any Sendable] - else { - throw JSONProviderSnapshot.JSONConfigError.topLevelJSONValueIsNotObject(filePath) - } - let values = try parseValues( - parsedDictionary, - keyEncoder: Self.keyEncoder, - secretsSpecifier: secretsSpecifier - ) - self.init( - values: values, - bytesDecoder: bytesDecoder, - ) - } + /// A decoder of bytes from a string. + var bytesDecoder: any ConfigBytesFromStringDecoder /// Parses config content from the provided JSON value. /// - Parameters: @@ -302,13 +292,30 @@ internal struct JSONProviderSnapshot { } @available(Configuration 1.0, *) -extension JSONProviderSnapshot: ConfigSnapshotProtocol { - var providerName: String { - "JSONProvider" +extension JSONSnapshot: FileConfigSnapshotProtocol { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public init(data: Data, providerName: String, parsingOptions: ParsingOptions) throws { + guard let parsedDictionary = try JSONSerialization.jsonObject(with: data) as? [String: any Sendable] + else { + throw JSONSnapshot.JSONConfigError.topLevelJSONValueIsNotObject + } + let values = try parseValues( + parsedDictionary, + keyEncoder: Self.keyEncoder, + secretsSpecifier: parsingOptions.secretsSpecifier + ) + self.init( + values: values, + providerName: providerName, + bytesDecoder: parsingOptions.bytesDecoder + ) } +} +@available(Configuration 1.0, *) +extension JSONSnapshot: ConfigSnapshotProtocol { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { + public func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { let encodedKey = Self.keyEncoder.encode(key) return try withConfigValueLookup(encodedKey: encodedKey) { guard let value = values[encodedKey] else { @@ -319,6 +326,27 @@ extension JSONProviderSnapshot: ConfigSnapshotProtocol { } } +@available(Configuration 1.0, *) +extension JSONSnapshot: CustomStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var description: String { + "\(providerName)[\(values.count) values]" + } +} + +@available(Configuration 1.0, *) +extension JSONSnapshot: CustomDebugStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var debugDescription: String { + let prettyValues = + values + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: ", ") + return "\(providerName)[\(values.count) values: \(prettyValues)]" + } +} + /// Parses a value emitted by JSONSerialization into a JSON config value. /// - Parameters: /// - parsedDictionary: The parsed JSON object from JSONSerialization. @@ -331,15 +359,15 @@ internal func parseValues( _ parsedDictionary: [String: any Sendable], keyEncoder: some ConfigKeyEncoder, secretsSpecifier: SecretsSpecifier -) throws -> [String: JSONProviderSnapshot.ValueWrapper] { - var values: [String: JSONProviderSnapshot.ValueWrapper] = [:] +) throws -> [String: JSONSnapshot.ValueWrapper] { + var values: [String: JSONSnapshot.ValueWrapper] = [:] var valuesToIterate: [([String], any Sendable)] = parsedDictionary.map { ([$0], $1) } while !valuesToIterate.isEmpty { let (keyComponents, value) = valuesToIterate.removeFirst() if let dictionary = value as? [String: any Sendable] { valuesToIterate.append(contentsOf: dictionary.map { (keyComponents + [$0], $1) }) } else { - let primitiveValue: JSONProviderSnapshot.JSONValue? + let primitiveValue: JSONSnapshot.JSONValue? if let array = value as? [any Sendable] { if array.isEmpty { primitiveValue = .emptyArray @@ -350,7 +378,7 @@ internal func parseValues( try array.enumerated() .map { index, value in guard let string = value as? String else { - throw JSONProviderSnapshot.JSONConfigError.unexpectedValueInArray( + throw JSONSnapshot.JSONConfigError.unexpectedValueInArray( keyComponents + ["\(index)"], "\(type(of: value))" ) @@ -369,7 +397,7 @@ internal func parseValues( } else if let bool = value as? Bool { return .bool(bool) } else { - throw JSONProviderSnapshot.JSONConfigError.unexpectedValueInArray( + throw JSONSnapshot.JSONConfigError.unexpectedValueInArray( keyComponents + ["\(index)"], "\(type(of: value))" ) @@ -377,7 +405,7 @@ internal func parseValues( } ) } else { - throw JSONProviderSnapshot.JSONConfigError.unsupportedPrimitiveValue( + throw JSONSnapshot.JSONConfigError.unsupportedPrimitiveValue( keyComponents + ["0"], "\(type(of: firstValue))" ) @@ -395,7 +423,7 @@ internal func parseValues( } else if value is NSNull { primitiveValue = nil } else { - throw JSONProviderSnapshot.JSONConfigError.unsupportedPrimitiveValue( + throw JSONSnapshot.JSONConfigError.unsupportedPrimitiveValue( keyComponents, "\(type(of: value))" ) diff --git a/Sources/Configuration/Providers/Common/ReloadingFileProviderCore.swift b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift similarity index 65% rename from Sources/Configuration/Providers/Common/ReloadingFileProviderCore.swift rename to Sources/Configuration/Providers/Files/ReloadingFileProvider.swift index 65b342a..e540c2f 100644 --- a/Sources/Configuration/Providers/Common/ReloadingFileProviderCore.swift +++ b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift @@ -19,20 +19,71 @@ import FoundationEssentials #else import Foundation #endif -import ServiceLifecycle -import Logging -import Metrics -import Synchronization + +public import SystemPackage +public import ServiceLifecycle +public import Logging +public import Metrics import AsyncAlgorithms -import SystemPackage +import Synchronization -/// A generic common implementation of file-based reloading for configuration providers. +/// A configuration provider that reads configuration from a file on disk with automatic reloading capability. +/// +/// `ReloadingFileProvider` is a generic file-based configuration provider that monitors +/// a configuration file for changes and automatically reloads the data when +/// the file is modified. This provider works with different file formats by using +/// different snapshot types that conform to ``FileConfigSnapshotProtocol``. +/// +/// ## Usage +/// +/// Create a reloading provider by specifying the snapshot type and file path: +/// +/// ```swift +/// // Using with a JSON snapshot and a custom poll interval +/// let jsonProvider = try await ReloadingFileProvider( +/// filePath: "/etc/config.json", +/// pollInterval: .seconds(30) +/// ) +/// +/// // Using with a YAML snapshot +/// let yamlProvider = try await ReloadingFileProvider( +/// filePath: "/etc/config.yaml" +/// ) +/// ``` +/// +/// ## Service integration +/// +/// This provider implements the `Service` protocol and must be run within a `ServiceGroup` +/// to enable automatic reloading: +/// +/// ```swift +/// let provider = try await ReloadingFileProvider(filePath: "/etc/config.json") +/// let serviceGroup = ServiceGroup(services: [provider], logger: logger) +/// try await serviceGroup.run() +/// ``` +/// +/// The provider monitors the file by polling at the specified interval (default: 15 seconds) +/// and notifies any active watchers when changes are detected. +/// +/// ## Configuration from a reader +/// +/// You can also initialize the provider using a configuration reader: +/// +/// ```swift +/// let envConfig = ConfigReader(provider: EnvironmentVariablesProvider()) +/// let provider = try await ReloadingFileProvider(config: envConfig) +/// ``` /// -/// This internal type handles all the common reloading logic, state management, -/// and service lifecycle for reloading file-based providers. It allows different provider types -/// (JSON, YAML, and so on) to reuse the same logic while providing their own format-specific deserialization. +/// This expects a `filePath` key in the configuration that specifies the path to the file. +/// For a full list of read configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. +/// +/// ## File monitoring +/// +/// The provider detects changes by monitoring both file timestamps and symlink target changes. +/// When a change is detected, it reloads the file and notifies all active watchers of the +/// updated configuration values. @available(Configuration 1.0, *) -internal final class ReloadingFileProviderCore: Sendable { +public final class ReloadingFileProvider: Sendable { /// The internal storage structure for the provider state. private struct Storage { @@ -63,6 +114,9 @@ internal final class ReloadingFileProviderCore + /// The options used for parsing the data. + private let parsingOptions: SnapshotType.ParsingOptions + /// The file system interface for reading files and timestamps. private let fileSystem: any CommonProviderFileSystem @@ -73,7 +127,7 @@ internal final class ReloadingFileProviderCore SnapshotType - - /// Creates a new reloading file provider core. - /// - /// This initializer performs the initial file load and snapshot creation, - /// resolves any symlinks, and sets up the internal storage. - /// - /// - Parameters: - /// - filePath: The path to the configuration file to monitor. - /// - pollInterval: The interval between timestamp checks. - /// - providerName: The human-readable name of the provider. - /// - fileSystem: The file system to use. - /// - logger: The logger instance. - /// - metrics: The metrics factory. - /// - createSnapshot: A closure that creates a snapshot from file data. - /// - Throws: If the initial file load or snapshot creation fails. internal init( + snapshotType: SnapshotType.Type = SnapshotType.self, + parsingOptions: SnapshotType.ParsingOptions, filePath: FilePath, pollInterval: Duration, - providerName: String, fileSystem: any CommonProviderFileSystem, logger: Logger, - metrics: any MetricsFactory, - createSnapshot: @Sendable @escaping (Data) async throws -> SnapshotType + metrics: any MetricsFactory ) async throws { + self.parsingOptions = parsingOptions self.filePath = filePath self.pollInterval = pollInterval - self.providerName = providerName + self.providerName = "ReloadingFileProvider<\(SnapshotType.self)>" self.fileSystem = fileSystem - self.createSnapshot = createSnapshot // Set up the logger with metadata var logger = logger @@ -130,9 +167,13 @@ internal final class ReloadingFileProviderCore LookupResult { +@available(Configuration 1.0, *) +extension ReloadingFileProvider: ConfigProvider { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { try storage.withLock { storage in try storage.snapshot.value(forKey: key, type: type) } } - internal func fetchValue(forKey key: AbsoluteConfigKey, type: ConfigType) async throws -> LookupResult { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func fetchValue( + forKey key: AbsoluteConfigKey, + type: ConfigType + ) async throws -> LookupResult { try await reloadIfNeeded(logger: logger) return try value(forKey: key, type: type) } - internal func watchValue( + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func watchValue( forKey key: AbsoluteConfigKey, type: ConfigType, updatesHandler: (ConfigUpdatesAsyncSequence, Never>) async throws -> Return @@ -395,11 +488,13 @@ extension ReloadingFileProviderCore: ConfigProvider { return try await updatesHandler(.init(stream)) } - internal func snapshot() -> any ConfigSnapshotProtocol { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func snapshot() -> any ConfigSnapshotProtocol { storage.withLock { $0.snapshot } } - internal func watchSnapshot( + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func watchSnapshot( updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return ) async throws -> Return { let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) @@ -425,4 +520,42 @@ extension ReloadingFileProviderCore: ConfigProvider { } } +@available(Configuration 1.0, *) +extension ReloadingFileProvider: Service { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func run() async throws { + logger.debug("File polling starting") + defer { + logger.debug("File polling stopping") + } + + var counter = 1 + for try await _ in AsyncTimerSequence(interval: pollInterval, clock: .continuous).cancelOnGracefulShutdown() { + defer { + counter += 1 + metrics.pollTickCounter.increment(by: 1) + } + + var tickLogger = logger + tickLogger[metadataKey: "\(providerName).poll.tick.number"] = .stringConvertible(counter) + tickLogger.debug("Poll tick starting") + defer { + tickLogger.debug("Poll tick stopping") + } + + do { + try await reloadIfNeeded(logger: tickLogger) + } catch { + tickLogger.debug( + "Poll tick failed, will retry on next tick", + metadata: [ + "error": "\(error)" + ] + ) + metrics.pollTickErrorCounter.increment(by: 1) + } + } + } +} + #endif diff --git a/Sources/Configuration/Providers/Common/ReloadingFileProviderMetrics.swift b/Sources/Configuration/Providers/Files/ReloadingFileProviderMetrics.swift similarity index 99% rename from Sources/Configuration/Providers/Common/ReloadingFileProviderMetrics.swift rename to Sources/Configuration/Providers/Files/ReloadingFileProviderMetrics.swift index 774de1d..a99930c 100644 --- a/Sources/Configuration/Providers/Common/ReloadingFileProviderMetrics.swift +++ b/Sources/Configuration/Providers/Files/ReloadingFileProviderMetrics.swift @@ -69,7 +69,7 @@ internal struct ReloadingFileProviderMetrics { /// /// - Parameters: /// - factory: The metrics factory to use for creating metric instances. - /// - providerName: The name of the provider. For example: "ReloadingJSONProvider". + /// - providerName: The name of the provider. For example: "ReloadingFileProvider". init(factory: any MetricsFactory, providerName: String) { let prefix = providerName.lowercased() self.pollTickCounter = Counter(label: "\(prefix)_poll_ticks_total", factory: factory) diff --git a/Sources/Configuration/Providers/YAML/YAMLProviderSnapshot.swift b/Sources/Configuration/Providers/Files/YAMLSnapshot.swift similarity index 70% rename from Sources/Configuration/Providers/YAML/YAMLProviderSnapshot.swift rename to Sources/Configuration/Providers/Files/YAMLSnapshot.swift index a50de00..31f5b99 100644 --- a/Sources/Configuration/Providers/YAML/YAMLProviderSnapshot.swift +++ b/Sources/Configuration/Providers/Files/YAMLSnapshot.swift @@ -15,9 +15,9 @@ #if YAMLSupport #if canImport(FoundationEssentials) -import FoundationEssentials +public import FoundationEssentials #else -import Foundation +public import Foundation #endif import Yams import Synchronization @@ -27,8 +27,43 @@ import SystemPackage /// /// This class represents a point-in-time view of configuration values. It handles /// the conversion from YAML types to configuration value types. +/// +/// Commonly used with ``FileProvider`` and ``ReloadingFileProvider``. @available(Configuration 1.0, *) -final class YAMLProviderSnapshot: Sendable { +public final class YAMLSnapshot: Sendable { + + /// Custom input configuration for YAML snapshot creation. + /// + /// This struct provides configuration options for parsing YAML data into configuration snapshots, + /// including byte decoding and secrets specification. + public struct ParsingOptions: FileParsingOptionsProtocol { + /// A decoder of bytes from a string. + public var bytesDecoder: any ConfigBytesFromStringDecoder + + /// A specifier for determining which configuration values should be treated as secrets. + public var secretsSpecifier: SecretsSpecifier + + /// Creates custom input configuration for YAML snapshots. + /// + /// - Parameters: + /// - bytesDecoder: The decoder to use for converting string values to byte arrays. + /// - secretsSpecifier: The specifier for identifying secret values. + public init( + bytesDecoder: some ConfigBytesFromStringDecoder = .base64, + secretsSpecifier: SecretsSpecifier = .none + ) { + self.bytesDecoder = bytesDecoder + self.secretsSpecifier = secretsSpecifier + } + + /// The default custom input configuration. + /// + /// Uses base64 byte decoding and treats no values as secrets. + public static var `default`: Self { + .init() + } + } + /// The key encoder for YAML. private static let keyEncoder: SeparatorKeyEncoder = .dotSeparated @@ -72,7 +107,7 @@ final class YAMLProviderSnapshot: Sendable { internal enum YAMLConfigError: Error, CustomStringConvertible { /// The top level YAML value was not a mapping. - case topLevelYAMLValueIsNotMapping(FilePath) + case topLevelYAMLValueIsNotMapping /// A YAML key is not convertible to string. case keyNotConvertibleToString([String]) @@ -88,8 +123,8 @@ final class YAMLProviderSnapshot: Sendable { var description: String { switch self { - case .topLevelYAMLValueIsNotMapping(let path): - return "Top level YAML value is not a mapping. File: \(path)" + case .topLevelYAMLValueIsNotMapping: + return "Top level YAML value is not a mapping." case .keyNotConvertibleToString(let keyPath): return "YAML key is not convertible to string: \(keyPath.joined(separator: "."))" case .unsupportedPrimitiveValue(let keyPath): @@ -102,6 +137,9 @@ final class YAMLProviderSnapshot: Sendable { } } + /// The name of the provider that created this snapshot. + public let providerName: String + /// A decoder of bytes from a string. let bytesDecoder: any ConfigBytesFromStringDecoder @@ -110,48 +148,14 @@ final class YAMLProviderSnapshot: Sendable { /// Using a Mutex since the Yams types aren't Sendable. let values: Mutex<[String: ValueWrapper]> - /// Creates a snapshot with pre-parsed values. - /// - /// - Parameters: - /// - bytesDecoder: The decoder for converting string values to bytes. - /// - values: The configuration values. - init(bytesDecoder: some ConfigBytesFromStringDecoder, values: sending [String: ValueWrapper]) { - self.bytesDecoder = bytesDecoder + init( + values: sending [String: ValueWrapper], + providerName: String, + bytesDecoder: any ConfigBytesFromStringDecoder + ) { self.values = .init(values) - } - - /// Creates a snapshot by parsing YAML data from a file. - /// - /// This initializer reads YAML data from the specified file, parses it using - /// the Yams library, and converts the parsed values into the internal - /// configuration format. The top-level YAML value must be a mapping. - /// - /// - Parameters: - /// - filePath: The path of the YAML file to read. - /// - fileSystem: The file system interface for reading the file. - /// - bytesDecoder: The decoder for converting string values to bytes. - /// - secretsSpecifier: The specifier for identifying secret values. - /// - Throws: An error if the YAML root is not a mapping, or any error from - /// file reading or YAML parsing. - convenience init( - filePath: FilePath, - fileSystem: some CommonProviderFileSystem, - bytesDecoder: some ConfigBytesFromStringDecoder, - secretsSpecifier: SecretsSpecifier - ) async throws { - let fileContents = try await fileSystem.fileContents(atPath: filePath) - guard let mapping = try Yams.Parser(yaml: fileContents).singleRoot()?.mapping else { - throw YAMLProviderSnapshot.YAMLConfigError.topLevelYAMLValueIsNotMapping(filePath) - } - let values = try parseValues( - mapping, - keyEncoder: Self.keyEncoder, - secretsSpecifier: secretsSpecifier - ) - self.init( - bytesDecoder: bytesDecoder, - values: values - ) + self.providerName = providerName + self.bytesDecoder = bytesDecoder } /// Parses config content from the provided YAML value. @@ -240,13 +244,29 @@ final class YAMLProviderSnapshot: Sendable { } @available(Configuration 1.0, *) -extension YAMLProviderSnapshot: ConfigSnapshotProtocol { - var providerName: String { - "YAMLProvider" +extension YAMLSnapshot: FileConfigSnapshotProtocol { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public convenience init(data: Data, providerName: String, parsingOptions: ParsingOptions) throws { + guard let mapping = try Yams.Parser(yaml: data).singleRoot()?.mapping else { + throw YAMLConfigError.topLevelYAMLValueIsNotMapping + } + let values = try parseValues( + mapping, + keyEncoder: Self.keyEncoder, + secretsSpecifier: parsingOptions.secretsSpecifier + ) + self.init( + values: values, + providerName: providerName, + bytesDecoder: parsingOptions.bytesDecoder + ) } +} +@available(Configuration 1.0, *) +extension YAMLSnapshot: ConfigSnapshotProtocol { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { + public func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { let encodedKey = Self.keyEncoder.encode(key) return try withConfigValueLookup(encodedKey: encodedKey) { try values.withLock { (values) -> ConfigValue? in @@ -263,6 +283,31 @@ extension YAMLProviderSnapshot: ConfigSnapshotProtocol { } } +@available(Configuration 1.0, *) +extension YAMLSnapshot: CustomStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var description: String { + values.withLock { values in + "\(providerName)[\(values.count) values]" + } + } +} + +@available(Configuration 1.0, *) +extension YAMLSnapshot: CustomDebugStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var debugDescription: String { + values.withLock { values in + let prettyValues = + values + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: ", ") + return "\(providerName)[\(values.count) values: \(prettyValues)]" + } + } +} + /// Parses the root emitted by Yams. /// - Parameters: /// - parsedDictionary: The parsed YAML mapping from Yams. @@ -275,24 +320,24 @@ internal func parseValues( _ parsedDictionary: Yams.Node.Mapping, keyEncoder: some ConfigKeyEncoder, secretsSpecifier: SecretsSpecifier -) throws -> [String: YAMLProviderSnapshot.ValueWrapper] { - var values: [String: YAMLProviderSnapshot.ValueWrapper] = [:] +) throws -> [String: YAMLSnapshot.ValueWrapper] { + var values: [String: YAMLSnapshot.ValueWrapper] = [:] var valuesToIterate: [([String], Yams.Node, Yams.Node)] = parsedDictionary.map { ([], $0, $1) } while !valuesToIterate.isEmpty { let (prefix, nodeKey, value) = valuesToIterate.removeFirst() guard let stringKey = nodeKey.string else { - throw YAMLProviderSnapshot.YAMLConfigError.keyNotConvertibleToString(prefix) + throw YAMLSnapshot.YAMLConfigError.keyNotConvertibleToString(prefix) } let keyComponents = prefix + [stringKey] if let mapping = value.mapping { valuesToIterate.append(contentsOf: mapping.map { (keyComponents, $0, $1) }) } else { - let yamlValue: YAMLProviderSnapshot.YAMLValue + let yamlValue: YAMLSnapshot.YAMLValue if let sequence = value.sequence { let scalarArray = try sequence.enumerated() .map { index, value in guard let scalar = value.scalar else { - throw YAMLProviderSnapshot.YAMLConfigError.nonScalarValueInArray(keyComponents, index) + throw YAMLSnapshot.YAMLConfigError.nonScalarValueInArray(keyComponents, index) } return scalar } @@ -300,7 +345,7 @@ internal func parseValues( } else if let scalar = value.scalar { yamlValue = .scalar(scalar) } else { - throw YAMLProviderSnapshot.YAMLConfigError.unsupportedPrimitiveValue(keyComponents) + throw YAMLSnapshot.YAMLConfigError.unsupportedPrimitiveValue(keyComponents) } let encodedKey = keyEncoder.encode(AbsoluteConfigKey(keyComponents)) let isSecret = secretsSpecifier.isSecret(key: encodedKey, value: ()) diff --git a/Sources/Configuration/Providers/JSON/JSONProvider.swift b/Sources/Configuration/Providers/JSON/JSONProvider.swift deleted file mode 100644 index 52de70e..0000000 --- a/Sources/Configuration/Providers/JSON/JSONProvider.swift +++ /dev/null @@ -1,252 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftConfiguration open source project -// -// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if JSONSupport - -public import SystemPackage - -// Needs full Foundation for JSONSerialization. -import Foundation - -/// A configuration provider that loads values from JSON files. -/// -/// This provider reads JSON files from the file system and makes their values -/// available as configuration. The JSON structure is flattened using dot notation, -/// allowing nested objects to be accessed with hierarchical keys. -/// -/// The provider loads the JSON file once during initialization and never reloads -/// it, making it a constant provider suitable for configuration that doesn't -/// change during application runtime. -/// -/// > Tip: Do you need to watch the JSON files on disk for changes, and reload them automatically? Check out ``ReloadingJSONProvider``. -/// -/// ## Package traits -/// -/// This provider is guarded by the `JSONSupport` package trait. -/// -/// ## Supported JSON types -/// -/// The provider supports these JSON value types: -/// - **Strings**: Mapped directly to string configuration values -/// - **Numbers**: Integers, doubles, and booleans -/// - **Arrays**: Homogeneous arrays of strings or numbers -/// - **Objects**: Nested objects are flattened using dot notation -/// - **null**: Ignored (no configuration value is created) -/// -/// ## Key flattening -/// -/// Nested JSON objects are flattened into dot-separated keys: -/// -/// ```json -/// { -/// "database": { -/// "host": "localhost", -/// "port": 5432 -/// } -/// } -/// ``` -/// -/// Becomes accessible as: -/// - `database.host` → `"localhost"` -/// - `database.port` → `5432` -/// -/// ## Secret handling -/// -/// The provider supports marking values as secret using a ``SecretsSpecifier``. -/// Secret values are automatically redacted in logs and debug output. -/// -/// ## Usage -/// -/// ```swift -/// // Load from a JSON file -/// let provider = try await JSONProvider(filePath: "/etc/config.json") -/// let config = ConfigReader(provider: provider) -/// -/// // Access nested values using dot notation -/// let host = config.string(forKey: "database.host") -/// let port = config.int(forKey: "database.port") -/// let isEnabled = config.bool(forKey: "features.enabled", default: false) -/// ``` -/// -/// ## Configuration context -/// -/// This provider ignores the context passed in ``AbsoluteConfigKey/context``. -/// All keys are resolved using only their component path. -@available(Configuration 1.0, *) -public struct JSONProvider: Sendable { - - /// A snapshot of the internal state. - private let _snapshot: JSONProviderSnapshot - - /// Creates a new JSON provider by loading the specified file. - /// - /// This initializer loads and parses the JSON file synchronously during - /// initialization. The file must contain a valid JSON object at the root level. - /// - /// ```swift - /// // Load configuration from a JSON file - /// let provider = try await JSONProvider( - /// filePath: "/etc/app-config.json", - /// secretsSpecifier: .keyBased { key in - /// key.contains("password") || key.contains("secret") - /// } - /// ) - /// ``` - /// - /// - Parameters: - /// - filePath: The file system path to the JSON configuration file. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - Throws: If the file cannot be read or parsed, or if the JSON structure is invalid. - public init( - filePath: FilePath, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - try await self.init( - filePath: filePath, - fileSystem: LocalCommonProviderFileSystem(), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } - - /// Creates a new JSON provider using a file path from configuration. - /// - /// This convenience initializer reads the JSON file path from another - /// configuration source, allowing the JSON provider to be configured - /// through configuration itself. - /// - /// ```swift - /// // Configure JSON provider through environment variables - /// let envProvider = EnvironmentVariablesProvider() - /// let config = ConfigReader(provider: envProvider) - /// - /// // JSON_FILE_PATH environment variable specifies the file - /// let jsonProvider = try await JSONProvider( - /// config: config.scoped(to: "json") - /// ) - /// ``` - /// - /// ## Required configuration keys - /// - /// - `filePath` (string): The file path to the JSON configuration file. - /// - /// - Parameters: - /// - config: The configuration reader containing the file path. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - Throws: If the file path is missing, or if the file cannot be read or parsed. - public init( - config: ConfigReader, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - try await self.init( - filePath: config.requiredString(forKey: "filePath", as: FilePath.self), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } - - /// Creates a new provider. - /// - Parameters: - /// - filePath: The path of the JSON file. - /// - fileSystem: The underlying file system. - /// - bytesDecoder: A decoder of bytes from a string. - /// - secretsSpecifier: A secrets specifier in case some of the values should be treated as secret. - /// - Throws: If the file cannot be read or parsed, or if the JSON structure is invalid. - internal init( - filePath: FilePath, - fileSystem: some CommonProviderFileSystem, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - self._snapshot = try await .init( - filePath: filePath, - fileSystem: fileSystem, - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } -} - -@available(Configuration 1.0, *) -extension JSONProvider: CustomStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var description: String { - "JSONProvider[\(_snapshot.values.count) values]" - } -} - -@available(Configuration 1.0, *) -extension JSONProvider: CustomDebugStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var debugDescription: String { - let prettyValues = _snapshot.values - .sorted { $0.key < $1.key } - .map { "\($0.key)=\($0.value)" } - .joined(separator: ", ") - return "JSONProvider[\(_snapshot.values.count) values: \(prettyValues)]" - } -} - -@available(Configuration 1.0, *) -extension JSONProvider: ConfigProvider { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var providerName: String { - _snapshot.providerName - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func value( - forKey key: AbsoluteConfigKey, - type: ConfigType - ) throws -> LookupResult { - try _snapshot.value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func fetchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType - ) async throws -> LookupResult { - try value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchSnapshot( - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await watchSnapshotFromSnapshot(updatesHandler: updatesHandler) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType, - updatesHandler: ( - ConfigUpdatesAsyncSequence, Never> - ) async throws -> Return - ) async throws -> Return { - try await watchValueFromValue(forKey: key, type: type, updatesHandler: updatesHandler) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func snapshot() -> any ConfigSnapshotProtocol { - _snapshot - } -} - -#endif diff --git a/Sources/Configuration/Providers/JSON/ReloadingJSONProvider.swift b/Sources/Configuration/Providers/JSON/ReloadingJSONProvider.swift deleted file mode 100644 index 44cc9d6..0000000 --- a/Sources/Configuration/Providers/JSON/ReloadingJSONProvider.swift +++ /dev/null @@ -1,276 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftConfiguration open source project -// -// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if JSONSupport && ReloadingSupport - -public import SystemPackage -public import ServiceLifecycle -public import Logging -public import Metrics -// Needs full Foundation for JSONSerialization. -import Foundation - -/// A configuration provider that loads values from a JSON file and automatically reloads them when the file changes. -/// -/// This provider reads a JSON file from the file system and makes its values -/// available as configuration. Unlike ``JSONProvider``, this provider continuously -/// monitors the file for changes and automatically reloads the configuration when -/// the file is modified. -/// -/// The provider must be run as part of a [`ServiceGroup`](https://swiftpackageindex.com/swift-server/swift-service-lifecycle/documentation/servicelifecycle/servicegroup) -/// for the periodic reloading to work. -/// -/// ## Package traits -/// -/// This provider is guarded by the `JSONSupport` and `ReloadingSupport` package traits. -/// -/// ## File monitoring -/// -/// The provider monitors the JSON file by checking its real path and modification timestamp at regular intervals -/// (default: 15 seconds). When a change is detected, the entire file is reloaded and parsed, and changed keys emit -/// a change event to active watchers. -/// -/// ## Watching for changes -/// -/// ```swift -/// let config = ConfigReader(provider: provider) -/// -/// // Watch for changes to specific values -/// try await config.watchString(forKey: "database.host") { updates in -/// for await host in updates { -/// print("Database host updated: \(host)") -/// } -/// } -/// ``` -/// -/// ## Similarities to JSONProvider -/// -/// Check out ``JSONProvider`` to learn more about using JSON for configuration. ``ReloadingJSONProvider`` is -/// a reloading variant of ``JSONProvider`` that otherwise follows the same behavior for handling secrets, -/// key and context mapping, and so on. -@available(Configuration 1.0, *) -public final class ReloadingJSONProvider: Sendable { - - /// The core implementation that handles all reloading logic. - private let core: ReloadingFileProviderCore - - /// Creates a new reloading JSON provider by loading the specified file. - /// - /// This initializer loads and parses the JSON file during initialization and - /// sets up the monitoring infrastructure. The file must contain a valid JSON - /// object at the root level. - /// - /// ```swift - /// // Load configuration from a JSON file with custom settings - /// let provider = try await ReloadingJSONProvider( - /// filePath: "/etc/app-config.json", - /// pollInterval: .seconds(5), - /// secretsSpecifier: .keyBased { key in - /// key.contains("password") || key.contains("secret") - /// } - /// ) - /// ``` - /// - /// - Parameters: - /// - filePath: The file system path to the JSON configuration file. - /// - pollInterval: The interval between file modification checks. Defaults to 15 seconds. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - logger: The logger. - /// - metrics: The metrics factory. - /// - Throws: If the file cannot be read or parsed, or if the JSON structure is invalid. - public convenience init( - filePath: FilePath, - pollInterval: Duration = .seconds(15), - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none, - logger: Logger = Logger(label: "ReloadingJSONProvider"), - metrics: any MetricsFactory = MetricsSystem.factory - ) async throws { - try await self.init( - filePath: filePath, - pollInterval: pollInterval, - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier, - fileSystem: LocalCommonProviderFileSystem(), - logger: logger, - metrics: metrics - ) - } - - /// Creates a new provider. - /// - Parameters: - /// - filePath: The path of the JSON file. - /// - pollInterval: The interval between file modification checks. - /// - bytesDecoder: A decoder of bytes from a string. - /// - secretsSpecifier: A secrets specifier in case some of the values should be treated as secret. - /// - fileSystem: The underlying file system. - /// - logger: The logger instance to use. - /// - metrics: The metrics factory to use. - /// - Throws: If the file cannot be read or parsed, or if the JSON structure is invalid. - internal init( - filePath: FilePath, - pollInterval: Duration, - bytesDecoder: some ConfigBytesFromStringDecoder, - secretsSpecifier: SecretsSpecifier, - fileSystem: some CommonProviderFileSystem, - logger: Logger, - metrics: any MetricsFactory - ) async throws { - self.core = try await ReloadingFileProviderCore( - filePath: filePath, - pollInterval: pollInterval, - providerName: "ReloadingJSONProvider", - fileSystem: fileSystem, - logger: logger, - metrics: metrics, - createSnapshot: { data in - // Parse JSON and create snapshot using existing logic - guard let parsedDictionary = try JSONSerialization.jsonObject(with: data) as? [String: any Sendable] - else { - throw JSONProviderSnapshot.JSONConfigError.topLevelJSONValueIsNotObject(filePath) - } - let values = try parseValues( - parsedDictionary, - keyEncoder: JSONProviderSnapshot.keyEncoder, - secretsSpecifier: secretsSpecifier - ) - return JSONProviderSnapshot( - values: values, - bytesDecoder: bytesDecoder - ) - } - ) - } - - /// Creates a new reloading JSON provider using a file path from configuration. - /// - /// This convenience initializer reads the JSON file path from another - /// configuration source, allowing the JSON provider to be configured - /// through configuration itself. - /// - /// ```swift - /// // Configure JSON provider through environment variables - /// let envProvider = EnvironmentVariablesProvider() - /// let config = ConfigReader(provider: envProvider) - /// - /// // JSON_FILE_PATH environment variable specifies the file - /// let jsonProvider = try await ReloadingJSONProvider( - /// config: config.scoped(to: "json"), - /// pollInterval: .seconds(10) - /// ) - /// ``` - /// - /// ## Required configuration keys - /// - /// - `filePath` (string): The file path to the JSON configuration file. - /// - `pollIntervalSeconds` (int, optional, default: `15`): The interval at which the provider checks the - /// file's last modified timestamp. - /// - /// - Parameters: - /// - config: The configuration reader containing the file path. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - logger: The logger. - /// - metrics: The metrics factory. - /// - Throws: If the file path is missing, or if the file cannot be read or parsed. - public convenience init( - config: ConfigReader, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none, - logger: Logger = Logger(label: "ReloadingJSONProvider"), - metrics: any MetricsFactory = MetricsSystem.factory - ) async throws { - try await self.init( - filePath: config.requiredString(forKey: "filePath", as: FilePath.self), - pollInterval: .seconds(config.int(forKey: "pollIntervalSeconds", default: 15)), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier, - fileSystem: LocalCommonProviderFileSystem(), - logger: logger, - metrics: metrics - ) - } -} - -@available(Configuration 1.0, *) -extension ReloadingJSONProvider: CustomStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var description: String { - let snapshot = core.snapshot() as! JSONProviderSnapshot - return "ReloadingJSONProvider[\(snapshot.values.count) values]" - } -} - -@available(Configuration 1.0, *) -extension ReloadingJSONProvider: CustomDebugStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var debugDescription: String { - let snapshot = core.snapshot() as! JSONProviderSnapshot - let prettyValues = snapshot.values - .sorted { $0.key < $1.key } - .map { "\($0.key)=\($0.value)" } - .joined(separator: ", ") - return "ReloadingJSONProvider[\(snapshot.values.count) values: \(prettyValues)]" - } -} - -@available(Configuration 1.0, *) -extension ReloadingJSONProvider: ConfigProvider { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var providerName: String { - core.providerName - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { - try core.value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func fetchValue(forKey key: AbsoluteConfigKey, type: ConfigType) async throws -> LookupResult { - try await core.fetchValue(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType, - updatesHandler: (ConfigUpdatesAsyncSequence, Never>) async throws -> Return - ) async throws -> Return { - try await core.watchValue(forKey: key, type: type, updatesHandler: updatesHandler) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func snapshot() -> any ConfigSnapshotProtocol { - core.snapshot() - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchSnapshot( - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await core.watchSnapshot(updatesHandler: updatesHandler) - } -} - -@available(Configuration 1.0, *) -extension ReloadingJSONProvider: Service { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func run() async throws { - try await core.run() - } -} - -#endif diff --git a/Sources/Configuration/Providers/Wrappers/KeyMappingProvider.swift b/Sources/Configuration/Providers/Wrappers/KeyMappingProvider.swift index 5357568..a149d3b 100644 --- a/Sources/Configuration/Providers/Wrappers/KeyMappingProvider.swift +++ b/Sources/Configuration/Providers/Wrappers/KeyMappingProvider.swift @@ -34,7 +34,7 @@ /// ```swift /// // Create providers /// let envProvider = EnvironmentVariablesProvider() -/// let jsonProvider = try await JSONProvider(filePath: "/etc/config.json") +/// let jsonProvider = try await FileProvider(filePath: "/etc/config.json") /// /// // Only remap the environment variables, not the JSON config /// let keyMappedEnvProvider = KeyMappingProvider(upstream: envProvider) { key in diff --git a/Sources/Configuration/Providers/YAML/ReloadingYAMLProvider.swift b/Sources/Configuration/Providers/YAML/ReloadingYAMLProvider.swift deleted file mode 100644 index e765eb1..0000000 --- a/Sources/Configuration/Providers/YAML/ReloadingYAMLProvider.swift +++ /dev/null @@ -1,285 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftConfiguration open source project -// -// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if YAMLSupport && ReloadingSupport - -public import SystemPackage -public import ServiceLifecycle -public import Logging -public import Metrics -import Yams -import Synchronization -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif - -/// A configuration provider that loads values from a YAML file and automatically reloads them when the file changes. -/// -/// This provider reads a YAML file from the file system and makes its values -/// available as configuration. Unlike ``YAMLProvider``, this provider continuously -/// monitors the file for changes and automatically reloads the configuration when -/// the file is modified. -/// -/// The provider must be run as part of a [`ServiceGroup`](https://swiftpackageindex.com/swift-server/swift-service-lifecycle/documentation/servicelifecycle/servicegroup) -/// for the periodic reloading to work. -/// -/// ## Package traits -/// -/// This provider is guarded by the `YAMLSupport` and `ReloadingSupport` package traits. -/// -/// ## File monitoring -/// -/// The provider monitors the YAML file by checking its real path and modification timestamp at regular intervals -/// (default: 15 seconds). When a change is detected, the entire file is reloaded and parsed, and changed keys emit -/// a change event to active watchers. -/// -/// ## Watching for changes -/// -/// ```swift -/// let config = ConfigReader(provider: provider) -/// -/// // Watch for changes to specific values -/// try await config.watchString(forKey: "database.host") { updates in -/// for await host in updates { -/// print("Database host updated: \(host)") -/// } -/// } -/// ``` -/// -/// ## Similarities to YAMLProvider -/// -/// Check out ``YAMLProvider`` to learn more about using YAML for configuration. ``ReloadingYAMLProvider`` is -/// a reloading variant of ``YAMLProvider`` that otherwise follows the same behavior for handling secrets, -/// key and context mapping, and so on. -@available(Configuration 1.0, *) -public final class ReloadingYAMLProvider: Sendable { - - /// The core implementation that handles all reloading logic. - private let core: ReloadingFileProviderCore - - /// Creates a new reloading YAML provider by loading the specified file. - /// - /// This initializer loads and parses the YAML file during initialization and - /// sets up the monitoring infrastructure. The file must contain a valid YAML - /// mapping at the root level. - /// - /// ```swift - /// // Load configuration from a YAML file with custom settings - /// let provider = try await ReloadingYAMLProvider( - /// filePath: "/etc/app-config.yaml", - /// pollInterval: .seconds(5), - /// secretsSpecifier: .keyBased { key in - /// key.contains("password") || key.contains("secret") - /// } - /// ) - /// ``` - /// - /// - Parameters: - /// - filePath: The file system path to the YAML configuration file. - /// - pollInterval: The interval between file modification checks. Defaults to 15 seconds. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - logger: The logger instance to use. - /// - metrics: The metrics factory to use. - /// - Throws: If the file cannot be read or parsed, or if the YAML structure is invalid. - public convenience init( - filePath: FilePath, - pollInterval: Duration = .seconds(15), - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none, - logger: Logger = Logger(label: "ReloadingYAMLProvider"), - metrics: any MetricsFactory = MetricsSystem.factory - ) async throws { - try await self.init( - filePath: filePath, - pollInterval: pollInterval, - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier, - fileSystem: LocalCommonProviderFileSystem(), - logger: logger, - metrics: metrics - ) - } - - /// Creates a new provider. - /// - Parameters: - /// - filePath: The path of the YAML file. - /// - pollInterval: The interval between file modification checks. - /// - bytesDecoder: A decoder of bytes from a string. - /// - secretsSpecifier: A secrets specifier in case some of the values should be treated as secret. - /// - fileSystem: The underlying file system. - /// - logger: The logger instance to use. - /// - metrics: The metrics factory to use. - /// - Throws: If the file cannot be read or parsed, or if the YAML structure is invalid. - internal init( - filePath: FilePath, - pollInterval: Duration, - bytesDecoder: some ConfigBytesFromStringDecoder, - secretsSpecifier: SecretsSpecifier, - fileSystem: some CommonProviderFileSystem, - logger: Logger, - metrics: any MetricsFactory - ) async throws { - self.core = try await ReloadingFileProviderCore( - filePath: filePath, - pollInterval: pollInterval, - providerName: "ReloadingYAMLProvider", - fileSystem: fileSystem, - logger: logger, - metrics: metrics, - createSnapshot: { data in - // Parse YAML and create snapshot using existing logic - let fileContents = String(decoding: data, as: UTF8.self) - guard let mapping = try Yams.Parser(yaml: fileContents).singleRoot()?.mapping else { - throw YAMLProviderSnapshot.YAMLConfigError.topLevelYAMLValueIsNotMapping(filePath) - } - let values = try parseValues( - mapping, - keyEncoder: SeparatorKeyEncoder.dotSeparated, - secretsSpecifier: secretsSpecifier - ) - return YAMLProviderSnapshot( - bytesDecoder: bytesDecoder, - values: values - ) - } - ) - } - - /// Creates a new reloading YAML provider using a file path from configuration. - /// - /// This convenience initializer reads the YAML file path from another - /// configuration source, allowing the YAML provider to be configured - /// through configuration itself. - /// - /// ```swift - /// // Configure YAML provider through environment variables - /// let envProvider = EnvironmentVariablesProvider() - /// let config = ConfigReader(provider: envProvider) - /// - /// // YAML_FILE_PATH environment variable specifies the file - /// let yamlProvider = try await ReloadingYAMLProvider( - /// config: config.scoped(to: "yaml") - /// ) - /// ``` - /// - /// ## Required configuration keys - /// - /// - `filePath` (string): The file path to the YAML configuration file. - /// - `pollIntervalSeconds` (int, optional, default: `15`): The interval at which the provider checks the - /// file's last modified timestamp. - /// - /// - Parameters: - /// - config: The configuration reader containing the file path. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - logger: The logger instance to use. - /// - metrics: The metrics factory to use. - /// - Throws: If the file path is missing, or if the file cannot be read or parsed. - public convenience init( - config: ConfigReader, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none, - logger: Logger = Logger(label: "ReloadingYAMLProvider"), - metrics: any MetricsFactory = MetricsSystem.factory - ) async throws { - try await self.init( - filePath: config.requiredString(forKey: "filePath", as: FilePath.self), - pollInterval: .seconds(config.int(forKey: "pollIntervalSeconds", default: 15)), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier, - fileSystem: LocalCommonProviderFileSystem(), - logger: logger, - metrics: metrics - ) - } -} - -@available(Configuration 1.0, *) -extension ReloadingYAMLProvider: CustomStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var description: String { - let snapshot = core.snapshot() as! YAMLProviderSnapshot - return snapshot.values.withLock { values in - "ReloadingYAMLProvider[\(values.count) values]" - } - } -} - -@available(Configuration 1.0, *) -extension ReloadingYAMLProvider: CustomDebugStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var debugDescription: String { - let snapshot = core.snapshot() as! YAMLProviderSnapshot - return snapshot.values.withLock { values in - let prettyValues = - values - .sorted { $0.key < $1.key } - .map { "\($0.key)=\($0.value)" } - .joined(separator: ", ") - return "ReloadingYAMLProvider[\(values.count) values: \(prettyValues)]" - } - } -} - -@available(Configuration 1.0, *) -extension ReloadingYAMLProvider: ConfigProvider { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var providerName: String { - core.providerName - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { - try core.value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func fetchValue(forKey key: AbsoluteConfigKey, type: ConfigType) async throws -> LookupResult { - try await core.fetchValue(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType, - updatesHandler: (ConfigUpdatesAsyncSequence, Never>) async throws -> Return - ) async throws -> Return { - try await core.watchValue(forKey: key, type: type, updatesHandler: updatesHandler) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func snapshot() -> any ConfigSnapshotProtocol { - core.snapshot() - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchSnapshot( - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await core.watchSnapshot(updatesHandler: updatesHandler) - } -} - -@available(Configuration 1.0, *) -extension ReloadingYAMLProvider: Service { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func run() async throws { - try await core.run() - } -} - -#endif diff --git a/Sources/Configuration/Providers/YAML/YAMLProvider.swift b/Sources/Configuration/Providers/YAML/YAMLProvider.swift deleted file mode 100644 index 99e56eb..0000000 --- a/Sources/Configuration/Providers/YAML/YAMLProvider.swift +++ /dev/null @@ -1,260 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftConfiguration open source project -// -// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if YAMLSupport - -public import SystemPackage -import Yams -import Synchronization -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif - -/// A configuration provider that loads values from YAML files. -/// -/// This provider reads YAML files from the file system and makes their values -/// available as configuration. The YAML structure is flattened using dot notation, -/// allowing nested mappings to be accessed with hierarchical keys. -/// -/// The provider loads the YAML file once during initialization and never reloads -/// it, making it a constant provider suitable for configuration that doesn't -/// change during application runtime. -/// -/// > Tip: Do you need to watch the YAML files on disk for changes, and reload them automatically? Check out ``ReloadingYAMLProvider``. -/// -/// ## Package traits -/// -/// This provider is guarded by the `YAMLSupport` package trait. -/// -/// ## Supported YAML types -/// -/// The provider supports these YAML value types: -/// - **Scalars**: Strings, integers, doubles, and booleans -/// - **Sequences**: Homogeneous arrays of scalars -/// - **Mappings**: Nested objects that are flattened using dot notation -/// - **null**: Ignored (no configuration value is created) -/// -/// ## Key flattening -/// -/// Nested YAML mappings are flattened into dot-separated keys: -/// -/// ```yaml -/// database: -/// host: localhost -/// port: 5432 -/// features: -/// enabled: true -/// ``` -/// -/// Becomes accessible as: -/// - `database.host` → `"localhost"` -/// - `database.port` → `5432` -/// - `features.enabled` → `true` -/// -/// ## Secret handling -/// -/// The provider supports marking values as secret using a ``SecretsSpecifier``. -/// Secret values are automatically redacted in logs and debug output. -/// -/// ## Usage -/// -/// ```swift -/// // Load from a YAML file -/// let provider = try await YAMLProvider(filePath: "/etc/config.yaml") -/// let config = ConfigReader(provider: provider) -/// -/// // Access nested values using dot notation -/// let host = config.string(forKey: "database.host") -/// let port = config.int(forKey: "database.port") -/// let isEnabled = config.bool(forKey: "features.enabled", default: false) -/// ``` -/// -/// ## Configuration context -/// -/// This provider ignores the context passed in ``AbsoluteConfigKey/context``. -/// All keys are resolved using only their component path. -@available(Configuration 1.0, *) -public struct YAMLProvider: Sendable { - - /// A snapshot of the internal state. - private let _snapshot: YAMLProviderSnapshot - - /// Creates a new YAML provider by loading the specified file. - /// - /// This initializer loads and parses the YAML file during initialization. - /// The file must contain a valid YAML mapping at the root level. - /// - /// ```swift - /// // Load configuration from a YAML file - /// let provider = try await YAMLProvider( - /// filePath: "/etc/app-config.yaml", - /// secretsSpecifier: .keyBased { key in - /// key.contains("password") || key.contains("secret") - /// } - /// ) - /// ``` - /// - /// - Parameters: - /// - filePath: The file system path to the YAML configuration file. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - Throws: If the file cannot be read or parsed, or if the YAML structure is invalid. - public init( - filePath: FilePath, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - try await self.init( - filePath: filePath, - fileSystem: LocalCommonProviderFileSystem(), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } - - /// Creates a new YAML provider using a file path from configuration. - /// - /// This convenience initializer reads the YAML file path from another - /// configuration source, allowing the YAML provider to be configured - /// through configuration itself. - /// - /// ```swift - /// // Configure YAML provider through environment variables - /// let envProvider = EnvironmentVariablesProvider() - /// let config = ConfigReader(provider: envProvider) - /// - /// // YAML_FILE_PATH environment variable specifies the file - /// let yamlProvider = try await YAMLProvider( - /// config: config.scoped(to: "yaml") - /// ) - /// ``` - /// - /// ## Required configuration keys - /// - /// - `filePath` (string): The file path to the YAML configuration file. - /// - /// - Parameters: - /// - config: The configuration reader containing the file path. - /// - bytesDecoder: The decoder used to convert string values to byte arrays. - /// - secretsSpecifier: Specifies which configuration values should be treated as secrets. - /// - Throws: If the file path is missing, or if the file cannot be read or parsed. - public init( - config: ConfigReader, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - try await self.init( - filePath: config.requiredString(forKey: "filePath", as: FilePath.self), - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } - - /// Creates a new provider. - /// - Parameters: - /// - filePath: The path of the YAML file. - /// - fileSystem: The underlying file system. - /// - bytesDecoder: A decoder of bytes from a string. - /// - secretsSpecifier: A secrets specifier in case some of the values should be treated as secret. - /// - Throws: If the file cannot be read or parsed, or if the YAML structure is invalid. - internal init( - filePath: FilePath, - fileSystem: some CommonProviderFileSystem, - bytesDecoder: some ConfigBytesFromStringDecoder = .base64, - secretsSpecifier: SecretsSpecifier = .none - ) async throws { - self._snapshot = try await .init( - filePath: filePath, - fileSystem: fileSystem, - bytesDecoder: bytesDecoder, - secretsSpecifier: secretsSpecifier - ) - } -} - -@available(Configuration 1.0, *) -extension YAMLProvider: CustomStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var description: String { - _snapshot.values.withLock { values in - "YAMLProvider[\(values.count) values]" - } - } -} - -@available(Configuration 1.0, *) -extension YAMLProvider: CustomDebugStringConvertible { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var debugDescription: String { - _snapshot.values.withLock { values in - let prettyValues = - values - .sorted { $0.key < $1.key } - .map { "\($0.key)=\($0.value)" } - .joined(separator: ", ") - return "YAMLProvider[\(values.count) values: \(prettyValues)]" - } - } -} - -@available(Configuration 1.0, *) -extension YAMLProvider: ConfigProvider { - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public var providerName: String { - _snapshot.providerName - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func value( - forKey key: AbsoluteConfigKey, - type: ConfigType - ) throws -> LookupResult { - try _snapshot.value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func fetchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType - ) async throws -> LookupResult { - try value(forKey: key, type: type) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType, - updatesHandler: ( - ConfigUpdatesAsyncSequence, Never> - ) async throws -> Return - ) async throws -> Return { - try await watchValueFromValue(forKey: key, type: type, updatesHandler: updatesHandler) - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func snapshot() -> any ConfigSnapshotProtocol { - _snapshot - } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public func watchSnapshot( - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await watchSnapshotFromSnapshot(updatesHandler: updatesHandler) - } -} - -#endif diff --git a/Sources/Configuration/SecretsSpecifier.swift b/Sources/Configuration/SecretsSpecifier.swift index 78c296d..738b020 100644 --- a/Sources/Configuration/SecretsSpecifier.swift +++ b/Sources/Configuration/SecretsSpecifier.swift @@ -49,7 +49,7 @@ /// Use this for complex logic that determines secrecy based on key patterns or values: /// /// ```swift -/// let provider = JSONProvider( +/// let provider = FileProvider( /// filePath: "/etc/config.json", /// secretsSpecifier: .dynamic { key, value in /// // Mark keys containing "password", diff --git a/Tests/ConfigurationTests/JSONProviderTests.swift b/Tests/ConfigurationTests/JSONFileProviderTests.swift similarity index 71% rename from Tests/ConfigurationTests/JSONProviderTests.swift rename to Tests/ConfigurationTests/JSONFileProviderTests.swift index 4f00e23..9a59458 100644 --- a/Tests/ConfigurationTests/JSONProviderTests.swift +++ b/Tests/ConfigurationTests/JSONFileProviderTests.swift @@ -22,33 +22,40 @@ import SystemPackage private let resourcesPath = FilePath(try! #require(Bundle.module.path(forResource: "Resources", ofType: nil))) let jsonConfigFile = resourcesPath.appending("/config.json") -struct JSONProviderTests { +struct JSONFileProviderTests { @available(Configuration 1.0, *) - var provider: JSONProvider { - get async throws { - try await JSONProvider(filePath: jsonConfigFile) + var provider: JSONSnapshot { + get throws { + try JSONSnapshot( + data: Data(contentsOf: URL(filePath: jsonConfigFile.string)), + providerName: "TestProvider", + parsingOptions: .default + ) } } @available(Configuration 1.0, *) @Test func printingDescription() async throws { let expectedDescription = #""" - JSONProvider[20 values] + TestProvider[20 values] """# - try await #expect(provider.description == expectedDescription) + try #expect(provider.description == expectedDescription) } @available(Configuration 1.0, *) @Test func printingDebugDescription() async throws { let expectedDebugDescription = #""" - JSONProvider[20 values: bool=1, booly.array=1,0, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=0, other.booly.array=0,1,1, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] + TestProvider[20 values: bool=1, booly.array=1,0, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=0, other.booly.array=0,1,1, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] """# - try await #expect(provider.debugDescription == expectedDebugDescription) + try #expect(provider.debugDescription == expectedDebugDescription) } @available(Configuration 1.0, *) @Test func compat() async throws { - try await ProviderCompatTest(provider: provider).runTest() + try await ProviderCompatTest( + provider: FileProvider(filePath: jsonConfigFile) + ) + .runTest() } } diff --git a/Tests/ConfigurationTests/ReloadingJSONProviderTests.swift b/Tests/ConfigurationTests/JSONReloadingFileProviderTests.swift similarity index 62% rename from Tests/ConfigurationTests/ReloadingJSONProviderTests.swift rename to Tests/ConfigurationTests/JSONReloadingFileProviderTests.swift index ef8b14c..04c3b31 100644 --- a/Tests/ConfigurationTests/ReloadingJSONProviderTests.swift +++ b/Tests/ConfigurationTests/JSONReloadingFileProviderTests.swift @@ -22,28 +22,28 @@ import ConfigurationTesting import Logging import SystemPackage -struct ReloadingJSONProviderTests { +struct JSONReloadingFileProviderTests { @available(Configuration 1.0, *) @Test func printingDescription() async throws { - let provider = try await ReloadingJSONProvider(filePath: jsonConfigFile) + let provider = try await ReloadingFileProvider(filePath: jsonConfigFile) let expectedDescription = #""" - ReloadingJSONProvider[20 values] + ReloadingFileProvider[20 values] """# #expect(provider.description == expectedDescription) } @available(Configuration 1.0, *) @Test func printingDebugDescription() async throws { - let provider = try await ReloadingJSONProvider(filePath: jsonConfigFile) + let provider = try await ReloadingFileProvider(filePath: jsonConfigFile) let expectedDebugDescription = #""" - ReloadingJSONProvider[20 values: bool=1, booly.array=1,0, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=0, other.booly.array=0,1,1, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] + ReloadingFileProvider[20 values: bool=1, booly.array=1,0, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=0, other.booly.array=0,1,1, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] """# #expect(provider.debugDescription == expectedDebugDescription) } @available(Configuration 1.0, *) @Test func compat() async throws { - let provider = try await ReloadingJSONProvider(filePath: jsonConfigFile) + let provider = try await ReloadingFileProvider(filePath: jsonConfigFile) try await ProviderCompatTest(provider: provider).runTest() } @@ -56,12 +56,12 @@ struct ReloadingJSONProviderTests { ]) let config = ConfigReader(provider: envProvider) - let reloadingProvider = try await ReloadingJSONProvider( + let reloadingProvider = try await ReloadingFileProvider( config: config.scoped(to: "json") ) - #expect(reloadingProvider.providerName == "ReloadingJSONProvider") - #expect(reloadingProvider.description.contains("ReloadingJSONProvider[20 values]")) + #expect(reloadingProvider.providerName == "ReloadingFileProvider") + #expect(reloadingProvider.description.contains("ReloadingFileProvider[20 values]")) } } diff --git a/Tests/ConfigurationTests/ReloadingFileProviderCoreTests.swift b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift similarity index 79% rename from Tests/ConfigurationTests/ReloadingFileProviderCoreTests.swift rename to Tests/ConfigurationTests/ReloadingFileProviderTests.swift index c693528..80b7658 100644 --- a/Tests/ConfigurationTests/ReloadingFileProviderCoreTests.swift +++ b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift @@ -26,21 +26,29 @@ import Synchronization import SystemPackage @available(Configuration 1.0, *) -private struct TestSnapshot: ConfigSnapshotProtocol { +private struct TestSnapshot: FileConfigSnapshotProtocol { + + struct Input: FileParsingOptionsProtocol { + static var `default`: TestSnapshot.Input { + .init() + } + } + var values: [String: ConfigValue] - var providerName: String { "TestProvider" } + var providerName: String func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { let encodedKey = SeparatorKeyEncoder.dotSeparated.encode(key) return LookupResult(encodedKey: encodedKey, value: values[encodedKey]) } - init(values: [String: ConfigValue]) { + init(values: [String: ConfigValue], providerName: String) { self.values = values + self.providerName = providerName } - init(contents: String) throws { + init(contents: String, providerName: String) throws { var values: [String: ConfigValue] = [:] // Simple key=value parser for testing @@ -52,7 +60,19 @@ private struct TestSnapshot: ConfigSnapshotProtocol { values[key] = .init(.string(value), isSecret: false) } } - self.init(values: values) + self.init(values: values, providerName: providerName) + } + + init(data: Data, providerName: String, parsingOptions: Input) throws { + try self.init(contents: String(decoding: data, as: UTF8.self), providerName: providerName) + } + + var description: String { + "TestSnapshot" + } + + var debugDescription: String { + description } } @@ -73,7 +93,7 @@ extension InMemoryFileSystem.FileInfo { @available(Configuration 1.0, *) private func withTestProvider( body: ( - ReloadingFileProviderCore, + ReloadingFileProvider, InMemoryFileSystem, FilePath, Date @@ -92,26 +112,23 @@ private func withTestProvider( ) ] ) - let core = try await ReloadingFileProviderCore( + let provider = try await ReloadingFileProvider( + parsingOptions: .default, filePath: filePath, pollInterval: .seconds(1), - providerName: "TestProvider", fileSystem: fileSystem, logger: .noop, - metrics: NOOPMetricsHandler.instance, - createSnapshot: { data in - try TestSnapshot(contents: String(decoding: data, as: UTF8.self)) - } + metrics: NOOPMetricsHandler.instance ) - return try await body(core, fileSystem, filePath, originalTimestamp) + return try await body(provider, fileSystem, filePath, originalTimestamp) } -struct CoreTests { +struct ReloadingFileProviderTests { @available(Configuration 1.0, *) @Test func testBasicManualReload() async throws { - try await withTestProvider { core, fileSystem, filePath, originalTimestamp in + try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in // Check initial values - let result1 = try core.value(forKey: ["key1"], type: .string) + let result1 = try provider.value(forKey: ["key1"], type: .string) #expect(try result1.value?.content.asString == "value1") // Update file content @@ -127,10 +144,10 @@ struct CoreTests { ) // Trigger reload - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Check updated value - let result2 = try core.value(forKey: ["key1"], type: .string) + let result2 = try provider.value(forKey: ["key1"], type: .string) #expect(try result2.value?.content.asString == "newValue1") } } @@ -150,20 +167,17 @@ struct CoreTests { ) ] ) - let core = try await ReloadingFileProviderCore( + let provider = try await ReloadingFileProvider( + parsingOptions: .default, filePath: filePath, pollInterval: .milliseconds(1), - providerName: "TestProvider", fileSystem: fileSystem, logger: .noop, - metrics: NOOPMetricsHandler.instance, - createSnapshot: { data in - try TestSnapshot(contents: String(decoding: data, as: UTF8.self)) - } + metrics: NOOPMetricsHandler.instance ) // Check initial values - let result1 = try core.value(forKey: ["key1"], type: .string) + let result1 = try provider.value(forKey: ["key1"], type: .string) #expect(try result1.value?.content.asString == "value1") // Update file content @@ -181,10 +195,10 @@ struct CoreTests { // Run the service and actively poll until we see the change try await withThrowingTaskGroup { group in group.addTask { - try await core.run() + try await provider.run() } for _ in 1..<1000 { - let result2 = try core.value(forKey: ["key1"], type: .string) + let result2 = try provider.value(forKey: ["key1"], type: .string) guard try result2.value?.content.asString == "newValue1" else { try await Task.sleep(for: .milliseconds(1)) continue @@ -199,7 +213,7 @@ struct CoreTests { @available(Configuration 1.0, *) @Test func testSymlink_targetPathChanged() async throws { - try await withTestProvider { core, fileSystem, filePath, originalTimestamp in + try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in let targetPath1 = FilePath("/test/config1.txt") let targetPath2 = FilePath("/test/config2.txt") @@ -230,10 +244,10 @@ struct CoreTests { timestamp: originalTimestamp, contents: .symlink(targetPath1) ) - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Check initial value (from target1) - let result1 = try core.value(forKey: ["key"], type: .string) + let result1 = try provider.value(forKey: ["key"], type: .string) #expect(try result1.value?.content.asString == "target1") // Change symlink to point to second target (with same timestamp) @@ -244,17 +258,17 @@ struct CoreTests { ) // Trigger reload - should detect the change even though timestamp is the same - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Check updated value (from target2) - let result2 = try core.value(forKey: ["key"], type: .string) + let result2 = try provider.value(forKey: ["key"], type: .string) #expect(try result2.value?.content.asString == "target2") } } @available(Configuration 1.0, *) @Test func testSymlink_timestampChanged() async throws { - try await withTestProvider { core, fileSystem, filePath, originalTimestamp in + try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in let targetPath = FilePath("/test/config1.txt") // Create two target files with the same timestamp @@ -275,10 +289,10 @@ struct CoreTests { timestamp: originalTimestamp, contents: .symlink(targetPath) ) - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Check initial value (from target1) - let result1 = try core.value(forKey: ["key"], type: .string) + let result1 = try provider.value(forKey: ["key"], type: .string) #expect(try result1.value?.content.asString == "target1") // Change symlink to point to second target (with same timestamp) @@ -293,23 +307,23 @@ struct CoreTests { ) // Trigger reload - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Check updated value (from target2) - let result2 = try core.value(forKey: ["key"], type: .string) + let result2 = try provider.value(forKey: ["key"], type: .string) #expect(try result2.value?.content.asString == "target2") } } @available(Configuration 1.0, *) @Test func testWatchValue() async throws { - try await withTestProvider { core, fileSystem, filePath, originalTimestamp in + try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in let firstValueConsumed = TestFuture(name: "First value consumed") let updateReceived = TestFuture(name: "Update") try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { - try await core.watchValue(forKey: ["key1"], type: .string) { updates in + try await provider.watchValue(forKey: ["key1"], type: .string) { updates in var iterator = updates.makeAsyncIterator() // First value (initial) @@ -338,7 +352,7 @@ struct CoreTests { ) // Trigger reload - try await core.reloadIfNeeded(logger: .noop) + try await provider.reloadIfNeeded(logger: .noop) // Wait for update let receivedValue = await updateReceived.value diff --git a/Tests/ConfigurationTests/YAMLProviderTests.swift b/Tests/ConfigurationTests/YAMLFileProviderTests.swift similarity index 72% rename from Tests/ConfigurationTests/YAMLProviderTests.swift rename to Tests/ConfigurationTests/YAMLFileProviderTests.swift index 6a0a818..f072daa 100644 --- a/Tests/ConfigurationTests/YAMLProviderTests.swift +++ b/Tests/ConfigurationTests/YAMLFileProviderTests.swift @@ -24,34 +24,41 @@ import SystemPackage private let resourcesPath = FilePath(try! #require(Bundle.module.path(forResource: "Resources", ofType: nil))) let yamlConfigFile = resourcesPath.appending("/config.yaml") -struct YAMLProviderTests { +struct YAMLFileProviderTests { @available(Configuration 1.0, *) - var provider: YAMLProvider { - get async throws { - try await YAMLProvider(filePath: yamlConfigFile) + var provider: YAMLSnapshot { + get throws { + try YAMLSnapshot( + data: Data(contentsOf: URL(filePath: yamlConfigFile.string)), + providerName: "TestProvider", + parsingOptions: .default + ) } } @available(Configuration 1.0, *) @Test func printingDescription() async throws { let expectedDescription = #""" - YAMLProvider[20 values] + TestProvider[20 values] """# - try await #expect(provider.description == expectedDescription) + try #expect(provider.description == expectedDescription) } @available(Configuration 1.0, *) @Test func printingDebugDescription() async throws { let expectedDebugDescription = #""" - YAMLProvider[20 values: bool=true, booly.array=true,false, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=false, other.booly.array=false,true,true, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] + TestProvider[20 values: bool=true, booly.array=true,false, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=false, other.booly.array=false,true,true, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] """# - try await #expect(provider.debugDescription == expectedDebugDescription) + try #expect(provider.debugDescription == expectedDebugDescription) } @available(Configuration 1.0, *) @Test func compat() async throws { - try await ProviderCompatTest(provider: provider).runTest() + try await ProviderCompatTest( + provider: FileProvider(filePath: yamlConfigFile) + ) + .runTest() } } diff --git a/Tests/ConfigurationTests/ReloadingYAMLProviderTests.swift b/Tests/ConfigurationTests/YAMLReloadingFileProviderTests.swift similarity index 62% rename from Tests/ConfigurationTests/ReloadingYAMLProviderTests.swift rename to Tests/ConfigurationTests/YAMLReloadingFileProviderTests.swift index deb7edc..06c07c0 100644 --- a/Tests/ConfigurationTests/ReloadingYAMLProviderTests.swift +++ b/Tests/ConfigurationTests/YAMLReloadingFileProviderTests.swift @@ -22,28 +22,28 @@ import ConfigurationTesting import Logging import SystemPackage -struct ReloadingYAMLProviderTests { +struct YAMLReloadingFileProviderTests { @available(Configuration 1.0, *) @Test func printingDescription() async throws { - let provider = try await ReloadingYAMLProvider(filePath: yamlConfigFile) + let provider = try await ReloadingFileProvider(filePath: yamlConfigFile) let expectedDescription = #""" - ReloadingYAMLProvider[20 values] + ReloadingFileProvider[20 values] """# #expect(provider.description == expectedDescription) } @available(Configuration 1.0, *) @Test func printingDebugDescription() async throws { - let provider = try await ReloadingYAMLProvider(filePath: yamlConfigFile) + let provider = try await ReloadingFileProvider(filePath: yamlConfigFile) let expectedDebugDescription = #""" - ReloadingYAMLProvider[20 values: bool=true, booly.array=true,false, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=false, other.booly.array=false,true,true, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] + ReloadingFileProvider[20 values: bool=true, booly.array=true,false, byteChunky.array=bWFnaWM=,bWFnaWMy, bytes=bWFnaWM=, double=3.14, doubly.array=3.14,2.72, int=42, inty.array=42,24, other.bool=false, other.booly.array=false,true,true, other.byteChunky.array=bWFnaWM=,bWFnaWMy,bWFnaWM=, other.bytes=bWFnaWMy, other.double=2.72, other.doubly.array=0.9,1.8, other.int=24, other.inty.array=16,32, other.string=Other Hello, other.stringy.array=Hello,Swift, string=Hello, stringy.array=Hello,World] """# #expect(provider.debugDescription == expectedDebugDescription) } @available(Configuration 1.0, *) @Test func compat() async throws { - let provider = try await ReloadingYAMLProvider(filePath: yamlConfigFile) + let provider = try await ReloadingFileProvider(filePath: yamlConfigFile) try await ProviderCompatTest(provider: provider).runTest() } @@ -56,12 +56,12 @@ struct ReloadingYAMLProviderTests { ]) let config = ConfigReader(provider: envProvider) - let reloadingProvider = try await ReloadingYAMLProvider( + let reloadingProvider = try await ReloadingFileProvider( config: config.scoped(to: "yaml") ) - #expect(reloadingProvider.providerName == "ReloadingYAMLProvider") - #expect(reloadingProvider.description.contains("ReloadingYAMLProvider[20 values]")) + #expect(reloadingProvider.providerName == "ReloadingFileProvider") + #expect(reloadingProvider.description.contains("ReloadingFileProvider[20 values]")) } } From 0b7b81c56729105973fbf16e504e39f81c1864cd Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 23 Oct 2025 14:24:41 +0200 Subject: [PATCH 02/19] Remove the word 'read' from the docs' --- Sources/Configuration/Providers/Files/FileProvider.swift | 2 +- .../Configuration/Providers/Files/ReloadingFileProvider.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Configuration/Providers/Files/FileProvider.swift b/Sources/Configuration/Providers/Files/FileProvider.swift index a4c3665..960f66a 100644 --- a/Sources/Configuration/Providers/Files/FileProvider.swift +++ b/Sources/Configuration/Providers/Files/FileProvider.swift @@ -56,7 +56,7 @@ import Foundation /// ``` /// /// This expects a `filePath` key in the configuration that specifies the path to the file. -/// For a full list of read configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. +/// For a full list of configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. @available(Configuration 1.0, *) public struct FileProvider: Sendable { diff --git a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift index e540c2f..0b3a99b 100644 --- a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift +++ b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift @@ -75,7 +75,7 @@ import Synchronization /// ``` /// /// This expects a `filePath` key in the configuration that specifies the path to the file. -/// For a full list of read configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. +/// For a full list of configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. /// /// ## File monitoring /// From 3df5506cc87e43efbeabfd20fb4162a3e07bd48f Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Fri, 24 Oct 2025 15:28:51 +0200 Subject: [PATCH 03/19] Swift 6.2 as minimum toolchain version --- .spi.yml | 2 +- .../hello-world-cli-example/Package.swift | 2 +- Package.swift | 16 +- Sources/Configuration/MultiProvider.swift | 148 +++-- .../AsyncCombineLatestManySequence.swift | 103 +++ .../CombineLatestManyStateMachine.swift | 612 ++++++++++++++++++ .../AsyncAlgos/CombineLatestManyStorage.swift | 267 ++++++++ .../Utilities/AsyncSequences.swift | 21 +- .../Utilities/combineLatestOneOrMore.swift | 130 ---- .../AsyncSequence+first.swift | 9 +- Tests/LinkageTest/Package.swift | 2 +- 11 files changed, 1115 insertions(+), 197 deletions(-) create mode 100644 Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift create mode 100644 Sources/Configuration/Utilities/AsyncAlgos/CombineLatestManyStateMachine.swift create mode 100644 Sources/Configuration/Utilities/AsyncAlgos/CombineLatestManyStorage.swift delete mode 100644 Sources/Configuration/Utilities/combineLatestOneOrMore.swift diff --git a/.spi.yml b/.spi.yml index d834ba2..9c692fd 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,7 +1,7 @@ version: 1 builder: configs: - - swift_version: '6.1' + - swift_version: '6.2' documentation_targets: - Configuration - ConfigurationTesting diff --git a/Examples/hello-world-cli-example/Package.swift b/Examples/hello-world-cli-example/Package.swift index 5d94efe..bc00de7 100644 --- a/Examples/hello-world-cli-example/Package.swift +++ b/Examples/hello-world-cli-example/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 import PackageDescription diff --git a/Package.swift b/Package.swift index 71e126b..9e8ecd3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 import PackageDescription #if canImport(FoundationEssentials) @@ -61,6 +61,7 @@ let enableAllTraitsExplicit = ProcessInfo.processInfo.environment["ENABLE_ALL_TR let enableAllTraits = spiGenerateDocs || previewDocs || enableAllTraitsExplicit let addDoccPlugin = previewDocs || spiGenerateDocs +let enableAllCIFlags = enableAllTraitsExplicit traits.insert( .default( @@ -77,6 +78,7 @@ let package = Package( traits: traits, dependencies: [ .package(url: "https://github.com/apple/swift-system", from: "1.5.0"), + .package(url: "https://github.com/apple/swift-collections", from: "1.3.0"), .package(url: "https://github.com/swift-server/swift-service-lifecycle", from: "2.7.0"), .package(url: "https://github.com/apple/swift-log", from: "1.6.3"), .package(url: "https://github.com/apple/swift-metrics", from: "2.7.0"), @@ -92,6 +94,10 @@ let package = Package( name: "SystemPackage", package: "swift-system" ), + .product( + name: "DequeModule", + package: "swift-collections" + ), .product( name: "Logging", package: "swift-log", @@ -179,8 +185,16 @@ for target in package.targets { // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0409-access-level-on-imports.md settings.append(.enableUpcomingFeature("InternalImportsByDefault")) + // https://docs.swift.org/compiler/documentation/diagnostics/nonisolated-nonsending-by-default/ + settings.append(.enableUpcomingFeature("NonisolatedNonsendingByDefault")) + settings.append(.enableExperimentalFeature("AvailabilityMacro=Configuration 1.0:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0")) + if enableAllCIFlags { + // Ensure all public types are explicitly annotated as Sendable or not Sendable. + settings.append(.unsafeFlags(["-Xfrontend", "-require-explicit-sendable"])) + } + target.swiftSettings = settings } diff --git a/Sources/Configuration/MultiProvider.swift b/Sources/Configuration/MultiProvider.swift index 4770473..84ca0cd 100644 --- a/Sources/Configuration/MultiProvider.swift +++ b/Sources/Configuration/MultiProvider.swift @@ -199,24 +199,27 @@ extension MultiProvider { _ body: (ConfigUpdatesAsyncSequence) async throws -> Return ) async throws -> Return { let providers = storage.providers - let sources: - [@Sendable ( - (ConfigUpdatesAsyncSequence) async throws -> Void - ) async throws -> Void] = providers.map { $0.watchSnapshot } - return try await combineLatestOneOrMore( - elementType: (any ConfigSnapshotProtocol).self, - sources: sources, - updatesHandler: { updateArrays in - try await body( - ConfigUpdatesAsyncSequence( - updateArrays - .map { array in - MultiSnapshot(snapshots: array) - } - ) + typealias UpdatesSequence = any (AsyncSequence & Sendable) + var updateSequences: [UpdatesSequence] = [] + updateSequences.reserveCapacity(providers.count) + return try await withProvidersWatchingSnapshot( + providers: ArraySlice(providers), + updateSequences: &updateSequences, + ) { providerUpdateSequences in + let updateArrays = combineLatestMany( + elementType: (any ConfigSnapshotProtocol).self, + failureType: Never.self, + providerUpdateSequences + ) + return try await body( + ConfigUpdatesAsyncSequence( + updateArrays + .map { array in + MultiSnapshot(snapshots: array) + } ) - } - ) + ) + } } /// Asynchronously resolves a configuration value from nested providers. @@ -290,43 +293,86 @@ extension MultiProvider { ) async throws -> Return { let providers = storage.providers let providerNames = providers.map(\.providerName) - let sources: - [@Sendable ( - ( - ConfigUpdatesAsyncSequence, Never> - ) async throws -> Void - ) async throws -> Void] = providers.map { provider in - { handler in - _ = try await provider.watchValue(forKey: key, type: type, updatesHandler: handler) - } - } - return try await combineLatestOneOrMore( - elementType: Result.self, - sources: sources, - updatesHandler: { updateArrays in - try await updatesHandler( - ConfigUpdatesAsyncSequence( - updateArrays - .map { array in - var results: [AccessEvent.ProviderResult] = [] - for (providerIndex, lookupResult) in array.enumerated() { - let providerName = providerNames[providerIndex] - results.append(.init(providerName: providerName, result: lookupResult)) - switch lookupResult { - case .success(let value) where value.value == nil: - // Got a success + nil from a nested provider, keep iterating. - continue - default: - // Got a success + non-nil or an error from a nested provider, propagate that up. - return (results, lookupResult.map { $0.value }) - } + typealias UpdatesSequence = any (AsyncSequence, Never> & Sendable) + var updateSequences: [UpdatesSequence] = [] + updateSequences.reserveCapacity(providers.count) + return try await withProvidersWatchingValue( + providers: ArraySlice(providers), + updateSequences: &updateSequences, + key: key, + configType: type, + ) { providerUpdateSequences in + let updateArrays = combineLatestMany( + elementType: Result.self, + failureType: Never.self, + providerUpdateSequences + ) + return try await updatesHandler( + ConfigUpdatesAsyncSequence( + updateArrays + .map { array in + var results: [AccessEvent.ProviderResult] = [] + for (providerIndex, lookupResult) in array.enumerated() { + let providerName = providerNames[providerIndex] + results.append(.init(providerName: providerName, result: lookupResult)) + switch lookupResult { + case .success(let value) where value.value == nil: + // Got a success + nil from a nested provider, keep iterating. + continue + default: + // Got a success + non-nil or an error from a nested provider, propagate that up. + return (results, lookupResult.map { $0.value }) } - // If all nested results were success + nil, return the same. - return (results, .success(nil)) } - ) + // If all nested results were success + nil, return the same. + return (results, .success(nil)) + } ) - } + ) + } + } +} + +@available(Configuration 1.0, *) +nonisolated(nonsending) private func withProvidersWatchingValue( + providers: ArraySlice, + updateSequences: inout [any (AsyncSequence, Never> & Sendable)], + key: AbsoluteConfigKey, + configType: ConfigType, + body: ([any (AsyncSequence, Never> & Sendable)]) async throws -> ReturnInner +) async throws -> ReturnInner { + guard let provider = providers.first else { + // Recursion termination, once we've collected all update sequences, execute the body. + return try await body(updateSequences) + } + return try await provider.watchValue(forKey: key, type: configType) { updates in + updateSequences.append(updates) + return try await withProvidersWatchingValue( + providers: providers.dropFirst(), + updateSequences: &updateSequences, + key: key, + configType: configType, + body: body + ) + } +} + +@available(Configuration 1.0, *) +nonisolated(nonsending) private func withProvidersWatchingSnapshot( + providers: ArraySlice, + updateSequences: inout [any (AsyncSequence & Sendable)], + body: ([any (AsyncSequence & Sendable)]) async throws -> ReturnInner +) async throws -> ReturnInner { + guard let provider = providers.first else { + // Recursion termination, once we've collected all update sequences, execute the body. + return try await body(updateSequences) + } + return try await provider.watchSnapshot { updates in + updateSequences.append(updates) + return try await withProvidersWatchingSnapshot( + providers: providers.dropFirst(), + updateSequences: &updateSequences, + body: body ) } } diff --git a/Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift b/Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift new file mode 100644 index 0000000..4743b7b --- /dev/null +++ b/Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Async Algorithms open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +// Vendored copy of https://github.com/apple/swift-async-algorithms/pull/360 + +/// Creates an asynchronous sequence that combines the latest values from many `AsyncSequence` types +/// by emitting a tuple of the values. ``combineLatestMany(_:)`` only emits a value whenever any of the base `AsyncSequence`s +/// emit a value (so long as each of the bases have emitted at least one value). +/// +/// Finishes: +/// ``combineLatestMany(_:)`` finishes when one of the bases finishes before emitting any value or +/// when all bases finished. +/// +/// Throws: +/// ``combineLatestMany(_:)`` throws when one of the bases throws. If one of the bases threw any buffered and not yet consumed +/// values will be dropped. +@available(Configuration 1.0, *) +internal func combineLatestMany( + elementType: Element.Type = Element.self, + failureType: Failure.Type = Failure.self, + _ bases: [any (AsyncSequence & Sendable)] +) -> some AsyncSequence<[Element], Failure> & Sendable { + AsyncCombineLatestManySequence(bases) +} + +/// An `AsyncSequence` that combines the latest values produced from many asynchronous sequences into an asynchronous sequence of tuples. +@available(Configuration 1.0, *) +internal struct AsyncCombineLatestManySequence: AsyncSequence, Sendable { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public typealias AsyncIterator = Iterator + + typealias Base = AsyncSequence & Sendable + let bases: [any Base] + + init(_ bases: [any Base]) { + self.bases = bases + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func makeAsyncIterator() -> AsyncIterator { + Iterator( + storage: .init(self.bases) + ) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public struct Iterator: AsyncIteratorProtocol { + final class InternalClass { + private let storage: CombineLatestManyStorage + + fileprivate init(storage: CombineLatestManyStorage) { + self.storage = storage + } + + deinit { + self.storage.iteratorDeinitialized() + } + + func next() async throws(Failure) -> [Element]? { + guard let element = try await self.storage.next() else { + return nil + } + + // This force unwrap is safe since there must be a third element. + return element + } + } + + let internalClass: InternalClass + + fileprivate init(storage: CombineLatestManyStorage) { + self.internalClass = InternalClass(storage: storage) + } + + public mutating func next() async throws(Failure) -> [Element]? { + try await self.internalClass.next() + } + } +} + +@available(*, unavailable) +extension AsyncCombineLatestManySequence.Iterator: Sendable {} diff --git a/Sources/Configuration/Utilities/AsyncAlgos/CombineLatestManyStateMachine.swift b/Sources/Configuration/Utilities/AsyncAlgos/CombineLatestManyStateMachine.swift new file mode 100644 index 0000000..ec84acd --- /dev/null +++ b/Sources/Configuration/Utilities/AsyncAlgos/CombineLatestManyStateMachine.swift @@ -0,0 +1,612 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Async Algorithms open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +// Vendored copy of https://github.com/apple/swift-async-algorithms/pull/360 + +import DequeModule + +/// State machine for combine latest. +@available(Configuration 1.0, *) +struct CombineLatestManyStateMachine: Sendable { + typealias DownstreamContinuation = UnsafeContinuation< + Result<[Element]?, Failure>, Never + > + typealias Base = AsyncSequence & Sendable + + private enum State: Sendable { + /// Small wrapper for the state of an upstream sequence. + struct Upstream: Sendable { + /// The upstream continuation. + var continuation: UnsafeContinuation? + /// The produced upstream element. + var element: Element? + /// Indicates wether the upstream finished/threw already. + var isFinished: Bool + } + + /// The initial state before a call to `next` happened. + case initial([any Base]) + + /// The state while we are waiting for downstream demand. + case waitingForDemand( + task: Task, + upstreams: ([Upstream]), + buffer: Deque<[Element]> + ) + + /// The state while we are consuming the upstream and waiting until we get a result from all upstreams. + case combining( + task: Task, + upstreams: ([Upstream]), + downstreamContinuation: DownstreamContinuation, + buffer: Deque<[Element]> + ) + + case upstreamsFinished( + buffer: Deque<[Element]> + ) + + case upstreamThrew( + error: Failure + ) + + /// The state once the downstream consumer stopped, i.e. by dropping all references + /// or by getting their `Task` cancelled. + case finished + + /// Internal state to avoid CoW. + case modifying + } + + private var state: State + + private let numberOfUpstreamSequences: Int + + /// Initializes a new `StateMachine`. + init(bases: [any Base]) { + self.state = .initial(bases) + self.numberOfUpstreamSequences = bases.count + } + + /// Actions returned by `iteratorDeinitialized()`. + enum IteratorDeinitializedAction { + /// Indicates that the `Task` needs to be cancelled and + /// the upstream continuations need to be resumed with a `CancellationFailure`. + case cancelTaskAndUpstreamContinuations( + task: Task, + upstreamContinuations: [UnsafeContinuation] + ) + } + + mutating func iteratorDeinitialized() -> IteratorDeinitializedAction? { + switch self.state { + case .initial: + // Nothing to do here. No demand was signalled until now + return .none + + case .combining: + // An iterator was deinitialized while we have a suspended continuation. + preconditionFailure( + "Internal inconsistency current state \(self.state) and received iteratorDeinitialized()" + ) + + case .waitingForDemand(let task, let upstreams, _): + // The iterator was dropped which signals that the consumer is finished. + // We can transition to finished now and need to clean everything up. + self.state = .finished + + return .cancelTaskAndUpstreamContinuations( + task: task, + upstreamContinuations: upstreams.map { $0.continuation } + .compactMap { $0 } + ) + + case .upstreamThrew, .upstreamsFinished: + // The iterator was dropped so we can transition to finished now. + self.state = .finished + + return .none + + case .finished: + // We are already finished so there is nothing left to clean up. + // This is just the references dropping afterwards. + return .none + + case .modifying: + preconditionFailure("Invalid state") + } + } + + mutating func taskIsStarted( + task: Task, + downstreamContinuation: DownstreamContinuation + ) { + switch self.state { + case .initial: + // The user called `next` and we are starting the `Task` + // to consume the upstream sequences + self.state = .combining( + task: task, + upstreams: Array(repeating: .init(isFinished: false), count: self.numberOfUpstreamSequences), + downstreamContinuation: downstreamContinuation, + buffer: .init() + ) + + case .combining, .waitingForDemand, .upstreamThrew, .upstreamsFinished, .finished: + // We only allow a single task to be created so this must never happen. + preconditionFailure("Internal inconsistency current state \(self.state) and received taskStarted()") + + case .modifying: + preconditionFailure("Invalid state") + } + } + + /// Actions returned by `childTaskSuspended()`. + enum ChildTaskSuspendedAction { + /// Indicates that the continuation should be resumed which will lead to calling `next` on the upstream. + case resumeContinuation( + upstreamContinuation: UnsafeContinuation + ) + } + + mutating func childTaskSuspended( + baseIndex: Int, + continuation: UnsafeContinuation + ) -> ChildTaskSuspendedAction? { + switch self.state { + case .initial: + // Child tasks are only created after we transitioned to `zipping` + preconditionFailure("Internal inconsistency current state \(self.state) and received childTaskSuspended()") + + case .upstreamsFinished: + preconditionFailure("Internal inconsistency current state \(self.state) and received upstreamThrew()") + + case .waitingForDemand(let task, var upstreams, let buffer): + self.state = .modifying + upstreams[baseIndex].continuation = continuation + + self.state = .waitingForDemand( + task: task, + upstreams: upstreams, + buffer: buffer + ) + + return .none + + case .combining: + // We are currently combining and need to resume any upstream until we transition to waitingForDemand + + return .resumeContinuation(upstreamContinuation: continuation) + + case .upstreamThrew, .finished: + // Since cancellation is cooperative it might be that child tasks are still getting + // suspended even though we already cancelled them. We must tolerate this and just resume + // the continuation with an error. + return .resumeContinuation( + upstreamContinuation: continuation + ) + + case .modifying: + preconditionFailure("Invalid state") + } + } + + /// Actions returned by `elementProduced()`. + enum ElementProducedAction { + /// Indicates that the downstream continuation should be resumed with the element. + case resumeContinuation( + downstreamContinuation: DownstreamContinuation, + result: Result<[Element]?, Failure> + ) + } + + mutating func elementProduced(value: Element, atBaseIndex baseIndex: Int) -> ElementProducedAction? { + switch self.state { + case .initial: + // Child tasks that are producing elements are only created after we transitioned to `zipping` + preconditionFailure("Internal inconsistency current state \(self.state) and received elementProduced()") + + case .upstreamsFinished: + preconditionFailure("Internal inconsistency current state \(self.state) and received upstreamThrew()") + + case .waitingForDemand(let task, var upstreams, var buffer): + // We got an element in late. This can happen since we race the upstreams. + // We have to store the new tuple in our buffer and remember the upstream states. + + var upstreamValues = upstreams.compactMap { $0.element } + guard upstreamValues.count == self.numberOfUpstreamSequences else { + preconditionFailure("Internal inconsistency current state \(self.state) and received elementProduced()") + } + + self.state = .modifying + + upstreamValues[baseIndex] = value + buffer.append(upstreamValues) + upstreams[baseIndex].element = value + + self.state = .waitingForDemand( + task: task, + upstreams: upstreams, + buffer: buffer + ) + + return .none + + case .combining(let task, var upstreams, let downstreamContinuation, let buffer): + precondition( + buffer.isEmpty, + "Internal inconsistency current state \(self.state) and the buffer is not empty" + ) + self.state = .modifying + upstreams[baseIndex].element = value + + let nonNilElements = upstreams.compactMap(\.element) + if nonNilElements.count == self.numberOfUpstreamSequences { + // We got an element from each upstream so we can resume the downstream now + self.state = .waitingForDemand( + task: task, + upstreams: upstreams, + buffer: buffer + ) + + return .resumeContinuation( + downstreamContinuation: downstreamContinuation, + result: .success(nonNilElements) + ) + } else { + // We are still waiting for one of the upstreams to produce an element + self.state = .combining( + task: task, + upstreams: upstreams, + downstreamContinuation: downstreamContinuation, + buffer: buffer + ) + + return .none + } + + case .upstreamThrew, .finished: + // Since cancellation is cooperative it might be that child tasks + // are still producing elements after we finished. + // We are just going to drop them since there is nothing we can do + return .none + + case .modifying: + preconditionFailure("Invalid state") + } + } + + /// Actions returned by `upstreamFinished()`. + enum UpstreamFinishedAction { + /// Indicates the task and the upstream continuations should be cancelled. + case cancelTaskAndUpstreamContinuations( + task: Task, + upstreamContinuations: [UnsafeContinuation] + ) + /// Indicates that the downstream continuation should be resumed with `nil` and + /// the task and the upstream continuations should be cancelled. + case resumeContinuationWithNilAndCancelTaskAndUpstreamContinuations( + downstreamContinuation: DownstreamContinuation, + task: Task, + upstreamContinuations: [UnsafeContinuation] + ) + } + + mutating func upstreamFinished(baseIndex: Int) -> UpstreamFinishedAction? { + switch self.state { + case .initial: + preconditionFailure("Internal inconsistency current state \(self.state) and received upstreamFinished()") + + case .upstreamsFinished: + preconditionFailure("Internal inconsistency current state \(self.state) and received upstreamFinished()") + + case .waitingForDemand(let task, var upstreams, let buffer): + // One of the upstreams finished. + + self.state = .modifying + upstreams[0].isFinished = true + + if upstreams.allSatisfy(\.isFinished) { + // All upstreams finished we can transition to either finished or upstreamsFinished now + if buffer.isEmpty { + self.state = .finished + } else { + self.state = .upstreamsFinished(buffer: buffer) + } + + return .cancelTaskAndUpstreamContinuations( + task: task, + upstreamContinuations: upstreams.map(\.continuation).compactMap { $0 } + ) + } else { + self.state = .waitingForDemand( + task: task, + upstreams: upstreams, + buffer: buffer + ) + return .none + } + + case .combining(let task, var upstreams, let downstreamContinuation, let buffer): + // One of the upstreams finished. + + self.state = .modifying + + // We need to track if an empty upstream finished. + // If that happens we can transition to finish right away. + let emptyUpstreamFinished = upstreams[baseIndex].element == nil + upstreams[baseIndex].isFinished = true + + // Implementing this for the two arities without variadic generics is a bit awkward sadly. + if emptyUpstreamFinished { + // All upstreams finished + self.state = .finished + + return .resumeContinuationWithNilAndCancelTaskAndUpstreamContinuations( + downstreamContinuation: downstreamContinuation, + task: task, + upstreamContinuations: upstreams.map(\.continuation).compactMap { $0 } + ) + + } else if upstreams.allSatisfy(\.isFinished) { + // All upstreams finished + self.state = .finished + + return .resumeContinuationWithNilAndCancelTaskAndUpstreamContinuations( + downstreamContinuation: downstreamContinuation, + task: task, + upstreamContinuations: upstreams.map(\.continuation).compactMap { $0 } + ) + + } else { + self.state = .combining( + task: task, + upstreams: upstreams, + downstreamContinuation: downstreamContinuation, + buffer: buffer + ) + return .none + } + + case .upstreamThrew, .finished: + // This is just everything finishing up, nothing to do here + return .none + + case .modifying: + preconditionFailure("Invalid state") + } + } + + /// Actions returned by `upstreamThrew()`. + enum UpstreamThrewAction { + /// Indicates the task and the upstream continuations should be cancelled. + case cancelTaskAndUpstreamContinuations( + task: Task, + upstreamContinuations: [UnsafeContinuation] + ) + /// Indicates that the downstream continuation should be resumed with the `error` and + /// the task and the upstream continuations should be cancelled. + case resumeContinuationWithFailureAndCancelTaskAndUpstreamContinuations( + downstreamContinuation: DownstreamContinuation, + error: Failure, + task: Task, + upstreamContinuations: [UnsafeContinuation] + ) + } + + mutating func upstreamThrew(_ error: Failure) -> UpstreamThrewAction? { + switch self.state { + case .initial: + preconditionFailure("Internal inconsistency current state \(self.state) and received upstreamThrew()") + + case .upstreamsFinished: + // We need to tolerate multiple upstreams failing + return .none + + case .waitingForDemand(let task, let upstreams, _): + // An upstream threw. We can cancel everything now and transition to finished. + // We just need to store the error for the next downstream demand + self.state = .upstreamThrew( + error: error + ) + + return .cancelTaskAndUpstreamContinuations( + task: task, + upstreamContinuations: upstreams.map(\.continuation).compactMap { $0 } + ) + + case .combining(let task, let upstreams, let downstreamContinuation, _): + // One of our upstreams threw. We need to transition to finished ourselves now + // and resume the downstream continuation with the error. Furthermore, we need to cancel all of + // the upstream work. + self.state = .finished + + return .resumeContinuationWithFailureAndCancelTaskAndUpstreamContinuations( + downstreamContinuation: downstreamContinuation, + error: error, + task: task, + upstreamContinuations: upstreams.map(\.continuation).compactMap { $0 } + ) + + case .upstreamThrew, .finished: + // This is just everything finishing up, nothing to do here + return .none + + case .modifying: + preconditionFailure("Invalid state") + } + } + + /// Actions returned by `cancelled()`. + enum CancelledAction { + /// Indicates that the downstream continuation needs to be resumed and + /// task and the upstream continuations should be cancelled. + case resumeDownstreamContinuationWithNilAndCancelTaskAndUpstreamContinuations( + downstreamContinuation: DownstreamContinuation, + task: Task, + upstreamContinuations: [UnsafeContinuation] + ) + /// Indicates that the task and the upstream continuations should be cancelled. + case cancelTaskAndUpstreamContinuations( + task: Task, + upstreamContinuations: [UnsafeContinuation] + ) + } + + mutating func cancelled() -> CancelledAction? { + switch self.state { + case .initial: + state = .finished + + return .none + + case .waitingForDemand(let task, let upstreams, _): + // The downstream task got cancelled so we need to cancel our upstream Task + // and resume all continuations. We can also transition to finished. + self.state = .finished + + return .cancelTaskAndUpstreamContinuations( + task: task, + upstreamContinuations: upstreams.map(\.continuation).compactMap { $0 } + ) + + case .combining(let task, let upstreams, let downstreamContinuation, _): + // The downstream Task got cancelled so we need to cancel our upstream Task + // and resume all continuations. We can also transition to finished. + self.state = .finished + + return .resumeDownstreamContinuationWithNilAndCancelTaskAndUpstreamContinuations( + downstreamContinuation: downstreamContinuation, + task: task, + upstreamContinuations: upstreams.map(\.continuation).compactMap { $0 } + ) + + case .upstreamsFinished: + // We can transition to finished now + self.state = .finished + + return .none + + case .upstreamThrew, .finished: + // We are already finished so nothing to do here: + + return .none + + case .modifying: + preconditionFailure("Invalid state") + } + } + + /// Actions returned by `next()`. + enum NextAction { + /// Indicates that a new `Task` should be created that consumes the sequence. + case startTask([any Base]) + /// Indicates that all upstream continuations should be resumed. + case resumeUpstreamContinuations( + upstreamContinuation: [UnsafeContinuation] + ) + /// Indicates that the downstream continuation should be resumed with the result. + case resumeContinuation( + downstreamContinuation: DownstreamContinuation, + result: Result<[Element]?, Failure> + ) + /// Indicates that the downstream continuation should be resumed with `nil`. + case resumeDownstreamContinuationWithNil(DownstreamContinuation) + } + + mutating func next(for continuation: DownstreamContinuation) -> NextAction { + switch self.state { + case .initial(let bases): + // This is the first time we get demand singalled so we have to start the task + // The transition to the next state is done in the taskStarted method + return .startTask(bases) + + case .combining: + // We already got demand signalled and have suspended the downstream task + // Getting a second next calls means the iterator was transferred across Tasks which is not allowed + preconditionFailure("Internal inconsistency current state \(self.state) and received next()") + + case .waitingForDemand(let task, var upstreams, var buffer): + // We got demand signalled now we have to check if there is anything buffered. + // If not we have to transition to combining and need to resume all upstream continuations now + self.state = .modifying + + guard let element = buffer.popFirst() else { + let upstreamContinuations = upstreams.map(\.continuation).compactMap { $0 } + for index in 0..: Sendable { + typealias StateMachine = CombineLatestManyStateMachine + + private let stateMachine: Mutex + + init(_ bases: [any StateMachine.Base]) { + self.stateMachine = .init(.init(bases: bases)) + } + + func iteratorDeinitialized() { + let action = self.stateMachine.withLock { $0.iteratorDeinitialized() } + + switch action { + case .cancelTaskAndUpstreamContinuations( + let task, + let upstreamContinuation + ): + task.cancel() + for item in upstreamContinuation { + item.resume() + } + + case .none: + break + } + } + + func next() async throws(Failure) -> [Element]? { + let result = await withTaskCancellationHandler { + await withUnsafeContinuation { continuation in + let action: StateMachine.NextAction? = self.stateMachine.withLock { stateMachine in + let action = stateMachine.next(for: continuation) + switch action { + case .startTask(let bases): + // first iteration, we start one child task per base to iterate over them + self.startTask( + stateMachine: &stateMachine, + bases: bases, + downstreamContinuation: continuation + ) + return nil + + case .resumeContinuation: + return action + + case .resumeUpstreamContinuations: + return action + + case .resumeDownstreamContinuationWithNil: + return action + } + } + + switch action { + case .startTask: + // We are handling the startTask in the lock already because we want to avoid + // other inputs interleaving while starting the task + fatalError("Internal inconsistency") + + case .resumeContinuation(let downstreamContinuation, let result): + downstreamContinuation.resume(returning: result) + + case .resumeUpstreamContinuations(let upstreamContinuations): + // bases can be iterated over for 1 iteration so their next value can be retrieved + for item in upstreamContinuations { + item.resume() + } + + case .resumeDownstreamContinuationWithNil(let continuation): + // the async sequence is already finished, immediately resuming + continuation.resume(returning: .success(nil)) + + case .none: + break + } + } + } onCancel: { + let action = self.stateMachine.withLock { stateMachine in + stateMachine.cancelled() + } + + switch action { + case .resumeDownstreamContinuationWithNilAndCancelTaskAndUpstreamContinuations( + let downstreamContinuation, + let task, + let upstreamContinuations + ): + task.cancel() + for item in upstreamContinuations { + item.resume() + } + + downstreamContinuation.resume(returning: .success(nil)) + + case .cancelTaskAndUpstreamContinuations(let task, let upstreamContinuations): + task.cancel() + for item in upstreamContinuations { + item.resume() + } + + case .none: + break + } + } + return try result.get() + } + + private func startTask( + stateMachine: inout StateMachine, + bases: [any (AsyncSequence & Sendable)], + downstreamContinuation: StateMachine.DownstreamContinuation + ) { + // This creates a new `Task` that is iterating the upstream + // sequences. We must store it to cancel it at the right times. + let task = Task { + await withTaskGroup(of: Result.self) { group in + // For each upstream sequence we are adding a child task that + // is consuming the upstream sequence + for (baseIndex, base) in bases.enumerated() { + group.addTask { + var baseIterator = base.makeAsyncIterator() + + loop: while true { + // We are creating a continuation before requesting the next + // element from upstream. This continuation is only resumed + // if the downstream consumer called `next` to signal his demand. + await withUnsafeContinuation { continuation in + let action = self.stateMachine.withLock { stateMachine in + stateMachine.childTaskSuspended(baseIndex: baseIndex, continuation: continuation) + } + + switch action { + case .resumeContinuation(let upstreamContinuation): + upstreamContinuation.resume() + + case .none: + break + } + } + + let element: Element? + do { + element = try await baseIterator.next(isolation: nil) + } catch { + return .failure(error as! Failure) // Looks like a compiler bug + } + + if let element = element { + let action = self.stateMachine.withLock { stateMachine in + stateMachine.elementProduced(value: element, atBaseIndex: baseIndex) + } + + switch action { + case .resumeContinuation(let downstreamContinuation, let result): + downstreamContinuation.resume(returning: result) + + case .none: + break + } + } else { + let action = self.stateMachine.withLock { stateMachine in + stateMachine.upstreamFinished(baseIndex: baseIndex) + } + + switch action { + case .resumeContinuationWithNilAndCancelTaskAndUpstreamContinuations( + let downstreamContinuation, + let task, + let upstreamContinuations + ): + + task.cancel() + for item in upstreamContinuations { + item.resume() + } + + downstreamContinuation.resume(returning: .success(nil)) + break loop + + case .cancelTaskAndUpstreamContinuations(let task, let upstreamContinuations): + task.cancel() + for item in upstreamContinuations { + item.resume() + } + + break loop + + case .none: + break loop + } + } + } + return .success(()) + } + } + + while !group.isEmpty { + let result = await group.next() + + switch result { + case .success, .none: + break + case .failure(let error): + // One of the upstream sequences threw an error + let action = self.stateMachine.withLock { stateMachine in + stateMachine.upstreamThrew(error) + } + + switch action { + case .cancelTaskAndUpstreamContinuations(let task, let upstreamContinuations): + task.cancel() + for item in upstreamContinuations { + item.resume() + } + case .resumeContinuationWithFailureAndCancelTaskAndUpstreamContinuations( + let downstreamContinuation, + let error, + let task, + let upstreamContinuations + ): + task.cancel() + for item in upstreamContinuations { + item.resume() + } + downstreamContinuation.resume(returning: .failure(error)) + case .none: + break + } + + group.cancelAll() + } + } + } + } + + stateMachine.taskIsStarted(task: task, downstreamContinuation: downstreamContinuation) + } +} diff --git a/Sources/Configuration/Utilities/AsyncSequences.swift b/Sources/Configuration/Utilities/AsyncSequences.swift index 260a981..75b2b36 100644 --- a/Sources/Configuration/Utilities/AsyncSequences.swift +++ b/Sources/Configuration/Utilities/AsyncSequences.swift @@ -14,18 +14,18 @@ /// A concrete async sequence for delivering updated configuration values. @available(Configuration 1.0, *) -public struct ConfigUpdatesAsyncSequence { +public struct ConfigUpdatesAsyncSequence: Sendable { /// The upstream async sequence that this concrete sequence wraps. /// /// This property holds the async sequence that provides the actual elements. /// All operations on this concrete sequence are delegated to this upstream sequence. - private let upstream: any AsyncSequence + private let upstream: any AsyncSequence & Sendable /// Creates a new concrete async sequence wrapping the provided existential sequence. /// /// - Parameter upstream: The async sequence to wrap. - public init(_ upstream: some AsyncSequence) { + public init(_ upstream: some AsyncSequence & Sendable) { self.upstream = upstream } } @@ -60,7 +60,7 @@ extension ConfigUpdatesAsyncSequence: AsyncSequence { // MARK: - AsyncSequence extensions @available(Configuration 1.0, *) -extension AsyncSequence where Failure == Never { +extension AsyncSequence where Failure == Never, Self: Sendable { /// Maps each element of the sequence using a throwing transform, introducing a failure type. /// @@ -89,8 +89,8 @@ extension AsyncSequence where Failure == Never { /// } /// ``` func mapThrowing( - _ transform: @escaping (Element) throws(Failure) -> NewValue - ) -> some AsyncSequence { + _ transform: @escaping @Sendable (Element) throws(Failure) -> NewValue + ) -> some AsyncSequence & Sendable { MapThrowingAsyncSequence(upstream: self, transform: transform) } } @@ -110,13 +110,18 @@ extension AsyncSequence where Failure == Never { /// - `Value`: The input element type from the upstream sequence. /// - `Upstream`: The upstream async sequence type that never throws. @available(Configuration 1.0, *) -private struct MapThrowingAsyncSequence> { +private struct MapThrowingAsyncSequence< + Element, + Failure: Error, + Value, + Upstream: AsyncSequence & Sendable +>: Sendable { /// The upstream async sequence to transform. var upstream: Upstream /// The throwing transform function to apply to each element. - var transform: (Value) throws(Failure) -> Element + var transform: @Sendable (Value) throws(Failure) -> Element } @available(Configuration 1.0, *) diff --git a/Sources/Configuration/Utilities/combineLatestOneOrMore.swift b/Sources/Configuration/Utilities/combineLatestOneOrMore.swift deleted file mode 100644 index d4f6a00..0000000 --- a/Sources/Configuration/Utilities/combineLatestOneOrMore.swift +++ /dev/null @@ -1,130 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftConfiguration open source project -// -// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Synchronization - -/// A container that maintains the latest values from multiple async sequences. -/// -/// This class coordinates the "combine latest" operation by storing the most recent -/// value from each source sequence and emitting combined arrays only when all sources -/// have produced at least one value. -@available(Configuration 1.0, *) -private final class Combiner: Sendable { - - /// The internal state. - private struct State { - /// The current elements. - var elements: [Element?] - } - - /// The underlying mutex-protected storage. - private let storage: Mutex - - /// The continuation where to send values. - private let continuation: AsyncStream<[Element]>.Continuation - - /// The stream of combined arrays of elements. - let stream: AsyncStream<[Element]> - - /// Creates a new combiner for the specified number of async sequences. - /// - /// - Parameter count: The number of async sequences to combine. Must be at least 1. - /// - Precondition: `count >= 1` - init(count: Int) { - precondition(count >= 1, "Combiner requires the count of 1 or more") - self.storage = .init( - .init(elements: Array(repeating: nil, count: count)) - ) - (self.stream, self.continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) - } - - /// Updates the value from a specific async sequence and emits a combined array if ready. - /// - /// This method atomically updates the value at the specified index and checks if - /// all sequences have produced at least one value. If so, it emits the current - /// snapshot of all latest values. - /// - /// - Parameters: - /// - value: The new value from the async sequence. - /// - index: The index of the async sequence that produced this value. - func updateValue(_ value: Element, at index: Int) { - let valueToEmit: [Element]? = storage.withLock { state in - state.elements[index] = value - let nonNilValues = state.elements.compactMap { $0 } - if nonNilValues.count == state.elements.count { - // All values have been emitted at least once, and something changed. - // Emit the latest snapshot now. - return nonNilValues - } else { - // Not all upstreams have emitted a value yet, don't emit a snapshot yet. - return nil - } - } - if let valueToEmit { - continuation.yield(valueToEmit) - } - } -} - -/// Combines multiple async sequences using a "combine latest" strategy. -/// -/// This function takes multiple async sequences and combines their latest values into -/// arrays. It only emits a combined array after all sequences have produced at least -/// one value, and then emits a new array whenever any sequence produces a new value. -/// -/// ## Behavior -/// -/// - **Initial emission**: No values are emitted until all sequences have produced at least one value -/// - **Subsequent emissions**: A new combined array is emitted whenever any sequence produces a new value -/// - **Order preservation**: Values in the combined arrays maintain the same order as the input sequences -/// - **Latest values**: Only the most recent value from each sequence is included in each emission -/// -/// ## Concurrency -/// -/// All input sequences are processed concurrently using a task group. If any sequence -/// throws an error or completes unexpectedly, the entire operation is cancelled. -/// -/// - Parameters: -/// - elementType: The type of elements in the input async sequences. -/// - sources: An array of closures that each iterate over an async sequence. -/// - updatesHandler: A closure that processes the combined sequence of arrays. -/// - Throws: When any source throws, when the handler throws, or when cancelled. -/// - Returns: The value returned by the handler. -/// - Precondition: `sources` must not be empty. -@available(Configuration 1.0, *) -func combineLatestOneOrMore( - elementType: Element.Type = Element.self, - sources: [@Sendable ((ConfigUpdatesAsyncSequence) async throws -> Void) async throws -> Void], - updatesHandler: (ConfigUpdatesAsyncSequence<[Element], Never>) async throws -> Return -) async throws -> Return { - precondition(!sources.isEmpty, "combineLatestTwoOrMore requires at least one source") - let combiner = Combiner(count: sources.count) - return try await withThrowingTaskGroup(of: Void.self, returning: Return.self) { group in - for (index, source) in sources.enumerated() { - group.addTask { - try await source { updates in - for await element in updates { - combiner.updateValue(element, at: index) - } - } - // TODO: Is this the right error to throw when a source returns prematurely? - throw CancellationError() - } - } - defer { - group.cancelAll() - } - return try await updatesHandler(.init(combiner.stream)) - } -} diff --git a/Sources/ConfigurationTestingInternal/AsyncSequence+first.swift b/Sources/ConfigurationTestingInternal/AsyncSequence+first.swift index 8c9cbe8..a14e4a9 100644 --- a/Sources/ConfigurationTestingInternal/AsyncSequence+first.swift +++ b/Sources/ConfigurationTestingInternal/AsyncSequence+first.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// @available(Configuration 1.0, *) -extension AsyncSequence where Failure == Never { +extension AsyncSequence where Failure == Never, Self: Sendable { /// Returns the first element of the async sequence, or nil if the sequence completes before emitting an element. package var first: Element? { get async { @@ -23,7 +23,7 @@ extension AsyncSequence where Failure == Never { } @available(Configuration 1.0, *) -extension AsyncSequence { +extension AsyncSequence where Self: Sendable { /// Returns the first element of the async sequence, or nil if the sequence completes before emitting an element. package var first: Element? { get async throws { @@ -36,7 +36,7 @@ extension AsyncSequence { /// - Parameter updates: The async sequence to get the first element from. /// - Returns: The first element, or nil if empty. @available(Configuration 1.0, *) -package func awaitFirst(updates: any AsyncSequence) async -> Value? { +package func awaitFirst(updates: any AsyncSequence & Sendable) async -> Value? { await updates.first } @@ -45,6 +45,7 @@ package func awaitFirst(updates: any AsyncSequence(updates: any AsyncSequence) async throws -> Value? { +package func awaitFirst(updates: any AsyncSequence & Sendable) async throws -> Value? +{ try await updates.first } diff --git a/Tests/LinkageTest/Package.swift b/Tests/LinkageTest/Package.swift index ad07440..5bc022d 100644 --- a/Tests/LinkageTest/Package.swift +++ b/Tests/LinkageTest/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 import PackageDescription From 1370c15ec98b55d55a7dc36a6923f275c5512eac Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 27 Oct 2025 18:29:13 +0100 Subject: [PATCH 04/19] Review feedback from Franz: move from Data to RawSpan in the file snapshot API --- .../Providers/Files/FileProvider.swift | 2 +- .../Files/FileProviderSnapshot.swift | 9 ++--- .../Providers/Files/JSONSnapshot.swift | 6 ++-- .../Files/ReloadingFileProvider.swift | 4 +-- .../Providers/Files/YAMLSnapshot.swift | 8 ++--- Sources/Configuration/Utilities/Span.swift | 33 +++++++++++++++++++ .../JSONFileProviderTests.swift | 2 +- .../ReloadingFileProviderTests.swift | 4 +-- .../YAMLFileProviderTests.swift | 2 +- 9 files changed, 49 insertions(+), 21 deletions(-) create mode 100644 Sources/Configuration/Utilities/Span.swift diff --git a/Sources/Configuration/Providers/Files/FileProvider.swift b/Sources/Configuration/Providers/Files/FileProvider.swift index 960f66a..639940a 100644 --- a/Sources/Configuration/Providers/Files/FileProvider.swift +++ b/Sources/Configuration/Providers/Files/FileProvider.swift @@ -130,7 +130,7 @@ public struct FileProvider: Sendable { ) async throws { let fileContents = try await fileSystem.fileContents(atPath: filePath) self._snapshot = try snapshotType.init( - data: fileContents, + data: fileContents.bytes, providerName: "FileProvider<\(SnapshotType.self)>", parsingOptions: parsingOptions ) diff --git a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift index 8ecc05b..edc3e13 100644 --- a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift +++ b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift @@ -13,11 +13,6 @@ //===----------------------------------------------------------------------===// import SystemPackage -#if canImport(FoundationEssentials) -public import FoundationEssentials -#else -public import Foundation -#endif /// A type that provides parsing options for file configuration snapshots. /// @@ -69,7 +64,7 @@ public protocol FileParsingOptionsProtocol: Sendable { /// let values: [String: ConfigValue] /// let providerName: String /// -/// init(data: Data, providerName: String, parsingOptions: MyParsingOptions) throws { +/// init(data: RawSpan, providerName: String, parsingOptions: MyParsingOptions) throws { /// self.providerName = providerName /// // Parse the data according to your format /// self.values = try parseMyFormat(data, using: parsingOptions) @@ -96,5 +91,5 @@ public protocol FileConfigSnapshotProtocol: ConfigSnapshotProtocol, CustomString /// - providerName: The name of the provider creating this snapshot. /// - parsingOptions: Parsing options that affect parsing behavior. /// - Throws: If the file data cannot be parsed or contains invalid configuration. - init(data: Data, providerName: String, parsingOptions: ParsingOptions) throws + init(data: RawSpan, providerName: String, parsingOptions: ParsingOptions) throws } diff --git a/Sources/Configuration/Providers/Files/JSONSnapshot.swift b/Sources/Configuration/Providers/Files/JSONSnapshot.swift index e4ec2b0..30e0caa 100644 --- a/Sources/Configuration/Providers/Files/JSONSnapshot.swift +++ b/Sources/Configuration/Providers/Files/JSONSnapshot.swift @@ -17,7 +17,7 @@ import SystemPackage // Needs full Foundation for JSONSerialization. -public import Foundation +import Foundation /// A snapshot of configuration values parsed from JSON data. /// @@ -294,8 +294,8 @@ public struct JSONSnapshot { @available(Configuration 1.0, *) extension JSONSnapshot: FileConfigSnapshotProtocol { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - public init(data: Data, providerName: String, parsingOptions: ParsingOptions) throws { - guard let parsedDictionary = try JSONSerialization.jsonObject(with: data) as? [String: any Sendable] + public init(data: RawSpan, providerName: String, parsingOptions: ParsingOptions) throws { + guard let parsedDictionary = try JSONSerialization.jsonObject(with: Data(data)) as? [String: any Sendable] else { throw JSONSnapshot.JSONConfigError.topLevelJSONValueIsNotObject } diff --git a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift index 0b3a99b..e6b77d1 100644 --- a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift +++ b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift @@ -170,7 +170,7 @@ public final class ReloadingFileProvider Date: Mon, 27 Oct 2025 18:44:00 +0100 Subject: [PATCH 05/19] Drop *Protocol suffix --- .../Documentation.docc/Documentation.md | 2 +- ...pshotProtocol.md => FileConfigSnapshot.md} | 2 +- .../Reference/FileParsingOptions.md | 11 +++++++ .../Reference/FileParsingOptionsProtocol.md | 11 ------- .../Reference/FileProvider.md | 2 +- .../Reference/JSONSnapshot.md | 2 +- .../Reference/ReloadingFileProvider.md | 2 +- .../Reference/YAMLSnapshot.md | 2 +- .../Providers/Files/FileProvider.swift | 20 ++++++------- .../Files/FileProviderSnapshot.swift | 10 +++---- .../Providers/Files/JSONSnapshot.swift | 4 +-- .../Files/ReloadingFileProvider.swift | 30 +++++++++---------- .../Providers/Files/YAMLSnapshot.swift | 4 +-- .../ReloadingFileProviderTests.swift | 4 +-- 14 files changed, 53 insertions(+), 53 deletions(-) rename Sources/Configuration/Documentation.docc/Reference/{FileConfigSnapshotProtocol.md => FileConfigSnapshot.md} (77%) create mode 100644 Sources/Configuration/Documentation.docc/Reference/FileParsingOptions.md delete mode 100644 Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md diff --git a/Sources/Configuration/Documentation.docc/Documentation.md b/Sources/Configuration/Documentation.docc/Documentation.md index 92f47b3..7649110 100644 --- a/Sources/Configuration/Documentation.docc/Documentation.md +++ b/Sources/Configuration/Documentation.docc/Documentation.md @@ -427,7 +427,7 @@ Any package can implement a ``ConfigProvider``, making the ecosystem extensible ### Creating a custom provider - ``ConfigSnapshotProtocol`` -- ``FileParsingOptionsProtocol`` +- ``FileParsingOptions`` - ``ConfigProvider`` - ``ConfigContent`` - ``ConfigValue`` diff --git a/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md b/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshot.md similarity index 77% rename from Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md rename to Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshot.md index f15bd6a..5f5ebe8 100644 --- a/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshotProtocol.md +++ b/Sources/Configuration/Documentation.docc/Reference/FileConfigSnapshot.md @@ -1,4 +1,4 @@ -# ``Configuration/FileConfigSnapshotProtocol`` +# ``Configuration/FileConfigSnapshot`` ## Topics diff --git a/Sources/Configuration/Documentation.docc/Reference/FileParsingOptions.md b/Sources/Configuration/Documentation.docc/Reference/FileParsingOptions.md new file mode 100644 index 0000000..8ac2a54 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Reference/FileParsingOptions.md @@ -0,0 +1,11 @@ +# ``Configuration/FileParsingOptions`` + +## Topics + +### Required properties + +- ``default`` + +### Parsing options + +- ``FileConfigSnapshot`` diff --git a/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md b/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md deleted file mode 100644 index 432db59..0000000 --- a/Sources/Configuration/Documentation.docc/Reference/FileParsingOptionsProtocol.md +++ /dev/null @@ -1,11 +0,0 @@ -# ``Configuration/FileParsingOptionsProtocol`` - -## Topics - -### Required properties - -- ``default`` - -### Parsing options - -- ``FileConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Documentation.docc/Reference/FileProvider.md b/Sources/Configuration/Documentation.docc/Reference/FileProvider.md index 921d194..34f9518 100644 --- a/Sources/Configuration/Documentation.docc/Reference/FileProvider.md +++ b/Sources/Configuration/Documentation.docc/Reference/FileProvider.md @@ -9,6 +9,6 @@ ### Reading configuration files -- ``FileConfigSnapshotProtocol`` +- ``FileConfigSnapshot`` - ``JSONSnapshot`` - ``YAMLSnapshot`` diff --git a/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md b/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md index 8e08e48..fd6ff92 100644 --- a/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md +++ b/Sources/Configuration/Documentation.docc/Reference/JSONSnapshot.md @@ -9,5 +9,5 @@ ### Snapshot configuration -- ``FileConfigSnapshotProtocol`` +- ``FileConfigSnapshot`` - ``ConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md b/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md index 35074c5..9f72e9b 100644 --- a/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md +++ b/Sources/Configuration/Documentation.docc/Reference/ReloadingFileProvider.md @@ -13,6 +13,6 @@ ### Monitoring file changes -- ``FileConfigSnapshotProtocol`` +- ``FileConfigSnapshot`` - ``JSONSnapshot`` - ``YAMLSnapshot`` diff --git a/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md b/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md index 618fff5..8e9d03b 100644 --- a/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md +++ b/Sources/Configuration/Documentation.docc/Reference/YAMLSnapshot.md @@ -9,5 +9,5 @@ ### Snapshot configuration -- ``FileConfigSnapshotProtocol`` +- ``FileConfigSnapshot`` - ``ConfigSnapshotProtocol`` diff --git a/Sources/Configuration/Providers/Files/FileProvider.swift b/Sources/Configuration/Providers/Files/FileProvider.swift index 639940a..af93177 100644 --- a/Sources/Configuration/Providers/Files/FileProvider.swift +++ b/Sources/Configuration/Providers/Files/FileProvider.swift @@ -23,7 +23,7 @@ import Foundation /// A configuration provider that reads from a file on disk using a configurable snapshot type. /// /// `FileProvider` is a generic file-based configuration provider that works with different -/// file formats by using different snapshot types that conform to ``FileConfigSnapshotProtocol``. +/// file formats by using different snapshot types that conform to ``FileConfigSnapshot``. /// This allows for a unified interface for reading JSON, YAML, or other structured configuration files. /// /// ## Usage @@ -58,10 +58,10 @@ import Foundation /// This expects a `filePath` key in the configuration that specifies the path to the file. /// For a full list of configuration keys, check out ``FileProvider/init(snapshotType:parsingOptions:config:)``. @available(Configuration 1.0, *) -public struct FileProvider: Sendable { +public struct FileProvider: Sendable { /// A snapshot of the internal state. - private let _snapshot: SnapshotType + private let _snapshot: Snapshot /// Creates a file provider that reads from the specified file path. /// @@ -74,8 +74,8 @@ public struct FileProvider: Sendable { /// - filePath: The path to the configuration file to read. /// - Throws: If the file cannot be read or if snapshot creation fails. public init( - snapshotType: SnapshotType.Type = SnapshotType.self, - parsingOptions: SnapshotType.ParsingOptions = .default, + snapshotType: Snapshot.Type = Snapshot.self, + parsingOptions: Snapshot.ParsingOptions = .default, filePath: FilePath ) async throws { try await self.init( @@ -100,8 +100,8 @@ public struct FileProvider: Sendable { /// - config: A configuration reader that contains the required configuration keys. /// - Throws: If the `filePath` key is missing, if the file cannot be read, or if snapshot creation fails. public init( - snapshotType: SnapshotType.Type = SnapshotType.self, - parsingOptions: SnapshotType.ParsingOptions = .default, + snapshotType: Snapshot.Type = Snapshot.self, + parsingOptions: Snapshot.ParsingOptions = .default, config: ConfigReader ) async throws { try await self.init( @@ -123,15 +123,15 @@ public struct FileProvider: Sendable { /// - fileSystem: The file system implementation to use for reading the file. /// - Throws: If the file cannot be read or if snapshot creation fails. internal init( - snapshotType: SnapshotType.Type, - parsingOptions: SnapshotType.ParsingOptions, + snapshotType: Snapshot.Type, + parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, fileSystem: some CommonProviderFileSystem ) async throws { let fileContents = try await fileSystem.fileContents(atPath: filePath) self._snapshot = try snapshotType.init( data: fileContents.bytes, - providerName: "FileProvider<\(SnapshotType.self)>", + providerName: "FileProvider<\(Snapshot.self)>", parsingOptions: parsingOptions ) } diff --git a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift index edc3e13..b4bcc25 100644 --- a/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift +++ b/Sources/Configuration/Providers/Files/FileProviderSnapshot.swift @@ -26,7 +26,7 @@ import SystemPackage /// Implement this protocol to provide parsing options: /// /// ```swift -/// struct MyParsingOptions: FileParsingOptionsProtocol { +/// struct MyParsingOptions: FileParsingOptions { /// let encoding: String.Encoding /// let dateFormat: String? /// @@ -37,7 +37,7 @@ import SystemPackage /// } /// ``` @available(Configuration 1.0, *) -public protocol FileParsingOptionsProtocol: Sendable { +public protocol FileParsingOptions: Sendable { /// The default instance of this options type. /// /// This property provides a default configuration that can be used when @@ -58,7 +58,7 @@ public protocol FileParsingOptionsProtocol: Sendable { /// To create a custom file configuration snapshot: /// /// ```swift -/// struct MyFormatSnapshot: FileConfigSnapshotProtocol { +/// struct MyFormatSnapshot: FileConfigSnapshot { /// typealias ParsingOptions = MyParsingOptions /// /// let values: [String: ConfigValue] @@ -75,11 +75,11 @@ public protocol FileParsingOptionsProtocol: Sendable { /// The snapshot is responsible for parsing the file data and converting it into a /// representation of configuration values that can be queried by the configuration system. @available(Configuration 1.0, *) -public protocol FileConfigSnapshotProtocol: ConfigSnapshotProtocol, CustomStringConvertible, +public protocol FileConfigSnapshot: ConfigSnapshotProtocol, CustomStringConvertible, CustomDebugStringConvertible { /// The parsing options type used for parsing this snapshot. - associatedtype ParsingOptions: FileParsingOptionsProtocol + associatedtype ParsingOptions: FileParsingOptions /// Creates a new snapshot from file data. /// diff --git a/Sources/Configuration/Providers/Files/JSONSnapshot.swift b/Sources/Configuration/Providers/Files/JSONSnapshot.swift index 30e0caa..7b8a2fb 100644 --- a/Sources/Configuration/Providers/Files/JSONSnapshot.swift +++ b/Sources/Configuration/Providers/Files/JSONSnapshot.swift @@ -32,7 +32,7 @@ public struct JSONSnapshot { /// /// This struct provides configuration options for parsing JSON data into configuration snapshots, /// including byte decoding and secrets specification. - public struct ParsingOptions: FileParsingOptionsProtocol { + public struct ParsingOptions: FileParsingOptions { /// A decoder of bytes from a string. public var bytesDecoder: any ConfigBytesFromStringDecoder @@ -292,7 +292,7 @@ public struct JSONSnapshot { } @available(Configuration 1.0, *) -extension JSONSnapshot: FileConfigSnapshotProtocol { +extension JSONSnapshot: FileConfigSnapshot { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public init(data: RawSpan, providerName: String, parsingOptions: ParsingOptions) throws { guard let parsedDictionary = try JSONSerialization.jsonObject(with: Data(data)) as? [String: any Sendable] diff --git a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift index e6b77d1..9b779e6 100644 --- a/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift +++ b/Sources/Configuration/Providers/Files/ReloadingFileProvider.swift @@ -32,7 +32,7 @@ import Synchronization /// `ReloadingFileProvider` is a generic file-based configuration provider that monitors /// a configuration file for changes and automatically reloads the data when /// the file is modified. This provider works with different file formats by using -/// different snapshot types that conform to ``FileConfigSnapshotProtocol``. +/// different snapshot types that conform to ``FileConfigSnapshot``. /// /// ## Usage /// @@ -83,13 +83,13 @@ import Synchronization /// When a change is detected, it reloads the file and notifies all active watchers of the /// updated configuration values. @available(Configuration 1.0, *) -public final class ReloadingFileProvider: Sendable { +public final class ReloadingFileProvider: Sendable { /// The internal storage structure for the provider state. private struct Storage { /// The current configuration snapshot. - var snapshot: SnapshotType + var snapshot: Snapshot /// Last modified timestamp of the resolved file. var lastModifiedTimestamp: Date @@ -101,7 +101,7 @@ public final class ReloadingFileProvider>.Continuation]] /// Active watchers for configuration snapshots. - var snapshotWatchers: [UUID: AsyncStream.Continuation] + var snapshotWatchers: [UUID: AsyncStream.Continuation] /// Returns the total number of active watchers. var totalWatcherCount: Int { @@ -115,7 +115,7 @@ public final class ReloadingFileProvider /// The options used for parsing the data. - private let parsingOptions: SnapshotType.ParsingOptions + private let parsingOptions: Snapshot.ParsingOptions /// The file system interface for reading files and timestamps. private let fileSystem: any CommonProviderFileSystem @@ -136,8 +136,8 @@ public final class ReloadingFileProvider, [AsyncStream>.Continuation] )] - typealias SnapshotWatchers = (SnapshotType, [AsyncStream.Continuation]) + typealias SnapshotWatchers = (Snapshot, [AsyncStream.Continuation]) guard let (valueWatchersToNotify, snapshotWatchersToNotify) = storage @@ -497,7 +497,7 @@ extension ReloadingFileProvider: ConfigProvider { public func watchSnapshot( updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return ) async throws -> Return { - let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) let id = UUID() // Add watcher and get initial snapshot diff --git a/Sources/Configuration/Providers/Files/YAMLSnapshot.swift b/Sources/Configuration/Providers/Files/YAMLSnapshot.swift index 8836bd4..84ffc75 100644 --- a/Sources/Configuration/Providers/Files/YAMLSnapshot.swift +++ b/Sources/Configuration/Providers/Files/YAMLSnapshot.swift @@ -36,7 +36,7 @@ public final class YAMLSnapshot: Sendable { /// /// This struct provides configuration options for parsing YAML data into configuration snapshots, /// including byte decoding and secrets specification. - public struct ParsingOptions: FileParsingOptionsProtocol { + public struct ParsingOptions: FileParsingOptions { /// A decoder of bytes from a string. public var bytesDecoder: any ConfigBytesFromStringDecoder @@ -244,7 +244,7 @@ public final class YAMLSnapshot: Sendable { } @available(Configuration 1.0, *) -extension YAMLSnapshot: FileConfigSnapshotProtocol { +extension YAMLSnapshot: FileConfigSnapshot { // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public convenience init(data: RawSpan, providerName: String, parsingOptions: ParsingOptions) throws { guard let mapping = try Yams.Parser(yaml: Data(data)).singleRoot()?.mapping else { diff --git a/Tests/ConfigurationTests/ReloadingFileProviderTests.swift b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift index f1771e4..724826f 100644 --- a/Tests/ConfigurationTests/ReloadingFileProviderTests.swift +++ b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift @@ -26,9 +26,9 @@ import Synchronization import SystemPackage @available(Configuration 1.0, *) -private struct TestSnapshot: FileConfigSnapshotProtocol { +private struct TestSnapshot: FileConfigSnapshot { - struct Input: FileParsingOptionsProtocol { + struct Input: FileParsingOptions { static var `default`: TestSnapshot.Input { .init() } From 3ed79a81777fc38593b822622bbfcb69cfbc91de Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 3 Nov 2025 10:58:29 +0100 Subject: [PATCH 06/19] Improved file provider tests --- Sources/Configuration/Deprecations.swift | 19 ++++ .../Providers/Files/FileProvider.swift | 2 +- Tests/ConfigurationTests/Deprecations.swift | 28 +++++ Tests/ConfigurationTests/FileProvider.swift | 54 +++++++++ .../JSONFileProviderTests.swift | 4 + .../ReloadingFileProviderTests.swift | 98 ++-------------- Tests/ConfigurationTests/TestSnapshot.swift | 107 ++++++++++++++++++ 7 files changed, 224 insertions(+), 88 deletions(-) create mode 100644 Sources/Configuration/Deprecations.swift create mode 100644 Tests/ConfigurationTests/Deprecations.swift create mode 100644 Tests/ConfigurationTests/FileProvider.swift create mode 100644 Tests/ConfigurationTests/TestSnapshot.swift diff --git a/Sources/Configuration/Deprecations.swift b/Sources/Configuration/Deprecations.swift new file mode 100644 index 0000000..9a3570d --- /dev/null +++ b/Sources/Configuration/Deprecations.swift @@ -0,0 +1,19 @@ +/// A provider of configuration in JSON files. +@available(Configuration 1.0, *) +@available(*, deprecated, message: "Renamed to FileProvider") +public typealias JSONProvider = FileProvider + +/// A reloading provider of configuration in JSON files. +@available(Configuration 1.0, *) +@available(*, deprecated, message: "Renamed to ReloadingFileProvider") +public typealias ReloadingJSONProvider = ReloadingFileProvider + +/// A provider of configuration in YAML files. +@available(Configuration 1.0, *) +@available(*, deprecated, message: "Renamed to FileProvider") +public typealias YAMLProvider = FileProvider + +/// A reloading provider of configuration in JSON files. +@available(Configuration 1.0, *) +@available(*, deprecated, message: "Renamed to ReloadingFileProvider") +public typealias ReloadingYAMLProvider = ReloadingFileProvider diff --git a/Sources/Configuration/Providers/Files/FileProvider.swift b/Sources/Configuration/Providers/Files/FileProvider.swift index af93177..dc497dc 100644 --- a/Sources/Configuration/Providers/Files/FileProvider.swift +++ b/Sources/Configuration/Providers/Files/FileProvider.swift @@ -123,7 +123,7 @@ public struct FileProvider: Sendable { /// - fileSystem: The file system implementation to use for reading the file. /// - Throws: If the file cannot be read or if snapshot creation fails. internal init( - snapshotType: Snapshot.Type, + snapshotType: Snapshot.Type = Snapshot.self, parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, fileSystem: some CommonProviderFileSystem diff --git a/Tests/ConfigurationTests/Deprecations.swift b/Tests/ConfigurationTests/Deprecations.swift new file mode 100644 index 0000000..3af1edd --- /dev/null +++ b/Tests/ConfigurationTests/Deprecations.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Configuration +import SystemPackage + +/// This type only exists to test that deprecated symbols are still usable. +@available(Configuration 1.0, *) +@available(*, deprecated) +struct Deprecations { + func fileProviders() async throws { + let _ = try await JSONProvider(filePath: "/dev/null") + let _ = try await ReloadingJSONProvider(filePath: "/dev/null") + let _ = try await YAMLProvider(filePath: "/dev/null") + let _ = try await ReloadingYAMLProvider(filePath: "/dev/null") + } +} diff --git a/Tests/ConfigurationTests/FileProvider.swift b/Tests/ConfigurationTests/FileProvider.swift new file mode 100644 index 0000000..58045fc --- /dev/null +++ b/Tests/ConfigurationTests/FileProvider.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Testing +import ConfigurationTestingInternal +@testable import Configuration +import Foundation +import ConfigurationTesting +import Logging +import Metrics +import ServiceLifecycle +import Synchronization +import SystemPackage + +@available(Configuration 1.0, *) +private func withTestProvider( + body: ( + FileProvider, + InMemoryFileSystem, + FilePath, + Date + ) async throws -> R +) async throws -> R { + try await withTestFileSystem { fileSystem, filePath, originalTimestamp in + let provider = try await FileProvider( + parsingOptions: .default, + filePath: filePath, + fileSystem: fileSystem + ) + return try await body(provider, fileSystem, filePath, originalTimestamp) + } +} + +struct FileProviderTests { + @available(Configuration 1.0, *) + @Test func testLoad() async throws { + try await withTestProvider { provider, fileSystem, filePath, originalTimestamp in + // Check initial values + let result1 = try provider.value(forKey: ["key1"], type: .string) + #expect(try result1.value?.content.asString == "value1") + } + } +} diff --git a/Tests/ConfigurationTests/JSONFileProviderTests.swift b/Tests/ConfigurationTests/JSONFileProviderTests.swift index cb17609..4e0f410 100644 --- a/Tests/ConfigurationTests/JSONFileProviderTests.swift +++ b/Tests/ConfigurationTests/JSONFileProviderTests.swift @@ -12,6 +12,8 @@ // //===----------------------------------------------------------------------===// +#if JSONSupport + import Testing import ConfigurationTestingInternal @testable import Configuration @@ -59,3 +61,5 @@ struct JSONFileProviderTests { .runTest() } } + +#endif diff --git a/Tests/ConfigurationTests/ReloadingFileProviderTests.swift b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift index 724826f..2ea9453 100644 --- a/Tests/ConfigurationTests/ReloadingFileProviderTests.swift +++ b/Tests/ConfigurationTests/ReloadingFileProviderTests.swift @@ -25,71 +25,6 @@ import ServiceLifecycle import Synchronization import SystemPackage -@available(Configuration 1.0, *) -private struct TestSnapshot: FileConfigSnapshot { - - struct Input: FileParsingOptions { - static var `default`: TestSnapshot.Input { - .init() - } - } - - var values: [String: ConfigValue] - - var providerName: String - - func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { - let encodedKey = SeparatorKeyEncoder.dotSeparated.encode(key) - return LookupResult(encodedKey: encodedKey, value: values[encodedKey]) - } - - init(values: [String: ConfigValue], providerName: String) { - self.values = values - self.providerName = providerName - } - - init(contents: String, providerName: String) throws { - var values: [String: ConfigValue] = [:] - - // Simple key=value parser for testing - for line in contents.split(separator: "\n") { - let parts = line.split(separator: "=", maxSplits: 1) - if parts.count == 2 { - let key = String(parts[0]).trimmingCharacters(in: .whitespaces) - let value = String(parts[1]).trimmingCharacters(in: .whitespaces) - values[key] = .init(.string(value), isSecret: false) - } - } - self.init(values: values, providerName: providerName) - } - - init(data: RawSpan, providerName: String, parsingOptions: Input) throws { - try self.init(contents: String(decoding: Data(data), as: UTF8.self), providerName: providerName) - } - - var description: String { - "TestSnapshot" - } - - var debugDescription: String { - description - } -} - -@available(Configuration 1.0, *) -extension InMemoryFileSystem.FileData { - static func file(contents: String) -> Self { - .file(Data(contents.utf8)) - } -} - -@available(Configuration 1.0, *) -extension InMemoryFileSystem.FileInfo { - static func file(timestamp: Date, contents: String) -> Self { - .init(lastModifiedTimestamp: timestamp, data: .file(contents: contents)) - } -} - @available(Configuration 1.0, *) private func withTestProvider( body: ( @@ -99,28 +34,17 @@ private func withTestProvider( Date ) async throws -> R ) async throws -> R { - let filePath = FilePath("/test/config.txt") - let originalTimestamp = Date(timeIntervalSince1970: 1_750_688_537) - let fileSystem = InMemoryFileSystem( - files: [ - filePath: .file( - timestamp: originalTimestamp, - contents: """ - key1=value1 - key2=value2 - """ - ) - ] - ) - let provider = try await ReloadingFileProvider( - parsingOptions: .default, - filePath: filePath, - pollInterval: .seconds(1), - fileSystem: fileSystem, - logger: .noop, - metrics: NOOPMetricsHandler.instance - ) - return try await body(provider, fileSystem, filePath, originalTimestamp) + try await withTestFileSystem { fileSystem, filePath, originalTimestamp in + let provider = try await ReloadingFileProvider( + parsingOptions: .default, + filePath: filePath, + pollInterval: .seconds(1), + fileSystem: fileSystem, + logger: .noop, + metrics: NOOPMetricsHandler.instance + ) + return try await body(provider, fileSystem, filePath, originalTimestamp) + } } struct ReloadingFileProviderTests { diff --git a/Tests/ConfigurationTests/TestSnapshot.swift b/Tests/ConfigurationTests/TestSnapshot.swift new file mode 100644 index 0000000..eea50e7 --- /dev/null +++ b/Tests/ConfigurationTests/TestSnapshot.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@testable import Configuration +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +import ConfigurationTestingInternal +import SystemPackage + +@available(Configuration 1.0, *) +struct TestSnapshot: FileConfigSnapshot { + + struct Input: FileParsingOptions { + static var `default`: TestSnapshot.Input { + .init() + } + } + + var values: [String: ConfigValue] + + var providerName: String + + func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { + let encodedKey = SeparatorKeyEncoder.dotSeparated.encode(key) + return LookupResult(encodedKey: encodedKey, value: values[encodedKey]) + } + + init(values: [String: ConfigValue], providerName: String) { + self.values = values + self.providerName = providerName + } + + init(contents: String, providerName: String) throws { + var values: [String: ConfigValue] = [:] + + // Simple key=value parser for testing + for line in contents.split(separator: "\n") { + let parts = line.split(separator: "=", maxSplits: 1) + if parts.count == 2 { + let key = String(parts[0]).trimmingCharacters(in: .whitespaces) + let value = String(parts[1]).trimmingCharacters(in: .whitespaces) + values[key] = .init(.string(value), isSecret: false) + } + } + self.init(values: values, providerName: providerName) + } + + init(data: RawSpan, providerName: String, parsingOptions: Input) throws { + try self.init(contents: String(decoding: Data(data), as: UTF8.self), providerName: providerName) + } + + var description: String { + "TestSnapshot" + } + + var debugDescription: String { + description + } +} + +@available(Configuration 1.0, *) +extension InMemoryFileSystem.FileData { + static func file(contents: String) -> Self { + .file(Data(contents.utf8)) + } +} + +@available(Configuration 1.0, *) +extension InMemoryFileSystem.FileInfo { + static func file(timestamp: Date, contents: String) -> Self { + .init(lastModifiedTimestamp: timestamp, data: .file(contents: contents)) + } +} + +@available(Configuration 1.0, *) +func withTestFileSystem( + _ body: (InMemoryFileSystem, FilePath, Date) async throws -> Return +) async throws -> Return { + let filePath = FilePath("/test/config.txt") + let originalTimestamp = Date(timeIntervalSince1970: 1_750_688_537) + let fileSystem = InMemoryFileSystem( + files: [ + filePath: .file( + timestamp: originalTimestamp, + contents: """ + key1=value1 + key2=value2 + """ + ) + ] + ) + return try await body(fileSystem, filePath, originalTimestamp) +} From b46d0ea9c462698fb1862b749cd42bf1ef75e93a Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 3 Nov 2025 11:24:08 +0100 Subject: [PATCH 07/19] Fix up tests on linux --- .gitignore | 1 + Sources/Configuration/Deprecations.swift | 22 ++++++++++++++----- .../Providers/Files/FileProvider.swift | 4 ++-- Tests/ConfigurationTests/Deprecations.swift | 2 ++ Tests/ConfigurationTests/TestSnapshot.swift | 4 ---- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index edad16a..1314b94 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ Package.resolved *.pyc .docc-build .vscode +.devcontainer diff --git a/Sources/Configuration/Deprecations.swift b/Sources/Configuration/Deprecations.swift index 9a3570d..51a0527 100644 --- a/Sources/Configuration/Deprecations.swift +++ b/Sources/Configuration/Deprecations.swift @@ -1,19 +1,31 @@ +#if JSONSupport /// A provider of configuration in JSON files. @available(Configuration 1.0, *) @available(*, deprecated, message: "Renamed to FileProvider") public typealias JSONProvider = FileProvider +#endif -/// A reloading provider of configuration in JSON files. -@available(Configuration 1.0, *) -@available(*, deprecated, message: "Renamed to ReloadingFileProvider") -public typealias ReloadingJSONProvider = ReloadingFileProvider - +#if YAMLSupport /// A provider of configuration in YAML files. @available(Configuration 1.0, *) @available(*, deprecated, message: "Renamed to FileProvider") public typealias YAMLProvider = FileProvider +#endif + +#if ReloadingSupport +#if JSONSupport +/// A reloading provider of configuration in JSON files. +@available(Configuration 1.0, *) +@available(*, deprecated, message: "Renamed to ReloadingFileProvider") +public typealias ReloadingJSONProvider = ReloadingFileProvider +#endif + +#if YAMLSupport /// A reloading provider of configuration in JSON files. @available(Configuration 1.0, *) @available(*, deprecated, message: "Renamed to ReloadingFileProvider") public typealias ReloadingYAMLProvider = ReloadingFileProvider +#endif + +#endif diff --git a/Sources/Configuration/Providers/Files/FileProvider.swift b/Sources/Configuration/Providers/Files/FileProvider.swift index dc497dc..189eb38 100644 --- a/Sources/Configuration/Providers/Files/FileProvider.swift +++ b/Sources/Configuration/Providers/Files/FileProvider.swift @@ -31,12 +31,12 @@ import Foundation /// Create a provider by specifying the snapshot type and file path: /// /// ```swift -/// // Using with JSON snapshot +/// // Using with a JSON snapshot /// let jsonProvider = try await FileProvider( /// filePath: "/etc/config.json" /// ) /// -/// // Using with YAML snapshot +/// // Using with a YAML snapshot /// let yamlProvider = try await FileProvider( /// filePath: "/etc/config.yaml" /// ) diff --git a/Tests/ConfigurationTests/Deprecations.swift b/Tests/ConfigurationTests/Deprecations.swift index 3af1edd..a924d17 100644 --- a/Tests/ConfigurationTests/Deprecations.swift +++ b/Tests/ConfigurationTests/Deprecations.swift @@ -19,10 +19,12 @@ import SystemPackage @available(Configuration 1.0, *) @available(*, deprecated) struct Deprecations { + #if ReloadingSupport && JSONSupport && YAMLSupport func fileProviders() async throws { let _ = try await JSONProvider(filePath: "/dev/null") let _ = try await ReloadingJSONProvider(filePath: "/dev/null") let _ = try await YAMLProvider(filePath: "/dev/null") let _ = try await ReloadingYAMLProvider(filePath: "/dev/null") } + #endif } diff --git a/Tests/ConfigurationTests/TestSnapshot.swift b/Tests/ConfigurationTests/TestSnapshot.swift index eea50e7..45cfd7e 100644 --- a/Tests/ConfigurationTests/TestSnapshot.swift +++ b/Tests/ConfigurationTests/TestSnapshot.swift @@ -13,11 +13,7 @@ //===----------------------------------------------------------------------===// @testable import Configuration -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import ConfigurationTestingInternal import SystemPackage From 50d19231dc69f6eafdc60b1db97afd3c4ff81a1c Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 3 Nov 2025 11:24:50 +0100 Subject: [PATCH 08/19] Fix license headers in Deprecations.swift --- Sources/Configuration/Deprecations.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/Configuration/Deprecations.swift b/Sources/Configuration/Deprecations.swift index 51a0527..0de81e6 100644 --- a/Sources/Configuration/Deprecations.swift +++ b/Sources/Configuration/Deprecations.swift @@ -1,3 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + #if JSONSupport /// A provider of configuration in JSON files. @available(Configuration 1.0, *) From 5ba74d98985211f97488ca4ea6943a2d097865cb Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 3 Nov 2025 11:32:21 +0100 Subject: [PATCH 09/19] Bump to Swift 6.2 as minimum version due to RawSpan --- .github/workflows/main.yml | 8 +++++++- .github/workflows/pull_request.yml | 8 +++++++- .spi.yml | 2 +- Examples/hello-world-cli-example/Package.swift | 2 +- Package.swift | 2 +- Tests/LinkageTest/Package.swift | 2 +- 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4fc09dd..a101158 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,7 +17,8 @@ jobs: linux_env_vars: '{"ENABLE_ALL_TRAITS":"1"}' linux_5_10_enabled: false linux_6_0_enabled: false - linux_6_1_arguments_override: "--explicit-target-dependency-import-check error" + linux_6_1_enabled: false + linux_6_2_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" # Windows is disabled, blocked on Swift Service Lifecycle Windows support: https://github.com/swift-server/swift-service-lifecycle/issues/213 @@ -45,6 +46,8 @@ jobs: runner_pool: nightly build_scheme: swift-configuration-Package xcode_16_2_enabled: false + xcode_16_3_enabled: false + xcode_16_4_enabled: false release-builds: name: Release builds @@ -53,6 +56,7 @@ jobs: linux_env_vars: '{"ENABLE_ALL_TRAITS":"1"}' linux_5_10_enabled: false linux_6_0_enabled: false + linux_6_1_enabled: false windows_6_0_enabled: false windows_6_1_enabled: false windows_nightly_next_enabled: false @@ -75,6 +79,7 @@ jobs: MATRIX_LINUX_COMMAND: ./Scripts/run-linkage-test.sh MATRIX_LINUX_5_10_ENABLED: false MATRIX_LINUX_6_0_ENABLED: false + MATRIX_LINUX_6_1_ENABLED: false MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false linkage-test: @@ -102,6 +107,7 @@ jobs: MATRIX_LINUX_COMMAND: ./Scripts/test-examples.sh MATRIX_LINUX_5_10_ENABLED: false MATRIX_LINUX_6_0_ENABLED: false + MATRIX_LINUX_6_1_ENABLED: false MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false example-packages: diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index ae9ef53..3f38baa 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -24,7 +24,8 @@ jobs: linux_env_vars: '{"ENABLE_ALL_TRAITS":"1"}' linux_5_10_enabled: false linux_6_0_enabled: false - linux_6_1_arguments_override: "--explicit-target-dependency-import-check error" + linux_6_1_enabled: false + linux_6_2_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" # Windows is disabled, blocked on Swift Service Lifecycle Windows support: https://github.com/swift-server/swift-service-lifecycle/issues/213 @@ -52,6 +53,8 @@ jobs: runner_pool: general build_scheme: swift-configuration-Package xcode_16_2_enabled: false + xcode_16_3_enabled: false + xcode_16_4_enabled: false release-builds: name: Release builds @@ -60,6 +63,7 @@ jobs: linux_env_vars: '{"ENABLE_ALL_TRAITS":"1"}' linux_5_10_enabled: false linux_6_0_enabled: false + linux_6_1_enabled: false windows_6_0_enabled: false windows_6_1_enabled: false windows_nightly_next_enabled: false @@ -82,6 +86,7 @@ jobs: MATRIX_LINUX_COMMAND: ./Scripts/run-linkage-test.sh MATRIX_LINUX_5_10_ENABLED: false MATRIX_LINUX_6_0_ENABLED: false + MATRIX_LINUX_6_1_ENABLED: false MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false linkage-test: @@ -109,6 +114,7 @@ jobs: MATRIX_LINUX_COMMAND: ./Scripts/test-examples.sh MATRIX_LINUX_5_10_ENABLED: false MATRIX_LINUX_6_0_ENABLED: false + MATRIX_LINUX_6_1_ENABLED: false MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false example-packages: diff --git a/.spi.yml b/.spi.yml index d834ba2..9c692fd 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,7 +1,7 @@ version: 1 builder: configs: - - swift_version: '6.1' + - swift_version: '6.2' documentation_targets: - Configuration - ConfigurationTesting diff --git a/Examples/hello-world-cli-example/Package.swift b/Examples/hello-world-cli-example/Package.swift index 5d94efe..bc00de7 100644 --- a/Examples/hello-world-cli-example/Package.swift +++ b/Examples/hello-world-cli-example/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 import PackageDescription diff --git a/Package.swift b/Package.swift index 27afe6e..0a16837 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 import PackageDescription #if canImport(FoundationEssentials) diff --git a/Tests/LinkageTest/Package.swift b/Tests/LinkageTest/Package.swift index ad07440..5bc022d 100644 --- a/Tests/LinkageTest/Package.swift +++ b/Tests/LinkageTest/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 import PackageDescription From 84484fb842303364fd86dec47a5e130e8feea9f9 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 3 Nov 2025 12:23:11 +0100 Subject: [PATCH 10/19] Disable nightly-next CI --- .github/workflows/main.yml | 4 ++++ .github/workflows/pull_request.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a101158..61bb2b3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,6 +19,7 @@ jobs: linux_6_0_enabled: false linux_6_1_enabled: false linux_6_2_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_next_enabled: false linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" # Windows is disabled, blocked on Swift Service Lifecycle Windows support: https://github.com/swift-server/swift-service-lifecycle/issues/213 @@ -57,6 +58,7 @@ jobs: linux_5_10_enabled: false linux_6_0_enabled: false linux_6_1_enabled: false + linux_nightly_next_enabled: false windows_6_0_enabled: false windows_6_1_enabled: false windows_nightly_next_enabled: false @@ -80,6 +82,7 @@ jobs: MATRIX_LINUX_5_10_ENABLED: false MATRIX_LINUX_6_0_ENABLED: false MATRIX_LINUX_6_1_ENABLED: false + MATRIX_LINUX_NIGHTLY_NEXT_ENABLED: false MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false linkage-test: @@ -108,6 +111,7 @@ jobs: MATRIX_LINUX_5_10_ENABLED: false MATRIX_LINUX_6_0_ENABLED: false MATRIX_LINUX_6_1_ENABLED: false + MATRIX_LINUX_NIGHTLY_NEXT_ENABLED: false MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false example-packages: diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 3f38baa..35f66c6 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -26,6 +26,7 @@ jobs: linux_6_0_enabled: false linux_6_1_enabled: false linux_6_2_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_next_enabled: false linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" # Windows is disabled, blocked on Swift Service Lifecycle Windows support: https://github.com/swift-server/swift-service-lifecycle/issues/213 @@ -64,6 +65,7 @@ jobs: linux_5_10_enabled: false linux_6_0_enabled: false linux_6_1_enabled: false + linux_nightly_next_enabled: false windows_6_0_enabled: false windows_6_1_enabled: false windows_nightly_next_enabled: false @@ -87,6 +89,7 @@ jobs: MATRIX_LINUX_5_10_ENABLED: false MATRIX_LINUX_6_0_ENABLED: false MATRIX_LINUX_6_1_ENABLED: false + MATRIX_LINUX_NIGHTLY_NEXT_ENABLED: false MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false linkage-test: @@ -115,6 +118,7 @@ jobs: MATRIX_LINUX_5_10_ENABLED: false MATRIX_LINUX_6_0_ENABLED: false MATRIX_LINUX_6_1_ENABLED: false + MATRIX_LINUX_NIGHTLY_NEXT_ENABLED: false MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false example-packages: From e40735d210ad12ae4748a7129d72d0c99473abbb Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 3 Nov 2025 12:28:11 +0100 Subject: [PATCH 11/19] Property disable nightly-next --- .github/workflows/main.yml | 1 + .github/workflows/pull_request.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 61bb2b3..f8c584b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,6 +19,7 @@ jobs: linux_6_0_enabled: false linux_6_1_enabled: false linux_6_2_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_6_1_enabled: false linux_nightly_next_enabled: false linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 35f66c6..81d9ff5 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -26,6 +26,7 @@ jobs: linux_6_0_enabled: false linux_6_1_enabled: false linux_6_2_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_6_1_enabled: false linux_nightly_next_enabled: false linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" From 78dcf8653da4b3b2f0b14a111936e7a3b090aa3f Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 3 Nov 2025 15:06:45 +0100 Subject: [PATCH 12/19] Bring over the tests for CombineLatestMany --- .../CombineLatestTests.swift | 209 ++++++++++++++++++ Tests/ConfigurationTests/GatedSequence.swift | 132 +++++++++++ 2 files changed, 341 insertions(+) create mode 100644 Tests/ConfigurationTests/CombineLatestTests.swift create mode 100644 Tests/ConfigurationTests/GatedSequence.swift diff --git a/Tests/ConfigurationTests/CombineLatestTests.swift b/Tests/ConfigurationTests/CombineLatestTests.swift new file mode 100644 index 0000000..d101afa --- /dev/null +++ b/Tests/ConfigurationTests/CombineLatestTests.swift @@ -0,0 +1,209 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Async Algorithms open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import XCTest +@testable import Configuration +import AsyncAlgorithms +import Synchronization + +@available(Configuration 1.0, *) +final class TestCombineLatestMany: XCTestCase { + func test_combineLatest() async throws { + let a = [1, 2, 3].async + let b = [4, 5, 6].async + let c = [7, 8, 9].async + let sequence = combineLatestMany([a, b, c]) + let actual = await Array(sequence) + XCTAssertGreaterThanOrEqual(actual.count, 3) + } + + func test_ordering1() async { + var a = GatedSequence([1, 2, 3]) + var b = GatedSequence([4, 5, 6]) + var c = GatedSequence([7, 8, 9]) + let finished = expectation(description: "finished") + let sequence = combineLatestMany([a, b, c]) + let validator = Validator<[Int]>() + validator.test(sequence) { iterator in + let pastEnd = await iterator.next(isolation: nil) + XCTAssertNil(pastEnd) + finished.fulfill() + } + var value = await validator.validate() + XCTAssertEqual(value, []) + a.advance() + value = validator.current + XCTAssertEqual(value, []) + b.advance() + value = validator.current + XCTAssertEqual(value, []) + c.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7]]) + a.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7]]) + b.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7]]) + c.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8]]) + a.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8]]) + b.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8]]) + c.advance() + + value = await validator.validate() + XCTAssertEqual( + value, + [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] + ) + + await fulfillment(of: [finished], timeout: 1.0) + value = validator.current + XCTAssertEqual( + value, + [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] + ) + } +} + +@available(Configuration 1.0, *) +public final class Validator: Sendable { + private enum Ready { + case idle + case ready + case pending(UnsafeContinuation) + } + + private struct State: Sendable { + var collected = [Element]() + var failure: (any Error)? + var ready: Ready = .idle + } + + private struct Envelope: @unchecked Sendable { + var contents: Contents + } + + private let state = Mutex(State()) + + private func ready(_ apply: (inout State) -> Void) { + state.withLock { state -> UnsafeContinuation? in + apply(&state) + switch state.ready { + case .idle: + state.ready = .ready + return nil + case .pending(let continuation): + state.ready = .idle + return continuation + case .ready: + return nil + } + }?.resume() + } + + internal func step() async { + await withUnsafeContinuation { (continuation: UnsafeContinuation) in + state.withLock { state -> UnsafeContinuation? in + switch state.ready { + case .ready: + state.ready = .idle + return continuation + case .idle: + state.ready = .pending(continuation) + return nil + case .pending: + fatalError() + } + }?.resume() + } + } + + let onEvent: (@Sendable (Result) async -> Void)? + + init(onEvent: @Sendable @escaping (Result) async -> Void) { + + self.onEvent = onEvent + } + + public init() { + self.onEvent = nil + } + + public func test( + _ sequence: S, + onFinish: @Sendable @escaping (inout S.AsyncIterator) async -> Void + ) where S.Element == Element { + let envelope = Envelope(contents: sequence) + Task { + var iterator = envelope.contents.makeAsyncIterator() + ready { _ in } + do { + while let item = try await iterator.next() { + await onEvent?(.success(item)) + ready { state in + state.collected.append(item) + } + } + await onEvent?(.success(nil)) + } catch { + await onEvent?(.failure(error)) + ready { state in + state.failure = error + } + } + ready { _ in } + await onFinish(&iterator) + } + } + + public func validate() async -> [Element] { + await step() + return current + } + + public var current: [Element] { + return state.withLock { state in + return state.collected + } + } + + public var failure: (any Error)? { + return state.withLock { state in + return state.failure + } + } +} diff --git a/Tests/ConfigurationTests/GatedSequence.swift b/Tests/ConfigurationTests/GatedSequence.swift new file mode 100644 index 0000000..280b16f --- /dev/null +++ b/Tests/ConfigurationTests/GatedSequence.swift @@ -0,0 +1,132 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Async Algorithms open source project +// +// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import Synchronization + +@available(Configuration 1.0, *) +public struct GatedSequence { + public typealias Failure = Never + let elements: [Element] + let gates: [Gate] + var index = 0 + + public mutating func advance() { + defer { index += 1 } + guard index < gates.count else { + return + } + gates[index].open() + } + + public init(_ elements: [Element]) { + self.elements = elements + self.gates = elements.map { _ in Gate() } + } +} + +@available(*, unavailable) +extension GatedSequence.Iterator: Sendable {} + +@available(Configuration 1.0, *) +extension GatedSequence: AsyncSequence { + public struct Iterator: AsyncIteratorProtocol { + var gatedElements: [(Element, Gate)] + + init(elements: [Element], gates: [Gate]) { + gatedElements = Array(zip(elements, gates)) + } + + public mutating func next() async -> Element? { + guard gatedElements.count > 0 else { + return nil + } + let (element, gate) = gatedElements.removeFirst() + await gate.enter() + return element + } + + public mutating func next(isolation actor: isolated (any Actor)?) async throws(Never) -> Element? { + guard gatedElements.count > 0 else { + return nil + } + let (element, gate) = gatedElements.removeFirst() + await gate.enter() + return element + } + } + + public func makeAsyncIterator() -> Iterator { + Iterator(elements: elements, gates: gates) + } +} + +@available(Configuration 1.0, *) +extension GatedSequence: Sendable where Element: Sendable {} + +@available(Configuration 1.0, *) +public final class Gate: Sendable { + enum State { + case closed + case open + case pending(UnsafeContinuation) + } + + let state = Mutex(State.closed) + + public func `open`() { + state.withLock { state -> UnsafeContinuation? in + switch state { + case .closed: + state = .open + return nil + case .open: + return nil + case .pending(let continuation): + state = .closed + return continuation + } + }?.resume() + } + + public func enter() async { + var other: UnsafeContinuation? + await withUnsafeContinuation { (continuation: UnsafeContinuation) in + state.withLock { state -> UnsafeContinuation? in + switch state { + case .closed: + state = .pending(continuation) + return nil + case .open: + state = .closed + return continuation + case .pending(let existing): + other = existing + state = .pending(continuation) + return nil + } + }?.resume() + } + other?.resume() + } +} From 28f3cb7c3a1fc53bcbd910b6746f86f1f14b82a3 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 3 Nov 2025 15:17:29 +0100 Subject: [PATCH 13/19] Fix docc disambiguations --- .../Reference/ConfigReader-Watch.md | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/Sources/Configuration/Documentation.docc/Reference/ConfigReader-Watch.md b/Sources/Configuration/Documentation.docc/Reference/ConfigReader-Watch.md index 686e2a7..6abb641 100644 --- a/Sources/Configuration/Documentation.docc/Reference/ConfigReader-Watch.md +++ b/Sources/Configuration/Documentation.docc/Reference/ConfigReader-Watch.md @@ -4,47 +4,47 @@ ### Watching string values - ``ConfigReader/watchString(forKey:isSecret:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchString(forKey:as:isSecret:fileID:line:updatesHandler:)-4q1c0`` -- ``ConfigReader/watchString(forKey:as:isSecret:fileID:line:updatesHandler:)-7lki4`` +- ``ConfigReader/watchString(forKey:as:isSecret:fileID:line:updatesHandler:)-7mxw1`` +- ``ConfigReader/watchString(forKey:as:isSecret:fileID:line:updatesHandler:)-818sy`` - ``ConfigReader/watchString(forKey:isSecret:default:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchString(forKey:as:isSecret:default:fileID:line:updatesHandler:)-4x6zt`` -- ``ConfigReader/watchString(forKey:as:isSecret:default:fileID:line:updatesHandler:)-1ncw1`` +- ``ConfigReader/watchString(forKey:as:isSecret:default:fileID:line:updatesHandler:)-6m0yu`` +- ``ConfigReader/watchString(forKey:as:isSecret:default:fileID:line:updatesHandler:)-6dpc3`` - ``ConfigReader/watchString(forKey:context:isSecret:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-1vua5`` -- ``ConfigReader/watchString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-1s8wu`` +- ``ConfigReader/watchString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-34wbx`` +- ``ConfigReader/watchString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-549xr`` - ``ConfigReader/watchString(forKey:context:isSecret:default:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchString(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-3ppdh`` -- ``ConfigReader/watchString(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-80t2z`` +- ``ConfigReader/watchString(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-9u7vf`` +- ``ConfigReader/watchString(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-1ofiv`` ### Watching required string values - ``ConfigReader/watchRequiredString(forKey:isSecret:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchRequiredString(forKey:as:isSecret:fileID:line:updatesHandler:)-29xb0`` -- ``ConfigReader/watchRequiredString(forKey:as:isSecret:fileID:line:updatesHandler:)-3dox3`` +- ``ConfigReader/watchRequiredString(forKey:as:isSecret:fileID:line:updatesHandler:)-86ot1`` +- ``ConfigReader/watchRequiredString(forKey:as:isSecret:fileID:line:updatesHandler:)-3lrs7`` - ``ConfigReader/watchRequiredString(forKey:context:isSecret:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchRequiredString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-6v7w5`` -- ``ConfigReader/watchRequiredString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-76kbb`` +- ``ConfigReader/watchRequiredString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-77978`` +- ``ConfigReader/watchRequiredString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-138o2`` ### Watching lists of string values - ``ConfigReader/watchStringArray(forKey:isSecret:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-5igvu`` -- ``ConfigReader/watchStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-38ruy`` +- ``ConfigReader/watchStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-8t4nb`` +- ``ConfigReader/watchStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-9cmju`` - ``ConfigReader/watchStringArray(forKey:isSecret:default:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchStringArray(forKey:as:isSecret:default:fileID:line:updatesHandler:)-7oi5b`` -- ``ConfigReader/watchStringArray(forKey:as:isSecret:default:fileID:line:updatesHandler:)-4rhx2`` +- ``ConfigReader/watchStringArray(forKey:as:isSecret:default:fileID:line:updatesHandler:)-59de`` +- ``ConfigReader/watchStringArray(forKey:as:isSecret:default:fileID:line:updatesHandler:)-8nsil`` - ``ConfigReader/watchStringArray(forKey:context:isSecret:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-6gaip`` -- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-5dyyx`` +- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-5occx`` +- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-30hf0`` - ``ConfigReader/watchStringArray(forKey:context:isSecret:default:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-7tbs9`` -- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-5yo2r`` +- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-4txm0`` +- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-3eipe`` ### Watching required lists of string values - ``ConfigReader/watchRequiredStringArray(forKey:isSecret:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchRequiredStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-1t82o`` -- ``ConfigReader/watchRequiredStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-7lk1k`` +- ``ConfigReader/watchRequiredStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-3whiy`` +- ``ConfigReader/watchRequiredStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-4zyyq`` - ``ConfigReader/watchRequiredStringArray(forKey:context:isSecret:fileID:line:updatesHandler:)`` -- ``ConfigReader/watchRequiredStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-5zo1e`` -- ``ConfigReader/watchRequiredStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-6kvcj`` +- ``ConfigReader/watchRequiredStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-97r4l`` +- ``ConfigReader/watchRequiredStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-4jcy3`` ### Watching Boolean values - ``ConfigReader/watchBool(forKey:isSecret:fileID:line:updatesHandler:)`` From 6c634ccccbe168e0faae14a84ce6fe46d7447586 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 3 Nov 2025 15:28:29 +0100 Subject: [PATCH 14/19] Fix format --- .../CombineLatestTests.swift | 330 +++++++++--------- Tests/ConfigurationTests/GatedSequence.swift | 164 ++++----- 2 files changed, 251 insertions(+), 243 deletions(-) diff --git a/Tests/ConfigurationTests/CombineLatestTests.swift b/Tests/ConfigurationTests/CombineLatestTests.swift index d101afa..1629539 100644 --- a/Tests/ConfigurationTests/CombineLatestTests.swift +++ b/Tests/ConfigurationTests/CombineLatestTests.swift @@ -22,6 +22,8 @@ // //===----------------------------------------------------------------------===// +// swift-format-ignore-file + import XCTest @testable import Configuration import AsyncAlgorithms @@ -29,181 +31,183 @@ import Synchronization @available(Configuration 1.0, *) final class TestCombineLatestMany: XCTestCase { - func test_combineLatest() async throws { - let a = [1, 2, 3].async - let b = [4, 5, 6].async - let c = [7, 8, 9].async - let sequence = combineLatestMany([a, b, c]) - let actual = await Array(sequence) - XCTAssertGreaterThanOrEqual(actual.count, 3) - } - - func test_ordering1() async { - var a = GatedSequence([1, 2, 3]) - var b = GatedSequence([4, 5, 6]) - var c = GatedSequence([7, 8, 9]) - let finished = expectation(description: "finished") - let sequence = combineLatestMany([a, b, c]) - let validator = Validator<[Int]>() - validator.test(sequence) { iterator in - let pastEnd = await iterator.next(isolation: nil) - XCTAssertNil(pastEnd) - finished.fulfill() + func test_combineLatest() async throws { + let a = [1, 2, 3].async + let b = [4, 5, 6].async + let c = [7, 8, 9].async + let sequence = combineLatestMany([a, b, c]) + let actual = await Array(sequence) + XCTAssertGreaterThanOrEqual(actual.count, 3) + } + + func test_ordering1() async { + var a = GatedSequence([1, 2, 3]) + var b = GatedSequence([4, 5, 6]) + var c = GatedSequence([7, 8, 9]) + let finished = expectation(description: "finished") + let sequence = combineLatestMany([a, b, c]) + let validator = Validator<[Int]>() + validator.test(sequence) { iterator in + let pastEnd = await iterator.next(isolation: nil) + XCTAssertNil(pastEnd) + finished.fulfill() + } + var value = await validator.validate() + XCTAssertEqual(value, []) + a.advance() + value = validator.current + XCTAssertEqual(value, []) + b.advance() + value = validator.current + XCTAssertEqual(value, []) + c.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7]]) + a.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7]]) + b.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7]]) + c.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8]]) + a.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8]]) + b.advance() + + value = await validator.validate() + XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8]]) + c.advance() + + value = await validator.validate() + XCTAssertEqual( + value, + [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] + ) + + await fulfillment(of: [finished], timeout: 1.0) + value = validator.current + XCTAssertEqual( + value, + [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] + ) } - var value = await validator.validate() - XCTAssertEqual(value, []) - a.advance() - value = validator.current - XCTAssertEqual(value, []) - b.advance() - value = validator.current - XCTAssertEqual(value, []) - c.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7]]) - a.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7]]) - b.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7]]) - c.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8]]) - a.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8]]) - b.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8]]) - c.advance() - - value = await validator.validate() - XCTAssertEqual( - value, - [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] - ) - - await fulfillment(of: [finished], timeout: 1.0) - value = validator.current - XCTAssertEqual( - value, - [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] - ) - } } @available(Configuration 1.0, *) public final class Validator: Sendable { - private enum Ready { - case idle - case ready - case pending(UnsafeContinuation) - } - - private struct State: Sendable { - var collected = [Element]() - var failure: (any Error)? - var ready: Ready = .idle - } - - private struct Envelope: @unchecked Sendable { - var contents: Contents - } - - private let state = Mutex(State()) - - private func ready(_ apply: (inout State) -> Void) { - state.withLock { state -> UnsafeContinuation? in - apply(&state) - switch state.ready { - case .idle: - state.ready = .ready - return nil - case .pending(let continuation): - state.ready = .idle - return continuation - case .ready: - return nil - } - }?.resume() - } - - internal func step() async { - await withUnsafeContinuation { (continuation: UnsafeContinuation) in - state.withLock { state -> UnsafeContinuation? in - switch state.ready { - case .ready: - state.ready = .idle - return continuation - case .idle: - state.ready = .pending(continuation) - return nil - case .pending: - fatalError() - } - }?.resume() + private enum Ready { + case idle + case ready + case pending(UnsafeContinuation) + } + + private struct State: Sendable { + var collected: [Element] = [] + var failure: (any Error)? + var ready: Ready = .idle + } + + private struct Envelope: @unchecked Sendable { + var contents: Contents } - } - - let onEvent: (@Sendable (Result) async -> Void)? - - init(onEvent: @Sendable @escaping (Result) async -> Void) { - - self.onEvent = onEvent - } - - public init() { - self.onEvent = nil - } - - public func test( - _ sequence: S, - onFinish: @Sendable @escaping (inout S.AsyncIterator) async -> Void - ) where S.Element == Element { - let envelope = Envelope(contents: sequence) - Task { - var iterator = envelope.contents.makeAsyncIterator() - ready { _ in } - do { - while let item = try await iterator.next() { - await onEvent?(.success(item)) - ready { state in - state.collected.append(item) - } + + private let state = Mutex(State()) + + private func ready(_ apply: (inout State) -> Void) { + state.withLock { state -> UnsafeContinuation? in + apply(&state) + switch state.ready { + case .idle: + state.ready = .ready + return nil + case .pending(let continuation): + state.ready = .idle + return continuation + case .ready: + return nil + } + }? + .resume() + } + + internal func step() async { + await withUnsafeContinuation { (continuation: UnsafeContinuation) in + state.withLock { state -> UnsafeContinuation? in + switch state.ready { + case .ready: + state.ready = .idle + return continuation + case .idle: + state.ready = .pending(continuation) + return nil + case .pending: + fatalError() + } + }? + .resume() } - await onEvent?(.success(nil)) - } catch { - await onEvent?(.failure(error)) - ready { state in - state.failure = error + } + + let onEvent: (@Sendable (Result) async -> Void)? + + init(onEvent: @Sendable @escaping (Result) async -> Void) { + + self.onEvent = onEvent + } + + public init() { + self.onEvent = nil + } + + public func test( + _ sequence: S, + onFinish: @Sendable @escaping (inout S.AsyncIterator) async -> Void + ) where S.Element == Element { + let envelope = Envelope(contents: sequence) + Task { + var iterator = envelope.contents.makeAsyncIterator() + ready { _ in } + do { + while let item = try await iterator.next() { + await onEvent?(.success(item)) + ready { state in + state.collected.append(item) + } + } + await onEvent?(.success(nil)) + } catch { + await onEvent?(.failure(error)) + ready { state in + state.failure = error + } + } + ready { _ in } + await onFinish(&iterator) } - } - ready { _ in } - await onFinish(&iterator) } - } - public func validate() async -> [Element] { - await step() - return current - } + public func validate() async -> [Element] { + await step() + return current + } - public var current: [Element] { - return state.withLock { state in - return state.collected + public var current: [Element] { + state.withLock { state in + state.collected + } } - } - public var failure: (any Error)? { - return state.withLock { state in - return state.failure + public var failure: (any Error)? { + state.withLock { state in + state.failure + } } - } } diff --git a/Tests/ConfigurationTests/GatedSequence.swift b/Tests/ConfigurationTests/GatedSequence.swift index 280b16f..a1331aa 100644 --- a/Tests/ConfigurationTests/GatedSequence.swift +++ b/Tests/ConfigurationTests/GatedSequence.swift @@ -22,27 +22,29 @@ // //===----------------------------------------------------------------------===// +// swift-format-ignore-file + import Synchronization @available(Configuration 1.0, *) public struct GatedSequence { - public typealias Failure = Never - let elements: [Element] - let gates: [Gate] - var index = 0 - - public mutating func advance() { - defer { index += 1 } - guard index < gates.count else { - return + public typealias Failure = Never + let elements: [Element] + let gates: [Gate] + var index = 0 + + public mutating func advance() { + defer { index += 1 } + guard index < gates.count else { + return + } + gates[index].open() } - gates[index].open() - } - public init(_ elements: [Element]) { - self.elements = elements - self.gates = elements.map { _ in Gate() } - } + public init(_ elements: [Element]) { + self.elements = elements + self.gates = elements.map { _ in Gate() } + } } @available(*, unavailable) @@ -50,35 +52,35 @@ extension GatedSequence.Iterator: Sendable {} @available(Configuration 1.0, *) extension GatedSequence: AsyncSequence { - public struct Iterator: AsyncIteratorProtocol { - var gatedElements: [(Element, Gate)] + public struct Iterator: AsyncIteratorProtocol { + var gatedElements: [(Element, Gate)] - init(elements: [Element], gates: [Gate]) { - gatedElements = Array(zip(elements, gates)) - } + init(elements: [Element], gates: [Gate]) { + gatedElements = Array(zip(elements, gates)) + } - public mutating func next() async -> Element? { - guard gatedElements.count > 0 else { - return nil - } - let (element, gate) = gatedElements.removeFirst() - await gate.enter() - return element - } + public mutating func next() async -> Element? { + guard gatedElements.count > 0 else { + return nil + } + let (element, gate) = gatedElements.removeFirst() + await gate.enter() + return element + } - public mutating func next(isolation actor: isolated (any Actor)?) async throws(Never) -> Element? { - guard gatedElements.count > 0 else { - return nil - } - let (element, gate) = gatedElements.removeFirst() - await gate.enter() - return element + public mutating func next(isolation actor: isolated (any Actor)?) async throws(Never) -> Element? { + guard gatedElements.count > 0 else { + return nil + } + let (element, gate) = gatedElements.removeFirst() + await gate.enter() + return element + } } - } - public func makeAsyncIterator() -> Iterator { - Iterator(elements: elements, gates: gates) - } + public func makeAsyncIterator() -> Iterator { + Iterator(elements: elements, gates: gates) + } } @available(Configuration 1.0, *) @@ -86,47 +88,49 @@ extension GatedSequence: Sendable where Element: Sendable {} @available(Configuration 1.0, *) public final class Gate: Sendable { - enum State { - case closed - case open - case pending(UnsafeContinuation) - } - - let state = Mutex(State.closed) - - public func `open`() { - state.withLock { state -> UnsafeContinuation? in - switch state { - case .closed: - state = .open - return nil - case .open: - return nil - case .pending(let continuation): - state = .closed - return continuation - } - }?.resume() - } - - public func enter() async { - var other: UnsafeContinuation? - await withUnsafeContinuation { (continuation: UnsafeContinuation) in - state.withLock { state -> UnsafeContinuation? in - switch state { - case .closed: - state = .pending(continuation) - return nil - case .open: - state = .closed - return continuation - case .pending(let existing): - other = existing - state = .pending(continuation) - return nil + enum State { + case closed + case open + case pending(UnsafeContinuation) + } + + let state = Mutex(State.closed) + + public func `open`() { + state.withLock { state -> UnsafeContinuation? in + switch state { + case .closed: + state = .open + return nil + case .open: + return nil + case .pending(let continuation): + state = .closed + return continuation + } + }? + .resume() + } + + public func enter() async { + var other: UnsafeContinuation? + await withUnsafeContinuation { (continuation: UnsafeContinuation) in + state.withLock { state -> UnsafeContinuation? in + switch state { + case .closed: + state = .pending(continuation) + return nil + case .open: + state = .closed + return continuation + case .pending(let existing): + other = existing + state = .pending(continuation) + return nil + } + }? + .resume() } - }?.resume() + other?.resume() } - other?.resume() - } } From 8dd8d6d5a7f81ff4fe176c5e1284e6882fbc7dbf Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 3 Nov 2025 16:24:00 +0100 Subject: [PATCH 15/19] Improve tests --- .../Configuration/ConfigProviderHelpers.swift | 22 ++-- Sources/Configuration/MultiProvider.swift | 22 ++-- .../CombineLatestTests.swift | 116 +++++++++--------- .../MultiProviderTests.swift | 58 +++++++++ 4 files changed, 143 insertions(+), 75 deletions(-) diff --git a/Sources/Configuration/ConfigProviderHelpers.swift b/Sources/Configuration/ConfigProviderHelpers.swift index 3dbef54..95cb83c 100644 --- a/Sources/Configuration/ConfigProviderHelpers.swift +++ b/Sources/Configuration/ConfigProviderHelpers.swift @@ -42,13 +42,15 @@ extension ConfigProvider { /// - updatesHandler: The closure that processes the async sequence of value updates. /// - Returns: The value returned by the handler closure. /// - Throws: Provider-specific errors or errors thrown by the handler. - public func watchValueFromValue( - forKey key: AbsoluteConfigKey, - type: ConfigType, - updatesHandler: ( - ConfigUpdatesAsyncSequence, Never> + nonisolated(nonsending) + public func watchValueFromValue( + forKey key: AbsoluteConfigKey, + type: ConfigType, + updatesHandler: ( + ConfigUpdatesAsyncSequence, Never> + ) async throws -> Return ) async throws -> Return - ) async throws -> Return { + { let (stream, continuation) = AsyncStream> .makeStream(bufferingPolicy: .bufferingNewest(1)) let initialValue: Result @@ -83,9 +85,11 @@ extension ConfigProvider { /// - Parameter updatesHandler: The closure that processes the async sequence of snapshot updates. /// - Returns: The value returned by the handler closure. /// - Throws: Provider-specific errors or errors thrown by the handler. - public func watchSnapshotFromSnapshot( - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { + nonisolated(nonsending) + public func watchSnapshotFromSnapshot( + updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return + ) async throws -> Return + { let (stream, continuation) = AsyncStream .makeStream(bufferingPolicy: .bufferingNewest(1)) let initialValue = snapshot() diff --git a/Sources/Configuration/MultiProvider.swift b/Sources/Configuration/MultiProvider.swift index 84ca0cd..23bcd0c 100644 --- a/Sources/Configuration/MultiProvider.swift +++ b/Sources/Configuration/MultiProvider.swift @@ -195,9 +195,11 @@ extension MultiProvider { /// - Parameter body: A closure that receives an async sequence of ``MultiSnapshot`` updates. /// - Returns: The value returned by the body closure. /// - Throws: Any error thrown by the nested providers or the body closure. - func watchSnapshot( - _ body: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { + nonisolated(nonsending) + func watchSnapshot( + _ body: (ConfigUpdatesAsyncSequence) async throws -> Return + ) async throws -> Return + { let providers = storage.providers typealias UpdatesSequence = any (AsyncSequence & Sendable) var updateSequences: [UpdatesSequence] = [] @@ -284,13 +286,15 @@ extension MultiProvider { /// - updatesHandler: A closure that receives an async sequence of combined updates from all providers. /// - Throws: Any error thrown by the nested providers or the handler closure. /// - Returns: The value returned by the handler. - func watchValue( - forKey key: AbsoluteConfigKey, - type: ConfigType, - updatesHandler: ( - ConfigUpdatesAsyncSequence<([AccessEvent.ProviderResult], Result), Never> + nonisolated(nonsending) + func watchValue( + forKey key: AbsoluteConfigKey, + type: ConfigType, + updatesHandler: ( + ConfigUpdatesAsyncSequence<([AccessEvent.ProviderResult], Result), Never> + ) async throws -> Return ) async throws -> Return - ) async throws -> Return { + { let providers = storage.providers let providerNames = providers.map(\.providerName) typealias UpdatesSequence = any (AsyncSequence, Never> & Sendable) diff --git a/Tests/ConfigurationTests/CombineLatestTests.swift b/Tests/ConfigurationTests/CombineLatestTests.swift index 1629539..45c85dd 100644 --- a/Tests/ConfigurationTests/CombineLatestTests.swift +++ b/Tests/ConfigurationTests/CombineLatestTests.swift @@ -24,78 +24,80 @@ // swift-format-ignore-file -import XCTest +import Testing @testable import Configuration import AsyncAlgorithms import Synchronization -@available(Configuration 1.0, *) -final class TestCombineLatestMany: XCTestCase { - func test_combineLatest() async throws { +struct TestCombineLatestMany { + @Test + @available(Configuration 1.0, *) + func combineLatest() async throws { let a = [1, 2, 3].async let b = [4, 5, 6].async let c = [7, 8, 9].async let sequence = combineLatestMany([a, b, c]) let actual = await Array(sequence) - XCTAssertGreaterThanOrEqual(actual.count, 3) + #expect(actual.count >= 3) } - func test_ordering1() async { + @Test + @available(Configuration 1.0, *) + func ordering1() async { var a = GatedSequence([1, 2, 3]) var b = GatedSequence([4, 5, 6]) var c = GatedSequence([7, 8, 9]) - let finished = expectation(description: "finished") - let sequence = combineLatestMany([a, b, c]) - let validator = Validator<[Int]>() - validator.test(sequence) { iterator in - let pastEnd = await iterator.next(isolation: nil) - XCTAssertNil(pastEnd) - finished.fulfill() + let value = await confirmation { confirmation in + let sequence = combineLatestMany([a, b, c]) + let validator = Validator<[Int]>() + validator.test(sequence) { iterator in + let pastEnd = await iterator.next(isolation: nil) + #expect(pastEnd == nil) + confirmation.confirm() + } + var value = await validator.validate() + #expect(value == []) + a.advance() + value = validator.current + #expect(value == []) + b.advance() + value = validator.current + #expect(value == []) + c.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7]]) + a.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7], [2, 4, 7]]) + b.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7]]) + c.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8]]) + a.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8]]) + b.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8]]) + c.advance() + + value = await validator.validate() + #expect( + value == + [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] + ) + return validator.current } - var value = await validator.validate() - XCTAssertEqual(value, []) - a.advance() - value = validator.current - XCTAssertEqual(value, []) - b.advance() - value = validator.current - XCTAssertEqual(value, []) - c.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7]]) - a.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7]]) - b.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7]]) - c.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8]]) - a.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8]]) - b.advance() - - value = await validator.validate() - XCTAssertEqual(value, [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8]]) - c.advance() - - value = await validator.validate() - XCTAssertEqual( - value, - [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] - ) - - await fulfillment(of: [finished], timeout: 1.0) - value = validator.current - XCTAssertEqual( - value, + #expect( + value == [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] ) } diff --git a/Tests/ConfigurationTests/MultiProviderTests.swift b/Tests/ConfigurationTests/MultiProviderTests.swift index ae8ee69..ed42d21 100644 --- a/Tests/ConfigurationTests/MultiProviderTests.swift +++ b/Tests/ConfigurationTests/MultiProviderTests.swift @@ -174,6 +174,64 @@ struct MultiProviderTests { #expect(accessReporter.events.count == 3) } + + @available(Configuration 1.0, *) + @Test func watchingTwoUpstreams_handlerReturns() async throws { + let first = InMemoryProvider( + name: "first", + values: [ + "value": "First" + ] + ) + let second = InMemoryProvider( + name: "first", + values: [ + "value": "Second" + ] + ) + let accessReporter = TestAccessReporter() + let config = ConfigReader(providers: [first, second], accessReporter: accessReporter) + + try await config.watchString(forKey: "value", default: "default") { updates in + var iterator = updates.makeAsyncIterator() + let firstValue = try await iterator.next() + #expect(firstValue == "First") + // Return immediately + } + + #expect(accessReporter.events.count == 1) + } + + @available(Configuration 1.0, *) + @Test func watchingTwoUpstreams_handlerThrowsError() async throws { + let first = InMemoryProvider( + name: "first", + values: [ + "value": "First" + ] + ) + let second = InMemoryProvider( + name: "first", + values: [ + "value": "Second" + ] + ) + let accessReporter = TestAccessReporter() + let config = ConfigReader(providers: [first, second], accessReporter: accessReporter) + + struct HandlerError: Error {} + await #expect(throws: HandlerError.self) { + try await config.watchString(forKey: "value", default: "default") { updates in + var iterator = updates.makeAsyncIterator() + let firstValue = try await iterator.next() + #expect(firstValue == "First") + // Throws immediately + throw HandlerError() + } + } + + #expect(accessReporter.events.count == 1) + } } @available(Configuration 1.0, *) From aa3180cba2f5b898a5cfd801373e6953630fc0e0 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 3 Nov 2025 18:32:11 +0100 Subject: [PATCH 16/19] Fix tests --- .../AsyncCombineLatestManySequence.swift | 5 +- .../CombineLatestTests.swift | 100 +++++++++--------- 2 files changed, 55 insertions(+), 50 deletions(-) diff --git a/Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift b/Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift index 4743b7b..75bd7e5 100644 --- a/Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift +++ b/Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift @@ -24,8 +24,9 @@ // Vendored copy of https://github.com/apple/swift-async-algorithms/pull/360 -/// Creates an asynchronous sequence that combines the latest values from many `AsyncSequence` types -/// by emitting a tuple of the values. ``combineLatestMany(_:)`` only emits a value whenever any of the base `AsyncSequence`s +/// Creates an asynchronous sequence that combines the latest values from many async sequences. +/// +/// ``combineLatestMany(_:)`` only emits a value whenever any of the base `AsyncSequence`s /// emit a value (so long as each of the bases have emitted at least one value). /// /// Finishes: diff --git a/Tests/ConfigurationTests/CombineLatestTests.swift b/Tests/ConfigurationTests/CombineLatestTests.swift index 45c85dd..d847179 100644 --- a/Tests/ConfigurationTests/CombineLatestTests.swift +++ b/Tests/ConfigurationTests/CombineLatestTests.swift @@ -28,6 +28,7 @@ import Testing @testable import Configuration import AsyncAlgorithms import Synchronization +import ConfigurationTestingInternal struct TestCombineLatestMany { @Test @@ -47,55 +48,58 @@ struct TestCombineLatestMany { var a = GatedSequence([1, 2, 3]) var b = GatedSequence([4, 5, 6]) var c = GatedSequence([7, 8, 9]) - let value = await confirmation { confirmation in - let sequence = combineLatestMany([a, b, c]) - let validator = Validator<[Int]>() - validator.test(sequence) { iterator in - let pastEnd = await iterator.next(isolation: nil) - #expect(pastEnd == nil) - confirmation.confirm() - } - var value = await validator.validate() - #expect(value == []) - a.advance() - value = validator.current - #expect(value == []) - b.advance() - value = validator.current - #expect(value == []) - c.advance() - - value = await validator.validate() - #expect(value == [[1, 4, 7]]) - a.advance() - - value = await validator.validate() - #expect(value == [[1, 4, 7], [2, 4, 7]]) - b.advance() - - value = await validator.validate() - #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7]]) - c.advance() - - value = await validator.validate() - #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8]]) - a.advance() - - value = await validator.validate() - #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8]]) - b.advance() - - value = await validator.validate() - #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8]]) - c.advance() - - value = await validator.validate() - #expect( - value == - [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] - ) - return validator.current + + let completion = TestFuture() + let sequence = combineLatestMany([a, b, c]) + let validator = Validator<[Int]>() + validator.test(sequence) { iterator in + let pastEnd = await iterator.next(isolation: nil) + #expect(pastEnd == nil) + completion.fulfill(()) } + var value = await validator.validate() + #expect(value == []) + a.advance() + value = validator.current + #expect(value == []) + b.advance() + value = validator.current + #expect(value == []) + c.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7]]) + a.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7], [2, 4, 7]]) + b.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7]]) + c.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8]]) + a.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8]]) + b.advance() + + value = await validator.validate() + #expect(value == [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8]]) + c.advance() + + value = await validator.validate() + #expect( + value == + [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] + ) + + await completion.value + + value = validator.current #expect( value == [[1, 4, 7], [2, 4, 7], [2, 5, 7], [2, 5, 8], [3, 5, 8], [3, 6, 8], [3, 6, 9]] From cc37e993a6ccfcab71680a766b96e5a0c824e2d2 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 4 Nov 2025 12:28:19 +0100 Subject: [PATCH 17/19] Fix a bug in combineLatestMany that was causing hangs --- .../AsyncCombineLatestManySequence.swift | 2 - .../CombineLatestManyStateMachine.swift | 3 +- .../TestFuture.swift | 71 +++++++++++-------- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift b/Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift index 75bd7e5..8ee9185 100644 --- a/Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift +++ b/Sources/Configuration/Utilities/AsyncAlgos/AsyncCombineLatestManySequence.swift @@ -82,8 +82,6 @@ internal struct AsyncCombineLatestManySequence: Sendabl // One of the upstreams finished. self.state = .modifying - upstreams[0].isFinished = true + upstreams[baseIndex].isFinished = true if upstreams.allSatisfy(\.isFinished) { // All upstreams finished we can transition to either finished or upstreamsFinished now @@ -358,7 +358,6 @@ struct CombineLatestManyStateMachine: Sendabl let emptyUpstreamFinished = upstreams[baseIndex].element == nil upstreams[baseIndex].isFinished = true - // Implementing this for the two arities without variadic generics is a bit awkward sadly. if emptyUpstreamFinished { // All upstreams finished self.state = .finished diff --git a/Sources/ConfigurationTestingInternal/TestFuture.swift b/Sources/ConfigurationTestingInternal/TestFuture.swift index f1e7dd0..c526465 100644 --- a/Sources/ConfigurationTestingInternal/TestFuture.swift +++ b/Sources/ConfigurationTestingInternal/TestFuture.swift @@ -12,8 +12,7 @@ // //===----------------------------------------------------------------------===// -// Needs full Foundation, NSLock is not available in FoundationEssentials. -import Foundation +import Synchronization /// A future implementation for testing asynchronous operations. /// @@ -38,7 +37,7 @@ import Foundation /// let result = await future.value /// ``` @available(Configuration 1.0, *) -package final class TestFuture: @unchecked Sendable /* lock + locked_state */ { +package final class TestFuture: @unchecked Sendable /* mutex */ { /// The internal state of the future. private enum State { @@ -48,11 +47,8 @@ package final class TestFuture: @unchecked Sendable /* lock + locke case fulfilled(T) } - /// Synchronizes access to the internal state. - private let lock: NSLock - /// The current state of the future. - private var locked_state: State + private let state: Mutex /// Optional name for debugging and logging purposes. private let name: String? @@ -82,9 +78,7 @@ package final class TestFuture: @unchecked Sendable /* lock + locke self.verbose = verbose self.file = file self.line = line - self.lock = NSLock() - self.lock.name = "TestFuture.lock" - self.locked_state = .waitingForFulfillment([]) + self.state = .init(.waitingForFulfillment([])) } /// Fulfills the future with the provided value. @@ -100,19 +94,27 @@ package final class TestFuture: @unchecked Sendable /* lock + locke if verbose { print("Fulfilling \(name ?? "unnamed") at \(file):\(line) with \(value)") } - let continuations: [CheckedContinuation] - lock.lock() - switch locked_state { - case .fulfilled: - fatalError("Fulfilled \(name ?? "unnamed") at \(file):\(line) twice") - case .waitingForFulfillment(let _continuations): - locked_state = .fulfilled(value) - continuations = _continuations + let continuations: [CheckedContinuation] = state.withLock { state in + switch state { + case .fulfilled: + fatalError("Fulfilled \(name ?? "unnamed") at \(file):\(line) twice") + case .waitingForFulfillment(let continuations): + if verbose { + print("Found \(continuations.count) waiting continuations for \(name ?? "unnamed")") + } + state = .fulfilled(value) + return continuations + } + } + if verbose { + print("Resuming \(continuations.count) continuations for \(name ?? "unnamed")") } - lock.unlock() for continuation in continuations { continuation.resume(returning: value) } + if verbose { + print("All continuations resumed for \(name ?? "unnamed")") + } } /// A result of getting the value from the internal storage. @@ -137,21 +139,32 @@ package final class TestFuture: @unchecked Sendable /* lock + locke print("Getting value from \(name ?? "unnamed") at \(file):\(line)") } return await withCheckedContinuation { continuation in - let result: GetValueResult - lock.lock() - switch locked_state { - case .fulfilled(let value): - result = .returnValue(value) - case .waitingForFulfillment(var continuations): - continuations.append(continuation) - locked_state = .waitingForFulfillment(continuations) - result = .appendedContinuation + let result: GetValueResult = state.withLock { state in + switch state { + case .fulfilled(let value): + if verbose { + print("\(name ?? "unnamed") already fulfilled, returning immediately") + } + return .returnValue(value) + case .waitingForFulfillment(var continuations): + if verbose { + print("\(name ?? "unnamed") not fulfilled, adding continuation (total: \(continuations.count + 1))") + } + continuations.append(continuation) + state = .waitingForFulfillment(continuations) + return .appendedContinuation + } } - lock.unlock() switch result { case .appendedContinuation: + if verbose { + print("\(name ?? "unnamed") continuation stored, waiting for fulfill") + } break case .returnValue(let value): + if verbose { + print("\(name ?? "unnamed") resuming continuation immediately with \(value)") + } continuation.resume(returning: value) } } From 800e58fef390ee01302060aa39c4a2ff4ec62b3e Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 4 Nov 2025 12:29:44 +0100 Subject: [PATCH 18/19] Formatting fix --- Sources/ConfigurationTestingInternal/TestFuture.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/ConfigurationTestingInternal/TestFuture.swift b/Sources/ConfigurationTestingInternal/TestFuture.swift index c526465..55741c4 100644 --- a/Sources/ConfigurationTestingInternal/TestFuture.swift +++ b/Sources/ConfigurationTestingInternal/TestFuture.swift @@ -148,7 +148,9 @@ package final class TestFuture: @unchecked Sendable /* mutex */ { return .returnValue(value) case .waitingForFulfillment(var continuations): if verbose { - print("\(name ?? "unnamed") not fulfilled, adding continuation (total: \(continuations.count + 1))") + print( + "\(name ?? "unnamed") not fulfilled, adding continuation (total: \(continuations.count + 1))" + ) } continuations.append(continuation) state = .waitingForFulfillment(continuations) From a88f20f2d7ebff6482514ad853b76ce03ad471ac Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 4 Nov 2025 13:26:30 +0100 Subject: [PATCH 19/19] Make Validator closure sending --- Tests/ConfigurationTests/CombineLatestTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ConfigurationTests/CombineLatestTests.swift b/Tests/ConfigurationTests/CombineLatestTests.swift index d847179..5ffe327 100644 --- a/Tests/ConfigurationTests/CombineLatestTests.swift +++ b/Tests/ConfigurationTests/CombineLatestTests.swift @@ -175,7 +175,7 @@ public final class Validator: Sendable { public func test( _ sequence: S, - onFinish: @Sendable @escaping (inout S.AsyncIterator) async -> Void + onFinish: sending @escaping (inout S.AsyncIterator) async -> Void ) where S.Element == Element { let envelope = Envelope(contents: sequence) Task {