xml_node_stream 2.0.0 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c48b75cdd974d95227eaccd1748c73d6e04d0793d6d9fe6e5fd0a2c9dfecfb32
4
- data.tar.gz: 47c1effbbe39895d2cac07f545904a27760c0606406daf0b2abfaf5139f2f4ae
3
+ metadata.gz: abb1e46f305cb09b383415a0fab348290c2058c98282360ceef41b73f0e6a0c6
4
+ data.tar.gz: fa5503fb2b14409bae9c92eadbf0cf525ce683e137709723b85f7622a599a831
5
5
  SHA512:
6
- metadata.gz: e50b6c273e787053f8020731c9e11830a19f3e047ce230e417fa927ae51cd274ca64b8921489a98831ba77aeef34d050fd309496c1018ee482fcf59abca671f7
7
- data.tar.gz: d8e2040f6d4117b29054813eb7741bd15567d374736df80a1cbaeff25fe9692848b2de7e44f0bfec34234d9e6bb5229bfb120023c8a5f7b057a614a0b8dc9fc9
6
+ metadata.gz: c6059c96f122e4e2312b7283b49a11a27c621833df66e3c3434bd330db838c13264c514b7523863d48f5ea364e9c63b0c7a4b03eeec936e7e15fb034fbb2edaa
7
+ data.tar.gz: 91b44394a4983f916f11ea4366493ed5f04edbbdc6a1ee3ded867d717973592f64a23343fd3cfb88c7fb607ea68f9c0a9b00003ae5fe6ac6d586685a17eb02ed
data/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 2.0.1
8
+
9
+ ### Fixed
10
+
11
+ - Added missing `stringio` require which caused a `NameError` when parsing an XML string on Ruby versions where the standard library no longer loads it implicitly.
12
+ - XPath selectors no longer drop nested elements with the same name (e.g. `.//a/a` now matches `a` elements nested inside other `a` elements).
13
+ - XPath selectors with a leading `//` now correctly search all descendants of the document root instead of only children of the root element.
14
+ - XPath selectors ending a `//` step with `*` (e.g. `book//*`) now match all descendants instead of skipping the first level.
15
+ - The `text()` selector no longer removes duplicate values when different nodes contain identical text.
16
+ - HTTP responses are now streamed incrementally instead of being read entirely into memory before parsing.
17
+
18
+ ### Changed
19
+
20
+ - HTTP requests that return an error status now raise `XmlNodeStream::HttpError` instead of parsing the error response body as XML.
21
+
22
+ ### Added
23
+
24
+ - HTTP redirects are now followed (up to 5) when parsing from a URL.
25
+
7
26
  ## 2.0.0
8
27
 
9
28
  ### Changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.0.0
1
+ 2.0.1
@@ -1,14 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "fiber"
4
5
 
5
6
  module XmlNodeStream
7
+ # Error raised when an HTTP request returns an unsuccessful response.
8
+ class HttpError < StandardError
9
+ attr_reader :response
10
+
11
+ # @param message [String] the error message
12
+ # @param response [Net::HTTPResponse, nil] the HTTP response that caused the error
13
+ def initialize(message, response = nil)
14
+ super(message)
15
+ @response = response
16
+ end
17
+ end
18
+
6
19
  # IO-like wrapper for HTTP responses that allows streaming
7
20
  class HttpStream
8
21
  # Default timeout values in seconds
9
22
  DEFAULT_OPEN_TIMEOUT = 10
10
23
  DEFAULT_READ_TIMEOUT = 60
11
24
 
25
+ # Maximum number of redirects to follow
26
+ MAX_REDIRECTS = 5
27
+
12
28
  # Create a new HttpStream.
13
29
  #
14
30
  # @param uri [URI] the URI to stream from
@@ -16,15 +32,13 @@ module XmlNodeStream
16
32
  # @param read_timeout [Integer] read timeout in seconds (default 60)
17
33
  def initialize(uri, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
18
34
  @uri = uri
19
- @http = Net::HTTP.new(uri.host, uri.port)
20
- @http.use_ssl = (uri.scheme == "https")
21
- @http.open_timeout = open_timeout
22
- @http.read_timeout = read_timeout
23
- @request = Net::HTTP::Get.new(uri.request_uri)
35
+ @open_timeout = open_timeout
36
+ @read_timeout = read_timeout
37
+ @http = nil
24
38
  @buffer = +""
25
39
  @eof = false
26
40
  @response = nil
27
- @body_reader = nil
41
+ @fiber = nil
28
42
  end
29
43
 
30
44
  # Read data from the stream.
@@ -108,7 +122,7 @@ module XmlNodeStream
108
122
  return nil if @buffer.empty?
109
123
 
110
124
  line = @buffer
111
- @buffer = ""
125
+ @buffer = +""
112
126
  line
113
127
  end
114
128
 
@@ -148,32 +162,67 @@ module XmlNodeStream
148
162
  private
149
163
 
150
164
  def ensure_response_started
151
- return if @response
165
+ return if @fiber
166
+
167
+ # The request runs inside a fiber so that the response body can be consumed incrementally.
168
+ # Net::HTTP#request with a block streams the body via read_body; each chunk is yielded out
169
+ # of the fiber and pulled on demand by read_chunk without buffering the whole response.
170
+ @fiber = Fiber.new do
171
+ uri = @uri
172
+ redirects = 0
173
+
174
+ loop do
175
+ connect(uri)
176
+ request = Net::HTTP::Get.new(uri.request_uri)
177
+ redirect_location = nil
178
+
179
+ @http.request(request) do |response|
180
+ @response = response
181
+
182
+ case response
183
+ when Net::HTTPRedirection
184
+ redirects += 1
185
+ if redirects > MAX_REDIRECTS
186
+ raise HttpError.new("Too many redirects requesting #{@uri}", response)
187
+ end
188
+ redirect_location = response["Location"]
189
+ unless redirect_location
190
+ raise HttpError.new("Redirect without Location header from #{uri}", response)
191
+ end
192
+ when Net::HTTPSuccess
193
+ response.read_body do |chunk|
194
+ Fiber.yield(chunk) unless chunk.empty?
195
+ end
196
+ else
197
+ raise HttpError.new("HTTP error #{response.code} requesting #{uri}", response)
198
+ end
199
+ end
200
+
201
+ break unless redirect_location
202
+
203
+ uri = URI.join(uri.to_s, redirect_location)
204
+ end
152
205
 
153
- @http.start unless @http.started?
154
- @response = @http.request(@request)
155
- @body_reader = @response.read_body
206
+ nil
207
+ end
208
+ end
209
+
210
+ def connect(uri)
211
+ close
212
+
213
+ @http = Net::HTTP.new(uri.host, uri.port)
214
+ @http.use_ssl = (uri.scheme == "https")
215
+ @http.open_timeout = @open_timeout
216
+ @http.read_timeout = @read_timeout
217
+ @http.start
156
218
  end
157
219
 
158
220
  def read_chunk
159
221
  return nil if @eof
160
222
 
161
- if @body_reader.is_a?(String)
162
- # Entire body was read at once
163
- if @body_reader.empty?
164
- @eof = true
165
- return nil
166
- end
167
- # Simulate chunking for consistency
168
- chunk = @body_reader.byteslice(0, 8192) || +""
169
- @body_reader = @body_reader.byteslice(8192..-1) || +""
170
- @eof = true if @body_reader.empty?
171
- chunk
172
- else
173
- # Should not happen with webmock but handling for real HTTP
174
- @eof = true
175
- nil
176
- end
223
+ chunk = (@fiber.alive? ? @fiber.resume : nil)
224
+ @eof = true if chunk.nil?
225
+ chunk
177
226
  end
178
227
  end
179
228
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  begin
4
- require "libxml"
4
+ require "libxml-ruby"
5
5
 
6
6
  module XmlNodeStream
7
7
  class Parser
@@ -9,8 +9,6 @@ module XmlNodeStream
9
9
  class Parser
10
10
  SUPPORTED_PARSERS = [:nokogiri, :libxml, :rexml]
11
11
 
12
- @parser = :rexml
13
-
14
12
  class << self
15
13
  # Set the parser implementation. The parser argument should be one of :nokogiri, :libxml, or :rexml. If this method
16
14
  # is not called, it will default to :rexml which is the slowest choice possible. If you set the parser to one of the
@@ -84,7 +82,7 @@ module XmlNodeStream
84
82
  @loaded_parsers ||= {}
85
83
  klass = @loaded_parsers[class_symbol]
86
84
  unless klass
87
- require File.expand_path(File.join(File.dirname(__FILE__), "parser", "#{class_symbol}_parser"))
85
+ require_relative "parser/#{class_symbol}_parser"
88
86
  class_name = "#{class_symbol.to_s.capitalize}Parser"
89
87
  klass = const_get(class_name)
90
88
  @loaded_parsers[class_symbol] = klass
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
3
  module XmlNodeStream
6
4
  # Partial implementation of XPath selectors. Only abbreviated paths and the text() function are supported. The rest of XPath
7
5
  # is unecessary in the context of a Ruby application since XPath is also a programming language. If you really need an XPath
@@ -38,11 +36,10 @@ module XmlNodeStream
38
36
  matched = [node]
39
37
  @parts.each do |part_matchers|
40
38
  context = matched
41
- context_set = context.to_set
42
39
  matched = []
43
40
 
44
41
  part_matchers.each do |matcher|
45
- matched.concat(matcher.select(context, context_set))
42
+ matched.concat(matcher.select(context))
46
43
  end
47
44
 
48
45
  break if matched.empty?
@@ -66,15 +63,9 @@ module XmlNodeStream
66
63
  path_length = path.length
67
64
 
68
65
  while i < path_length
69
- # Skip leading slash for absolute paths
70
- if i == 0 && path[i] == "/"
71
- parts << [Matcher.new("")]
72
- i += 1
73
- next
74
- end
75
-
76
- # Look for // (descendant operator)
66
+ # Look for // (descendant operator); a leading // searches from the document root
77
67
  if i < path_length - 1 && path[i] == "/" && path[i + 1] == "/"
68
+ parts << [Matcher.new("")] if i == 0
78
69
  i += 2
79
70
  # Check if there's a name after //
80
71
  name_match = path[i..].match(/\A([a-zA-Z_][\w-]*)/)
@@ -84,12 +75,23 @@ module XmlNodeStream
84
75
  elsif i >= path_length
85
76
  # // at end of path is invalid
86
77
  raise ArgumentError, "Invalid XPath pattern: #{path} (// cannot be at end)"
78
+ elsif path[i] == "*" && (i + 1 >= path_length || path[i + 1] == "/")
79
+ # //* selects all descendants
80
+ parts << [Matcher.new("%")]
81
+ i += 1
87
82
  else
88
83
  parts << [Matcher.new("%")]
89
84
  end
90
85
  next
91
86
  end
92
87
 
88
+ # Skip leading slash for absolute paths
89
+ if i == 0 && path[i] == "/"
90
+ parts << [Matcher.new("")]
91
+ i += 1
92
+ next
93
+ end
94
+
93
95
  # Regular path segment
94
96
  if path[i] == "/"
95
97
  i += 1
@@ -139,43 +141,40 @@ module XmlNodeStream
139
141
  # @param path [String] the path pattern to match
140
142
  def initialize(path)
141
143
  @path = path
144
+ @text = (path == "text()")
142
145
  @extractor = case path
143
146
  when "text()"
144
- lambda { |node, context_set| node.value unless node.value.nil? || node.value.empty? }
147
+ lambda { |node| node.value unless node.value.nil? || node.value.empty? }
145
148
  when "%"
146
- lambda { |node, context_set| node.descendants }
149
+ lambda { |node| node.descendants }
147
150
  when "*"
148
- lambda { |node, context_set| node.children }
151
+ lambda { |node| node.children }
149
152
  when "."
150
- lambda { |node, context_set| node }
153
+ lambda { |node| node }
151
154
  when ".."
152
- lambda { |node, context_set| node.parent || [] }
155
+ lambda { |node| node.parent || [] }
153
156
  when ""
154
- lambda { |node, context_set|
157
+ lambda { |node|
155
158
  root = Node.new(nil)
156
159
  root.children << node.root
157
160
  root
158
161
  }
159
162
  when /^%(.+)$/ # descendants with name filter: %name
160
163
  name = $1
161
- lambda { |node, context_set| node.descendants.select { |d| d.name == name } }
164
+ lambda { |node| node.descendants.select { |d| d.name == name } }
162
165
  else
163
- lambda { |node, context_set|
164
- # Only return children matching the name
165
- # Don't include children that are already in the context
166
- node.children.select { |child| child.name == @path && !context_set&.include?(child) }
167
- }
166
+ lambda { |node| node.children.select { |child| child.name == @path } }
168
167
  end
169
168
  end
170
169
 
171
170
  # Select all nodes that match a partial path.
172
171
  #
173
172
  # @param context_nodes [Array<Node>] the nodes to select from
174
- # @param context_set [Set<Node>, nil] optional set version of context_nodes for performance
175
173
  # @return [Array<Node>] the matching nodes
176
- def select(context_nodes, context_set = nil)
177
- context_set ||= context_nodes.to_set
178
- context_nodes.collect { |node| @extractor.call(node, context_set) if node.is_a?(Node) }.flatten.compact.uniq
174
+ def select(context_nodes)
175
+ results = context_nodes.collect { |node| @extractor.call(node) if node.is_a?(Node) }.flatten.compact
176
+ # Text values from different nodes can legitimately be equal, so only dedupe nodes.
177
+ @text ? results : results.uniq
179
178
  end
180
179
  end
181
180
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
+ require "stringio"
4
5
  require "uri"
5
6
 
6
7
  require_relative "xml_node_stream/node"
@@ -35,5 +35,5 @@ Gem::Specification.new do |spec|
35
35
 
36
36
  spec.require_paths = ["lib"]
37
37
 
38
- spec.add_development_dependency "bundler"
38
+ spec.required_ruby_version = ">= 2.7"
39
39
  end
metadata CHANGED
@@ -1,28 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: xml_node_stream
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: bundler
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :development
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
11
+ dependencies: []
26
12
  email:
27
13
  - bbdurand@gmail.com
28
14
  executables: []
@@ -57,7 +43,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
57
43
  requirements:
58
44
  - - ">="
59
45
  - !ruby/object:Gem::Version
60
- version: '0'
46
+ version: '2.7'
61
47
  required_rubygems_version: !ruby/object:Gem::Requirement
62
48
  requirements:
63
49
  - - ">="