pipbreak 0.1.0__tar.gz

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.
pipbreak-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Youssef Elaasri
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 all
13
+ 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 THE
21
+ SOFTWARE.
@@ -0,0 +1,209 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipbreak
3
+ Version: 0.1.0
4
+ Summary: Find the installed packages a dependency update breaks - the calls pip's version ranges let through.
5
+ Author: Youssef Elaasri
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/youssef-elaasri/pipbreak
8
+ Project-URL: Issues, https://github.com/youssef-elaasri/pipbreak/issues
9
+ Keywords: dependencies,dependabot,renovate,upgrade,compatibility,static-analysis
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Classifier: Topic :: System :: Software Distribution
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # pipbreak
23
+
24
+ **`pip install -U werkzeug` succeeds. Your Flask app is now broken.**
25
+
26
+ pip only checks the version ranges packages declare, and those are usually open-ended - Flask 2.2
27
+ asks for `Werkzeug>=2.2.2`, so pip happily installs Werkzeug 3, which removed APIs Flask 2.2 calls.
28
+ Dependabot opens the PR, pip resolves it, and you find out at runtime - or not at all, if your
29
+ tests don't happen to go through that code.
30
+
31
+ pipbreak reads the code of your installed packages and checks every call one package makes into
32
+ another against the versions actually installed. On a dependency-update PR it compares before and
33
+ after, and tells you exactly what the update breaks:
34
+
35
+ > ### pipbreak: this update breaks 2 call(s) in installed packages
36
+ >
37
+ > Changed: Werkzeug 2.3.8 → 3.1.3
38
+ >
39
+ > | Package | Where | Problem |
40
+ > |---|---|---|
41
+ > | Flask 2.2.5 | `flask/cli.py:250` | 'werkzeug' has no attribute '__version__' in Werkzeug==3.1.3. |
42
+ > | Flask 2.2.5 | `flask/testing.py:116` | 'werkzeug' has no attribute '__version__' in Werkzeug==3.1.3. |
43
+ >
44
+ > These packages accept the new version in their metadata but call APIs it no longer has, so pip
45
+ > installs them without complaint and they fail at runtime. Upgrade them too, or hold this update back.
46
+
47
+ No configuration, no type stubs, zero dependencies. Python 3.10+.
48
+
49
+ ## How well does it work?
50
+
51
+ - **Known breaks:** on 12 documented "update broke a package that didn't pin it" cases, it flagged
52
+ **8 of the 9 real breaks** (5 at the exact failing line) and stayed **quiet on all 3 safe updates**
53
+ ([benchmark](research/KNOWN_BREAKS.md)).
54
+ - **Real Dependabot PRs:** on 40 open-source bump PRs it caught the Werkzeug 3 breaks in Flask,
55
+ Flask-Login and Flask-WTF in every PR that had them - including one whose CI passed
56
+ ([study](research/UPGRADES.md)).
57
+ - **What it can't see:** arguments forwarded through `**kwargs`, methods on objects whose type is
58
+ only known at runtime, binary (C extension) incompatibilities, and behaviour changes behind an
59
+ unchanged API. It complements your tests; it doesn't replace them.
60
+
61
+ ## On Dependabot / Renovate PRs
62
+
63
+ ```yaml
64
+ # .github/workflows/pipbreak.yml
65
+ name: pipbreak
66
+ on: pull_request
67
+ permissions:
68
+ contents: read
69
+ pull-requests: write # for the comment; Dependabot PRs get a read-only token without this
70
+ jobs:
71
+ dependency-update:
72
+ if: github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]'
73
+ runs-on: ubuntu-latest
74
+ steps:
75
+ - uses: actions/checkout@v5
76
+ - uses: actions/setup-python@v6
77
+ with:
78
+ python-version: "3.12" # the Python you deploy on
79
+ - uses: youssef-elaasri/pipbreak@v0
80
+ # with:
81
+ # install: pip install -r requirements/prod.txt # default: requirements.txt, else `pip install .`
82
+ # working-directory: backend
83
+ # fail-on-break: false
84
+ ```
85
+
86
+ The action installs your dependencies twice - from the PR's base commit and from the PR - finds
87
+ which packages changed, and reports only what the change breaks: as a job summary, as one PR comment
88
+ it keeps up to date, and as a failed check. A safe update stays silent.
89
+
90
+ Use `pull_request`, not `pull_request_target`: installing the PR's dependencies runs their build
91
+ code, which must not see a write token for your repository.
92
+
93
+ ## On your machine
94
+
95
+ ```bash
96
+ pip install pipbreak
97
+
98
+ pip install -U werkzeug
99
+ pipbreak --deps --uses werkzeug # what in my environment calls Werkzeug APIs that are gone?
100
+ pipbreak --deps # every installed package against every other
101
+ pipbreak --deps --python .venv/bin/python # another environment
102
+
103
+ # before/after, like the action does
104
+ python -m pipbreak.compare --base .venv-old/bin/python --head .venv/bin/python --markdown report.md
105
+ ```
106
+
107
+ - `--uses LIB` (repeatable; import or distribution name) narrows the check to packages that import
108
+ LIB. That takes seconds; a whole environment takes a minute or two.
109
+ - Only calls from one distribution into another count. A package's own internals, the stdlib,
110
+ missing optional extras, vendored copies and shipped test suites are skipped.
111
+ - Exit code is 1 when there are errors (`--strict` makes warnings fail too).
112
+
113
+ ## Checking your own code
114
+
115
+ The same engine checks your project's code against your installed versions - useful for code
116
+ written for a different version of a library (by you, a tutorial, or an AI assistant):
117
+
118
+ ```text
119
+ $ pipbreak examples/
120
+ examples/app.py:9:1: error API002 'pydantic.BaseSettings' is not available in pydantic==2.9.2: `BaseSettings` has been moved to the `pydantic-settings` package. ...
121
+ examples/app.py:9:1: warning API101 'pydantic.validator' is deprecated in pydantic==2.9.2: Pydantic V1 style `@validator` validators are deprecated. ...
122
+ examples/app.py:31:8: error API002 'os.path' has no attribute 'exist' in Python 3.13. Did you mean 'exists'?
123
+ examples/app.py:33:27: error API003 shutil.copy() has no parameter 'overwrite' in Python 3.13.
124
+ examples/app.py:37:39: error API003 random.choices() has no parameter 'unique' in Python 3.13.
125
+ examples/app.py:45:30: error API002 'collections' has no attribute 'Mapping' in Python 3.13. Did you mean 'collections.abc.Mapping'?
126
+ examples/app.py:50:12: error API002 'Path' object has no attribute 'exist' in Python 3.13. Did you mean 'exists'?
127
+ examples/app.py:52:27: error API003 path.read_text() has no parameter 'encodings' in Python 3.13. Did you mean 'encoding'?
128
+ examples/app.py:56:12: warning API101 'User.dict' is deprecated in pydantic==2.9.2: The `dict` method is deprecated; use `model_dump` instead.
129
+ examples/app.py:60:12: warning API101 'User.parse_obj' is deprecated in pydantic==2.9.2: The `parse_obj` method is deprecated; use `model_validate` instead.
130
+ examples/app.py:71:12: warning API005 'Job' object has no attribute 'retry_count': it is never assigned in the class or its bases.
131
+ ```
132
+
133
+ Run the same file against pydantic 1.10 and the answer changes: `BaseSettings`, `.dict()` and
134
+ `.parse_obj()` are fine, but `field_validator` doesn't exist yet (`Did you mean 'validator'?`).
135
+
136
+ ```bash
137
+ pipbreak src/ # check against the current interpreter
138
+ pipbreak --python .venv/bin/python src/ # check against another environment
139
+ pipbreak --format github src/ # inline annotations on GitHub PRs
140
+ pipbreak --format json src/ # machine-readable
141
+ ```
142
+
143
+ In CI, after installing your pinned dependencies: `pip install pipbreak && pipbreak --format github src/`.
144
+
145
+ ## Checks
146
+
147
+ | Code | Severity | Meaning |
148
+ |--------|----------|---------|
149
+ | API001 | error | Package or module doesn't exist / isn't installed |
150
+ | API002 | error | Name or attribute doesn't exist in the installed version (with "did you mean") |
151
+ | API003 | error | Call uses a keyword argument the function doesn't accept |
152
+ | API004 | error | Call passes more positional arguments than the function accepts |
153
+ | API005 | warning | Attribute is never assigned anywhere in the object's class (instances with a `__dict__`) |
154
+ | API101 | warning | API is deprecated (PEP 702 `@deprecated` or a `.. deprecated::` docstring note) |
155
+
156
+ ## What it knows about your variables
157
+
158
+ A light, flow-insensitive inference tracks what each name holds, per function scope:
159
+
160
+ | Code | Inferred as | Checks |
161
+ |------|-------------|--------|
162
+ | `p = Path(name)`, `Path(name).read_text()` | a `Path`, exactly | missing attributes, method signatures, deprecations |
163
+ | `def f(user: User)`, `x: Client \| None` | a `User` or subclass | method signatures, deprecations |
164
+ | `client.get(url)` with `-> Response` | a `Response` or subclass | method signatures, deprecations |
165
+ | `self` in a method | the class or a subclass | method signatures, deprecations |
166
+ | `class Job: ...` in your file | modelled from its source and its real base classes | all of the above |
167
+
168
+ Missing attributes are only reported for objects we saw constructed: an annotated parameter may be a
169
+ subclass with more attributes. For classes whose instances have no `__dict__` (`__slots__`, builtins)
170
+ that's an **error**. Otherwise attributes are read from the assignments in the class source, so it's a
171
+ **warning** (API005): code elsewhere could still set the attribute.
172
+
173
+ ## Avoiding false positives
174
+
175
+ - Imports and calls under feature probes are skipped: `if hasattr(...)`, `sys.platform`,
176
+ `sys.version_info`, `os.name`, `TYPE_CHECKING`, `try/except ImportError|AttributeError`, and
177
+ flags assigned from these (`HAVE_FORK = hasattr(os, "fork")`).
178
+ - Your own modules, relative imports, and names you reassign are never checked. A name bound to
179
+ different things in one scope (or used as a loop/comprehension/lambda variable) isn't inferred.
180
+ - Classes with a custom `__getattr__`/`__getattribute__`, a custom metaclass (ORMs, pydantic, enums),
181
+ class decorators, or code that writes `self.__dict__`/`setattr(self, ...)` are never reported as
182
+ missing attributes - only deprecations and signatures are checked.
183
+ - `super()`, proxies, and functions taking `**kwargs` / `*args` aren't checked for those arguments.
184
+ - Silence a line with `# pipbreak: ignore`.
185
+
186
+ Checked against pydantic 2.9 and 21 stdlib packages (429 files), the only errors left are real for the
187
+ environment: Unix-only APIs (`os.fork`, `socket.AF_UNIX`) in modules that only run on Unix.
188
+
189
+ ## Limitations
190
+
191
+ - **Imports installed modules** to inspect them, so their import-time code runs. Use it on
192
+ environments you'd run anyway (the action only installs what your PR already would).
193
+ - Inference is per scope and doesn't follow `isinstance` narrowing, container contents (`users[0].x`),
194
+ or return types without annotations.
195
+ - Python only.
196
+
197
+ ## Development
198
+
199
+ ```bash
200
+ pip install -e .
201
+ python -m unittest discover -s tests
202
+ ```
203
+
204
+ The `research/` folder holds the studies behind the numbers above, run in a locked-down Docker
205
+ sandbox (`research/run-docker.ps1`).
206
+
207
+ ## License
208
+
209
+ MIT
@@ -0,0 +1,188 @@
1
+ # pipbreak
2
+
3
+ **`pip install -U werkzeug` succeeds. Your Flask app is now broken.**
4
+
5
+ pip only checks the version ranges packages declare, and those are usually open-ended - Flask 2.2
6
+ asks for `Werkzeug>=2.2.2`, so pip happily installs Werkzeug 3, which removed APIs Flask 2.2 calls.
7
+ Dependabot opens the PR, pip resolves it, and you find out at runtime - or not at all, if your
8
+ tests don't happen to go through that code.
9
+
10
+ pipbreak reads the code of your installed packages and checks every call one package makes into
11
+ another against the versions actually installed. On a dependency-update PR it compares before and
12
+ after, and tells you exactly what the update breaks:
13
+
14
+ > ### pipbreak: this update breaks 2 call(s) in installed packages
15
+ >
16
+ > Changed: Werkzeug 2.3.8 → 3.1.3
17
+ >
18
+ > | Package | Where | Problem |
19
+ > |---|---|---|
20
+ > | Flask 2.2.5 | `flask/cli.py:250` | 'werkzeug' has no attribute '__version__' in Werkzeug==3.1.3. |
21
+ > | Flask 2.2.5 | `flask/testing.py:116` | 'werkzeug' has no attribute '__version__' in Werkzeug==3.1.3. |
22
+ >
23
+ > These packages accept the new version in their metadata but call APIs it no longer has, so pip
24
+ > installs them without complaint and they fail at runtime. Upgrade them too, or hold this update back.
25
+
26
+ No configuration, no type stubs, zero dependencies. Python 3.10+.
27
+
28
+ ## How well does it work?
29
+
30
+ - **Known breaks:** on 12 documented "update broke a package that didn't pin it" cases, it flagged
31
+ **8 of the 9 real breaks** (5 at the exact failing line) and stayed **quiet on all 3 safe updates**
32
+ ([benchmark](research/KNOWN_BREAKS.md)).
33
+ - **Real Dependabot PRs:** on 40 open-source bump PRs it caught the Werkzeug 3 breaks in Flask,
34
+ Flask-Login and Flask-WTF in every PR that had them - including one whose CI passed
35
+ ([study](research/UPGRADES.md)).
36
+ - **What it can't see:** arguments forwarded through `**kwargs`, methods on objects whose type is
37
+ only known at runtime, binary (C extension) incompatibilities, and behaviour changes behind an
38
+ unchanged API. It complements your tests; it doesn't replace them.
39
+
40
+ ## On Dependabot / Renovate PRs
41
+
42
+ ```yaml
43
+ # .github/workflows/pipbreak.yml
44
+ name: pipbreak
45
+ on: pull_request
46
+ permissions:
47
+ contents: read
48
+ pull-requests: write # for the comment; Dependabot PRs get a read-only token without this
49
+ jobs:
50
+ dependency-update:
51
+ if: github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]'
52
+ runs-on: ubuntu-latest
53
+ steps:
54
+ - uses: actions/checkout@v5
55
+ - uses: actions/setup-python@v6
56
+ with:
57
+ python-version: "3.12" # the Python you deploy on
58
+ - uses: youssef-elaasri/pipbreak@v0
59
+ # with:
60
+ # install: pip install -r requirements/prod.txt # default: requirements.txt, else `pip install .`
61
+ # working-directory: backend
62
+ # fail-on-break: false
63
+ ```
64
+
65
+ The action installs your dependencies twice - from the PR's base commit and from the PR - finds
66
+ which packages changed, and reports only what the change breaks: as a job summary, as one PR comment
67
+ it keeps up to date, and as a failed check. A safe update stays silent.
68
+
69
+ Use `pull_request`, not `pull_request_target`: installing the PR's dependencies runs their build
70
+ code, which must not see a write token for your repository.
71
+
72
+ ## On your machine
73
+
74
+ ```bash
75
+ pip install pipbreak
76
+
77
+ pip install -U werkzeug
78
+ pipbreak --deps --uses werkzeug # what in my environment calls Werkzeug APIs that are gone?
79
+ pipbreak --deps # every installed package against every other
80
+ pipbreak --deps --python .venv/bin/python # another environment
81
+
82
+ # before/after, like the action does
83
+ python -m pipbreak.compare --base .venv-old/bin/python --head .venv/bin/python --markdown report.md
84
+ ```
85
+
86
+ - `--uses LIB` (repeatable; import or distribution name) narrows the check to packages that import
87
+ LIB. That takes seconds; a whole environment takes a minute or two.
88
+ - Only calls from one distribution into another count. A package's own internals, the stdlib,
89
+ missing optional extras, vendored copies and shipped test suites are skipped.
90
+ - Exit code is 1 when there are errors (`--strict` makes warnings fail too).
91
+
92
+ ## Checking your own code
93
+
94
+ The same engine checks your project's code against your installed versions - useful for code
95
+ written for a different version of a library (by you, a tutorial, or an AI assistant):
96
+
97
+ ```text
98
+ $ pipbreak examples/
99
+ examples/app.py:9:1: error API002 'pydantic.BaseSettings' is not available in pydantic==2.9.2: `BaseSettings` has been moved to the `pydantic-settings` package. ...
100
+ examples/app.py:9:1: warning API101 'pydantic.validator' is deprecated in pydantic==2.9.2: Pydantic V1 style `@validator` validators are deprecated. ...
101
+ examples/app.py:31:8: error API002 'os.path' has no attribute 'exist' in Python 3.13. Did you mean 'exists'?
102
+ examples/app.py:33:27: error API003 shutil.copy() has no parameter 'overwrite' in Python 3.13.
103
+ examples/app.py:37:39: error API003 random.choices() has no parameter 'unique' in Python 3.13.
104
+ examples/app.py:45:30: error API002 'collections' has no attribute 'Mapping' in Python 3.13. Did you mean 'collections.abc.Mapping'?
105
+ examples/app.py:50:12: error API002 'Path' object has no attribute 'exist' in Python 3.13. Did you mean 'exists'?
106
+ examples/app.py:52:27: error API003 path.read_text() has no parameter 'encodings' in Python 3.13. Did you mean 'encoding'?
107
+ examples/app.py:56:12: warning API101 'User.dict' is deprecated in pydantic==2.9.2: The `dict` method is deprecated; use `model_dump` instead.
108
+ examples/app.py:60:12: warning API101 'User.parse_obj' is deprecated in pydantic==2.9.2: The `parse_obj` method is deprecated; use `model_validate` instead.
109
+ examples/app.py:71:12: warning API005 'Job' object has no attribute 'retry_count': it is never assigned in the class or its bases.
110
+ ```
111
+
112
+ Run the same file against pydantic 1.10 and the answer changes: `BaseSettings`, `.dict()` and
113
+ `.parse_obj()` are fine, but `field_validator` doesn't exist yet (`Did you mean 'validator'?`).
114
+
115
+ ```bash
116
+ pipbreak src/ # check against the current interpreter
117
+ pipbreak --python .venv/bin/python src/ # check against another environment
118
+ pipbreak --format github src/ # inline annotations on GitHub PRs
119
+ pipbreak --format json src/ # machine-readable
120
+ ```
121
+
122
+ In CI, after installing your pinned dependencies: `pip install pipbreak && pipbreak --format github src/`.
123
+
124
+ ## Checks
125
+
126
+ | Code | Severity | Meaning |
127
+ |--------|----------|---------|
128
+ | API001 | error | Package or module doesn't exist / isn't installed |
129
+ | API002 | error | Name or attribute doesn't exist in the installed version (with "did you mean") |
130
+ | API003 | error | Call uses a keyword argument the function doesn't accept |
131
+ | API004 | error | Call passes more positional arguments than the function accepts |
132
+ | API005 | warning | Attribute is never assigned anywhere in the object's class (instances with a `__dict__`) |
133
+ | API101 | warning | API is deprecated (PEP 702 `@deprecated` or a `.. deprecated::` docstring note) |
134
+
135
+ ## What it knows about your variables
136
+
137
+ A light, flow-insensitive inference tracks what each name holds, per function scope:
138
+
139
+ | Code | Inferred as | Checks |
140
+ |------|-------------|--------|
141
+ | `p = Path(name)`, `Path(name).read_text()` | a `Path`, exactly | missing attributes, method signatures, deprecations |
142
+ | `def f(user: User)`, `x: Client \| None` | a `User` or subclass | method signatures, deprecations |
143
+ | `client.get(url)` with `-> Response` | a `Response` or subclass | method signatures, deprecations |
144
+ | `self` in a method | the class or a subclass | method signatures, deprecations |
145
+ | `class Job: ...` in your file | modelled from its source and its real base classes | all of the above |
146
+
147
+ Missing attributes are only reported for objects we saw constructed: an annotated parameter may be a
148
+ subclass with more attributes. For classes whose instances have no `__dict__` (`__slots__`, builtins)
149
+ that's an **error**. Otherwise attributes are read from the assignments in the class source, so it's a
150
+ **warning** (API005): code elsewhere could still set the attribute.
151
+
152
+ ## Avoiding false positives
153
+
154
+ - Imports and calls under feature probes are skipped: `if hasattr(...)`, `sys.platform`,
155
+ `sys.version_info`, `os.name`, `TYPE_CHECKING`, `try/except ImportError|AttributeError`, and
156
+ flags assigned from these (`HAVE_FORK = hasattr(os, "fork")`).
157
+ - Your own modules, relative imports, and names you reassign are never checked. A name bound to
158
+ different things in one scope (or used as a loop/comprehension/lambda variable) isn't inferred.
159
+ - Classes with a custom `__getattr__`/`__getattribute__`, a custom metaclass (ORMs, pydantic, enums),
160
+ class decorators, or code that writes `self.__dict__`/`setattr(self, ...)` are never reported as
161
+ missing attributes - only deprecations and signatures are checked.
162
+ - `super()`, proxies, and functions taking `**kwargs` / `*args` aren't checked for those arguments.
163
+ - Silence a line with `# pipbreak: ignore`.
164
+
165
+ Checked against pydantic 2.9 and 21 stdlib packages (429 files), the only errors left are real for the
166
+ environment: Unix-only APIs (`os.fork`, `socket.AF_UNIX`) in modules that only run on Unix.
167
+
168
+ ## Limitations
169
+
170
+ - **Imports installed modules** to inspect them, so their import-time code runs. Use it on
171
+ environments you'd run anyway (the action only installs what your PR already would).
172
+ - Inference is per scope and doesn't follow `isinstance` narrowing, container contents (`users[0].x`),
173
+ or return types without annotations.
174
+ - Python only.
175
+
176
+ ## Development
177
+
178
+ ```bash
179
+ pip install -e .
180
+ python -m unittest discover -s tests
181
+ ```
182
+
183
+ The `research/` folder holds the studies behind the numbers above, run in a locked-down Docker
184
+ sandbox (`research/run-docker.ps1`).
185
+
186
+ ## License
187
+
188
+ MIT
@@ -0,0 +1,4 @@
1
+ from .checker import Finding, check_paths, check_source
2
+
3
+ __all__ = ["Finding", "check_paths", "check_source"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())