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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +170 -1
- data/exe/purl +484 -0
- data/lib/purl/lookup.rb +194 -0
- data/lib/purl/lookup_formatter.rb +119 -0
- data/lib/purl/package_url.rb +30 -0
- data/lib/purl/version.rb +1 -1
- data/lib/purl.rb +8 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f5be58ada7f5731a371cee07f0175e020aa7a49ea7d228c88e1af4c2554d5ecc
|
|
4
|
+
data.tar.gz: 96e6ff76c5626c4c414651324eee74c4845e4fb337b93470dc534d49622facfc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
##
|
|
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
|
data/lib/purl/lookup.rb
ADDED
|
@@ -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
|
data/lib/purl/package_url.rb
CHANGED
|
@@ -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
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
|
+
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
|