slurm_hostlist 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 995f5429991f0a2fa26b5f7810da33de0ff8f6df6ed6f041a7e340f119248fd0
4
+ data.tar.gz: 716770221f07852d043f8d3dae881fd0514fd9d202ef3f0387e0efa6e850a818
5
+ SHA512:
6
+ metadata.gz: f6f7f62383ed60a557b5562f774addc72393b6cb518d1ee38e8bf53f4b7110e411180b8b5a75fed62c680a2377c3b935ba2995b7974db286bb796a438c600c99
7
+ data.tar.gz: 86d9552ef5c05c3de523efd5406845d477f582b2df3c5fc763553434009a24cf53b747249cd53aeba198e17539eb3139c7920b0ca81f778425e47292829cabac
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - First release: `expand`, `compress` (with `sort:`), `count` and `valid?`,
6
+ plus a `slurm-hostlist` command. Differentially tested against Slurm's
7
+ `hostlist.c`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ohio Supercomputer Center
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,152 @@
1
+ # slurm_hostlist
2
+
3
+ Expand and compress [Slurm](https://slurm.schedmd.com/) hostlist expressions
4
+ in Ruby, getting the same answers Slurm does.
5
+
6
+ ```ruby
7
+ require 'slurm_hostlist'
8
+
9
+ SlurmHostlist.expand('n[01-03],login1')
10
+ # => ["n01", "n02", "n03", "login1"]
11
+
12
+ SlurmHostlist.compress(%w[n01 n02 n03 n07 login1])
13
+ # => "n[01-03,07],login1"
14
+ ```
15
+
16
+ No dependencies. It's a port of Slurm's own `src/common/hostlist.c`,
17
+ differentially tested against it, so edge cases that break a quick regex
18
+ work the way Slurm handles them: suffixes after brackets (`node[1-4]-ib`),
19
+ several bracket groups (`rack[1-2]-n[01-16]`), and mixed zero-padding.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ gem install slurm_hostlist
25
+ ```
26
+
27
+ or in a Gemfile:
28
+
29
+ ```ruby
30
+ gem 'slurm_hostlist'
31
+ ```
32
+
33
+ Ruby 2.7 or newer.
34
+
35
+ ## Usage
36
+
37
+ | Call | Same as |
38
+ |---|---|
39
+ | `SlurmHostlist.expand(expression)` → `Array<String>` | `scontrol show hostnames` |
40
+ | `SlurmHostlist.compress(hosts)` → `String` | `scontrol show hostlist` |
41
+ | `SlurmHostlist.compress(hosts, sort: true)` → `String` | `scontrol show hostlistsorted` |
42
+ | `SlurmHostlist.count(expression)` → `Integer` | number of hosts, duplicates included |
43
+ | `SlurmHostlist.valid?(expression)` → `true`/`false` | whether Slurm accepts it |
44
+
45
+ `compress` takes an array of host names or an expression string. Input
46
+ Slurm would reject (`n[3-1]`, `n[1`, `n[]`) raises
47
+ `SlurmHostlist::InvalidHostlist`, a subclass of `ArgumentError`.
48
+
49
+ ```ruby
50
+ SlurmHostlist.expand('rack[1-2]-n[1-2]')
51
+ # => ["rack1-n1", "rack1-n2", "rack2-n1", "rack2-n2"]
52
+
53
+ SlurmHostlist.compress(%w[n3 n1 n2]) # => "n[3,1-2]" (order kept)
54
+ SlurmHostlist.compress(%w[n3 n1 n2], sort: true) # => "n[1-3]"
55
+
56
+ SlurmHostlist.expand(ENV.fetch('SLURM_JOB_NODELIST'))
57
+ ```
58
+
59
+ A command is included too:
60
+
61
+ ```sh
62
+ $ slurm-hostlist expand 'n[01-03]'
63
+ n01
64
+ n02
65
+ n03
66
+ $ slurm-hostlist compress n01 n02 n03
67
+ n[01-03]
68
+ $ squeue -h -o %N | slurm-hostlist compress --sort
69
+ ```
70
+
71
+ ## Behaviour worth knowing
72
+
73
+ These all match Slurm:
74
+
75
+ - **Order and duplicates are kept.** `expand('n[1-3],n2')` gives
76
+ `n1 n2 n3 n2`. Without `sort: true`, `compress` only joins a host onto the
77
+ range just before it, so `%w[n3 n1 n2]` gives `n[3,1-2]`.
78
+ - **Zero-padding comes from the low number.** `n[01-3]` gives
79
+ `n01 n02 n03`; `n[1-02]` gives `n1 n2`.
80
+ - **Padding that can't be shared stays apart.** `%w[n01 n1 n2]` compresses to
81
+ `n[01,1-2]`, but `%w[n9 n10]` to `n[9-10]`.
82
+ - **Hosts from a bracket with a suffix aren't re-bracketed.** Compressing
83
+ `node[1-2]-ib` gives `node1-ib,node2-ib`.
84
+ - **With three or more bracket groups, Slurm's order isn't left-to-right:**
85
+ in `a[1-2]b[3-4]c[5-6]` the *middle* group changes slowest
86
+ (`a1b3c5 a1b3c6 a2b3c5 a2b3c6 a1b4c5 ...`).
87
+ - **Numbers are read with C's `strtoul`**, quirks included: `n[+3]` and
88
+ `n[ 3]` give `n03` (width comes from the text as written), and `n[5-]`
89
+ gives `n5`.
90
+
91
+ ## How it's checked against Slurm
92
+
93
+ - **Differential testing.** `script/differential` generates random
94
+ expressions, realistic ones and deliberately odd ones, and compares this
95
+ gem with Slurm on all three operations. Before the first release it ran
96
+ 300,000 cases against Slurm's `hostlist.c` (Slurm `master`, built the way
97
+ production Slurm is, with asserts off) with no differences.
98
+ `script/oracle/build.sh` rebuilds that oracle from Slurm's source (it needs
99
+ only git and gcc), and
100
+ `script/differential --oracle script/oracle/build/hostlist_oracle` runs the
101
+ comparison.
102
+ - **Recorded cases in CI.** `test/fixtures/slurm_cases.jsonl` holds about
103
+ 3,000 cases and Slurm's answers, recorded with `script/record_fixtures`, and
104
+ the test suite replays them, so CI checks against Slurm without Slurm
105
+ installed.
106
+ - **Check against your own Slurm.** On any machine with `scontrol`:
107
+
108
+ ```sh
109
+ script/differential --scontrol --cases 1000
110
+ ```
111
+
112
+ ## Known differences and limits
113
+
114
+ - **Host numbers of 18446744073709551615 (2⁶⁴ − 1) or more are rejected.**
115
+ Current Slurm rejects them too, but released versions before SchedMD
116
+ commit `0266d18d3e` (September 2026) accept them: the number silently
117
+ becomes 18446744073709551615 (a different host), and a range ending there
118
+ can run the parser out of memory. Against an older `scontrol`,
119
+ `script/differential` counts these separately. Real clusters don't number
120
+ hosts anywhere near this.
121
+ - **Sorted output in cases where Slurm itself misbehaves.** For some inputs,
122
+ Slurm's `hostlistsorted` code reads memory it has already freed, so its
123
+ output depends on how Slurm was compiled. For
124
+ `rack[17-21,18446744073709551614]`, one build of Slurm's code returned
125
+ `rack[18446744073709551614]` (five hosts dropped) and another returned
126
+ something else. There's nothing reliable to match there. It takes unusual input, such as host
127
+ numbers 2³¹ or more apart, or mixed padding widths that overlap, and
128
+ `Hostlist#diverged_from_slurm?` reports when it happens. `expand` and
129
+ unsorted `compress` never go down that path.
130
+ - **`{...}` hostlist functions** are expanded by slurmctld, not by the
131
+ hostlist code, so they're treated as literal text, as Slurm does when no
132
+ controller is reachable.
133
+ - **Multi-dimensional (3D/4D) node naming**, from Slurm builds configured
134
+ for more than one dimension, isn't supported. This covers ordinary
135
+ one-dimensional clusters, which is what current Slurm installs use.
136
+
137
+ ## Development
138
+
139
+ ```sh
140
+ bundle install
141
+ bundle exec rake test
142
+ ```
143
+
144
+ To re-record the fixture from a local Slurm:
145
+
146
+ ```sh
147
+ script/record_fixtures --scontrol
148
+ ```
149
+
150
+ ## License
151
+
152
+ MIT. Copyright Ohio Supercomputer Center.
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # slurm-hostlist expand 'n[01-03],login1' one host per line
5
+ # slurm-hostlist compress n01 n02 n03 n[01-03]
6
+ # slurm-hostlist compress --sort < hosts.txt hosts from stdin, sorted
7
+ # slurm-hostlist count 'n[1-100]' 100
8
+
9
+ require 'slurm_hostlist'
10
+
11
+ USAGE = <<~TEXT
12
+ usage: slurm-hostlist expand EXPRESSION
13
+ slurm-hostlist compress [--sort] [HOST ...] (reads stdin if no hosts given)
14
+ slurm-hostlist count EXPRESSION
15
+ TEXT
16
+
17
+ command = ARGV.shift
18
+ begin
19
+ case command
20
+ when 'expand'
21
+ abort USAGE if ARGV.length != 1
22
+ puts SlurmHostlist.expand(ARGV.first)
23
+ when 'compress'
24
+ sort = ARGV.delete('--sort') ? true : false
25
+ hosts = ARGV.empty? ? $stdin.read.split : ARGV
26
+ puts SlurmHostlist.compress(hosts, sort: sort)
27
+ when 'count'
28
+ abort USAGE if ARGV.length != 1
29
+ puts SlurmHostlist.count(ARGV.first)
30
+ when '-v', '--version'
31
+ puts SlurmHostlist::VERSION
32
+ else
33
+ abort USAGE
34
+ end
35
+ rescue SlurmHostlist::InvalidHostlist => e
36
+ abort "slurm-hostlist: #{e.message}"
37
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlurmHostlist
4
+ # One run of hosts sharing a prefix and a zero-padding width, e.g. the
5
+ # "n[01-04]" in "n[01-04],login1". A host with no trailing number (or one
6
+ # expanded from a bracket followed by a suffix, like "node1-ib") is stored
7
+ # as a "single" host whose prefix is the whole name.
8
+ #
9
+ # Mirrors Slurm's hostrange_t. The fields are mutable on purpose: Slurm
10
+ # adjusts widths in place when it decides two ranges can merge, and the
11
+ # output depends on that.
12
+ class HostRange
13
+ attr_accessor :prefix, :lo, :hi, :width, :single
14
+
15
+ def self.single(name)
16
+ new(name, 0, 0, 0, true)
17
+ end
18
+
19
+ def initialize(prefix, lo, hi, width, single = false)
20
+ @prefix = prefix
21
+ @lo = lo
22
+ @hi = hi
23
+ @width = width
24
+ @single = single
25
+ end
26
+
27
+ def count
28
+ return 1 if single
29
+
30
+ hi - lo + 1
31
+ end
32
+
33
+ # The host names in this range, in order.
34
+ def hosts
35
+ return [prefix] if single
36
+
37
+ (lo..hi).map { |n| prefix + SlurmHostlist.pad(n, width) }
38
+ end
39
+
40
+ # The numeric part as written inside brackets: "01" or "01-04".
41
+ # Slurm's hostrange_numstr().
42
+ def numstr
43
+ return '' if single
44
+
45
+ text = SlurmHostlist.pad(lo, width)
46
+ text += "-#{SlurmHostlist.pad(hi, width)}" if lo < hi
47
+ text
48
+ end
49
+
50
+ def copy
51
+ return HostRange.single(prefix) if single
52
+
53
+ HostRange.new(prefix, lo, hi, width)
54
+ end
55
+ end
56
+
57
+ # Slurm's "%0*lu": the number, left-padded with zeros to width.
58
+ def self.pad(number, width)
59
+ number.to_s.rjust(width, '0')
60
+ end
61
+ end
@@ -0,0 +1,517 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlurmHostlist
4
+ # A parsed hostlist: an ordered list of HostRange objects.
5
+ #
6
+ # This is a port of the parts of Slurm's src/common/hostlist.c that back
7
+ # `scontrol show hostnames`, `show hostlist` and `show hostlistsorted`.
8
+ # Method comments name the C function each one follows, so behaviour can be
9
+ # checked against Slurm line by line.
10
+ class Hostlist
11
+ ULONG_MAX = (2**64) - 1
12
+ MAX_RANGE = 64 * 1024 # hosts in one bracket range (MAX_SLURM_NODES)
13
+ MAX_PREFIX_CNT = 64 * 1024 # hosts from brackets earlier in a name
14
+ MAX_RANGES = 256 * 1024 # comma-separated ranges in one bracket
15
+ # Binary, like the text they're compared against (parsing works on bytes).
16
+ SEPARATORS = "\t, \n".b.freeze
17
+ SPACES = " \t\n\v\f\r".b.freeze
18
+
19
+ attr_reader :ranges
20
+
21
+ def initialize(ranges = [])
22
+ @ranges = ranges
23
+ @diverged_from_slurm = false
24
+ end
25
+
26
+ # True if sort! went down a path where Slurm reads freed memory, so
27
+ # Slurm's own output can't be reproduced (see #coalesce).
28
+ def diverged_from_slurm?
29
+ @diverged_from_slurm
30
+ end
31
+
32
+ # Parse a hostlist expression. Raises InvalidHostlist when Slurm would
33
+ # reject it. Slurm's _hostlist_create_bracketed().
34
+ def self.parse(expression)
35
+ text = expression.to_s.b
36
+ # C strings end at the first NUL.
37
+ nul = text.index("\0")
38
+ text = text[0...nul] if nul
39
+
40
+ list = new
41
+ tokens(text).each do |token|
42
+ ok = list.push_token(token)
43
+ raise InvalidHostlist, "invalid hostlist: #{expression}" unless ok
44
+ end
45
+ list
46
+ end
47
+
48
+ # Split on separators, keeping bracketed parts together:
49
+ # "a[1,2],b" is two tokens, "a[1,2]" and "b". Slurm's _next_tok().
50
+ def self.tokens(text)
51
+ tokens = []
52
+ length = text.length
53
+ pos = 0
54
+
55
+ loop do
56
+ pos += 1 while pos < length && separator?(text[pos])
57
+ break if pos >= length
58
+
59
+ start = pos
60
+ parse = pos
61
+ pos = scan_token_end(text, pos, parse)
62
+ tokens << text[start...pos]
63
+ pos += 1 while pos < length && separator?(text[pos])
64
+ end
65
+
66
+ tokens
67
+ end
68
+
69
+ # Find where the token starting at pos ends. A separator inside a
70
+ # [...] pair doesn't end the token.
71
+ def self.scan_token_end(text, pos, parse)
72
+ length = text.length
73
+ loop do
74
+ pos += 1 while pos < length && !separator?(text[pos])
75
+
76
+ extended = false
77
+ loop do
78
+ open = text.index('[', parse)
79
+ return pos if open.nil? || open >= pos
80
+
81
+ close = text.index(']', parse)
82
+ return pos if close.nil? || close < open
83
+
84
+ if close < pos
85
+ parse = close + 1
86
+ else
87
+ pos = close
88
+ extended = true
89
+ break
90
+ end
91
+ end
92
+ return pos unless extended
93
+ end
94
+ end
95
+
96
+ def self.separator?(char)
97
+ SEPARATORS.include?(char)
98
+ end
99
+
100
+ # C's strtoul(), base 10. Returns [value, index after the number], with
101
+ # index 0 when nothing was converted. Skips leading whitespace, takes an
102
+ # optional sign, and clamps overflow to ULONG_MAX, as glibc does.
103
+ def self.strtoul(text)
104
+ i = 0
105
+ i += 1 while i < text.length && SPACES.include?(text[i])
106
+
107
+ negative = false
108
+ if text[i] == '+' || text[i] == '-'
109
+ negative = (text[i] == '-')
110
+ i += 1
111
+ end
112
+
113
+ digits_start = i
114
+ i += 1 while i < text.length && text[i] >= '0' && text[i] <= '9'
115
+ return [0, 0] if i == digits_start
116
+
117
+ magnitude = text[digits_start...i].to_i
118
+ value = if magnitude > ULONG_MAX
119
+ ULONG_MAX
120
+ elsif negative
121
+ (ULONG_MAX + 1 - magnitude) % (ULONG_MAX + 1)
122
+ else
123
+ magnitude
124
+ end
125
+ [value, i]
126
+ end
127
+
128
+ # One entry inside brackets: "7", "01-16". Returns [lo, hi, width] or nil.
129
+ # The width is the length of the low number as written.
130
+ # Slurm's _parse_single_range().
131
+ def self.parse_single_range(text)
132
+ return nil if text.include?('x') # "boxes" are for multi-dimensional systems
133
+
134
+ dash = text.index('-')
135
+ if dash
136
+ lo_text = text[0...dash]
137
+ hi_text = text[(dash + 1)..]
138
+ return nil if hi_text.start_with?('-') # no negative numbers
139
+ else
140
+ lo_text = text
141
+ hi_text = nil
142
+ end
143
+
144
+ width = lo_text.length
145
+ lo, lo_end = strtoul(lo_text)
146
+ return nil if lo_end.zero?
147
+
148
+ if hi_text && !hi_text.empty?
149
+ hi, hi_end = strtoul(hi_text)
150
+ return nil if hi_end.zero? || hi_end != hi_text.length
151
+ else
152
+ hi = lo
153
+ return nil if lo_end != lo_text.length
154
+ end
155
+
156
+ return nil if hi == ULONG_MAX
157
+ return nil if lo > hi
158
+ return nil if hi - lo + 1 > MAX_RANGE
159
+
160
+ [lo, hi, width]
161
+ end
162
+
163
+ # "1-3,7,10-12" -> [[1, 3, 1], [7, 7, 1], [10, 12, 2]], or nil if any
164
+ # entry is invalid. Slurm's _parse_range_list().
165
+ def self.parse_range_list(text)
166
+ entries = text.empty? ? [''] : text.split(',', -1)
167
+ raise InvalidHostlist, 'too many ranges' if entries.length > MAX_RANGES
168
+
169
+ ranges = []
170
+ entries.each do |entry|
171
+ range = parse_single_range(entry)
172
+ return nil if range.nil?
173
+
174
+ ranges << range
175
+ end
176
+ ranges
177
+ end
178
+
179
+ # Add one token: a plain host name or a bracketed expression.
180
+ # Returns false if Slurm would reject it.
181
+ def push_token(token)
182
+ open = token.rindex('[')
183
+ return push_host(token) if open.nil?
184
+
185
+ prefix = token[0...open]
186
+ rest = token[(open + 1)..]
187
+ close = rest.index(']')
188
+ return false if close.nil?
189
+
190
+ after = rest[(close + 1)..]
191
+ suffix = after.empty? || after.start_with?(',') ? nil : after
192
+
193
+ ranges = self.class.parse_range_list(rest[0...close])
194
+ return false if ranges.nil?
195
+
196
+ push_range_list(prefix, suffix, ranges) == 0
197
+ end
198
+
199
+ # Expand any brackets left in the prefix ("rack[1-2]-n" in
200
+ # "rack[1-2]-n[1-4]"), then push the ranges for each resulting prefix.
201
+ # Returns 0 or -1. Slurm's _push_range_list().
202
+ def push_range_list(prefix, suffix, ranges)
203
+ open = prefix.rindex('[')
204
+ close = prefix.rindex(']')
205
+ unless open && close && close > open
206
+ ranges.each { |lo, hi, width| push_hr(prefix, suffix, lo, hi, width) }
207
+ return 0
208
+ end
209
+
210
+ head = prefix[0...open]
211
+ tail = prefix[(close + 1)..]
212
+ recurse = head.include?('[')
213
+
214
+ prefix_ranges = self.class.parse_range_list(prefix[(open + 1)...close])
215
+ return -1 if prefix_ranges.nil?
216
+
217
+ prefix_count = 0
218
+ prefix_ranges.each do |lo, hi, width|
219
+ prefix_count += hi - lo + 1
220
+ return -1 if prefix_count > MAX_PREFIX_CNT
221
+
222
+ (lo..hi).each do |n|
223
+ new_prefix = head + SlurmHostlist.pad(n, width) + tail
224
+ if recurse
225
+ # Slurm keeps MAX(rc, rc1) starting from 0, so a failure in a
226
+ # deeper bracket is ignored rather than failing the whole list.
227
+ push_range_list(new_prefix, suffix, ranges)
228
+ else
229
+ ranges.each { |r_lo, r_hi, r_width| push_hr(new_prefix, suffix, r_lo, r_hi, r_width) }
230
+ end
231
+ end
232
+ end
233
+ 0
234
+ end
235
+
236
+ # Push a range. With a suffix ("node[1-2]-ib"), each host is pushed as a
237
+ # whole name, so it is never re-bracketed. Slurm's hostlist_push_hr().
238
+ def push_hr(prefix, suffix, lo, hi, width)
239
+ if suffix
240
+ (lo..hi).each do |n|
241
+ push_range(HostRange.single(prefix + SlurmHostlist.pad(n, width) + suffix))
242
+ end
243
+ else
244
+ push_range(HostRange.new(prefix, lo, hi, width))
245
+ end
246
+ end
247
+
248
+ # Push a plain host name, splitting off any trailing number.
249
+ # Slurm's hostlist_push_host_dims() and _parse_hostname_len().
250
+ def push_host(name)
251
+ idx = name.length - 1
252
+ idx -= 1 while idx >= 0 && name[idx] >= '0' && name[idx] <= '9'
253
+ prefix_length = idx + 1
254
+
255
+ if prefix_length == name.length
256
+ push_range(HostRange.single(name))
257
+ return true
258
+ end
259
+
260
+ number = name[prefix_length..].to_i
261
+ return false if number >= ULONG_MAX
262
+
263
+ width = name.length - prefix_length
264
+ push_range(HostRange.new(name[0...prefix_length], number, number, width))
265
+ true
266
+ end
267
+
268
+ # Append a range, merging it into the last one when it continues it.
269
+ # Only the last range is considered, so input order matters.
270
+ # Slurm's hostlist_push_range().
271
+ def push_range(range)
272
+ last = @ranges.last
273
+ if last &&
274
+ last.hi == range.lo - 1 &&
275
+ self.class.prefix_cmp(last, range).zero? &&
276
+ self.class.width_combine(last, range)
277
+ last.hi = range.hi
278
+ else
279
+ @ranges << range.copy
280
+ end
281
+ end
282
+
283
+ def hosts
284
+ @ranges.flat_map(&:hosts)
285
+ end
286
+
287
+ # The compressed form, e.g. "n[1-3,5],login1".
288
+ # Slurm's hostlist_ranged_string() and _get_bracketed_list().
289
+ def to_s
290
+ parts = []
291
+ i = 0
292
+ while i < @ranges.length
293
+ part, i = bracketed_list(i)
294
+ parts << part
295
+ end
296
+ parts.join(',')
297
+ end
298
+
299
+ def bracketed_list(start)
300
+ first = @ranges[start]
301
+ following = @ranges[start + 1]
302
+ needs_brackets = first.count > 1 || self.class.within_range(first, following)
303
+
304
+ text = first.prefix.dup
305
+ text << '[' if needs_brackets
306
+
307
+ i = start
308
+ loop do
309
+ text << ',' if i > start
310
+ text << @ranges[i].numstr
311
+ i += 1
312
+ break unless i < @ranges.length && self.class.within_range(@ranges[i], @ranges[i - 1])
313
+ end
314
+
315
+ text << ']' if needs_brackets
316
+ [text, i]
317
+ end
318
+
319
+ # --- sorting (scontrol show hostlistsorted) --------------------------------
320
+
321
+ # Sort, then merge overlapping and adjacent ranges. Duplicates are kept.
322
+ # Slurm's hostlist_sort().
323
+ def sort!
324
+ return self if @ranges.length <= 1
325
+
326
+ @ranges = self.class.merge_sort(@ranges)
327
+ coalesce
328
+ collapse
329
+ self
330
+ end
331
+
332
+ # glibc's qsort() is a top-down merge sort (msort.c). The comparison can
333
+ # change widths as a side effect, so the order comparisons happen in
334
+ # matters; this follows msort's exactly.
335
+ def self.merge_sort(items)
336
+ return items.dup if items.length <= 1
337
+
338
+ left_size = items.length / 2
339
+ left = merge_sort(items[0...left_size])
340
+ right = merge_sort(items[left_size..])
341
+
342
+ merged = []
343
+ li = 0
344
+ ri = 0
345
+ while li < left.length && ri < right.length
346
+ if range_cmp(left[li], right[ri]) <= 0
347
+ merged << left[li]
348
+ li += 1
349
+ else
350
+ merged << right[ri]
351
+ ri += 1
352
+ end
353
+ end
354
+ merged.concat(left[li..]) if li < left.length
355
+ merged.concat(right[ri..]) if ri < right.length
356
+ merged
357
+ end
358
+
359
+ # Order by prefix (natural order), then by first number, or by width when
360
+ # widths can't be combined. Slurm's hostrange_cmp(); the result is a C
361
+ # int, so a large difference is truncated to 32 bits as it is there.
362
+ def self.range_cmp(first, second)
363
+ result = prefix_cmp(first, second)
364
+ return result unless result.zero?
365
+
366
+ if width_combine(first, second)
367
+ c_int(first.lo - second.lo)
368
+ else
369
+ first.width - second.width
370
+ end
371
+ end
372
+
373
+ def self.c_int(value)
374
+ low = value & 0xFFFFFFFF
375
+ low >= 2**31 ? low - 2**32 : low
376
+ end
377
+
378
+ # Split overlapping neighbours so no host appears in two ranges, keeping
379
+ # the duplicates as their own one-host ranges. Slurm's hostlist_coalesce().
380
+ def coalesce
381
+ i = @ranges.length - 1
382
+ while i > 0
383
+ overlap = self.class.intersect(@ranges[i - 1], @ranges[i])
384
+ if overlap
385
+ previous = @ranges[i - 1]
386
+ following = @ranges[i]
387
+ insert_at = i
388
+
389
+ following.hi = previous.hi if overlap.hi < previous.hi
390
+ previous.hi = overlap.lo
391
+ following.lo = overlap.hi
392
+
393
+ # Slurm removes index i here (the following range, not the empty
394
+ # one) and then keeps reading the range it just freed, so what
395
+ # Slurm outputs from this point on is undefined; it can drop hosts.
396
+ # Flag it so callers and the differential test can tell.
397
+ if previous.hi < previous.lo
398
+ @diverged_from_slurm = true
399
+ delete_range(i)
400
+ end
401
+
402
+ while overlap.lo <= overlap.hi
403
+ one = HostRange.new(overlap.prefix, overlap.lo, overlap.lo, overlap.width)
404
+ # Slurm's j++ advances even when the insert is skipped.
405
+ if overlap.lo > previous.hi
406
+ insert_range(one, insert_at)
407
+ insert_at += 1
408
+ end
409
+ if overlap.lo < following.lo
410
+ insert_range(one, insert_at)
411
+ insert_at += 1
412
+ end
413
+ overlap.lo += 1
414
+ end
415
+ i = @ranges.length
416
+ end
417
+ i -= 1
418
+ end
419
+ end
420
+
421
+ # Join neighbours that continue each other. Slurm's hostlist_collapse().
422
+ def collapse
423
+ i = @ranges.length - 1
424
+ while i > 0
425
+ previous = @ranges[i - 1]
426
+ following = @ranges[i]
427
+ if previous.hi == following.lo - 1 &&
428
+ self.class.prefix_cmp(previous, following).zero? &&
429
+ self.class.width_combine(previous, following)
430
+ previous.hi = following.hi
431
+ delete_range(i)
432
+ end
433
+ i -= 1
434
+ end
435
+ end
436
+
437
+ # The hosts two ranges share, as a new range, or nil.
438
+ # Slurm's hostrange_intersect().
439
+ def self.intersect(first, second)
440
+ return nil if first.single || second.single
441
+ return nil unless first.hi > second.lo
442
+ return nil unless prefix_cmp(first, second).zero?
443
+ return nil unless width_combine(first, second)
444
+
445
+ overlap = first.copy
446
+ overlap.lo = second.lo
447
+ overlap.hi = [second.hi, first.hi].min
448
+ overlap
449
+ end
450
+
451
+ # Slurm's hostlist_insert_range(): a copy goes in; nothing happens if the
452
+ # position is past the end.
453
+ def insert_range(range, position)
454
+ return false if position > @ranges.length
455
+
456
+ @ranges.insert(position, range.copy)
457
+ true
458
+ end
459
+
460
+ def delete_range(position)
461
+ @ranges.delete_at(position)
462
+ end
463
+
464
+ # --- comparisons ---------------------------------------------------------
465
+
466
+ # 0 when both ranges share a prefix and both are (or both aren't) single
467
+ # hosts. Slurm's hostrange_prefix_cmp().
468
+ def self.prefix_cmp(first, second)
469
+ return 1 if first.nil?
470
+ return -1 if second.nil?
471
+
472
+ result = if first.prefix == second.prefix
473
+ 0
474
+ else
475
+ NaturalCompare.compare(first.prefix, second.prefix)
476
+ end
477
+ return result unless result.zero?
478
+
479
+ (second.single ? 1 : 0) - (first.single ? 1 : 0)
480
+ end
481
+
482
+ # True when two ranges belong inside the same brackets.
483
+ # Slurm's hostrange_within_range().
484
+ def self.within_range(first, second)
485
+ return false unless prefix_cmp(first, second).zero?
486
+
487
+ !first.single && !second.single
488
+ end
489
+
490
+ # Can two ranges share one zero-padding width without changing any host
491
+ # name? If so, one width is adjusted in place to match the other.
492
+ # Slurm's hostrange_width_combine() and _width_equiv().
493
+ def self.width_combine(first, second)
494
+ return true if first.width == second.width
495
+
496
+ first_pad = zero_padding(first.lo, first.width)
497
+ first_pad_other = zero_padding(first.lo, second.width)
498
+ second_pad = zero_padding(second.lo, second.width)
499
+ second_pad_other = zero_padding(second.lo, first.width)
500
+
501
+ return false if first_pad != first_pad_other && second_pad != second_pad_other
502
+
503
+ if first_pad != first_pad_other
504
+ second.width = first.width
505
+ else
506
+ first.width = second.width
507
+ end
508
+ true
509
+ end
510
+
511
+ # Zeros needed to pad number to width. Slurm's _zero_padded().
512
+ def self.zero_padding(number, width)
513
+ digits = number.to_s.length
514
+ width > digits ? width - digits : 0
515
+ end
516
+ end
517
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlurmHostlist
4
+ # Slurm's natural-order string comparison (strnatcmp, by Martin Pool), ported
5
+ # so prefix ordering and "same prefix" checks agree with Slurm exactly.
6
+ # Works on bytes, as the C original does. Returns -1, 0 or 1.
7
+ module NaturalCompare
8
+ ZERO = 48
9
+ NINE = 57
10
+ # C isspace() in the "C" locale: space, \t, \n, \v, \f, \r
11
+ SPACES = [32, 9, 10, 11, 12, 13].freeze
12
+
13
+ module_function
14
+
15
+ def compare(a, b)
16
+ a = a.bytes
17
+ b = b.bytes
18
+ ai = 0
19
+ bi = 0
20
+
21
+ loop do
22
+ ca = a[ai]
23
+ cb = b[bi]
24
+
25
+ # Skip leading whitespace.
26
+ while space?(ca)
27
+ ai += 1
28
+ ca = a[ai]
29
+ end
30
+ while space?(cb)
31
+ bi += 1
32
+ cb = b[bi]
33
+ end
34
+
35
+ # Compare a run of digits.
36
+ if digit?(ca) && digit?(cb)
37
+ fractional = (ca == ZERO || cb == ZERO)
38
+ result = if fractional
39
+ compare_left(a, ai, b, bi)
40
+ else
41
+ compare_right(a, ai, b, bi)
42
+ end
43
+ return result if result != 0
44
+ end
45
+
46
+ return 0 if ca.nil? && cb.nil?
47
+
48
+ return -1 if signed(ca) < signed(cb)
49
+ return 1 if signed(ca) > signed(cb)
50
+
51
+ ai += 1
52
+ bi += 1
53
+ end
54
+ end
55
+
56
+ # Right-aligned numbers: the longest run of digits wins; at equal length,
57
+ # the first differing digit decides.
58
+ def compare_right(a, ai, b, bi)
59
+ bias = 0
60
+ loop do
61
+ ca = a[ai]
62
+ cb = b[bi]
63
+
64
+ return bias if !digit?(ca) && !digit?(cb)
65
+ return -1 unless digit?(ca)
66
+ return 1 unless digit?(cb)
67
+
68
+ if ca < cb
69
+ bias = -1 if bias.zero?
70
+ elsif ca > cb
71
+ bias = 1 if bias.zero?
72
+ end
73
+
74
+ ai += 1
75
+ bi += 1
76
+ end
77
+ end
78
+
79
+ # Left-aligned numbers (one starts with 0): the first different digit wins.
80
+ def compare_left(a, ai, b, bi)
81
+ loop do
82
+ ca = a[ai]
83
+ cb = b[bi]
84
+
85
+ return 0 if !digit?(ca) && !digit?(cb)
86
+ return -1 unless digit?(ca)
87
+ return 1 unless digit?(cb)
88
+ return -1 if ca < cb
89
+ return 1 if ca > cb
90
+
91
+ ai += 1
92
+ bi += 1
93
+ end
94
+ end
95
+
96
+ # nil is the end of the string (C's NUL). C's char is signed on the
97
+ # platforms Slurm runs on, so bytes above 127 compare as negative.
98
+ def signed(byte)
99
+ return 0 if byte.nil?
100
+
101
+ byte > 127 ? byte - 256 : byte
102
+ end
103
+
104
+ def digit?(byte)
105
+ !byte.nil? && byte >= ZERO && byte <= NINE
106
+ end
107
+
108
+ def space?(byte)
109
+ !byte.nil? && SPACES.include?(byte)
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlurmHostlist
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'slurm_hostlist/version'
4
+ require_relative 'slurm_hostlist/natural_compare'
5
+ require_relative 'slurm_hostlist/host_range'
6
+ require_relative 'slurm_hostlist/hostlist'
7
+
8
+ # Expand and compress Slurm hostlist expressions, matching Slurm's own
9
+ # behaviour (`scontrol show hostnames`, `show hostlist`, `show hostlistsorted`).
10
+ #
11
+ # SlurmHostlist.expand('n[01-03],login1') # => ["n01", "n02", "n03", "login1"]
12
+ # SlurmHostlist.compress(%w[n1 n2 n3 n7]) # => "n[1-3,7]"
13
+ module SlurmHostlist
14
+ # Raised for input Slurm itself would reject, e.g. "n[3-1]" or "n[1-".
15
+ class InvalidHostlist < ArgumentError; end
16
+
17
+ module_function
18
+
19
+ # Expand an expression into host names, in Slurm's order and keeping
20
+ # duplicates. Like `scontrol show hostnames`.
21
+ #
22
+ # expand('rack[1-2]-n[1-2]') # => ["rack1-n1", "rack1-n2", "rack2-n1", "rack2-n2"]
23
+ # expand('') # => []
24
+ def expand(expression)
25
+ encoding = encoding_of(expression)
26
+ Hostlist.parse(expression).hosts.map { |host| host.force_encoding(encoding) }
27
+ end
28
+
29
+ # Compress host names (an Array, or an expression String) into Slurm's
30
+ # compact form. Like `scontrol show hostlist`: input order is kept and
31
+ # duplicates aren't removed. With sort: true, like
32
+ # `scontrol show hostlistsorted`.
33
+ #
34
+ # compress(%w[n3 n1 n2]) # => "n[3,1-2]"
35
+ # compress(%w[n3 n1 n2], sort: true) # => "n[1-3]"
36
+ def compress(hosts, sort: false)
37
+ expression = hosts.is_a?(Array) ? hosts.join(',') : hosts.to_s
38
+ list = Hostlist.parse(expression)
39
+ list.sort! if sort
40
+ list.to_s.force_encoding(encoding_of(expression))
41
+ end
42
+
43
+ # Number of hosts an expression expands to, duplicates included.
44
+ def count(expression)
45
+ Hostlist.parse(expression).ranges.sum(&:count)
46
+ end
47
+
48
+ # True if Slurm would accept the expression.
49
+ def valid?(expression)
50
+ Hostlist.parse(expression)
51
+ true
52
+ rescue InvalidHostlist
53
+ false
54
+ end
55
+
56
+ def encoding_of(value)
57
+ value.is_a?(String) ? value.encoding : Encoding::UTF_8
58
+ end
59
+ end
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: slurm_hostlist
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Travis Ravert
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-09-29 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ Pure Ruby, dependency-free expansion and compression of Slurm hostlist
15
+ expressions like "n[01-16],login[1-2]". A port of Slurm's hostlist.c,
16
+ differentially tested against Slurm to give the same results as
17
+ `scontrol show hostnames`, `show hostlist` and `show hostlistsorted`.
18
+ email:
19
+ - travert@osc.edu
20
+ executables:
21
+ - slurm-hostlist
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - CHANGELOG.md
26
+ - LICENSE
27
+ - README.md
28
+ - exe/slurm-hostlist
29
+ - lib/slurm_hostlist.rb
30
+ - lib/slurm_hostlist/host_range.rb
31
+ - lib/slurm_hostlist/hostlist.rb
32
+ - lib/slurm_hostlist/natural_compare.rb
33
+ - lib/slurm_hostlist/version.rb
34
+ homepage: https://github.com/Oglopf/slurm_hostlist
35
+ licenses:
36
+ - MIT
37
+ metadata:
38
+ homepage_uri: https://github.com/Oglopf/slurm_hostlist
39
+ changelog_uri: https://github.com/Oglopf/slurm_hostlist/blob/main/CHANGELOG.md
40
+ rubygems_mfa_required: 'true'
41
+ post_install_message:
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '2.7'
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 3.5.22
57
+ signing_key:
58
+ specification_version: 4
59
+ summary: Expand and compress Slurm hostlists, matching Slurm's own output
60
+ test_files: []