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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,10 @@
1
+
2
+ [lint]
3
+ ruff
4
+
5
+ [test]
6
+ pytest
7
+ pytest-cov
8
+
9
+ [typecheck]
10
+ mypy
@@ -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__)