purl 1.3.1 → 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: 6c6c3fd2d17a4601a4c296b18ce67d41c423a5893aeb2b79cd484c0028863abd
4
- data.tar.gz: 2c5e92ca69e3bb8442df158f34944fad645319cdbac5fb45875c7c8521be5d0c
3
+ metadata.gz: f5be58ada7f5731a371cee07f0175e020aa7a49ea7d228c88e1af4c2554d5ecc
4
+ data.tar.gz: 96e6ff76c5626c4c414651324eee74c4845e4fb337b93470dc534d49622facfc
5
5
  SHA512:
6
- metadata.gz: 34e7c52f43f92146d605e06338f25b5d12f80f495e320901aed32206dc3e743029be9b51eb5bcd1f84ef46ca450a76ac45fa7c74da5b2506eb3d53b82db6c840
7
- data.tar.gz: 7941f3f3cb65695448599c590e75ef06198fdafe4739e7587e0f7518f6e58fc948896452c38136a3c1a1e6e0d52acac4b766c2a5f2dd22d0a33f8b2e1b61feb1
6
+ metadata.gz: adfb3b0b945ad8787738388c3b40087ae662974ba60ff3783100ba845c8e19e8fc73c4480b3296a5df701080af31919dbea66d0c5d3803ec29e18a098da0d837
7
+ data.tar.gz: a96abd807ede7d4c6eaff1cb0bdd6bacdfc8723e3dad839f17ecbc7b3502edb9b24e2f32f84fec3b3c100e7ec5cd3a940229fc6421f964c9927fc370587948a3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ 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
+
20
+ ## [1.4.0] - 2025-01-06
21
+
22
+ ### Added
23
+ - Command-line interface with parse, validate, convert, url, generate, and info commands plus JSON output support
24
+
10
25
  ## [1.3.1] - 2025-08-04
11
26
 
12
27
  ### Fixed
data/README.md CHANGED
@@ -16,6 +16,7 @@ This library features comprehensive error handling with namespaced error types,
16
16
 
17
17
  ## Features
18
18
 
19
+ - **Command-line interface** with parse, validate, convert, generate, and info commands plus JSON output
19
20
  - **Comprehensive PURL parsing and validation** with 37 package types (32 official + 5 additional ecosystems)
20
21
  - **Better error handling** with namespaced error classes and contextual information
21
22
  - **Bidirectional registry URL conversion** - generate registry URLs from PURLs and parse PURLs from registry URLs
@@ -46,7 +47,175 @@ Or install it yourself as:
46
47
  gem install purl
47
48
  ```
48
49
 
49
- ## Usage
50
+ ## Command Line Interface
51
+
52
+ The purl gem includes a command-line interface that provides convenient access to all parsing, validation, conversion, and generation functionality.
53
+
54
+ ### Installation
55
+
56
+ The CLI is automatically available after installing the gem:
57
+
58
+ ```bash
59
+ gem install purl
60
+ purl --help
61
+ ```
62
+
63
+ ### Available Commands
64
+
65
+ ```bash
66
+ purl parse <purl-string> # Parse and display PURL components
67
+ purl validate <purl-string> # Validate a PURL (exit code indicates success)
68
+ purl convert <registry-url> # Convert registry URL to PURL
69
+ purl url <purl-string> # Convert PURL to registry URL
70
+ purl generate [options] # Generate PURL from components
71
+ purl info [type] # Show information about PURL types
72
+ ```
73
+
74
+ ### JSON Output
75
+
76
+ All commands support JSON output with the `--json` flag:
77
+
78
+ ```bash
79
+ purl --json parse "pkg:gem/rails@7.0.0"
80
+ purl --json info gem
81
+ ```
82
+
83
+ ### Command Examples
84
+
85
+ #### Parse a PURL
86
+ ```bash
87
+ $ purl parse "pkg:gem/rails@7.0.0"
88
+ Valid PURL: pkg:gem/rails@7.0.0
89
+ Components:
90
+ Type: gem
91
+ Namespace: (none)
92
+ Name: rails
93
+ Version: 7.0.0
94
+ Qualifiers: (none)
95
+ Subpath: (none)
96
+
97
+ $ purl --json parse "pkg:npm/@babel/core@7.0.0"
98
+ {
99
+ "success": true,
100
+ "purl": "pkg:npm/%40babel/core@7.0.0",
101
+ "components": {
102
+ "type": "npm",
103
+ "namespace": "@babel",
104
+ "name": "core",
105
+ "version": "7.0.0",
106
+ "qualifiers": {},
107
+ "subpath": null
108
+ }
109
+ }
110
+ ```
111
+
112
+ #### Validate a PURL
113
+ ```bash
114
+ $ purl validate "pkg:gem/rails@7.0.0"
115
+ Valid PURL
116
+
117
+ $ purl validate "invalid-purl"
118
+ Invalid PURL: PURL must start with 'pkg:'
119
+ ```
120
+
121
+ #### Convert Registry URL to PURL
122
+ ```bash
123
+ $ purl convert "https://rubygems.org/gems/rails"
124
+ pkg:gem/rails
125
+
126
+ $ purl convert "https://www.npmjs.com/package/@babel/core"
127
+ pkg:npm/@babel/core
128
+ ```
129
+
130
+ #### Convert PURL to Registry URL
131
+ ```bash
132
+ $ purl url "pkg:gem/rails@7.0.0"
133
+ https://rubygems.org/gems/rails
134
+
135
+ $ purl url "pkg:npm/@babel/core@7.0.0"
136
+ https://www.npmjs.com/package/@babel/core
137
+
138
+ $ purl --json url "pkg:gem/rails@7.0.0"
139
+ {
140
+ "success": true,
141
+ "purl": "pkg:gem/rails@7.0.0",
142
+ "registry_url": "https://rubygems.org/gems/rails",
143
+ "type": "gem"
144
+ }
145
+ ```
146
+
147
+ #### Generate a PURL
148
+ ```bash
149
+ $ purl generate --type gem --name rails --version 7.0.0
150
+ pkg:gem/rails@7.0.0
151
+
152
+ $ purl generate --type npm --namespace @babel --name core --version 7.0.0 --qualifiers "arch=x64,os=linux"
153
+ pkg:npm/%40babel/core@7.0.0?arch=x64&os=linux
154
+ ```
155
+
156
+ #### Show Type Information
157
+ ```bash
158
+ $ purl info gem
159
+ Type: gem
160
+ Known: Yes
161
+ Description: RubyGems
162
+ Default registry: https://rubygems.org
163
+ Registry URL generation: Yes
164
+ Reverse parsing: Yes
165
+ Examples:
166
+ pkg:gem/ruby-advisory-db-check@0.12.4
167
+ pkg:gem/rails@7.0.4
168
+ pkg:gem/bundler@2.3.26
169
+ Registry URL patterns:
170
+ https://rubygems.org/gems/:name
171
+ https://rubygems.org/gems/:name/versions/:version
172
+
173
+ $ purl info # Shows all types
174
+ Known PURL types:
175
+
176
+ alpm
177
+ Description: Arch Linux packages
178
+ Registry support: No
179
+ Reverse parsing: No
180
+ ...
181
+ Total types: 37
182
+ Registry supported: 20
183
+ ```
184
+
185
+ ### Generate Options
186
+
187
+ The `generate` command supports all PURL components:
188
+
189
+ ```bash
190
+ purl generate --help
191
+ Usage: purl generate [options]
192
+ --type TYPE Package type (required)
193
+ --name NAME Package name (required)
194
+ --namespace NAMESPACE Package namespace
195
+ --version VERSION Package version
196
+ --qualifiers QUALIFIERS Qualifiers as key=value,key2=value2
197
+ --subpath SUBPATH Package subpath
198
+ -h, --help Show this help
199
+ ```
200
+
201
+ ### Exit Codes
202
+
203
+ The CLI uses standard exit codes:
204
+ - `0` - Success
205
+ - `1` - Error (invalid PURL, unsupported operation, etc.)
206
+
207
+ This makes the CLI suitable for use in scripts and CI/CD pipelines:
208
+
209
+ ```bash
210
+ if purl validate "pkg:gem/rails@7.0.0"; then
211
+ echo "Valid PURL"
212
+ else
213
+ echo "Invalid PURL"
214
+ exit 1
215
+ fi
216
+ ```
217
+
218
+ ## Library Usage
50
219
 
51
220
  ### Basic PURL Parsing
52
221
 
data/exe/purl ADDED
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "json"
6
+ require "net/http"
7
+ require "uri"
8
+ require_relative "../lib/purl"
9
+
10
+ class PurlCLI
11
+ def self.run(args = ARGV)
12
+ new.run(args)
13
+ end
14
+
15
+ def initialize
16
+ @json_output = false
17
+ end
18
+
19
+ def run(args)
20
+ if args.empty?
21
+ puts usage
22
+ exit 1
23
+ end
24
+
25
+ # Check for global --json flag
26
+ if args.include?("--json")
27
+ @json_output = true
28
+ args.delete("--json")
29
+ end
30
+
31
+ command = args.shift
32
+ case command
33
+ when "parse"
34
+ parse_command(args)
35
+ when "validate"
36
+ validate_command(args)
37
+ when "convert"
38
+ convert_command(args)
39
+ when "generate"
40
+ generate_command(args)
41
+ when "url"
42
+ url_command(args)
43
+ when "info"
44
+ info_command(args)
45
+ when "lookup"
46
+ lookup_command(args)
47
+ when "--help", "-h", "help"
48
+ puts usage
49
+ exit 0
50
+ when "--version", "-v"
51
+ puts Purl::VERSION
52
+ exit 0
53
+ else
54
+ puts "Unknown command: #{command}"
55
+ puts usage
56
+ exit 1
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def usage
63
+ <<~USAGE
64
+ purl - Parse, validate, convert and generate Package URLs (PURLs)
65
+
66
+ Usage:
67
+ purl [--json] parse <purl-string> Parse and display PURL components
68
+ purl [--json] validate <purl-string> Validate a PURL (exit code indicates success)
69
+ purl [--json] convert <registry-url> Convert registry URL to PURL
70
+ purl [--json] url <purl-string> Convert PURL to registry URL
71
+ purl [--json] generate [options] Generate PURL from components
72
+ purl [--json] info [type] Show information about PURL types
73
+ purl [--json] lookup <purl-string> Look up package information from ecosyste.ms
74
+ purl --version Show version
75
+ purl --help Show this help
76
+
77
+ Global Options:
78
+ --json Output results in JSON format
79
+
80
+ Examples:
81
+ purl parse "pkg:gem/rails@7.0.0"
82
+ purl --json parse "pkg:gem/rails@7.0.0"
83
+ purl validate "pkg:npm/@babel/core@7.0.0"
84
+ purl convert "https://rubygems.org/gems/rails"
85
+ purl url "pkg:gem/rails@7.0.0"
86
+ purl generate --type gem --name rails --version 7.0.0
87
+ purl --json info gem
88
+ purl lookup "pkg:cargo/rand"
89
+ USAGE
90
+ end
91
+
92
+ def parse_command(args)
93
+ if args.empty?
94
+ output_error("PURL string required")
95
+ exit 1
96
+ end
97
+
98
+ purl_string = args[0]
99
+
100
+ begin
101
+ purl = Purl.parse(purl_string)
102
+
103
+ if @json_output
104
+ result = {
105
+ success: true,
106
+ purl: purl.to_s,
107
+ components: {
108
+ type: purl.type,
109
+ namespace: purl.namespace,
110
+ name: purl.name,
111
+ version: purl.version,
112
+ qualifiers: purl.qualifiers || {},
113
+ subpath: purl.subpath
114
+ }
115
+ }
116
+ puts JSON.pretty_generate(result)
117
+ else
118
+ puts "Valid PURL: #{purl.to_s}"
119
+ puts "Components:"
120
+ puts " Type: #{purl.type}"
121
+ puts " Namespace: #{purl.namespace || '(none)'}"
122
+ puts " Name: #{purl.name}"
123
+ puts " Version: #{purl.version || '(none)'}"
124
+
125
+ if purl.qualifiers && !purl.qualifiers.empty?
126
+ puts " Qualifiers:"
127
+ purl.qualifiers.each do |key, value|
128
+ puts " #{key}: #{value}"
129
+ end
130
+ else
131
+ puts " Qualifiers: (none)"
132
+ end
133
+
134
+ puts " Subpath: #{purl.subpath || '(none)'}"
135
+ end
136
+ rescue Purl::Error => e
137
+ output_error("Error parsing PURL: #{e.message}")
138
+ exit 1
139
+ end
140
+ end
141
+
142
+ def validate_command(args)
143
+ if args.empty?
144
+ output_error("PURL string required")
145
+ exit 1
146
+ end
147
+
148
+ purl_string = args[0]
149
+
150
+ begin
151
+ purl = Purl.parse(purl_string)
152
+ if @json_output
153
+ result = {
154
+ success: true,
155
+ valid: true,
156
+ purl: purl.to_s,
157
+ message: "Valid PURL"
158
+ }
159
+ puts JSON.pretty_generate(result)
160
+ else
161
+ puts "Valid PURL"
162
+ end
163
+ exit 0
164
+ rescue Purl::Error => e
165
+ if @json_output
166
+ result = {
167
+ success: false,
168
+ valid: false,
169
+ purl: purl_string,
170
+ error: e.message
171
+ }
172
+ puts JSON.pretty_generate(result)
173
+ else
174
+ puts "Invalid PURL: #{e.message}"
175
+ end
176
+ exit 1
177
+ end
178
+ end
179
+
180
+ def convert_command(args)
181
+ if args.empty?
182
+ output_error("Registry URL required")
183
+ exit 1
184
+ end
185
+
186
+ registry_url = args[0]
187
+
188
+ begin
189
+ purl = Purl.from_registry_url(registry_url)
190
+ if @json_output
191
+ result = {
192
+ success: true,
193
+ registry_url: registry_url,
194
+ purl: purl.to_s,
195
+ components: {
196
+ type: purl.type,
197
+ namespace: purl.namespace,
198
+ name: purl.name,
199
+ version: purl.version,
200
+ qualifiers: purl.qualifiers || {},
201
+ subpath: purl.subpath
202
+ }
203
+ }
204
+ puts JSON.pretty_generate(result)
205
+ else
206
+ puts purl.to_s
207
+ end
208
+ rescue Purl::Error => e
209
+ if @json_output
210
+ result = {
211
+ success: false,
212
+ registry_url: registry_url,
213
+ error: e.message
214
+ }
215
+ puts JSON.pretty_generate(result)
216
+ else
217
+ puts "Error converting URL: #{e.message}"
218
+ end
219
+ exit 1
220
+ end
221
+ end
222
+
223
+ def url_command(args)
224
+ if args.empty?
225
+ output_error("PURL string required")
226
+ exit 1
227
+ end
228
+
229
+ purl_string = args[0]
230
+
231
+ begin
232
+ purl = Purl.parse(purl_string)
233
+
234
+ unless purl.supports_registry_url?
235
+ if @json_output
236
+ result = {
237
+ success: false,
238
+ purl: purl_string,
239
+ error: "Registry URL generation not supported for type '#{purl.type}'"
240
+ }
241
+ puts JSON.pretty_generate(result)
242
+ else
243
+ puts "Error: Registry URL generation not supported for type '#{purl.type}'"
244
+ end
245
+ exit 1
246
+ end
247
+
248
+ registry_url = purl.registry_url
249
+
250
+ if @json_output
251
+ result = {
252
+ success: true,
253
+ purl: purl.to_s,
254
+ registry_url: registry_url,
255
+ type: purl.type
256
+ }
257
+ puts JSON.pretty_generate(result)
258
+ else
259
+ puts registry_url
260
+ end
261
+ rescue Purl::Error => e
262
+ if @json_output
263
+ result = {
264
+ success: false,
265
+ purl: purl_string,
266
+ error: e.message
267
+ }
268
+ puts JSON.pretty_generate(result)
269
+ else
270
+ puts "Error: #{e.message}"
271
+ end
272
+ exit 1
273
+ end
274
+ end
275
+
276
+ def generate_command(args)
277
+ options = {}
278
+ OptionParser.new do |opts|
279
+ opts.banner = "Usage: purl generate [options]"
280
+
281
+ opts.on("--type TYPE", "Package type (required)") do |v|
282
+ options[:type] = v
283
+ end
284
+
285
+ opts.on("--name NAME", "Package name (required)") do |v|
286
+ options[:name] = v
287
+ end
288
+
289
+ opts.on("--namespace NAMESPACE", "Package namespace") do |v|
290
+ options[:namespace] = v
291
+ end
292
+
293
+ opts.on("--version VERSION", "Package version") do |v|
294
+ options[:version] = v
295
+ end
296
+
297
+ opts.on("--qualifiers QUALIFIERS", "Qualifiers as key=value,key2=value2") do |v|
298
+ qualifiers = {}
299
+ v.split(",").each do |pair|
300
+ key, value = pair.split("=", 2)
301
+ if key && value
302
+ qualifiers[key.strip] = value.strip
303
+ end
304
+ end
305
+ options[:qualifiers] = qualifiers unless qualifiers.empty?
306
+ end
307
+
308
+ opts.on("--subpath SUBPATH", "Package subpath") do |v|
309
+ options[:subpath] = v
310
+ end
311
+
312
+ opts.on("-h", "--help", "Show this help") do
313
+ puts opts
314
+ exit 0
315
+ end
316
+ end.parse!(args)
317
+
318
+ unless options[:type] && options[:name]
319
+ output_error("--type and --name are required")
320
+ puts "Use 'purl generate --help' for more information" unless @json_output
321
+ exit 1
322
+ end
323
+
324
+ begin
325
+ purl = Purl::PackageURL.new(**options)
326
+ if @json_output
327
+ result = {
328
+ success: true,
329
+ purl: purl.to_s,
330
+ components: {
331
+ type: purl.type,
332
+ namespace: purl.namespace,
333
+ name: purl.name,
334
+ version: purl.version,
335
+ qualifiers: purl.qualifiers || {},
336
+ subpath: purl.subpath
337
+ }
338
+ }
339
+ puts JSON.pretty_generate(result)
340
+ else
341
+ puts purl.to_s
342
+ end
343
+ rescue Purl::Error => e
344
+ if @json_output
345
+ result = {
346
+ success: false,
347
+ error: e.message,
348
+ options: options
349
+ }
350
+ puts JSON.pretty_generate(result)
351
+ else
352
+ puts "Error generating PURL: #{e.message}"
353
+ end
354
+ exit 1
355
+ end
356
+ end
357
+
358
+ def info_command(args)
359
+ if args.empty?
360
+ # Show all types
361
+ all_info = Purl.all_type_info
362
+ metadata = Purl.types_config_metadata
363
+
364
+ if @json_output
365
+ result = {
366
+ success: true,
367
+ types: all_info,
368
+ metadata: metadata
369
+ }
370
+ puts JSON.pretty_generate(result)
371
+ else
372
+ puts "Known PURL types:"
373
+ puts
374
+
375
+ all_info.each do |type, info|
376
+ puts " #{type}"
377
+ puts " Description: #{info[:description] || 'No description available'}"
378
+ puts " Registry support: #{info[:registry_url_generation] ? 'Yes' : 'No'}"
379
+ puts " Reverse parsing: #{info[:reverse_parsing] ? 'Yes' : 'No'}"
380
+ puts
381
+ end
382
+
383
+ puts "Total types: #{metadata[:total_types]}"
384
+ puts "Registry supported: #{metadata[:registry_supported_types]}"
385
+ end
386
+ else
387
+ # Show specific type info
388
+ type = args[0]
389
+ info = Purl.type_info(type)
390
+
391
+ if @json_output
392
+ result = {
393
+ success: true,
394
+ type: info
395
+ }
396
+ puts JSON.pretty_generate(result)
397
+ else
398
+ puts "Type: #{info[:type]}"
399
+ puts "Known: #{info[:known] ? 'Yes' : 'No'}"
400
+ puts "Description: #{info[:description] || 'No description available'}"
401
+
402
+ if info[:default_registry]
403
+ puts "Default registry: #{info[:default_registry]}"
404
+ end
405
+
406
+ puts "Registry URL generation: #{info[:registry_url_generation] ? 'Yes' : 'No'}"
407
+ puts "Reverse parsing: #{info[:reverse_parsing] ? 'Yes' : 'No'}"
408
+
409
+ if info[:examples] && !info[:examples].empty?
410
+ puts "Examples:"
411
+ info[:examples].each do |example|
412
+ puts " #{example}"
413
+ end
414
+ end
415
+
416
+ if info[:route_patterns] && !info[:route_patterns].empty?
417
+ puts "Registry URL patterns:"
418
+ info[:route_patterns].each do |pattern|
419
+ puts " #{pattern}"
420
+ end
421
+ end
422
+ end
423
+ end
424
+ end
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
+
469
+ def output_error(message)
470
+ if @json_output
471
+ result = {
472
+ success: false,
473
+ error: message
474
+ }
475
+ puts JSON.pretty_generate(result)
476
+ else
477
+ puts "Error: #{message}"
478
+ end
479
+ end
480
+ end
481
+
482
+ if __FILE__ == $0
483
+ PurlCLI.run
484
+ end
@@ -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.3.1"
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.3.1
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -28,7 +28,8 @@ description: |-
28
28
  It supports 37 package types (32 official + 5 additional ecosystems) and is fully compliant with the official PURL specification test suite.
29
29
  email:
30
30
  - andrewnez@gmail.com
31
- executables: []
31
+ executables:
32
+ - purl
32
33
  extensions: []
33
34
  extra_rdoc_files: []
34
35
  files:
@@ -40,8 +41,11 @@ files:
40
41
  - README.md
41
42
  - Rakefile
42
43
  - SECURITY.md
44
+ - exe/purl
43
45
  - lib/purl.rb
44
46
  - lib/purl/errors.rb
47
+ - lib/purl/lookup.rb
48
+ - lib/purl/lookup_formatter.rb
45
49
  - lib/purl/package_url.rb
46
50
  - lib/purl/registry_url.rb
47
51
  - lib/purl/version.rb