alma_course_loader 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :test
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+
3
+ lib = File.expand_path('../lib', __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'alma_course_loader/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'alma_course_loader'
9
+ spec.version = AlmaCourseLoader::VERSION
10
+ spec.authors = ['Lancaster University Library']
11
+ spec.email = ['library.dit@lancaster.ac.uk']
12
+
13
+ spec.summary = 'Support for creating Alma course loader files'
14
+ spec.description = 'This gem provides basic support for creating Alma' \
15
+ 'course loader files.'
16
+ spec.homepage = 'https://github.com/lulibrary/alma_course_loader'
17
+ spec.license = 'MIT'
18
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
19
+ f.match(%r{^(test|spec|features)/})
20
+ end
21
+ spec.bindir = 'exe'
22
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.add_dependency 'clamp'
26
+ spec.add_dependency 'dotenv'
27
+
28
+ spec.add_development_dependency 'bundler'
29
+ spec.add_development_dependency 'rake'
30
+ spec.add_development_dependency 'minitest'
31
+ spec.add_development_dependency 'minitest-reporters'
32
+ spec.add_development_dependency 'rubocop'
33
+ end
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "alma_course_loader"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'alma_course_loader/cli/diff'
5
+
6
+ dsn = ENV['SENTRY_DSN']
7
+ unless dsn.nil? || dsn.empty?
8
+ require 'raven'
9
+ Raven.configure { |config| config.dsn = dsn }
10
+ end
11
+ AlmaCourseLoader::CLI::Diff.run
@@ -0,0 +1,8 @@
1
+ require 'alma_course_loader/diff'
2
+ require 'alma_course_loader/filter'
3
+ require 'alma_course_loader/reader'
4
+ require 'alma_course_loader/writer'
5
+ require 'alma_course_loader/version'
6
+
7
+ # A framework for generating Alma course loader files
8
+ module AlmaCourseLoader; end
@@ -0,0 +1,129 @@
1
+ require 'clamp'
2
+ require 'dotenv'
3
+
4
+ require 'alma_course_loader/filter'
5
+
6
+ module AlmaCourseLoader
7
+ module CLI
8
+ # The abstract base class for course loader command line processing
9
+ # @abstract Loader implementations should subclass this class and implement
10
+ # the #extractors, #reader and #time_period methods
11
+ class CourseLoader < Clamp::Command
12
+ # Exit codes
13
+ EXIT_OK = 0
14
+
15
+ # Clamp command-line options
16
+ option %w[-d --delete], :flag, 'generate a course delete file'
17
+ option %w[-e --env-file], 'ENV_FILE', 'environment definitions file'
18
+ option %w[-f --filter], 'FILTER',
19
+ 'filter condition: [field][op]value', multivalued: true do |value|
20
+ AlmaCourseLoader::Filter.parse(value, extractors)
21
+ end
22
+ option %w[-F --fields], :flag, 'list the fields available to filters'
23
+ option %w[-l --log-file], 'LOG_FILE', 'the activity log file'
24
+ option %w[-L --log-level], 'LOG_LEVEL',
25
+ 'the log level (fatal|error|warn|info|debug)' do |value|
26
+ {
27
+ debug: Logger::DEBUG,
28
+ error: Logger::ERROR,
29
+ fatal: Logger::FATAL,
30
+ info: Logger::INFO,
31
+ warn: Logger::WARN
32
+ }[value.downcase.to_sym] || Logger::ERROR
33
+ end
34
+ option %w[-o --out-file], 'OUT_FILE', 'the output file'
35
+ option %w[-r --rollover], :flag, 'generate a course rollover file'
36
+ option %w[-t --time-period], 'PERIOD',
37
+ 'the academic year (2016 etc.)', multivalued: true do |value|
38
+ time_period(value)
39
+ end
40
+ option %w[-T --current-time-period], 'CURRENT_PERIOD',
41
+ 'the current academic year' do |value|
42
+ time_period(value)
43
+ end
44
+
45
+ # Displays an error message and exits
46
+ # @param msg [String, nil] the error message
47
+ # @param code [Integer] the exit code - do not exit if nil
48
+ # @return [void]
49
+ def error(msg = nil, code = nil)
50
+ STDERR.puts(msg) if msg
51
+ exit(code) if code
52
+ end
53
+
54
+ # Returns a hash of named field value extractor descriptions. Keys
55
+ # correspond to the keys of the extractors hash, values should be short
56
+ # descriptions of each field extractor.
57
+ # @abstract Subclasses should implement this method
58
+ # @return [Hash<String|Symbol, String>] the field extractor descriptions
59
+ def extractor_details
60
+ nil
61
+ end
62
+
63
+ # Returns a hash of named field value extractors
64
+ # @abstract Subclasses must implement this method
65
+ # @return [Hash<String|Symbol, Method|Proc>] the field extractors
66
+ def extractors
67
+ raise NotImplementedError
68
+ end
69
+
70
+ # Clamp entry point - executes the command
71
+ # @return [void]
72
+ def execute
73
+ # List the available field extractors and exit if required
74
+ list_fields if fields?
75
+ # Otherwise write a course loader file and exit
76
+ write_file
77
+ end
78
+
79
+ # Lists the available field extractors and exits
80
+ # @return [void]
81
+ def list_fields
82
+ d = extractor_details || {}
83
+ extractors.each_key { |k| puts "#{k}#{d[k] ? ': ' : ''}#{d[k] || ''}" }
84
+ exit(EXIT_OK)
85
+ end
86
+
87
+ # Creates a Logger instance
88
+ # @return [Logger] the Logger instance
89
+ def logger
90
+ return nil unless log_file
91
+ logger = Logger.new(log_file)
92
+ logger.level = log_level
93
+ logger
94
+ end
95
+
96
+ # Creates a Reader instance to retrieve course data
97
+ # @abstract Subclasses must implement this method.
98
+ # Filters are defined in the filter_list array:
99
+ # MyReader.new(..., filters: filter_list)
100
+ # @return [AlmaCourseLoader::Reader] a subclass of Reader
101
+ def reader
102
+ raise NotImplementedError
103
+ end
104
+
105
+ # Parses a time period string and returns an appropriate representation
106
+ # @abstract Subclasses may implement this method
107
+ # @param time_period_s [String] the time period string
108
+ # @return [Object] the subclass-specific representation of the time period
109
+ def time_period(time_period_s)
110
+ time_period_s
111
+ end
112
+
113
+ # Writes an Alma course loader file and exits
114
+ # @return [void]
115
+ def write_file
116
+ op = if rollover?
117
+ :rollover
118
+ elsif delete?
119
+ :delete
120
+ else
121
+ :update
122
+ end
123
+ Dotenv.load(env_file) unless env_file.nil? || env_file.empty?
124
+ AlmaCourseLoader::Writer.write(out_file, op, reader)
125
+ exit(EXIT_OK)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,85 @@
1
+ require 'clamp'
2
+ require 'logger'
3
+
4
+ module AlmaCourseLoader
5
+ module CLI
6
+ # Implements the course_loader_diff command-line interface
7
+ class Diff < Clamp::Command
8
+ # Exit codes
9
+ EXIT_OK = 0
10
+ # Log file alignment for multi-line entries
11
+ LOG_SPACE = ' '.freeze
12
+ private_constant :LOG_SPACE
13
+ # Log file timestamp
14
+ LOG_TIME = '%Y-%m-%d %H:%M:%S: '.freeze
15
+
16
+ option %w[-c --create], 'FILE', 'write new courses to FILE'
17
+ option %w[-d --delete], 'FILE', 'write deleted courses to FILE'
18
+ option %w[-l --log], 'FILE', 'log activity to FILE',
19
+ attribute_name: :log_file
20
+ option %w[-r --rollover], :flag,
21
+ 'create courses with rollover if rollover course/section is given'
22
+ option %w[-u --update], 'FILE', 'write updated courses to FILE'
23
+ option %w[-v --verbose], :flag, 'enable verbose logging'
24
+ parameter 'OLD', 'the old course loader file', attribute_name: :old_file
25
+ parameter 'NEW', 'the new course loader file', attribute_name: :new_file
26
+
27
+ # Clamp entry point - executes the command
28
+ def execute
29
+ filename = log_filename
30
+ @logger = logger(filename)
31
+ ::AlmaCourseLoader::Diff.diff(old_file, new_file,
32
+ create: create, delete: delete,
33
+ rollover: rollover?,
34
+ update: update) do |old, new, op, opts|
35
+ log(old, new, op, opts) if @logger
36
+ end
37
+ @logger.close if @logger && filename
38
+ exit(EXIT_OK)
39
+ end
40
+
41
+ # Logs a course update operation
42
+ # @param old [String] the course entry from the old file
43
+ # @param new [String] the course entry from the new file
44
+ # @param op [Symbol] the course operation (:create|:delete|:update)
45
+ # @param opts [Hash<Symbol, Object>] the diff options
46
+ # @return [void]
47
+ def log(old, new, op, opts)
48
+ fields = log_course_fields(old, new, op, opts)
49
+ course = "#{fields[0]}:#{fields[2]}"
50
+ @logger.info("#{op.to_s.capitalize} #{course}\n")
51
+ return unless verbose?
52
+ @logger.debug("#{old.nil? ? '' : '< '}#{old}\n") if old
53
+ @logger.debug("#{new.nil? ? '' : '> '}#{new}\n") if new
54
+ end
55
+
56
+ # Returns the course entry fields
57
+ # @param old [String] the course entry from the old file
58
+ # @param new [String] the course entry from the new file
59
+ # @param op [Symbol] the course operation (:create|:delete|:update)
60
+ # @param opts [Hash<Symbol, Object>] the diff options
61
+ def log_course_fields(old, new, op, opts)
62
+ op == :delete ? old.split("\t") : new.split("\t")
63
+ end
64
+
65
+ # Returns the command-line log filename or nil to force logging to STDOUT
66
+ # @return [String, nil] the log filename
67
+ def log_filename
68
+ log_file.nil? || log_file.empty? || log_file == '-' ? nil : log_file
69
+ end
70
+
71
+ # Returns a Logger instance
72
+ # @param filename [String, nil] the log filename, or nil to log to STDOUT
73
+ # @return [Logger] the logger
74
+ def logger(filename = nil)
75
+ logger = Logger.new(filename || STDOUT)
76
+ logger.level = verbose? ? Logger::DEBUG : Logger::INFO
77
+ logger.formatter = proc do |severity, datetime, _prog_name, msg|
78
+ time_s = severity == 'DEBUG' ? LOG_SPACE : datetime.strftime(LOG_TIME)
79
+ "#{time_s}#{msg}"
80
+ end
81
+ logger
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,173 @@
1
+ require 'logger'
2
+
3
+ module AlmaCourseLoader
4
+ # Exception raised by the diff block to ignore the current course
5
+ class SkipCourse < StandardError; end
6
+
7
+ # Compares two course loader files and outputs separate files of new, deleted
8
+ # and updated courses
9
+ class Diff
10
+ # Course loader operations
11
+ OPS = %i[create delete update].freeze
12
+
13
+ class << self
14
+ # Reports differences between old and new course loader files
15
+ # @param old_file [String] the old course loader filename
16
+ # @param new_file [String] the new course loader filename
17
+ # @param opts [Hash] the diff options
18
+ # @option opts [IO] :create the new courses file
19
+ # @option opts [IO] :delete the deleted courses file
20
+ # @option opts [Boolean] :rollover if true, create new courses as
21
+ # rollovers when rollover course/section are present
22
+ # @option opts [IO] :update the updated courses file
23
+ # @yield [old_line, new_line, op, opts] passes course loader lines,
24
+ # operation (:create|:delete|:update) and diff options to the block; the
25
+ # block is only called for differences between files
26
+ # @yieldparam old_line [String] the old course loader line
27
+ # @yieldparam new_line [String] the new course loader line
28
+ # @yieldparam op [Symbol] the operation (:create|:delete|:update)
29
+ # @yieldparam opts [Hash] the diff options
30
+ # @return [void]
31
+ def diff(old_file = nil, new_file = nil, **opts, &block)
32
+ # Read the course loader data from the old and new files
33
+ old = read(old_file)
34
+ new = read(new_file)
35
+ # Create the creations, deletions and updates output files
36
+ files = open_output_files(opts)
37
+ # Perform the diff
38
+ process(old, new, files, opts, &block)
39
+ ensure
40
+ close_output_files(files)
41
+ end
42
+
43
+ private
44
+
45
+ # Returns true if the course entry has rollover course/section, else false
46
+ # @param fields [Array<String>] the course loader fields
47
+ # @return [Boolean] true if rollover course/section are set, else false
48
+ def can_rollover?(fields)
49
+ # Rollover requires course code and section
50
+ return false if fields[29].nil? || fields[29].empty?
51
+ return false if fields[30].nil? || fields[30].empty?
52
+ true
53
+ end
54
+
55
+ # Closes output files
56
+ # @param files [Hash<Symbol, IO>] the output files
57
+ # @return [void]
58
+ def close_output_files(files = nil)
59
+ files.values.each(&:close) if files
60
+ end
61
+
62
+ # Formats the course loader line for the specified operation
63
+ # @param line [String] the course loader line
64
+ # @param op [Symbol] the operation (:create|:delete|:update)
65
+ # @param opts [Hash<Symbol, Object>] the diff options
66
+ # @return [String] the course loader line with the specified operation
67
+ def format(line, op, opts = {})
68
+ # Format the line (rollover code/section are never needed)
69
+ line = line.split("\t")
70
+ if op == :create && opts[:rollover] && can_rollover?(line)
71
+ line[28] = 'ROLLOVER' # Rollover code/section already present
72
+ elsif op == :delete
73
+ line[28..30] = ['DELETE', '', ''] # Rollover code/section not needed
74
+ else # op is either :create without rollover or :update
75
+ line[28..30] = ['', '', ''] # Update, rollover code/section not needed
76
+ end
77
+ # Return the formatted line
78
+ line.join("\t")
79
+ end
80
+
81
+ # Returns the key for the course loader data hash
82
+ # @param fields [Array<String>] the course loader entry fields
83
+ # @return [String] the key for the course loader data hash
84
+ def key(fields)
85
+ # course-code:section-id
86
+ "#{fields[0]}:#{fields[2]}"
87
+ end
88
+
89
+ # Creates an output file
90
+ # @param file [IO, String] the output file instance or filename
91
+ # @param mode [String] the output file mode
92
+ # @return [IO] the output file
93
+ def open(file, mode = 'w')
94
+ return file if file.nil? || file.is_a?(IO)
95
+ raise ArgumentError('IO or filename expected') unless file.is_a?(String)
96
+ File.open(file, mode)
97
+ end
98
+
99
+ # Creates output files
100
+ # @param opts [Hash] the diff options
101
+ # @return [Hash<Symbol, IO>] the output files, indexed by operation
102
+ # (:create|:delete|:update)
103
+ def open_output_files(opts)
104
+ files = {}
105
+ OPS.each { |op| files[op] = open(opts[op]) }
106
+ files
107
+ end
108
+
109
+ # Returns the diff operation
110
+ # @param old_line [String] the old course loader line
111
+ # @param new_line [String] the new course loader line
112
+ # @return [Symbol, nil] the operation (:create|:delete|:update) or nil if
113
+ # there are no changes
114
+ def operation(old_line = nil, new_line = nil)
115
+ return nil if old_line == new_line
116
+ return :create if old_line.nil?
117
+ return :delete if new_line.nil?
118
+ :update
119
+ end
120
+
121
+ # Process the input files
122
+ # @param old [Hash<String, String>] the old course loader data
123
+ # @param new [Hash<String, String>] the new course loader data
124
+ # @param files [Hash<Symbol, IO>] the output files, indexed by operation
125
+ # (:create|:delete|:update)
126
+ # @param opts [Hash] the diff options
127
+ # @return [void]
128
+ def process(old, new, files = nil, opts = {}, &block)
129
+ # Handle deletions and updates to the old file
130
+ old.each do |course, line|
131
+ write(line, new[course], files, opts, &block)
132
+ end
133
+ # Handle new additions to the old file
134
+ new.each do |course, line|
135
+ write(nil, line, files, opts, &block) unless old.key?(course)
136
+ end
137
+ end
138
+
139
+ # Returns the course loader file data as a hash: { course => loader line }
140
+ # @param filename [String] the course loader filename
141
+ # @return [Hash<String, String>] the course loader data
142
+ def read(filename)
143
+ result = {}
144
+ File.readlines(filename).each do |line|
145
+ line.chomp!
146
+ fields = line.split("\t")
147
+ result[key(fields)] = line
148
+ end
149
+ result
150
+ end
151
+
152
+ # Write the diff result to the appropriate output file
153
+ # @param old_line [String] the old course loader line
154
+ # @param new_line [String] the new course loader line
155
+ # @param files [Hash<Symbol, IO>] the output files, indexed by operation
156
+ # (:create|:delete|:update)
157
+ # @param opts [Hash] the diff options
158
+ # @return [void]
159
+ def write(old_line = nil, new_line = nil, files = nil, opts = {})
160
+ # Determine the diff operation
161
+ op = operation(old_line, new_line)
162
+ return if op.nil?
163
+ # Call the block
164
+ yield(old_line, new_line, op, opts) if block_given?
165
+ # Write the line to the update file
166
+ line = op == :delete ? old_line : new_line
167
+ files[op].write("#{format(line, op, opts)}\n") if files[op]
168
+ rescue SkipCourse
169
+ # The block requested that this course is skipped
170
+ end
171
+ end
172
+ end
173
+ end