slither 0.99.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,16 @@
1
+
2
+ == 0.99.2 / 2009-04-28
3
+
4
+ * Added better support for float formatting
5
+ * Added the money_with_implied_decimal type
6
+
7
+ == 0.99.1 / 2009-04-22
8
+
9
+ * Make the missing method build a column i.e. body.record_type 1
10
+ * Prevent duplicate column names
11
+ * Better error messages
12
+ * Implement custom padding (spaces (default), zero)
13
+
14
+ == 0.99.0 / 2009-04-14
15
+
16
+ * Initial Release
@@ -0,0 +1,100 @@
1
+ == slither
2
+ by Ryan Wood
3
+ http://ryanwood.com
4
+
5
+ == DESCRIPTION:
6
+
7
+ A simple, clean DSL for describing, writing, and parsing fixed-width text files.
8
+
9
+ == FEATURES:
10
+
11
+ * Easy DSL syntax
12
+ * Can parse and format fixed width files
13
+ * Templated sections for reuse
14
+
15
+ == SYNOPSIS:
16
+
17
+ # Create a Slither::Defintion to describe a file format
18
+ Slither.define :simple do |d|
19
+
20
+ # This is a template section that can be reused in other sections
21
+ d.template :boundary do |t|
22
+ t.column :record_type, 4
23
+ t.column :company_id, 12
24
+ end
25
+
26
+ # Create a header section
27
+ d.header, :align => :left do |header|
28
+ # The trap tells Slither which lines should fall into this section
29
+ header.trap { |line| line[0,4] == 'HEAD' }
30
+ # Use the boundary template for the columns
31
+ header.template :boundary
32
+ end
33
+
34
+ d.body do |body|
35
+ body.trap { |line| line[0,4] =~ /[^(HEAD|FOOT)]/ }
36
+ body.column :id, 10, :type => :integer
37
+ body.column :name, 10, :align => :left
38
+ body.spacer 3
39
+ body.column :state, 2
40
+ end
41
+
42
+ d.footer do |footer|
43
+ footer.trap { |line| line[0,4] == 'FOOT' }
44
+ footer.template :boundary
45
+ footer.column :record_count, 10
46
+ end
47
+ end
48
+
49
+ Supported types are: string, integer, date, float, money, and money_with_implied_decimal.
50
+
51
+ Then either feed it a nested struct with data values to create the file in the defined format:
52
+
53
+ test_data = {
54
+ :body => [
55
+ { :id => 12, :name => "Ryan", :state => 'SC' },
56
+ { :id => 23, :name => "Joe", :state => 'VA' },
57
+ { :id => 42, :name => "Tommy", :state => 'FL' },
58
+ ],
59
+ :header => { :record_type => 'HEAD', :company_id => 'ABC' },
60
+ :footer => { :record_type => 'FOOT', :company_id => 'ABC' }
61
+ }
62
+
63
+ # Generates the file as a string
64
+ puts Slither.generate(:simple, test_data)
65
+
66
+ # Writes the file
67
+ Slither.write('outfile.txt', :simple, test_data)
68
+
69
+ or parse files already in that format into a nested hash:
70
+
71
+ parsed_data = Slither.parse('infile.txt', :test).inspect
72
+
73
+ == INSTALL:
74
+
75
+ sudo gem install slither
76
+
77
+ == LICENSE:
78
+
79
+ (The MIT License)
80
+
81
+ Copyright (c) 2008
82
+
83
+ Permission is hereby granted, free of charge, to any person obtaining
84
+ a copy of this software and associated documentation files (the
85
+ 'Software'), to deal in the Software without restriction, including
86
+ without limitation the rights to use, copy, modify, merge, publish,
87
+ distribute, sublicense, and/or sell copies of the Software, and to
88
+ permit persons to whom the Software is furnished to do so, subject to
89
+ the following conditions:
90
+
91
+ The above copyright notice and this permission notice shall be
92
+ included in all copies or substantial portions of the Software.
93
+
94
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
95
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
96
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
97
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
98
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
99
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
100
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,37 @@
1
+ require 'rake'
2
+ require 'spec/rake/spectask'
3
+
4
+ desc "Run all examples with RCov"
5
+ Spec::Rake::SpecTask.new('rcov') do |t|
6
+ t.spec_files = FileList['spec/*.rb']
7
+ t.rcov = true
8
+ t.rcov_opts = ['--exclude', 'spec']
9
+ end
10
+
11
+ begin
12
+ require 'bones'
13
+ Bones.setup
14
+ rescue LoadError
15
+ load 'tasks/setup.rb'
16
+ end
17
+
18
+ ensure_in_path 'lib'
19
+ require 'bones'
20
+
21
+ task :default => 'spec:run'
22
+
23
+ PROJ.name = 'slither'
24
+ PROJ.authors = 'Ryan Wood'
25
+ PROJ.email = 'ryan.wood@gmail.com'
26
+ PROJ.url = 'http://github.com/ryanwood/slither'
27
+ PROJ.version = '0.99.3'
28
+ PROJ.exclude = %w(\.git .gitignore ^tasks \.eprj ^pkg)
29
+ PROJ.readme_file = 'README.rdoc'
30
+
31
+ #PROJ.rubyforge.name = 'codeforpeople'
32
+
33
+ PROJ.rdoc.exclude << '^data'
34
+ PROJ.notes.exclude = %w(^README\.rdoc$ ^data ^pkg)
35
+
36
+ # PROJ.svn.path = 'bones'
37
+ # PROJ.spec.opts << '--color'
data/TODO ADDED
@@ -0,0 +1,13 @@
1
+ == 0.99.2
2
+
3
+ * Add :limit option on sections
4
+ * Add :validation option for columns
5
+ * Add a validate_file() method to parse a file and run all validation tests (implies validation implemented)
6
+
7
+ == 1.0.0
8
+
9
+ * Better Documentation
10
+
11
+ == 1.x
12
+
13
+ * Alternate Section Flow (other than linear), i.e. repeatable sections (think batch)
@@ -0,0 +1,7 @@
1
+ $: << File.dirname(__FILE__)
2
+ require 'slither/slither'
3
+ require 'slither/definition'
4
+ require 'slither/section'
5
+ require 'slither/column'
6
+ require 'slither/parser'
7
+ require 'slither/generator'
@@ -0,0 +1,124 @@
1
+ require 'date'
2
+
3
+ class Slither
4
+ class ParserError < RuntimeError; end
5
+
6
+ class Column
7
+ attr_reader :name, :length, :alignment, :type, :padding, :precision, :options
8
+
9
+ def initialize(name, length, options = {})
10
+ assert_valid_options(options)
11
+ @name = name
12
+ @length = length
13
+ @options = options
14
+ @alignment = options[:align] || :right
15
+ @type = options[:type] || :string
16
+ @padding = options[:padding] || :space
17
+ @truncate = options[:truncate] || false
18
+ # Only used with floats, this determines the decimal places
19
+ @precision = options[:precision]
20
+ end
21
+
22
+ def unpacker
23
+ "A#{@length}"
24
+ end
25
+
26
+ def parse(value)
27
+ case @type
28
+ when :integer: value.to_i
29
+ when :float, :money: value.to_f
30
+ when :money_with_implied_decimal:
31
+ value.to_f / 100
32
+ when :date:
33
+ if @options[:format]
34
+ Date.strptime(value, @options[:format])
35
+ else
36
+ Date.strptime(value)
37
+ end
38
+ else value.strip
39
+ end
40
+ rescue
41
+ raise ParserError, "The value '#{value}' could not be converted to type #{@type}: #{$!}"
42
+ end
43
+
44
+ def format(value)
45
+ pad(formatter % to_s(value))
46
+ rescue
47
+ puts "Could not format column '#{@name}' as a '#{@type}' with formatter '#{formatter}' and value of '#{value}' (formatted: '#{to_s(value)}'). #{$!}"
48
+ end
49
+
50
+ private
51
+
52
+ def formatter
53
+ "%#{aligner}#{sizer}s"
54
+ end
55
+
56
+ def aligner
57
+ @alignment == :left ? '-' : ''
58
+ end
59
+
60
+ def sizer
61
+ (@type == :float && @precision) ? @precision : @length
62
+ end
63
+
64
+ # Manually apply padding. sprintf only allows padding on numeric fields.
65
+ def pad(value)
66
+ return value unless @padding == :zero
67
+ matcher = @alignment == :right ? /^ +/ : / +$/
68
+ space = value.match(matcher)
69
+ return value unless space
70
+ value.gsub(space[0], '0' * space[0].size)
71
+ end
72
+
73
+ def to_s(value)
74
+ result = case @type
75
+ when :date:
76
+ # If it's a DBI::Timestamp object, see if we can convert it to a Time object
77
+ unless value.respond_to?(:strftime)
78
+ value = value.to_time if value.respond_to?(:to_time)
79
+ end
80
+ if value.respond_to?(:strftime)
81
+ if @options[:format]
82
+ value.strftime(@options[:format])
83
+ else
84
+ value.strftime
85
+ end
86
+ else
87
+ value.to_s
88
+ end
89
+ when :float:
90
+ @options[:format] ? @options[:format] % value.to_f : value.to_f.to_s
91
+ when :money:
92
+ "%.2f" % value.to_f
93
+ when :money_with_implied_decimal:
94
+ "%d" % (value.to_f * 100)
95
+ else
96
+ value.to_s
97
+ end
98
+ validate_size result
99
+ end
100
+
101
+ def assert_valid_options(options)
102
+ unless options[:align].nil? || [:left, :right].include?(options[:align])
103
+ raise ArgumentError, "Option :align only accepts :right (default) or :left"
104
+ end
105
+ unless options[:padding].nil? || [:space, :zero].include?(options[:padding])
106
+ raise ArgumentError, "Option :padding only accepts :space (default) or :zero"
107
+ end
108
+ end
109
+
110
+ def validate_size(result)
111
+ # Handle when length is out of range
112
+ if result.length > @length
113
+ if @truncate
114
+ start = @alignment == :left ? 0 : -@length
115
+ result = result[start, @length]
116
+ else
117
+ raise Slither::FormattedStringExceedsLengthError,
118
+ "The formatted value '#{result}' in column '#{@name}' exceeds the allowed length of #{@length} chararacters."
119
+ end
120
+ end
121
+ result
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,33 @@
1
+ class Slither
2
+ class Definition
3
+ attr_reader :sections, :templates, :options
4
+
5
+ def initialize(options = {})
6
+ @sections = []
7
+ @templates = {}
8
+ @options = { :align => :right }.merge(options)
9
+ end
10
+
11
+ def section(name, options = {}, &block)
12
+ raise( ArgumentError, "Reserved or duplicate section name: '#{name}'") if
13
+ Section::RESERVED_NAMES.include?( name ) ||
14
+ (@sections.size > 0 && @sections.map{ |s| s.name }.include?( name ))
15
+
16
+ section = Slither::Section.new(name, @options.merge(options))
17
+ section.definition = self
18
+ yield(section)
19
+ @sections << section
20
+ section
21
+ end
22
+
23
+ def template(name, options = {}, &block)
24
+ section = Slither::Section.new(name, @options.merge(options))
25
+ yield(section)
26
+ @templates[name] = section
27
+ end
28
+
29
+ def method_missing(method, *args, &block)
30
+ section(method, *args, &block)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ class Slither
2
+ class Generator
3
+
4
+ def initialize(definition)
5
+ @definition = definition
6
+ end
7
+
8
+ def generate(data)
9
+ @builder = []
10
+ @definition.sections.each do |section|
11
+ content = data[section.name]
12
+ if content
13
+ content = [content] unless content.is_a?(Array)
14
+ raise(Slither::RequiredSectionEmptyError, "Required section '#{section.name}' was empty.") if content.empty?
15
+ content.each do |row|
16
+ @builder << section.format(row)
17
+ end
18
+ else
19
+ raise(Slither::RequiredSectionEmptyError, "Required section '#{section.name}' was empty.") unless section.optional
20
+ end
21
+ end
22
+ @builder.join("\n")
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,53 @@
1
+ class Slither
2
+ class Parser
3
+
4
+ def initialize(definition, file)
5
+ @definition = definition
6
+ @file = file
7
+ # This may be used in the future for non-linear or repeating sections
8
+ @mode = :linear
9
+ end
10
+
11
+ def parse()
12
+ @parsed = {}
13
+ @content = read_file
14
+ unless @content.empty?
15
+ @definition.sections.each do |section|
16
+ rows = fill_content(section)
17
+ raise(Slither::RequiredSectionNotFoundError, "Required section '#{section.name}' was not found.") unless rows > 0 || section.optional
18
+ end
19
+ end
20
+ @parsed
21
+ end
22
+
23
+ private
24
+
25
+ def read_file
26
+ content = []
27
+ File.open(@file, 'r') do |f|
28
+ while (line = f.gets) do
29
+ content << line
30
+ end
31
+ end
32
+ content
33
+ end
34
+
35
+ def fill_content(section)
36
+ matches = 0
37
+ loop do
38
+ line = @content.first
39
+ break unless section.match(line)
40
+ add_to_section(section, line)
41
+ matches += 1
42
+ @content.shift
43
+ end
44
+ matches
45
+ end
46
+
47
+ def add_to_section(section, line)
48
+ @parsed[section.name] = [] unless @parsed[section.name]
49
+ @parsed[section.name] << section.parse(line)
50
+ end
51
+
52
+ end
53
+ end
@@ -0,0 +1,76 @@
1
+ class Slither
2
+ class Section
3
+ attr_accessor :definition, :optional
4
+ attr_reader :name, :columns, :options
5
+
6
+ RESERVED_NAMES = [:spacer]
7
+
8
+ def initialize(name, options = {})
9
+ @name = name
10
+ @options = options
11
+ @columns = []
12
+ @trap = options[:trap]
13
+ @optional = options[:optional] || false
14
+ end
15
+
16
+ def column(name, length, options = {})
17
+ raise(Slither::DuplicateColumnNameError, "You have already defined a column named '#{name}'.") if @columns.map do |c|
18
+ RESERVED_NAMES.include?(c.name) ? nil : c.name
19
+ end.flatten.include?(name)
20
+ col = Column.new(name, length, @options.merge(options))
21
+ @columns << col
22
+ col
23
+ end
24
+
25
+ def spacer(length)
26
+ column(:spacer, length)
27
+ end
28
+
29
+ def trap(&block)
30
+ @trap = block
31
+ end
32
+
33
+ def template(name)
34
+ template = @definition.templates[name]
35
+ raise ArgumentError, "Template #{name} not found as a known template." unless template
36
+ @columns = @columns + template.columns
37
+ # Section options should trump template options
38
+ @options = template.options.merge(@options)
39
+ end
40
+
41
+ def format(data)
42
+ # raise( ColumnMismatchError,
43
+ # "The '#{@name}' section has #{@columns.size} column(s) defined, but there are #{data.size} column(s) provided in the data."
44
+ # ) unless @columns.size == data.size
45
+ row = ''
46
+ @columns.each do |column|
47
+ row += column.format(data[column.name])
48
+ end
49
+ row
50
+ end
51
+
52
+ def parse(line)
53
+ line_data = line.unpack(unpacker)
54
+ row = {}
55
+ @columns.each_with_index do |c, i|
56
+ row[c.name] = c.parse(line_data[i]) unless RESERVED_NAMES.include?(c.name)
57
+ end
58
+ row
59
+ end
60
+
61
+ def match(raw_line)
62
+ raw_line.nil? ? false : @trap.call(raw_line)
63
+ end
64
+
65
+ def method_missing(method, *args)
66
+ column(method, *args)
67
+ end
68
+
69
+ private
70
+
71
+ def unpacker
72
+ @columns.map { |c| c.unpacker }.join('')
73
+ end
74
+
75
+ end
76
+ end