pastvu 1.0.0
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/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +301 -0
- data/lib/pastvu/basic_response.rb +21 -0
- data/lib/pastvu/cluster/cluster.rb +7 -0
- data/lib/pastvu/cluster/cluster_collection.rb +9 -0
- data/lib/pastvu/collection.rb +27 -0
- data/lib/pastvu/comment/comment.rb +29 -0
- data/lib/pastvu/comment/comment_collection.rb +18 -0
- data/lib/pastvu/configuration.rb +38 -0
- data/lib/pastvu/model.rb +38 -0
- data/lib/pastvu/params_validator/type_check.rb +48 -0
- data/lib/pastvu/params_validator/value_check.rb +53 -0
- data/lib/pastvu/params_validator.rb +8 -0
- data/lib/pastvu/parser.rb +21 -0
- data/lib/pastvu/photo/photo.rb +37 -0
- data/lib/pastvu/photo/photo_collection.rb +26 -0
- data/lib/pastvu/request.rb +42 -0
- data/lib/pastvu/response/bounds_response.rb +13 -0
- data/lib/pastvu/response/information_response.rb +8 -0
- data/lib/pastvu/version.rb +5 -0
- data/lib/pastvu.rb +101 -0
- data/pastvu.gemspec +26 -0
- metadata +108 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1ede36b18a77672d8488001474123f2e4a869ee521d2006c8a56186a6f3c9b30
|
|
4
|
+
data.tar.gz: 6e222696f72e76757ea24a0f8dd7dfc0d089f1b4081665708bc7d531548fda22
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e34ccfa15c79465afa76b9ea236f53675caaa5f7e8e88d6734b7d72fef2a39a49ef5d24c7bc559778c083a13ed1c6490bbbb08b5e8e9cad4b5b5b55fcd84b624
|
|
7
|
+
data.tar.gz: 26f7aaaa4014f9afb341876fa932354687ab0568c5257bb6bf88b6567d028a627c5a3ca6242780676c78e46fbf088be1dea92bf5e879a5fbc81b46634e154508
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 projecteurlumiere
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# PastVu
|
|
2
|
+
|
|
3
|
+
PastVu gem is a Ruby wrapper for [PastVu API](https://docs.pastvu.com/en/dev/api). It allows convenient interaction with the API in your Ruby code.
|
|
4
|
+
|
|
5
|
+
[PastVu](https://pastvu.com) is an open-source online platform for gathering, geo-tagging, attributing and discussing retro photos. Its [repository](https://github.com/PastVu/pastvu) can be found on GitHub.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
10
|
+
|
|
11
|
+
$ bundle add pastvu --version "~> 1.0.0"
|
|
12
|
+
|
|
13
|
+
or add the following line to the Gemfile manually
|
|
14
|
+
|
|
15
|
+
# gem "pastvu", "~> 1.0.0"
|
|
16
|
+
|
|
17
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
18
|
+
|
|
19
|
+
$ gem install pastvu
|
|
20
|
+
|
|
21
|
+
and do not forget to require the gem in your code:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "pastvu"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Refer to [PastVu API documentation](https://docs.pastvu.com/en/dev/api) for available interactions, parameters, parameter types and response examples.
|
|
30
|
+
|
|
31
|
+
### Scenario: Getting nearest photos
|
|
32
|
+
|
|
33
|
+
#### Step one - request data
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
golden_gate_coordinates = [37.82,-122.469322]
|
|
37
|
+
|
|
38
|
+
# It sends photo.giveNearestPhotos request
|
|
39
|
+
# Returns a PhotoCollection instance on success
|
|
40
|
+
photo_collection = Pastvu.nearest_photos(geo: golden_gate_coordinates)
|
|
41
|
+
|
|
42
|
+
# Optional params, too, go as keyword arguments:
|
|
43
|
+
photo_collection = Pastvu.nearest_photos(
|
|
44
|
+
geo: golden_gate_coordinates,
|
|
45
|
+
except: 228481,
|
|
46
|
+
limit: 12
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# PastVu API does not allow requesting >30 nearest photos
|
|
50
|
+
# but you can skip the ones you have already requested
|
|
51
|
+
new_photo_collection = photo_collection.next
|
|
52
|
+
|
|
53
|
+
# The previous limit (12 in the case above or 30 if no limit was specified) defines how many photos the new collection gets
|
|
54
|
+
# It is possible to set a new limit in the argument (no more than 30!):
|
|
55
|
+
new_photo_collection = photo_collection.next(5)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
#### Step two - work with data
|
|
59
|
+
|
|
60
|
+
##### Manipulate attributes:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# The full response can be immediately transformed into JSON or Hash
|
|
64
|
+
photo_collection.to_json
|
|
65
|
+
photo_collection.to_hash
|
|
66
|
+
|
|
67
|
+
# On iteration pastvu gem extracts photo information into Photo object
|
|
68
|
+
photo_collection.each do |photo|
|
|
69
|
+
# Photo attributes are available as methods:
|
|
70
|
+
puts photo.title
|
|
71
|
+
puts photo.year
|
|
72
|
+
puts photo.geo
|
|
73
|
+
# etc
|
|
74
|
+
|
|
75
|
+
# Photo can be transformed into hash:
|
|
76
|
+
hash = photo.to_hash
|
|
77
|
+
puts hash["title"]
|
|
78
|
+
puts hash["year"]
|
|
79
|
+
puts hash["geo"]
|
|
80
|
+
# etc
|
|
81
|
+
|
|
82
|
+
# and into json:
|
|
83
|
+
photo.to_json
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
##### Download photos:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
photo_collection.each_with_index do |photo, i|
|
|
91
|
+
# All return download URL as String:
|
|
92
|
+
photo.standard
|
|
93
|
+
photo.original
|
|
94
|
+
photo.thumb # or thumbnail
|
|
95
|
+
|
|
96
|
+
# The path to a new photo must end with ".jpg" or ".jpeg"
|
|
97
|
+
desired_path_to_photo = "awesome_photo_number_#{i + 1}.jpg"
|
|
98
|
+
|
|
99
|
+
# All return File object:
|
|
100
|
+
photo.download(desired_path_to_photo, :standard)
|
|
101
|
+
photo.download(desired_path_to_photo, :original)
|
|
102
|
+
photo.download(desired_path_to_photo, :thumb) # or :thumbnail
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
##### Request more data about the photo:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
photo_collection.each do |photo|
|
|
110
|
+
# To make a request for comments:
|
|
111
|
+
photo.comments # returns CommentCollection
|
|
112
|
+
# See also the section on comments
|
|
113
|
+
|
|
114
|
+
# To make a request for full photo information
|
|
115
|
+
photo.reload # returns a new Photo object
|
|
116
|
+
# See also the section on photo information
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Scenario: Getting photos inside geographical bounds
|
|
121
|
+
|
|
122
|
+
#### Step one - prepare request
|
|
123
|
+
|
|
124
|
+
PastVu API accepts geoJSON polygons and geoJSON multipolygons
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# use JSON string in the GeoJSON format:
|
|
128
|
+
paris_montmartre = '{"coordinates":[[[2.34218629483172,48.88623415508624],[2.34218629483172,48.88425956838617],[2.3449771858020085,48.88425956838617],[2.3449771858020085,48.88623415508624],[2.34218629483172,48.88623415508624]]],"type":"Polygon"}'
|
|
129
|
+
|
|
130
|
+
# or use hash in the GeoJSON format:
|
|
131
|
+
paris_montmartre = {
|
|
132
|
+
"type" => "Polygon",
|
|
133
|
+
"coordinates" => [
|
|
134
|
+
[
|
|
135
|
+
[
|
|
136
|
+
2.34218629483172,
|
|
137
|
+
48.88623415508624
|
|
138
|
+
],
|
|
139
|
+
[
|
|
140
|
+
2.34218629483172,
|
|
141
|
+
48.88425956838617
|
|
142
|
+
],
|
|
143
|
+
[
|
|
144
|
+
2.3449771858020085,
|
|
145
|
+
48.88425956838617
|
|
146
|
+
],
|
|
147
|
+
[
|
|
148
|
+
2.3449771858020085,
|
|
149
|
+
48.88623415508624
|
|
150
|
+
],
|
|
151
|
+
[
|
|
152
|
+
2.34218629483172,
|
|
153
|
+
48.88623415508624
|
|
154
|
+
]
|
|
155
|
+
]
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
#### Step two - request data
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
# It sends photo.getByBounds request
|
|
164
|
+
# It returns BoundsResponse
|
|
165
|
+
bounds_response = Pastvu.by_bounds(
|
|
166
|
+
geometry: paris_montmartre,
|
|
167
|
+
z: 16
|
|
168
|
+
)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The response may contain clusters, photos, or both - see [API docs](https://docs.pastvu.com/en/dev/api)
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
photo_collection = bounds_response.photos # returns PhotoCollection
|
|
175
|
+
|
|
176
|
+
cluster_collection = bounds_response.clusters # returns ClusterCollection
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
#### Step three - manipulate data
|
|
180
|
+
|
|
181
|
+
##### Photos
|
|
182
|
+
|
|
183
|
+
PhotoCollection and each photo object have almost all the same methods as discussed in the previous scenario section.
|
|
184
|
+
|
|
185
|
+
Note that PastVu API may send different photo data for by_bounds and nearest_photos requests. This, however, does not obstruct in-built convenience methods for downloading.
|
|
186
|
+
|
|
187
|
+
For instance:
|
|
188
|
+
```ruby
|
|
189
|
+
photo_collection.each do |photo|
|
|
190
|
+
photo.original # will work
|
|
191
|
+
photo.year2 # should work for by_bounds but might not work for nearest_photos
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
photo_collection.next # will not work for by_bounds
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
##### Clusters
|
|
198
|
+
|
|
199
|
+
On the [Pastvu website](https://pastvu.com), clusters are representations of multiple photos. Users see clusters when zooming out.
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
# The full response can be immediately transformed into JSON or Hash
|
|
203
|
+
cluster_collection.to_json
|
|
204
|
+
cluster_collection.to_hash
|
|
205
|
+
|
|
206
|
+
cluster_collection.each do |cluster|
|
|
207
|
+
# Cluster attributes are available as methods:
|
|
208
|
+
puts cluster.c
|
|
209
|
+
# etc
|
|
210
|
+
|
|
211
|
+
# Cluster can be transformed into Hash
|
|
212
|
+
hash = cluster.to_hash
|
|
213
|
+
puts hash["c"]
|
|
214
|
+
# etc
|
|
215
|
+
|
|
216
|
+
# and into JSON
|
|
217
|
+
cluster.to_json
|
|
218
|
+
|
|
219
|
+
cluster.photo # returns Photo object corresponding to the clusters cover thumbnail
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Scenario: Getting full photo information
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
photo_cid = 5
|
|
227
|
+
|
|
228
|
+
# It sends photo.giveForPage request
|
|
229
|
+
# Returns informationResponse on success
|
|
230
|
+
photo_information = Pastvu.photo_info(photo_cid)
|
|
231
|
+
|
|
232
|
+
# informationResponse can be transformed into JSON and Hash
|
|
233
|
+
photo_information.to_json
|
|
234
|
+
photo_information.to_hash
|
|
235
|
+
|
|
236
|
+
photo = photo_information.to_photo # returns Photo object
|
|
237
|
+
|
|
238
|
+
# There is also a shorthand to return Photo object immediately instead of informationResponse
|
|
239
|
+
photo = Pastvu.photo(photo_cid)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The created Photo object will respond to all the methods discussed previously but it tends to have more attributes. Finally, it is possible to request full information for any Photo object by calling `Photo#reload` on Photo instance, which returns a new Photo instance.
|
|
243
|
+
|
|
244
|
+
### Scenario: Getting commentaries for a photo
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
photo_cid = 5
|
|
248
|
+
|
|
249
|
+
# It makes comment.giveForObj request
|
|
250
|
+
# It returns CommentCollection on success
|
|
251
|
+
comment_collection = Pastvu.comments(photo_cid)
|
|
252
|
+
|
|
253
|
+
comment_collection.users # returns hash with data about all the users who left a comment under the photo
|
|
254
|
+
|
|
255
|
+
comment_collection.each do |comment|
|
|
256
|
+
# Comment attributes are available as methods
|
|
257
|
+
puts cluster.user
|
|
258
|
+
puts cluster.txt
|
|
259
|
+
# etc
|
|
260
|
+
|
|
261
|
+
# Cluster can be transformed into Hash
|
|
262
|
+
hash = cluster.to_hash
|
|
263
|
+
puts hash["user"]
|
|
264
|
+
puts hash["txt"]
|
|
265
|
+
# etc
|
|
266
|
+
|
|
267
|
+
# and into JSON
|
|
268
|
+
cluster.to_json
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
comment.replies # returns Array containing replies to the given comment
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Configuration
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
Pastvu.configure do |c|
|
|
279
|
+
|
|
280
|
+
c.host # "pastvu.com"
|
|
281
|
+
c.path # "api2"
|
|
282
|
+
c.user_agent # "Ruby PastVu Gem/#{VERSION}, #{RUBY_PLATFORM}, Ruby/#{RUBY_VERSION}"
|
|
283
|
+
|
|
284
|
+
# Raise when API response is not of expected format
|
|
285
|
+
c.ensure_successful_responses # "true"
|
|
286
|
+
|
|
287
|
+
# Raise when supplied params are not of the required type
|
|
288
|
+
c.check_params_type # "true"
|
|
289
|
+
|
|
290
|
+
# Raise when supplied params are not of the required values
|
|
291
|
+
c.check_params_value # "true"
|
|
292
|
+
end
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Contributing
|
|
296
|
+
|
|
297
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/projecteurlumiere/pastvu.
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Pastvu
|
|
2
|
+
class BasicResponse
|
|
3
|
+
attr_accessor :json, :hash
|
|
4
|
+
|
|
5
|
+
def initialize(response_body)
|
|
6
|
+
@json = response_body
|
|
7
|
+
if Pastvu.config.ensure_successful_responses
|
|
8
|
+
@hash = self.to_hash
|
|
9
|
+
raise RuntimeError, "Unexpected response from the server: #{@hash}" unless @hash["result"]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_json
|
|
14
|
+
@hash ? Parser.to_json(@hash) : @json
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_hash
|
|
18
|
+
@hash || Parser.to_hash(@json)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Pastvu
|
|
2
|
+
class Collection < BasicResponse
|
|
3
|
+
include Enumerable
|
|
4
|
+
|
|
5
|
+
def initialize(attr)
|
|
6
|
+
attr.instance_of?(Hash) ? @hash = attr : super
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def each
|
|
10
|
+
return to_enum(:each) unless block_given?
|
|
11
|
+
|
|
12
|
+
@hash ||= self.to_hash
|
|
13
|
+
|
|
14
|
+
reduce_hash.each do |model_hash|
|
|
15
|
+
yield @model.new model_hash
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def reduce_hash
|
|
22
|
+
@path.reduce(@hash) do |hash, path_key|
|
|
23
|
+
hash.fetch(path_key)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Pastvu
|
|
2
|
+
class Comment < Model
|
|
3
|
+
def replies
|
|
4
|
+
populate_replies unless @comments.nil?
|
|
5
|
+
@replies ||= []
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def replies=(value)
|
|
9
|
+
@replies = value
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_hash
|
|
13
|
+
instance_variables.each_with_object({}) do |var, object|
|
|
14
|
+
next if var == :@replies
|
|
15
|
+
|
|
16
|
+
var = var[1..-1]
|
|
17
|
+
object[camelize(var).to_sym] = method(var).call
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def populate_replies
|
|
24
|
+
@replies = @comments.map do |comment|
|
|
25
|
+
Comment.new comment
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Pastvu
|
|
2
|
+
class CommentCollection < Collection
|
|
3
|
+
def initialize(attributes)
|
|
4
|
+
super attributes
|
|
5
|
+
@path = %w[result comments]
|
|
6
|
+
@model = Comment
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def users
|
|
10
|
+
@hash ||= self.to_hash
|
|
11
|
+
@hash["result"]["users"]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def photo
|
|
15
|
+
Pastvu.photo @hash["result"]["cid"]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Pastvu
|
|
2
|
+
class Configuration
|
|
3
|
+
VALID_OPTIONS = %i[
|
|
4
|
+
host
|
|
5
|
+
path
|
|
6
|
+
user_agent
|
|
7
|
+
ensure_successful_responses
|
|
8
|
+
check_params_type
|
|
9
|
+
check_params_value
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
DEFAULT_VALUES = {
|
|
13
|
+
default_host: "pastvu.com",
|
|
14
|
+
default_path: "api2",
|
|
15
|
+
default_user_agent: "Ruby PastVu Gem/#{VERSION}, #{RUBY_PLATFORM}, Ruby/#{RUBY_VERSION}",
|
|
16
|
+
default_ensure_successful_responses: true,
|
|
17
|
+
default_check_params_type: true,
|
|
18
|
+
default_check_params_value: true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
attr_accessor *VALID_OPTIONS
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
reset!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reset!
|
|
28
|
+
VALID_OPTIONS.each do |option|
|
|
29
|
+
self.send(option.to_s.concat("=").to_sym, DEFAULT_VALUES["default_".concat(option.to_s).to_sym])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def configure
|
|
34
|
+
yield self
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/pastvu/model.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Pastvu
|
|
2
|
+
class Model
|
|
3
|
+
def initialize(attributes)
|
|
4
|
+
attributes.each do |key, value|
|
|
5
|
+
key = snakecase(key)
|
|
6
|
+
instance_variable_set("@#{key}", value)
|
|
7
|
+
self.class.send(:attr_accessor, key)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_hash
|
|
12
|
+
instance_variables.each_with_object({}) do |var, object|
|
|
13
|
+
var = var[1..-1]
|
|
14
|
+
object[camelize(var).to_sym] = method(var).call
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_json
|
|
19
|
+
Parser.to_json(to_hash)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def snakecase(camel_cased_word)
|
|
25
|
+
camel_cased_word.to_s.gsub(/::/, '/').
|
|
26
|
+
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
|
|
27
|
+
gsub(/([a-z\d])([A-Z])/,'\1_\2').
|
|
28
|
+
tr("-", "_").
|
|
29
|
+
downcase
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def camelize(snake_cased_word, capitalize_first_letter = false)
|
|
33
|
+
array = snake_cased_word.to_s.split('_').collect(&:capitalize)
|
|
34
|
+
array[0]&.downcase! unless capitalize_first_letter
|
|
35
|
+
array.join
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Pastvu
|
|
2
|
+
class TypeCheck
|
|
3
|
+
ALLOWED_TYPES = {
|
|
4
|
+
cid: Integer,
|
|
5
|
+
distance: Integer, # <= 1000000 (meters)
|
|
6
|
+
except: Integer,
|
|
7
|
+
geo: Array, # [lat and lon]
|
|
8
|
+
geometry: Hash, # [geoJSON]
|
|
9
|
+
isPainting: [TrueClass, FalseClass],
|
|
10
|
+
limit: Integer, # <= 30
|
|
11
|
+
localWork: [TrueClass, FalseClass],
|
|
12
|
+
skip: Integer,
|
|
13
|
+
type: String, # "photo" or "painting"
|
|
14
|
+
year: Integer,
|
|
15
|
+
year2: Integer,
|
|
16
|
+
z: Integer
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
def self.validate(params)
|
|
20
|
+
errors = {}
|
|
21
|
+
ALLOWED_TYPES.each do |k, type|
|
|
22
|
+
param = params[k]
|
|
23
|
+
next if param.nil?
|
|
24
|
+
next if self.correct_type? param, type
|
|
25
|
+
|
|
26
|
+
errors.merge!({ k.to_sym => [param, type] })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
errors.empty? ? true : report_errors(errors)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.report_errors(errors)
|
|
33
|
+
report = errors.map do |k, v|
|
|
34
|
+
"\n#{k}: #{v[0]} must be #{v[1]}"
|
|
35
|
+
end.join(", ")
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, "expect correct params type\n #{report}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.correct_type?(param, type)
|
|
41
|
+
if type.instance_of?(Array)
|
|
42
|
+
type.any? { |type| param.instance_of?(type) }
|
|
43
|
+
else
|
|
44
|
+
param.instance_of?(type)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Pastvu
|
|
2
|
+
class ValueCheck
|
|
3
|
+
VALIDATIONS = {
|
|
4
|
+
distance: ->(d) { d.between?(1, 1_000_000) },
|
|
5
|
+
geo: [->(g) { g.size == 2 },
|
|
6
|
+
->(g) { g.all? { |coordinate| coordinate.instance_of?(Float) || coordinate.instance_of?(Integer) } }],
|
|
7
|
+
geometry: ->(g) do
|
|
8
|
+
begin
|
|
9
|
+
permitted_types = %w[Polygon Multipolyigon]
|
|
10
|
+
permitted_types.any? { |t| t == g["type"] }
|
|
11
|
+
rescue TypeError
|
|
12
|
+
false
|
|
13
|
+
end
|
|
14
|
+
end,
|
|
15
|
+
limit: ->(l) { l.between?(1, 30) },
|
|
16
|
+
type: ->(t) { %w[photo painting].any?(t.downcase) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
def self.validate(params)
|
|
20
|
+
errors = {}
|
|
21
|
+
|
|
22
|
+
VALIDATIONS.each do |k, v|
|
|
23
|
+
next if params[k].nil?
|
|
24
|
+
|
|
25
|
+
next if v.instance_of?(Array) ? call_each(v, params[k]) : v.call(params[k])
|
|
26
|
+
|
|
27
|
+
errors.merge!({ k.to_sym => [params[k], v] })
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if errors.empty?
|
|
31
|
+
true
|
|
32
|
+
else
|
|
33
|
+
report_errors(errors)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.report_errors(errors)
|
|
38
|
+
report = errors.map do |k, v|
|
|
39
|
+
"\n#{k}: #{v[0]}"
|
|
40
|
+
end.join(", ")
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, "expect params to pass validations\n #{report}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.call_each(array, argument)
|
|
46
|
+
array.each do |lambda|
|
|
47
|
+
return false unless lambda.call(argument)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Pastvu
|
|
4
|
+
class Parser
|
|
5
|
+
def self.to_json(hash)
|
|
6
|
+
JSON.dump(hash)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.to_hash(json)
|
|
10
|
+
hash = JSON.parse(json)
|
|
11
|
+
# symbolize_keys hash
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# def self.symbolize_keys(hash)
|
|
15
|
+
# hash.transform_keys do |k|
|
|
16
|
+
# k.to_sym
|
|
17
|
+
# symbolize_keys(hash[k]) if hash[k].instance_of?(Hash)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Pastvu
|
|
2
|
+
class Photo < Model
|
|
3
|
+
# VALID_ATTRIBUTES = %w[cid s file title dir geo year ccount]
|
|
4
|
+
# attr_accessor *VALID_ATTRIBUTES
|
|
5
|
+
|
|
6
|
+
def reload
|
|
7
|
+
Pastvu.photo @cid
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def comments
|
|
11
|
+
Pastvu.comments @cid
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def download(size, path)
|
|
15
|
+
raise ArgumentError, "expect size to be correct symbol" unless %i[original standard thumbnail thumb].include?(size)
|
|
16
|
+
raise ArgumentError, "expect file extension to be .jpeg or .jpg" unless path[-4..-1] == ".jpg" || path[-5..-1] == ".jpeg"
|
|
17
|
+
|
|
18
|
+
Request.download(method(size).call, path)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def standard
|
|
22
|
+
"https://pastvu.com/_p/d/".concat(file)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def original
|
|
26
|
+
"https://pastvu.com/_p/a/".concat(file)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def thumbnail
|
|
30
|
+
"https://pastvu.com/_p/h/".concat(file)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def thumb
|
|
34
|
+
thumbnail
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Pastvu
|
|
2
|
+
class PhotoCollection < Collection
|
|
3
|
+
def initialize(attributes, params = nil)
|
|
4
|
+
super attributes
|
|
5
|
+
@params = params
|
|
6
|
+
@path = %w[result photos]
|
|
7
|
+
@model = Photo
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def next(n_photos = nil)
|
|
11
|
+
raise "next can be used with nearest photos requests only" if @params.nil?
|
|
12
|
+
|
|
13
|
+
n_photos ||= @params[:limit] || 30
|
|
14
|
+
# params get passed to PhotoCollection when it is a request for nearest photos on.y
|
|
15
|
+
raise ArgumentError, "n_photos must be Integer between 1 & 30" unless n_photos.instance_of?(Integer) && n_photos.between?(1, 30)
|
|
16
|
+
|
|
17
|
+
new_skip = {
|
|
18
|
+
skip: (@params[:skip] || 0) + n_photos
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
new_params = @params.merge(new_skip)
|
|
22
|
+
|
|
23
|
+
Pastvu.nearest_photos(geo: @params[:geo], **new_params)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "addressable"
|
|
3
|
+
require "open-uri"
|
|
4
|
+
|
|
5
|
+
module Pastvu
|
|
6
|
+
class Request
|
|
7
|
+
attr_reader :response
|
|
8
|
+
|
|
9
|
+
def initialize(method, params)
|
|
10
|
+
@method = method
|
|
11
|
+
@params = params
|
|
12
|
+
|
|
13
|
+
request(build_uri)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.download(uri, path)
|
|
17
|
+
download = URI.parse(uri).open("User-Agent" => Pastvu.config.user_agent)
|
|
18
|
+
IO.copy_stream(download, path)
|
|
19
|
+
File.new(path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def request(uri)
|
|
23
|
+
Net::HTTP.get_response(uri, "User-Agent" => Pastvu.config.user_agent) do |response|
|
|
24
|
+
@response = response.body
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_uri
|
|
29
|
+
uri = Addressable::URI.new({ scheme: 'https', host: Pastvu.config.host })
|
|
30
|
+
|
|
31
|
+
template = Addressable::Template.new(uri.to_s + "{/path*}" + "{?query*}")
|
|
32
|
+
|
|
33
|
+
template.expand({
|
|
34
|
+
"path" => Pastvu.config.path,
|
|
35
|
+
"query" => {
|
|
36
|
+
"method" => @method,
|
|
37
|
+
"params" => JSON.dump(@params)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/pastvu.rb
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pastvu/version"
|
|
4
|
+
require_relative "pastvu/configuration"
|
|
5
|
+
require_relative "pastvu/request"
|
|
6
|
+
require_relative "pastvu/basic_response"
|
|
7
|
+
require_relative "pastvu/model"
|
|
8
|
+
require_relative "pastvu/collection"
|
|
9
|
+
require_relative "pastvu/parser"
|
|
10
|
+
require_relative "pastvu/params_validator"
|
|
11
|
+
|
|
12
|
+
require_relative "pastvu/response/bounds_response"
|
|
13
|
+
require_relative "pastvu/response/information_response"
|
|
14
|
+
require_relative "pastvu/cluster/cluster_collection"
|
|
15
|
+
require_relative "pastvu/cluster/cluster"
|
|
16
|
+
require_relative "pastvu/comment/comment_collection"
|
|
17
|
+
require_relative "pastvu/comment/comment"
|
|
18
|
+
require_relative "pastvu/photo/photo_collection"
|
|
19
|
+
require_relative "pastvu/photo/photo"
|
|
20
|
+
|
|
21
|
+
require_relative "pastvu/params_validator/type_check"
|
|
22
|
+
require_relative "pastvu/params_validator/value_check"
|
|
23
|
+
|
|
24
|
+
module Pastvu
|
|
25
|
+
METHODS = {
|
|
26
|
+
photo_info: "photo.giveForPage",
|
|
27
|
+
comments: "comment.giveForObj",
|
|
28
|
+
nearest_photos: "photo.giveNearestPhotos",
|
|
29
|
+
by_bounds: "photo.getByBounds"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def self.photo_info(cid)
|
|
33
|
+
params = {
|
|
34
|
+
cid: cid
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
ParamsValidator.validate params
|
|
38
|
+
|
|
39
|
+
InformationResponse.new request(__method__, params)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.photo(cid)
|
|
43
|
+
self.photo_info(cid).to_photo
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.comments(cid)
|
|
47
|
+
params = {
|
|
48
|
+
cid: cid
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ParamsValidator.validate params
|
|
52
|
+
|
|
53
|
+
CommentCollection.new request(__method__, params)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.nearest_photos(geo:, **params)
|
|
57
|
+
params = {
|
|
58
|
+
geo: geo,
|
|
59
|
+
except: params[:except],
|
|
60
|
+
distance: params[:distance],
|
|
61
|
+
year: params[:year],
|
|
62
|
+
year2: params[:year2],
|
|
63
|
+
type: params[:type],
|
|
64
|
+
limit: params[:limit],
|
|
65
|
+
skip: params[:skip]
|
|
66
|
+
}.compact
|
|
67
|
+
|
|
68
|
+
ParamsValidator.validate params
|
|
69
|
+
|
|
70
|
+
PhotoCollection.new request(__method__, params), params
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.by_bounds(geometry:, z:, **params)
|
|
74
|
+
params[:localWork] = true if z >= 17
|
|
75
|
+
|
|
76
|
+
params = {
|
|
77
|
+
geometry: geometry.instance_of?(Hash) ? geometry : Parser.to_hash(geometry),
|
|
78
|
+
z: z,
|
|
79
|
+
isPainting: params[:isPainting] || params[:is_painting],
|
|
80
|
+
year: params[:year],
|
|
81
|
+
year2: params[:year2],
|
|
82
|
+
localWork: params[:localWork] || params[:local_work]
|
|
83
|
+
}.compact
|
|
84
|
+
|
|
85
|
+
ParamsValidator.validate params
|
|
86
|
+
|
|
87
|
+
BoundsResponse.new request(__method__, params)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.request(method, params)
|
|
91
|
+
Request.new(METHODS[method], params).response
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.config
|
|
95
|
+
@config ||= Configuration.new
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.configure(&block)
|
|
99
|
+
config.configure(&block)
|
|
100
|
+
end
|
|
101
|
+
end
|
data/pastvu.gemspec
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/pastvu/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "pastvu"
|
|
7
|
+
spec.version = Pastvu::VERSION
|
|
8
|
+
spec.authors = ["Evgeny Nedoborskiy"]
|
|
9
|
+
spec.email = ["129510705+projecteurlumiere@users.noreply.github.com"]
|
|
10
|
+
spec.homepage = "https://github.com/projecteurlumiere/pastvu"
|
|
11
|
+
spec.summary = "A Ruby wrapper for PastVu API"
|
|
12
|
+
|
|
13
|
+
spec.license = "MIT"
|
|
14
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
15
|
+
|
|
16
|
+
spec.files = Dir.chdir(__dir__) do
|
|
17
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
18
|
+
(File.expand_path(f) == __FILE__) ||
|
|
19
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .rspec .circleci appveyor Gemfile])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
spec.add_dependency "addressable", "~> 2.8"
|
|
24
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
|
25
|
+
spec.add_development_dependency "webmock", "~> 3.19"
|
|
26
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pastvu
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Evgeny Nedoborskiy
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2024-01-12 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: addressable
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.8'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.8'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rspec
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: webmock
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.19'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.19'
|
|
55
|
+
description:
|
|
56
|
+
email:
|
|
57
|
+
- 129510705+projecteurlumiere@users.noreply.github.com
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- CHANGELOG.md
|
|
63
|
+
- LICENSE.txt
|
|
64
|
+
- README.md
|
|
65
|
+
- lib/pastvu.rb
|
|
66
|
+
- lib/pastvu/basic_response.rb
|
|
67
|
+
- lib/pastvu/cluster/cluster.rb
|
|
68
|
+
- lib/pastvu/cluster/cluster_collection.rb
|
|
69
|
+
- lib/pastvu/collection.rb
|
|
70
|
+
- lib/pastvu/comment/comment.rb
|
|
71
|
+
- lib/pastvu/comment/comment_collection.rb
|
|
72
|
+
- lib/pastvu/configuration.rb
|
|
73
|
+
- lib/pastvu/model.rb
|
|
74
|
+
- lib/pastvu/params_validator.rb
|
|
75
|
+
- lib/pastvu/params_validator/type_check.rb
|
|
76
|
+
- lib/pastvu/params_validator/value_check.rb
|
|
77
|
+
- lib/pastvu/parser.rb
|
|
78
|
+
- lib/pastvu/photo/photo.rb
|
|
79
|
+
- lib/pastvu/photo/photo_collection.rb
|
|
80
|
+
- lib/pastvu/request.rb
|
|
81
|
+
- lib/pastvu/response/bounds_response.rb
|
|
82
|
+
- lib/pastvu/response/information_response.rb
|
|
83
|
+
- lib/pastvu/version.rb
|
|
84
|
+
- pastvu.gemspec
|
|
85
|
+
homepage: https://github.com/projecteurlumiere/pastvu
|
|
86
|
+
licenses:
|
|
87
|
+
- MIT
|
|
88
|
+
metadata: {}
|
|
89
|
+
post_install_message:
|
|
90
|
+
rdoc_options: []
|
|
91
|
+
require_paths:
|
|
92
|
+
- lib
|
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: 3.0.0
|
|
98
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
requirements: []
|
|
104
|
+
rubygems_version: 3.4.20
|
|
105
|
+
signing_key:
|
|
106
|
+
specification_version: 4
|
|
107
|
+
summary: A Ruby wrapper for PastVu API
|
|
108
|
+
test_files: []
|