c7n-kit 1.0.0__py3-none-any.whl

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.
c7n_kit/__init__.py ADDED
File without changes
c7n_kit/cadence.py ADDED
@@ -0,0 +1,181 @@
1
+ """c7n_kit/cadence.py -- grow without blowing up the window.
2
+
3
+ WHY CADENCE APPLIES PER TYPE AND NOT PER POLICY
4
+ `c7n-org` enumerates resources ONCE per policy file and then applies all
5
+ the filters over that same list. The cost of a run is driven by the
6
+ number of ENUMERATIONS, not the number of rules: adding a rule to a type
7
+ you already enumerate is nearly free (it's one more filter over data
8
+ already in memory); adding a new type costs a full enumeration across
9
+ every account and region.
10
+
11
+ Direct consequence: if cadence were declared and executed per policy, a
12
+ resource type with rules in both frequencies would get enumerated TWICE a
13
+ day -- once in the fast run, once in the slow one -- and separating would
14
+ end up MORE expensive than not separating at all. That's why cadence is
15
+ DECLARED per policy (in its `metadata.frequency`, which is where the
16
+ rule's author thinks about it) but EXECUTED per resource type, taking the
17
+ fastest one requested by any of its policies: that way each type gets
18
+ enumerated exactly once per run, always.
19
+
20
+ Taking the fastest one (not the slowest, not an average) is what
21
+ guarantees separating by cadence never degrades an existing detection: a
22
+ single high-frequency policy on a type whose rules are mostly slow
23
+ PROMOTES the whole type to the fast cadence. That costs more than it
24
+ would if that policy didn't exist, and that's why `promoted_to_fast`
25
+ exists: it's the concrete lever to bring the cost down (moving that one
26
+ policy alone to the slow cadence brings the whole type down with it).
27
+
28
+ WHY AN INVALID `frequency` RAISES INSTEAD OF FALLING BACK TO THE DEFAULT
29
+ A typo like `frequency: dayly` treated as if it were the default runs too
30
+ often if the default is the fast cadence (expensive, but visible: shows
31
+ up in the bill) or too rarely if the default is the slow one (cheap, but
32
+ invisible: the rule stops detecting at the frequency its author intended
33
+ and nobody notices until it was needed). Neither way of failing silently
34
+ is acceptable, so a value not in the list of valid cadences raises
35
+ `ValueError` the moment it's read, not later.
36
+
37
+ SHAPE OF `policy` OBJECTS
38
+ This file does NOT import the `Policy` dataclass from `c7n_kit/policies.py`
39
+ on purpose: every module in the kit has to be copyable on its own (see
40
+ CONTRACTS.md), and tying it to another module in the same kit breaks that
41
+ promise the moment someone copies one without the other. Instead, it
42
+ accesses the same three fields the `Policy` contract defines, via duck
43
+ typing:
44
+
45
+ .name str -- to say WHICH policy promoted a type
46
+ .resource str -- the resource type, canonical (prefixed with `aws.`)
47
+ .metadata dict -- the `metadata:` block as-is, `frequency` comes from there
48
+
49
+ Any object with those three attributes works, whether it's the kit's
50
+ real `Policy`, a dict wrapped in `types.SimpleNamespace`, or the result
51
+ of your own YAML parser.
52
+ """
53
+ from __future__ import annotations
54
+
55
+ # Valid cadences, ORDERED from fastest to slowest. This order is what
56
+ # defines "the fastest wins": adding a new cadence just means inserting
57
+ # it at the right position in this tuple, nothing else.
58
+ DEFAULT_CADENCES = ("4h", "daily")
59
+
60
+
61
+ def _metadata_of(policy) -> dict:
62
+ return getattr(policy, "metadata", None) or {}
63
+
64
+
65
+ def _name_of(policy) -> str:
66
+ return getattr(policy, "name", None) or "(unnamed)"
67
+
68
+
69
+ def _resource_of(policy) -> str:
70
+ resource = getattr(policy, "resource", None)
71
+ if not resource:
72
+ raise ValueError(
73
+ f"policy {_name_of(policy)!r}: doesn't declare `resource` (resource "
74
+ f"type), cadence cannot be grouped without it")
75
+ return resource
76
+
77
+
78
+ def policy_cadence(policy, cadences=DEFAULT_CADENCES) -> str:
79
+ """The cadence declared by ONE policy, validated.
80
+
81
+ The default (when `metadata.frequency` isn't present) is
82
+ `cadences[0]`, i.e. the FASTEST in the list. This choice is
83
+ deliberate: the default has to be the expensive one, so forgetting to
84
+ declare `frequency` costs money (visible in the bill) and not
85
+ invisibility (a rule that thinks it runs every 4h but actually runs
86
+ once a day). If your organization prefers the opposite default, pass
87
+ `cadences` with the order that fits: the first one always wins as
88
+ both the default and the "fastest."
89
+ """
90
+ metadata = _metadata_of(policy)
91
+ value = metadata.get("frequency", cadences[0])
92
+ if value not in cadences:
93
+ # Raise instead of falling back to the default: see module docstring.
94
+ raise ValueError(
95
+ f"policy {_name_of(policy)!r}: frequency={value!r} is not a "
96
+ f"valid cadence. Valid ones: {list(cadences)}")
97
+ return value
98
+
99
+
100
+ def _group_by_resource(policies):
101
+ by_resource: dict[str, list] = {}
102
+ for p in policies:
103
+ by_resource.setdefault(_resource_of(p), []).append(p)
104
+ return by_resource
105
+
106
+
107
+ def cadence_by_type(policies, cadences=DEFAULT_CADENCES) -> dict[str, str]:
108
+ """{resource_type: cadence}, taking the FASTEST one requested by any
109
+ of the policies of that type.
110
+
111
+ `policies` is any iterable of objects with `.resource` and `.metadata`
112
+ (see module docstring). Every invalid `frequency` raises as soon as
113
+ that policy is evaluated -- there's no way for a typo to slip through
114
+ unnoticed, not even on a type where it ends up not mattering because
115
+ another policy already fixed the fast cadence.
116
+ """
117
+ by_resource = _group_by_resource(policies)
118
+ result = {}
119
+ for resource, items in by_resource.items():
120
+ requested = {policy_cadence(p, cadences) for p in items}
121
+ result[resource] = min(requested, key=cadences.index)
122
+ return result
123
+
124
+
125
+ def promoted_to_fast(policies, cadences=DEFAULT_CADENCES
126
+ ) -> dict[str, list[str]]:
127
+ """{resource_type: [policy names]} for the types that ended up on the
128
+ FAST cadence (`cadences[0]`) because of a single policy, while the
129
+ rest of their rules asked for something slower.
130
+
131
+ This is the concrete lever to bring down the cost of a run: a type on
132
+ this list gets enumerated at the full expensive frequency because of
133
+ ONE rule. Moving it alone to a slower cadence brings the whole type
134
+ down with it, without touching any other policy.
135
+
136
+ A type where ALL policies already ask for the fast cadence doesn't
137
+ show up here: there's nothing to "promote," that's simply the cadence
138
+ the whole type is entitled to on its own.
139
+ """
140
+ fast = cadences[0]
141
+ by_resource = _group_by_resource(policies)
142
+ result = {}
143
+ for resource, items in by_resource.items():
144
+ requested = {policy_cadence(p, cadences) for p in items}
145
+ if len(requested) > 1 and min(requested, key=cadences.index) == fast:
146
+ culprits = sorted(
147
+ _name_of(p) for p in items
148
+ if policy_cadence(p, cadences) == fast)
149
+ result[resource] = culprits
150
+ return result
151
+
152
+
153
+ def _cli(argv=None):
154
+ """What runs on each cadence, from the command line."""
155
+ import sys
156
+ from c7n_kit.policies import load
157
+
158
+ argv = sys.argv[1:] if argv is None else argv
159
+ if len(argv) != 1:
160
+ sys.exit("usage: python -m c7n_kit.cadence <policies-directory>")
161
+
162
+ ps = load(argv[0])
163
+ by_type = cadence_by_type(ps)
164
+ for resource_type, cad in sorted(by_type.items()):
165
+ print(f" {resource_type:<24} {cad}")
166
+
167
+ promoted = promoted_to_fast(ps)
168
+ print()
169
+ if promoted:
170
+ # The actionable data: moving that single policy brings the whole
171
+ # type down.
172
+ print("types on the fast cadence because of ONE policy:")
173
+ for resource_type, policy in sorted(promoted.items()) if isinstance(promoted, dict) else promoted:
174
+ print(f" {resource_type:<24} promoted by: {policy}")
175
+ else:
176
+ print("promoted to the fast cadence: none")
177
+ return 0
178
+
179
+
180
+ if __name__ == "__main__":
181
+ raise SystemExit(_cli())
c7n_kit/cli.py ADDED
@@ -0,0 +1,37 @@
1
+ """Unified entrypoint for the `c7n-kit` console script.
2
+
3
+ Each subcommand dispatches to the same `_cli(argv)` function that module's
4
+ own `python -m c7n_kit.<module>` invocation already calls, so both
5
+ invocation styles produce byte-identical output. Nothing here duplicates
6
+ logic that lives in `coverage.py`, `cadence.py` or `dashboard.py`.
7
+
8
+ `gaps.py` has no subcommand here: unlike the other three modules it has no
9
+ `if __name__ == "__main__":` block, because it is meant to be used as a
10
+ library fed a `c7n-org` log rather than invoked directly.
11
+ """
12
+ import sys
13
+
14
+ USAGE = "usage: c7n-kit <coverage|cadence|dashboard> [args...]"
15
+
16
+
17
+ def main(argv=None):
18
+ argv = sys.argv[1:] if argv is None else argv
19
+
20
+ if not argv or argv[0] not in ("coverage", "cadence", "dashboard"):
21
+ print(USAGE, file=sys.stderr)
22
+ return 1
23
+
24
+ subcommand, rest = argv[0], argv[1:]
25
+
26
+ if subcommand == "coverage":
27
+ from c7n_kit.coverage import _cli
28
+ elif subcommand == "cadence":
29
+ from c7n_kit.cadence import _cli
30
+ else:
31
+ from c7n_kit.dashboard import _cli
32
+
33
+ return _cli(rest)
34
+
35
+
36
+ if __name__ == "__main__":
37
+ raise SystemExit(main())
c7n_kit/coverage.py ADDED
@@ -0,0 +1,315 @@
1
+ """Cross-checks what policies CLAIM to cover against a catalog of controls.
2
+
3
+ This module doesn't measure whether a policy works: it measures whether
4
+ the policy -> control mapping is honest. There are two ways it can be
5
+ dishonest, and both are detectable without running anything:
6
+
7
+ 1. A policy cites a control, FROM THE FAMILY OF THE CATALOG PASSED IN,
8
+ that doesn't exist in that catalog (typo, ID of a retired control,
9
+ an old version of the framework). If that were ignored, the
10
+ reported coverage would look higher than it really is and nobody
11
+ would notice until an external audit. That's why `orphans` is a
12
+ required field of `Coverage`, not an optional detail.
13
+ 2. A policy declares `metadata.framework` (singular, a string) AND
14
+ `metadata.frameworks` (plural, a list) at the same time. There's no
15
+ reasonable "who wins" rule: whichever one the code picks, someone
16
+ is going to edit the other thinking it has an effect, and nothing
17
+ will happen. That's why this raises instead of resolving silently.
18
+
19
+ THE FAMILY FILTER (why it exists)
20
+ A real policy maps to several frameworks at once, e.g.
21
+ `frameworks: ['CIS 5.6', 'FSBP EC2.8']`. If `coverage()` is called with
22
+ only the FSBP catalog, `CIS 5.6` can't be treated as an orphan: it's not
23
+ that the control doesn't exist, it's that this control isn't this
24
+ cross-check's business. The first version of this module didn't make
25
+ this distinction and treated any control missing from the catalog passed
26
+ in as an orphan -- the result was that `orphans` screamed on EVERY run
27
+ (with the controls from the other frameworks the policy also cites), it
28
+ became useless as a signal, and whoever consumed it ended up muting it.
29
+ That's why `coverage()` first computes which families the catalog has
30
+ (`evaluated_families`) and discards, without adding them to
31
+ covered/missing/orphans, any control cited from ANY OTHER family. Only
32
+ what is the catalog's own business enters the cross-check.
33
+
34
+ Rule of the kit: AN ABSENCE IS NOT A ZERO. If the catalog has no control
35
+ from a given family, that family simply doesn't appear in `by_family` --
36
+ it doesn't show up as "(0, 0)" mixed in with families that do exist and
37
+ are at real zero coverage. For the same reason, `evaluated_families`
38
+ exists so a "21% coverage" figure is never read without knowing over
39
+ which universe that percentage was computed.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import re
45
+ from dataclasses import dataclass
46
+ from pathlib import Path
47
+
48
+ # Special case: "SOX ITGC" is a TWO-word family (unlike "FSBP", "CIS",
49
+ # "PCI"). Without this special case, `_family("SOX ITGC 1.1")` would
50
+ # return "SOX" alone, that "family" would never match the real family of
51
+ # the SOX catalog ("SOX ITGC"), and any policy citing a SOX control would
52
+ # end up silently dropped from the cross-check -- exactly the kind of
53
+ # "absence read as zero" this kit exists to prevent. It's declared HERE
54
+ # (not only in `_family`) because it's also needed to validate a catalog
55
+ # line: a "SOX ITGC 1.1" line has TWO spaces, and without this special
56
+ # case the SOX catalog couldn't even load.
57
+ _TWO_WORD_FAMILIES = ("SOX ITGC",)
58
+
59
+ # A catalog line is "<FAMILY> <ID>" with one separating space, except for
60
+ # the two-word-family special case (`_TWO_WORD_FAMILIES`), where it's two
61
+ # spaces: "SOX ITGC" + " " + "<ID>".
62
+ # FAMILY: letters (possibly with digits/dashes, e.g. "PCI", "CIS-AWS").
63
+ # ID: alphanumeric with dots (e.g. "RDS.1", "2.1.1", "3.5.1"). Any other
64
+ # shape (no space, with tabs, extra columns) is a formatting error:
65
+ # better to raise now than let a control through that will never match in
66
+ # `coverage()` and shows up as "missing" forever.
67
+ _TWO_WORD_FAMILY_ALT = "|".join(re.escape(f) for f in _TWO_WORD_FAMILIES)
68
+ _CATALOG_LINE_RE = re.compile(
69
+ rf"^(?:{_TWO_WORD_FAMILY_ALT}|[A-Za-z][A-Za-z0-9_-]*) [A-Za-z0-9][A-Za-z0-9.]*$"
70
+ )
71
+
72
+
73
+ class AmbiguousMetadataError(ValueError):
74
+ """A policy declares `framework` and `frameworks` at the same time.
75
+
76
+ See the module docstring: there's no safe tiebreaker rule, so instead
77
+ of picking one and surprising whoever edits the other, loading stops
78
+ here.
79
+ """
80
+
81
+
82
+ class InvalidCatalogError(ValueError):
83
+ """The catalog file has duplicate or malformed lines.
84
+
85
+ A duplicate inflates `by_family` (it counts the same control twice
86
+ as "total"). A malformed line will never match any real
87
+ `metadata.frameworks`, so the control is doomed to appear in
88
+ `missing` forever even if it's covered -- neither case is allowed to
89
+ pass quietly.
90
+ """
91
+
92
+
93
+ class Catalog(list):
94
+ """The list of controls, which ALSO knows whether it's complete.
95
+
96
+ It's a subclass of `list` on purpose: the contract says
97
+ `load_catalog` returns `list[str]`, and that's still true, but this
98
+ way completeness travels attached to the data instead of being a
99
+ separate parameter the caller can forget to pass.
100
+
101
+ And forgetting it wouldn't be a minor detail. An incomplete catalog
102
+ produces false orphans (a policy cites a control that exists but
103
+ isn't in the file) and coverage that lies low. If completeness
104
+ traveled as a separate boolean, the day someone doesn't pass it the
105
+ report claims a completeness that nobody verified.
106
+
107
+ `complete is None` means the catalog doesn't declare it. It does
108
+ NOT mean complete.
109
+ """
110
+ __slots__ = ("complete", "path")
111
+
112
+ def __init__(self, items, complete=None, path=None):
113
+ super().__init__(items)
114
+ self.complete = complete
115
+ self.path = path
116
+
117
+
118
+ @dataclass
119
+ class Coverage:
120
+ covered: dict[str, list[str]] # control -> names of the policies covering it
121
+ missing: list[str] # catalog controls with no policy at all
122
+ orphans: list[str] # controls cited from the catalog's own family/families that are NOT in it
123
+ by_family: dict[str, tuple[int, int]] # family -> (covered, total)
124
+ families_evaluated: list[str] # families the catalog passed to this run had
125
+ # `True`/`False` as declared by the catalog, `None` if it doesn't declare it.
126
+ # A percentage computed over an incomplete catalog is not comparable to
127
+ # one computed over a complete one, and whoever reads the number needs
128
+ # to be able to tell which of the two they're looking at.
129
+ catalog_complete: bool | None = None
130
+
131
+
132
+ _COMPLETE_RE = re.compile(r'^#\s*complete:\s*(yes|no)\b', re.I | re.M)
133
+
134
+
135
+ def load_catalog(path: str) -> list[str]:
136
+ """Reads a catalog file (one control per line) and validates it.
137
+
138
+ Doesn't delegate validation to `coverage()` because by the time the
139
+ catalog gets there it's too late to tell whether a "missing" control
140
+ is a real, unimplemented control or a control that could never match
141
+ because the file had a tab instead of a space. It's validated here,
142
+ with the file in view, so the error can point at the line number.
143
+ """
144
+ controls: list[str] = []
145
+ seen: set[str] = set()
146
+ duplicates: set[str] = set()
147
+ malformed: list[tuple[int, str]] = []
148
+
149
+ with Path(path).open("r", encoding="utf-8") as f:
150
+ for number, raw_line in enumerate(f, start=1):
151
+ line = raw_line.rstrip("\n")
152
+ stripped = line.strip()
153
+ if not stripped or stripped.startswith("#"):
154
+ continue # blank line or comment: not a control
155
+ if not _CATALOG_LINE_RE.match(stripped):
156
+ malformed.append((number, line))
157
+ continue
158
+ if stripped in seen:
159
+ duplicates.add(stripped)
160
+ seen.add(stripped)
161
+ controls.append(stripped)
162
+
163
+ if malformed or duplicates:
164
+ parts = []
165
+ if malformed:
166
+ detail = ", ".join(f"line {n}: {l!r}" for n, l in malformed)
167
+ parts.append(f"malformed ({detail})")
168
+ if duplicates:
169
+ parts.append(f"duplicated ({', '.join(sorted(duplicates))})")
170
+ raise InvalidCatalogError(f"{path}: " + "; ".join(parts))
171
+
172
+ _m = _COMPLETE_RE.search(Path(path).read_text(encoding="utf-8"))
173
+ _complete = None if _m is None else _m.group(1).lower() == 'yes'
174
+ return Catalog(controls, complete=_complete, path=path)
175
+
176
+
177
+ def _family(control: str) -> str:
178
+ """Returns the family of a control ("FSBP", "CIS", "PCI", "SOX ITGC").
179
+
180
+ Used both to classify what a policy cites and to see which families a
181
+ catalog has -- both sides of `coverage()` have to use the same rule
182
+ to be comparable.
183
+ """
184
+ for family in _TWO_WORD_FAMILIES:
185
+ if control == family or control.startswith(family + " "):
186
+ return family
187
+ return control.split(" ", 1)[0]
188
+
189
+
190
+ def _declared_controls(policy) -> list[str]:
191
+ """Returns the controls a policy claims to cover, or [] if it claims none."""
192
+ frameworks = policy.metadata.get("frameworks")
193
+ framework = policy.metadata.get("framework")
194
+
195
+ if frameworks is not None and framework is not None:
196
+ raise AmbiguousMetadataError(
197
+ f"{policy.name} ({policy.file}) declares 'framework' and "
198
+ "'frameworks' at the same time"
199
+ )
200
+ if frameworks is not None:
201
+ return list(frameworks)
202
+ if framework is not None:
203
+ return [framework]
204
+ return []
205
+
206
+
207
+ def coverage(policies, catalog: list[str]) -> Coverage:
208
+ """Cross-checks `policies` against `catalog` and builds the coverage report.
209
+
210
+ `catalog` is the source of truth for which controls exist FOR ITS
211
+ OWN FAMILY/FAMILIES (see `families_evaluated`). A real policy cites
212
+ controls from several frameworks at once
213
+ (`frameworks: ['CIS 5.6', 'FSBP EC2.8']`); if this function is called
214
+ with only the FSBP catalog, `CIS 5.6` is neither orphan nor missing
215
+ nor covered -- it's a control from a family this run never evaluated.
216
+ Only what belongs to the catalog's own family/families enters the
217
+ cross-check; everything else is ignored ON PURPOSE (see the module
218
+ docstring, section "THE FAMILY FILTER").
219
+ """
220
+ catalog_set = set(catalog) # defensive dedup; the real validation lives in load_catalog
221
+ catalog_families = {_family(c) for c in catalog_set}
222
+
223
+ covered: dict[str, list[str]] = {}
224
+ orphans_set: set[str] = set()
225
+
226
+ for policy in policies:
227
+ for control in _declared_controls(policy):
228
+ if _family(control) not in catalog_families:
229
+ continue # not this catalog's business: neither covers, nor is missing, nor an orphan
230
+ if control in catalog_set:
231
+ covered.setdefault(control, []).append(policy.name)
232
+ else:
233
+ orphans_set.add(control)
234
+
235
+ missing = sorted(c for c in catalog_set if c not in covered)
236
+ orphans = sorted(orphans_set)
237
+
238
+ families_total: dict[str, int] = {}
239
+ families_covered: dict[str, int] = {}
240
+ for control in catalog_set:
241
+ family = _family(control)
242
+ families_total[family] = families_total.get(family, 0) + 1
243
+ if control in covered:
244
+ families_covered[family] = families_covered.get(family, 0) + 1
245
+
246
+ by_family = {
247
+ family: (families_covered.get(family, 0), total)
248
+ for family, total in families_total.items()
249
+ }
250
+
251
+ # Completeness travels with the catalog, not as a separate parameter:
252
+ # if it were a parameter, the day someone doesn't pass it the report
253
+ # would claim a completeness nobody verified.
254
+ return Coverage(
255
+ covered=covered,
256
+ missing=missing,
257
+ orphans=orphans,
258
+ by_family=by_family,
259
+ families_evaluated=sorted(catalog_families),
260
+ # `getattr`, not direct access: `coverage()` accepts any iterable
261
+ # of controls, not just a `Catalog`. If a plain list is passed,
262
+ # completeness stays `None`, which means "unknown," not
263
+ # "complete."
264
+ catalog_complete=getattr(catalog, "complete", None),
265
+ )
266
+
267
+
268
+ def _cli(argv=None):
269
+ """Coverage report from the command line.
270
+
271
+ Without this, `python -m c7n_kit.coverage` imports the module, does
272
+ nothing and exits 0. A command that exits clean without having done
273
+ anything is the same shape of bug this kit chases, and this repo's
274
+ README had it until this got run.
275
+ """
276
+ import sys
277
+ from c7n_kit.policies import load
278
+
279
+ argv = sys.argv[1:] if argv is None else argv
280
+ if len(argv) != 2:
281
+ sys.exit("usage: python -m c7n_kit.coverage <catalog.txt> <policies-directory>")
282
+
283
+ catalog = load_catalog(argv[0])
284
+ c = coverage(load(argv[1]), catalog)
285
+ total = len(c.covered) + len(c.missing)
286
+
287
+ for fam in c.families_evaluated:
288
+ cov, tot = c.by_family.get(fam, (0, 0))
289
+ pct = f"{100 * cov // tot}%" if tot else "-"
290
+ print(f"{fam} {cov}/{tot} controls ({pct})")
291
+
292
+ # Completeness is ALWAYS printed, even when it's True. A percentage
293
+ # without its declared denominator can't be taken to an audit, and
294
+ # only showing the notice in the bad case trains people not to look
295
+ # for it.
296
+ if c.catalog_complete is True:
297
+ print(f"catalog complete: yes")
298
+ elif c.catalog_complete is False:
299
+ print(f"catalog complete: NO -- the percentage is a floor, not the real number")
300
+ else:
301
+ print(f"catalog complete: undeclared -- the denominator cannot be asserted")
302
+
303
+ if c.orphans:
304
+ print(f"\norphans ({len(c.orphans)}) -- cited by a policy and "
305
+ f"absent from the catalog, they add coverage that doesn't exist:")
306
+ for h in c.orphans:
307
+ print(f" {h}")
308
+ else:
309
+ print("orphans: none")
310
+
311
+ return 0 if not c.orphans else 1
312
+
313
+
314
+ if __name__ == "__main__":
315
+ raise SystemExit(_cli())