drupid 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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