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
This commit is contained in:
Danny Milosavljevic 2026-01-09 23:57:19 +01:00
parent 1c477aea8d
commit 811ee1ab9f
No known key found for this signature in database
GPG key ID: E71A35542C30BAA5
5 changed files with 341 additions and 13 deletions

View file

@ -577,6 +577,7 @@ SCM_TESTS = \
tests/import/hexpm.scm \ tests/import/hexpm.scm \
tests/import/luanti.scm \ tests/import/luanti.scm \
tests/import/npm-binary.scm \ tests/import/npm-binary.scm \
tests/import/nuget.scm \
tests/import/opam.scm \ tests/import/opam.scm \
tests/import/print.scm \ tests/import/print.scm \
tests/import/pypi.scm \ tests/import/pypi.scm \

View file

@ -14709,6 +14709,50 @@ and generate package expressions for all those packages that are not yet
in Guix. in Guix.
@end table @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 @item minetest
@cindex minetest @cindex minetest
@cindex ContentDB @cindex ContentDB

View file

@ -37,6 +37,7 @@
#:use-module (srfi srfi-26) #:use-module (srfi srfi-26)
#:use-module (srfi srfi-34) ; For catch #:use-module (srfi srfi-34) ; For catch
#:use-module (srfi srfi-37) #:use-module (srfi srfi-37)
#:use-module (srfi srfi-39) ; parameters
#:use-module (srfi srfi-71) ; multi-value let #:use-module (srfi srfi-71) ; multi-value let
#:use-module (sxml simple) #:use-module (sxml simple)
#:use-module (sxml match) #:use-module (sxml match)
@ -60,7 +61,10 @@
#:use-module (guix packages) #:use-module (guix packages)
#:use-module (guix upstream) #:use-module (guix upstream)
#:use-module (guix http-client) #: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)) nuget-recursive-import))
;; copy from guix/import/pypi.scm ;; copy from guix/import/pypi.scm
@ -84,9 +88,12 @@
;; Example: <https://api.nuget.org/v3/registration5-semver1/newtonsoft.json/index.json>. ;; Example: <https://api.nuget.org/v3/registration5-semver1/newtonsoft.json/index.json>.
;; List of all packages. You get a lot of references to @type CatalogPage out. ;; 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-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-registration-url
(define %nuget-v3-package-versions-url "https://api.nuget.org/v3-flatcontainer/") (make-parameter "https://api.nuget.org/v3/registration5-semver1/"))
(define %nuget-symbol-packages-url "https://globalcdn.nuget.org/symbol-packages/") (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") (define %nuget-nuspec "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd")
;;; Version index https://api.nuget.org/v3-flatcontainer/{id-lower}/index.json ;;; 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) (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) (define (nuget->semver-range range-str)
(semver-range (parse-nuget-range->primitives 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 version that satisfies the range. This version correctly handles list
creation and filtering to avoid type errors." creation and filtering to avoid type errors."
(let* ((name-lower (string-downcase name)) (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))) (let ((versions-json (json-fetch versions-url)))
(if versions-json (if versions-json
(let* ((available-versions (vector->list (or (assoc-ref versions-json "versions") (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) (warning (G_ "Failed to fetch version list for ~a~%") name)
#f))))) #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 ;;; 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, "Fetch the full 'catalogEntry' JSON object for a specific package version,
correctly handling the paginated structure of the registration index." correctly handling the paginated structure of the registration index."
(let* ((name-lower (string-downcase name)) (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))) (index-json (json-fetch index-url)))
(if index-json (if index-json
(let loop ((pages-to-check (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) (warning (G_ "Failed to fetch registration index for ~a~%") name)
#f)))) #f))))
;; Make this testable.
(set! nuget-fetch-catalog-entry nuget-fetch-catalog-entry)
(define (car-safe lst) (define (car-safe lst)
(if (null? 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 and commit. Returns an association list with 'url' and 'commit' keys on
success, or #f on failure." success, or #f on failure."
(let* ((name (string-append (string-downcase package-name) "." version ".snupkg")) (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) (format (current-error-port)
"~%;; Source repository not found in NuGet catalog entry.~%;; ~ "~%;; Source repository not found in NuGet catalog entry.~%;; ~
Attempting to find it in symbol package: ~a~%" Attempting to find it in symbol package: ~a~%"
@ -311,6 +327,9 @@ success, or #f on failure."
(define (nuget-name->guix-name name) (define (nuget-name->guix-name name)
(string-append "dotnet-" (snake-case name))) (string-append "dotnet-" (snake-case name)))
;; Make this testable.
(set! nuget-name->guix-name nuget-name->guix-name)
(define nuget->guix-package (define nuget->guix-package
(memoize (memoize
(lambda* (package-name #:key (repo 'nuget) (version #f) (license-prefix identity) #:allow-other-keys) (lambda* (package-name #:key (repo 'nuget) (version #f) (license-prefix identity) #:allow-other-keys)

View file

@ -51,8 +51,6 @@ Import and convert the NuGet package for PACKAGE-NAME.\n"))
(display (G_ " (display (G_ "
-r, --recursive import packages recursively")) -r, --recursive import packages recursively"))
(display (G_ " (display (G_ "
-s, --style=STYLE choose output style, either specification or variable"))
(display (G_ "
-p, --license-prefix=PREFIX -p, --license-prefix=PREFIX
add custom prefix to licenses")) add custom prefix to licenses"))
(display (G_ " (display (G_ "
@ -73,10 +71,6 @@ Import and convert the NuGet package for PACKAGE-NAME.\n"))
(lambda (opt name arg result) (lambda (opt name arg result)
(alist-cons 'repo (string->symbol arg) (alist-cons 'repo (string->symbol arg)
(alist-delete 'repo result)))) (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 (option '(#\p "license-prefix") #t #f
(lambda (opt name arg result) (lambda (opt name arg result)
(alist-cons 'license-prefix arg (alist-cons 'license-prefix arg

270
tests/import/nuget.scm Normal file
View file

@ -0,0 +1,270 @@
;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2025 Danny Milosavljevic <dannym@friendly-machines.com>
;;;
;;; 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 <http://www.gnu.org/licenses/>.
(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")