plumb 0.0.14 → 0.0.16
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 +4 -4
- data/.rubocop.yml +7 -1
- data/README.md +73 -12
- data/Rakefile +2 -0
- data/docs/styles.css +540 -0
- data/examples/concurrent_downloads.rb +1 -1
- data/examples/csv_stream.rb +1 -1
- data/lib/plumb/attributes.rb +48 -4
- data/lib/plumb/composable.rb +9 -3
- data/lib/plumb/hash_class.rb +1 -1
- data/lib/plumb/json_schema_visitor.rb +7 -0
- data/lib/plumb/key.rb +15 -13
- data/lib/plumb/pipeline.rb +1 -2
- data/lib/plumb/schema.rb +2 -2
- data/lib/plumb/types.rb +16 -0
- data/lib/plumb/version.rb +1 -1
- metadata +8 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 13814b82e58449726341c0a26a398fee38818f852fe09f19ba7754a99b7c54ce
|
|
4
|
+
data.tar.gz: 2205b2bc5f48b19b417afe8403cdbd7afbcac3689307b6ba69237ad094718815
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 604b2d74cf5a78578471d5e6cf523f956025ea95c2e9b17dec7337cca41de10dc2254bc66b69ea2255ee18059b885ef6bb5bd64ea32a34a763cf095168c80022
|
|
7
|
+
data.tar.gz: 50e065d933d9c3a00ff04a0e3e2375997d43039fc8d5c3ba6c7c1a6dc9455bda9d056bfefb11e81b4c75f0a26c01919d7f18c5b0da6aefa33c1f668ddabbaa29
|
data/.rubocop.yml
CHANGED
data/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
**This library is work in progress!**
|
|
4
4
|
|
|
5
|
-
Composable data validation, coercion and processing in Ruby. Takes over from https://github.com/ismasan/parametric
|
|
5
|
+
Composable data validation, coercion and processing in Ruby. Takes over from [https://github.com/ismasan/parametric](https://github.com/ismasan/parametric)
|
|
6
6
|
|
|
7
|
-
This library takes ideas from the excellent https://dry-rb.org ecosystem, with some of the features offered by Dry-Types, Dry-Schema, Dry-Struct. However, I'm aiming at a subset of the functionality with a (hopefully) smaller API surface and fewer concepts, focusing on lessons learned after using Parametric in production for many years.
|
|
7
|
+
This library takes ideas from the excellent [https://dry-rb.org](https://dry-rb.org) ecosystem, with some of the features offered by Dry-Types, Dry-Schema, Dry-Struct. However, I'm aiming at a subset of the functionality with a (hopefully) smaller API surface and fewer concepts, focusing on lessons learned after using Parametric in production for many years.
|
|
8
8
|
|
|
9
9
|
If you're after raw performance and versatility I strongly recommend you use the Dry gems.
|
|
10
10
|
|
|
@@ -135,7 +135,7 @@ joe = User.parse({ name: 'Joe', email: 'joe@email.com', age: 20}) # returns vali
|
|
|
135
135
|
Users.parse([joe]) # returns valid array of user hashes
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
-
More about [Types::Hash](#typeshash) and [Types::Array](#typesarray). There's also [tuples](#typestuple), [hash maps](#
|
|
138
|
+
More about [Types::Hash](#typeshash) and [Types::Array](#typesarray). There's also [tuples](#typestuple), [hash maps](#maps), [data structs](#typesdata) and [streams](#typesstream), and it's possible to create your own composite types.
|
|
139
139
|
|
|
140
140
|
### Type composition
|
|
141
141
|
|
|
@@ -232,6 +232,7 @@ You can see more use cases in [the examples directory](https://github.com/ismasa
|
|
|
232
232
|
* `Types::Numeric`
|
|
233
233
|
* `Types::String`
|
|
234
234
|
* `Types::Hash`
|
|
235
|
+
* `Types::SymbolizedHash`
|
|
235
236
|
* `Types::UUID::V4`
|
|
236
237
|
* `Types::Email`
|
|
237
238
|
* `Types::Date`
|
|
@@ -328,26 +329,26 @@ type.resolve(['a', 'a', 'b']) # Valid
|
|
|
328
329
|
type.resolve(['a', 'x', 'b']) # Failure
|
|
329
330
|
```
|
|
330
331
|
|
|
331
|
-
### `#
|
|
332
|
+
### `#where`
|
|
332
333
|
|
|
333
|
-
The `#
|
|
334
|
+
The `#where` helper matches attributes of the object with values, using `#===`.
|
|
334
335
|
|
|
335
336
|
```ruby
|
|
336
|
-
LimitedArray = Types::Array[String].
|
|
337
|
-
LimitedString = Types::String.
|
|
338
|
-
LimitedSet = Types::Any[Set].
|
|
337
|
+
LimitedArray = Types::Array[String].where(size: 10)
|
|
338
|
+
LimitedString = Types::String.where(size: 10)
|
|
339
|
+
LimitedSet = Types::Any[Set].where(size: 10)
|
|
339
340
|
```
|
|
340
341
|
|
|
341
342
|
The size is matched via `#===`, so ranges also work.
|
|
342
343
|
|
|
343
344
|
```ruby
|
|
344
|
-
Password = Types::String.
|
|
345
|
+
Password = Types::String.where(bytesize: 10..20)
|
|
345
346
|
```
|
|
346
347
|
|
|
347
348
|
The helper accepts multiple attribute/value pairs
|
|
348
349
|
|
|
349
350
|
```ruby
|
|
350
|
-
JoeBloggs = Types::Any[User].
|
|
351
|
+
JoeBloggs = Types::Any[User].where(first_name: 'Joe', last_name: 'Bloggs')
|
|
351
352
|
```
|
|
352
353
|
|
|
353
354
|
#### `#transform`
|
|
@@ -838,7 +839,19 @@ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
|
|
|
838
839
|
User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
|
|
839
840
|
```
|
|
840
841
|
|
|
841
|
-
###
|
|
842
|
+
### `Types::SymbolizedHash`
|
|
843
|
+
|
|
844
|
+
This type turns a hash's keys into symbols by calling `#to_sym` on them, and returning a new Hash.
|
|
845
|
+
|
|
846
|
+
```ruby
|
|
847
|
+
# Make sure to symbolize keys first
|
|
848
|
+
type = Types::SymbolizedHash > Types::Hash[name: String, age: Integer]
|
|
849
|
+
type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
### maps
|
|
842
855
|
|
|
843
856
|
You can also use Hash syntax to define a hash map with specific types for all keys and values:
|
|
844
857
|
|
|
@@ -1026,7 +1039,7 @@ end
|
|
|
1026
1039
|
|
|
1027
1040
|
### Types::Data
|
|
1028
1041
|
|
|
1029
|
-
`Types::Data` provides a superclass to define **
|
|
1042
|
+
`Types::Data` provides a superclass to define **immutable** structs or value objects with typed / coercible attributes.
|
|
1030
1043
|
|
|
1031
1044
|
#### `[]` Syntax
|
|
1032
1045
|
|
|
@@ -1177,7 +1190,51 @@ Using `attribute?` allows for optional attributes. If the attribute is not prese
|
|
|
1177
1190
|
attribute? :company, Company
|
|
1178
1191
|
```
|
|
1179
1192
|
|
|
1193
|
+
#### Before steps, symbolizing keys
|
|
1194
|
+
|
|
1195
|
+
The optional `.step` helper adds arbitrary Plumb steps to a Data constructor's internal pipeline.
|
|
1196
|
+
|
|
1197
|
+
This pipeline processes input data when initialising a Data instance.
|
|
1198
|
+
|
|
1199
|
+
This example adds the built-in `Types::SymbolizedHash` type to make sure struct inputs are symbolised before processing.
|
|
1200
|
+
|
|
1201
|
+
```ruby
|
|
1202
|
+
class Person < Types::Data
|
|
1203
|
+
step Types::SymbolizedHash
|
|
1204
|
+
|
|
1205
|
+
attribute :name, String
|
|
1206
|
+
attribute :age, Integer
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
# String keys will be symbolised now
|
|
1210
|
+
person = Person.new('name' => 'Joe', 'age' => 40)
|
|
1211
|
+
person.name # 'Joe'
|
|
1212
|
+
person.to_h # => { name: 'Joe', age: 40 }
|
|
1213
|
+
```
|
|
1214
|
+
|
|
1215
|
+
Inline blocks can be registered as steps
|
|
1216
|
+
|
|
1217
|
+
```ruby
|
|
1218
|
+
class Person < Types::Data
|
|
1219
|
+
# upcase all values
|
|
1220
|
+
step do |r|
|
|
1221
|
+
upcased = r.value.transform_values(&:upcase)
|
|
1222
|
+
r.valid upcased
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
attribute :name, String
|
|
1226
|
+
attribute :last_name, String
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
person = Person.new(name: 'joe', last_name: 'bloggs')
|
|
1230
|
+
person.name # => 'JOE'
|
|
1231
|
+
person.last_name # => 'BLOGGS'
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
A Data class steps are inherited to its child classes.
|
|
1235
|
+
|
|
1180
1236
|
#### Inheritance
|
|
1237
|
+
|
|
1181
1238
|
Data structs can inherit from other structs. This is useful for defining a base struct with common attributes.
|
|
1182
1239
|
|
|
1183
1240
|
```ruby
|
|
@@ -1759,3 +1816,7 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/ismasa
|
|
|
1759
1816
|
## License
|
|
1760
1817
|
|
|
1761
1818
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
1819
|
+
|
|
1820
|
+
## Credits
|
|
1821
|
+
|
|
1822
|
+
Created by [Ismael Celis](https://ismaelcelis.com)
|
data/Rakefile
CHANGED
data/docs/styles.css
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/* Reset and Base Styles */
|
|
2
|
+
* {
|
|
3
|
+
margin: 0;
|
|
4
|
+
padding: 0;
|
|
5
|
+
box-sizing: border-box;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
:root {
|
|
9
|
+
--primary-color: #2563eb;
|
|
10
|
+
--primary-dark: #1e40af;
|
|
11
|
+
--secondary-color: #64748b;
|
|
12
|
+
--bg-color: #f8fafc;
|
|
13
|
+
--sidebar-bg: #1e293b;
|
|
14
|
+
--sidebar-text: #cbd5e1;
|
|
15
|
+
--sidebar-hover: #334155;
|
|
16
|
+
--content-bg: #ffffff;
|
|
17
|
+
--text-color: #1e293b;
|
|
18
|
+
--text-light: #64748b;
|
|
19
|
+
--border-color: #e2e8f0;
|
|
20
|
+
--code-bg: #f1f5f9;
|
|
21
|
+
--code-border: #cbd5e1;
|
|
22
|
+
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
|
23
|
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
html {
|
|
27
|
+
scroll-behavior: smooth;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
body {
|
|
31
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
|
32
|
+
line-height: 1.6;
|
|
33
|
+
color: var(--text-color);
|
|
34
|
+
background-color: var(--bg-color);
|
|
35
|
+
padding-top: 60px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Top Menu Bar */
|
|
39
|
+
.top-menu {
|
|
40
|
+
position: fixed;
|
|
41
|
+
top: 0;
|
|
42
|
+
left: 0;
|
|
43
|
+
right: 0;
|
|
44
|
+
height: 60px;
|
|
45
|
+
background-color: var(--content-bg);
|
|
46
|
+
border-bottom: 1px solid var(--border-color);
|
|
47
|
+
box-shadow: var(--shadow);
|
|
48
|
+
z-index: 1000;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.top-menu-content {
|
|
52
|
+
max-width: 100%;
|
|
53
|
+
height: 100%;
|
|
54
|
+
padding: 0 2rem;
|
|
55
|
+
display: flex;
|
|
56
|
+
justify-content: space-between;
|
|
57
|
+
align-items: center;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.top-menu-brand {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: baseline;
|
|
63
|
+
gap: 0.75rem;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.brand-name {
|
|
67
|
+
font-size: 1.5rem;
|
|
68
|
+
font-weight: 700;
|
|
69
|
+
color: var(--text-color);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.brand-tagline {
|
|
73
|
+
font-size: 0.875rem;
|
|
74
|
+
color: var(--text-light);
|
|
75
|
+
font-weight: 400;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.top-menu .github-link {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
gap: 0.5rem;
|
|
82
|
+
padding: 0.5rem 1rem;
|
|
83
|
+
background-color: var(--text-color);
|
|
84
|
+
color: #ffffff;
|
|
85
|
+
border-radius: 6px;
|
|
86
|
+
text-decoration: none;
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
font-size: 0.875rem;
|
|
89
|
+
transition: all 0.2s ease;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.top-menu .github-link:hover {
|
|
93
|
+
background-color: #000000;
|
|
94
|
+
text-decoration: none;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.top-menu .github-link svg {
|
|
98
|
+
width: 20px;
|
|
99
|
+
height: 20px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Layout */
|
|
103
|
+
.container {
|
|
104
|
+
display: flex;
|
|
105
|
+
min-height: calc(100vh - 60px);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Sidebar */
|
|
109
|
+
.sidebar {
|
|
110
|
+
width: 280px;
|
|
111
|
+
background-color: var(--sidebar-bg);
|
|
112
|
+
color: var(--sidebar-text);
|
|
113
|
+
padding: 2rem 0;
|
|
114
|
+
position: fixed;
|
|
115
|
+
top: 60px;
|
|
116
|
+
height: calc(100vh - 60px);
|
|
117
|
+
overflow-y: auto;
|
|
118
|
+
box-shadow: var(--shadow-lg);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.sidebar::-webkit-scrollbar {
|
|
122
|
+
width: 8px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.sidebar::-webkit-scrollbar-track {
|
|
126
|
+
background: var(--sidebar-bg);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.sidebar::-webkit-scrollbar-thumb {
|
|
130
|
+
background: var(--sidebar-hover);
|
|
131
|
+
border-radius: 4px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.logo {
|
|
135
|
+
padding: 0 1.5rem 1.5rem;
|
|
136
|
+
border-bottom: 1px solid var(--sidebar-hover);
|
|
137
|
+
margin-bottom: 1.5rem;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.logo h2 {
|
|
141
|
+
font-size: 1.75rem;
|
|
142
|
+
color: #ffffff;
|
|
143
|
+
margin-bottom: 0.25rem;
|
|
144
|
+
font-weight: 700;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.logo .tagline {
|
|
148
|
+
font-size: 0.875rem;
|
|
149
|
+
color: var(--sidebar-text);
|
|
150
|
+
font-weight: 400;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.nav-menu {
|
|
154
|
+
list-style: none;
|
|
155
|
+
padding-left: 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.nav-menu li {
|
|
159
|
+
margin: 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.nav-menu a {
|
|
163
|
+
display: block;
|
|
164
|
+
padding: 0.625rem 1.5rem;
|
|
165
|
+
color: var(--sidebar-text);
|
|
166
|
+
text-decoration: none;
|
|
167
|
+
transition: all 0.2s ease;
|
|
168
|
+
font-size: 0.9375rem;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.nav-menu li:not(.nav-submenu) > a {
|
|
172
|
+
font-weight: 600;
|
|
173
|
+
margin-top: 0.5rem;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.nav-submenu a {
|
|
177
|
+
padding-left: 2.5rem;
|
|
178
|
+
font-size: 0.875rem;
|
|
179
|
+
color: var(--sidebar-text);
|
|
180
|
+
opacity: 0.9;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.nav-menu a:hover {
|
|
184
|
+
background-color: var(--sidebar-hover);
|
|
185
|
+
color: #ffffff;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.nav-menu a.active {
|
|
189
|
+
background-color: var(--primary-color);
|
|
190
|
+
color: #ffffff;
|
|
191
|
+
border-left: 3px solid #ffffff;
|
|
192
|
+
padding-left: calc(1.5rem - 3px);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.nav-submenu a.active {
|
|
196
|
+
padding-left: calc(2.5rem - 3px);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* Accordion Menus */
|
|
200
|
+
.nav-accordion {
|
|
201
|
+
position: relative;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.accordion-toggle {
|
|
205
|
+
display: flex;
|
|
206
|
+
justify-content: space-between;
|
|
207
|
+
align-items: center;
|
|
208
|
+
cursor: pointer;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.accordion-icon {
|
|
212
|
+
font-size: 0.75rem;
|
|
213
|
+
transition: transform 0.2s ease;
|
|
214
|
+
margin-left: 0.5rem;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.nav-accordion.expanded .accordion-icon {
|
|
218
|
+
transform: rotate(-180deg);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.accordion-content {
|
|
222
|
+
list-style: none;
|
|
223
|
+
padding-left: 0;
|
|
224
|
+
max-height: 0;
|
|
225
|
+
overflow: hidden;
|
|
226
|
+
transition: max-height 0.3s ease;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.nav-accordion.expanded .accordion-content {
|
|
230
|
+
max-height: 500px;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.nav-h4 a {
|
|
234
|
+
padding-left: 3.5rem;
|
|
235
|
+
font-size: 0.8125rem;
|
|
236
|
+
color: var(--sidebar-text);
|
|
237
|
+
opacity: 0.85;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.nav-h4 a.active {
|
|
241
|
+
padding-left: calc(3.5rem - 3px);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* Main Content */
|
|
245
|
+
.content {
|
|
246
|
+
flex: 1;
|
|
247
|
+
margin-left: 280px;
|
|
248
|
+
padding: 3rem;
|
|
249
|
+
max-width: 1200px;
|
|
250
|
+
width: 79vw;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* Page Header */
|
|
254
|
+
.page-header {
|
|
255
|
+
margin-bottom: 3rem;
|
|
256
|
+
padding-bottom: 2rem;
|
|
257
|
+
border-bottom: 2px solid var(--border-color);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.page-header h1 {
|
|
261
|
+
font-size: 3rem;
|
|
262
|
+
font-weight: 800;
|
|
263
|
+
color: var(--text-color);
|
|
264
|
+
margin-bottom: 0.5rem;
|
|
265
|
+
letter-spacing: -0.025em;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.page-header .subtitle {
|
|
269
|
+
font-size: 1.25rem;
|
|
270
|
+
color: var(--text-light);
|
|
271
|
+
font-weight: 400;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* Sections */
|
|
275
|
+
.section {
|
|
276
|
+
margin-bottom: 4rem;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.section h2 {
|
|
280
|
+
font-size: 2rem;
|
|
281
|
+
font-weight: 700;
|
|
282
|
+
color: var(--text-color);
|
|
283
|
+
margin-bottom: 1.5rem;
|
|
284
|
+
padding-bottom: 0.5rem;
|
|
285
|
+
border-bottom: 2px solid var(--primary-color);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.subsection {
|
|
289
|
+
margin-bottom: 2.5rem;
|
|
290
|
+
background-color: var(--content-bg);
|
|
291
|
+
padding: 2rem;
|
|
292
|
+
border-radius: 8px;
|
|
293
|
+
box-shadow: var(--shadow);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.subsection h3 {
|
|
297
|
+
font-size: 1.5rem;
|
|
298
|
+
font-weight: 600;
|
|
299
|
+
color: var(--text-color);
|
|
300
|
+
margin-bottom: 1rem;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.subsection h4 {
|
|
304
|
+
font-size: 1.25rem;
|
|
305
|
+
font-weight: 600;
|
|
306
|
+
color: var(--text-color);
|
|
307
|
+
margin: 1.5rem 0 1rem;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* Typography */
|
|
311
|
+
p {
|
|
312
|
+
margin-bottom: 1rem;
|
|
313
|
+
line-height: 1.75;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
a {
|
|
317
|
+
color: var(--primary-color);
|
|
318
|
+
text-decoration: none;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
a:hover {
|
|
322
|
+
color: var(--primary-dark);
|
|
323
|
+
text-decoration: underline;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
strong {
|
|
327
|
+
font-weight: 600;
|
|
328
|
+
color: var(--text-color);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* Lists */
|
|
332
|
+
ul, ol {
|
|
333
|
+
margin-bottom: 1rem;
|
|
334
|
+
padding-left: 1.5rem;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
li {
|
|
338
|
+
margin-bottom: 0.5rem;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.feature-list {
|
|
342
|
+
list-style: none;
|
|
343
|
+
padding-left: 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.feature-list li {
|
|
347
|
+
padding-left: 1.5rem;
|
|
348
|
+
position: relative;
|
|
349
|
+
margin-bottom: 0.75rem;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.feature-list li::before {
|
|
353
|
+
content: "→";
|
|
354
|
+
position: absolute;
|
|
355
|
+
left: 0;
|
|
356
|
+
color: var(--primary-color);
|
|
357
|
+
font-weight: bold;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/* Code Blocks */
|
|
361
|
+
code {
|
|
362
|
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
|
363
|
+
font-size: 0.875em;
|
|
364
|
+
background-color: var(--code-bg);
|
|
365
|
+
padding: 0.2em 0.4em;
|
|
366
|
+
border-radius: 3px;
|
|
367
|
+
border: 1px solid var(--code-border);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
pre {
|
|
371
|
+
margin: 1.5rem 0;
|
|
372
|
+
padding: 0;
|
|
373
|
+
overflow-x: auto;
|
|
374
|
+
border-radius: 6px;
|
|
375
|
+
box-shadow: var(--shadow);
|
|
376
|
+
background-color: #282c34;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
pre code {
|
|
380
|
+
display: block;
|
|
381
|
+
padding: 1.25rem;
|
|
382
|
+
background-color: transparent;
|
|
383
|
+
color: inherit;
|
|
384
|
+
border: none;
|
|
385
|
+
border-radius: 6px;
|
|
386
|
+
line-height: 1.6;
|
|
387
|
+
overflow-x: auto;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
pre code::-webkit-scrollbar {
|
|
391
|
+
height: 8px;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
pre code::-webkit-scrollbar-track {
|
|
395
|
+
background: var(--sidebar-hover);
|
|
396
|
+
border-radius: 4px;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
pre code::-webkit-scrollbar-thumb {
|
|
400
|
+
background: var(--sidebar-text);
|
|
401
|
+
border-radius: 4px;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* Images */
|
|
405
|
+
.image-container {
|
|
406
|
+
margin: 2rem 0;
|
|
407
|
+
text-align: center;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
.image-container img {
|
|
411
|
+
max-width: 100%;
|
|
412
|
+
height: auto;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.image-container .caption {
|
|
416
|
+
margin-top: 0.75rem;
|
|
417
|
+
font-size: 0.875rem;
|
|
418
|
+
color: var(--text-light);
|
|
419
|
+
font-style: italic;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
@media (max-width: 1248px) {
|
|
423
|
+
.content p img {
|
|
424
|
+
width: 64vw;
|
|
425
|
+
height: auto;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* Responsive Design */
|
|
430
|
+
@media (max-width: 1024px) {
|
|
431
|
+
.sidebar {
|
|
432
|
+
width: 240px;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.content {
|
|
436
|
+
width: 76vw;
|
|
437
|
+
margin-left: 240px;
|
|
438
|
+
padding: 2rem;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
@media (max-width: 768px) {
|
|
443
|
+
/* Stack sidebar and content vertically on handhelds */
|
|
444
|
+
.container {
|
|
445
|
+
flex-direction: column;
|
|
446
|
+
}
|
|
447
|
+
.top-menu-content {
|
|
448
|
+
padding: 0 1rem;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.brand-tagline {
|
|
452
|
+
display: none;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.top-menu .github-link span {
|
|
456
|
+
display: none;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.top-menu .github-link {
|
|
460
|
+
padding: 0.5rem;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.sidebar {
|
|
464
|
+
display:none;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.content {
|
|
468
|
+
margin-left: 0;
|
|
469
|
+
width: 100vw;
|
|
470
|
+
padding: 1.5rem;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.content p img {
|
|
474
|
+
width: 78vw;
|
|
475
|
+
height: auto;
|
|
476
|
+
}
|
|
477
|
+
.page-header h1 {
|
|
478
|
+
font-size: 2rem;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
.page-header .subtitle {
|
|
482
|
+
font-size: 1rem;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.section h2 {
|
|
486
|
+
font-size: 1.5rem;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.subsection {
|
|
490
|
+
padding: 1.5rem;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.subsection h3 {
|
|
494
|
+
font-size: 1.25rem;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
pre code {
|
|
498
|
+
padding: 1rem;
|
|
499
|
+
font-size: 0.8125rem;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/* Smooth Scrolling and Anchor Offset */
|
|
504
|
+
section {
|
|
505
|
+
scroll-margin-top: 5rem;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
article {
|
|
509
|
+
scroll-margin-top: 5rem;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* Print Styles */
|
|
513
|
+
@media print {
|
|
514
|
+
.top-menu {
|
|
515
|
+
display: none;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
body {
|
|
519
|
+
padding-top: 0;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.sidebar {
|
|
523
|
+
display: none;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.content {
|
|
527
|
+
margin-left: 0;
|
|
528
|
+
max-width: 100%;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
.subsection {
|
|
532
|
+
box-shadow: none;
|
|
533
|
+
border: 1px solid var(--border-color);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
pre code {
|
|
537
|
+
background-color: var(--code-bg);
|
|
538
|
+
color: var(--text-color);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
@@ -54,7 +54,7 @@ module Types
|
|
|
54
54
|
image = result.value
|
|
55
55
|
path = path_for(image.url)
|
|
56
56
|
File.open(path, 'wb') { |f| f.write(image.io.read) }
|
|
57
|
-
result.valid image.
|
|
57
|
+
result.valid image.where(url: path, io: File.new(path))
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def path_for(url)
|
data/examples/csv_stream.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Types
|
|
|
22
22
|
.transform(::Enumerator, &:each)
|
|
23
23
|
|
|
24
24
|
# Turn a string file path into a CSV stream
|
|
25
|
-
# ex. csv_enum =
|
|
25
|
+
# ex. csv_enum = StringToCSV.parse('./files/data.csv') #=> Enumerator
|
|
26
26
|
StringToCSV = OpenFile >> FileToCSV
|
|
27
27
|
end
|
|
28
28
|
|
data/lib/plumb/attributes.rb
CHANGED
|
@@ -116,7 +116,7 @@ module Plumb
|
|
|
116
116
|
attr_reader :errors, :attributes
|
|
117
117
|
|
|
118
118
|
def initialize(attrs = {})
|
|
119
|
-
assign_attributes(attrs)
|
|
119
|
+
assign_attributes(self.class._pipeline.parse(attrs))
|
|
120
120
|
freeze
|
|
121
121
|
end
|
|
122
122
|
|
|
@@ -135,8 +135,8 @@ module Plumb
|
|
|
135
135
|
|
|
136
136
|
def inspect
|
|
137
137
|
%(#<#{self.class}:#{object_id} [#{valid? ? 'valid' : 'invalid'}] #{attributes.map do |k, v|
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
[k, v.inspect].join(':')
|
|
139
|
+
end.join(' ')}>)
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
# @return [Hash]
|
|
@@ -175,11 +175,53 @@ module Plumb
|
|
|
175
175
|
def prepare_attributes(attrs) = attrs
|
|
176
176
|
|
|
177
177
|
module ClassMethods
|
|
178
|
+
def _set_pipeline(pl)
|
|
179
|
+
@_pipeline = pl
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def _pipeline
|
|
183
|
+
@_pipeline || Plumb::Types::Any
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Add a step to the processing pipeline that runs before attribute validation.
|
|
187
|
+
# This allows you to transform or validate the input data before it's assigned to attributes.
|
|
188
|
+
#
|
|
189
|
+
# @param st [Plumb::Step, nil] A step object to add to the pipeline
|
|
190
|
+
# @param block [Proc, nil] A block to use as a step (if st is nil)
|
|
191
|
+
# @return [Class] Returns self for method chaining
|
|
192
|
+
#
|
|
193
|
+
# @example Transform input before validation
|
|
194
|
+
# class Person
|
|
195
|
+
# include Plumb::Attributes
|
|
196
|
+
#
|
|
197
|
+
# step { |result| result.valid(result.value.transform_keys(&:to_sym)) }
|
|
198
|
+
# attribute :name, Types::String
|
|
199
|
+
# end
|
|
200
|
+
#
|
|
201
|
+
# @example Add custom validation
|
|
202
|
+
# class Person
|
|
203
|
+
# include Plumb::Attributes
|
|
204
|
+
#
|
|
205
|
+
# step do |result|
|
|
206
|
+
# if result.value[:name].nil?
|
|
207
|
+
# result.invalid(errors: 'Name is required')
|
|
208
|
+
# else
|
|
209
|
+
# result
|
|
210
|
+
# end
|
|
211
|
+
# end
|
|
212
|
+
# attribute :name, Types::String
|
|
213
|
+
# end
|
|
214
|
+
def step(st = nil, &block)
|
|
215
|
+
@_pipeline = _pipeline >> (st || block)
|
|
216
|
+
self
|
|
217
|
+
end
|
|
218
|
+
|
|
178
219
|
def _schema
|
|
179
220
|
@_schema ||= HashClass.new
|
|
180
221
|
end
|
|
181
222
|
|
|
182
223
|
def inherited(subclass)
|
|
224
|
+
subclass._set_pipeline _pipeline
|
|
183
225
|
_schema._schema.each do |key, type|
|
|
184
226
|
subclass.attribute(key, type)
|
|
185
227
|
end
|
|
@@ -220,7 +262,9 @@ module Plumb
|
|
|
220
262
|
# attribute(:friends, [Person])
|
|
221
263
|
#
|
|
222
264
|
def attribute(name, type = Types::Any, writer: false, &block)
|
|
223
|
-
|
|
265
|
+
# Key accepts String or Symbol, with optional '?' suffix for optional keys
|
|
266
|
+
# for Data structs, we always convert to Symbol keys
|
|
267
|
+
key = Key.wrap(name, symbolize: true)
|
|
224
268
|
name = key.to_sym
|
|
225
269
|
type = Composable.wrap(type)
|
|
226
270
|
|
data/lib/plumb/composable.rb
CHANGED
|
@@ -311,16 +311,22 @@ module Plumb
|
|
|
311
311
|
|
|
312
312
|
# Check attributes of an object against values, using #===
|
|
313
313
|
# @example
|
|
314
|
-
# type = Types::Array.
|
|
315
|
-
# type = Types::String.
|
|
314
|
+
# type = Types::Array.where(size: 1..10)
|
|
315
|
+
# type = Types::String.where(bytesize: 1..10)
|
|
316
316
|
#
|
|
317
317
|
# @param attrs [Hash]
|
|
318
|
-
def
|
|
318
|
+
def where(attrs)
|
|
319
319
|
attrs.reduce(self) do |t, (name, value)|
|
|
320
320
|
t >> AttributeValueMatch.new(t, name, value)
|
|
321
321
|
end
|
|
322
322
|
end
|
|
323
323
|
|
|
324
|
+
# @deprecated User {#where} instead
|
|
325
|
+
def with(...)
|
|
326
|
+
warn 'Composable#with() is deprecated. Use #where() instead. #with is reserved to make copies of Data structs'
|
|
327
|
+
where(...)
|
|
328
|
+
end
|
|
329
|
+
|
|
324
330
|
# Register a policy for this step.
|
|
325
331
|
# Mode 1.a: #policy(:name, arg) a single policy with an argument
|
|
326
332
|
# Mode 1.b: #policy(:name) a single policy without an argument
|
data/lib/plumb/hash_class.rb
CHANGED
|
@@ -113,7 +113,7 @@ module Plumb
|
|
|
113
113
|
initial = {}
|
|
114
114
|
initial = initial.merge(input) if @inclusive
|
|
115
115
|
output = _schema.each.with_object(initial) do |(key, field), ret|
|
|
116
|
-
key_s = key.
|
|
116
|
+
key_s = key.to_key
|
|
117
117
|
if input.key?(key_s)
|
|
118
118
|
r = field.call(field_result.reset(input[key_s]))
|
|
119
119
|
errors[key_s] = r.errors unless r.valid?
|
|
@@ -61,6 +61,13 @@ module Plumb
|
|
|
61
61
|
props
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# Trying to visit the deferred could go into infinite recursion
|
|
65
|
+
# if a type is deferring to itself
|
|
66
|
+
# Not clear what deferred types would mean for JSON Schema anyway.
|
|
67
|
+
on(:deferred) do |node, props|
|
|
68
|
+
props
|
|
69
|
+
end
|
|
70
|
+
|
|
64
71
|
on(:hash) do |node, props|
|
|
65
72
|
props.merge(
|
|
66
73
|
TYPE => 'object',
|
data/lib/plumb/key.rb
CHANGED
|
@@ -2,28 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
module Plumb
|
|
4
4
|
class Key
|
|
5
|
-
OPTIONAL_EXP = /(\w+)(\?)?$/
|
|
5
|
+
# OPTIONAL_EXP = /(\w+)(\?)?$/
|
|
6
|
+
OPTIONAL_EXP = /(?<word>[A-Za-z0-9_$]+)(?<qmark>\?)?/
|
|
6
7
|
|
|
7
|
-
def self.wrap(key)
|
|
8
|
-
key.is_a?(Key) ? key : new(key)
|
|
8
|
+
def self.wrap(key, symbolize: false)
|
|
9
|
+
key.is_a?(Key) ? key : new(key, symbolize:)
|
|
9
10
|
end
|
|
10
11
|
|
|
11
|
-
attr_reader :to_sym, :node_name
|
|
12
|
+
attr_reader :to_key, :to_sym, :node_name
|
|
12
13
|
|
|
13
|
-
def initialize(key, optional: false)
|
|
14
|
-
|
|
15
|
-
match = OPTIONAL_EXP.match(
|
|
14
|
+
def initialize(key, optional: false, symbolize: false)
|
|
15
|
+
key_type = symbolize ? Symbol : key.class
|
|
16
|
+
match = OPTIONAL_EXP.match(key.to_s)
|
|
17
|
+
key = match[:word]
|
|
18
|
+
@to_key = key_type == Symbol ? key.to_sym : key
|
|
19
|
+
@to_sym = @to_key.to_sym
|
|
20
|
+
@optional = !match[:qmark].nil? ? true : optional
|
|
16
21
|
@node_name = :key
|
|
17
|
-
@key = match[1]
|
|
18
|
-
@to_sym = @key.to_sym
|
|
19
|
-
@optional = !match[2].nil? ? true : optional
|
|
20
22
|
freeze
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
def to_s = @
|
|
25
|
+
def to_s = @to_key.to_s
|
|
24
26
|
|
|
25
27
|
def hash
|
|
26
|
-
@
|
|
28
|
+
@to_key.hash
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def eql?(other)
|
|
@@ -35,7 +37,7 @@ module Plumb
|
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def inspect
|
|
38
|
-
"#{@
|
|
40
|
+
"#{@to_key}#{'?' if @optional}"
|
|
39
41
|
end
|
|
40
42
|
end
|
|
41
43
|
end
|
data/lib/plumb/pipeline.rb
CHANGED
data/lib/plumb/schema.rb
CHANGED
data/lib/plumb/types.rb
CHANGED
|
@@ -155,6 +155,22 @@ module Plumb
|
|
|
155
155
|
Date = Any[::Date]
|
|
156
156
|
Time = Any[::Time]
|
|
157
157
|
|
|
158
|
+
# A type that recursively converts string keys to symbols in nested hashes.
|
|
159
|
+
# This is commonly used for normalizing payload data in commands and events.
|
|
160
|
+
#
|
|
161
|
+
# @example Simple hash symbolization
|
|
162
|
+
# SymbolizedHash.parse({ 'name' => 'John' }) # => { name: 'John' }
|
|
163
|
+
# @example Nested hash symbolization
|
|
164
|
+
# SymbolizedHash.parse({ 'user' => { 'name' => 'John' } }) # => { user: { name: 'John' } }
|
|
165
|
+
# @example Mixed types preserved
|
|
166
|
+
# SymbolizedHash.parse({ 'count' => 1, 'active' => true }) # => { count: 1, active: true }
|
|
167
|
+
SymbolizedHash = Hash[
|
|
168
|
+
# String keys are converted to symbols, existing symbols are preserved
|
|
169
|
+
(Symbol | String.transform(::Symbol, &:to_sym)),
|
|
170
|
+
# Hash values are recursively symbolized, other types pass through unchanged
|
|
171
|
+
Any.defer { SymbolizedHash } | Any
|
|
172
|
+
]
|
|
173
|
+
|
|
158
174
|
module UUID
|
|
159
175
|
V4 = String[/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i].as_node(:uuid)
|
|
160
176
|
end
|
data/lib/plumb/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: plumb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.16
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ismael Celis
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: bigdecimal
|
|
@@ -38,7 +37,7 @@ dependencies:
|
|
|
38
37
|
- - ">="
|
|
39
38
|
- !ruby/object:Gem::Version
|
|
40
39
|
version: '0'
|
|
41
|
-
description: Data validation and
|
|
40
|
+
description: Data structures, validation, coercion and processing toolkit for Ruby
|
|
42
41
|
email:
|
|
43
42
|
- ismaelct@gmail.com
|
|
44
43
|
executables: []
|
|
@@ -54,6 +53,7 @@ files:
|
|
|
54
53
|
- bench/compare_parametric_struct.rb
|
|
55
54
|
- bench/parametric_schema.rb
|
|
56
55
|
- bench/plumb_hash.rb
|
|
56
|
+
- docs/styles.css
|
|
57
57
|
- examples/command_objects.rb
|
|
58
58
|
- examples/concurrent_downloads.rb
|
|
59
59
|
- examples/csv_stream.rb
|
|
@@ -97,11 +97,11 @@ files:
|
|
|
97
97
|
- lib/plumb/value_class.rb
|
|
98
98
|
- lib/plumb/version.rb
|
|
99
99
|
- lib/plumb/visitor_handlers.rb
|
|
100
|
-
homepage: https://github.
|
|
100
|
+
homepage: https://ismasan.github.io/plumb
|
|
101
101
|
licenses:
|
|
102
102
|
- MIT
|
|
103
|
-
metadata:
|
|
104
|
-
|
|
103
|
+
metadata:
|
|
104
|
+
source_code_uri: https://github.com/ismasan/plumb
|
|
105
105
|
rdoc_options: []
|
|
106
106
|
require_paths:
|
|
107
107
|
- lib
|
|
@@ -116,8 +116,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
116
116
|
- !ruby/object:Gem::Version
|
|
117
117
|
version: '0'
|
|
118
118
|
requirements: []
|
|
119
|
-
rubygems_version: 3.
|
|
120
|
-
signing_key:
|
|
119
|
+
rubygems_version: 3.6.9
|
|
121
120
|
specification_version: 4
|
|
122
121
|
summary: Data validation and transformation library.
|
|
123
122
|
test_files: []
|