goods 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: bc907ce0650d82d39c2d077d63492e2ad9b1d4fa
4
+ data.tar.gz: 69b1b31e9bdfc828364fb7c16dda6c53545b3dfe
5
+ SHA512:
6
+ metadata.gz: 783ddac11cd0e9ac7648a2dc36c8f34573ad2d5935529e91e95ffd8ed2b9d76b7aedec8fdf63818f1a713facb284270e6b4e34c3dc6d6257ddac6dc341225ada
7
+ data.tar.gz: 37c18feb8ebe1e7f0108690ee662bb6f869726b1495277250d3fcbbf35a877200d02d3bb009777eac26800dfead0407848dcc75c97f5e8b22ebb93bdaae2a106
data/.gitignore ADDED
@@ -0,0 +1,19 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ .DS_Store
19
+ *sublime*
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in goods.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Artem Pyanykh
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # Goods
2
+
3
+ The purpose of this gem is to provide simple, yet reliable solution for parsing
4
+ YML (Yandex Market Language) files, with clean and convenient interface,
5
+ and a few extra capabilites, such as categories prunning.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ gem 'goods'
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install goods
20
+
21
+ ## Usage
22
+
23
+ 1. **How to parse YML-catalog**:
24
+
25
+ begin
26
+ catalog = Goods.from_string(xml, url, encoding)
27
+ [ or Goods.from_url(url, encoding) ]
28
+ [ or Goods.from_file(io, encoding) ]
29
+ rescue Goods::XML::InvalidFormatError => e
30
+ #do something
31
+ end
32
+
33
+ * `encoding` is optional;
34
+ * `url` is optional when parsing string;
35
+
36
+ 2. **How to iterate over offers**:
37
+
38
+ catalog.offers.each do |offer|
39
+ offer.id # => "some id"
40
+ offer.category # => <Goods::Category>
41
+ offer.currency # => <Goods::Currency>
42
+ offer.price # => 50.0 (for example)
43
+ etc...
44
+ end
45
+ 3. **How to iterate over categories**:
46
+
47
+ catalog.categories.each do |category|
48
+ category.id # => "some id"
49
+ category.name # => "some name"
50
+ category.parent # => <Goods::Category> or nil
51
+ etc...
52
+ end
53
+
54
+ 4. **How to iterate over currencies**:
55
+
56
+ catalog.currencies.each do |currency|
57
+ currency.id # => "RUR"
58
+ currency.rate # => 1.00 or 30.00 or some other float
59
+ etc...
60
+ end
61
+
62
+ 5. **How to get a single element from the collection**:
63
+
64
+ If you know an ID of an object, you may act like this
65
+
66
+ (for example, let's take currencies)
67
+ rur = catalog.currencies.find("RUR") # => <Goods::Curency>
68
+ rur.rate # => 1.00
69
+
70
+ 6. **How to prune categories in a whole catalog**:
71
+
72
+ catalog.prune(level_of_pruning)
73
+
74
+ It will replace all categories with level greater than `level_of_pruning` with their parents at that level.
75
+
76
+ What is the purpose of prunning? For example, with very deep categories structure it may be very costly in terms of performance to mirror this structure in your database. It may be sufficient to have a representation with lower level of details.
77
+
78
+ 7. **How to convert currencies and prices for a whole catalog**:
79
+
80
+ rur = catalog.currencies.find("RUR")
81
+ catalog.convert_currency(rur)
82
+
83
+ It will convert all prices and change `currency` for every offer.
84
+
85
+ 8. **But what's with invalid elements**:
86
+
87
+ General validation is performed according to DTD spec., and guarantees that you will have all the fields, that you're expecting to find in a YML-catalog. However, there can be somewhat trickier inconsistencies in YML-files, like offer having non-existing category_id, or currency_id.
88
+
89
+ All that valid (according to DTD), but defective elements are saved under `defectives` property of a collection. Each defective element has `invalid_field` property. For example:
90
+
91
+ defectives = catalog.offers.defectives # => Array of Goods::Offer
92
+ defectives.first.invalid_fields # => [:category_id, :currency_id]
93
+ 9. **How can I manually validate YML-file against DTD?**:
94
+
95
+ validator = Goods::XML::Validator.new
96
+ validator.valid?(xml_string) # => true or false
97
+ validator.error # => first validation error
98
+
99
+ 10. **Is that all?**
100
+
101
+ No, it is not. For more information look at the source code.
102
+
103
+
104
+ ## Contributing
105
+
106
+ At current time, Goods::Offer is quite incomplete, and works only with properties, that I need. Generalization of Goods::Offer is welcome!
107
+
108
+ **So**:
109
+
110
+ 1. Fork it
111
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
112
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
113
+ 4. Push to the branch (`git push origin my-new-feature`)
114
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/goods.gemspec ADDED
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'goods/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "goods"
8
+ spec.version = Goods::VERSION
9
+ spec.authors = ["Artem Pyanykh"]
10
+ spec.email = ["artem.pyanykh@gmail.com"]
11
+ spec.description = <<DESC
12
+ The purpose of this gem is to provide simple, yet reliable solution for parsing
13
+ YML (Yandex Market Language) files, with clean and convenient interface,
14
+ and extra capabilites, such as categories prunning.
15
+ DESC
16
+ spec.summary = %q{Simple parser for YML (Yandex Market Language) files with a few twists.}
17
+ spec.homepage = "https://github.com/ArtemPyanykh/goods"
18
+ spec.license = "MIT"
19
+
20
+ spec.files = `git ls-files`.split($/)
21
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
22
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.add_development_dependency "bundler", "~> 1.3"
26
+ spec.add_development_dependency "rake"
27
+ spec.add_development_dependency "rspec", "~> 3.0.0.beta1"
28
+
29
+ spec.add_runtime_dependency "libxml-ruby"
30
+ spec.add_runtime_dependency "nokogiri"
31
+ end
@@ -0,0 +1,32 @@
1
+ module Goods
2
+ class Catalog
3
+ attr_reader :categories, :currencies, :offers
4
+
5
+ def initialize(params)
6
+ if params[:string]
7
+ from_string(params[:string], params[:url], params[:encoding])
8
+ else
9
+ raise ArgumentError, "should provide either :string or :url param"
10
+ end
11
+ end
12
+
13
+ def prune(level)
14
+ @offers.prune_categories(level)
15
+ @categories.prune(level)
16
+ end
17
+
18
+ def convert_currency(other_currency)
19
+ @offers.convert_currency(other_currency)
20
+ end
21
+
22
+ private
23
+
24
+ def from_string(xml_string, url, encoding)
25
+ @xml_string = xml_string
26
+ @xml = XML.new(xml_string, url, encoding)
27
+ @categories = CategoriesList.new(@xml.categories)
28
+ @currencies = CurrenciesList.new(@xml.currencies)
29
+ @offers = OffersList.new(@categories, @currencies, @xml.offers)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,26 @@
1
+ module Goods
2
+ class CategoriesList
3
+ extend Container
4
+ container_for Category
5
+
6
+ def initialize(categories = [])
7
+ categories.each do |category|
8
+ add category
9
+ end
10
+ end
11
+
12
+ def prune(level)
13
+ level >= 0 or raise ArgumentError, "incorrect level"
14
+
15
+ items.delete_if { |k, v| v.level > level }
16
+ end
17
+
18
+ private
19
+
20
+ def prepare(object_or_hash)
21
+ category = super
22
+ category.parent = find(category.parent_id) if category.parent_id
23
+ category
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,54 @@
1
+ module Goods
2
+ class Category
3
+ include Containable
4
+ attr_accessor :parent
5
+ attr_field :parent_id
6
+ attr_field :name
7
+
8
+ def initialize(description)
9
+ self.description = description
10
+ @parent_at_level = {}
11
+ end
12
+
13
+ def level
14
+ @level ||=
15
+ begin
16
+ if parent
17
+ parent.level + 1
18
+ else
19
+ 0
20
+ end
21
+ end
22
+ end
23
+
24
+ def root
25
+ return self unless parent
26
+ @root ||= parent.root
27
+ end
28
+
29
+ def parent_at_level(level)
30
+ valid_parent_level?(level) or raise ArgumentError.new('incorrect level')
31
+ return self if self.level == level
32
+
33
+ @parent_at_level[level] ||= parent.parent_at_level(level)
34
+ end
35
+
36
+ private
37
+
38
+ def valid_parent_level?(level)
39
+ level >= 0 && level <= self.level
40
+ end
41
+
42
+ def apply_validation_rules
43
+ validate :id, proc { |val| !(val.nil? || val.empty?) }
44
+ validate :name, proc { |val| !(val.nil? || val.empty?) }
45
+ validate :parent_id, proc { |parent_id|
46
+ if !(parent_id.nil? || parent_id.empty?)
47
+ parent && (parent.id == parent_id)
48
+ else
49
+ parent == nil
50
+ end
51
+ }
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,54 @@
1
+ module Goods
2
+ module Containable
3
+ def self.included(base)
4
+ # Class macro that defined mathod to access instance variable @field_name
5
+ # in the following way (illustrative example)
6
+ #
7
+ # def field_name
8
+ # @field_name ||= description[field_name]
9
+ # end
10
+ base.define_singleton_method :attr_field do |field_name|
11
+ define_method field_name do
12
+ if field = instance_variable_get("@#{field_name}")
13
+ field
14
+ else
15
+ instance_variable_set("@#{field_name}", description[field_name])
16
+ instance_variable_get("@#{field_name}")
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ def description
23
+ @description
24
+ end
25
+
26
+ def id
27
+ @id ||= description[:id]
28
+ end
29
+
30
+ def invalid_fields
31
+ @invalid_fields ||= []
32
+ end
33
+
34
+ def valid?
35
+ reset_validation
36
+ apply_validation_rules
37
+ invalid_fields.empty?
38
+ end
39
+
40
+ private
41
+
42
+ def reset_validation
43
+ invalid_fields.clear
44
+ end
45
+
46
+ def validate(field, predicate)
47
+ invalid_fields << field unless predicate.call(send(field))
48
+ end
49
+
50
+ def description=(description)
51
+ @description = description
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,58 @@
1
+ module Goods
2
+ module Container
3
+ def container_for(klass)
4
+ define_singleton_method :_containable_class do
5
+ klass
6
+ end
7
+
8
+ include ContainerMethods
9
+ include Enumerable
10
+ # Need to redefine Enumerable#find
11
+ include SearchMethods
12
+ end
13
+
14
+ module ContainerMethods
15
+ def add(object_or_hash)
16
+ element = prepare(object_or_hash)
17
+
18
+ if element.valid?
19
+ items[element.id] = element
20
+ else
21
+ defectives << element
22
+ end
23
+ end
24
+
25
+ def defectives
26
+ @defectives ||= []
27
+ end
28
+
29
+ def each(&block)
30
+ items.values.each(&block)
31
+ end
32
+
33
+ def size
34
+ items.size
35
+ end
36
+
37
+ private
38
+
39
+ def items
40
+ @items ||= {}
41
+ end
42
+
43
+ def prepare(object_or_hash)
44
+ if object_or_hash.kind_of? self.class._containable_class
45
+ object_or_hash
46
+ else
47
+ self.class._containable_class.new(object_or_hash)
48
+ end
49
+ end
50
+ end
51
+
52
+ module SearchMethods
53
+ def find(id)
54
+ items[id]
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,12 @@
1
+ module Goods
2
+ class CurrenciesList
3
+ extend Container
4
+ container_for Currency
5
+
6
+ def initialize(currencies = [])
7
+ currencies.each do |currency|
8
+ add currency
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,29 @@
1
+ module Goods
2
+ class Currency
3
+ include Containable
4
+
5
+ def initialize(description)
6
+ self.description = description
7
+ end
8
+
9
+ def rate
10
+ @rate ||= description[:rate].to_f
11
+ end
12
+
13
+ def plus
14
+ @plus ||= description[:plus].to_f
15
+ end
16
+
17
+ def in(other_currency)
18
+ self.rate/other_currency.rate
19
+ end
20
+
21
+ private
22
+
23
+ def apply_validation_rules
24
+ validate :id, proc { |val| !(val.nil? || val.empty?) }
25
+ validate :rate, proc { |val| val >= 1 }
26
+ validate :plus, proc { |val| val >= 0 }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,43 @@
1
+ module Goods
2
+ class Offer
3
+ include Containable
4
+ attr_accessor :category, :currency, :price
5
+ attr_field :category_id
6
+ attr_field :currency_id
7
+ attr_field :available
8
+ attr_field :description
9
+ attr_field :model
10
+ attr_field :name
11
+ attr_field :picture
12
+ attr_field :vendor
13
+
14
+ def initialize(description)
15
+ self.description = description
16
+ @price = description[:price].to_f
17
+ end
18
+
19
+ def convert_currency(other_currency)
20
+ self.price *= currency.in(other_currency)
21
+ self.currency = other_currency
22
+ @currency_id = other_currency.id
23
+ end
24
+
25
+ def change_category(other_category)
26
+ self.category = other_category
27
+ @category_id = other_category.id
28
+ end
29
+
30
+ private
31
+
32
+ def apply_validation_rules
33
+ validate :id, proc { |val| !(val.nil? || val.empty?) }
34
+ validate :category_id, proc { |category_id|
35
+ category_id && category && (category_id == category.id)
36
+ }
37
+ validate :currency_id, proc { |currency_id|
38
+ currency_id && currency && (currency_id == currency.id)
39
+ }
40
+ validate :price, proc { |price| price && price > 0 }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,38 @@
1
+ module Goods
2
+ class OffersList
3
+ extend Container
4
+ container_for Offer
5
+ attr_accessor :categories_list, :currencies_list
6
+
7
+ def initialize(categories_list = nil, currencies_list = nil, offers = [])
8
+ self.categories_list = categories_list
9
+ self.currencies_list = currencies_list
10
+ offers.each do |offer|
11
+ add offer
12
+ end
13
+ end
14
+
15
+ def prune_categories(level)
16
+ self.each do |offer|
17
+ if offer.category.level > level
18
+ offer.change_category(offer.category.parent_at_level(level))
19
+ end
20
+ end
21
+ end
22
+
23
+ def convert_currency(other_currency)
24
+ self.each do |offer|
25
+ offer.convert_currency(other_currency)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def prepare(object_or_hash)
32
+ object = super
33
+ object.category = categories_list.find(object.category_id) if categories_list
34
+ object.currency = currencies_list.find(object.currency_id) if currencies_list
35
+ object
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,3 @@
1
+ module Goods
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,66 @@
1
+ require "xml"
2
+
3
+ module Goods
4
+ class XML
5
+ class Validator
6
+ attr_reader :error
7
+
8
+ def initialize
9
+ @error = nil
10
+ end
11
+
12
+ def valid?(xml)
13
+ validate(xml)
14
+ error.nil?
15
+ end
16
+
17
+ def validate(xml)
18
+ @error = nil
19
+ document = LibXML::XML::Document.string(xml)
20
+
21
+ # Should silence STDERR, because libxml2 spews validation error
22
+ # to standard error stream
23
+ silence_stream(STDERR) do
24
+ # Catch first exception due to bug in libxml - it throws
25
+ # 'the model is not determenistic' error. The second validation runs
26
+ # just fine and gives the real error.
27
+ begin
28
+ document.validate(dtd)
29
+ rescue LibXML::XML::Error => e
30
+ #nothing
31
+ end
32
+
33
+ begin
34
+ document.validate(dtd)
35
+ rescue LibXML::XML::Error => e
36
+ @error = e.to_s
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def dtd_path
44
+ File.expand_path("../../../support/shops.dtd", __FILE__)
45
+ end
46
+
47
+ def dtd_string
48
+ File.read(dtd_path)
49
+ end
50
+
51
+ def dtd
52
+ @dtd ||= LibXML::XML::Dtd.new(dtd_string)
53
+ end
54
+
55
+ def silence_stream(stream)
56
+ old_stream = stream.dup
57
+ stream.reopen(RbConfig::CONFIG['host_os'] =~ /mswin|mingw/ ? 'NUL:' : '/dev/null')
58
+ stream.sync = true
59
+ yield
60
+ ensure
61
+ stream.reopen(old_stream)
62
+ end
63
+ end
64
+ end
65
+ end
66
+