myprecious 0.0.8 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,71 @@
1
+ require 'myprecious'
2
+ require 'pathname'
3
+
4
+ class <<MyPrecious
5
+ attr_accessor :caching_disabled
6
+
7
+ ##
8
+ # Declare a path as a data cache
9
+ #
10
+ # This method returns the path it was given in +fpath+.
11
+ #
12
+ def data_cache(fpath)
13
+ (@data_caches ||= []) << fpath
14
+ return fpath
15
+ end
16
+
17
+ ##
18
+ # Retrieve an Array of all known data caches
19
+ #
20
+ def data_caches
21
+ (@data_caches || [])
22
+ end
23
+ end
24
+
25
+ module MyPrecious
26
+ module DataCaching
27
+ ##
28
+ # Use cached data in or write data to a file cache
29
+ #
30
+ # +cache+ should be a Pathname to a file in which JSON data or can
31
+ # be cached.
32
+ #
33
+ # The block given will only be invoked if the cache does not exist or
34
+ # is stale. The block must return JSON.dump -able data.
35
+ #
36
+ def apply_cache(cache, &get_data)
37
+ cache = Pathname(cache)
38
+ if !MyPrecious.caching_disabled && cache.exist? && cache.stat.mtime > Time.now - ONE_DAY
39
+ return cache.open('r') {|inf| JSON.load(inf)}
40
+ else
41
+ # Short-circuit to error if we've already received one for filling this cache
42
+ if @data_cache_errors_fetching && @data_cache_errors_fetching[cache]
43
+ raise @data_cache_errors_fetching[cache]
44
+ end
45
+
46
+ result = begin
47
+ DataCaching.print_error_info(cache.basename('.json'), &get_data)
48
+ rescue StandardError => e
49
+ # Remember this error in case there is another attempt to fill this cache
50
+ (@data_cache_errors_fetching ||= {})[cache] = e
51
+ raise
52
+ end
53
+
54
+ cache.dirname.mkpath
55
+ cache.open('w') {|outf| JSON.dump(result, outf)}
56
+ return result
57
+ end
58
+ end
59
+ private :apply_cache
60
+
61
+
62
+ def self.print_error_info(target)
63
+ yield
64
+ rescue Interrupt
65
+ raise
66
+ rescue StandardError => e
67
+ $stderr.puts "Error fetching data for #{target}: #{e.message}"
68
+ raise
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,1175 @@
1
+ require 'json'
2
+ require 'myprecious'
3
+ require 'myprecious/data_caches'
4
+ require 'open-uri'
5
+ require 'open3'
6
+ require 'parslet'
7
+ require 'rest-client'
8
+ require 'rubygems/package'
9
+ require 'shellwords'
10
+ require 'tmpdir'
11
+ require 'zip'
12
+
13
+ module MyPrecious
14
+ class PyPackageInfo
15
+ include DataCaching
16
+
17
+ COMMON_REQ_FILE_NAMES = %w[requirements.txt Packages]
18
+ MIN_RELEASED_DAYS = 90
19
+ MIN_STABLE_DAYS = 14
20
+
21
+ PACKAGE_CACHE_DIR = MyPrecious.data_cache(DATA_DIR / "py-package-cache")
22
+ CODE_CACHE_DIR = MyPrecious.data_cache(DATA_DIR / "py-code-cache")
23
+
24
+ ACCEPTED_URI_SCHEMES = %w[
25
+ http
26
+ https
27
+ git
28
+ git+git
29
+ git+http
30
+ git+https
31
+ git+ssh
32
+ ]
33
+
34
+ ##
35
+ # Guess the name of the requirements file in the given directory
36
+ #
37
+ # Best effort (currently, consulting a static list of likely file names for
38
+ # existence), and may return +nil+.
39
+ #
40
+ def self.guess_req_file(fpath)
41
+ COMMON_REQ_FILE_NAMES.find do |fname|
42
+ fpath.join(fname).exist?
43
+ end
44
+ end
45
+
46
+ ##
47
+ # Get an appropriate, human friendly column title for an attribute
48
+ #
49
+ def self.col_title(attr)
50
+ case attr
51
+ when :name then 'Package'
52
+ else Reporting.common_col_title(attr)
53
+ end
54
+ end
55
+
56
+ ##
57
+ # Construct an instance
58
+ #
59
+ # At least one of the keywords +name:+ or +url:+ _MUST_ be provided.
60
+ #
61
+ def initialize(name: nil, version_reqs: [], url: nil, install: false)
62
+ super()
63
+ if name.nil? and url.nil?
64
+ raise ArgumentError, "At least one of name: or url: must be specified"
65
+ end
66
+ @name = name
67
+ @version_reqs = version_reqs
68
+ @url = url && URI(url)
69
+ @install = install
70
+ if pinning_req = self.version_reqs.find(&:determinative?)
71
+ current_version = pinning_req.vernum
72
+ end
73
+ end
74
+ attr_reader :name, :version_reqs, :url
75
+ attr_accessor :install
76
+ alias_method :install?, :install
77
+
78
+ ##
79
+ # Was this requirement specified as a direct reference to a URL providing
80
+ # the package?
81
+ #
82
+ def direct_reference?
83
+ !url.nil?
84
+ end
85
+
86
+ ##
87
+ # For packages specified without a name, do what is necessary to find the
88
+ # name
89
+ #
90
+ def resolve_name!
91
+ return unless direct_reference?
92
+
93
+ name_from_setup = setup_data['name']
94
+ if !@name.nil? && @name != name_from_setup
95
+ warn("Requirement file entry for #{@name} points to archive for #{name_from_setup}")
96
+ else
97
+ @name = name_from_setup
98
+ end
99
+ end
100
+
101
+ ##
102
+ # For requirements not deterministically specifying a version, determine
103
+ # which version would be installed
104
+ #
105
+ def resolve_version!
106
+ return @current_version if @current_version
107
+
108
+ if direct_reference?
109
+ # Use setup_data
110
+ @current_version = parse_version_str(setup_data['version'] || '0a0.dev0')
111
+ elsif pinning_req = self.version_reqs.find(&:determinative?)
112
+ @current_version = parse_version_str(pinning_req.vernum)
113
+ else
114
+ # Use data from pypi
115
+ puts "Resolving current version of #{name}..."
116
+ if inferred_ver = latest_version_satisfying_reqs
117
+ self.current_version = inferred_ver
118
+ puts " -> #{inferred_ver}"
119
+ else
120
+ puts " (unknown)"
121
+ end
122
+ end
123
+ end
124
+
125
+ ##
126
+ # Test if the version constraints on this package are satisfied by the
127
+ # given version
128
+ #
129
+ # All current version requirements are in #version_reqs.
130
+ #
131
+ def satisfied_by?(version)
132
+ version_reqs.all? {|r| r.satisfied_by?(version)}
133
+ end
134
+
135
+ ##
136
+ # Incorporate the requirements for this package specified in another object
137
+ # into this instance
138
+ #
139
+ def incorporate(other_req)
140
+ if other_req.name != self.name
141
+ raise ArgumentError, "Cannot incorporate requirements for #{other_req.name} into #{self.name}"
142
+ end
143
+
144
+ self.version_reqs.concat(other_req.version_reqs)
145
+ self.install ||= other_req.install
146
+ if current_version.nil? && (pinning_req = self.version_reqs.find(&:determinative?))
147
+ current_version = pinning_req.vernum
148
+ end
149
+ end
150
+
151
+ def current_version
152
+ @current_version
153
+ end
154
+
155
+ def current_version=(val)
156
+ @current_version = val.kind_of?(Version) ? val : parse_version_str(val)
157
+ end
158
+
159
+ ##
160
+ # An Array of Arrays containing version (MyPrecious::PyPackageInfo::Version
161
+ # or String) and release date (Time)
162
+ #
163
+ # The returned Array is sorted in order of descending version number, with
164
+ # strings not conforming to PEP-440 sorted lexicographically following all
165
+ # PEP-440 conformant versions, the latter presented as
166
+ # MyPrecious::PyPackageInfo::Version objects.
167
+ #
168
+ def versions_with_release
169
+ @versions ||= begin
170
+ all_releases = get_package_info.fetch('releases', {})
171
+ ver_release_pairs = all_releases.each_pair.map do |ver, info|
172
+ [
173
+ parse_version_str(ver),
174
+ info.select {|f| f['packagetype'] == 'sdist'}.map do |f|
175
+ Time.parse(f['upload_time_iso_8601'])
176
+ end.min
177
+ ].freeze
178
+ end
179
+ ver_release_pairs.reject! do |vn, rd|
180
+ (vn.kind_of?(Version) && vn.prerelease?) || rd.nil?
181
+ end
182
+ ver_release_pairs.sort! do |l, r|
183
+ case
184
+ when l[0].kind_of?(String) && r[0].kind_of?(Version) then -1
185
+ when l[0].kind_of?(Version) && r[0].kind_of?(String) then 1
186
+ else l <=> r
187
+ end
188
+ end
189
+ ver_release_pairs.reverse!
190
+ ver_release_pairs.freeze
191
+ end
192
+ end
193
+
194
+ def latest_version_satisfying_reqs
195
+ versions_with_release.each do |ver, rel_date|
196
+ return ver if self.satisfied_by?(ver.to_s)
197
+ return ver if version_reqs.all? {|req| req.satisfied_by?(ver.to_s)}
198
+ end
199
+ return nil
200
+ end
201
+
202
+ ##
203
+ # Age in days of the current version
204
+ #
205
+ def age
206
+ return @age if defined? @age
207
+ @age = get_age
208
+ end
209
+
210
+ def latest_version
211
+ versions_with_release[0][0].to_s
212
+ end
213
+
214
+ def latest_released
215
+ versions_with_release[0][1]
216
+ end
217
+
218
+ ##
219
+ # Version number recommended based on stability criteria
220
+ #
221
+ # May return +nil+ if no version meets the established criteria
222
+ #
223
+ def recommended_version
224
+ return nil if versions_with_release.empty?
225
+ return @recommended_version if defined? @recommended_version
226
+
227
+ orig_time_horizon = time_horizon = \
228
+ Time.now - (MIN_RELEASED_DAYS * ONE_DAY)
229
+ horizon_versegs = nil
230
+ versions_with_release.each do |vn, rd|
231
+ if vn.kind_of?(Version)
232
+ horizon_versegs = nonpatch_versegs(vn)
233
+ break
234
+ end
235
+ end
236
+
237
+ versions_with_release.each do |ver, released|
238
+ next if ver.kind_of?(String) || ver.prerelease?
239
+ return (@recommended_version = current_version) if current_version && current_version >= ver
240
+
241
+ # Reset the time-horizon clock if moving back into previous patch-series
242
+ if (nonpatch_versegs(ver) <=> horizon_versegs) < 0
243
+ time_horizon = orig_time_horizon
244
+ end
245
+
246
+ if released < time_horizon && version_reqs.all? {|r| r.satisfied_by?(ver, strict: false)}
247
+ return (@recommended_version = ver)
248
+ end
249
+ time_horizon = [time_horizon, released - (MIN_STABLE_DAYS * ONE_DAY)].min
250
+ end
251
+ return (@recommended_version = nil)
252
+ end
253
+
254
+ def homepage_uri
255
+ get_package_info['info']['home_page']
256
+ end
257
+
258
+ def license
259
+ # TODO: Implement better, showing difference between current and recommended
260
+ LicenseDescription.new(get_package_info['info']['license'])
261
+ end
262
+
263
+ def changelog
264
+ # This is wrong
265
+ info = get_package_info['info']
266
+ return info['project_url']
267
+ end
268
+
269
+ def days_between_current_and_recommended
270
+ v, cv_rel = versions_with_release.find do |v, r|
271
+ case
272
+ when current_version.prerelease?
273
+ v < current_version
274
+ else
275
+ v == current_version
276
+ end
277
+ end || []
278
+ v, rv_rel = versions_with_release.find {|v, r| v == recommended_version} || []
279
+ return nil if cv_rel.nil? || rv_rel.nil?
280
+
281
+ return ((rv_rel - cv_rel) / ONE_DAY).to_i
282
+ end
283
+
284
+ def obsolescence
285
+ at_least_moderate = false
286
+ if current_version.kind_of?(Version) && recommended_version
287
+ cv_major = [current_version.epoch, current_version.final.first]
288
+ rv_major = [recommended_version.epoch, recommended_version.final.first]
289
+
290
+ case
291
+ when rv_major[0] < cv_major[0]
292
+ return nil
293
+ when cv_major[0] < rv_major[0]
294
+ # Can't compare, rely on days_between_current_and_recommended
295
+ when cv_major[1] + 1 < rv_major[1]
296
+ return :severe
297
+ when cv_major[1] < rv_major[1]
298
+ at_least_moderate = true
299
+ end
300
+
301
+ days_between = days_between_current_and_recommended
302
+
303
+ return Reporting.obsolescence_by_age(
304
+ days_between,
305
+ at_least_moderate: at_least_moderate,
306
+ )
307
+ end
308
+ end
309
+
310
+ ##
311
+ # Parses requirement line based on grammar in PEP 508
312
+ # (https://www.python.org/dev/peps/pep-0508/#complete-grammar)
313
+ #
314
+ class ReqSpecParser < Parslet::Parser
315
+ COMPARATORS = %w[<= < != === == >= > ~=]
316
+ ENVVARS = %w[
317
+ python_version python_full_version
318
+ os_name sys_platform platform_release
319
+ platform_system platform_version
320
+ platform_machine platform_python_implementation
321
+ implementation_name implementation_version
322
+ extra
323
+ ]
324
+ root :specification
325
+
326
+ rule(:wsp) { match[' \t'] }
327
+ rule(:wsp_r) { wsp.repeat }
328
+ rule(:version_cmp) { wsp_r >> COMPARATORS.map {|o| str(o)}.inject(&:|) }
329
+ rule(:version) { wsp_r >> (match['[:alnum:]_.*+!-']).repeat(1) }
330
+ rule(:version_one) { (version_cmp.as(:op) >> version.as(:ver)).as(:verreq) }
331
+ rule(:version_many) { version_one.repeat(1,1) >> (wsp_r >> str(',') >> version_one).repeat }
332
+ rule(:versionspec) { (str('(') >> version_many >> str(')')) | version_many }
333
+ rule(:urlspec) { str('@') >> wsp_r >> uri_reference.as(:url) }
334
+ rule(:marker_op) { version_cmp | (wsp_r >> str('in')) | (wsp_r >> str('not') >> wsp.repeat(1) >> str('in')) }
335
+ rule(:python_str_c) { (wsp | match['A-Za-z0-9().{}_*#:;,/?\[\]!~`@$%^&=+|<>-']) }
336
+ rule(:dquote) { str('"') }
337
+ rule(:squote) { str("'") }
338
+ rule(:python_str) {
339
+ (squote >> (python_str_c | dquote).repeat.as(:str) >> squote) | \
340
+ (dquote >> (python_str_c | squote).repeat.as(:str) >> dquote)
341
+ }
342
+ rule(:env_var) { ENVVARS.map {|n| str(n)}.inject(&:|) }
343
+ rule(:marker_var) { wsp_r >> (env_var | python_str)}
344
+ rule(:marker_expr) { marker_var.as(:l) >> marker_op.as(:o) >> marker_var.as(:r) | wsp_r >> str('(') >> marker >> wsp_r >> str(')') }
345
+ rule(:marker_and) { marker_expr.as(:l) >> wsp_r >> str('and').as(:o) >> marker_expr.as(:r) | marker_expr }
346
+ rule(:marker_or) { marker_and.as(:l) >> wsp_r >> str('or').as(:o) >> marker_and.as(:r) | marker_and }
347
+ rule(:marker) { marker_or }
348
+ rule(:quoted_marker) { str(';') >> wsp_r >> marker.as(:markers) }
349
+ rule(:identifier_end) { match['[:alnum:]'] | match['_.-'].repeat >> match['[:alnum:]'] }
350
+ rule(:identifier) { match['[:alnum:]'] >> identifier_end.repeat }
351
+ rule(:name) { identifier }
352
+ rule(:extras_list) { identifier.as(:id).repeat(1,1) >> (wsp_r >> str(',') >> wsp_r >> identifier.as(:id)).repeat }
353
+ rule(:extras) { str('[') >> wsp_r >> extras_list >> wsp_r >> str(']') }
354
+ rule(:name_req) { name.as(:package) >> wsp_r >> extras.as(:extras).maybe >> wsp_r >> versionspec.as(:verreqs).maybe >> wsp_r >> quoted_marker.maybe }
355
+ rule(:url_req) { name.as(:package) >> wsp_r >> extras.as(:extras).maybe >> wsp_r >> urlspec >> (wsp.repeat(1) | any.absent?) >> quoted_marker.maybe }
356
+ rule(:specification) { wsp_r >> (url_req | name_req) >> wsp_r }
357
+
358
+ # URI
359
+ rule(:uri_reference) { uri | relative_ref }
360
+ rule(:query_maybe) { (str('?') >> query).maybe }
361
+ rule(:fragment_maybe) { (str('#') >> fragment).maybe }
362
+ rule(:uri) { scheme >> str(':') >> hier_part >> query_maybe >> fragment_maybe }
363
+ rule(:hier_part) { (str('//') >> authority >> path_abempty) | path_absolute | path_rootless | path_empty }
364
+ rule(:absolute_uri) { scheme >> str(':') >> hier_part >> query_maybe }
365
+ rule(:relative_ref) { relative_part >> query_maybe >> fragment_maybe }
366
+ rule(:relative_part) { str('//') >> authority >> path_abempty | path_absolute | path_noscheme | path_empty }
367
+ rule(:scheme) { match['[:alpha:]'] >> match['[:alnum:]+.-'].repeat }
368
+ rule(:authority) { (userinfo >> str('@')).maybe >> host >> (str(':') >> port).maybe }
369
+ rule(:userinfo) { (unreserved | pct_encoded | sub_delims | str(':')).repeat }
370
+ rule(:host) { ip_literal | ipv4address | reg_name }
371
+ rule(:port) { match['0-9'].repeat }
372
+ rule(:ip_literal) { str('[') >> (ipv6address | ipvfuture) >> str(']') }
373
+ rule(:ipvfuture) { str('v') >> match['[:xdigit:]'].repeat(1) >> str('.') >> (unreserved | sub_delims | str(':')).repeat(1) }
374
+ rule(:ipv6address) {
375
+ c = str(':')
376
+ cc = str('::')
377
+
378
+ (h16 >> c).repeat(6,6) >> ls32 |
379
+ cc >> (h16 >> c).repeat(5,5) >> ls32 |
380
+ h16.maybe >> cc >> (h16 >> c).repeat(4,4) >> ls32 |
381
+ ((h16 >> c).maybe >> h16).maybe >> cc >> (h16 >> c).repeat(3,3) >> ls32 |
382
+ ((h16 >> c).repeat(0,2) >> h16).maybe >> cc >> (h16 >> c).repeat(2,2) >> ls32 |
383
+ ((h16 >> c).repeat(0,3) >> h16).maybe >> cc >> h16 >> c >> ls32 |
384
+ ((h16 >> c).repeat(0,4) >> h16).maybe >> cc >> ls32 |
385
+ ((h16 >> c).repeat(0,5) >> h16).maybe >> cc >> h16 |
386
+ ((h16 >> c).repeat(0,6) >> h16).maybe >> cc
387
+ }
388
+ rule(:h16) { match['[:xdigit:]'].repeat(1,4) }
389
+ rule(:ls32) { h16 >> str(':') >> h16 | ipv4address }
390
+ rule(:ipv4address) { dec_octet >> (str('.') >> dec_octet).repeat(3,3) }
391
+ rule(:dec_octet) {
392
+ d = match['0-9']
393
+ nz = match['1-9']
394
+
395
+ d |
396
+ nz >> d |
397
+ str('1') >> d.repeat(2,2) |
398
+ str('2') >> match['0-4'] >> d |
399
+ str('25') >> match['0-5']
400
+ }
401
+ rule(:reg_name) { (unreserved | pct_encoded | sub_delims).repeat }
402
+ rule(:path) { path_abempty | path_absolute | path_noscheme | path_rootless | path_empty }
403
+ Parslet.str('/').tap do |sl|
404
+ rule(:path_abempty) { (sl >> segment).repeat }
405
+ rule(:path_absolute) { sl >> (segment_nz >> (sl >> segment).repeat).maybe }
406
+ rule(:path_noscheme) { segment_nz_nc >> (sl >> segment).repeat }
407
+ rule(:path_rootless) { segment_nz >> (sl >> segment).repeat }
408
+ end
409
+ rule(:path_empty) { pchar.absent? }
410
+ rule(:segment) { pchar.repeat }
411
+ rule(:segment_nz) { pchar.repeat(1) }
412
+ rule(:segment_nz_nc) { (unreserved | pct_encoded | sub_delims | str('@')).repeat(1) }
413
+ rule(:pchar) { unreserved | pct_encoded | sub_delims | match[':@'] }
414
+ rule(:query) { (pchar | match['/?']).repeat }
415
+ rule(:fragment) { (pchar | match['/?']).repeat }
416
+ rule(:pct_encoded) { str('%') >> match['[:xdigit:]'].repeat(2,2) }
417
+ rule(:unreserved) { match['[:alnum:]._~-'] }
418
+ rule(:reserved) { gen_delims | sub_delims }
419
+ rule(:gen_delims) { match[':/?#()@'] }
420
+ rule(:sub_delims) { match["!$&'()*+,;="] }
421
+ end
422
+
423
+ ##
424
+ # Transforms parse tree from ReqSpecParser to usable objects
425
+ #
426
+ class ReqSpecTransform < Parslet::Transform
427
+ rule(:verreq => {op: simple(:o), ver: simple(:v)}) {Requirement.new(o.to_s, v.to_s)}
428
+ rule(package: simple(:n)) {|c| PyPackageInfo.new(name: c[:n].to_s)}
429
+ rule(package: simple(:n), verreqs: sequence(:rs)) {|c| PyPackageInfo.new(
430
+ name: c[:n].to_s,
431
+ version_reqs: c[:rs],
432
+ )}
433
+ rule(package: simple(:n), url: simple(:url)) {|c| PyPackageInfo.new(
434
+ name: c[:n].to_s,
435
+ url: c[:url].to_s,
436
+ )}
437
+
438
+ ##
439
+ # Apply transform after normalizing a parse tree
440
+ #
441
+ # This method should be applied only to a parse tree expected to come
442
+ # from a requirement specification.
443
+ #
444
+ def apply_spec(ptree)
445
+ norm_ptree = {}
446
+ # TODO: :extras should be in this list, and we should default them to []
447
+ %i[package verreqs url].each do |c|
448
+ norm_ptree[c] = ptree[c] if ptree.has_key?(c)
449
+ end
450
+ apply(norm_ptree)
451
+ end
452
+ end
453
+
454
+ ##
455
+ # Representation of a single requirement clause
456
+ #
457
+ class Requirement
458
+ def initialize(op, vernum)
459
+ super()
460
+ @op = case op
461
+ when '<' then :<
462
+ when '<=' then :<=
463
+ when '==' then :==
464
+ when '>=' then :>=
465
+ when '>' then :>
466
+ when '!=' then :!=
467
+ when '~=' then :compatible
468
+ when '===' then :str_equal
469
+ when Symbol then op
470
+ else
471
+ raise "Unknown requirement operator #{op.inspect}"
472
+ end
473
+ @vernum = vernum
474
+ end
475
+ attr_reader :op, :vernum
476
+
477
+ def determinative?
478
+ [:==, :str_equal].include?(op)
479
+ end
480
+
481
+ ##
482
+ # Query if this requirement is satisfied by a particular version
483
+ #
484
+ # When +strict:+ is false and the instance is an equality-type requirement
485
+ # (i.e. the +op+ is +:==+ or +:str_equal+), the result is always +true+.
486
+ #
487
+ def satisfied_by?(version, strict: true)
488
+ req_key = PyPackageInfo.parse_version_str(self.vernum)
489
+ cand_key = PyPackageInfo.parse_version_str(version)
490
+
491
+ return true if !strict && %i[== str_equal].include?(op)
492
+
493
+ return case op
494
+ when :compatible
495
+ req_key, cand_key = comp_keys(version)
496
+ (cand_key <=> req_key) >= 0 && (cand_key <=> series(req_key)) == 0
497
+ when :str_equal
498
+ self.vernum == version.to_s
499
+ else
500
+ req_key, cand_key = comp_keys(version)
501
+ if comp_result = (cand_key <=> req_key)
502
+ comp_result.send(op, 0)
503
+ else
504
+ warn("Cannot test #{cand_key.inspect} #{op} #{req_key} (<=> returned nil)")
505
+ end
506
+ end
507
+ end
508
+
509
+ private
510
+ def comp_keys(other)
511
+ [self.vernum, other].map {|v| PyPackageInfo.parse_version_str(v)}
512
+ end
513
+
514
+ def series(comp_key)
515
+ comp_key.dup.tap do |result|
516
+ result.final.to_series
517
+ end
518
+ end
519
+ end
520
+
521
+ VERSION_PATTERN = /^
522
+ ((?<epoch> \d+ ) ! )?
523
+ (?<final> \d+ (\.\d+)* (\.\*)? )
524
+ ( # Pre-release (a | b | rc) group
525
+ [._-]?
526
+ (?<pre_group> a(lpha)? | b(eta)? | c | pre(view)? | rc )
527
+ [._-]?
528
+ (?<pre_n> \d* )
529
+ )?
530
+ ( # Post-release group
531
+ (
532
+ [._-]? (post|r(ev)?) [._-]?
533
+ |
534
+ - # Implicit post release
535
+ )
536
+ (?<post> ((?<![._-]) | \d) \d* )
537
+ )?
538
+ ( # Development release group
539
+ [._-]?
540
+ dev
541
+ (?<dev> \d* )
542
+ )?
543
+ ( # Local version segment
544
+ \+
545
+ (?<local>.*)
546
+ )?
547
+ $/x
548
+
549
+ module VersionParsing
550
+ def parse_version_str(s)
551
+ return s if s.kind_of?(Version)
552
+ return s unless parts = VERSION_PATTERN.match(s.downcase)
553
+
554
+ # Normalization
555
+ pre_group = case parts[:pre_group]
556
+ when 'alpha' then 'a'
557
+ when 'beta' then 'b'
558
+ when 'c', 'pre', 'preview' then 'rc'
559
+ else parts[:pre_group]
560
+ end
561
+
562
+ return Version.new(
563
+ FinalVersion.new(parts[:final]),
564
+ epoch: parts[:epoch],
565
+ pre: [pre_group, parts[:pre_n]],
566
+ post: parts[:post],
567
+ dev: parts[:dev],
568
+ local: parts[:local],
569
+ )
570
+ end
571
+ end
572
+ extend VersionParsing
573
+ include VersionParsing
574
+
575
+ ##
576
+ # Represents a full PEP-440 version
577
+ #
578
+ class Version
579
+ NOT_PRE = ['z', 0]
580
+
581
+ def initialize(final, epoch: 0, pre: [], post: nil, dev: nil, local: nil)
582
+ @epoch = (epoch || 0).to_i
583
+ @final = final.kind_of?(FinalVersion) ? final : FinalVersion.new(final)
584
+ @pre = normalize_part(pre[1]) {|n| n && [pre[0], n]}
585
+ @post = normalize_part(post) {|n| n && [n] }
586
+ @dev = normalize_part(dev) {|n| n}
587
+ @local = case local
588
+ when nil then nil
589
+ when Array then local
590
+ else local.to_s.split(/[._-]/).map {|part| try_to_i(part)}
591
+ end
592
+ end
593
+ attr_reader *%i[epoch final local]
594
+
595
+ def inspect
596
+ "#<#{self.class.name} #{to_s.inspect}>"
597
+ end
598
+
599
+ def to_s
600
+ [].tap do |parts|
601
+ parts << "#{epoch}!" unless epoch == 0
602
+ parts << final.to_s
603
+ parts << "#{@pre[0]}#{@pre[1]}" if @pre
604
+ parts << ".post#{@post}" if @post
605
+ parts << ".dev#{@dev}" if @dev
606
+ parts << "+#{local}" if local
607
+ end.join('')
608
+ end
609
+
610
+ def pre_group
611
+ @pre && @pre[0]
612
+ end
613
+
614
+ def pre_num
615
+ @pre && @pre[1]
616
+ end
617
+
618
+ def <=>(rhs)
619
+ return nil unless rhs.kind_of?(self.class)
620
+ steps = Enumerator.new do |comps|
621
+ %i[epoch final pre_comp post_comp dev_comp].each do |attr|
622
+ comps << (send(attr) <=> rhs.send(attr))
623
+ end
624
+
625
+ case [local, rhs.local].count(&:nil?)
626
+ when 2 then comps << 0
627
+ when 1 then comps << (local.nil? ? -1 : 1)
628
+ else comps << (local <=> rhs.local)
629
+ end
630
+ end
631
+ steps.find {|v| v != 0} || 0
632
+ end
633
+ include Comparable
634
+
635
+ def prerelease?
636
+ !!(@pre || @dev)
637
+ end
638
+
639
+ private
640
+ def normalize_part(value)
641
+ yield case value
642
+ when '' then 0
643
+ when nil then nil
644
+ else value.to_i
645
+ end
646
+ end
647
+
648
+ def try_to_i(s)
649
+ if /^\d+$/ =~ s
650
+ s.to_i
651
+ else
652
+ s
653
+ end
654
+ end
655
+
656
+ def pre_comp
657
+ @pre || NOT_PRE
658
+ end
659
+
660
+ def post_comp
661
+ @post || []
662
+ end
663
+
664
+ def dev_comp
665
+ @dev || Float::INFINITY
666
+ end
667
+ end
668
+
669
+ ##
670
+ # Represents the "final" part of a PEP-440 version string
671
+ #
672
+ class FinalVersion
673
+ def initialize(final_ver)
674
+ @value = case final_ver
675
+ when Array then final_ver
676
+ else final_ver.split('.').map {|s| seg_value(s)}
677
+ end
678
+ end
679
+
680
+ def [](n)
681
+ @value[n]
682
+ end
683
+
684
+ def length
685
+ @value.length
686
+ end
687
+
688
+ def each(&blk)
689
+ @value.each(&blk)
690
+ end
691
+ include Enumerable
692
+
693
+ def to_s
694
+ @value.join('.')
695
+ end
696
+
697
+ def inspect
698
+ "#<#{self.class.name} #{to_s}>"
699
+ end
700
+
701
+ def <=>(rhs)
702
+ nil unless rhs.kind_of?(FinalVersion)
703
+ (0..Float::INFINITY).lazy.each do |i|
704
+ return 0 if self[i].nil? && rhs[i].nil?
705
+ return 0 if [self[i], rhs[i]].include?(:*)
706
+ diff = (self[i] || 0) <=> (rhs[i] || 0)
707
+ return diff if diff != 0
708
+ end
709
+ end
710
+ include Comparable
711
+
712
+ def to_series
713
+ self.class.new(@value.dup.tap do |mver|
714
+ mver[-1] = :*
715
+ end.join('.'))
716
+ end
717
+
718
+ private
719
+ def seg_value(s)
720
+ if s == '*'
721
+ :*
722
+ else
723
+ s.to_i
724
+ end
725
+ end
726
+ end
727
+
728
+ ##
729
+ # Reads package requirements from a file
730
+ #
731
+ class Reader
732
+ def initialize(packages_fpath, only_constrain: false)
733
+ super()
734
+ @files = [Pathname(packages_fpath)]
735
+ @only_constrain = only_constrain
736
+ end
737
+
738
+ ##
739
+ # Enumerate packages described by requirements targeted by this instance
740
+ #
741
+ # Each invocation of the block receives a PyPackageInfo object, which
742
+ # will have, at minimum, either a #name or #url not +nil+. It is
743
+ # possible that multiple iterations will process separate PyPackageInfo
744
+ # for the same package, in which case PyPackageInfo#incorporate is useful.
745
+ #
746
+ # An Enumerator is returned if no block is given.
747
+ #
748
+ def each_package_constrained
749
+ generator = Enumerator.new do |items|
750
+ continued_line = ''
751
+ current_file.each_line do |pkg_line|
752
+ pkg_line = pkg_line.chomp
753
+ next if /^#/ =~ pkg_line
754
+ if /(?<=\s)#.*$/ =~ pkg_line
755
+ pkg_line = pkg_line[0...-$&.length]
756
+ end
757
+
758
+ # Yes, this _does_ happen after comment lines are skipped :facepalm:
759
+ if /\\$/ =~ pkg_line
760
+ continued_line += pkg_line[0..-2]
761
+ next
762
+ end
763
+ pkg_line, continued_line = (continued_line + pkg_line).strip, ''
764
+ next if pkg_line.empty?
765
+
766
+ process_line_into(items, pkg_line)
767
+ end
768
+ end
769
+
770
+ if block_given?
771
+ generator.each {|item| yield item}
772
+ else
773
+ generator
774
+ end
775
+ end
776
+
777
+ ##
778
+ # Enumerate packages targeted for installation by this instance
779
+ #
780
+ # Each invocation of the block receives a PyPackageInfo object targeted
781
+ # for installation. Each of these PyPackageInfo object will have a
782
+ # resolved #name and #current_version (if possible).
783
+ #
784
+ # An Enumerator is returned if no block is given.
785
+ #
786
+ def each_installed_package
787
+ generator = Enumerator.new do |items|
788
+ packages = {}
789
+
790
+ each_package_constrained do |pkg|
791
+ pkg.resolve_name!
792
+ if packages.has_key?(pkg.name)
793
+ packages[pkg.name].incorporate(pkg)
794
+ else
795
+ packages[pkg.name] = pkg
796
+ end
797
+ end
798
+
799
+ to_install = []
800
+ packages.each_value do |pkg|
801
+ next unless pkg.install?
802
+ to_install << pkg.name
803
+ end
804
+
805
+ while pkg_name = to_install.shift
806
+ pkg = packages[pkg_name]
807
+ pkg.resolve_version!
808
+ items << pkg
809
+ end
810
+ end
811
+
812
+ if block_given?
813
+ generator.each {|item| yield item}
814
+ else
815
+ generator
816
+ end
817
+ end
818
+
819
+ private
820
+ def current_file
821
+ @files.last
822
+ end
823
+
824
+ def in_file(fpath)
825
+ @files << Pathname(fpath)
826
+ begin
827
+ yield
828
+ ensure
829
+ @files.pop
830
+ end
831
+ end
832
+
833
+ def only_constrain?
834
+ @only_constrain
835
+ end
836
+
837
+ def reading_constraints
838
+ prev_val, @only_constrain = @only_constrain, true
839
+ begin
840
+ yield
841
+ ensure
842
+ @only_constrain = prev_val
843
+ end
844
+ end
845
+
846
+ def process_line_into(items, pkg_line)
847
+ case pkg_line
848
+ when /^-r (.)$/
849
+ if only_constrain?
850
+ warn("-r directive appears in constraints file #{current_file}")
851
+ end
852
+ in_file(current_file.dirname / $1) do
853
+ each_package_constrained {|pkg| items << pkg}
854
+ end
855
+ when /^-c (.)$/
856
+ in_file(current_file.dirname / $1) do
857
+ reading_constraints do
858
+ each_package_constrained {|pkg| items << pkg}
859
+ end
860
+ end
861
+ when /^-e/
862
+ warn %Q{#{current_file} lists "editable" package: #{pkg_line}}
863
+ else
864
+ insert_package_from_line_into(items, pkg_line)
865
+ end
866
+ end
867
+
868
+ def insert_package_from_line_into(items, pkg_line)
869
+ parse_tree = begin
870
+ ReqSpecParser.new.parse(pkg_line)
871
+ rescue Parslet::ParseFailed
872
+ if (uri = URI.try_parse(pkg_line)) && ACCEPTED_URI_SCHEMES.include?(uri.scheme)
873
+ if only_constrain?
874
+ warn("#{current_file} is a constraints file but specifies URL #{uri}")
875
+ else
876
+ items << PyPackageInfo.new(url: uri, install: true)
877
+ end
878
+ return
879
+ end
880
+ warn("Unreportable line in #{current_file}: #{pkg_line}")
881
+ return
882
+ end
883
+
884
+ # Transform parse tree into a spec
885
+ spec = ReqSpecTransform.new.apply_spec(parse_tree)
886
+ if spec.kind_of?(PyPackageInfo)
887
+ spec.install ||= !only_constrain?
888
+ items << spec
889
+ else
890
+ warn("Unhandled requirement parse tree: #{explain_parse_tree parse_tree}")
891
+ end
892
+ end
893
+
894
+ def explain_parse_tree(parse_tree)
895
+ case parse_tree
896
+ when Array
897
+ "[#{parse_tree.map {|i| "#<#{i.class.name}>"}.join(', ')}]"
898
+ when Hash
899
+ "{#{parse_tree.map {|k, v| "#{k.inspect} => #<#{v.class.name}>"}.join(', ')}}"
900
+ else
901
+ "#<#{parse_tree.class.name}>"
902
+ end
903
+ end
904
+ end
905
+
906
+ def pypi_url
907
+ "https://pypi.org/pypi/#{name}/json"
908
+ end
909
+
910
+ def pypi_release_url(release)
911
+ "https://pypi.org/pypi/#{name}/#{release}/json"
912
+ end
913
+
914
+ private
915
+ def get_package_info
916
+ cache = PACKAGE_CACHE_DIR.join("#{name}.json")
917
+ apply_cache(cache) do
918
+ pypi_response = RestClient.get(pypi_url)
919
+ JSON.parse(pypi_response)
920
+ end
921
+ end
922
+
923
+ def get_release_info(release)
924
+ cache = PACKAGE_CACHE_DIR.join(name, "#{release}.json")
925
+ apply_cache(cache) do
926
+ pypi_response = RestClient.get(pypi_release_url(release))
927
+ JSON.parse(pypi_response)
928
+ end
929
+ end
930
+
931
+ def get_age
932
+ versions_with_release.each do |vnum, released|
933
+ return ((Time.now - released) / ONE_DAY).to_i if vnum == current_version
934
+ end
935
+ return nil
936
+ end
937
+
938
+ ##
939
+ # Given a version, return the parts that we expect to define the
940
+ # major/minor release series
941
+ #
942
+ # Returns an Array
943
+ #
944
+ def nonpatch_versegs(ver)
945
+ return nil if ver.nil?
946
+ [ver.epoch] + ver.final.take(2)
947
+ end
948
+
949
+ ##
950
+ # Get data from the setup.py file of the package
951
+ #
952
+ def setup_data
953
+ return @setup_data if defined? @setup_data
954
+ unless self.url
955
+ raise "#setup_data called for #{name}, may only be called for packages specified by URL"
956
+ end
957
+
958
+ python_code = <<~END_OF_PYTHON
959
+ import json, sys
960
+ from unittest.mock import patch
961
+
962
+ sys.path[0:0] = ['.']
963
+
964
+ def capture_setup(**kwargs):
965
+ capture_setup.captured = kwargs
966
+
967
+ with patch('setuptools.setup', capture_setup):
968
+ import setup
969
+
970
+ json.dump(
971
+ capture_setup.captured,
972
+ sys.stdout,
973
+ default=lambda o: "<{}.{}>".format(type(o).__module__, type(o).__qualname__),
974
+ )
975
+ END_OF_PYTHON
976
+
977
+ output, status = with_package_files do |workdir|
978
+ Dir.chdir(workdir) do
979
+ Open3.capture2('python3', stdin_data: python_code)
980
+ end
981
+ end || []
982
+
983
+ @setup_data = begin
984
+ case status
985
+ when nil
986
+ warn("Package files unavailable, could not read setup.py")
987
+ {}
988
+ when :success?.to_proc
989
+ JSON.parse(output)
990
+ else
991
+ warn("Failed to read setup.py in for #{self.url}")
992
+ {}
993
+ end
994
+ rescue StandardError => ex
995
+ warn("Failed to read setup.py in for #{self.url}: #{ex}")
996
+ {}
997
+ end
998
+ end
999
+
1000
+ ##
1001
+ # Yield a Pathname for the directory containing the package files
1002
+ #
1003
+ # Returns the result of the block, or +nil+ if the block is not
1004
+ # executed. The directory with the package files may be removed when
1005
+ # the block exits.
1006
+ #
1007
+ def with_package_files(&blk)
1008
+ case self.url.scheme
1009
+ when 'git'
1010
+ return with_git_worktree(self.url, &blk)
1011
+ when /^git\+/
1012
+ git_uri = self.url.dup
1013
+ git_uri.scheme = self.url.scheme[4..-1]
1014
+ return with_git_worktree(git_uri, &blk)
1015
+ when 'http', 'https'
1016
+ case
1017
+ when zip_url?
1018
+ return with_unzipped_files(&blk)
1019
+ when tgz_url?
1020
+ return with_untarred_files(&blk)
1021
+ else
1022
+ warn("Unknown archive type for URL: #{self.url}")
1023
+ return nil
1024
+ end
1025
+ else
1026
+ warn("Unable to process URI package requirement: #{self.url}")
1027
+ end
1028
+ end
1029
+
1030
+ ##
1031
+ # Implementation of #with_package_files for git URIs
1032
+ #
1033
+ def with_git_worktree(uri)
1034
+ git_url = uri.dup
1035
+ git_url.path, committish = uri.path.split('@', 2)
1036
+ uri_fragment, git_url.fragment = uri.fragment, nil
1037
+ repo_path = CODE_CACHE_DIR.join("git_#{Digest::MD5.hexdigest(git_url.to_s)}.git")
1038
+
1039
+ CODE_CACHE_DIR.mkpath
1040
+
1041
+ in_dir_git_cmd = ['git', '-C', repo_path.to_s]
1042
+
1043
+ if repo_path.exist?
1044
+ puts "Fetching #{git_url} to #{repo_path}..."
1045
+ cmd = in_dir_git_cmd + ['fetch', '--tags', 'origin', '+refs/heads/*:refs/heads/*']
1046
+ output, status = Open3.capture2(*cmd)
1047
+ unless status.success?
1048
+ warn("Failed to fetch 'origin' in #{repo_path}")
1049
+ return
1050
+ end
1051
+ else
1052
+ cmd = ['git', 'clone', '--bare', git_url.to_s, repo_path.to_s]
1053
+ output, status = Open3.capture2(*cmd)
1054
+ unless status.success?
1055
+ warn("Failed to clone #{git_url}")
1056
+ return
1057
+ end
1058
+ end
1059
+
1060
+ committish ||= (
1061
+ cmd = in_dir_git_cmd + ['ls-remote', 'origin', 'HEAD']
1062
+ output, status = Open3.capture2(*cmd)
1063
+ unless status.success?
1064
+ raise "Unable to read the HEAD of orgin"
1065
+ end
1066
+ output.split("\t")[0]
1067
+ )
1068
+ Dir.mktmpdir("myprecious-git-") do |workdir|
1069
+ cmds = [
1070
+ in_dir_git_cmd + ['archive', committish],
1071
+ ['tar', '-x', '-C', workdir.to_s],
1072
+ ]
1073
+ statuses = Open3.pipeline(*cmds, in: :close)
1074
+ if failed_i = statuses.find {|s| s.exited? && !s.success?}
1075
+ exitstatus = statuses[failed_i].exitstatus
1076
+ failed_cmd_str = cmds[failed_i].shelljoin
1077
+ warn(
1078
+ "Failed to create temporary folder at command:\n" +
1079
+ " #{failed_cmd.light_red} (exited with code #{exitstatus})"
1080
+ )
1081
+ return
1082
+ end
1083
+
1084
+ fragment_parts = Hash[URI.decode_www_form(uri.fragment || '')]
1085
+ package_dir = Pathname(workdir).join(
1086
+ fragment_parts.fetch('subdirectory', '.')
1087
+ )
1088
+ return (yield package_dir)
1089
+ end
1090
+ end
1091
+
1092
+ def get_url_content_type
1093
+ # TODO: Make a HEAD request to the URL to find out the content type
1094
+ return 'application/octet-stream'
1095
+ end
1096
+
1097
+ def zip_url?
1098
+ case get_url_content_type
1099
+ when 'application/zip' then true
1100
+ when 'application/octet-stream'
1101
+ self.url.path.downcase.end_with?('.zip')
1102
+ else false
1103
+ end
1104
+ end
1105
+
1106
+ ##
1107
+ # Implementation of #with_package_files for ZIP file URLs
1108
+ #
1109
+ def with_unzipped_files
1110
+ zip_path = extracted_url("zip") do |url_f, zip_path|
1111
+ Zip::File.open_buffer(url_f) do |zip_file|
1112
+ zip_file.each do |entry|
1113
+ if entry.name_safe?
1114
+ dest_file = zip_path.join(entry.name.split('/', 2)[1])
1115
+ dest_file.dirname.mkpath
1116
+ entry.extract(dest_file.to_s) {:overwrite}
1117
+ else
1118
+ warn("Did not extract #{entry.name} from #{self.url}")
1119
+ end
1120
+ end
1121
+ end
1122
+ end
1123
+
1124
+ return (yield zip_path)
1125
+ end
1126
+
1127
+ def tgz_url?
1128
+ case get_url_content_type
1129
+ when %r{^application/(x-tar(\+gzip)?|gzip)$} then true
1130
+ when 'application/octet-stream'
1131
+ !!(self.url.path.downcase =~ /\.(tar\.gz|tgz)$/)
1132
+ else false
1133
+ end
1134
+ end
1135
+
1136
+ ##
1137
+ # Implementation of #with_package_files for TGZ file URLs
1138
+ #
1139
+ def with_untarred_files
1140
+ tar_path = extracted_url("tar") do |url_f, tar_path|
1141
+ Gem::Package::TarReader.new(Zlib::GzipReader.new(url_f)) do |tar_file|
1142
+ tar_file.each do |entry|
1143
+ if entry.full_name =~ %r{(^|/)\.\./}
1144
+ warn("Did not extract #{entry.name} from #{self.url}")
1145
+ elsif entry.file?
1146
+ dest_file = tar_path.join(entry.full_name.split('/', 2)[1])
1147
+ dest_file.dirname.mkpath
1148
+ dest_file.open('wb') do |df|
1149
+ IO.copy_stream(entry, df)
1150
+ end
1151
+ end
1152
+ end
1153
+ end
1154
+ end
1155
+
1156
+ return (yield tar_path)
1157
+ end
1158
+
1159
+ def extracted_url(archive_type, &blk)
1160
+ puts "Downloading #{self.url}"
1161
+ extraction_path = CODE_CACHE_DIR.join(
1162
+ "#{archive_type}_#{Digest::MD5.hexdigest(self.url.to_s)}"
1163
+ )
1164
+ CODE_CACHE_DIR.mkpath
1165
+
1166
+ if %w[http https].include?(self.url.scheme)
1167
+ # TODO: Make a HEAD request to see if re-download is necessary
1168
+ end
1169
+
1170
+ self.url.open('rb') {|url_f| yield url_f, extraction_path}
1171
+
1172
+ return extraction_path
1173
+ end
1174
+ end
1175
+ end