carbon 1.1.3 → 2.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.
- data/.gitignore +4 -22
- data/CHANGELOG +11 -0
- data/Gemfile +10 -1
- data/README.markdown +185 -0
- data/Rakefile +13 -26
- data/bin/carbon +3 -3
- data/carbon.gemspec +17 -23
- data/developer_notes/MULTI.markdown +25 -0
- data/developer_notes/REDUCE_HTTP_CONNECTIONS.markdown +46 -0
- data/features/shell.feature +1 -1
- data/features/support/env.rb +3 -4
- data/lib/carbon.rb +242 -96
- data/lib/carbon/registry.rb +50 -0
- data/lib/carbon/shell.rb +14 -8
- data/lib/carbon/shell/emitter.rb +33 -29
- data/lib/carbon/version.rb +1 -1
- data/test/carbon_test.rb +167 -0
- metadata +128 -182
- data/MIT-LICENSE.txt +0 -19
- data/README.rdoc +0 -266
- data/doc/INTEGRATION_GUIDE.rdoc +0 -1002
- data/doc/examining-response-with-jsonview.png +0 -0
- data/doc/shell_example +0 -43
- data/doc/timeout-error.png +0 -0
- data/doc/with-committee-reports.png +0 -0
- data/doc/without-committee-reports.png +0 -0
- data/lib/carbon/base.rb +0 -62
- data/lib/carbon/emission_estimate.rb +0 -165
- data/lib/carbon/emission_estimate/request.rb +0 -100
- data/lib/carbon/emission_estimate/response.rb +0 -61
- data/lib/carbon/emission_estimate/storage.rb +0 -33
- data/spec/fixtures/vcr_cassettes/flight.yml +0 -47
- data/spec/fixtures/vcr_cassettes/residence.yml +0 -44
- data/spec/lib/carbon/emission_estimate/request_spec.rb +0 -41
- data/spec/lib/carbon/emission_estimate/response_spec.rb +0 -33
- data/spec/lib/carbon/emission_estimate_spec.rb +0 -32
- data/spec/lib/carbon_spec.rb +0 -384
- data/spec/spec_helper.rb +0 -60
- data/spec/specwatchr +0 -60
data/lib/carbon.rb
CHANGED
@@ -1,115 +1,261 @@
|
|
1
|
-
require '
|
2
|
-
require '
|
3
|
-
require '
|
4
|
-
require '
|
5
|
-
require 'rest' # provided by nap gem
|
6
|
-
require 'active_support'
|
7
|
-
require 'active_support/version'
|
8
|
-
%w{
|
9
|
-
active_support/core_ext
|
10
|
-
active_support/inflector
|
11
|
-
active_support/inflector/inflections
|
12
|
-
active_support/json/decoding
|
13
|
-
}.each do |active_support_3_requirement|
|
14
|
-
require active_support_3_requirement
|
15
|
-
end if ActiveSupport::VERSION::MAJOR >= 3
|
1
|
+
require 'net/http'
|
2
|
+
require 'hashie/mash'
|
3
|
+
require 'multi_json'
|
4
|
+
require 'active_support/core_ext'
|
16
5
|
|
17
|
-
require '
|
6
|
+
require 'carbon/registry'
|
18
7
|
|
19
|
-
# A module (aka mixin) that lets you estimate carbon emissions by querying the {Brighter Planet carbon middleware emission estimate web service}[http://carbon.brighterplanet.com].
|
20
|
-
#
|
21
|
-
# class RentalCar
|
22
|
-
# include Carbon
|
23
|
-
# [...]
|
24
|
-
# emit_as :automobile do
|
25
|
-
# provide :make
|
26
|
-
# provide :model
|
27
|
-
# provide :model_year
|
28
|
-
# end
|
29
|
-
# end
|
30
|
-
#
|
31
|
-
# The DSL consists of the methods <tt>emit_as</tt> and <tt>provide</tt>.
|
32
|
-
#
|
33
|
-
# In this example, the DSL says:
|
34
|
-
# * A rental car emits carbon like an "automobile", which is one of Brighter Planet's recognized emitter classes.
|
35
|
-
# * Your implementation can provide up to three data points about a rental car: its make, its model, and its model year (but not necessarily all of them, all the time.)
|
36
|
-
#
|
37
|
-
# Once you've mixed in <tt>Carbon</tt>, you get the method <tt>emission_estimate</tt>, which you can call at any time to request an emission estimate.
|
38
8
|
module Carbon
|
39
|
-
|
40
|
-
autoload :EmissionEstimate, 'carbon/emission_estimate'
|
41
|
-
autoload :Registry, 'carbon/registry'
|
42
|
-
|
43
|
-
def self.included(klass) # :nodoc:
|
44
|
-
klass.extend ClassMethods
|
45
|
-
end
|
46
|
-
|
47
|
-
class RealtimeEstimateFailed < RuntimeError # :nodoc:
|
48
|
-
end
|
49
|
-
class QueueingFailed < RuntimeError # :nodoc:
|
50
|
-
end
|
51
|
-
class RateLimited < RuntimeError # :nodoc:
|
52
|
-
end
|
53
|
-
class TriedToUseAsyncResponseAsNumber < RuntimeError # :nodoc:
|
54
|
-
end
|
9
|
+
DOMAIN = 'http://impact.brighterplanet.com'
|
55
10
|
|
56
|
-
#
|
57
|
-
|
11
|
+
# @private
|
12
|
+
# Make sure there are no warnings about class vars.
|
13
|
+
@@key = nil unless defined?(@@key)
|
58
14
|
|
59
|
-
|
15
|
+
# Set the Brighter Planet API key that you can get from http://keys.brighterplanet.com
|
16
|
+
#
|
17
|
+
# @param [String] key The alphanumeric key.
|
18
|
+
#
|
19
|
+
# @return [nil]
|
20
|
+
def self.key=(key)
|
21
|
+
@@key = key
|
22
|
+
end
|
60
23
|
|
61
|
-
|
62
|
-
|
24
|
+
# Get the key you've set.
|
25
|
+
#
|
26
|
+
# @return [String] The key you set.
|
27
|
+
def self.key
|
28
|
+
@@key
|
63
29
|
end
|
64
|
-
|
65
|
-
|
66
|
-
|
30
|
+
|
31
|
+
# Do a simple query.
|
32
|
+
#
|
33
|
+
# See the {file:README.html#API_response section about API responses} for an explanation of +Hashie::Mash+.
|
34
|
+
#
|
35
|
+
# @param [String] emitter The {http://impact.brighterplanet.com/emitters.json camelcased emitter name}.
|
36
|
+
# @param [Hash] params Characteristics, your API key (if you didn't set it globally), timeframe, compliance, etc.
|
37
|
+
#
|
38
|
+
# @option params [Timeframe] :timeframe (Timeframe.this_year) What time period to focus the calculation on. See {https://github.com/rossmeissl/timeframe timeframe} documentation.
|
39
|
+
# @option params [Array<Symbol>] :comply ([]) What {http://impact.brighterplanet.com/protocols.json calculation protocols} to require.
|
40
|
+
# @option params [String, Numeric] _characteristic_ Pieces of data about an emitter. The {http://impact.brighterplanet.com/flights/options Flight characteristics API} lists valid keys like +:aircraft+, +:origin_airport+, etc.
|
41
|
+
#
|
42
|
+
# @return [Hashie::Mash] An {file:README.html#API_response API response as documented in the README}
|
43
|
+
#
|
44
|
+
# @example A flight taken in 2009
|
45
|
+
# Carbon.query('Flight', :origin_airport => 'MSN', :destination_airport => 'ORD', :date => '2009-01-01', :timeframe => Timeframe.new(:year => 2009), :comply => [:tcr])
|
46
|
+
def self.query(emitter, params = {})
|
47
|
+
params ||= {}
|
48
|
+
params = params.reverse_merge(:key => key) if key
|
49
|
+
uri = ::URI.parse("#{DOMAIN}/#{emitter.underscore.pluralize}.json")
|
50
|
+
raw_response = ::Net::HTTP.post_form(uri, params)
|
51
|
+
response = ::Hashie::Mash.new
|
52
|
+
case raw_response
|
53
|
+
when ::Net::HTTPSuccess
|
54
|
+
response.status = raw_response.code.to_i
|
55
|
+
response.success = true
|
56
|
+
response.merge! ::MultiJson.decode(raw_response.body)
|
57
|
+
else
|
58
|
+
response.status = raw_response.code.to_i
|
59
|
+
response.success = false
|
60
|
+
response.error_body = raw_response.respond_to?(:body) ? raw_response.body : ''
|
61
|
+
response.errors = [raw_response.class.name]
|
62
|
+
end
|
63
|
+
response
|
67
64
|
end
|
68
|
-
|
69
|
-
#
|
65
|
+
|
66
|
+
# Perform many queries in parallel. Can be >90% faster than doing them serially (one after the other).
|
70
67
|
#
|
71
|
-
#
|
68
|
+
# See the {file:README.html#API_response section about API responses} for an explanation of +Hashie::Mash+.
|
69
|
+
#
|
70
|
+
# @param [Array<Array>] queries Multiple queries like you would pass to {Carbon.query}
|
71
|
+
#
|
72
|
+
# @return [Array<Hashie::Mash>] An array of {file:README.html#API_response API responses} in the same order as the queries.
|
73
|
+
#
|
74
|
+
# @note Not supported on JRuby because it uses {https://github.com/igrigorik/em-http-request em-http-request}, which suffers from {https://github.com/eventmachine/eventmachine/issues/155 an issue with +pending_connect_timeout+}.
|
75
|
+
#
|
76
|
+
# @example Two flights and an automobile trip
|
77
|
+
# queries = [
|
78
|
+
# ['Flight', :origin_airport => 'MSN', :destination_airport => 'ORD', :date => '2009-01-01', :timeframe => Timeframe.new(:year => 2009), :comply => [:tcr]],
|
79
|
+
# ['Flight', :origin_airport => 'SFO', :destination_airport => 'LAX', :date => '2011-09-29', :timeframe => Timeframe.new(:year => 2011), :comply => [:iso]],
|
80
|
+
# ['AutomobileTrip', :make => 'Nissan', :model => 'Altima', :timeframe => Timeframe.new(:year => 2008), :comply => [:tcr]]
|
81
|
+
# ]
|
82
|
+
# Carbon.multi(queries)
|
83
|
+
def self.multi(queries)
|
84
|
+
require 'em-http-request'
|
85
|
+
unsorted = {}
|
86
|
+
multi = ::EventMachine::MultiRequest.new
|
87
|
+
::EventMachine.run do
|
88
|
+
queries.each_with_index do |(emitter, params), query_idx|
|
89
|
+
params ||= {}
|
90
|
+
params = params.reverse_merge(:key => key) if key
|
91
|
+
multi.add query_idx, ::EventMachine::HttpRequest.new(DOMAIN).post(:path => "/#{emitter.underscore.pluralize}.json", :body => params)
|
92
|
+
end
|
93
|
+
multi.callback do
|
94
|
+
multi.responses[:callback].each do |query_idx, http|
|
95
|
+
response = ::Hashie::Mash.new
|
96
|
+
response.status = http.response_header.status
|
97
|
+
if (200..299).include?(response.status)
|
98
|
+
response.success = true
|
99
|
+
response.merge! ::MultiJson.decode(http.response)
|
100
|
+
else
|
101
|
+
response.success = false
|
102
|
+
response.errors = [http.response]
|
103
|
+
end
|
104
|
+
unsorted[query_idx] = response
|
105
|
+
end
|
106
|
+
multi.responses[:errback].each do |query_idx, http|
|
107
|
+
response = ::Hashie::Mash.new
|
108
|
+
response.status = http.response_header.status
|
109
|
+
response.success = false
|
110
|
+
response.errors = ['Timeout or other network error.']
|
111
|
+
unsorted[query_idx] = response
|
112
|
+
end
|
113
|
+
::EventMachine.stop
|
114
|
+
end
|
115
|
+
end
|
116
|
+
unsorted.sort_by do |query_idx, _|
|
117
|
+
query_idx
|
118
|
+
end.map do |_, response|
|
119
|
+
response
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Called when you +include Carbon+ and adds the class method +emit_as+.
|
124
|
+
# @private
|
125
|
+
def self.included(klass)
|
126
|
+
klass.extend ClassMethods
|
127
|
+
end
|
128
|
+
|
129
|
+
# Mixed into any class that includes +Carbon+.
|
72
130
|
module ClassMethods
|
73
|
-
#
|
131
|
+
# DSL for declaring how to represent this class an an emitter.
|
74
132
|
#
|
75
|
-
#
|
133
|
+
# You get this when you +include Carbon+ in a class.
|
76
134
|
#
|
77
|
-
#
|
78
|
-
#
|
79
|
-
#
|
135
|
+
# @param [String] emitter The {http://impact.brighterplanet.com/emitters.json camelcased emitter name}.
|
136
|
+
#
|
137
|
+
# @return [nil]
|
138
|
+
#
|
139
|
+
# Things to note in the MyFlight example:
|
140
|
+
#
|
141
|
+
# * Sending +:origin+ to Brighter Planet *as* +:origin_airport+. Otherwise Brighter Planet won't recognize +:origin+.
|
142
|
+
# * Saying we're *keying* on one code or another. Otherwise Brighter Planet will first try against full names and possibly other columns.
|
143
|
+
# * Giving *blocks* to pull codes from +MyAircraft+ and +MyAirline+ objects. Otherwise you might get a querystring like +airline[iata_code]=#<MyAirline [...]>+
|
144
|
+
#
|
145
|
+
# @example MyFlight
|
146
|
+
# # A a flight in your data warehouse
|
147
|
+
# class MyFlight
|
148
|
+
# def airline
|
149
|
+
# # ... => MyAirline(:name, :icao_code, ...)
|
150
|
+
# end
|
151
|
+
# def aircraft
|
152
|
+
# # ... => MyAircraft(:name, :icao_code, ...)
|
153
|
+
# end
|
154
|
+
# def origin
|
155
|
+
# # ... => String
|
156
|
+
# end
|
157
|
+
# def destination
|
158
|
+
# # ... => String
|
159
|
+
# end
|
160
|
+
# def segments_per_trip
|
161
|
+
# # ... => Integer
|
162
|
+
# end
|
163
|
+
# def trips
|
164
|
+
# # ... => Integer
|
165
|
+
# end
|
166
|
+
# include Carbon
|
167
|
+
# emit_as 'Flight' do
|
168
|
+
# provide :segments_per_trip
|
169
|
+
# provide :trips
|
170
|
+
# provide :origin, :as => :origin_airport, :key => :iata_code
|
171
|
+
# provide :destination, :as => :destination_airport, :key => :iata_code
|
172
|
+
# provide(:airline, :key => :iata_code) { |f| f.airline.iata_code }
|
173
|
+
# provide(:aircraft, :key => :icao_code) { { |f| f.aircraft.icao_code }
|
174
|
+
# end
|
80
175
|
# end
|
81
|
-
def emit_as(
|
82
|
-
|
83
|
-
::
|
176
|
+
def emit_as(emitter, &blk)
|
177
|
+
emitter = emitter.to_s.singularize.camelcase
|
178
|
+
registrar = Registry::Registrar.new self, emitter
|
179
|
+
registrar.instance_eval(&blk)
|
84
180
|
end
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
181
|
+
end
|
182
|
+
|
183
|
+
# What will be sent to Brighter Planet CM1.
|
184
|
+
# @private
|
185
|
+
def impact_params
|
186
|
+
return unless registration = Registry.instance[self.class.name]
|
187
|
+
registration.characteristics.inject({}) do |memo, (method_id, translation_options)|
|
188
|
+
k = translation_options.has_key?(:as) ? translation_options[:as] : method_id
|
189
|
+
if translation_options.has_key?(:key)
|
190
|
+
k = "#{k}[#{translation_options[:key]}]"
|
191
|
+
end
|
192
|
+
v = if translation_options.has_key?(:blk)
|
193
|
+
translation_options[:blk].call self
|
194
|
+
else
|
195
|
+
send method_id
|
196
|
+
end
|
197
|
+
memo[k] = v
|
198
|
+
memo
|
90
199
|
end
|
91
200
|
end
|
92
201
|
|
93
|
-
#
|
94
|
-
#
|
95
|
-
#
|
96
|
-
#
|
97
|
-
#
|
98
|
-
#
|
99
|
-
#
|
100
|
-
#
|
101
|
-
#
|
102
|
-
#
|
103
|
-
#
|
104
|
-
#
|
105
|
-
#
|
106
|
-
#
|
107
|
-
#
|
108
|
-
#
|
109
|
-
#
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
202
|
+
# Get an impact estimate from Brighter Planet CM1.
|
203
|
+
#
|
204
|
+
# You get this when you +include Carbon+ in a class.
|
205
|
+
#
|
206
|
+
# The return value is a {http://rdoc.info/github/intridea/hashie/Hashie/Mash Hashie::Mash} because it's a simple way to access a deep response object.
|
207
|
+
#
|
208
|
+
# Here's a map of what's included in a response:
|
209
|
+
#
|
210
|
+
# certification
|
211
|
+
# characteristics.{}.description
|
212
|
+
# characteristics.{}.object
|
213
|
+
# compliance.[]
|
214
|
+
# decisions.{}.description
|
215
|
+
# decisions.{}.methodology
|
216
|
+
# decisions.{}.object
|
217
|
+
# emitter
|
218
|
+
# equivalents.{}
|
219
|
+
# errors.[]
|
220
|
+
# methodology
|
221
|
+
# scope
|
222
|
+
# timeframe.endDate
|
223
|
+
# timeframe.startDate
|
224
|
+
#
|
225
|
+
# @param [Hash] extra_params Anything that your +emit_as+ won't include.
|
226
|
+
#
|
227
|
+
# @option extra_params [Timeframe] :timeframe
|
228
|
+
# @option extra_params [Array<Symbol>] :comply
|
229
|
+
# @option extra_params [String] :key In case you didn't define it globally, or want to use a different one here.
|
230
|
+
#
|
231
|
+
# @return [Hashie::Mash]
|
232
|
+
#
|
233
|
+
# @example Getting impact estimate for MyFlight
|
234
|
+
# ?> my_flight = MyFlight.new([...])
|
235
|
+
# => #<MyFlight [...]>
|
236
|
+
# ?> my_impact = my_flight.impact(:timeframe => Timeframe.new(:year => 2009))
|
237
|
+
# => #<Hashie::Mash [...]>
|
238
|
+
# ?> my_impact.decisions.carbon.object.value
|
239
|
+
# => 1014.92
|
240
|
+
# ?> my_impact.decisions.carbon.object.units
|
241
|
+
# => "kilograms"
|
242
|
+
# ?> my_impact.methodology
|
243
|
+
# => "http://impact.brighterplanet.com/flights?[...]"
|
244
|
+
#
|
245
|
+
# @example How do I use a Hashie::Mash?
|
246
|
+
# ?> mash['hello']
|
247
|
+
# => "world"
|
248
|
+
# ?> mash.hello
|
249
|
+
# => "world"
|
250
|
+
# ?> mash.keys
|
251
|
+
# => ["hello"]
|
252
|
+
#
|
253
|
+
# @example Other examples of what's in the response
|
254
|
+
# my_impact.carbon.object.value
|
255
|
+
# my_impact.characteristics.airline.description
|
256
|
+
# my_impact.equivalents.lightbulbs_for_a_week
|
257
|
+
def impact(extra_params = {})
|
258
|
+
return unless registration = Registry.instance[self.class.name]
|
259
|
+
Carbon.query registration.emitter, impact_params.merge(extra_params)
|
114
260
|
end
|
115
261
|
end
|
data/lib/carbon/registry.rb
CHANGED
@@ -1,6 +1,56 @@
|
|
1
1
|
require 'singleton'
|
2
|
+
|
2
3
|
module Carbon
|
4
|
+
# Used internally to hold the information about how each class that has called `emit_as`.
|
5
|
+
# @private
|
3
6
|
class Registry < ::Hash
|
4
7
|
include ::Singleton
|
8
|
+
|
9
|
+
# Used internally to record the emitter and parameters (characteristics) provided by a class that has called `emit_as`.
|
10
|
+
# @private
|
11
|
+
class Registration < ::Struct.new(:emitter, :characteristics)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Used internally when instance-eval'ing the `emit_as` DSL.
|
15
|
+
# @private
|
16
|
+
class Registrar
|
17
|
+
# @private
|
18
|
+
def initialize(klass, emitter)
|
19
|
+
@klass = klass
|
20
|
+
Registry.instance[klass.name] = Registration.new
|
21
|
+
Registry.instance[klass.name].emitter = emitter
|
22
|
+
Registry.instance[klass.name].characteristics = {}
|
23
|
+
end
|
24
|
+
|
25
|
+
# Indicate that you will send in a piece of data about the emitter.
|
26
|
+
#
|
27
|
+
# @param [Symbol] method_id What method to call to get the value in question.
|
28
|
+
#
|
29
|
+
# @option translation_options [Symbol] :as (name of the method) If your method name does not match the Brighter Planet characteristic name.
|
30
|
+
# @option translation_options [Symbol] :key (a number of columns) What you are keying on. By default, we do a fuzzy match against a number of fields, including full names and various codes.
|
31
|
+
#
|
32
|
+
# @yield [] Pass a block for the common use case of calling a method on a object.
|
33
|
+
#
|
34
|
+
# @example Your method is named one thing but should be sent +:as+ something else.
|
35
|
+
# provide :my_distance, :as => :distance
|
36
|
+
#
|
37
|
+
# @example You are keying on something well-known like {http://en.wikipedia.org/wiki/Airline_codes IATA airline codes}.
|
38
|
+
# provide(:airline, :key => :iata_code) { |f| f.airline.iata_code }
|
39
|
+
#
|
40
|
+
# @example Better to use a block
|
41
|
+
# provide(:airline, :key => :iata_code) { |f| f.airline.iata_code }
|
42
|
+
# # is equivalent to
|
43
|
+
# def airline_iata_code
|
44
|
+
# airline.iata_code
|
45
|
+
# end
|
46
|
+
# provide :airline_iata_code, :as => :airline, :key => :iata_code
|
47
|
+
def provide(method_id, translation_options = {}, &blk)
|
48
|
+
translation_options = translation_options.dup
|
49
|
+
if block_given?
|
50
|
+
translation_options[:blk] = blk
|
51
|
+
end
|
52
|
+
Registry.instance[@klass.name].characteristics[method_id] = translation_options
|
53
|
+
end
|
54
|
+
end
|
5
55
|
end
|
6
56
|
end
|
data/lib/carbon/shell.rb
CHANGED
@@ -1,8 +1,18 @@
|
|
1
|
-
require '
|
1
|
+
require 'carbon'
|
2
2
|
require 'bombshell'
|
3
3
|
require 'conversions'
|
4
|
+
require 'brighter_planet_metadata'
|
5
|
+
|
4
6
|
module Carbon
|
7
|
+
# @private
|
5
8
|
class Shell < Bombshell::Environment
|
9
|
+
class << self
|
10
|
+
# @private
|
11
|
+
def emitters
|
12
|
+
::BrighterPlanet.metadata.emitters
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
6
16
|
include Bombshell::Shell
|
7
17
|
|
8
18
|
before_launch do
|
@@ -20,24 +30,21 @@ module Carbon
|
|
20
30
|
|
21
31
|
prompt_with 'carbon-'
|
22
32
|
|
33
|
+
# @private
|
23
34
|
def help
|
24
35
|
puts " => #{self.class.emitters.join ', '}"
|
25
36
|
end
|
26
37
|
|
38
|
+
# @private
|
27
39
|
def key(k)
|
28
40
|
::Carbon.key = k
|
29
41
|
puts " => Using key #{::Carbon.key}"
|
30
42
|
end
|
31
43
|
|
44
|
+
# @private
|
32
45
|
def emitter(e, saved = {})
|
33
46
|
Emitter.launch e, saved
|
34
47
|
end
|
35
|
-
|
36
|
-
class << self
|
37
|
-
def emitters
|
38
|
-
::BrighterPlanet.metadata.emitters
|
39
|
-
end
|
40
|
-
end
|
41
48
|
end
|
42
49
|
end
|
43
50
|
|
@@ -48,4 +55,3 @@ if File.exist?(dotfile = File.join(ENV['HOME'], '.brighter_planet'))
|
|
48
55
|
end
|
49
56
|
|
50
57
|
require 'carbon/shell/emitter'
|
51
|
-
|