diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2840bef..e3451cc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,12 +1,14 @@ # NuGet Publishing -Packages are published via GitHub Actions triggered by git tags. Each component workflow (e.g. `mevd.yml`) owns its build, test, and pack steps; on tag pushes it calls the generic `sign-and-publish.yml` reusable workflow to sign and publish to NuGet.org. +Packages are published via GitHub Actions triggered by git tags. Each component workflow (e.g. `mevd.yml` or `document-extraction.yml`) owns its build, test, and pack steps; on tag pushes it calls the generic `release.yml` reusable workflow to sign and publish to NuGet.org. ## Tag format - `/v` — release all packages in a component (e.g. `mevd/v1.0.0-preview.1`) - `//v` — release a single package (e.g. `mevd/PgVector/v1.0.0-preview.1`) +DocumentExtraction uses the `document-extraction` tag prefix (e.g. `document-extraction/PdfPig/v1.0.0-preview.1`). + The subcomponent name must match the directory name under the component's `src/` folder exactly (e.g. `PgVector`, `Qdrant`, `InMemory`). Preview/prerelease versions use standard NuGet prerelease suffixes in the tag (e.g. `v1.0.0-preview.1`). @@ -15,15 +17,15 @@ Preview/prerelease versions use standard NuGet prerelease suffixes in the tag (e When changing C# files under `MEVD/src//`, also update the `` in the provider's `.csproj`. Choose the appropriate SemVer increment for the change. Changes limited to tests, documentation, shared code, or central build and dependency files do not require a provider version update. -Release tags must exactly match project versions. A single-provider tag must match that provider's version, while a component-wide `mevd/v` tag requires every MEVD provider to have that version. +Release tags must exactly match project versions. A single-provider tag must match that provider's version, while a component-wide `/v` tag requires every provider in that component to have that version. ## Release flow -Pushing a matching tag triggers the component workflow, which runs the full test suite, packs NuGet packages, then hands them to `sign-and-publish.yml` for signing (Azure Key Vault) → GitHub Release → NuGet.org (gated by manual approval on the `nuget-release-gate` environment). +Pushing a matching tag triggers the component workflow, which runs the full test suite, packs NuGet packages, then hands them to `release.yml` for signing (Azure Key Vault) → GitHub Release → NuGet.org (gated by manual approval on the `nuget-release-gate` environment). ## Adding a new component Create a `.yml` workflow that: 1. Triggers on its own tag prefix (e.g. `mycomp/v*`) 2. Handles all component-specific build and test logic -3. On tag pushes, calls `package.yml` then `sign-and-publish.yml` +3. On tag pushes, packs NuGet packages and calls `release.yml` diff --git a/.github/skills/publish-providers/SKILL.md b/.github/skills/publish-providers/SKILL.md index 32ec137..2174994 100644 --- a/.github/skills/publish-providers/SKILL.md +++ b/.github/skills/publish-providers/SKILL.md @@ -1,11 +1,17 @@ --- name: publish-providers -description: Publish one or more MEVD providers when the user explicitly requests a release, for example "publish Chroma and PgVector". Never publish when merely discussing or editing this skill. +description: Publish one or more MEVD or DocumentExtraction providers when the user explicitly requests a release, for example "publish Chroma and PgVector" or "publish PdfPig and Mistral". Never publish when merely discussing or editing this skill. --- 1. Require explicitly named providers; never infer additional releases. Verify `origin` points to `CommunityToolkit/AI`. 2. Fetch `origin main --tags` and pin the release commit to `origin/main`. Do not switch branches, commit changes, or bump versions. -3. At that commit, resolve each provider to its exact directory name under `MEVD/src`. Read its literal `` from its `.csproj`; form `mevd//v`. Stop on unknown providers or ambiguous versions. +3. At that commit, resolve each provider to its exact directory name using the component table below. Read its literal `` from its `.csproj`; form `//v`. Stop on unknown or ambiguous providers or versions; require a component name if a provider exists in both components. + + | Component | Provider directories | Tag prefix | Workflow | + | --- | --- | --- | --- | + | MEVD | `MEVD/src` | `mevd` | `mevd.yml` | + | DocumentExtraction | `DocumentExtraction/src` | `document-extraction` | `document-extraction.yml` | + 4. Preflight every requested tag locally and remotely before making changes. Stop on any tag pointing to another commit; skip remote tags already targeting the release commit. Reuse matching local tags. 5. For each remaining tag, create it at the pinned commit if missing, then run `git push origin refs/tags/`. Push tags individually so each triggers GitHub Actions. Never force-push, push branches, or use `--tags`. Stop on failure and report any tags already pushed. 6. Verify the remote tag targets and report the provider/version, tag, and workflow link. Distinguish a triggered release from a published package: NuGet publishing still requires approval of `nuget-release-gate`. diff --git a/.github/workflows/document-extraction.yml b/.github/workflows/document-extraction.yml index a2d5b3f..183cce2 100644 --- a/.github/workflows/document-extraction.yml +++ b/.github/workflows/document-extraction.yml @@ -3,6 +3,9 @@ name: Document extraction on: push: branches: [main] + tags: + - "document-extraction/v*" + - "document-extraction/*/v*" paths: - 'DocumentExtraction/**' - 'Directory.Build.props' @@ -10,11 +13,8 @@ on: - 'Directory.Packages.props' - 'CommunityToolkit.AI.slnx' - 'global.json' - - 'eng/Test-DocumentExtractionPackages.ps1' - - 'eng/DocumentExtraction.PackageSmoke/**' - 'eng/LegacySupport.props' - 'Shared/**' - - 'nuget.config' - '.github/workflows/document-extraction.yml' pull_request: branches: [main, 'copilot/*'] @@ -25,13 +25,9 @@ on: - 'Directory.Packages.props' - 'CommunityToolkit.AI.slnx' - 'global.json' - - 'eng/Test-DocumentExtractionPackages.ps1' - - 'eng/DocumentExtraction.PackageSmoke/**' - 'eng/LegacySupport.props' - 'Shared/**' - - 'nuget.config' - '.github/workflows/document-extraction.yml' - workflow_dispatch: permissions: contents: read @@ -41,8 +37,8 @@ env: DOTNET_CLI_TELEMETRY_OPTOUT: true jobs: - build-test-pack: - name: Build, test and pack (${{ matrix.os }}) + build-and-test: + name: Build and test (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -59,17 +55,67 @@ jobs: run: dotnet build DocumentExtraction/DocumentExtraction.slnf --no-restore -c Release - name: Test run: dotnet test DocumentExtraction/DocumentExtraction.slnf --no-build --no-restore -c Release - - name: Restore portable test dependency closure - run: dotnet restore DocumentExtraction/DocumentExtraction.slnf --configfile DocumentExtraction/nuget.config -p:DocumentExtractionTestAsset=netstandard2.0 - - name: Test using actual netstandard assets - run: dotnet test DocumentExtraction/DocumentExtraction.slnf --no-restore -c Release -p:DocumentExtractionTestAsset=netstandard2.0 - - name: Pack preview artifacts - run: dotnet pack DocumentExtraction/DocumentExtraction.slnf --no-build --no-restore -c Release --output artifacts/document-extraction - - name: Verify packaged assets and experimental opt-in - shell: pwsh - run: ./eng/Test-DocumentExtractionPackages.ps1 -PackageDirectory artifacts/document-extraction -OutputDirectory "${{ runner.temp }}/document-extraction-package-checks" - - uses: actions/upload-artifact@v6 + + package: + name: Package + if: startsWith(github.ref, 'refs/tags/') + needs: build-and-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 with: - name: document-extraction-preview-packages-${{ matrix.os }} - path: artifacts/document-extraction/*.nupkg - if-no-files-found: error \ No newline at end of file + global-json-file: global.json + + - name: Parse release tag + id: tag + run: | + rest="${GITHUB_REF_NAME#document-extraction/}" + if [[ "$rest" == v* ]]; then + version="${rest#v}" + echo "pack_target=DocumentExtraction/DocumentExtraction.slnf" >> "$GITHUB_OUTPUT" + else + provider="${rest%%/v*}" + version="${rest#*/v}" + echo "pack_target=DocumentExtraction/src/$provider/$provider.csproj" >> "$GITHUB_OUTPUT" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Restore + run: dotnet restore DocumentExtraction/DocumentExtraction.slnf --configfile DocumentExtraction/nuget.config + + - name: Pack NuGet packages + env: + PACK_TARGET: ${{ steps.tag.outputs.pack_target }} + PACKAGE_VERSION: ${{ steps.tag.outputs.version }} + run: > + dotnet pack "$PACK_TARGET" + --no-restore + -c Release + -o ./nuget + -p:PackageVersion="$PACKAGE_VERSION" + -p:CI=true + + - name: Upload NuGet packages + uses: actions/upload-artifact@v6 + with: + name: nuget-packages + path: ./nuget + if-no-files-found: error + + - name: Upload signing file list + uses: actions/upload-artifact@v6 + with: + name: nuget-list + path: ${{ github.workspace }}/.github/workflows/SignClientFileList.txt + if-no-files-found: error + + release: + name: Release + if: startsWith(github.ref, 'refs/tags/') + needs: package + permissions: + contents: write + id-token: write + uses: ./.github/workflows/release.yml + secrets: inherit \ No newline at end of file diff --git a/DocumentExtraction/README.md b/DocumentExtraction/README.md index eab1ddb..ca02cab 100644 --- a/DocumentExtraction/README.md +++ b/DocumentExtraction/README.md @@ -39,9 +39,7 @@ dependencies come from the public `dotnet-libraries` feed, not NuGet.org. The .NET Standard 2.0 asset makes the managed provider APIs available to compatible applications; it is not a new .NET Framework 4.6.2 target or a certification of -every legacy host, backend SDK or native dependency. Tests run on .NET 10, -including the full offline suites explicitly loading the netstandard provider -assemblies. A separate package consumer verifies the actual packed assets. +every legacy host, backend SDK or native dependency. Tests run on .NET 10. Use a supported application runtime and check each backend's platform policy. .NET 8 is not a supported runtime and no dedicated `net8.0` asset is shipped. An existing .NET 8 app may resolve the portable asset, but that is technical @@ -233,23 +231,21 @@ or retries unsupported structured results through another inference request. ## Building from source -From the repository root with PowerShell 7 and the SDK from `global.json`: +From the repository root with the SDK from `global.json`: ```powershell dotnet restore DocumentExtraction/DocumentExtraction.slnf --configfile DocumentExtraction/nuget.config dotnet build DocumentExtraction/DocumentExtraction.slnf -c Release --no-restore dotnet test DocumentExtraction/DocumentExtraction.slnf -c Release --no-build --no-restore -dotnet restore DocumentExtraction/DocumentExtraction.slnf --configfile DocumentExtraction/nuget.config -p:DocumentExtractionTestAsset=netstandard2.0 -dotnet test DocumentExtraction/DocumentExtraction.slnf -c Release --no-restore -p:DocumentExtractionTestAsset=netstandard2.0 -dotnet pack DocumentExtraction/DocumentExtraction.slnf -c Release --no-build --no-restore --output artifacts/document-extraction -./eng/Test-DocumentExtractionPackages.ps1 -PackageDirectory artifacts/document-extraction -OutputDirectory artifacts/document-extraction-package-checks ``` -The second test run loads the actual `netstandard2.0` provider assemblies; restore -its profile before running it. The package check requires a new output directory -and restores into its own package cache. It verifies consumer diagnostics without -opt-in, then compiles and executes opted-in consumers against the packed modern -and `netstandard2.0` assets. +## Publishing + +Like MEVD, releases are triggered by tags: `document-extraction//v` +for one provider or `document-extraction/v` for all providers. Tag versions +must match the corresponding project versions. The workflow builds and tests, +packs the selected providers, then uses the shared signing and publishing workflow. +Publishing to NuGet.org requires approval of `nuget-release-gate`. ## Work with the result diff --git a/DocumentExtraction/test/Directory.Build.props b/DocumentExtraction/test/Directory.Build.props index d51337a..b35771a 100644 --- a/DocumentExtraction/test/Directory.Build.props +++ b/DocumentExtraction/test/Directory.Build.props @@ -5,19 +5,11 @@ net10.0 Exe $(NoWarn);CS1591;xUnit1051;EXTEXP0019;MEDE0001;CTDE0001 - $(DefineConstants);STANDARD_PROVIDER_ASSET - - - - TargetFramework=netstandard2.0 - true - - diff --git a/DocumentExtraction/test/Mistral.UnitTests/MistralDocumentExtractionClientTests.cs b/DocumentExtraction/test/Mistral.UnitTests/MistralDocumentExtractionClientTests.cs index 28cb27c..a0b67e6 100644 --- a/DocumentExtraction/test/Mistral.UnitTests/MistralDocumentExtractionClientTests.cs +++ b/DocumentExtraction/test/Mistral.UnitTests/MistralDocumentExtractionClientTests.cs @@ -588,11 +588,7 @@ public async Task ExtractAsync_Five503Responses_StopsAndDisposesAllAttempts() } HttpRequestException error = await Assert.ThrowsAsync(() => Extract(session, RequestedOptions())); -#if STANDARD_PROVIDER_ASSET - Assert.Null(error.StatusCode); -#else Assert.Equal(HttpStatusCode.ServiceUnavailable, error.StatusCode); -#endif Assert.Contains("503", error.Message); Assert.Equal([3d, 6d, 9d, 12d], session.Delays.Select(d => d.TotalSeconds)); Assert.All(session.Handler.Requests, AssertRequest); @@ -610,11 +606,7 @@ public async Task ExtractAsync_Non503Failure_DoesNotRetry(HttpStatusCode status) { using var session = new OfflineSession(Encoding.UTF8.GetBytes("failure"), status); HttpRequestException error = await Assert.ThrowsAsync(() => Extract(session)); -#if STANDARD_PROVIDER_ASSET - Assert.Null(error.StatusCode); -#else Assert.Equal(status, error.StatusCode); -#endif Assert.Contains(((int)status).ToString(System.Globalization.CultureInfo.InvariantCulture), error.Message); Assert.Empty(session.Delays); await session.AssertCleanAndBorrowedAsync(1, 1); diff --git a/DocumentExtraction/test/Mistral.UnitTests/MistralDualHostTests.cs b/DocumentExtraction/test/Mistral.UnitTests/MistralDualHostTests.cs index 57cc232..04ed5e6 100644 --- a/DocumentExtraction/test/Mistral.UnitTests/MistralDualHostTests.cs +++ b/DocumentExtraction/test/Mistral.UnitTests/MistralDualHostTests.cs @@ -144,11 +144,7 @@ public async Task ExtractAsync_AuthenticationFailure_DoesNotTryAnotherHostOrCred using var session = new OfflineSession(status: HttpStatusCode.Unauthorized, ocrEndpoint: endpoint, useApiKey: useApiKey); HttpRequestException error = await Assert.ThrowsAsync(() => Extract(session)); -#if STANDARD_PROVIDER_ASSET - Assert.Null(error.StatusCode); -#else Assert.Equal(HttpStatusCode.Unauthorized, error.StatusCode); -#endif Assert.Contains("401", error.Message); Assert.Equal(endpoint, Assert.Single(session.Handler.Requests).Message.RequestUri); Assert.Empty(session.Delays); diff --git a/DocumentExtraction/test/ProviderAssetTests.cs b/DocumentExtraction/test/ProviderAssetTests.cs deleted file mode 100644 index 4f3fbd4..0000000 --- a/DocumentExtraction/test/ProviderAssetTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Reflection; -using System.Runtime.Versioning; - -namespace CommunityToolkit.DocumentExtraction.Tests; - -public sealed class ProviderAssetTests -{ - [Fact] - public void ProviderAsset_HasExpectedTargetAndExperimentalContract() - { - Assembly testAssembly = typeof(ProviderAssetTests).Assembly; - string providerName = testAssembly.GetName().Name!.Replace(".UnitTests", "", StringComparison.Ordinal); - Assembly provider = Assembly.Load(providerName); -#if STANDARD_PROVIDER_ASSET - const string expectedFramework = ".NETStandard,Version=v2.0"; -#else - string expectedFramework = testAssembly.GetCustomAttribute()!.FrameworkName; -#endif - Assert.Equal(expectedFramework, provider.GetCustomAttribute()!.FrameworkName); - CustomAttributeData marker = Assert.Single(provider.GetCustomAttributesData(), - attribute => attribute.AttributeType.FullName == "System.Diagnostics.CodeAnalysis.ExperimentalAttribute"); - Assert.Equal("CTDE0001", Assert.Single(marker.ConstructorArguments).Value); - Assert.Equal("https://github.com/CommunityToolkit/AI/blob/main/DocumentExtraction/README.md#experimental-api-diagnostics", - Assert.Single(marker.NamedArguments, argument => argument.MemberName == "UrlFormat").TypedValue.Value); - } -} diff --git a/eng/DocumentExtraction.PackageSmoke/DocumentExtraction.PackageSmoke.csproj b/eng/DocumentExtraction.PackageSmoke/DocumentExtraction.PackageSmoke.csproj deleted file mode 100644 index fb7d758..0000000 --- a/eng/DocumentExtraction.PackageSmoke/DocumentExtraction.PackageSmoke.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - Exe - net10.0 - false - $(NoWarn);EXTEXP0019;MEDE0001 - $(NoWarn);CTDE0001 - $(TargetFramework) - - - - - - - - - - - - - - - - - - - - - diff --git a/eng/DocumentExtraction.PackageSmoke/Program.cs b/eng/DocumentExtraction.PackageSmoke/Program.cs deleted file mode 100644 index c9c49bf..0000000 --- a/eng/DocumentExtraction.PackageSmoke/Program.cs +++ /dev/null @@ -1,154 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Net; -using System.Reflection; -using System.Runtime.Versioning; -using System.Security.Cryptography; -using Azure; -using Azure.AI.ContentUnderstanding; -using Azure.AI.DocumentIntelligence; -using CommunityToolkit.DocumentExtraction.AzureContentUnderstanding; -using CommunityToolkit.DocumentExtraction.AzureDocumentIntelligence; -using CommunityToolkit.DocumentExtraction.Mistral; -using CommunityToolkit.DocumentExtraction.PdfPig; -using CommunityToolkit.DocumentExtraction.PdfPig.UnitTests; -using CommunityToolkit.DocumentExtraction.VisionLanguageModel; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DocumentExtraction; -using Microsoft.Extensions.Documents; - -if (args.Length != 2) -{ - throw new ArgumentException("Supply the expected asset TFM and an output receipt path."); -} - -Type[] types = [typeof(PdfPigDocumentExtractionClient), typeof(ContentUnderstandingExtractionClient), - typeof(DocumentIntelligenceExtractionClient), typeof(MistralDocumentExtractionClient), typeof(VisionLanguageModelDocumentExtractionClient)]; -string expectedFramework = args[0] switch -{ - "netstandard2.0" => ".NETStandard,Version=v2.0", - "net10.0" => ".NETCoreApp,Version=v10.0", - _ => throw new ArgumentException("Expected a net10.0 or netstandard2.0 provider asset."), -}; -var assemblies = new List(); -foreach (Type type in types) -{ - Assembly assembly = type.Assembly; - if (!typeof(DocumentExtractionClient).IsAssignableFrom(type) || - assembly.GetCustomAttribute()?.FrameworkName != expectedFramework) - { - throw new InvalidDataException($"Wrong provider contract or asset: {type}."); - } - - CustomAttributeData marker = assembly.GetCustomAttributesData().Single(attribute => - attribute.AttributeType.FullName == "System.Diagnostics.CodeAnalysis.ExperimentalAttribute"); - if (!Equals(marker.ConstructorArguments.Single().Value, "CTDE0001") || - !Equals(marker.NamedArguments.Single(argument => argument.MemberName == "UrlFormat").TypedValue.Value, - "https://github.com/CommunityToolkit/AI/blob/main/DocumentExtraction/README.md#experimental-api-diagnostics")) - { - throw new InvalidDataException($"Wrong experimental metadata: {type}."); - } - - bool portable = args[0] == "netstandard2.0"; - if ((marker.AttributeType.Assembly == assembly) != portable || - portable && marker.AttributeType.IsVisible || - !assembly.GetCustomAttributes() - .Any(attribute => attribute.AssemblyName == assembly.GetName().Name + ".UnitTests")) - { - throw new InvalidDataException($"Wrong legacy shim visibility or friend assembly: {type}."); - } - - assemblies.Add(new { type = type.FullName, path = assembly.Location, - sha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(assembly.Location))), - target = expectedFramework, diagnostic = "CTDE0001", - internalExperimentalShim = portable, friendAssemblyVerified = true }); -} - -foreach (string name in new[] { "OcrEndpoint", "DefaultModel" }) -{ - PropertyInfo property = typeof(MistralDocumentExtractionClientOptions).GetProperty(name)!; - if (!property.GetCustomAttributesData().Any(attribute => - attribute.AttributeType.FullName == "System.Runtime.CompilerServices.RequiredMemberAttribute") || - !property.SetMethod!.ReturnParameter.GetRequiredCustomModifiers().Any(modifier => - modifier.FullName == "System.Runtime.CompilerServices.IsExternalInit")) - { - throw new InvalidDataException($"Mistral {name} lost its required/init contract."); - } -} - -// Construct the service adapters without sending a service request. -var contentUnderstandingClient = new ContentUnderstandingClient(new Uri("https://offline.invalid"), new AzureKeyCredential("offline")); -using DocumentExtractionClient cu = new ContentUnderstandingExtractionClient(contentUnderstandingClient); -var documentIntelligenceClient = new DocumentIntelligenceClient(new Uri("https://offline.invalid"), new AzureKeyCredential("offline")); -using DocumentExtractionClient di = new DocumentIntelligenceExtractionClient(documentIntelligenceClient); -using DocumentExtractionClient pdf = new PdfPigDocumentExtractionClient(); -using var input = new MemoryStream(PdfFixture.Create()); -ExtractionDocument document = await pdf.ExtractAsync(input, "invoice", "application/pdf"); -if (!input.CanRead || document.SourcePages.Count != 3 || - !document.EnumerateContent().Any(element => element.Content is TextContent text && text.Text.Contains("Native invoice 42", StringComparison.Ordinal))) -{ - throw new InvalidDataException("Native PDF extraction or borrowed-input behavior failed."); -} - -using var httpClient = new HttpClient(new OfflineHandler()); -using DocumentExtractionClient mistral = new MistralDocumentExtractionClient( - new MistralDocumentExtractionClientOptions - { - OcrEndpoint = new Uri("https://api.mistral.ai/v1/ocr"), - DefaultModel = "mistral-ocr-latest", - }, "offline-key", httpClient); -input.Position = 0; -ExtractionDocument mistralResult = await mistral.ExtractAsync(input, "invoice", "application/pdf"); -if (!mistralResult.EnumerateContent().Any(element => element.Content is TextContent text && text.Text == "offline invoice")) -{ - throw new InvalidDataException("Offline Mistral mapping failed."); -} - -using var chatClient = new OfflineChatClient(); -using DocumentExtractionClient vision = new VisionLanguageModelDocumentExtractionClient(chatClient); -using DocumentExtractionClient structuredVision = new VisionLanguageModelDocumentExtractionClient( - chatClient, new VisionLanguageModelDocumentExtractionClientOptions { UseStructuredOutput = true }); -input.Position = 0; -ExtractionDocument visionResult = await vision.ExtractAsync(input, "invoice", "application/pdf"); -if (!visionResult.EnumerateContent().Any(element => element.Content is TextContent text && text.Text == "offline invoice")) -{ - throw new InvalidDataException("Offline Vision mapping failed."); -} - -File.WriteAllText(args[1], System.Text.Json.JsonSerializer.Serialize(new -{ - runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, - providerAsset = args[0], assemblies, pdfPages = document.SourcePages.Count, - fiveConstructorsCompiledAndExecuted = true, mistralRequiredInitContract = true, - liveServiceCalls = 0, modelInferenceCalls = 0, -}, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); -Console.WriteLine($"Actual {args[0]} provider packages: constructors, metadata, native PDF and offline Mistral/Vision passed."); - -internal sealed class OfflineHandler : HttpMessageHandler -{ - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("""{"pages":[{"index":0,"markdown":"offline invoice"}]}"""), - }); - } -} - -internal sealed class OfflineChatClient : IChatClient -{ - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "offline invoice"))); - } - - public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException("The offline fixture supports only non-streaming extraction."); - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } -} diff --git a/eng/Test-DocumentExtractionPackages.ps1 b/eng/Test-DocumentExtractionPackages.ps1 deleted file mode 100644 index 0c4bf91..0000000 --- a/eng/Test-DocumentExtractionPackages.ps1 +++ /dev/null @@ -1,116 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] - [string] $PackageDirectory, - [Parameter(Mandatory)] - [string] $OutputDirectory, - [string] $NuGetConfig = (Join-Path $PSScriptRoot '../DocumentExtraction/nuget.config'), - [string] $ConsumerProject = (Join-Path $PSScriptRoot 'DocumentExtraction.PackageSmoke/DocumentExtraction.PackageSmoke.csproj') -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest -$PackageDirectory = [IO.Path]::GetFullPath($PackageDirectory) -$NuGetConfig = [IO.Path]::GetFullPath($NuGetConfig) -$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) -$ConsumerProject = [IO.Path]::GetFullPath($ConsumerProject) -if (Test-Path $OutputDirectory) { - throw "Use a new output directory; existing evidence will not be overwritten: $OutputDirectory" -} -$archives = @(Get-ChildItem -LiteralPath $PackageDirectory -Filter 'CommunityToolkit.DocumentExtraction.*.nupkg') -if ($archives.Count -eq 0) { - throw "No extraction packages found in $PackageDirectory" -} -foreach ($archive in $archives) { - $zip = [IO.Compression.ZipFile]::OpenRead($archive.FullName) - try { - $frameworks = @($zip.Entries.FullName | - Where-Object { $_ -match '^lib/[^/]+/.+' } | - ForEach-Object { $_.Split('/')[1] } | Sort-Object -Unique) - if (($frameworks -join ';') -ne 'net10.0;netstandard2.0') { - throw "Expected only net10.0 and netstandard2.0 lib assets in $($archive.Name); found $($frameworks -join ', ')." - } - } - finally { - $zip.Dispose() - } -} -New-Item -ItemType Directory -Path $OutputDirectory | Out-Null -[xml] $config = Get-Content $NuGetConfig -Raw -$source = $config.CreateElement('add') -$source.SetAttribute('key', 'toolkit-provider-packages') -$source.SetAttribute('value', $PackageDirectory) -[void] $config.configuration.packageSources.AppendChild($source) -$mapping = $config.CreateElement('packageSource') -$mapping.SetAttribute('key', 'toolkit-provider-packages') -$pattern = $config.CreateElement('package') -$pattern.SetAttribute('pattern', 'CommunityToolkit.DocumentExtraction.*') -[void] $mapping.AppendChild($pattern) -[void] $config.configuration.packageSourceMapping.AppendChild($mapping) -$consumerConfig = Join-Path $OutputDirectory 'NuGet.config' -$config.Save($consumerConfig) - -# Do not allow previously restored preview versions to substitute for these packages. -$env:NUGET_PACKAGES = Join-Path $OutputDirectory 'packages' -$env:NUGET_HTTP_CACHE_PATH = Join-Path $OutputDirectory 'http-cache' -function Invoke-Logged { - param([string] $Name, [string[]] $Arguments, [switch] $ExpectExperimentalError) - $log = Join-Path $OutputDirectory "$Name.log" - & dotnet @Arguments *> $log - $code = $LASTEXITCODE - if ($ExpectExperimentalError) { - if ($code -eq 0 -or !(Select-String -Path $log -Pattern 'error CTDE0001' -Quiet)) { - throw "Expected an unsuppressed external CTDE0001 error; inspect $log" - } - } - elseif ($code -ne 0) { - Get-Content $log -Tail 30 - throw "Package consumer check failed; inspect $log" - } - Write-Output "$Name succeeded." -} - -Invoke-Logged 'restore' @('restore', $ConsumerProject, '--configfile', $consumerConfig, '--verbosity', 'quiet') -foreach ($pair in @( - @{ Runtime = 'net10.0'; Asset = 'net10.0' }, - @{ Runtime = 'net10.0'; Asset = 'netstandard2.0' } -)) { - $name = "$($pair.Runtime)-$($pair.Asset)" - $common = @($ConsumerProject, '-c', 'Release', '-f', $pair.Runtime, '--no-restore', - "-p:ProviderAssetFramework=$($pair.Asset)", '--verbosity', 'quiet') - Invoke-Logged "$name-unsuppressed" (@('build') + $common + @('-p:EnableExperimentalOptIn=false', '--no-incremental')) -ExpectExperimentalError - Invoke-Logged "$name-opted-in" (@('build') + $common + @('-p:EnableExperimentalOptIn=true', '--no-incremental')) - Invoke-Logged "$name-executed" @('run', '--project', $ConsumerProject, '-c', 'Release', '-f', $pair.Runtime, - '--no-build', '--no-restore', "-p:ProviderAssetFramework=$($pair.Asset)", '-p:EnableExperimentalOptIn=true', - '--', $pair.Asset, (Join-Path $OutputDirectory "$name.json")) - $receipt = Get-Content (Join-Path $OutputDirectory "$name.json") -Raw | ConvertFrom-Json - foreach ($binding in $receipt.assemblies) { - $assemblyName = [IO.Path]::GetFileNameWithoutExtension($binding.path) - $matches = @($archives | Where-Object { $_.Name -like "$assemblyName.[0-9]*.nupkg" }) - if ($matches.Count -ne 1) { - throw "Expected one matching provider archive for $assemblyName." - } - $zip = [IO.Compression.ZipFile]::OpenRead($matches[0].FullName) - try { - $entry = $zip.GetEntry("lib/$($pair.Asset)/$assemblyName.dll") - if ($null -eq $entry) { - throw "Missing selected provider asset for $assemblyName." - } - $stream = $entry.Open() - $algorithm = [Security.Cryptography.SHA256]::Create() - try { - $hash = [Convert]::ToHexString($algorithm.ComputeHash($stream)) - } - finally { - $algorithm.Dispose() - $stream.Dispose() - } - if ($binding.sha256 -ne $hash) { - throw "Loaded provider DLL does not match selected archive asset: $assemblyName." - } - } - finally { - $zip.Dispose() - } - } -}