alma_course_loader 0.9.1

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,272 @@
1
+ require 'json'
2
+ require 'set'
3
+
4
+ module AlmaCourseLoader
5
+ # Methods for parsing filter specification strings
6
+ # The primary method is parse_filter(str, extractors)
7
+ module FilterParser
8
+ # The regular expression describing a filter string: [!][extractor[op]]value
9
+ FILTER = Regexp.new('\s*(?<negate>!)?\s*' \
10
+ '(' \
11
+ '(?<extractor>[^\s]*)\s+' \
12
+ '(?<method><=?|>=?|==|!=|~|=~|!~|keyin|valuein|in)\s+' \
13
+ ')?' \
14
+ '(?<value>.*)').freeze
15
+
16
+ # Filter operators mapped to method names
17
+ #
18
+ # The operators in filter expressions are converted to methods called on
19
+ # the filter value. Note that this means that the < and > operators are
20
+ # inverted ('field < filter-value' is equivalent to 'filter-value.>(field)')
21
+ # Operators not specified explicitly in FILTER_MAP are called directly as
22
+ # methods.
23
+ #
24
+ # The hash value is either:
25
+ # - a Symbol representing the method name to call on the filter value;
26
+ # - an array [method-name-symbol, negated] containing the method name symbol
27
+ # and a Boolean indicating whether the method is implicitly negated,
28
+ # e.g. !~ is !match(); the default is no implicit negation.
29
+ #
30
+ FILTER_MAP = {
31
+ :~ => :match,
32
+ :=~ => :match,
33
+ :!~ => [:match, true],
34
+ :< => :>,
35
+ :<= => :>=,
36
+ :>= => :<=,
37
+ :> => :<,
38
+ :in => :include?,
39
+ :keyin => :key?,
40
+ :valuein => :value?
41
+ }.freeze
42
+
43
+ # Parses a filter string and returns the parsed filter string components
44
+ # @param str [String] the filter string: [extractor][+|-]value
45
+ # where value is a JSON string specifying the value(s) to match,
46
+ # extractor is the optional name of an entry in the extractors hash,
47
+ # @param extractors [Hash<Symbol, Proc|Method>] a hash of named field
48
+ # extractors referenced by the filter string; this may include a nil
49
+ # key which specifies the default extractor if not given in the filter
50
+ # string
51
+ # @return [Array] the parsed filter string components:
52
+ # values, method, extractor, negate: [Object, Symbol, Proc, Boolean]
53
+ # @raise [ArgumentError] if the filter string is invalid
54
+ def parse_filter(str = nil, extractors = nil)
55
+ # Parse the filter string
56
+ raise ArgumentError, 'expected filter' if str.nil? || str.empty?
57
+ match = FILTER.match(str)
58
+ raise ArgumentError, "invalid filter: #{str}" if match.nil?
59
+ # Get the filter value(s)
60
+ value = Private.parse_value(match)
61
+ # Get the method used to compare the filter value(s)
62
+ method, method_negate = parse_method(match, value)
63
+ # Get the effective negate flag
64
+ negate = Private.negate_flag(match[:negate] == '!', method_negate)
65
+ # Get the named extractor from the hash or block
66
+ extractor = Private.parse_extractor(match, extractors)
67
+ # Return the parsed filter string components
68
+ [value, method, extractor, negate]
69
+ end
70
+
71
+ # Validates and returns the method symbol and negation flag
72
+ # @param match [MatchData] the parsed filter string
73
+ # @param values [Object] the filter value(s)
74
+ # @return [Array] the method symbol and negate flag
75
+ # @raise [ArgumentError] if the specified method is not supported by the
76
+ # filter values
77
+ def parse_method(match, values)
78
+ method_info = Private.method_symbol(match, values)
79
+ method = method_info[0]
80
+ unless values.respond_to?(method)
81
+ raise ArgumentError, "invalid method: #{values.class.name}\##{method}"
82
+ end
83
+ method_info
84
+ end
85
+
86
+ # Private helpers
87
+ module Private
88
+ # Returns true if the value parameter is a collection
89
+ # @param value [Object] the value to test
90
+ # @return [Boolean] true if the value is a collection, false otherwise
91
+ def self.collection?(value)
92
+ value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(Set)
93
+ end
94
+
95
+ # Returns a Proc implementing the filter match
96
+ # @param values [Object] the filter values
97
+ # @param method [Symbol] the method to call against the filter values
98
+ # @param negate [Boolean] if true, negate the result of the method
99
+ # @return [Proc] the Proc implementing the filter
100
+ def self.matcher(values, method = nil, negate = false)
101
+ method, negate = method_symbol(method, values) if method.nil?
102
+ proc do |value|
103
+ result = values.send(method, value)
104
+ negate ? !result : result
105
+ end
106
+ end
107
+
108
+ # Returns the method symbol or appropriate default if not specified
109
+ # If no method is specified in the filter string, the default is :include?
110
+ # for Arrays, Hashes and Sets, :match for regular expressions and :== for
111
+ # everything else.
112
+ # @param match [MatchData] the parsed filter string
113
+ # @param values [Object] the filter value(s)
114
+ # @return [Array] the method symbol and negation flag (false|true)
115
+ def self.method_symbol(match, values)
116
+ method = match[:method]
117
+ # Return the default if no method is specified
118
+ return method_symbol_default(values) if method.nil? || method.empty?
119
+ method = method.to_sym
120
+ # Return the method as specified unless it's mapped
121
+ return method, false unless FILTER_MAP.key?(method)
122
+ # Otherwise return the mapped filter
123
+ method, negate = FILTER_MAP[method]
124
+ [method, negate.nil? ? false : negate]
125
+ end
126
+
127
+ # Returns a default method symbol based on the filter value type
128
+ # @param values [Object] the filter value(s)
129
+ # @return [Array] the default method symbol and negation flag (false|true)
130
+ def self.method_symbol_default(values)
131
+ return :include?, false if collection?(values)
132
+ return :match, false if values.is_a?(Regexp)
133
+ [:==, false]
134
+ end
135
+
136
+ # Returns the actual filter negate flag based on the requested filter
137
+ # negate flag and the compare method's negate flag
138
+ # @param filter_negate [Boolean] the requested filter negate flag
139
+ # @param method_negate [Boolean] the compare method's negate flag
140
+ # @return [Boolean] the effective negate flag
141
+ def self.negate_flag(filter_negate = false, method_negate = false)
142
+ # The effective negate flag is true only if exactly one of the arguments
143
+ # is true (filter_flag xor method_negate) since double negation cancels
144
+ filter_negate != method_negate
145
+ end
146
+
147
+ # Returns the extractor Proc specified by a parsed filter string
148
+ # @param match [MatchData] the parsed filter string
149
+ # @param extractors [Hash<Symbol, Proc|Method>] the extractors
150
+ # @raise [ArgumentError] if a specified extractor is invalid or there is
151
+ # no default extractor in extractors when required
152
+ def self.parse_extractor(match, extractors = nil)
153
+ raise ArgumentError, 'extractors required' \
154
+ if extractors.nil? || extractors.empty?
155
+ e = match[:extractor]
156
+ extractor = extractors[e ? e.to_sym : nil]
157
+ return extractor unless extractor.nil?
158
+ raise ArgumentError, "invalid extractor: #{e}" if e
159
+ raise ArgumentError, 'no default extractor'
160
+ end
161
+
162
+ # Returns a parsed regular expression
163
+ # @param regexp [String] the regular expression from the value string
164
+ # @return [Regexp] the parsed regular expression
165
+ # @raise [ArgumentError] if the regular expression cannot be parsed
166
+ def self.parse_regexp(regexp)
167
+ # Remove enclosing / if present
168
+ first = regexp.start_with?('/') ? 1 : 0
169
+ last = regexp.end_with?('/') ? -2 : -1
170
+ Regexp.new(regexp[first..last])
171
+ rescue RegexpError
172
+ raise ArgumentError, "invalid regular expression: #{regexp}"
173
+ end
174
+
175
+ # Returns the parsed value string
176
+ # @param match [MatchData] the parsed filter string
177
+ # @return [Object] the filter value(s)
178
+ # @raise [ArgumentError] if the value string cannot be parsed
179
+ def self.parse_value(match)
180
+ value = match[:value]
181
+ return nil if value.nil?
182
+ # Parse strings starting with / as regular expressions
183
+ return parse_regexp(value) if value.start_with?('/')
184
+ # Parse all other strings as JSON
185
+ JSON.parse(value)
186
+ rescue JSON::ParserError
187
+ raise ArgumentError, "invalid value: #{value}"
188
+ end
189
+ end
190
+ end
191
+
192
+ # Implements a course filter
193
+ class Filter
194
+ extend FilterParser
195
+
196
+ # @!attribute [rw] extractor
197
+ # @return [Proc, Method] a block accepting a year, course and cohort and
198
+ # returning the value used in filter matching
199
+ attr_accessor :extractor
200
+
201
+ # @!attribute [rw] method
202
+ # @return [Symbol] the method to call against the values
203
+ attr_accessor :method
204
+
205
+ # @!attribute [rw] negate
206
+ # @return [Boolean] if true, the filter result is negated
207
+ attr_accessor :negate
208
+
209
+ # @!attribute [rw] values
210
+ # @return [Object] the value or collection to match against
211
+ attr_accessor :values
212
+
213
+ # Parses a filter string and returns a Filter instance
214
+ # @param str [String] the filter string: [extractor][+|-]value
215
+ # where value is a JSON string specifying the value(s) to match,
216
+ # extractor is the optional name of an entry in the extractors hash,
217
+ # @param extractors [Hash<Symbol, Proc|Method>] a hash of named field
218
+ # extractors referenced by the filter string; this may include a nil
219
+ # key which specifies the default extractor if not given in the filter
220
+ # string
221
+ # @raise [ArgumentError] if the filter string is invalid
222
+ def self.parse(str = nil, extractors = nil)
223
+ new(*parse_filter(str, extractors))
224
+ end
225
+
226
+ # Initialises a new Filter instance
227
+ # @param values [Object] the value or collection to match against
228
+ # @param method [Symbol] the method symbol applied to the filter values
229
+ # @param extractor [Proc, Method] a block accepting a year, course and
230
+ # cohort and returning the value used in filter matching.
231
+ # This may be also passed as a code block.
232
+ # @param negate [Boolean] if true, negate the result of the filter
233
+ # @raise [ArgumentError]
234
+ # @return [void]
235
+ def initialize(values = nil, method = nil, extractor = nil, negate = false,
236
+ &block)
237
+ self.method = method
238
+ self.negate = negate
239
+ self.extractor = extractor || block
240
+ self.values = values
241
+ @matcher = matcher_proc(method)
242
+ end
243
+
244
+ # Applies the filter to a course/cohort
245
+ # @param year [Object] the course year
246
+ # @param course [Object] the course
247
+ # @param cohort [Object] the course cohort
248
+ # @return [Boolean] true if the course passes the filter, false otherwise
249
+ def call(year = nil, course = nil, cohort = nil)
250
+ # Get the value for matching
251
+ value = extractor.call(year, course, cohort)
252
+ # Test against the filter values (true => match, false => no match)
253
+ result = @matcher.call(value)
254
+ # Return the result with appropriate negation
255
+ negate ? !result : result
256
+ end
257
+
258
+ private
259
+
260
+ # Returns a Proc instance which calls the specified method on the filter
261
+ # value object
262
+ # @param method [Method, Proc, Symbol] the method (Method/Proc arguments are
263
+ # returned as-is)
264
+ # @return [Proc] the Proc instance
265
+ def matcher_proc(method)
266
+ # Return Method/Proc arguments unchanged
267
+ return method if method.is_a?(Method) || method.is_a?(Proc)
268
+ # Otherwise return a Proc which invokes method on the filter values
269
+ proc { |value| values.send(method, value) }
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,298 @@
1
+ require 'alma_course_loader/version'
2
+
3
+ module AlmaCourseLoader
4
+ # The abstract base class for course readers.
5
+ #
6
+ # A course reader is responsible for reading course data from a data source
7
+ # and iterating over each course element. A course element is either the
8
+ # course itself or, if the course is divided into cohorts, a single cohort
9
+ # of the course.
10
+ #
11
+ # It is expected that courses are retrieved by year, and the iterators accept
12
+ # a list of years for which courses are required. The implementation and
13
+ # type of years, courses and cohorts is deferred to the subclasses.
14
+ #
15
+ # Filters may be defined which determine whether a single course element is
16
+ # processed or ignored. The course element must pass all filters before it
17
+ # is processed.
18
+ #
19
+ # The years and filters controlling course element iteration can be passed
20
+ # to the constructor, which defines defaults for subsequent iterators, or to
21
+ # the #each method, which defines the criteria for that iteration. When
22
+ # creating a reader which will be passed to a writer, always define the
23
+ # iterator criteria in the reader constructor, as the writer uses the
24
+ # default iterator.
25
+ #
26
+ # There are two classes of iterators. The first invokes its block with the
27
+ # course details: yield(year, course, cohort, instructors)
28
+ # The second, used by the Writer class, invokes its block with an array of
29
+ # data suitable for writing as a row in a course loader CSV file: yield(row)
30
+ #
31
+ # @abstract Subclasses must implement the following methods:
32
+ # #courses
33
+ # returns a list of course objects
34
+ # #course_cohorts(course)
35
+ # returns a list of cohorts for the course or nil if cohorts are not used
36
+ # #current_academic_year
37
+ # returns the current academic year
38
+ # #instructors(year, course, cohort)
39
+ # returns a list of instructors for the course
40
+ # #row_data(array, year, course, cohort, instructors)
41
+ # configures an array of formatted course information suitable for writing
42
+ # to an Alma course loader CSV file
43
+ #
44
+ # @example Create course filters
45
+ # # Define extractors to retrieve values from a course element for matching
46
+ # # Extractors are called with the year, course and cohort arguments
47
+ # get_code = proc { |year, course, cohort| course.code }
48
+ # get_title = proc { |year, course, cohort| course.title }
49
+ #
50
+ # # Define a list of course codes and a regular expression matching titles
51
+ # codes = ['COMPSCI101', 'PHYSICS101', 'MAGIC101']
52
+ # titles = Regexp.compile('dissertation|(extended|ma) essay|test', true)
53
+ #
54
+ # # Define a filter which passes only the specified course codes
55
+ # include_codes = AlmaCourseLoader::Filter.new(codes, :include, get_code)
56
+ #
57
+ # # Define a filter which passes all codes except those specified
58
+ # exclude_codes = AlmaCourseLoader::Filter.new(codes, :exclude, get_code)
59
+ #
60
+ # # Define a filter which passes all titles matching the regular expression
61
+ # include_titles = AlmaCourseLoader::Filter.new(titles, :include, get_title)
62
+ #
63
+ # # Define a filter which passes all titles except those matching the regexp
64
+ # exclude_titles = AlmaCourseLoader::Filter.new(titles, :exclude, get_title)
65
+ #
66
+ # @example Create a reader with default selection criteria
67
+ # # Filters are passed in an array, course elements must pass all filters
68
+ # reader = Reader.new(2016, 2017, filters: [exclude_codes, include_titles])
69
+ #
70
+ # @example Iterate course elements using default selection criteria
71
+ # reader.each { |year, course, cohort, instructors| ... }
72
+ #
73
+ # @example Iterate course elements using specific selection criteria
74
+ # # Use an empty array to override filters. filters: nil will use the
75
+ # # default filters.
76
+ # reader.each(2012, filters: []) { |year, course, cohort, instructors| ... }
77
+ #
78
+ # @example Iterate course CSV rows
79
+ # reader.each_row { |row_array| ... }
80
+ # reader.each_row(2012, filters: []) { |row_array| ... }
81
+ #
82
+ class Reader
83
+ # Initialises a new Reader instance
84
+ # Positional parameters are the default years to iterate over
85
+ # @param current_year [Object] the current academic year
86
+ # @param filters [Array<AlmaCourseLoader::Filter>] default course filters
87
+ def initialize(*years, current_year: nil, filters: nil)
88
+ @current_academic_year = current_year || current_academic_year
89
+ @filters = filters
90
+ @years = years.nil? || years.empty? ? [@current_academic_year] : years
91
+ end
92
+
93
+ # Iterate over the courses for specified years. Only courses which pass
94
+ # the filters are passed to the block.
95
+ # Positional parameters are years
96
+ # @param filters [Array<AlmaCourseLoader::Filter>] course filters
97
+ # @return [void]
98
+ # @yield [year, course, cohort, year, instructors] Passes the course to the
99
+ # block
100
+ # @yieldparam year [Object] the course year
101
+ # @yieldparam course [Object] the course
102
+ # @yieldparam cohort [Object] the course cohort
103
+ # @yieldparam instructors [Array<Object>] the course instructors
104
+ def each(*years, filters: nil, &block)
105
+ # Process courses for each year
106
+ years = @years if years.nil? || years.empty?
107
+ years.each { |year| each_course_in_year(year, filters: filters, &block) }
108
+ nil
109
+ end
110
+
111
+ # Iterates over the cohorts in a course, or just the course itself if
112
+ # cohort processing is disabled. Only courses/cohorts which pass the filters
113
+ # are passed to the block.
114
+ # @param year [Object] the course year
115
+ # @param course [Object] the course
116
+ # @param filters [Array<AlmaCourseLoader::Filter>] course filters
117
+ # @return [void]
118
+ # @yield [year, course, cohort, year, instructors] Passes the course to the
119
+ # block
120
+ # @yieldparam year [Object] the course year
121
+ # @yieldparam course [Object] the course
122
+ # @yieldparam cohort [Object] the course cohort
123
+ # @yieldparam instructors [Array<Object>] the course instructors
124
+ def each_cohort_in_course(year, course, filters: nil, &block)
125
+ cohorts = course_cohorts(year, course)
126
+ if cohorts.nil?
127
+ # Process the course
128
+ process_course(year, course, nil, filters, &block)
129
+ else
130
+ cohorts.each do |cohort|
131
+ # Process each cohort of the course
132
+ process_course(year, course, cohort, filters, &block)
133
+ end
134
+ end
135
+ end
136
+
137
+ # Iterates over the cohorts in a course, or just the course itself if
138
+ # cohort processing is disabled. Only courses/cohorts which pass the filters
139
+ # are passed to the block.
140
+ # @param year [Object] the course year
141
+ # @param course [Object] the course
142
+ # @param filters [Array<AlmaCourseLoader::Filter>] course filters
143
+ # @return [void]
144
+ # @yield [row] Passes the course to the block
145
+ # @yieldparam row [Array<String>] the course as a CSV row (array)
146
+ def each_cohort_in_course_row(year, course, filters: nil, &block)
147
+ each_cohort_in_course(year, course, filters: filters) do |*args|
148
+ row(*args, &block)
149
+ end
150
+ end
151
+
152
+ # Iterate over the courses for the specified year. Only courses which pass
153
+ # the filters are passed to the block.
154
+ # @param year [Object] the year
155
+ # @param filters [Array<AlmaCourseLoader::Filter>] filters selecting courses
156
+ # @return [void]
157
+ # @yield [year, course, cohort, year, instructors] Passes the course to the
158
+ # block
159
+ # @yieldparam year [Object] the course year
160
+ # @yieldparam course [Object] the course
161
+ # @yieldparam cohort [Object] the course cohort
162
+ # @yieldparam instructors [Array<Object>] the course instructors
163
+ def each_course_in_year(year, filters: nil, &block)
164
+ # Simplify the test for filter existence
165
+ filters ||= @filters
166
+ filters = nil if filters.is_a?(Array) && filters.empty?
167
+ # Get all courses for the year
168
+ courses(year).each do |course|
169
+ each_cohort_in_course(year, course, filters: filters, &block)
170
+ end
171
+ nil
172
+ end
173
+
174
+ # Iterate over the courses for the specified year. Only courses which pass
175
+ # the filters are passed to the block.
176
+ # @param year [Object] the year
177
+ # @param filters [Array<AlmaCourseLoader::Filter>] course filters
178
+ # @return [void]
179
+ # @yield [row] Passes the course to the block
180
+ # @yieldparam row [Array<String>] the course as a CSV row (array)
181
+ def each_course_in_year_row(year, filters: nil, &block)
182
+ each_course_in_year(year, filters: filters) { |*args| row(*args, &block) }
183
+ end
184
+
185
+ # Iterate over the courses for specified years. Only courses which pass
186
+ # the filters are passed to the block.
187
+ # Positional parameters are years
188
+ # @param filters [Array<AlmaCourseLoader::Filter>] course filters
189
+ # @return [void]
190
+ # @yield [row] Passes the course to the block
191
+ # @yieldparam row [Array<String>] the course as a CSV row (array)
192
+ def each_row(*years, filters: nil, &block)
193
+ each(*years, filters: filters) { |*args| row(*args, &block) }
194
+ end
195
+
196
+ protected
197
+
198
+ # Returns a list of courses for the specified year
199
+ # @abstract Subclasses must implement this method.
200
+ # @param year [Object] the course year
201
+ # @return [Array<Object>] the courses for the year
202
+ def courses(year)
203
+ []
204
+ end
205
+
206
+ # Returns a list of cohorts for the specified course
207
+ # @abstract Subclasses should implement this method. If courses are not
208
+ # divided into cohorts, this method must return nil.
209
+ # @param course [Object] the course
210
+ # @return [Array<Object>, nil] the cohorts for the course, or nil to disable
211
+ # cohort processing
212
+ def course_cohorts(year, course)
213
+ nil
214
+ end
215
+
216
+ # Returns the current academic year
217
+ # @abstract Subclasses should implement this method
218
+ # @return [Object] the current academic year
219
+ def current_academic_year
220
+ nil
221
+ end
222
+
223
+ # Applies filters to the course/cohort to determine whether to process
224
+ # @param year [Object] the course year
225
+ # @param course [Object] the course
226
+ # @param cohort [Object] the course cohort
227
+ # @return [Boolean] true if the course is to be processed, false if not
228
+ def filter(year, course, cohort, filters)
229
+ # Return true if no filters are specified
230
+ return true if filters.nil? || filters.empty?
231
+ # Return false if any of the filters returns false
232
+ filters.each { |f| return false unless f.call(year, course, cohort) }
233
+ # At this point all filters returned true
234
+ true
235
+ end
236
+
237
+ # Returns a list of instructors for the course/cohort
238
+ # @abstract Subclasses should implement this method
239
+ # @param year [Object] the course year
240
+ # @param course [Object] the course
241
+ # @param cohort [Object] the course cohort
242
+ # @return [Array<Object>] the course instructors
243
+ def instructors(year, course, cohort)
244
+ []
245
+ end
246
+
247
+ # Filters and processes a course
248
+ # @param year [Object] the course year
249
+ # @param course [Object] the course
250
+ # @param cohort [Object] the LUSI course cohort
251
+ # @param filters [Array<AlmaCourseLoader::Filter>] the course filters
252
+ # @return [Boolean] true if the course was processed, false otherwise
253
+ # @yield Passes the course to the block
254
+ # @yieldparam year [Object] the course year
255
+ # @yieldparam course [Object] the course
256
+ # @yieldparam cohort [Object] the course cohort
257
+ # @yieldparam instructors [Array<Object>] the course instructors
258
+ def process_course(year, course, cohort = nil, filters = nil)
259
+ # The course must pass all filters
260
+ return false unless filter(year, course, cohort, filters)
261
+ # Get the course instructors
262
+ course_instructors = instructors(year, course, cohort)
263
+ # Pass the details to the block
264
+ yield(year, course, cohort, course_instructors) if block_given?
265
+ # Indicate that the course was processed
266
+ true
267
+ end
268
+
269
+ # Returns a CSV row (array) for a specific course/cohort
270
+ # @param year [Object] the course year
271
+ # @param course [Object] the course
272
+ # @param cohort [Object] the course cohort
273
+ # @param instructors [Array<Object>] the
274
+ # course instructor enrolments
275
+ # @yield Passes the CSV row array to the block
276
+ # @yieldparam row [Array<String>] the CSV row array
277
+ def row(year = nil, course = nil, cohort = nil, instructors = nil)
278
+ # Create and populate the CSV row
279
+ data = Array.new(31)
280
+ row_data(data, year, course, cohort, instructors)
281
+ # Pass the row to the block
282
+ yield(data) if block_given?
283
+ # Return the row
284
+ data
285
+ end
286
+
287
+ # Populates the CSV row (array) for a specific course/cohort
288
+ # @abstract Subclasses must implement this method
289
+ # @param data [Array<String>] the CSV row (array)
290
+ # @param year [Object] the course year
291
+ # @param course [Object] the course
292
+ # @param cohort [Object] the course cohort
293
+ # @param instructors [Array<Object>] the course instructors
294
+ def row_data(data, year, course, cohort, instructors)
295
+ data
296
+ end
297
+ end
298
+ end