From 811ee1ab9fb585130fe0c27df03f10dc21b1e7f7 Mon Sep 17 00:00:00 2001 From: Danny Milosavljevic Date: Fri, 9 Jan 2026 23:57:19 +0100 Subject: [PATCH] import: nuget: Add tests and documentation. * guix/import/nuget.scm: Prevent optimizing small functions away completely. * tests/import/nuget.scm: New file. * doc/guix.texi (nuget): Document it. * Makefile.am (SCM_TESTS): Add reference to it. Fixes: guix/guix#5483 Change-Id: Id58932fe404a11a03e61a91d3b6177b39548f1bc --- Makefile.am | 1 + doc/guix.texi | 44 ++++++ guix/import/nuget.scm | 33 ++++- guix/scripts/import/nuget.scm | 6 - tests/import/nuget.scm | 270 ++++++++++++++++++++++++++++++++++ 5 files changed, 341 insertions(+), 13 deletions(-) create mode 100644 tests/import/nuget.scm diff --git a/Makefile.am b/Makefile.am index dabceddf2ac..e71b2d2ed5e 100644 --- a/Makefile.am +++ b/Makefile.am @@ -577,6 +577,7 @@ SCM_TESTS = \ tests/import/hexpm.scm \ tests/import/luanti.scm \ tests/import/npm-binary.scm \ + tests/import/nuget.scm \ tests/import/opam.scm \ tests/import/print.scm \ tests/import/pypi.scm \ diff --git a/doc/guix.texi b/doc/guix.texi index a6d6d655fb5..a22230e1538 100644 --- a/doc/guix.texi +++ b/doc/guix.texi @@ -14709,6 +14709,50 @@ and generate package expressions for all those packages that are not yet in Guix. @end table +@item nuget +@cindex nuget +@cindex .NET +Import metadata from @uref{https://www.nuget.org/, NuGet}, the package +manager for .NET. Information is taken from the JSON-formatted metadata +provided through NuGet's v3 API at @code{api.nuget.org} and includes +most relevant information, including package dependencies. +There are some caveats, however. The metadata does not always include +repository information, in which case the importer attempts to extract +it from the symbol package (@file{.snupkg}) if available. +Additionally, dependencies are grouped by target framework in NuGet, +but the importer flattens all dependency groups into a single list. + +The command below imports metadata for the @code{Avalonia} .NET package: + +@example +guix import nuget Avalonia +@end example + +You can also recursively import all dependencies: + +@example +guix import nuget -r Avalonia +@end example + +@table @code +@item --archive=@var{repo} +@itemx -a @var{repo} +Specify the archive repository. Currently only @code{nuget} is supported, +which uses the official NuGet package repository at @code{nuget.org}. + +@item --recursive +@itemx -r +Traverse the dependency graph of the given upstream package recursively +and generate package expressions for all those packages that are not yet +in Guix. + +@item --license-prefix=@var{prefix} +@itemx -p @var{prefix} +Add a custom prefix to license identifiers in the generated package +definitions. This can be useful when license identifiers need to be +qualified with a module name. +@end table + @item minetest @cindex minetest @cindex ContentDB diff --git a/guix/import/nuget.scm b/guix/import/nuget.scm index d540e6817f6..a8060b27306 100644 --- a/guix/import/nuget.scm +++ b/guix/import/nuget.scm @@ -37,6 +37,7 @@ #:use-module (srfi srfi-26) #:use-module (srfi srfi-34) ; For catch #:use-module (srfi srfi-37) + #:use-module (srfi srfi-39) ; parameters #:use-module (srfi srfi-71) ; multi-value let #:use-module (sxml simple) #:use-module (sxml match) @@ -60,7 +61,10 @@ #:use-module (guix packages) #:use-module (guix upstream) #:use-module (guix http-client) - #:export (nuget->guix-package + #:export (%nuget-v3-registration-url + %nuget-v3-package-versions-url + %nuget-symbol-packages-url + nuget->guix-package nuget-recursive-import)) ;; copy from guix/import/pypi.scm @@ -84,9 +88,12 @@ ;; Example: . ;; List of all packages. You get a lot of references to @type CatalogPage out. (define %nuget-v3-feed-catalog-url "https://api.nuget.org/v3/catalog0/index.json") -(define %nuget-v3-registration-url "https://api.nuget.org/v3/registration5-semver1/") -(define %nuget-v3-package-versions-url "https://api.nuget.org/v3-flatcontainer/") -(define %nuget-symbol-packages-url "https://globalcdn.nuget.org/symbol-packages/") +(define %nuget-v3-registration-url + (make-parameter "https://api.nuget.org/v3/registration5-semver1/")) +(define %nuget-v3-package-versions-url + (make-parameter "https://api.nuget.org/v3-flatcontainer/")) +(define %nuget-symbol-packages-url + (make-parameter "https://globalcdn.nuget.org/symbol-packages/")) (define %nuget-nuspec "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd") ;;; Version index https://api.nuget.org/v3-flatcontainer/{id-lower}/index.json @@ -150,6 +157,9 @@ primitives suitable for the 'semver-range' constructor." (warning (G_ "Unrecognized NuGet range format: '~a'.~%") str) '())))))))) +;; Make this testable. +(set! parse-nuget-range->primitives parse-nuget-range->primitives) + (define (nuget->semver-range range-str) (semver-range (parse-nuget-range->primitives range-str))) @@ -158,7 +168,7 @@ primitives suitable for the 'semver-range' constructor." version that satisfies the range. This version correctly handles list creation and filtering to avoid type errors." (let* ((name-lower (string-downcase name)) - (versions-url (string-append %nuget-v3-package-versions-url name-lower "/index.json"))) + (versions-url (string-append (%nuget-v3-package-versions-url) name-lower "/index.json"))) (let ((versions-json (json-fetch versions-url))) (if versions-json (let* ((available-versions (vector->list (or (assoc-ref versions-json "versions") @@ -193,6 +203,9 @@ primitives suitable for the 'semver-range' constructor." (warning (G_ "Failed to fetch version list for ~a~%") name) #f))))) +;; Make this testable. +(set! nuget-find-best-version-for-range nuget-find-best-version-for-range) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;; ;;; Part 2: Core Data Fetching and Package Generation @@ -202,7 +215,7 @@ primitives suitable for the 'semver-range' constructor." "Fetch the full 'catalogEntry' JSON object for a specific package version, correctly handling the paginated structure of the registration index." (let* ((name-lower (string-downcase name)) - (index-url (string-append %nuget-v3-registration-url name-lower "/index.json")) + (index-url (string-append (%nuget-v3-registration-url) name-lower "/index.json")) (index-json (json-fetch index-url))) (if index-json (let loop ((pages-to-check @@ -242,6 +255,9 @@ primitives suitable for the 'semver-range' constructor." (warning (G_ "Failed to fetch registration index for ~a~%") name) #f)))) +;; Make this testable. +(set! nuget-fetch-catalog-entry nuget-fetch-catalog-entry) + (define (car-safe lst) (if (null? lst) '() @@ -253,7 +269,7 @@ file using the system 'unzip' command, and parse it to find the repository URL and commit. Returns an association list with 'url' and 'commit' keys on success, or #f on failure." (let* ((name (string-append (string-downcase package-name) "." version ".snupkg")) - (snupkg-url (string-append %nuget-symbol-packages-url name))) + (snupkg-url (string-append (%nuget-symbol-packages-url) name))) (format (current-error-port) "~%;; Source repository not found in NuGet catalog entry.~%;; ~ Attempting to find it in symbol package: ~a~%" @@ -311,6 +327,9 @@ success, or #f on failure." (define (nuget-name->guix-name name) (string-append "dotnet-" (snake-case name))) +;; Make this testable. +(set! nuget-name->guix-name nuget-name->guix-name) + (define nuget->guix-package (memoize (lambda* (package-name #:key (repo 'nuget) (version #f) (license-prefix identity) #:allow-other-keys) diff --git a/guix/scripts/import/nuget.scm b/guix/scripts/import/nuget.scm index 8338adf3eb6..8bb870cd9e9 100644 --- a/guix/scripts/import/nuget.scm +++ b/guix/scripts/import/nuget.scm @@ -51,8 +51,6 @@ Import and convert the NuGet package for PACKAGE-NAME.\n")) (display (G_ " -r, --recursive import packages recursively")) (display (G_ " - -s, --style=STYLE choose output style, either specification or variable")) - (display (G_ " -p, --license-prefix=PREFIX add custom prefix to licenses")) (display (G_ " @@ -73,10 +71,6 @@ Import and convert the NuGet package for PACKAGE-NAME.\n")) (lambda (opt name arg result) (alist-cons 'repo (string->symbol arg) (alist-delete 'repo result)))) - (option '(#\s "style") #t #f - (lambda (opt name arg result) - (alist-cons 'style (string->symbol arg) - (alist-delete 'style result)))) (option '(#\p "license-prefix") #t #f (lambda (opt name arg result) (alist-cons 'license-prefix arg diff --git a/tests/import/nuget.scm b/tests/import/nuget.scm new file mode 100644 index 00000000000..cbbffed5396 --- /dev/null +++ b/tests/import/nuget.scm @@ -0,0 +1,270 @@ +;;; GNU Guix --- Functional package management for GNU +;;; Copyright © 2025 Danny Milosavljevic +;;; +;;; This file is part of GNU Guix. +;;; +;;; GNU Guix is free software; you can redistribute it and/or modify it +;;; under the terms of the GNU General Public License as published by +;;; the Free Software Foundation; either version 3 of the License, or (at +;;; your option) any later version. +;;; +;;; GNU Guix is distributed in the hope that it will be useful, but +;;; WITHOUT ANY WARRANTY; without even the implied warranty of +;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;;; GNU General Public License for more details. +;;; +;;; You should have received a copy of the GNU General Public License +;;; along with GNU Guix. If not, see . + +(define-module (test-nuget) + #:use-module (guix import nuget) + #:use-module (guix tests) + #:use-module (guix tests http) + #:use-module (guix http-client) + #:use-module (json) + #:use-module (semver) + #:use-module (ice-9 match) + #:use-module (srfi srfi-34) + #:use-module (srfi srfi-64) + #:use-module (srfi srfi-71) + #:use-module (web uri)) + +(define (make-versions-json versions) + "Generate a NuGet package versions index JSON string." + (scm->json-string + `((versions . ,(list->vector versions))))) + +(define (make-catalog-entry id version description summary home-page license + repo-url repo-commit dependencies) + "Generate a NuGet catalog entry alist." + `((id . ,id) + (version . ,version) + (description . ,description) + (summary . ,summary) + (projectUrl . ,home-page) + (licenseExpression . ,license) + ,@(if repo-url + `((repository . ((url . ,repo-url) + (commit . ,repo-commit)))) + '()) + (dependencyGroups + . ,(list->vector + (if (null? dependencies) + '() + `(((targetFramework . "net6.0") + (dependencies + . ,(list->vector + (map (lambda (dep) + `((id . ,(car dep)) + (range . ,(cdr dep)))) + dependencies)))))))))) + +(define (make-registration-index-json catalog-entry) + "Generate a NuGet registration index JSON string." + (scm->json-string + `((items + . #(((items + . #(((catalogEntry . ,catalog-entry)))))))))) + +(define test-avalonia-versions-json + (make-versions-json '("0.10.0" "0.10.1" "11.0.0" "11.0.1" "11.1.0"))) + +(define test-avalonia-catalog-entry + (make-catalog-entry "Avalonia" "11.1.0" + "A cross-platform UI framework for .NET" + "Avalonia UI Framework" + "https://avaloniaui.net/" + "MIT" + "https://github.com/AvaloniaUI/Avalonia.git" + "abc123def456" + '(("System.Text.Json" . "[6.0.0, )")))) + +(define test-avalonia-index-json + (make-registration-index-json test-avalonia-catalog-entry)) + +(define test-system-text-json-versions-json + (make-versions-json '("6.0.0" "6.0.1" "7.0.0" "8.0.0"))) + +(define test-system-text-json-catalog-entry + (make-catalog-entry "System.Text.Json" "8.0.0" + "Provides high-performance JSON APIs" + "JSON library" + "https://dot.net/" + "MIT" + "https://github.com/dotnet/runtime.git" + "def789abc012" + '())) + +(define test-system-text-json-index-json + (make-registration-index-json test-system-text-json-catalog-entry)) + +(define test-package-no-repo-versions-json + (make-versions-json '("1.0.0"))) + +(define test-package-no-repo-catalog-entry + (make-catalog-entry "TestPackage" "1.0.0" + "Test package without repository" + #f + "https://example.com/" + "MIT" + #f #f + '())) + +(define test-package-no-repo-index-json + (make-registration-index-json test-package-no-repo-catalog-entry)) + +(define-syntax-rule (with-nuget responses body ...) + (with-http-server responses + (parameterize ((%nuget-v3-package-versions-url + (%local-url #:path "/versions/")) + (%nuget-v3-registration-url + (%local-url #:path "/registration/")) + (%nuget-symbol-packages-url + (%local-url #:path "/symbols/"))) + body ...))) + +(test-begin "nuget") + +(test-assert "nuget->guix-package" + ;; Replace network resources with sample data. + (with-nuget `(("/versions/avalonia/index.json" 200 ,test-avalonia-versions-json) + ("/registration/avalonia/index.json" 200 ,test-avalonia-index-json)) + (let ((package-sexp dependencies (nuget->guix-package "Avalonia"))) + (match package-sexp + (`(package + (name "dotnet-avalonia") + (version "11.1.0") + (source + (origin + (method git-fetch) + (uri (git-reference + (url "https://github.com/AvaloniaUI/Avalonia.git") + (commit "abc123def456"))) + (file-name (git-file-name name version)) + (sha256 (base32 ,(? string?))))) + (build-system mono-build-system) + (inputs (list dotnet-system-text-json)) + (home-page "https://avaloniaui.net/") + (synopsis ,(? string?)) + (description ,(? string?)) + (license ,?)) + (equal? dependencies '("System.Text.Json"))) + (x + (pk 'fail x #f)))))) + +(test-assert "nuget-name->guix-name" + (and (string=? ((@@ (guix import nuget) nuget-name->guix-name) "Avalonia") + "dotnet-avalonia") + (string=? ((@@ (guix import nuget) nuget-name->guix-name) "System.Text.Json") + "dotnet-system-text-json"))) + +(test-assert "nuget-recursive-import" + ;; Replace network resources with sample data. + ;; recursive-import returns a list of package s-expressions in topological order. + (with-nuget `(("/versions/avalonia/index.json" 200 ,test-avalonia-versions-json) + ("/registration/avalonia/index.json" 200 ,test-avalonia-index-json) + ("/versions/system.text.json/index.json" 200 + ,test-system-text-json-versions-json) + ("/registration/system.text.json/index.json" 200 + ,test-system-text-json-index-json)) + (let ((packages (nuget-recursive-import "Avalonia"))) + (match packages + ((first second) + ;; Check that we got two packages + (and (match first + (`(package (name ,name1) . ,_) + (or (string=? name1 "dotnet-system-text-json") + (string=? name1 "dotnet-avalonia")))) + (match second + (`(package (name ,name2) . ,_) + (or (string=? name2 "dotnet-system-text-json") + (string=? name2 "dotnet-avalonia")))))) + (x + (pk 'fail-recursive-count x #f)))))) + +(test-assert "parse-nuget-range->primitives: exact version" + (let ((result ((@@ (guix import nuget) parse-nuget-range->primitives) "[1.0.0]"))) + (match result + (((('= slice))) + (equal? slice '(1 0 0 0 () ()))) + (_ #f)))) + +(test-assert "parse-nuget-range->primitives: minimum version" + (let ((result ((@@ (guix import nuget) parse-nuget-range->primitives) "1.0.0"))) + (match result + ((('>= slice)) + (equal? slice '(1 0 0 0 () ()))) + (_ #f)))) + +(test-assert "parse-nuget-range->primitives: range with brackets" + (let ((result ((@@ (guix import nuget) parse-nuget-range->primitives) "[1.0.0,2.0.0]"))) + (match result + ((('>= sv1) ('<= sv2)) + (and (semver? sv1) + (semver? sv2) + (string=? (semver->string sv1) "1.0.0") + (string=? (semver->string sv2) "2.0.0"))) + (_ #f)))) + +(test-assert "parse-nuget-range->primitives: range with parens" + (let ((result ((@@ (guix import nuget) parse-nuget-range->primitives) "(1.0.0,2.0.0)"))) + (match result + ((('> sv1) ('< sv2)) + (and (semver? sv1) + (semver? sv2) + (string=? (semver->string sv1) "1.0.0") + (string=? (semver->string sv2) "2.0.0"))) + (_ #f)))) + +(test-assert "parse-nuget-range->primitives: open-ended range" + (let ((result ((@@ (guix import nuget) parse-nuget-range->primitives) "[1.0.0, )"))) + (match result + ((('>= sv)) + (and (semver? sv) + (string=? (semver->string sv) "1.0.0"))) + (_ #f)))) + +(test-assert "nuget-find-best-version-for-range: stable version" + ;; Test that it finds the highest stable version matching a range + (with-nuget `(("/versions/avalonia/index.json" 200 ,test-avalonia-versions-json)) + (let ((version ((@@ (guix import nuget) nuget-find-best-version-for-range) + "Avalonia" "[11.0.0,)"))) + (string=? version "11.1.0")))) + +(test-assert "nuget-find-best-version-for-range: closed range" + (with-nuget `(("/versions/avalonia/index.json" 200 ,test-avalonia-versions-json)) + (let ((version ((@@ (guix import nuget) nuget-find-best-version-for-range) + "Avalonia" "[11.0.0,12.0.0]"))) + (string=? version "11.1.0")))) + +(test-assert "nuget-fetch-catalog-entry: finds specific version" + (with-nuget `(("/registration/avalonia/index.json" 200 ,test-avalonia-index-json)) + (let ((entry ((@@ (guix import nuget) nuget-fetch-catalog-entry) + "Avalonia" "11.1.0"))) + (and entry + (string=? (assoc-ref entry "version") "11.1.0") + (string=? (assoc-ref entry "id") "Avalonia"))))) + +(test-assert "nuget->guix-package: package without repository" + ;; Test package with no repository info (source should have FIXME) + (with-nuget `(("/versions/testpackage/index.json" 200 + ,test-package-no-repo-versions-json) + ("/registration/testpackage/index.json" 200 + ,test-package-no-repo-index-json) + ("/symbols/testpackage.1.0.0.snupkg" 404 "")) + (let ((package-sexp dependencies (nuget->guix-package "TestPackage"))) + (match package-sexp + (`(package + (name "dotnet-testpackage") + (version "1.0.0") + (source + (origin + (method url-fetch) + (uri "FIXME: No source URL found.") + . ,_)) + . ,_) + (equal? dependencies '())) + (x + (pk 'fail-no-repo x #f)))))) + +(test-end "nuget")