vcti-error-core 1.0.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.
- vcti_error_core-1.0.0/LICENSE +8 -0
- vcti_error_core-1.0.0/PKG-INFO +132 -0
- vcti_error_core-1.0.0/README.md +108 -0
- vcti_error_core-1.0.0/pyproject.toml +68 -0
- vcti_error_core-1.0.0/setup.cfg +4 -0
- vcti_error_core-1.0.0/src/vcti/error/core/__init__.py +25 -0
- vcti_error_core-1.0.0/src/vcti/error/core/codes.py +113 -0
- vcti_error_core-1.0.0/src/vcti/error/core/errors.py +33 -0
- vcti_error_core-1.0.0/src/vcti/error/core/py.typed +0 -0
- vcti_error_core-1.0.0/src/vcti/error/core/system_error.py +49 -0
- vcti_error_core-1.0.0/src/vcti_error_core.egg-info/PKG-INFO +132 -0
- vcti_error_core-1.0.0/src/vcti_error_core.egg-info/SOURCES.txt +16 -0
- vcti_error_core-1.0.0/src/vcti_error_core.egg-info/dependency_links.txt +1 -0
- vcti_error_core-1.0.0/src/vcti_error_core.egg-info/requires.txt +10 -0
- vcti_error_core-1.0.0/src/vcti_error_core.egg-info/top_level.txt +1 -0
- vcti_error_core-1.0.0/tests/test_codes.py +136 -0
- vcti_error_core-1.0.0/tests/test_system_error.py +34 -0
- vcti_error_core-1.0.0/tests/test_version.py +15 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright (c) 2018-2026 Visual Collaboration Technologies Inc.
|
|
2
|
+
All Rights Reserved.
|
|
3
|
+
|
|
4
|
+
This software is proprietary and confidential. Unauthorized copying,
|
|
5
|
+
distribution, or use of this software, via any medium, is strictly
|
|
6
|
+
prohibited. Access is granted only to authorized VCollab developers
|
|
7
|
+
and individuals explicitly authorized by Visual Collaboration
|
|
8
|
+
Technologies Inc.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vcti-error-core
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Exit-code mechanics: validation, composition, and MRO-aware resolution of exception-to-exit-code mappings
|
|
5
|
+
Author: Visual Collaboration Technologies Inc.
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Repository, https://github.com/vcollab/vcti-python-error-core
|
|
8
|
+
Project-URL: Changelog, https://github.com/vcollab/vcti-python-error-core/blob/main/CHANGELOG.md
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Python: <3.15,>=3.12
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest; extra == "test"
|
|
18
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
19
|
+
Provides-Extra: lint
|
|
20
|
+
Requires-Dist: ruff; extra == "lint"
|
|
21
|
+
Provides-Extra: typecheck
|
|
22
|
+
Requires-Dist: mypy; extra == "typecheck"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# vcti-error-core
|
|
26
|
+
|
|
27
|
+
Exit-code mechanics: validation, composition, and MRO-aware resolution of exception-to-exit-code mappings.
|
|
28
|
+
|
|
29
|
+
## Overview
|
|
30
|
+
|
|
31
|
+
When a Python script runs as a subprocess of a partner application, the
|
|
32
|
+
process **exit code is the only reliable contract** between caller and
|
|
33
|
+
callee. `vcti-error-core` provides the mechanics for that contract — and
|
|
34
|
+
deliberately none of the vocabulary. A contract is plain data: a
|
|
35
|
+
read-only `Mapping[type[BaseException], int]` owned by whoever defines
|
|
36
|
+
it. This package validates such mappings (ranges, claims), composes them
|
|
37
|
+
collision-safely, and resolves an exception to its code MRO-aware, plus
|
|
38
|
+
an errno-correct constructor for OS exceptions. The actual VCollab code
|
|
39
|
+
vocabularies live in [`vcti-error-contract`](https://pypi.org/project/vcti-error-contract/);
|
|
40
|
+
any team can publish its own contract package on these mechanics.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install vcti-error-core
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
> Migrating from the retired `vcti-error` 1.x? Uninstall it first — it
|
|
49
|
+
> owns `vcti/error/__init__.py`, which conflicts with this namespace
|
|
50
|
+
> family. Its API lives on in `vcti.error.contract.legacy`.
|
|
51
|
+
|
|
52
|
+
### In `requirements.txt`
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
vcti-error-core>=1.0.0
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### In `pyproject.toml` dependencies
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
dependencies = [
|
|
62
|
+
"vcti-error-core>=1.0.0",
|
|
63
|
+
]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
A contract is a dict; the mechanics do the rest:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from vcti.error.core import combine, error_code, validate
|
|
74
|
+
|
|
75
|
+
class AppError(Exception): ...
|
|
76
|
+
class LicenseExpired(AppError): ...
|
|
77
|
+
|
|
78
|
+
MY_CODES = {AppError: 32, LicenseExpired: 33}
|
|
79
|
+
validate(MY_CODES, claim=range(32, 64)) # fail fast at startup
|
|
80
|
+
|
|
81
|
+
CODES = combine({Exception: 1, FileNotFoundError: 2}, MY_CODES)
|
|
82
|
+
|
|
83
|
+
def main() -> int:
|
|
84
|
+
try:
|
|
85
|
+
run()
|
|
86
|
+
return 0
|
|
87
|
+
except Exception as err:
|
|
88
|
+
return error_code(err, CODES, default=1)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Resolution is MRO-aware — a subclass inherits its nearest mapped
|
|
92
|
+
ancestor's code (`class QuotaExceeded(LicenseExpired)` still exits 33).
|
|
93
|
+
Composition is collision-safe — two contracts disagreeing about a class
|
|
94
|
+
raise `CodeCollisionError` at startup, not in production log analysis.
|
|
95
|
+
|
|
96
|
+
Errno-correct OS errors for programmatic raises:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from vcti.error.core import system_error
|
|
100
|
+
|
|
101
|
+
raise system_error(FileNotFoundError, "config.yaml")
|
|
102
|
+
# FileNotFoundError(ENOENT, "No such file or directory", "config.yaml")
|
|
103
|
+
# -> err.errno / err.strerror / err.filename all set, as if the OS raised it
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## API surface
|
|
109
|
+
|
|
110
|
+
| Name | Kind | Purpose |
|
|
111
|
+
|------|------|---------|
|
|
112
|
+
| `ErrorCodes` | type alias | `Mapping[type[BaseException], int]` — a contract |
|
|
113
|
+
| `validate(codes, *, claim)` | function | Range/type checks; `claim` enforces a contract's allocated range |
|
|
114
|
+
| `combine(*sets)` | function | Read-only merge; disagreement → `CodeCollisionError` |
|
|
115
|
+
| `error_code(exc, codes, *, default)` | function | MRO-aware resolution; `default` is caller-supplied |
|
|
116
|
+
| `system_error(cls, *args)` | function | Errno-correct OS-exception construction |
|
|
117
|
+
| `MAX_EXIT_CODE` | constant | 255 (POSIX truncates exit codes modulo 256) |
|
|
118
|
+
| `ErrorCodesError` | exception | Base; `CodeRangeError`, `CodeCollisionError` |
|
|
119
|
+
|
|
120
|
+
Rules baked into `validate`: codes are 1–255 (0 is success), stay ≤125
|
|
121
|
+
in practice (126+ collides with shell/signal conventions), and `bool`
|
|
122
|
+
codes are rejected.
|
|
123
|
+
|
|
124
|
+
See [docs/design.md](docs/design.md) for the mechanics/contract split
|
|
125
|
+
and [docs/extending.md](docs/extending.md) for writing a contract
|
|
126
|
+
package.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Dependencies
|
|
131
|
+
|
|
132
|
+
None. Standard library only.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# vcti-error-core
|
|
2
|
+
|
|
3
|
+
Exit-code mechanics: validation, composition, and MRO-aware resolution of exception-to-exit-code mappings.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
When a Python script runs as a subprocess of a partner application, the
|
|
8
|
+
process **exit code is the only reliable contract** between caller and
|
|
9
|
+
callee. `vcti-error-core` provides the mechanics for that contract — and
|
|
10
|
+
deliberately none of the vocabulary. A contract is plain data: a
|
|
11
|
+
read-only `Mapping[type[BaseException], int]` owned by whoever defines
|
|
12
|
+
it. This package validates such mappings (ranges, claims), composes them
|
|
13
|
+
collision-safely, and resolves an exception to its code MRO-aware, plus
|
|
14
|
+
an errno-correct constructor for OS exceptions. The actual VCollab code
|
|
15
|
+
vocabularies live in [`vcti-error-contract`](https://pypi.org/project/vcti-error-contract/);
|
|
16
|
+
any team can publish its own contract package on these mechanics.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install vcti-error-core
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
> Migrating from the retired `vcti-error` 1.x? Uninstall it first — it
|
|
25
|
+
> owns `vcti/error/__init__.py`, which conflicts with this namespace
|
|
26
|
+
> family. Its API lives on in `vcti.error.contract.legacy`.
|
|
27
|
+
|
|
28
|
+
### In `requirements.txt`
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
vcti-error-core>=1.0.0
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### In `pyproject.toml` dependencies
|
|
35
|
+
|
|
36
|
+
```toml
|
|
37
|
+
dependencies = [
|
|
38
|
+
"vcti-error-core>=1.0.0",
|
|
39
|
+
]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
A contract is a dict; the mechanics do the rest:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from vcti.error.core import combine, error_code, validate
|
|
50
|
+
|
|
51
|
+
class AppError(Exception): ...
|
|
52
|
+
class LicenseExpired(AppError): ...
|
|
53
|
+
|
|
54
|
+
MY_CODES = {AppError: 32, LicenseExpired: 33}
|
|
55
|
+
validate(MY_CODES, claim=range(32, 64)) # fail fast at startup
|
|
56
|
+
|
|
57
|
+
CODES = combine({Exception: 1, FileNotFoundError: 2}, MY_CODES)
|
|
58
|
+
|
|
59
|
+
def main() -> int:
|
|
60
|
+
try:
|
|
61
|
+
run()
|
|
62
|
+
return 0
|
|
63
|
+
except Exception as err:
|
|
64
|
+
return error_code(err, CODES, default=1)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Resolution is MRO-aware — a subclass inherits its nearest mapped
|
|
68
|
+
ancestor's code (`class QuotaExceeded(LicenseExpired)` still exits 33).
|
|
69
|
+
Composition is collision-safe — two contracts disagreeing about a class
|
|
70
|
+
raise `CodeCollisionError` at startup, not in production log analysis.
|
|
71
|
+
|
|
72
|
+
Errno-correct OS errors for programmatic raises:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from vcti.error.core import system_error
|
|
76
|
+
|
|
77
|
+
raise system_error(FileNotFoundError, "config.yaml")
|
|
78
|
+
# FileNotFoundError(ENOENT, "No such file or directory", "config.yaml")
|
|
79
|
+
# -> err.errno / err.strerror / err.filename all set, as if the OS raised it
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## API surface
|
|
85
|
+
|
|
86
|
+
| Name | Kind | Purpose |
|
|
87
|
+
|------|------|---------|
|
|
88
|
+
| `ErrorCodes` | type alias | `Mapping[type[BaseException], int]` — a contract |
|
|
89
|
+
| `validate(codes, *, claim)` | function | Range/type checks; `claim` enforces a contract's allocated range |
|
|
90
|
+
| `combine(*sets)` | function | Read-only merge; disagreement → `CodeCollisionError` |
|
|
91
|
+
| `error_code(exc, codes, *, default)` | function | MRO-aware resolution; `default` is caller-supplied |
|
|
92
|
+
| `system_error(cls, *args)` | function | Errno-correct OS-exception construction |
|
|
93
|
+
| `MAX_EXIT_CODE` | constant | 255 (POSIX truncates exit codes modulo 256) |
|
|
94
|
+
| `ErrorCodesError` | exception | Base; `CodeRangeError`, `CodeCollisionError` |
|
|
95
|
+
|
|
96
|
+
Rules baked into `validate`: codes are 1–255 (0 is success), stay ≤125
|
|
97
|
+
in practice (126+ collides with shell/signal conventions), and `bool`
|
|
98
|
+
codes are rejected.
|
|
99
|
+
|
|
100
|
+
See [docs/design.md](docs/design.md) for the mechanics/contract split
|
|
101
|
+
and [docs/extending.md](docs/extending.md) for writing a contract
|
|
102
|
+
package.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Dependencies
|
|
107
|
+
|
|
108
|
+
None. Standard library only.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vcti-error-core"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Exit-code mechanics: validation, composition, and MRO-aware resolution of exception-to-exit-code mappings"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{name = "Visual Collaboration Technologies Inc."}
|
|
12
|
+
]
|
|
13
|
+
license = "LicenseRef-Proprietary"
|
|
14
|
+
license-files = ["LICENSE"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
]
|
|
21
|
+
requires-python = ">=3.12,<3.15"
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Repository = "https://github.com/vcollab/vcti-python-error-core"
|
|
26
|
+
Changelog = "https://github.com/vcollab/vcti-python-error-core/blob/main/CHANGELOG.md"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
include = ["vcti.error.core", "vcti.error.core.*"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
"vcti.error.core" = ["py.typed"]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
test = ["pytest", "pytest-cov"]
|
|
37
|
+
lint = ["ruff"]
|
|
38
|
+
typecheck = ["mypy"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
addopts = "--cov=vcti.error.core --cov-report=term-missing --cov-fail-under=95"
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
python_version = "3.12"
|
|
45
|
+
strict = true
|
|
46
|
+
files = ["src"]
|
|
47
|
+
namespace_packages = true
|
|
48
|
+
explicit_package_bases = true
|
|
49
|
+
mypy_path = ["src"]
|
|
50
|
+
|
|
51
|
+
[tool.coverage.run]
|
|
52
|
+
branch = true
|
|
53
|
+
|
|
54
|
+
[tool.coverage.report]
|
|
55
|
+
exclude_also = [
|
|
56
|
+
"raise NotImplementedError",
|
|
57
|
+
"if TYPE_CHECKING:",
|
|
58
|
+
"if __name__ == .__main__.:",
|
|
59
|
+
"@(abc\\.)?abstractmethod",
|
|
60
|
+
"\\.\\.\\.",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
target-version = "py312"
|
|
65
|
+
line-length = 99
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint]
|
|
68
|
+
select = ["E", "F", "W", "I", "UP"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""vcti.error.core — exit-code mechanics: validate, combine, and resolve code mappings."""
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
|
|
7
|
+
from .codes import MAX_EXIT_CODE, ErrorCodes, combine, error_code, validate
|
|
8
|
+
from .errors import CodeCollisionError, CodeRangeError, ErrorCodesError
|
|
9
|
+
from .system_error import EXCEPTION_ERRNO_MAPPING, system_error
|
|
10
|
+
|
|
11
|
+
__version__ = version("vcti-error-core")
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"EXCEPTION_ERRNO_MAPPING",
|
|
15
|
+
"MAX_EXIT_CODE",
|
|
16
|
+
"CodeCollisionError",
|
|
17
|
+
"CodeRangeError",
|
|
18
|
+
"ErrorCodes",
|
|
19
|
+
"ErrorCodesError",
|
|
20
|
+
"combine",
|
|
21
|
+
"error_code",
|
|
22
|
+
"system_error",
|
|
23
|
+
"validate",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Pure functions over exception-to-exit-code mappings.
|
|
4
|
+
|
|
5
|
+
A *contract* is nothing but a read-only ``Mapping[type[BaseException],
|
|
6
|
+
int]`` owned by whoever defines it — typically a contract package that
|
|
7
|
+
exports the mapping and calls :func:`validate` at import time. This
|
|
8
|
+
module holds no state and pre-registers nothing; applications compose
|
|
9
|
+
contracts explicitly with :func:`combine` and resolve exceptions with
|
|
10
|
+
:func:`error_code`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from types import MappingProxyType
|
|
17
|
+
|
|
18
|
+
from .errors import CodeCollisionError, CodeRangeError
|
|
19
|
+
|
|
20
|
+
type ErrorCodes = Mapping[type[BaseException], int]
|
|
21
|
+
"""An exception-to-exit-code contract: class -> code (1..255)."""
|
|
22
|
+
|
|
23
|
+
#: Highest exit code that survives POSIX truncation (codes are taken
|
|
24
|
+
#: modulo 256; 0 is reserved for success).
|
|
25
|
+
MAX_EXIT_CODE = 255
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate(codes: ErrorCodes, *, claim: range | None = None) -> None:
|
|
29
|
+
"""Check that ``codes`` is a well-formed exit-code contract.
|
|
30
|
+
|
|
31
|
+
Contract packages call this at import time so a malformed contract
|
|
32
|
+
fails at startup, not in an error path.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
codes: The contract mapping to check.
|
|
36
|
+
claim: The code range this contract claims (e.g.
|
|
37
|
+
``range(32, 64)``). When given, every code must fall inside
|
|
38
|
+
it — the mechanism that keeps separately-owned contracts
|
|
39
|
+
collision-free by allocation rather than by luck.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
TypeError: If a key is not an exception class or a code is not
|
|
43
|
+
an ``int`` (``bool`` is rejected explicitly — ``True`` is
|
|
44
|
+
almost certainly a bug, not exit code 1).
|
|
45
|
+
CodeRangeError: If a code is outside ``1..255`` or outside
|
|
46
|
+
``claim``.
|
|
47
|
+
"""
|
|
48
|
+
for cls, code in codes.items():
|
|
49
|
+
if not (isinstance(cls, type) and issubclass(cls, BaseException)):
|
|
50
|
+
raise TypeError(f"contract key {cls!r} is not an exception class")
|
|
51
|
+
if isinstance(code, bool) or not isinstance(code, int):
|
|
52
|
+
raise TypeError(f"exit code for {cls.__name__} must be an int, got {code!r}")
|
|
53
|
+
if not 1 <= code <= MAX_EXIT_CODE:
|
|
54
|
+
raise CodeRangeError(
|
|
55
|
+
f"exit code {code} for {cls.__name__} is outside 1..{MAX_EXIT_CODE} "
|
|
56
|
+
f"(0 is reserved for success; POSIX truncates codes modulo 256)"
|
|
57
|
+
)
|
|
58
|
+
if claim is not None and code not in claim:
|
|
59
|
+
raise CodeRangeError(
|
|
60
|
+
f"exit code {code} for {cls.__name__} is outside the claimed "
|
|
61
|
+
f"range {claim.start}..{claim.stop - 1}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def combine(*sets: ErrorCodes) -> ErrorCodes:
|
|
66
|
+
"""Merge contract mappings into one read-only contract.
|
|
67
|
+
|
|
68
|
+
Composition is an application-startup act: an app combines the
|
|
69
|
+
contracts it speaks (its platform's, its own) into the single
|
|
70
|
+
mapping it resolves against. Later sets may repeat earlier entries
|
|
71
|
+
verbatim; they may not disagree.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A read-only merged mapping.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
CodeCollisionError: If the same exception class is mapped to
|
|
78
|
+
two different codes.
|
|
79
|
+
"""
|
|
80
|
+
merged: dict[type[BaseException], int] = {}
|
|
81
|
+
for codes in sets:
|
|
82
|
+
for cls, code in codes.items():
|
|
83
|
+
existing = merged.get(cls)
|
|
84
|
+
if existing is not None and existing != code:
|
|
85
|
+
raise CodeCollisionError(f"{cls.__name__} is mapped to both {existing} and {code}")
|
|
86
|
+
merged[cls] = code
|
|
87
|
+
return MappingProxyType(merged)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def error_code(
|
|
91
|
+
exc: BaseException | type[BaseException],
|
|
92
|
+
codes: ErrorCodes,
|
|
93
|
+
*,
|
|
94
|
+
default: int,
|
|
95
|
+
) -> int:
|
|
96
|
+
"""Resolve an exception (instance or class) to its exit code.
|
|
97
|
+
|
|
98
|
+
Resolution is MRO-aware: the nearest mapped ancestor wins, so a
|
|
99
|
+
subclass of a mapped exception inherits its category instead of
|
|
100
|
+
falling through to ``default``.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
exc: The exception instance or class to classify.
|
|
104
|
+
codes: The contract to resolve against.
|
|
105
|
+
default: Returned when no ancestor is mapped. Required — what
|
|
106
|
+
"unspecified" means is contract vocabulary, and the
|
|
107
|
+
mechanics have no opinion about it.
|
|
108
|
+
"""
|
|
109
|
+
cls = exc if isinstance(exc, type) else type(exc)
|
|
110
|
+
for ancestor in cls.__mro__:
|
|
111
|
+
if ancestor in codes:
|
|
112
|
+
return codes[ancestor]
|
|
113
|
+
return default
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Exceptions raised by the exit-code mechanics.
|
|
4
|
+
|
|
5
|
+
Catch :class:`ErrorCodesError` to handle any mechanics failure
|
|
6
|
+
generically. Each leaf also inherits the closest stdlib exception so
|
|
7
|
+
existing ``except ValueError`` call sites keep working.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ErrorCodesError(Exception):
|
|
14
|
+
"""Base class for all vcti-error-core errors."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CodeRangeError(ErrorCodesError, ValueError):
|
|
18
|
+
"""An exit code is outside the valid or claimed range.
|
|
19
|
+
|
|
20
|
+
Valid codes are 1..255: 0 is reserved for success, and POSIX
|
|
21
|
+
truncates exit codes modulo 256, so anything above 255 would
|
|
22
|
+
silently alias a smaller code. When a contract declares a claimed
|
|
23
|
+
range, every code must also fall inside it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CodeCollisionError(ErrorCodesError, ValueError):
|
|
28
|
+
"""The same exception class is mapped to two different exit codes.
|
|
29
|
+
|
|
30
|
+
Raised by ``combine()`` when contract mappings disagree about a
|
|
31
|
+
class. Several classes sharing one code is legal — codes are
|
|
32
|
+
categories — but one class must resolve to exactly one code.
|
|
33
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Errno-correct construction of OS-level exceptions.
|
|
4
|
+
|
|
5
|
+
Python's OS exceptions (``FileNotFoundError``, ``IsADirectoryError``,
|
|
6
|
+
...) carry an ``errno`` when the operating system raises them — but
|
|
7
|
+
``errno`` is ``None`` when they are raised programmatically, which
|
|
8
|
+
breaks callers that inspect ``err.errno``. :func:`system_error` builds
|
|
9
|
+
the instance the way the OS would.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import errno
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from types import MappingProxyType
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
#: OS exception class -> the errno the operating system would set.
|
|
21
|
+
#: (Keyed wide as ``type[Exception]`` so lookups with arbitrary
|
|
22
|
+
#: exception classes type-check; every entry is an OSError subclass.)
|
|
23
|
+
EXCEPTION_ERRNO_MAPPING: Mapping[type[Exception], int] = MappingProxyType(
|
|
24
|
+
{
|
|
25
|
+
FileNotFoundError: errno.ENOENT,
|
|
26
|
+
FileExistsError: errno.EEXIST,
|
|
27
|
+
IsADirectoryError: errno.EISDIR,
|
|
28
|
+
NotADirectoryError: errno.ENOTDIR,
|
|
29
|
+
PermissionError: errno.EACCES,
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def system_error(exception_class: type[Exception], *args: Any, **kwargs: Any) -> Exception:
|
|
35
|
+
"""Create an exception instance carrying the appropriate errno.
|
|
36
|
+
|
|
37
|
+
Intended for ``OSError`` subclasses, whose two leading constructor
|
|
38
|
+
arguments are ``(errno, strerror)`` — further ``args`` (e.g. a
|
|
39
|
+
filename) are appended. Classes without a known errno fall back to
|
|
40
|
+
``EINVAL``; for non-``OSError`` classes the errno pair simply lands
|
|
41
|
+
in ``args``.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> err = system_error(FileNotFoundError, "config.yaml")
|
|
45
|
+
>>> err.errno == errno.ENOENT and err.filename == "config.yaml"
|
|
46
|
+
True
|
|
47
|
+
"""
|
|
48
|
+
errno_value = EXCEPTION_ERRNO_MAPPING.get(exception_class, errno.EINVAL)
|
|
49
|
+
return exception_class(errno_value, os.strerror(errno_value), *args, **kwargs)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vcti-error-core
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Exit-code mechanics: validation, composition, and MRO-aware resolution of exception-to-exit-code mappings
|
|
5
|
+
Author: Visual Collaboration Technologies Inc.
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Repository, https://github.com/vcollab/vcti-python-error-core
|
|
8
|
+
Project-URL: Changelog, https://github.com/vcollab/vcti-python-error-core/blob/main/CHANGELOG.md
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Python: <3.15,>=3.12
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest; extra == "test"
|
|
18
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
19
|
+
Provides-Extra: lint
|
|
20
|
+
Requires-Dist: ruff; extra == "lint"
|
|
21
|
+
Provides-Extra: typecheck
|
|
22
|
+
Requires-Dist: mypy; extra == "typecheck"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# vcti-error-core
|
|
26
|
+
|
|
27
|
+
Exit-code mechanics: validation, composition, and MRO-aware resolution of exception-to-exit-code mappings.
|
|
28
|
+
|
|
29
|
+
## Overview
|
|
30
|
+
|
|
31
|
+
When a Python script runs as a subprocess of a partner application, the
|
|
32
|
+
process **exit code is the only reliable contract** between caller and
|
|
33
|
+
callee. `vcti-error-core` provides the mechanics for that contract — and
|
|
34
|
+
deliberately none of the vocabulary. A contract is plain data: a
|
|
35
|
+
read-only `Mapping[type[BaseException], int]` owned by whoever defines
|
|
36
|
+
it. This package validates such mappings (ranges, claims), composes them
|
|
37
|
+
collision-safely, and resolves an exception to its code MRO-aware, plus
|
|
38
|
+
an errno-correct constructor for OS exceptions. The actual VCollab code
|
|
39
|
+
vocabularies live in [`vcti-error-contract`](https://pypi.org/project/vcti-error-contract/);
|
|
40
|
+
any team can publish its own contract package on these mechanics.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install vcti-error-core
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
> Migrating from the retired `vcti-error` 1.x? Uninstall it first — it
|
|
49
|
+
> owns `vcti/error/__init__.py`, which conflicts with this namespace
|
|
50
|
+
> family. Its API lives on in `vcti.error.contract.legacy`.
|
|
51
|
+
|
|
52
|
+
### In `requirements.txt`
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
vcti-error-core>=1.0.0
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### In `pyproject.toml` dependencies
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
dependencies = [
|
|
62
|
+
"vcti-error-core>=1.0.0",
|
|
63
|
+
]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
A contract is a dict; the mechanics do the rest:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from vcti.error.core import combine, error_code, validate
|
|
74
|
+
|
|
75
|
+
class AppError(Exception): ...
|
|
76
|
+
class LicenseExpired(AppError): ...
|
|
77
|
+
|
|
78
|
+
MY_CODES = {AppError: 32, LicenseExpired: 33}
|
|
79
|
+
validate(MY_CODES, claim=range(32, 64)) # fail fast at startup
|
|
80
|
+
|
|
81
|
+
CODES = combine({Exception: 1, FileNotFoundError: 2}, MY_CODES)
|
|
82
|
+
|
|
83
|
+
def main() -> int:
|
|
84
|
+
try:
|
|
85
|
+
run()
|
|
86
|
+
return 0
|
|
87
|
+
except Exception as err:
|
|
88
|
+
return error_code(err, CODES, default=1)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Resolution is MRO-aware — a subclass inherits its nearest mapped
|
|
92
|
+
ancestor's code (`class QuotaExceeded(LicenseExpired)` still exits 33).
|
|
93
|
+
Composition is collision-safe — two contracts disagreeing about a class
|
|
94
|
+
raise `CodeCollisionError` at startup, not in production log analysis.
|
|
95
|
+
|
|
96
|
+
Errno-correct OS errors for programmatic raises:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from vcti.error.core import system_error
|
|
100
|
+
|
|
101
|
+
raise system_error(FileNotFoundError, "config.yaml")
|
|
102
|
+
# FileNotFoundError(ENOENT, "No such file or directory", "config.yaml")
|
|
103
|
+
# -> err.errno / err.strerror / err.filename all set, as if the OS raised it
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## API surface
|
|
109
|
+
|
|
110
|
+
| Name | Kind | Purpose |
|
|
111
|
+
|------|------|---------|
|
|
112
|
+
| `ErrorCodes` | type alias | `Mapping[type[BaseException], int]` — a contract |
|
|
113
|
+
| `validate(codes, *, claim)` | function | Range/type checks; `claim` enforces a contract's allocated range |
|
|
114
|
+
| `combine(*sets)` | function | Read-only merge; disagreement → `CodeCollisionError` |
|
|
115
|
+
| `error_code(exc, codes, *, default)` | function | MRO-aware resolution; `default` is caller-supplied |
|
|
116
|
+
| `system_error(cls, *args)` | function | Errno-correct OS-exception construction |
|
|
117
|
+
| `MAX_EXIT_CODE` | constant | 255 (POSIX truncates exit codes modulo 256) |
|
|
118
|
+
| `ErrorCodesError` | exception | Base; `CodeRangeError`, `CodeCollisionError` |
|
|
119
|
+
|
|
120
|
+
Rules baked into `validate`: codes are 1–255 (0 is success), stay ≤125
|
|
121
|
+
in practice (126+ collides with shell/signal conventions), and `bool`
|
|
122
|
+
codes are rejected.
|
|
123
|
+
|
|
124
|
+
See [docs/design.md](docs/design.md) for the mechanics/contract split
|
|
125
|
+
and [docs/extending.md](docs/extending.md) for writing a contract
|
|
126
|
+
package.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Dependencies
|
|
131
|
+
|
|
132
|
+
None. Standard library only.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/vcti/error/core/__init__.py
|
|
5
|
+
src/vcti/error/core/codes.py
|
|
6
|
+
src/vcti/error/core/errors.py
|
|
7
|
+
src/vcti/error/core/py.typed
|
|
8
|
+
src/vcti/error/core/system_error.py
|
|
9
|
+
src/vcti_error_core.egg-info/PKG-INFO
|
|
10
|
+
src/vcti_error_core.egg-info/SOURCES.txt
|
|
11
|
+
src/vcti_error_core.egg-info/dependency_links.txt
|
|
12
|
+
src/vcti_error_core.egg-info/requires.txt
|
|
13
|
+
src/vcti_error_core.egg-info/top_level.txt
|
|
14
|
+
tests/test_codes.py
|
|
15
|
+
tests/test_system_error.py
|
|
16
|
+
tests/test_version.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vcti
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Tests for validate(), combine(), and error_code()."""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from vcti.error.core import (
|
|
8
|
+
CodeCollisionError,
|
|
9
|
+
CodeRangeError,
|
|
10
|
+
ErrorCodesError,
|
|
11
|
+
combine,
|
|
12
|
+
error_code,
|
|
13
|
+
validate,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AppError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LicenseExpired(AppError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestValidate:
|
|
26
|
+
def test_valid_contract_passes(self):
|
|
27
|
+
assert validate({Exception: 1, FileNotFoundError: 2, AppError: 32}) is None
|
|
28
|
+
|
|
29
|
+
def test_empty_contract_passes(self):
|
|
30
|
+
assert validate({}) is None
|
|
31
|
+
|
|
32
|
+
def test_claim_containing_all_codes_passes(self):
|
|
33
|
+
assert validate({AppError: 32, LicenseExpired: 63}, claim=range(32, 64)) is None
|
|
34
|
+
|
|
35
|
+
def test_code_zero_raises(self):
|
|
36
|
+
with pytest.raises(CodeRangeError, match="reserved for success"):
|
|
37
|
+
validate({AppError: 0})
|
|
38
|
+
|
|
39
|
+
def test_code_above_255_raises(self):
|
|
40
|
+
with pytest.raises(CodeRangeError, match="modulo 256"):
|
|
41
|
+
validate({AppError: 256})
|
|
42
|
+
|
|
43
|
+
def test_negative_code_raises(self):
|
|
44
|
+
with pytest.raises(CodeRangeError):
|
|
45
|
+
validate({AppError: -1})
|
|
46
|
+
|
|
47
|
+
def test_code_outside_claim_raises(self):
|
|
48
|
+
with pytest.raises(CodeRangeError, match=r"outside the claimed range 32\.\.63"):
|
|
49
|
+
validate({AppError: 5}, claim=range(32, 64))
|
|
50
|
+
|
|
51
|
+
def test_bool_code_raises_type_error(self):
|
|
52
|
+
with pytest.raises(TypeError, match="must be an int"):
|
|
53
|
+
validate({AppError: True})
|
|
54
|
+
|
|
55
|
+
def test_non_int_code_raises_type_error(self):
|
|
56
|
+
with pytest.raises(TypeError, match="must be an int"):
|
|
57
|
+
validate({AppError: "2"})
|
|
58
|
+
|
|
59
|
+
def test_non_class_key_raises_type_error(self):
|
|
60
|
+
with pytest.raises(TypeError, match="not an exception class"):
|
|
61
|
+
validate({"AppError": 2})
|
|
62
|
+
|
|
63
|
+
def test_non_exception_class_key_raises_type_error(self):
|
|
64
|
+
with pytest.raises(TypeError, match="not an exception class"):
|
|
65
|
+
validate({int: 2})
|
|
66
|
+
|
|
67
|
+
def test_base_exception_subclass_is_allowed(self):
|
|
68
|
+
assert validate({KeyboardInterrupt: 130}) is None
|
|
69
|
+
|
|
70
|
+
def test_range_error_is_value_error(self):
|
|
71
|
+
with pytest.raises(ValueError):
|
|
72
|
+
validate({AppError: 0})
|
|
73
|
+
with pytest.raises(ErrorCodesError):
|
|
74
|
+
validate({AppError: 0})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestCombine:
|
|
78
|
+
def test_merges_disjoint_contracts(self):
|
|
79
|
+
merged = combine({Exception: 1}, {AppError: 32})
|
|
80
|
+
assert merged == {Exception: 1, AppError: 32}
|
|
81
|
+
|
|
82
|
+
def test_result_is_read_only(self):
|
|
83
|
+
merged = combine({Exception: 1})
|
|
84
|
+
with pytest.raises(TypeError):
|
|
85
|
+
merged[AppError] = 32 # type: ignore[index]
|
|
86
|
+
|
|
87
|
+
def test_agreeing_duplicate_is_allowed(self):
|
|
88
|
+
merged = combine({Exception: 1, AppError: 32}, {AppError: 32})
|
|
89
|
+
assert merged[AppError] == 32
|
|
90
|
+
|
|
91
|
+
def test_classes_may_share_a_code(self):
|
|
92
|
+
merged = combine({AppError: 32, LicenseExpired: 32})
|
|
93
|
+
assert merged[AppError] == merged[LicenseExpired] == 32
|
|
94
|
+
|
|
95
|
+
def test_disagreeing_codes_raise(self):
|
|
96
|
+
with pytest.raises(CodeCollisionError, match="AppError is mapped to both 32 and 33"):
|
|
97
|
+
combine({AppError: 32}, {AppError: 33})
|
|
98
|
+
|
|
99
|
+
def test_no_arguments_gives_empty_contract(self):
|
|
100
|
+
assert dict(combine()) == {}
|
|
101
|
+
|
|
102
|
+
def test_inputs_are_not_mutated(self):
|
|
103
|
+
first = {Exception: 1}
|
|
104
|
+
combine(first, {AppError: 32})
|
|
105
|
+
assert first == {Exception: 1}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestErrorCode:
|
|
109
|
+
CODES = {Exception: 1, FileNotFoundError: 2, AppError: 32}
|
|
110
|
+
|
|
111
|
+
def test_exact_class_match(self):
|
|
112
|
+
assert error_code(FileNotFoundError, self.CODES, default=1) == 2
|
|
113
|
+
|
|
114
|
+
def test_instance_accepted(self):
|
|
115
|
+
assert error_code(FileNotFoundError("gone"), self.CODES, default=1) == 2
|
|
116
|
+
|
|
117
|
+
def test_subclass_inherits_ancestor_code(self):
|
|
118
|
+
assert error_code(LicenseExpired, self.CODES, default=1) == 32
|
|
119
|
+
|
|
120
|
+
def test_nearest_ancestor_wins(self):
|
|
121
|
+
codes = {Exception: 1, AppError: 32, LicenseExpired: 33}
|
|
122
|
+
assert error_code(LicenseExpired, codes, default=1) == 33
|
|
123
|
+
|
|
124
|
+
def test_unmapped_falls_to_exception_when_mapped(self):
|
|
125
|
+
assert error_code(RuntimeError, self.CODES, default=99) == 1
|
|
126
|
+
|
|
127
|
+
def test_unmapped_hierarchy_returns_default(self):
|
|
128
|
+
# KeyboardInterrupt derives from BaseException, not Exception.
|
|
129
|
+
assert error_code(KeyboardInterrupt, self.CODES, default=99) == 99
|
|
130
|
+
|
|
131
|
+
def test_default_is_keyword_only_and_required(self):
|
|
132
|
+
with pytest.raises(TypeError):
|
|
133
|
+
error_code(FileNotFoundError, self.CODES) # type: ignore[call-arg]
|
|
134
|
+
|
|
135
|
+
def test_empty_contract_returns_default(self):
|
|
136
|
+
assert error_code(AppError, {}, default=7) == 7
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Tests for errno-correct system error construction."""
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from vcti.error.core import EXCEPTION_ERRNO_MAPPING, system_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestSystemError:
|
|
14
|
+
@pytest.mark.parametrize(("exception_cls", "expected_errno"), EXCEPTION_ERRNO_MAPPING.items())
|
|
15
|
+
def test_known_classes_get_their_errno(self, exception_cls, expected_errno):
|
|
16
|
+
err = system_error(exception_cls)
|
|
17
|
+
assert err.errno == expected_errno
|
|
18
|
+
assert err.strerror == os.strerror(expected_errno)
|
|
19
|
+
|
|
20
|
+
def test_filename_argument_passes_through(self):
|
|
21
|
+
err = system_error(FileNotFoundError, "/some/path")
|
|
22
|
+
assert err.errno == errno.ENOENT
|
|
23
|
+
assert err.filename == "/some/path"
|
|
24
|
+
|
|
25
|
+
def test_unknown_class_falls_back_to_einval(self):
|
|
26
|
+
class Unknown(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
err = system_error(Unknown)
|
|
30
|
+
assert err.args == (errno.EINVAL, os.strerror(errno.EINVAL))
|
|
31
|
+
|
|
32
|
+
def test_mapping_is_read_only(self):
|
|
33
|
+
with pytest.raises(TypeError):
|
|
34
|
+
EXCEPTION_ERRNO_MAPPING[OSError] = errno.EIO # type: ignore[index]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Version tests for vcti-error-core."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import vcti.error.core
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestVersion:
|
|
11
|
+
def test_version_exists(self):
|
|
12
|
+
assert hasattr(vcti.error.core, "__version__")
|
|
13
|
+
|
|
14
|
+
def test_version_is_valid_semver(self):
|
|
15
|
+
assert re.match(r"^\d+\.\d+\.\d+", vcti.error.core.__version__)
|