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,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