purl 1.1.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2e3a83fa55008ae8b31be8d3e5c7306556e4b7d4371e6db46927deae1edee3c
4
- data.tar.gz: 5a092dfa8e630db8ca28064b1e101a62320d601708b38351a92b2f5431d692dc
3
+ metadata.gz: 6c6c3fd2d17a4601a4c296b18ce67d41c423a5893aeb2b79cd484c0028863abd
4
+ data.tar.gz: 2c5e92ca69e3bb8442df158f34944fad645319cdbac5fb45875c7c8521be5d0c
5
5
  SHA512:
6
- metadata.gz: a8db461d6066ce37a6fb9c50c373e2dd6739fddb123ac0627c14f645dadc0b610b24e30a6d0dbcda28148f103870ba8cbc28e34efb65ab026b463752753fcd6d
7
- data.tar.gz: 5420d53af149d4c1ef1fd8337f232bf4f03f420fcaa3a2de32a38751505167c0eb3d431b382ad803f29ed8b2230f80789cbcdb9c15b4e43a0af5b69fcb554031
6
+ metadata.gz: 34e7c52f43f92146d605e06338f25b5d12f80f495e320901aed32206dc3e743029be9b51eb5bcd1f84ef46ca450a76ac45fa7c74da5b2506eb3d53b82db6c840
7
+ data.tar.gz: 7941f3f3cb65695448599c590e75ef06198fdafe4739e7587e0f7518f6e58fc948896452c38136a3c1a1e6e0d52acac4b766c2a5f2dd22d0a33f8b2e1b61feb1
data/.gitmodules ADDED
@@ -0,0 +1,3 @@
1
+ [submodule "purl-spec"]
2
+ path = purl-spec
3
+ url = https://github.com/package-url/purl-spec.git
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.1] - 2025-08-04
11
+
12
+ ### Fixed
13
+ - Remove arbitrary business logic validation, follow PURL spec for namespace requirements
14
+
15
+ ## [1.3.0] - 2025-07-29
16
+
17
+ ### Added
18
+ - RFC 6570 URI templates for registry URL generation
19
+ - Advanced URL templating capabilities for dynamic registry URL construction
20
+
21
+ ### Enhanced
22
+ - Registry URL generation now supports more flexible URL patterns
23
+ - Improved templating system for custom registry configurations
24
+
25
+ ## [1.2.0] - 2025-07-27
26
+
27
+ ### Added
28
+ - Default registry URLs for 10 additional package types:
29
+ - `golang`: https://pkg.go.dev (Go package discovery site)
30
+ - `luarocks`: https://luarocks.org (Lua package repository)
31
+ - `clojars`: https://clojars.org (Clojure package repository)
32
+ - `elm`: https://package.elm-lang.org (Elm package catalog)
33
+ - `deno`: https://deno.land (Deno module registry)
34
+ - `homebrew`: https://formulae.brew.sh (Homebrew package browser)
35
+ - `bioconductor`: https://bioconductor.org (R bioinformatics packages)
36
+ - `huggingface`: https://huggingface.co (Machine learning models)
37
+ - `swift`: https://swiftpackageindex.com (Swift package index)
38
+ - `conan`: https://conan.io/center (C/C++ package center)
39
+
40
+ ### Enhanced
41
+ - Registry configuration support for newly added package types
42
+ - Updated test suite to validate all new default registries
43
+ - Improved package type coverage with comprehensive registry URL mapping
44
+
45
+ ### Configuration
46
+ - Updated `purl-types.json` to version 1.2.0 with enhanced registry configurations
47
+ - Added specialized registry handling for Go's unique import path structure
48
+
10
49
  ## [1.1.2] - 2025-07-25
11
50
 
12
51
  ### Added
data/README.md CHANGED
@@ -385,6 +385,25 @@ rake spec:types
385
385
  rake spec:verify_types
386
386
  ```
387
387
 
388
+ ### Testing Against Official Specification
389
+
390
+ This library includes the official [purl-spec](https://github.com/package-url/purl-spec) repository as a git submodule for testing and validation:
391
+
392
+ ```bash
393
+ # Initialize submodule (first time only)
394
+ git submodule update --init --recursive
395
+
396
+ # Update submodule to latest spec
397
+ git submodule update --remote purl-spec
398
+ ```
399
+
400
+ The tests use files from the submodule to:
401
+ - **Schema validation**: Validate our `purl-types.json` against the official schema in `purl-spec/schemas/`
402
+ - **Type compliance**: Ensure our supported types match the official types in `purl-spec/types/`
403
+ - **Test data**: Access official test cases and examples from `purl-spec/tests/`
404
+
405
+ The submodule is automatically updated weekly via Dependabot, ensuring tests stay current with the latest specification changes. When the submodule updates, you can review and merge the PR to adopt new spec requirements.
406
+
388
407
  ### Rake Tasks
389
408
 
390
409
  - `rake spec:update` - Fetch latest test cases from official PURL spec repository
@@ -74,13 +74,13 @@ module Purl
74
74
  def initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil)
75
75
  @type = validate_and_normalize_type(type)
76
76
  @name = validate_name(name)
77
- @namespace = validate_namespace(namespace) if namespace
77
+ @namespace = validate_namespace(namespace)
78
78
  @version = validate_version(version) if version
79
79
  @qualifiers = validate_qualifiers(qualifiers) if qualifiers
80
80
  @subpath = validate_subpath(subpath) if subpath
81
81
 
82
- # Type-specific validation
83
- validate_type_specific_rules
82
+ # Apply post-validation normalization that depends on other components
83
+ apply_post_validation_normalization
84
84
  end
85
85
 
86
86
  # Parse a PURL string into a PackageURL object
@@ -407,7 +407,7 @@ module Purl
407
407
  # PyPI names are case-insensitive and _ should be normalized to -
408
408
  name_str.downcase.gsub("_", "-")
409
409
  when "mlflow"
410
- # MLflow name normalization is deferred until after qualifiers are set
410
+ # MLflow name normalization happens after qualifiers are validated
411
411
  name_str
412
412
  when "composer"
413
413
  # Composer names should be lowercase
@@ -418,6 +418,16 @@ module Purl
418
418
  end
419
419
 
420
420
  def validate_namespace(namespace)
421
+ # Check namespace requirements from spec
422
+ if namespace_required_for_type?(@type) && namespace.nil?
423
+ raise ValidationError.new(
424
+ "#{@type.capitalize} PURLs require a namespace per the type definition",
425
+ component: :namespace,
426
+ value: namespace,
427
+ rule: "#{@type.downcase} packages need namespace"
428
+ )
429
+ end
430
+
421
431
  return nil if namespace.nil?
422
432
 
423
433
  namespace_str = namespace.to_s.strip
@@ -516,110 +526,35 @@ module Purl
516
526
  subpath_str
517
527
  end
518
528
 
519
- def validate_type_specific_rules
520
- case @type.downcase
521
- when "conan"
522
- validate_conan_specific_rules
523
- when "cran"
524
- validate_cran_specific_rules
525
- when "swift"
526
- validate_swift_specific_rules
527
- when "cpan"
528
- validate_cpan_specific_rules
529
- when "mlflow"
530
- validate_mlflow_specific_rules
529
+ def apply_post_validation_normalization
530
+ # MLflow names are case sensitive or insensitive based on repository per spec
531
+ if @type&.downcase == "mlflow" && @qualifiers && @qualifiers["repository_url"] && @qualifiers["repository_url"].include?("azuredatabricks")
532
+ # Databricks MLflow is case insensitive - normalize to lowercase per spec
533
+ @name = @name.downcase
531
534
  end
535
+ # Other MLflow repositories (like Azure ML) are case sensitive - no normalization needed
532
536
  end
533
537
 
534
- def validate_conan_specific_rules
535
- # For conan packages, if a namespace is present WITHOUT any qualifiers,
536
- # it's ambiguous (test case 30)
537
- if @namespace && (@qualifiers.nil? || (@qualifiers["user"].nil? && @qualifiers["channel"].nil?))
538
- raise ValidationError.new(
539
- "Conan PURLs with namespace require 'user' and/or 'channel' qualifiers to be unambiguous",
540
- component: :qualifiers,
541
- value: @qualifiers,
542
- rule: "conan packages with namespace need user/channel qualifiers"
543
- )
544
- end
538
+ def namespace_required_for_type?(type)
539
+ return false unless type
545
540
 
546
- # If channel qualifier is present without namespace, user qualifier is also needed (test case 31)
547
- # But if namespace is present, channel alone can be valid (test case 29)
548
- if @qualifiers && @qualifiers["channel"] && @qualifiers["user"].nil? && @namespace.nil?
549
- raise ValidationError.new(
550
- "Conan PURLs with 'channel' qualifier require 'user' qualifier to be unambiguous",
551
- component: :qualifiers,
552
- value: @qualifiers,
553
- rule: "conan packages with channel need user qualifier"
554
- )
555
- end
556
- end
557
-
558
- def validate_cran_specific_rules
559
- # CRAN packages require a version to be unambiguous
560
- if @version.nil?
561
- raise ValidationError.new(
562
- "CRAN PURLs require a version to be unambiguous",
563
- component: :version,
564
- value: @version,
565
- rule: "cran packages need version"
566
- )
567
- end
568
- end
569
-
570
- def validate_swift_specific_rules
571
- # Swift packages require a namespace to be unambiguous
572
- if @namespace.nil?
573
- raise ValidationError.new(
574
- "Swift PURLs require a namespace to be unambiguous",
575
- component: :namespace,
576
- value: @namespace,
577
- rule: "swift packages need namespace"
578
- )
579
- end
541
+ # Read from purl-types.json (included in gem)
542
+ types_data = self.class.purl_types_data
543
+ type_config = types_data.dig("types", type.downcase)
544
+ return false unless type_config
580
545
 
581
- # Swift packages require a version to be unambiguous
582
- if @version.nil?
583
- raise ValidationError.new(
584
- "Swift PURLs require a version to be unambiguous",
585
- component: :version,
586
- value: @version,
587
- rule: "swift packages need version"
588
- )
589
- end
590
- end
591
-
592
- def validate_mlflow_specific_rules
593
- # MLflow names are case sensitive or insensitive based on repository
594
- if @qualifiers && @qualifiers["repository_url"] && @qualifiers["repository_url"].include?("azuredatabricks")
595
- # Azure Databricks MLflow is case insensitive - normalize to lowercase
596
- @name = @name.downcase
597
- end
598
- # Other MLflow repositories are case sensitive - no normalization needed
546
+ # Check namespace_requirement field
547
+ type_config["namespace_requirement"] == "required"
599
548
  end
600
549
 
601
- def validate_cpan_specific_rules
602
- # CPAN has complex rules about module vs distribution names
603
- # These test cases are checking for specific invalid patterns
604
-
605
- # Case 51: "Perl-Version" should be invalid (module name like distribution name)
606
- if @name == "Perl-Version"
607
- raise ValidationError.new(
608
- "CPAN module name 'Perl-Version' conflicts with distribution naming",
609
- component: :name,
610
- value: @name,
611
- rule: "cpan module vs distribution name conflict"
612
- )
613
- end
614
-
615
- # Case 52: namespace with distribution-like name should be invalid
616
- if @namespace == "GDT" && @name == "URI::PackageURL"
617
- raise ValidationError.new(
618
- "CPAN distribution name 'GDT/URI::PackageURL' has invalid format",
619
- component: :name,
620
- value: "#{@namespace}/#{@name}",
621
- rule: "cpan distribution vs module name conflict"
622
- )
550
+ def self.purl_types_data
551
+ @purl_types_data ||= begin
552
+ require "json"
553
+ types_file = File.join(File.dirname(__FILE__), "..", "..", "purl-types.json")
554
+ JSON.parse(File.read(types_file))
555
+ rescue
556
+ # Fallback to empty structure if file can't be read
557
+ {"types" => {}}
623
558
  end
624
559
  end
625
560
 
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "addressable/template"
4
+
3
5
  module Purl
4
6
  class RegistryURL
5
7
  # Load registry patterns from JSON configuration
6
8
  def self.load_registry_patterns
7
9
  @registry_patterns ||= begin
8
- # Load JSON config directly to avoid circular dependency
10
+ # Load extended registry configs
9
11
  config_path = File.join(__dir__, "..", "..", "purl-types.json")
10
12
  require "json"
11
13
  config = JSON.parse(File.read(config_path))
@@ -16,37 +18,18 @@ module Purl
16
18
  next unless type_config["registry_config"]
17
19
 
18
20
  registry_config = type_config["registry_config"]
19
- patterns[type] = build_pattern_config(type, registry_config)
21
+ patterns[type] = build_pattern_config(type, registry_config, type_config)
20
22
  end
21
23
 
22
24
  patterns
23
25
  end
24
26
  end
25
27
 
26
- def self.build_pattern_config(type, config)
27
- # Get the default registry for this type from parent config
28
- type_config = load_types_config["types"][type]
28
+ def self.build_pattern_config(type, config, type_config)
29
+ # Get the default registry for this type from the extended config
29
30
  default_registry = type_config["default_registry"]
30
31
 
31
- # Build full URLs from templates if we have a default registry
32
- route_patterns = []
33
- if default_registry
34
- # Add all template variations
35
- if config["path_template"]
36
- route_patterns << default_registry + config["path_template"]
37
- end
38
- if config["namespace_path_template"]
39
- route_patterns << default_registry + config["namespace_path_template"]
40
- end
41
- if config["version_path_template"]
42
- route_patterns << default_registry + config["version_path_template"]
43
- end
44
- if config["namespace_version_path_template"]
45
- route_patterns << default_registry + config["namespace_version_path_template"]
46
- end
47
- end
48
- # Fall back to legacy route_patterns if available
49
- route_patterns = config["route_patterns"] if route_patterns.empty? && config["route_patterns"]
32
+ # Route patterns are replaced by URI templates
50
33
 
51
34
  # Build reverse regex from template or use legacy format
52
35
  reverse_regex = nil
@@ -63,10 +46,13 @@ module Purl
63
46
 
64
47
  {
65
48
  base_url: config["base_url"] || (default_registry ? default_registry + config["path_template"]&.split('/:').first : nil),
66
- route_patterns: route_patterns,
67
49
  reverse_regex: reverse_regex,
68
50
  pattern: build_generation_lambda(type, config, default_registry),
69
- reverse_parser: reverse_regex ? build_reverse_parser(type, config) : nil
51
+ reverse_parser: reverse_regex ? build_reverse_parser(type, config) : nil,
52
+ uri_template: config["uri_template"] ? Addressable::Template.new(config["uri_template"]) : nil,
53
+ uri_template_no_namespace: config["uri_template_no_namespace"] ? Addressable::Template.new(config["uri_template_no_namespace"]) : nil,
54
+ uri_template_with_version: config["uri_template_with_version"] ? Addressable::Template.new(config["uri_template_with_version"]) : nil,
55
+ uri_template_with_version_no_namespace: config["uri_template_with_version_no_namespace"] ? Addressable::Template.new(config["uri_template_with_version_no_namespace"]) : nil
70
56
  }
71
57
  end
72
58
 
@@ -394,18 +380,41 @@ module Purl
394
380
  pattern_config = REGISTRY_PATTERNS[type.to_s.downcase]
395
381
  return [] unless pattern_config
396
382
 
397
- pattern_config[:route_patterns] || []
383
+ # Generate route patterns from URI templates
384
+ patterns = []
385
+
386
+ if pattern_config[:uri_template]
387
+ patterns << uri_template_to_route_pattern(pattern_config[:uri_template])
388
+ end
389
+
390
+ if pattern_config[:uri_template_no_namespace]
391
+ patterns << uri_template_to_route_pattern(pattern_config[:uri_template_no_namespace])
392
+ end
393
+
394
+ if pattern_config[:uri_template_with_version]
395
+ patterns << uri_template_to_route_pattern(pattern_config[:uri_template_with_version])
396
+ end
397
+
398
+ if pattern_config[:uri_template_with_version_no_namespace]
399
+ patterns << uri_template_to_route_pattern(pattern_config[:uri_template_with_version_no_namespace])
400
+ end
401
+
402
+ patterns.uniq
398
403
  end
399
404
 
400
405
  def self.all_route_patterns
401
406
  result = {}
402
407
  REGISTRY_PATTERNS.each do |type, config|
403
- if config[:route_patterns]
404
- result[type] = config[:route_patterns]
405
- end
408
+ patterns = route_patterns_for(type)
409
+ result[type] = patterns unless patterns.empty?
406
410
  end
407
411
  result
408
412
  end
413
+
414
+ private_class_method def self.uri_template_to_route_pattern(template)
415
+ # Convert URI template format {variable} to route pattern format :variable
416
+ template.pattern.gsub(/\{([^}]+)\}/, ':\1')
417
+ end
409
418
 
410
419
  def initialize(purl)
411
420
  @purl = purl
@@ -426,8 +435,12 @@ module Purl
426
435
  if base_url
427
436
  # Use custom base URL with the same URL structure
428
437
  generate_with_custom_base_url(base_url, pattern_config)
438
+ elsif pattern_config[:uri_template]
439
+ # Use URI template if available
440
+ template = select_uri_template(pattern_config, include_version: false)
441
+ generate_with_uri_template(template)
429
442
  else
430
- # Use default base URL
443
+ # Fall back to legacy lambda pattern
431
444
  pattern_config[:pattern].call(@purl)
432
445
  end
433
446
  rescue MissingRegistryInfoError
@@ -438,27 +451,121 @@ module Purl
438
451
  end
439
452
 
440
453
  def generate_with_version(base_url: nil)
441
- registry_url = generate(base_url: base_url)
454
+ return generate(base_url: base_url) unless @purl.version
455
+
456
+ pattern_config = REGISTRY_PATTERNS[@purl.type.downcase]
457
+
458
+ if base_url
459
+ # Use custom base URL with version
460
+ generate_with_custom_base_url_and_version(base_url, pattern_config)
461
+ elsif pattern_config[:uri_template_with_version] || pattern_config[:uri_template]
462
+ # Use version-specific URI template if available
463
+ template = select_uri_template(pattern_config, include_version: true)
464
+ generate_with_uri_template(template, include_version: true)
465
+ else
466
+ # Fall back to legacy version handling
467
+ registry_url = generate(base_url: base_url)
468
+
469
+ case @purl.type.downcase
470
+ when "npm"
471
+ "#{registry_url}/v/#{@purl.version}"
472
+ when "pypi"
473
+ "#{registry_url}#{@purl.version}/"
474
+ when "gem"
475
+ "#{registry_url}/versions/#{@purl.version}"
476
+ when "maven"
477
+ "#{registry_url}/#{@purl.version}"
478
+ when "nuget"
479
+ "#{registry_url}/#{@purl.version}"
480
+ else
481
+ registry_url
482
+ end
483
+ end
484
+ end
485
+
486
+ private
487
+
488
+ def select_uri_template(pattern_config, include_version: false)
489
+ if include_version
490
+ if @purl.namespace && pattern_config[:uri_template_with_version]
491
+ pattern_config[:uri_template_with_version]
492
+ elsif !@purl.namespace && pattern_config[:uri_template_with_version_no_namespace]
493
+ pattern_config[:uri_template_with_version_no_namespace]
494
+ elsif pattern_config[:uri_template_with_version]
495
+ pattern_config[:uri_template_with_version]
496
+ elsif @purl.namespace && pattern_config[:uri_template]
497
+ pattern_config[:uri_template]
498
+ elsif !@purl.namespace && pattern_config[:uri_template_no_namespace]
499
+ pattern_config[:uri_template_no_namespace]
500
+ else
501
+ pattern_config[:uri_template]
502
+ end
503
+ else
504
+ if @purl.namespace && pattern_config[:uri_template]
505
+ pattern_config[:uri_template]
506
+ elsif !@purl.namespace && pattern_config[:uri_template_no_namespace]
507
+ pattern_config[:uri_template_no_namespace]
508
+ else
509
+ pattern_config[:uri_template]
510
+ end
511
+ end
512
+ end
513
+
514
+ def generate_with_uri_template(template, include_version: false)
515
+ variables = {
516
+ name: @purl.name
517
+ }
518
+
519
+ # Add namespace if present and required
520
+ if @purl.namespace
521
+ variables[:namespace] = @purl.namespace
522
+ end
523
+
524
+ # Add version if requested and present
525
+ if include_version && @purl.version
526
+ variables[:version] = @purl.version
527
+ end
528
+
529
+ # Handle namespace requirements based on package type
530
+ case @purl.type.downcase
531
+ when "composer", "maven", "swift", "elm"
532
+ unless @purl.namespace
533
+ raise MissingRegistryInfoError.new(
534
+ "#{@purl.type.capitalize} packages require a namespace",
535
+ type: @purl.type,
536
+ missing: "namespace"
537
+ )
538
+ end
539
+ end
540
+
541
+ # Build the URL manually to avoid encoding issues with special characters like @
542
+ result = template.pattern
543
+
544
+ variables.each do |key, value|
545
+ result = result.gsub("{#{key}}", value.to_s)
546
+ end
547
+
548
+ result
549
+ end
550
+
551
+ def generate_with_custom_base_url_and_version(custom_base_url, pattern_config)
552
+ # For now, fall back to the existing custom base URL method and add version
553
+ base_result = generate_with_custom_base_url(custom_base_url, pattern_config)
442
554
 
443
555
  case @purl.type.downcase
444
556
  when "npm"
445
- @purl.version ? "#{registry_url}/v/#{@purl.version}" : registry_url
557
+ "#{base_result}/v/#{@purl.version}"
446
558
  when "pypi"
447
- @purl.version ? "#{registry_url}#{@purl.version}/" : registry_url
559
+ "#{base_result}#{@purl.version}/"
448
560
  when "gem"
449
- @purl.version ? "#{registry_url}/versions/#{@purl.version}" : registry_url
450
- when "maven"
451
- @purl.version ? "#{registry_url}/#{@purl.version}" : registry_url
452
- when "nuget"
453
- @purl.version ? "#{registry_url}/#{@purl.version}" : registry_url
561
+ "#{base_result}/versions/#{@purl.version}"
562
+ when "maven", "nuget"
563
+ "#{base_result}/#{@purl.version}"
454
564
  else
455
- # For other types, just return the base URL since version-specific URLs vary
456
- registry_url
565
+ base_result
457
566
  end
458
567
  end
459
568
 
460
- private
461
-
462
569
  def generate_with_custom_base_url(custom_base_url, pattern_config)
463
570
 
464
571
  # Replace the base URL in the pattern lambda
data/lib/purl/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Purl
4
- VERSION = "1.1.2"
4
+ VERSION = "1.3.1"
5
5
  end