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.
- checksums.yaml +7 -0
- data/.gitignore +63 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +586 -0
- data/Rakefile +10 -0
- data/alma_course_loader.gemspec +33 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/course_loader_diff +11 -0
- data/lib/alma_course_loader.rb +8 -0
- data/lib/alma_course_loader/cli/course_loader.rb +129 -0
- data/lib/alma_course_loader/cli/diff.rb +85 -0
- data/lib/alma_course_loader/diff.rb +173 -0
- data/lib/alma_course_loader/filter.rb +272 -0
- data/lib/alma_course_loader/reader.rb +298 -0
- data/lib/alma_course_loader/version.rb +3 -0
- data/lib/alma_course_loader/writer.rb +116 -0
- metadata +162 -0
data/Rakefile
ADDED
@@ -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
|
data/bin/console
ADDED
@@ -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__)
|
data/bin/setup
ADDED
@@ -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
|