drupid 1.0.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.
@@ -0,0 +1,563 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ # Copyright (c) 2012 Lifepillar
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ module Drupid
24
+
25
+ # A convenience class that encapsulates methods for detecting
26
+ # some project's properties from a local copy of a project.
27
+ class ProjectInfo
28
+ include Drupid::Utils
29
+
30
+ # The project's name.
31
+ attr :project_name
32
+ # The project's core compatibility number. See Drupid::VersionCore.
33
+ attr :project_core
34
+ # The project's version. See Drupid::Version.
35
+ attr :project_version
36
+ # The project's type ('core', 'module', 'theme', 'profile').
37
+ attr :project_type
38
+ # The full path to the project's directory.
39
+ attr :project_dir
40
+ # The full path to the main .info file of this project
41
+ attr :info_file
42
+
43
+ # The argument must be the path to a .info file or a project directory.
44
+ # If the path to a directory is passed, then this object will try
45
+ # automatically detect the .info file inside the directory, which is
46
+ # not a trivial task because, among the rest,
47
+ #
48
+ # - the .info file may be located in some sub-directory;
49
+ # - there may be more than one .info file;
50
+ # - the .info file name may be unrelated to the name of its containing
51
+ # directories.
52
+ #
53
+ # Faithful to its name, Drupid won't try to be too keen, and it will raise
54
+ # an exception if it cannot reliably detect a .info file.
55
+ def initialize(project_or_info_path)
56
+ p = Pathname.new(project_or_info_path).realpath # must exist
57
+ @project_core = nil
58
+ @project_type = nil
59
+ @project_version = nil
60
+ if '.info' == p.extname
61
+ @project_name = p.basename('.info').to_s
62
+ @info_file = p
63
+ grandparent = @info_file.parent.parent
64
+ if grandparent.basename.to_s == @project_name
65
+ @project_dir = grandparent
66
+ else
67
+ @project_dir = @info_file.parent
68
+ end
69
+ else
70
+ @project_name = p.basename.to_s
71
+ @project_dir = p
72
+ @info_file = _identify_main_info_file
73
+ end
74
+ debug "Parsing project info from #{@info_file}"
75
+ _parse_info_file
76
+ end
77
+
78
+ def core_project?
79
+ @is_core_project
80
+ end
81
+
82
+ private
83
+
84
+ # Returns the absolute path of the "main" .info file of this project.
85
+ # The first file satisfying one of the following heuristics is returned:
86
+ #
87
+ # 1. './#name.info' exists.
88
+ # 2. './#name/#name.info' exists.
89
+ # 3. './<any name>.info' exists and no other .info file exists at the top-level.
90
+ # 4. './#name/<any name>.info' exists and no other .info file exists inside './#name'.
91
+ # 5. There is a unique .info file, anywhere inside the project's folder.
92
+ #
93
+ # If none of the above is satisfied, pick any .info file and set the
94
+ # project's name after the .info file's 'project' field (if any). Then
95
+ # return that .info file.
96
+ #
97
+ # Finally, if the .info file has no 'project' field, give up
98
+ # hoping that one day Drupal will have better specifications and that people
99
+ # will eventually follow the specifications—but complain fiercely
100
+ # by raising an exception.
101
+ def _identify_main_info_file
102
+ attempts = [
103
+ @project_dir + (@project_name + '.info'),
104
+ @project_dir + @project_name + (@project_name + '.info'),
105
+ @project_dir + '*.info',
106
+ @project_dir + @project_name + '*.info',
107
+ @project_dir+'**/*.info'
108
+ ]
109
+ attempts.each do |p|
110
+ list = Pathname.glob(p)
111
+ if 1 == list.size and list.first.exist?
112
+ # Set the project's name after the .info file name
113
+ @project_name = list.first.basename('.info').to_s
114
+ return list.first
115
+ end
116
+ end
117
+ # We get here if all the above has failed.
118
+ Pathname.glob(@project_dir+'**/*.info').each do |p|
119
+ data = p.open("r").read
120
+ match = data.match(/project\s*=\s*["']?(.+)["']?/)
121
+ unless match.nil?
122
+ @project_name = match[1].strip
123
+ return p
124
+ end
125
+ end
126
+ # Give up :/
127
+ raise "The .info file for #{@project_name} cannot be reliably detected"
128
+ end
129
+
130
+ # Extracts the relevant information from the .info file.
131
+ def _parse_info_file
132
+ _read_info_file
133
+ _check_project_name
134
+ _set_project_version
135
+ _set_project_type
136
+ end
137
+
138
+ # Reads the content of the .info file into a hash.
139
+ # Parses only 'simple' key-value pairs (of the form X = v).
140
+ # Then, check for some other keys useful to determine the project's type
141
+ # (e.g, 'stylesheets')
142
+ def _read_info_file
143
+ @info_data = Hash.new
144
+ data = @info_file.open("r").read
145
+ data.each_line do |l|
146
+ next if l =~ /^\s*$/
147
+ next if l =~ /^\s*;/
148
+ if l.match(/^(.+)=(.+)$/)
149
+ key = $~[1].strip
150
+ value = $~[2].strip.gsub(/\A["']|["']\Z/, '')
151
+ @info_data[key] = value
152
+ end
153
+ end
154
+ @info_data['stylesheets'] = true if data.match(/^\s*stylesheets */)
155
+ @info_data['regions'] = true if data.match(/^\s*regions */)
156
+ end
157
+
158
+ # If the .info file name differs from the name of the containing directory
159
+ # and the .info file contains a 'project' field, do the following:
160
+ #
161
+ # - if <project name>.info does not exist and if the 'project' field is
162
+ # the same as the .info file name or the directory name, update the project's
163
+ # name accordingly.
164
+ #
165
+ # This check will fix, for example, the project's name for a project like
166
+ # Google Analytics, whose project's name is 'google_analytics' but the .info
167
+ # file is called 'googleanalytics.info'.
168
+ # It will also fix the project's name when the directory name has been
169
+ # changed and this object has been passed the path to the project's directory
170
+ # rather than the path to the .info file.
171
+ #
172
+ # Testing that <project name>.info does not exist is necessary to avoid
173
+ # renaming projects when more than one .info file exists in the same directory
174
+ # (see for example the Entity module).
175
+ def _check_project_name
176
+ dirname = @project_dir.basename.to_s
177
+ if @project_name != dirname and # E.g., 'featured_news' != 'featured_news_feature'
178
+ !(@info_file.dirname+(dirname+'.info')).exist? and # E.g., '.../featured_news_feature/featured_news_feature.info' does not exist
179
+ @info_data.has_key?('project')
180
+ pn = @info_data['project']
181
+ if pn == @info_file.basename('.info').to_s or pn == dirname
182
+ @project_name = pn
183
+ end
184
+ end
185
+ end
186
+
187
+ def _set_project_core
188
+ @info_data['core'].match(/^(\d+)\.x$/)
189
+ raise "Missing mandatory core compatibility for #{@project_name}" unless $1
190
+ @project_core = VersionCore.new($1)
191
+ end
192
+
193
+ def _set_project_version
194
+ _set_project_core
195
+ if @info_data.has_key?('version')
196
+ v = @info_data['version']
197
+ v = @project_core.to_s + '-' + v if v !~ /^#{@project_core}-/
198
+ @project_version = Version.from_s(v)
199
+ else
200
+ @project_version = nil
201
+ end
202
+ end
203
+
204
+ # *Requires:* @info_data must not be nil
205
+ def _set_project_type
206
+ # Determine whether this is a core project
207
+ if (@info_data.has_key?('package') and @info_data['package'] =~ /Core/i) or
208
+ (@info_data.has_key?('project') and @info_data['project'] =~ /drupal/i)
209
+ @is_core_project = true
210
+ else
211
+ @is_core_project = false
212
+ end
213
+ # Determine the project's type (module, profile or theme)
214
+ if @info_file.sub_ext('.profile').exist?
215
+ @project_type = 'profile'
216
+ elsif @info_file.sub_ext('.module').exist?
217
+ @project_type = 'module'
218
+ elsif @info_data.has_key?('engine') or
219
+ @info_data.has_key?('Base theme') or
220
+ @info_data.has_key?('base theme') or
221
+ @info_data.has_key?('stylesheets') or
222
+ @info_data.has_key?('regions')
223
+ @project_type = 'theme'
224
+ end
225
+ # If the above didn't work, examine the path the project is in as a last resort.
226
+ # This is needed, at least, to avoid "type cannot be determined" errors
227
+ # for some test directories in Drupal Core, which contain an .info file
228
+ # but no other file :/
229
+ unless project_type
230
+ @project_dir.each_filename do |p|
231
+ case p
232
+ when 'modules'
233
+ @project_type = 'module'
234
+ when 'themes'
235
+ @project_type = 'theme'
236
+ when 'profiles'
237
+ @project_type = 'profile'
238
+ end
239
+ end
240
+ end
241
+ raise "The project's type for #{@project_name} cannot be determined" unless @project_type
242
+ end
243
+
244
+ end # ProjectInfo
245
+
246
+
247
+ # Base class for projects.
248
+ class Project < Component
249
+ include Comparable
250
+
251
+ attr :core
252
+ attr_accessor :location
253
+ # The type of this project, which is one among 'drupal', 'module', 'theme'
254
+ # and 'profile', or nil if the type has not been determined or assigned.
255
+ # Note that this does not coincide with the 'type' field in a Drush makefile,
256
+ # whose feasible values are 'core', 'module', 'theme', 'profile'.
257
+ attr_accessor :proj_type
258
+ attr_accessor :l10n_path
259
+ attr_accessor :l10n_url
260
+
261
+ # Creates a new project with a given name and compatibility number.
262
+ # Optionally, specify a short version string (i.e., a version string
263
+ # without core compatibility number).
264
+ #
265
+ # Examples:
266
+ # p = Drupid::Project.new('cck', 6)
267
+ # p = Drupid::Project.new('views', 7, '1.2')
268
+ def initialize name, core_num, vers = nil
269
+ super(name)
270
+ @core = VersionCore.new(core_num)
271
+ @core_project = ('drupal' == @name) ? true : nil
272
+ @version = vers ? Version.from_s(@core.to_s + '-' + vers) : nil
273
+ @proj_type = ('drupal' == @name) ? 'drupal' : nil
274
+ @info_file = nil
275
+ end
276
+
277
+ # Returns true if a version is specified for this project, false otherwise.
278
+ def has_version?
279
+ nil != @version
280
+ end
281
+
282
+ # Returns the version of this project as a Drupid::Version object,
283
+ # or nil if this project has not been assigned a version.
284
+ def version
285
+ @version
286
+ end
287
+
288
+ # Assigns a version to this project.
289
+ # The argument must be a String object or a Drupid::Version object.
290
+ # For the syntax of the String argument, see Drupid::Version.
291
+ def version=(new_version)
292
+ if new_version.is_a?(Version)
293
+ temp_version = new_version
294
+ elsif new_version.is_a?(String)
295
+ v = new_version
296
+ temp_version = Version.from_s(v)
297
+ else
298
+ raise NotDrupalVersionError
299
+ end
300
+ raise NotDrupalVersionError, "Incompatible version for project #{extended_name}: #{temp_version.long}" if temp_version.core != core
301
+ @version = temp_version
302
+ end
303
+
304
+ # Updates the version of this project to the latest recommended release;
305
+ # if no recommended release is available, tries to retrieve the version
306
+ # of the latest supported release; if no supported release is found, the version
307
+ # of this project is not modified.
308
+ # Raises an error if no release history for the project is found (presumably
309
+ # because no such project exists on drupal.org).
310
+ #
311
+ # *Requires:* a network connection.
312
+ def update_version
313
+ v = recommended_release
314
+ v = supported_release unless v
315
+ if v
316
+ self.version = core.to_s + '-' + v
317
+ debug "Version updated: #{extended_name}"
318
+ end
319
+ end
320
+
321
+ # Returns true if this object corresponds to Drupal core;
322
+ # returns false otherwise.
323
+ def drupal?
324
+ 'drupal' == proj_type
325
+ end
326
+
327
+ # Returns true if this is a profile; returns false otherwise.
328
+ def profile?
329
+ 'profile' == proj_type
330
+ end
331
+
332
+ # Returns true if this is a core project; returns false otherwise.
333
+ def core_project?
334
+ @core_project
335
+ end
336
+
337
+ def core_project=(c)
338
+ @core_project = c
339
+ end
340
+
341
+ # See Version for the reason why we define == explicitly.
342
+ def ==(other)
343
+ @name == other.name and
344
+ @core == other.core and
345
+ @version == other.version
346
+ end
347
+
348
+ # Compares this project with another to determine which is newer.
349
+ # The comparison returns nil if the two projects have different names
350
+ # or at least one of them has no version;
351
+ # otherwise, returns -1 if this project is older than the other,
352
+ # 1 if this project is more recent than the other,
353
+ # 0 if this project has the same version as the other.
354
+ def <=>(other)
355
+ return nil if @name != other.name
356
+ c = core <=> other.core
357
+ if 0 == c
358
+ return nil unless has_version? and other.has_version?
359
+ return version <=> other.version
360
+ else
361
+ return c
362
+ end
363
+ end
364
+
365
+ # Returns the name and the version of this project as a string, e.g.,
366
+ # 'media-7.x-2.0-unstable2' or 'drupal-7.14'.
367
+ # If no version is specified for this project,
368
+ # returns only the project's name and core compatibility number.
369
+ def extended_name
370
+ if has_version?
371
+ return name + '-' + ((drupal?) ? version.short : version.long)
372
+ else
373
+ return name + '-' + core.to_s
374
+ end
375
+ end
376
+
377
+ # Returns a list of the names of the extensions (modules and themes) upon
378
+ # which this project and its subprojects (the projects contained within
379
+ # this one) depend.
380
+ # Returns an empty list if no local copy of this project exists.
381
+ #
382
+ # If :subprojects is set to false, subprojects' dependencies are not computed.
383
+ #
384
+ # Options: subprojects
385
+ def dependencies options = {}
386
+ return [] unless exist?
387
+ deps = Array.new
388
+ if options.has_key?(:subprojects) and (not options[:subprojects])
389
+ reload_project_info unless @info_file and @info_file.exist?
390
+ info_files = [@info_file]
391
+ else
392
+ info_files = Dir["#{local_path}/**/*.info"]
393
+ end
394
+ info_files.each do |info|
395
+ f = File.open(info, "r").read
396
+ f.each_line do |l|
397
+ matchdata = l.match(/^\s*dependencies\s*\[\s*\]\s*=\s*["']?([^\s("']+)/)
398
+ if nil != matchdata
399
+ deps << matchdata[1].strip
400
+ end
401
+ matchdata = l.match(/^\s*base +theme\s*=\s*(.+)$/)
402
+ if nil != matchdata
403
+ d = matchdata[1].strip
404
+ deps << d.gsub(/\A["']|["']\Z/, '') # Strip leading and trailing quotes
405
+ end
406
+ end
407
+ end
408
+ # Remove duplicates and self-dependency
409
+ deps.uniq!
410
+ deps.delete(name)
411
+ return deps
412
+ end
413
+
414
+ # Returns a list of the names of the extensions (modules and themes)
415
+ # contained in this project.
416
+ # Returns a list containing only the project's name
417
+ # if no local copy of this project exists.
418
+ def extensions
419
+ return [name] unless exist?
420
+ # Note that the project's name may be different from the name of the .info file.
421
+ ext = [name]
422
+ Dir["#{local_path}/**/*.info"].map do |p|
423
+ ext << File.basename(p, '.info')
424
+ end
425
+ ext.uniq!
426
+ return ext
427
+ end
428
+
429
+ def reload_project_info
430
+ project_info = ProjectInfo.new(@local_path)
431
+ raise "Inconsistent naming: expected #{@name}, got #{project_info.project_name}" unless @name == project_info.project_name
432
+ raise "Inconsistent core: expected #{@core}, got #{project_info.project_core}" unless @core == project_info.project_core
433
+ @proj_type = project_info.project_type
434
+ @core_project = project_info.core_project?
435
+ @version = project_info.project_version
436
+ @info_file = project_info.info_file
437
+ end
438
+
439
+ def fetch
440
+ # Try to get the latest version if:
441
+ # (1) the project is not local;
442
+ # (2) it does not have a version already;
443
+ # (3) no download type has been explicitly given.
444
+ unless has_version? or download_url =~ /file:\/\// or download_type
445
+ update_version
446
+ end
447
+ # If the project has no version we fetch it even if it is cached.
448
+ # If the project has a download type, we fetch it even if it is cached
449
+ # (say the download type is 'git' and the revision is changed in the
450
+ # makefile, then the cached project must be updated accordingly).
451
+ if has_version? and !download_type and cached_location.exist?
452
+ @local_path = cached_location
453
+ debug "#{extended_name} is cached"
454
+ else
455
+ blah "Fetching #{extended_name}"
456
+ if download_type
457
+ if download_url
458
+ src = download_url
459
+ elsif 'git' == download_type # Download from git.drupal.org
460
+ src = "http://git.drupal.org/project/#{name}.git"
461
+ else
462
+ raise "No download URL specified for #{extended_name}" unless download_url
463
+ end
464
+ else
465
+ src = extended_name
466
+ end
467
+ downloader = Drupid.makeDownloader src, cached_location.dirname.to_s, cached_location.basename.to_s, download_specs.merge({:type => download_type})
468
+ downloader.fetch
469
+ downloader.stage
470
+ @local_path = downloader.staged_path
471
+ end
472
+ reload_project_info unless drupal?
473
+ end
474
+
475
+ # Returns the relative path where this project should be installed
476
+ # within a platform.
477
+ # For example, for a module called 'Foo', it might be something like
478
+ # 'modules/contrib/foo'.
479
+ def target_path
480
+ case proj_type
481
+ when 'drupal'
482
+ return Pathname.new('.')
483
+ when nil
484
+ raise "Undefined project type for #{name}."
485
+ else
486
+ return Pathname.new(proj_type + 's') + subdir + directory_name
487
+ end
488
+ end
489
+
490
+ # Returns the path to a makefile contained in this project, if any.
491
+ # Returns nil if this project does not contain any makefile.
492
+ # For an embedded makefile to be recognized, the makefile
493
+ # itself must be named '#name.make' or 'drupal-org.make'.
494
+ #
495
+ # *Requires:* a local copy of this project.
496
+ def makefile
497
+ return nil unless self.exist?
498
+ paths = [
499
+ local_path + "#{name}.make",
500
+ local_path + 'drupal-org.make' # Used in Drupal distributions
501
+ ]
502
+ paths.each do |p|
503
+ return p if p.exist?
504
+ end
505
+ return nil
506
+ end
507
+
508
+ # Compares this project with another, returning an array of differences.
509
+ # If this project contains a makefile, ignore the content of the following
510
+ # directories inside the project: libraries, modules, profiles and themes.
511
+ def file_level_compare_with tgt
512
+ args = Array.new
513
+ if makefile
514
+ args << '-f' << '- /libraries/***' # this syntax requires rsync >=2.6.7.
515
+ args << '-f' << '- /modules/***'
516
+ args << '-f' << '- /profiles/***'
517
+ args << '-f' << '- /themes/***'
518
+ end
519
+ if drupal?
520
+ args << '-f' << '+ /profiles/default/***' # D6
521
+ args << '-f' << '+ /profiles/minimal/***' # D7
522
+ args << '-f' << '+ /profiles/standard/***' # D7
523
+ args << '-f' << '+ /profiles/testing/***' # D7
524
+ args << '-f' << '- /profiles/***'
525
+ args << '-f' << '+ /sites/all/README.txt'
526
+ args << '-f' << '+ /sites/default/default.settings.php'
527
+ args << '-f' << '- /sites/***'
528
+ end
529
+ super(tgt, args)
530
+ end
531
+
532
+ # Returns the version of the latest recommended release of this project
533
+ # as a string, or nil if no recommended release can be found
534
+ # or the project does not exist at drupal.org.
535
+ #
536
+ # *Requires:* a network connection.
537
+ #
538
+ # See also: Drupid::Drush.pm_releases, Drupid::Project.supported_release
539
+ def recommended_release
540
+ output = Drush.pm_releases(name + '-' + core.to_s)
541
+ return unless output =~ /RELEASES FOR/
542
+ rl = Drush.recommended_release(output)
543
+ return rl.sub(/^#{core}-/, '') if rl
544
+ return nil
545
+ end
546
+
547
+ # Returns the version of the latest supported release
548
+ # as a string, or nil if no supported release can be found
549
+ # or the project does not exist at drupal.org.
550
+ #
551
+ # *Requires:* a network connection.
552
+ #
553
+ # See also: Drupid::Drush.pm_releases, Drupid::Project.recommended_release
554
+ def supported_release
555
+ output = Drush.pm_releases(name + '-' + core.to_s)
556
+ return nil unless output =~ /RELEASES FOR/
557
+ rl = Drush.supported_release(output)
558
+ return rl.sub(/^#{core}-/, '') if rl
559
+ return nil
560
+ end
561
+
562
+ end # Project
563
+ end # Drupid