purl 1.4.0 → 1.5.0

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: 4a3bf4cd374d69577598e05fa94658b7768c94b3522314a3382d1f9b872b203b
4
- data.tar.gz: e1ddc0ec9b9ec7d675c9eeff8527af612610bcce7bc344313fe5c79a4975bd7d
3
+ metadata.gz: f5be58ada7f5731a371cee07f0175e020aa7a49ea7d228c88e1af4c2554d5ecc
4
+ data.tar.gz: 96e6ff76c5626c4c414651324eee74c4845e4fb337b93470dc534d49622facfc
5
5
  SHA512:
6
- metadata.gz: 6a4e84903907467f13205f4f9c31c29e67945f83c87d68f577008598d50da3f702d98b1c0e115a5c4a441a2babfdae6b6463da5e125f5f1630476d983a116849
7
- data.tar.gz: b6e45ef48f2fb2d52eea945b658a24a3e3cc4236c7656ba21b8287bb5eb7b662d7adf9d46148771b48cfcb884f5f3fcce0bbbaffb54f3d7126a3d4f1f5f297a8
6
+ metadata.gz: adfb3b0b945ad8787738388c3b40087ae662974ba60ff3783100ba845c8e19e8fc73c4480b3296a5df701080af31919dbea66d0c5d3803ec29e18a098da0d837
7
+ data.tar.gz: a96abd807ede7d4c6eaff1cb0bdd6bacdfc8723e3dad839f17ecbc7b3502edb9b24e2f32f84fec3b3c100e7ec5cd3a940229fc6421f964c9927fc370587948a3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.0] - 2025-08-06
11
+
12
+ ### Added
13
+ - `versionless` convenience method to create a PackageURL without version component
14
+ - `lookup` command to CLI for fetching package information from ecosyste.ms API with version-specific details
15
+ - `Purl::Lookup` class for programmatic package information lookup
16
+ - `lookup` instance method on `PackageURL` for convenient package information retrieval
17
+ - `Purl::LookupFormatter` class for customizable lookup result formatting
18
+ - Package maintainer information display in lookup results
19
+
10
20
  ## [1.4.0] - 2025-01-06
11
21
 
12
22
  ### Added
data/exe/purl CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  require "optparse"
5
5
  require "json"
6
+ require "net/http"
7
+ require "uri"
6
8
  require_relative "../lib/purl"
7
9
 
8
10
  class PurlCLI
@@ -40,6 +42,8 @@ class PurlCLI
40
42
  url_command(args)
41
43
  when "info"
42
44
  info_command(args)
45
+ when "lookup"
46
+ lookup_command(args)
43
47
  when "--help", "-h", "help"
44
48
  puts usage
45
49
  exit 0
@@ -66,6 +70,7 @@ class PurlCLI
66
70
  purl [--json] url <purl-string> Convert PURL to registry URL
67
71
  purl [--json] generate [options] Generate PURL from components
68
72
  purl [--json] info [type] Show information about PURL types
73
+ purl [--json] lookup <purl-string> Look up package information from ecosyste.ms
69
74
  purl --version Show version
70
75
  purl --help Show this help
71
76
 
@@ -80,6 +85,7 @@ class PurlCLI
80
85
  purl url "pkg:gem/rails@7.0.0"
81
86
  purl generate --type gem --name rails --version 7.0.0
82
87
  purl --json info gem
88
+ purl lookup "pkg:cargo/rand"
83
89
  USAGE
84
90
  end
85
91
 
@@ -417,6 +423,49 @@ class PurlCLI
417
423
  end
418
424
  end
419
425
 
426
+ def lookup_command(args)
427
+ if args.empty?
428
+ output_error("PURL string required")
429
+ exit 1
430
+ end
431
+
432
+ purl_string = args[0]
433
+
434
+ begin
435
+ # Validate PURL first
436
+ purl = Purl.parse(purl_string)
437
+
438
+ # Use the library lookup method
439
+ info = purl.lookup(user_agent: "purl-ruby-cli/#{Purl::VERSION}")
440
+
441
+ # Use formatter to generate output
442
+ formatter = Purl::LookupFormatter.new
443
+
444
+ if @json_output
445
+ result = formatter.format_json(info, purl)
446
+ puts JSON.pretty_generate(result)
447
+ exit 1 unless result[:success]
448
+ else
449
+ if info
450
+ puts formatter.format_text(info, purl)
451
+ else
452
+ puts "Package not found in ecosyste.ms database"
453
+ exit 1
454
+ end
455
+ end
456
+
457
+ rescue Purl::Error => e
458
+ output_error("Invalid PURL: #{e.message}")
459
+ exit 1
460
+ rescue Purl::LookupError => e
461
+ output_error("Lookup failed: #{e.message}")
462
+ exit 1
463
+ rescue StandardError => e
464
+ output_error("Lookup failed: #{e.message}")
465
+ exit 1
466
+ end
467
+ end
468
+
420
469
  def output_error(message)
421
470
  if @json_output
422
471
  result = {
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Purl
8
+ # Provides lookup functionality for packages using the ecosyste.ms API
9
+ class Lookup
10
+ ECOSYSTE_MS_API_BASE = "https://packages.ecosyste.ms/api/v1"
11
+
12
+ # Initialize a new Lookup instance
13
+ #
14
+ # @param user_agent [String] User agent string for API requests
15
+ # @param timeout [Integer] Request timeout in seconds
16
+ def initialize(user_agent: nil, timeout: 10)
17
+ @user_agent = user_agent || "purl-ruby/#{Purl::VERSION}"
18
+ @timeout = timeout
19
+ end
20
+
21
+ # Look up package information for a given PURL
22
+ #
23
+ # @param purl [String, PackageURL] PURL string or PackageURL object
24
+ # @return [Hash, nil] Package information hash or nil if not found
25
+ # @raise [LookupError] if the lookup fails due to network or API errors
26
+ #
27
+ # @example
28
+ # lookup = Purl::Lookup.new
29
+ # info = lookup.package_info("pkg:cargo/rand@0.9.2")
30
+ # puts info[:package][:name] # => "rand"
31
+ # puts info[:version][:published_at] if info[:version] # => "2025-07-20T17:47:01.870Z"
32
+ def package_info(purl)
33
+ purl_obj = purl.is_a?(PackageURL) ? purl : PackageURL.parse(purl.to_s)
34
+
35
+ # Make API request to ecosyste.ms
36
+ uri = URI("#{ECOSYSTE_MS_API_BASE}/packages/lookup")
37
+ uri.query = URI.encode_www_form({ purl: purl_obj.to_s })
38
+
39
+ response_data = make_request(uri)
40
+
41
+ return nil unless response_data.is_a?(Array) && response_data.length > 0
42
+
43
+ package_data = response_data[0]
44
+
45
+ result = {
46
+ purl: purl_obj.to_s,
47
+ package: extract_package_info(package_data)
48
+ }
49
+
50
+ # If PURL has a version and we have a versions_url, fetch version-specific details
51
+ if purl_obj.version && package_data["versions_url"]
52
+ version_info = fetch_version_info(package_data["versions_url"], purl_obj.version)
53
+ result[:version] = version_info if version_info
54
+ end
55
+
56
+ result
57
+ end
58
+
59
+ # Look up version information for a specific version of a package
60
+ #
61
+ # @param purl [String, PackageURL] PURL string or PackageURL object (must include version)
62
+ # @return [Hash, nil] Version information hash or nil if not found
63
+ # @raise [LookupError] if the lookup fails due to network or API errors
64
+ # @raise [ArgumentError] if the PURL doesn't include a version
65
+ #
66
+ # @example
67
+ # lookup = Purl::Lookup.new
68
+ # version_info = lookup.version_info("pkg:cargo/rand@0.9.2")
69
+ # puts version_info[:published_at] # => "2025-07-20T17:47:01.870Z"
70
+ def version_info(purl)
71
+ purl_obj = purl.is_a?(PackageURL) ? purl : PackageURL.parse(purl.to_s)
72
+
73
+ raise ArgumentError, "PURL must include a version" unless purl_obj.version
74
+
75
+ # First get the package info to get the versions_url
76
+ package_result = package_info(purl_obj.versionless)
77
+ return nil unless package_result && package_result[:package][:versions_url]
78
+
79
+ fetch_version_info(package_result[:package][:versions_url], purl_obj.version)
80
+ end
81
+
82
+ private
83
+
84
+ def make_request(uri)
85
+ http = Net::HTTP.new(uri.host, uri.port)
86
+ http.use_ssl = true
87
+ http.read_timeout = @timeout
88
+ http.open_timeout = @timeout
89
+
90
+ request = Net::HTTP::Get.new(uri)
91
+ request["User-Agent"] = @user_agent
92
+
93
+ response = http.request(request)
94
+
95
+ case response.code.to_i
96
+ when 200
97
+ JSON.parse(response.body)
98
+ when 404
99
+ nil
100
+ else
101
+ raise LookupError, "API request failed with status #{response.code}"
102
+ end
103
+ rescue JSON::ParserError => e
104
+ raise LookupError, "Failed to parse API response: #{e.message}"
105
+ rescue Net::TimeoutError, Net::OpenTimeout, Net::ReadTimeout => e
106
+ raise LookupError, "Request timeout: #{e.message}"
107
+ rescue StandardError => e
108
+ raise LookupError, "Lookup failed: #{e.message}"
109
+ end
110
+
111
+ def extract_package_info(package_data)
112
+ {
113
+ name: package_data["name"],
114
+ ecosystem: package_data["ecosystem"],
115
+ description: package_data["description"],
116
+ homepage: package_data["homepage"],
117
+ repository_url: package_data["repository_url"],
118
+ registry_url: package_data["registry_url"],
119
+ licenses: package_data["licenses"],
120
+ latest_version: package_data["latest_release_number"],
121
+ latest_version_published_at: package_data["latest_release_published_at"],
122
+ versions_count: package_data["versions_count"],
123
+ keywords: package_data["keywords_array"],
124
+ install_command: package_data["install_command"],
125
+ documentation_url: package_data["documentation_url"],
126
+ maintainers: extract_maintainers(package_data["maintainers"]),
127
+ versions_url: package_data["versions_url"]
128
+ }
129
+ end
130
+
131
+ def fetch_version_info(versions_url, version)
132
+ return nil unless versions_url && version
133
+
134
+ begin
135
+ uri = URI("#{versions_url}/#{URI.encode_www_form_component(version)}")
136
+ data = make_request(uri)
137
+
138
+ return nil unless data
139
+
140
+ # Extract relevant version information
141
+ version_info = {
142
+ number: data["number"],
143
+ published_at: data["published_at"],
144
+ version_url: data["version_url"],
145
+ download_url: data["download_url"],
146
+ registry_url: data["registry_url"],
147
+ documentation_url: data["documentation_url"],
148
+ install_command: data["install_command"]
149
+ }
150
+
151
+ # Add metadata if available
152
+ if data["metadata"]
153
+ metadata = data["metadata"]
154
+ version_info[:downloads] = metadata["downloads"] if metadata["downloads"]
155
+ version_info[:size] = metadata["crate_size"] || metadata["size"] if metadata["crate_size"] || metadata["size"]
156
+ version_info[:yanked] = metadata["yanked"] if metadata.key?("yanked")
157
+
158
+ if metadata["published_by"] && metadata["published_by"].is_a?(Hash)
159
+ published_by = metadata["published_by"]
160
+ if published_by["name"] && published_by["login"]
161
+ version_info[:published_by] = "#{published_by["name"]} (#{published_by["login"]})"
162
+ elsif published_by["login"]
163
+ version_info[:published_by] = published_by["login"]
164
+ end
165
+ end
166
+ end
167
+
168
+ version_info
169
+ rescue StandardError
170
+ # Don't fail if version lookup fails
171
+ nil
172
+ end
173
+ end
174
+
175
+ def extract_maintainers(maintainers_data)
176
+ return nil unless maintainers_data && maintainers_data.is_a?(Array)
177
+
178
+ maintainers_data.map do |maintainer|
179
+ {
180
+ login: maintainer["login"],
181
+ name: maintainer["name"],
182
+ url: maintainer["url"]
183
+ }.compact # Remove nil values
184
+ end
185
+ end
186
+ end
187
+
188
+ # Error raised when package lookup fails
189
+ class LookupError < Error
190
+ def initialize(message)
191
+ super(message)
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purl
4
+ # Formats package lookup results for human-readable display
5
+ class LookupFormatter
6
+ def initialize
7
+ end
8
+
9
+ # Format package lookup results for console output
10
+ #
11
+ # @param lookup_result [Hash] Result from Purl::Lookup#package_info
12
+ # @param purl [PackageURL] Original PURL object
13
+ # @return [String] Formatted output string
14
+ def format_text(lookup_result, purl)
15
+ return "Package not found" unless lookup_result
16
+
17
+ package = lookup_result[:package]
18
+ version_info = lookup_result[:version]
19
+
20
+ output = []
21
+
22
+ # Package header
23
+ output << "Package: #{package[:name]} (#{package[:ecosystem]})"
24
+ output << "#{package[:description]}" if package[:description]
25
+
26
+ # Keywords - add without extra spacing, the blank line before Version Info will handle spacing
27
+ if package[:keywords] && !package[:keywords].empty?
28
+ output << "Keywords: #{package[:keywords].join(", ")}"
29
+ end
30
+
31
+ # Version Information section
32
+ output << ""
33
+ output << "Version Information:"
34
+ if package[:latest_version]
35
+ output << " Latest: #{package[:latest_version]}"
36
+ output << " Published: #{package[:latest_version_published_at]}" if package[:latest_version_published_at]
37
+ end
38
+ output << " Total versions: #{format_number(package[:versions_count])}" if package[:versions_count]
39
+
40
+ # Links section
41
+ output << ""
42
+ output << "Links:"
43
+ output << " Homepage: #{package[:homepage]}" if package[:homepage]
44
+ output << " Repository: #{package[:repository_url]}" if package[:repository_url]
45
+ output << " Registry: #{package[:registry_url]}" if package[:registry_url]
46
+ output << " Documentation: #{package[:documentation_url]}" if package[:documentation_url]
47
+
48
+ # Package Info section
49
+ if package[:licenses] || package[:install_command] || package[:maintainers]
50
+ output << ""
51
+ output << "Package Info:"
52
+ output << " License: #{package[:licenses]}" if package[:licenses]
53
+ output << " Install: #{package[:install_command]}" if package[:install_command]
54
+
55
+ if package[:maintainers] && !package[:maintainers].empty?
56
+ output << " Maintainers:"
57
+ package[:maintainers].each do |maintainer|
58
+ if maintainer[:name] && maintainer[:login]
59
+ output << " #{maintainer[:name]} (#{maintainer[:login]})"
60
+ elsif maintainer[:login]
61
+ output << " #{maintainer[:login]}"
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ # Version-specific details
68
+ if version_info
69
+ output << ""
70
+ output << "Specific Version (#{purl.version}):"
71
+ output << " Published: #{version_info[:published_at]}" if version_info[:published_at]
72
+ output << " Published by: #{version_info[:published_by]}" if version_info[:published_by]
73
+ output << " Downloads: #{format_number(version_info[:downloads])}" if version_info[:downloads]
74
+ output << " Size: #{format_number(version_info[:size])} bytes" if version_info[:size]
75
+ output << " Yanked: #{version_info[:yanked] ? 'Yes' : 'No'}" if version_info.key?(:yanked)
76
+
77
+ if version_info[:registry_url] || version_info[:documentation_url] || version_info[:download_url]
78
+ output << " Version Links:"
79
+ output << " Registry: #{version_info[:registry_url]}" if version_info[:registry_url]
80
+ output << " Documentation: #{version_info[:documentation_url]}" if version_info[:documentation_url]
81
+ output << " Download: #{version_info[:download_url]}" if version_info[:download_url]
82
+ output << " API: #{version_info[:version_url]}" if version_info[:version_url]
83
+ end
84
+ end
85
+
86
+ output.join("\n")
87
+ end
88
+
89
+ # Format package lookup results for JSON output
90
+ #
91
+ # @param lookup_result [Hash] Result from Purl::Lookup#package_info
92
+ # @param purl [PackageURL] Original PURL object
93
+ # @return [Hash] JSON-ready hash structure
94
+ def format_json(lookup_result, purl)
95
+ return {
96
+ success: false,
97
+ purl: purl.to_s,
98
+ error: "Package not found in ecosyste.ms database"
99
+ } unless lookup_result
100
+
101
+ result = {
102
+ success: true,
103
+ purl: purl.to_s,
104
+ package: lookup_result[:package]
105
+ }
106
+
107
+ result[:version] = lookup_result[:version] if lookup_result[:version]
108
+
109
+ result
110
+ end
111
+
112
+ private
113
+
114
+ def format_number(num)
115
+ return num.to_s unless num.is_a?(Numeric)
116
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
117
+ end
118
+ end
119
+ end
@@ -360,6 +360,36 @@ module Purl
360
360
  self.class.new(**new_attrs)
361
361
  end
362
362
 
363
+ # Create a new PackageURL without the version component
364
+ #
365
+ # @return [PackageURL] new PackageURL instance with version set to nil
366
+ #
367
+ # @example
368
+ # purl = PackageURL.parse("pkg:gem/rails@7.0.0")
369
+ # versionless = purl.versionless
370
+ # puts versionless.to_s # "pkg:gem/rails"
371
+ def versionless
372
+ with(version: nil)
373
+ end
374
+
375
+ # Look up package information using the ecosyste.ms API
376
+ #
377
+ # @param user_agent [String] User agent string for API requests
378
+ # @param timeout [Integer] Request timeout in seconds
379
+ # @return [Hash, nil] Package information hash or nil if not found
380
+ # @raise [LookupError] if the lookup fails due to network or API errors
381
+ #
382
+ # @example
383
+ # purl = PackageURL.parse("pkg:cargo/rand@0.9.2")
384
+ # info = purl.lookup
385
+ # puts info[:package][:name] # => "rand"
386
+ # puts info[:version][:published_at] if info[:version] # => "2025-07-20T17:47:01.870Z"
387
+ def lookup(user_agent: nil, timeout: 10)
388
+ require_relative "lookup"
389
+ lookup_client = Lookup.new(user_agent: user_agent, timeout: timeout)
390
+ lookup_client.package_info(self)
391
+ end
392
+
363
393
  private
364
394
 
365
395
  def validate_and_normalize_type(type)
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.4.0"
4
+ VERSION = "1.5.0"
5
5
  end
data/lib/purl.rb CHANGED
@@ -4,6 +4,8 @@ require_relative "purl/version"
4
4
  require_relative "purl/errors"
5
5
  require_relative "purl/package_url"
6
6
  require_relative "purl/registry_url"
7
+ require_relative "purl/lookup"
8
+ require_relative "purl/lookup_formatter"
7
9
 
8
10
  # The main PURL (Package URL) module providing functionality to parse,
9
11
  # validate, and generate package URLs according to the PURL specification.
@@ -21,6 +23,12 @@ require_relative "purl/registry_url"
21
23
  # purl = Purl.from_registry_url("https://rubygems.org/gems/rails")
22
24
  # puts purl.to_s # "pkg:gem/rails"
23
25
  #
26
+ # @example Package information lookup
27
+ # purl = Purl.parse("pkg:cargo/rand@0.9.2")
28
+ # info = purl.lookup
29
+ # puts info[:package][:description]
30
+ # puts info[:version][:published_at] if info[:version]
31
+ #
24
32
  # @see https://github.com/package-url/purl-spec PURL Specification
25
33
  module Purl
26
34
  # Base error class for all PURL-related errors
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: purl
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -44,6 +44,8 @@ files:
44
44
  - exe/purl
45
45
  - lib/purl.rb
46
46
  - lib/purl/errors.rb
47
+ - lib/purl/lookup.rb
48
+ - lib/purl/lookup_formatter.rb
47
49
  - lib/purl/package_url.rb
48
50
  - lib/purl/registry_url.rb
49
51
  - lib/purl/version.rb