chrysalis 0.1.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,193 @@
1
+ require 'singleton'
2
+ require 'chrysalis/project'
3
+ require 'chrysalis/vcs'
4
+ require 'chrysalis/errors'
5
+ require 'chrysalis/core_ext/string'
6
+
7
+
8
+ module Chrysalis
9
+
10
+ META_DIRECTORY = '.chrysalis'.freeze
11
+ META_CACHE_FILE = '.chrysalis/cache.yml'.freeze
12
+
13
+ class << self
14
+ def boot
15
+ Chrysalis::Loader.instance
16
+ Chrysalis::Cache.instance
17
+ end
18
+
19
+ #--
20
+ # TODO: A proper solution for configuration options needs to be implemented.
21
+ def options
22
+ @options ||= OpenStruct.new :prefix => './lib'
23
+ end
24
+ end
25
+
26
+
27
+ # Contains an entry for each project included as a component of larger
28
+ # application or library.
29
+ class Manifest
30
+ include Singleton
31
+
32
+ def initialize # :nodoc:
33
+ @projects = {}
34
+ end
35
+
36
+ # Returns a Project if +key+ is a url. Returns a URL if +key+ is a Project.
37
+ #
38
+ # The Manifest functions as a two-way hash, where either a URL or a Project
39
+ # can be used as a key, returning its associated value.
40
+ def [](key)
41
+ return @projects.invert[key] if key.is_a?(Project)
42
+ @projects[key]
43
+ end
44
+
45
+ def <<(proj) # :nodoc:
46
+ key = Chrysalis::Loader.instance.loading
47
+
48
+ raise ManifestError, "Project already exists in manifest. (URL: #{key})" if @projects[key]
49
+ @projects[key] = proj
50
+ end
51
+
52
+ end
53
+
54
+ # Stores the results of retrieving a working copy from a repository.
55
+ #--
56
+ # The cache is implemented as hash, where each URL-based key serves as an
57
+ # index to another hash. The entire structure is serialized to the cache file.
58
+ # When deserializing, the structure is used to instantiate WorkingCopy
59
+ # instances, effectively recovering the state of previously retrieved working
60
+ # copies.
61
+ class Cache
62
+ include Singleton
63
+
64
+ def initialize # :nodoc:
65
+ @hash = {}
66
+ load
67
+ end
68
+
69
+ def [](url)
70
+ @hash[url]
71
+ end
72
+
73
+ def []=(url, working_copy)
74
+ @hash[url] = working_copy.to_hash
75
+ serialize
76
+ end
77
+
78
+ private
79
+ def load
80
+ return if (!File.exist?(META_CACHE_FILE))
81
+
82
+ File.open(META_CACHE_FILE) do |input|
83
+ @hash = YAML.load(input)
84
+ @hash = {} if @hash.nil?
85
+ end
86
+
87
+ expired = @hash.reject do |url, value|
88
+ working_copy = WorkingCopy.create(value)
89
+ if working_copy.nil?
90
+ false
91
+ else
92
+ if !working_copy.exist?
93
+ false
94
+ else
95
+ Chrysalis::Loader.instance.load(url, working_copy)
96
+ true
97
+ end
98
+ end
99
+ end
100
+
101
+ expired.each { |key, value| @hash.delete(key) }
102
+ serialize
103
+ end
104
+
105
+ def serialize
106
+ FileUtils.mkdir_p(META_DIRECTORY) if (!File.exist?(META_DIRECTORY))
107
+
108
+ File.open(META_CACHE_FILE, 'w') do |out|
109
+ YAML.dump(@hash, out)
110
+ end
111
+ end
112
+
113
+ end
114
+
115
+
116
+ # Manages tasks synthesized by Chrysalis.
117
+ #
118
+ # Chrysalis synthesizes any tasks into an <em>all:</em> namespace. When
119
+ # executing a task in this namespace, Chrysalis will retrieve any required
120
+ # dependencies and execute the task on them.
121
+ #
122
+ # Graph algorithms are used to ensure that task execution is done in the
123
+ # correct order. Tasks are guaranteed to be executed on dependencies before
124
+ # being executed on any dependents.
125
+ #
126
+ # For example, if a <tt>build</tt> task is declared, then an <tt>all:build</tt>
127
+ # task will be synthesized. Executing <em>rake all:build</em> will first
128
+ # retrieve dependencies, execute build upon them, and then invoke the main
129
+ # project's build task.
130
+
131
+ module TaskManager
132
+ @@tasks = []
133
+
134
+ # Synthesizes a task into the <em>all:</em> namespace.
135
+ def self.synthesize_task(name, comment)
136
+ # Don't synthesize tasks "all:*", "project:*", or "retrieve" tasks. These
137
+ # tasks are specific to Chrysalis, and only useful in the context of the
138
+ # main project.
139
+ if name.begin?('all:') || name.begin?('project:') || name.eql?('retrieve')
140
+ return
141
+ end
142
+
143
+ if !@@tasks.include?(name)
144
+
145
+ rake_namespace :all do
146
+ rake_task name => "^retrieve"
147
+
148
+ rake_desc "Invoke #{name} task, including dependencies."
149
+ rake_task name do
150
+
151
+ order = Chrysalis::Manifest.instance[:main].task_exec_order
152
+ order.delete(:main)
153
+
154
+ order.each do |url|
155
+ proj = Chrysalis::Manifest.instance[url]
156
+ if proj.task?(name)
157
+ cur_dir = FileUtils.pwd
158
+ FileUtils.cd(proj.working_copy.path)
159
+
160
+ puts ""
161
+ puts "Invoking #{name} in #{proj.working_copy.path}"
162
+
163
+ cmd = "rake #{name}"
164
+ cmd << " --trace" if Rake.application.options.trace
165
+
166
+ # This is a work-around for funky handling of processes on
167
+ # Windows. If not done, rake will always fail with the following
168
+ # message:
169
+ # > rake aborted!
170
+ # > undefined method `exitstatus' for nil:NilClass
171
+ cmd << " 2>&1" if RUBY_PLATFORM.match(/mswin/)
172
+
173
+ sh cmd
174
+
175
+ FileUtils.cd(cur_dir)
176
+ end
177
+ end
178
+
179
+ if Chrysalis::Manifest.instance[:main].task?(name)
180
+ puts ""
181
+ puts "Invoking #{name}"
182
+ Rake.application[name].invoke
183
+ end
184
+ end
185
+ end
186
+
187
+ @@tasks << name
188
+ end
189
+ end
190
+
191
+ end
192
+
193
+ end
@@ -0,0 +1,129 @@
1
+ require 'pathname'
2
+ require 'gratr'
3
+ require 'gratr/dot'
4
+ require 'chrysalis/repository'
5
+ require 'chrysalis/core_ext/string'
6
+
7
+
8
+ module Chrysalis
9
+
10
+ # Represents a software development project, typically an application or
11
+ # library.
12
+ class Project
13
+ attr_accessor :name
14
+ attr_accessor :version
15
+ attr_accessor :working_copy
16
+
17
+ def initialize
18
+ Chrysalis::Manifest.instance << self
19
+
20
+ @working_copy = nil
21
+ @tasks = {}
22
+ @dependencies = []
23
+ yield self if block_given?
24
+ end
25
+
26
+ def url
27
+ Chrysalis::Manifest.instance[self]
28
+ end
29
+
30
+ # Add a dependency, located at +url+, to the project.
31
+ def dependency(url, params = {})
32
+ @dependencies << [url, params]
33
+ end
34
+ alias_method :dependency=, :dependency
35
+
36
+
37
+ def task(name, comment)
38
+ desc = (@tasks.has_key?(name) ? @tasks[name] : '')
39
+ desc.concat_sep(comment, " / ")
40
+ @tasks[name] = desc
41
+ end
42
+
43
+ def task?(name)
44
+ @tasks.has_key? name
45
+ end
46
+
47
+ # Retrieve all dependencies required by the project.
48
+ def retrieve
49
+ # A project can be declared as a dependency multiple times, which is a
50
+ # common occurance for shared libraries. After the initial retrieval, the
51
+ # retrieved flag is set. Subsequent retrievals are unnessesary.
52
+ return if @retrieved
53
+ @retrieved = true
54
+
55
+ FileUtils.mkdir_p(Chrysalis.options.prefix)
56
+
57
+ # Retrieve each direct dependency from its repository, and load it into
58
+ # the manifest.
59
+ @dependencies.each do |url, params|
60
+ # If the URL of the project is already in the manifest, it has already
61
+ # been loaded.
62
+ if !Chrysalis::Manifest.instance[url]
63
+ working_copy = Repository.retrieve(url, Chrysalis.options.prefix, params)
64
+ Chrysalis::Loader.instance.load(url, working_copy)
65
+ end
66
+ end
67
+
68
+ # Transitively retrieve dependencies.
69
+ @dependencies.each do |url, params|
70
+ Chrysalis::Manifest.instance[url].retrieve
71
+ end
72
+ end
73
+
74
+ def info
75
+ n = (@name ? @name : "<Unknown Name>")
76
+ v = (@version ? "(#{@version})" : "")
77
+
78
+ printf "- %s %s\n", n, v
79
+ puts " #{self.url}" if self.url.is_a?(String)
80
+
81
+ if Rake.application.options.trace
82
+ @tasks.each do |name, comment|
83
+ printf " %-18s # %s\n", name, comment
84
+ end
85
+ end
86
+ end
87
+
88
+ def graph(g = GRATR::Digraph.new)
89
+ @dependencies.each do |url, params|
90
+ raise "Illegal declaration of dependency on self." if self.url == url
91
+
92
+ # If an arc has already been connected between a project and a
93
+ # dependency, a cycle has been induced. This needs to be detected to
94
+ # prevent this function from looping indefinately.
95
+ #
96
+ # For example:
97
+ # libone ---depends-on--> libtwo
98
+ # libtwo ---depends-on--> libone
99
+ #
100
+ # Would graph as follows:
101
+ # libone --> libtwo
102
+ # ^ |
103
+ # '-----------'
104
+ #
105
+ # If not prevented this will keep adding edges from libone -> libtwo ->
106
+ # libone -> libtwo -> libone, and on and on and on, ad infinitum.
107
+ if !g.edge?(self.url, url)
108
+ g.add_edge!(self.url, url)
109
+
110
+ # Build the graph recursively, by telling the dependency to construct
111
+ # its portion.
112
+ dep = Chrysalis::Manifest.instance[url]
113
+ dep.graph(g) if !dep.nil?
114
+ end
115
+ end
116
+
117
+ return g.add_vertex!(self.url)
118
+ end
119
+
120
+ def task_exec_order
121
+ g = graph
122
+ raise "Cyclic dependency detected." if g.cyclic?
123
+
124
+ g.topsort.reverse
125
+ end
126
+
127
+ end
128
+
129
+ end
@@ -0,0 +1,90 @@
1
+ require 'rake'
2
+ require 'chrysalis/loader'
3
+
4
+
5
+ # Intercept Rake's methods for declaring tasks.
6
+ #
7
+ # Task interception is used to construct internal tables, and synthesize
8
+ # tasks in the <em>all:</em> namespace.
9
+ #
10
+ # Tasks declared in the course of loading a dependency are intercepted
11
+ # completely, and are unknown to Rake. This prevents dependencies from
12
+ # polluting the task space of their dependents.
13
+ #
14
+ # Tasks declared by the main rakefile are forwarded to Rake's original methods,
15
+ # ensuring that the system remains consistent.
16
+
17
+ class Object
18
+
19
+ alias_method :rake_task, :task
20
+ def task(args, &block)
21
+ task_name, deps = Rake.application.resolve_args(args)
22
+ Chrysalis::Loader.instance.task_intercept(task_name) { rake_task(args, &block) }
23
+ end
24
+
25
+ alias_method :rake_file, :file
26
+ def file(args, &block)
27
+ task_name, deps = Rake.application.resolve_args(args)
28
+ Chrysalis::Loader.instance.file_task_intercept(task_name) { rake_file(args, &block) }
29
+ end
30
+
31
+ alias_method :rake_file_create, :file_create
32
+ def file_create(args, &block)
33
+ task_name, deps = Rake.application.resolve_args(args)
34
+ Chrysalis::Loader.instance.file_task_intercept(task_name) { rake_file_create(args, &block) }
35
+ end
36
+
37
+ #--
38
+ # NOTE: Intercepting directory tasks is unnecessary, because Rake itself
39
+ # implements them as a set of file_create tasks. Thus, the interception
40
+ # of file_create is sufficient.
41
+ #alias_method :rake_directory, :directory
42
+ #def directory(dir)
43
+ #end
44
+
45
+ alias_method :rake_multitask, :multitask
46
+ def multitask(args, &block)
47
+ task_name, deps = Rake.application.resolve_args(args)
48
+ Chrysalis::Loader.instance.task_intercept(task_name) { rake_multitask(args, &block) }
49
+ end
50
+
51
+ alias_method :rake_namespace, :namespace
52
+ def namespace(name=nil, &block)
53
+ Chrysalis::Loader.instance.namespace_intercept(name, block) { rake_namespace(name, &block) }
54
+ end
55
+
56
+ alias_method :rake_rule, :rule
57
+ def rule(args, &block)
58
+ Chrysalis::Loader.instance.rule_intercept { rake_rule(args, &block) }
59
+ end
60
+
61
+ alias_method :rake_desc, :desc
62
+ def desc(comment)
63
+ Chrysalis::Loader.instance.desc_intercept(comment) { rake_desc(comment) }
64
+ end
65
+
66
+ end
67
+
68
+
69
+ module Rake
70
+
71
+ class Application
72
+
73
+ alias_method :rake_top_level, :top_level
74
+
75
+ # Invoked after the main rakefile has been loaded, but before any tasks are
76
+ # executed.
77
+ #
78
+ # Chrysalis reimplements this method for the purpose of hooking into the
79
+ # loader and seeding the <em>all:</em> namespace with tasks, prior to
80
+ # retrieval of dependencies.
81
+ #
82
+ # After invoking the hook, execution proceeds with Rake's original method.
83
+ def top_level
84
+ Chrysalis.post :rakefile_loaded
85
+ rake_top_level
86
+ end
87
+
88
+ end
89
+
90
+ end
@@ -0,0 +1,142 @@
1
+ require 'pathname'
2
+ require 'chrysalis/errors'
3
+
4
+
5
+ module Chrysalis
6
+
7
+ # Represents a repository from which dependencies can be retrieved.
8
+ #
9
+ # This class is intended to be subclassed in order to implement support for
10
+ # concrete types of repositories.
11
+ class Repository
12
+ @@repositories = []
13
+
14
+ def self.inherited(repository) # :nodoc:
15
+ @@repositories << repository
16
+ end
17
+
18
+ # Retrieves a WorkingCopy from the repository located at +url+. The working
19
+ # copy is placed at the path +to+, which defaults to the current directory.
20
+ # An optional set of parameters can be specified in +params+.
21
+ #
22
+ # Returns a WorkingCopy.
23
+ #
24
+ # Using the factory design pattern, this method locates a subclass of
25
+ # Repository that implements support for the given URL. That subclass will
26
+ # be instructed to retrieve a working copy from the repository.
27
+ #
28
+ # If the working copy has already been retrieved, it will be found in the
29
+ # cache and returned directly.
30
+ #
31
+ # If support for the given URL is not implemented, a RepositoryError will be
32
+ # raised.
33
+ def self.retrieve(url, to = '.', params = {})
34
+ cached = WorkingCopy.create(Chrysalis::Cache.instance[url])
35
+ return cached if cached
36
+
37
+ @@repositories.reverse.each { |repository|
38
+ if repository.retrieves?(url, params)
39
+ working_copy = repository.new(url, params).retrieve(to)
40
+ Chrysalis::Cache.instance[url] = working_copy
41
+ return working_copy
42
+ end
43
+ }
44
+ raise RepositoryError, "Unknown version control system. (URL: #{url})"
45
+ end
46
+
47
+
48
+ # Returns +true+ if this class implements support for +url+. Otherwise,
49
+ # returns +false+.
50
+ #
51
+ # Because this class is abstract, +false+ is returned unconditionally.
52
+ # Subclasses are expected to provide an implementation.
53
+ def self.retrieves?(url, params = {})
54
+ false
55
+ end
56
+
57
+ def initialize(url, params = {})
58
+ end
59
+
60
+ # Retrieves a working copy from the repository.
61
+ #
62
+ # Because this class is abstract, always raises an UnimplementedError.
63
+ # Subclasses are expected to provide an implementation.
64
+ def retrieve(to = '.')
65
+ raise UnimplementedError, "Repository#retrieve not implemented"
66
+ end
67
+
68
+ end
69
+
70
+
71
+ # Represents a working copy that has been retrieved from a repository.
72
+ #
73
+ # This class implements support for a file system-based working copy stored in
74
+ # a directory.
75
+ #
76
+ # Subclasses can provide implementation for working copies with additional
77
+ # functionality, such as those retrieved from a version control system.
78
+ class WorkingCopy
79
+ @@working_copies = [self]
80
+
81
+ def self.inherited(working_copies) # :nodoc:
82
+ @@working_copies << working_copies
83
+ end
84
+
85
+ # Returns a WorkingCopy constructed with +params+.
86
+ #
87
+ # Using the factory design pattern, this method instantiates a new instance
88
+ # of WorkingCopy based on the key <tt>:type</tt> in the +params+ hash.
89
+ #
90
+ # If <tt>:type</tt> is not supported, +nil+ is returned.
91
+ #
92
+ # This method is intended to be used for the purpose of instantiating a
93
+ # working copies directly from the cache, to obviate the need for retrieval
94
+ # when the working copy already exists on the system.
95
+ def self.create(params)
96
+ return nil if params.nil?
97
+ type = params[:type]
98
+
99
+ @@working_copies.each do |working_copy|
100
+ return working_copy.new(params) if working_copy.type == type
101
+ end
102
+ nil
103
+ end
104
+
105
+
106
+ attr_reader :url
107
+ attr_reader :path
108
+
109
+ def self.type
110
+ :directory
111
+ end
112
+
113
+ def initialize(params = {})
114
+ @url = params[:url]
115
+ @path = params[:path]
116
+ end
117
+
118
+ # Returns +true+ if the working copy exists on disk. Otherwise, returns
119
+ # +false+.
120
+ #
121
+ # Typically, if the working copy no longer exits on disk, it has been
122
+ # explicitly removed by a developer.
123
+ def exist?
124
+ Pathname.new(@path).exist?
125
+ end
126
+
127
+ # Returns a hash which will be serialized to the cache.
128
+ #
129
+ # At a minimum, a <tt>:type</tt> key must be present in the hash. This key
130
+ # is used when loading the cache, to instantiate working copies that have
131
+ # previously been retrieved.
132
+ #
133
+ # Subclasses can add any additional keys and values to the hash, and utilize
134
+ # them for purposes of deserialization.
135
+ def to_hash
136
+ { :type => self.class.type,
137
+ :url => @url,
138
+ :path => @path }
139
+ end
140
+ end
141
+
142
+ end
@@ -0,0 +1,78 @@
1
+ require 'rake'
2
+ require 'rake/tasklib'
3
+ require 'chrysalis/manifest'
4
+
5
+
6
+ module Rake
7
+
8
+ class ChrysalisTask < TaskLib
9
+
10
+ def initialize()
11
+ yield self if block_given?
12
+ define
13
+ end
14
+
15
+ def define
16
+ namespace :project do
17
+
18
+ desc "Show project information."
19
+ task :info do
20
+ raise "No project has been defined." if Chrysalis::Manifest.instance[:main].nil?
21
+
22
+ puts ""
23
+ puts "Project information: "
24
+
25
+ main = Chrysalis::Manifest.instance[:main]
26
+ main.graph.topsort.each do |url|
27
+ p = Chrysalis::Manifest.instance[url]
28
+ if p.nil?
29
+ puts "* #{url} [Not Loaded]"
30
+ else
31
+ p.info
32
+ end
33
+ end
34
+
35
+ end
36
+
37
+ desc "Display task execution order."
38
+ task :order do
39
+ raise "No project has been defined." if Chrysalis::Manifest.instance[:main].nil?
40
+
41
+ order = Chrysalis::Manifest.instance[:main].task_exec_order
42
+
43
+ puts ""
44
+ puts "Tasks execution order: "
45
+ order.each { |url| puts "- #{url}" }
46
+ end
47
+
48
+ desc "Draw dependency graph."
49
+ task :graph do
50
+ raise "No project has been defined." if Chrysalis::Manifest.instance[:main].nil?
51
+
52
+ g = Chrysalis::Manifest.instance[:main].graph
53
+
54
+ name = Chrysalis::Manifest.instance[:main].name
55
+ file = (name ? name.downcase.concat("-graph") : "graph")
56
+
57
+ dot = g.write_to_graphic_file('jpg', file)
58
+
59
+ puts "Dependency graph saved to #{dot}"
60
+ end
61
+
62
+ end
63
+
64
+ desc "Retrieve dependencies."
65
+ task :retrieve do
66
+ raise "No project has been defined." if Chrysalis::Manifest.instance[:main].nil?
67
+ Chrysalis::Manifest.instance[:main].retrieve
68
+ end
69
+
70
+ self
71
+ end
72
+
73
+ end
74
+
75
+ end
76
+
77
+
78
+ Chrysalis.boot
@@ -0,0 +1,63 @@
1
+ require 'uri'
2
+ require 'pathname'
3
+ require 'chrysalis/repository'
4
+ require 'chrysalis/ar'
5
+
6
+
7
+ module Chrysalis
8
+ module VCS
9
+ module File
10
+
11
+ # Implements support for copying dependencies from a file system.
12
+ #
13
+ # Repositories of this type are identified by <em>file://</em> URLs.
14
+ #
15
+ # Example:
16
+ # - file:///Users/jaredhanson/Projects/libfoo
17
+
18
+ class Repository < Chrysalis::Repository
19
+
20
+ def self.retrieves?(url, params = {})
21
+ uri = URI::parse(url)
22
+ return uri.scheme == 'file'
23
+ end
24
+
25
+
26
+ def initialize(url, params = {})
27
+ @url = url
28
+ @params = params
29
+ end
30
+
31
+ def retrieve(to = '.')
32
+ target = Pathname.new(to).join(copy_to)
33
+ raise IOError, "Refusing to overwrite existing path. (path: #{target})" if target.exist?
34
+
35
+ unless @params[:noop]
36
+ puts ""
37
+ puts "Copying #{@url} ..."
38
+
39
+ source = URI::parse(@url)
40
+ FileUtils.cp_r(source.path, target)
41
+ end
42
+
43
+ extract_path = Archive.extract(target.to_s, to, @params)
44
+ WorkingCopy.new(:url => @url,
45
+ :path => extract_path)
46
+ end
47
+
48
+ private
49
+ def copy_to
50
+ return @params[:copy_to] if @params[:copy_to]
51
+
52
+ uri = URI::parse(@url)
53
+ dir = uri.path.split('/')[-1]
54
+
55
+ raise ParameterError, "Invalid URL. (URL: #{@url})" if dir.nil?
56
+ return dir
57
+ end
58
+
59
+ end
60
+
61
+ end
62
+ end
63
+ end
@@ -0,0 +1 @@
1
+ require 'chrysalis/vcs/file/repository'