diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4b85cfe --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # [pcisar] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: https://firebirdsql.org/en/donate/ diff --git a/.gitignore b/.gitignore index 9f146d8..633fd26 100644 --- a/.gitignore +++ b/.gitignore @@ -133,5 +133,9 @@ dmypy.json *.wpr *.wpu -# Sphinx build -docs/_build \ No newline at end of file +# Doc build +docs/site +docs/firebird-base.docset/ + +# Other local files and directories +local/ diff --git a/.readthedocs.yml b/.readthedocs.yml index 279e491..e66dae9 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -5,16 +5,16 @@ # Required version: 2 -# Build documentation in the docs/ directory with Sphinx -sphinx: - configuration: docs/conf.py - build: - os: "ubuntu-22.04" + os: "ubuntu-24.04" tools: python: "3.11" - -# Optionally set the version of Python and requirements required to build your docs -python: + jobs: install: - - requirements: docs/requirements.txt + - pip install . zensical mkdocstrings-python + build: + html: + - cd docs && zensical build + post_build: + - mkdir -p "$READTHEDOCS_OUTPUT/html" + - cp -a docs/site/. "$READTHEDOCS_OUTPUT/html/" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..694f22c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# Agent guidance + +This repository is the `firebird-base` Python library, a collection of modules under `src/firebird/base/`. +Work from the repository root when using the paths and commands below. + +Read [development/README.md](development/README.md) for the package map, dependencies, +build and test workflow. Before changing a module, read its matching `development/.md` +file and update that file when its architecture or maintenance contract changes. The module +files are the development reference; `docs/docs/` contains published user documentation. + +## Working rules + +- Keep changes scoped to the affected module and its tests. This library is shared by other + Firebird Python projects, so preserve public signatures and serialization formats unless + the task explicitly changes them. +- Add or adjust tests in `tests/test_.py`; configuration option tests belong in + `tests/config/`. Exercise both normal behavior and relevant failure paths. +- Do not hand edit generated `src/firebird/base/config_pb2.py`, `src/firebird/base/config_pb2.pyi`, + or `tests/base_test_pb2.*`. Update the corresponding `.proto` source and regenerate the artifacts + if the schema changes. +- Run focused tests first, then `hatch test` for the configured Python matrix when available. + Run `hatch run ruff check src/firebird/base` and `git diff --check` for code changes. + Report any unavailable environment or pre-existing failure separately. +- Follow `pyproject.toml` for Python support, dependency versions, Ruff settings, and Hatch + environments. Preserve the existing module style and license headers. + +## Module references + +- [types](development/types.md) +- [collections](development/collections.md) +- [strconv](development/strconv.md) +- [config](development/config.md) +- [buffer](development/buffer.md) +- [hooks](development/hooks.md) +- [logging](development/logging.md) +- [trace](development/trace.md) +- [protobuf](development/protobuf.md) +- [signal](development/signal.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5b6ff..671d7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,99 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Development + +### Added + +- Support for hooks registered with `ANY` as the source, including a specific event or `ANY` + event, with regression tests for callback delivery and removal. +- Generated `llms.txt` for the published documentation. +- Hatch scripts for regenerating the configuration and test protobuf bindings. +- `AGENTS.md` and per-module development guides. + +### Changed + +- Migrated the documentation from Sphinx and reStructuredText to Zensical and Markdown, + including the Read the Docs and docset build workflows. +- Linked the published changelog to this file and expanded the configuration, collections, + buffer, logging, trace, and protobuf documentation. +- Updated the protobuf tests and regenerated the test protobuf binding. + +### Fixed + +- Corrected the public protobuf descriptor registration name to `register_descriptor()`; + the previous `register_decriptor()` spelling remains as a deprecated alias until 3.0. +- Corrected configuration, trace, and signal documentation examples and their generated + API references. + +## [2.0.3] - 2026-04-17 + +### Fixed + +- Problem with `entry_points` in `protobuf` module under Python 3.14 + +## [2.0.2] - 2025-06-02 + +### Fixed + +- "quick fingers" issue with `_decompose` fix. + +## [2.0.1] - 2025-06-02 + +### Fixed + +- Issue with trace configuration. +- Issues with `_decompose`. +- Signature match in `eventsocket`. + +## [2.0.0] - 2025-04-30 + +### Added + +- `firebird.base.buffer.MemoryBuffer.get_raw` method. +- `get_raw` method to `BufferFactory`, `BytesBufferFactory` and `CTypesBufferFactory`. +- `__repr__` method for `PyCode` and `PyCallable` that will limit output to 50 characters. +- Optional `encoding` parameter for `ZMQAddress` constructor. + +### Changed + +- Tests changed from `unittest` to `pytest`, 96% code coverage. +- Minimal Python version raised to 3.11. +- The `firebird.base.logging` module was completelly reworked. +- Function `firebird.base.types.Conjunctive` renamed to `conjunctive`. +- `firebird.base.collections.DataList.__init__` parameter `frozen` is now keyword-only. +- `firebird.base.collections.DataList.extract` parameter `copy` is now keyword-only. +- `firebird.base.collections.DataList.sort` parameter `reverse` is now keyword-only. +- `firebird.base.collections.DataList.split` parameter `frozen` is now keyword-only. +- `firebird.base.collections.Registry.popitem` parameter `last` is now keyword-only. +- `firebird.base.collections.BaseObjectCollection.contains` parameter `expr` now does not have default value. +- Deprecated `firebird.base.config.create_config` function was removed. +- `firebird.base.config.DirectoryScheme` parameter `force_home` is now keyword only. +- `firebird.base.config.Option` parameters `required` and `default` are now keyword only. +- Parameter `context` was removed from `firebird.base.trace.traced` decorator. +- Option `context` was removed from `firebird.base.trace.BaseTraceConfig`. +- Log function return value as `repr` rather than `str`. +- Sentinel objects completely reworked. Individual sentinels are now classes derived from `Sentinel`. + +### Fixed + +- Broken `firebird.base.types.Distinct` support for dataclasses and hash function. +- Raise `BufferError` istead `IOError` in `firebird.base.buffer.MemoryBuffer` methods `resize`, + `read` and `read_number` +- Problem with `firebird.base.collections.Registry.pop` that did not raised `KeyError` when + `default` was not specified. +- Bug in `firebird.base.collections.Registry.popitem` with `last` = True. +- Problem with name handling in `firebird.base.config.ConfigOption.clear` and `set_value`. +- Problem with `firebird.base.config.WindowsDirectoryScheme` and `firebird.base.config.MacOSDirectoryScheme` constructors. +- Problem with `firebird.base.config.ListOption.item_types` value. +- Problem with internal `.Convertor` initialization in `firebird.base.config.ListOption`. +- Use copy of `default` list stead direct use in `firebird.base.config.ListOption`. +- `firebird.base.config.ListOption.get_formatted` and `firebird.base.config.ListOption.get_as_str` + should return typed values for multitype lists. +- `firebird.base.config.ConfigOption.validate` should validate the `Config` as well if defined. +- `firebird.base.config.ConfigListOption.validate` should report error for empty list when `required`. +- Problem with conversion of flags from string in `firebird.base.strconv`. + ## [1.8.0] - 2024-05-03 ### Added diff --git a/README.md b/README.md index da3c7aa..9d31f3f 100644 --- a/README.md +++ b/README.md @@ -5,160 +5,44 @@ [![PyPI - Version](https://img.shields.io/pypi/v/firebird-base.svg)](https://pypi.org/project/firebird-base) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/firebird-base.svg)](https://pypi.org/project/firebird-base) [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) +[![PyPI - Downloads](https://img.shields.io/pypi/dm/firebird-base)](https://pypi.org/project/firebird-base) +[![Libraries.io SourceRank](https://img.shields.io/librariesio/sourcerank/pypi/firebird-base)](https://libraries.io/pypi/firebird-base) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/FirebirdSQL/python3-base) -The firebird-base package is a set of Python 3 modules commonly used by [Firebird Project](https://github.com/FirebirdSQL) -in various development projects (for example the firebird-driver or Saturnin). However, these -modules have general applicability outside the scope of development for [Firebird](https://www.firebirdsql.org). - ------ - -**Table of Contents** - -- [Installation](#installation) -- [License](#license) -- [Introduction](#introduction) -- [Documentation](#documentation) +`firebird-base` provides reusable Python modules used by the [Firebird Project](https://github.com/FirebirdSQL) +and other applications. The modules can be used independently of the Firebird database. ## Installation +Requires Python `>=3.11,<4`. + ```console pip install firebird-base ``` -## License - -`firebird-base` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. - -## Introduction - -### Common data types - -The `types` module provides collection of classes and other types that are often used by -other library modules or applications. - -- Exception `Error` that is intended to be used as a base class of all application-related - errors. The important difference from `Exception` class is that `Error` accepts keyword - arguments, that are stored into instance attributes with the same name. -- `Singleton` base class for singletons. -- `Sentinel` base class for named sentinel objects that provide meaningful `str` and `repr`, - along with collection of predefined sentinels. -- `Distinct` abstract base class for classes (incl. dataclasses) with distinct instances. -- Collection of `Enums` and `custom string types`. - -### Various collection types - -The `collections` module provides data structures that behave much like builtin `list` and -`dict` types, but with direct support of operations that can use structured data stored in -container, and which would normally require utilization of `operator`, `functools` or other -means. - -All containers provide next operations: - -- `filter` and `filterfalse` that return generator that yields items for which expr is - evaluated as True (or False). -- `find` that returns first item for which expr is evaluated as True, or default. -- `contains` that returns True if there is any item for which expr is evaluated as True. -- `occurrence` that returns number of items for which expr is evaluated as True. -- `all` and `any` that return True if expr is evaluated as True for all or any collection element(s). -- `report` that returns generator that yields data produced by expression(s) evaluated on collection items. - -Individual collection types provide additional operations like splitting and extracting -based on expression etc. - -Expressions used by these methods could be strings that contain Python expression referencing -the collection item(s), or lambda functions. - -### Data conversion from/to string - -While Python types typically support conversion to string via builtin `str()` function (and -custom `__str__` methods), there is no symetric operation that converts string created by -`str()` back to typed value. Module `strconv` provides support for such symetric conversion -from/to string for any data type. - -Symetric string conversion is used by `firebird.base.config` module, notably by -`firebird.base.config.ListOption` and `firebird.base.config.DataclassOption`. You can -extend the range of data types supported by these options by registering convertors for -required data types. +Import the submodules you need, for example `import firebird.base.config`. -### Configuration definitions - -Complex applications (and some library modules like `logging`) could be often parametrized -via configuration. Module `firebird.base.config` provides a framework for unified structured -configuration that supports: - -- configuration options of various data type, including lists and other complex types -- validation -- direct manipulation of configuration values -- reading from (and writing into) configuration in `configparser` format -- exchanging configuration (for example between processes) using Google protobuf messages - -Additionally, the `ApplicationDirectoryScheme` abstract base class defines set of mostly -used application directories. The function `get_directory_scheme()` could be then used -to obtain instance that implements platform-specific standards for file-system location -for these directories. Currently, only "Windows", "Linux" and "MacOS" directory schemes -are supported. - -### Memory buffer manager - -Module `buffer` provides a raw memory buffer manager with convenient methods to read/write -data of various data types. - -### Hook manager - -Module `hooks` provides a general framework for callbacks and “hookable” events, that -supports multiple usage strategies. - -### Context-based logging - -Module `logging` provides context-based logging system built on top of standard `logging` -module. - -The context-based logging: - -- Adds context information (defined as combination of topic, agent and context string values) - into `logging.LogRecord`, that could be used in logging message. -- Adds support for f-string message format. -- Allows assignment of loggers to specific contexts. The `LoggingManager` class maintains - a set of bindings between `Logger` objects and combination of `agent`, `context` and `topic` - specifications. It’s possible to bind loggers to exact combination of values, or whole - sets of values using `ANY` sentinel. It means that is possible to assign specific Logger - to log messages for particular agent in any context, or any agent operating in specific - context etc. - -### Trace/audit for class instances - -Module `trace` provides trace/audit logging for functions or object methods through -context-based logging provided by `logging` module. - -The trace logging is performed by `traced` decorator. You can use this decorator directly, -or use `TracedMixin` class to automatically decorate methods of class instances on creation. -Each decorated callable could log messages before execution, after successful execution or -on failed execution (when unhandled exception is raised by callable). The trace decorator -can automatically add `agent` and `context` information, and include parameters passed to -callable, execution time, return value, information about raised exception etc. to log messages. - -The trace logging is managed by `TraceManager`, that allows dynamic configuration of traced -callables at runtime. +## Documentation -Trace supports configuration based on `firebird.base.config`. +For module guides and API details, see the [firebird-base documentation](https://firebird-base.rtfd.io/). -### Registry for Google Protocol Buffer messages and enums +## Agent skill -Module `protobuf` provides central registry for Google Protocol Buffer messages and enums. -The generated `*_pb2.py protobuf` files could be registered using `register_decriptor` or -`load_registered` function. The registry could be then used to obtain information about -protobuf messages or enum types, or to create message instances or enum values. +The [firebird-base skill](skills/firebird-base/SKILL.md) provides guidance for using individual +modules. Copy or link its entire `skills/firebird-base` directory from this repository into +the skill directory used by your coding agent: -### Callback systems +| Agent | User skill directory | +| --- | --- | +| [Codex](https://learn.chatgpt.com/docs/build-skills) | `~/.codex/skills/` | +| [Claude Code](https://code.claude.com/docs/en/skills) | `~/.claude/skills/` | +| [Gemini CLI](https://geminicli.com/docs/cli/using-agent-skills/) | `~/.agents/skills/` (also supports `~/.gemini/skills/`) | -Module `firebird.base.signal` provides two callback mechanisms: one based on signals and -slots similar to Qt signal/slot, and second based on optional method delegation similar to -events in Delphi. +## Development -In both cases, the callback callables could be functions, instance or class methods, -partials and lambda functions. The `inspect` module is used to define the signature for -callbacks, and to validate that only compatible callables are assigned. +For the package map, dependencies, build and test workflow, and module maintenance notes, +see [development/README.md](development/README.md) and the other files in [`development/`](development/). -## Documentation +## License -The documentation for this package is available at [https://firebird-base.readthedocs.io](https://firebird-base.readthedocs.io) +`firebird-base` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. diff --git a/convert.py b/convert.py new file mode 100644 index 0000000..653c234 --- /dev/null +++ b/convert.py @@ -0,0 +1,199 @@ +import os +import re + +def convert_rst_to_md(filename, content): + # Rule 6: Determine module name + base_name = os.path.splitext(os.path.basename(filename))[0] + module_name = f"firebird.base.{base_name}" + + # Overrides for some files + if base_name in ['index', 'introduction', 'changelog', 'license', 'modules']: + module_name = "firebird.base" + + lines = content.splitlines() + new_lines = [] + i = 0 + + def process_roles(text): + def replace_role(match): + role = match.group(1) + content = match.group(2) + display_content = content + if display_content.startswith('~'): + display_content = display_content[1:].split('.')[-1] + + if role == 'doc': + return f"[{display_content.capitalize()}]({display_content}.md)" + return f"`{display_content}`" + + return re.sub(r':(doc|ref|class|func|obj|mod|attr|exc):`([^`]+)`', replace_role, text) + + while i < len(lines): + line = lines[i] + + # Skip module/synopsis + if line.strip().startswith('.. module::') or line.strip().startswith(':synopsis:'): + i += 1 + continue + + # Rule 1: Headers + # Title with overline and underline + if i + 2 < len(lines) and re.match(r'^[#*=+^"~-]+$', line.strip()) and \ + re.match(r'^[#*=+^"~-]+$', lines[i+2].strip()) and \ + len(line.strip()) >= len(lines[i+1].strip()) and len(lines[i+2].strip()) >= len(lines[i+1].strip()): + new_lines.append(f"# {lines[i+1].strip()}") + i += 3 + continue + + # Header with underline + if i + 1 < len(lines) and re.match(r'^[#*=+^"~-]+$', lines[i+1].strip()) and \ + len(lines[i+1].strip()) >= len(line.strip()) and len(line.strip()) > 0: + char = lines[i+1].strip()[0] + level = 2 + if char == '#': level = 1 + elif char == '=': level = 2 + elif char == '-': level = 3 + elif char == '^': level = 4 + elif char == '"': level = 5 + elif char == '~': level = 6 + new_lines.append(f"{'#' * level} {line.strip()}") + i += 2 + continue + + # Rule 2: code-block + if line.strip().startswith('.. code-block::'): + lang = line.split('::')[-1].strip() + new_lines.append(f"```{lang}") + i += 1 + # Skip empty lines until indented block + while i < len(lines) and not lines[i].strip(): + i += 1 + # Find indentation + if i < len(lines): + indent = len(lines[i]) - len(lines[i].lstrip()) + while i < len(lines) and (not lines[i].strip() or len(lines[i]) - len(lines[i].lstrip()) >= indent): + new_lines.append(lines[i][indent:]) + i += 1 + new_lines.append("```") + continue + + # Rule 3: Admonitions + admonitions = ['note', 'tip', 'warning', 'important', 'caution', 'seealso', 'admonition', 'error', 'danger'] + match_adm = re.match(r'^\.\.\s+(' + '|'.join(admonitions) + r')::(.*)', line.strip()) + if match_adm: + adm_type = match_adm.group(1) + adm_text = match_adm.group(2).strip() + + if adm_type == 'admonition': + new_lines.append(f"!!! {adm_text}") + i += 1 + else: + new_lines.append(f"!!! {adm_type}") + if adm_text: + new_lines.append(f" {process_roles(adm_text)}") + i += 1 + + # Skip empty lines + while i < len(lines) and not lines[i].strip(): + i += 1 + if i < len(lines): + curr_indent = len(lines[i]) - len(lines[i].lstrip()) + if curr_indent > 0: + indent = curr_indent + while i < len(lines) and (not lines[i].strip() or len(lines[i]) - len(lines[i].lstrip()) >= indent): + content_line = lines[i][indent:] + content_line = process_roles(content_line) + content_line = re.sub(r'^\.\.\s+(' + '|'.join(admonitions) + r')::', r'!!! \1', content_line) + new_lines.append(f" {content_line}") + i += 1 + continue + + # Rule 5: mkdocstrings + match_auto = re.match(r'^\.\.\s+auto(class|function|exception|data)::\s+([\w\.]+)', line.strip()) + if match_auto: + obj_type = match_auto.group(1) + obj_name = match_auto.group(2) + full_name = obj_name if '.' in obj_name else f"{module_name}.{obj_name}" + new_lines.append(f"::: {full_name}") + i += 1 + # Skip options + while i < len(lines) and (not lines[i].strip() or (len(lines[i]) - len(lines[i].lstrip()) > 0 and lines[i].strip().startswith(':'))): + i += 1 + continue + + # Rule 4 & 7: Sphinx roles and toctree + line = process_roles(line) + + if line.strip().startswith('.. toctree::'): + i += 1 + while i < len(lines) and (not lines[i].strip() or lines[i].strip().startswith(':')): + i += 1 + while i < len(lines) and lines[i].strip() and not lines[i].strip().startswith('.'): + fname = lines[i].strip() + new_lines.append(f"- [{fname.capitalize()}]({fname}.md)") + i += 1 + continue + + # Rule 8: license include + if line.strip().startswith('.. include::'): + inc_path = line.split('::')[-1].strip() + if 'LICENSE' in inc_path: + try: + with open('LICENSE', 'r') as f: + new_lines.extend(f.read().splitlines()) + except: + new_lines.append(f"(Include {inc_path})") + i += 1 + continue + + # Horizontal rules + if re.match(r'^[#*=+^"~-]{3,}$', line.strip()): + new_lines.append("---") + i += 1 + continue + + # Handle simple links like `Firebird Project`_ + line = re.sub(r'`([^`]+)`_', r'\1', line) + # Handle link definitions + match_link_def = re.match(r'^\.\.\s+_([^:]+):\s+(.*)', line.strip()) + if match_link_def: + new_lines.append(f"[{match_link_def.group(1)}]: {match_link_def.group(2)}") + i += 1 + continue + + if line.strip().startswith('.. |'): + i += 1 + continue + + if line.strip() == '|': + new_lines.append("") + i += 1 + continue + + new_lines.append(line) + i += 1 + + return "\n".join(new_lines) + +files_to_convert = [ + 'buffer.txt', 'changelog.txt', 'collections.txt', 'config.txt', + 'hooks.txt', 'index.txt', 'introduction.txt', 'license.txt', + 'logging.txt', 'modules.txt', 'protobuf.txt', 'signal.txt', + 'strconv.txt', 'trace.txt', 'types.txt' +] + +for filename in files_to_convert: + src_path = os.path.join('docs', filename) + dest_path = os.path.join('zendocs/docs', filename.replace('.txt', '.md')) + + if os.path.exists(src_path): + with open(src_path, 'r') as f: + content = f.read() + + converted = convert_rst_to_md(filename, content) + + with open(dest_path, 'w') as f: + f.write(converted) + print(f"Converted {src_path} to {dest_path}") + else: + print(f"Skipping {src_path}, not found.") diff --git a/development/README.md b/development/README.md new file mode 100644 index 0000000..ff3399f --- /dev/null +++ b/development/README.md @@ -0,0 +1,49 @@ +# Development map + +All paths and commands here are relative to the repository root. `firebird-base` is a set +of reusable Python modules, not an application with a central startup path. The package +lives in `src/firebird/base/`; `src/firebird/base/__init__.py` has no public reexports. +Import the needed submodule directly. `__about__.py` holds the version read by Hatch. + +## Module relationships + +| Module | Role | Main dependencies within this package | +| --- | --- | --- | +| [types](types.md) | Errors, sentinels, identity bases, validated value types | Foundation | +| [collections](collections.md) | Queryable `DataList` and keyed `Registry` | `types` | +| [strconv](strconv.md) | Registry of bidirectional string convertors | `types`, `collections` | +| [protobuf](protobuf.md) | Descriptor and enum registry | `types`, `collections`, Google protobuf | +| [config](config.md) | Typed options, nested configurations, paths, INI and protobuf exchange | `types`, `strconv`, `config_pb2` | +| [buffer](buffer.md) | Binary buffer reads and writes | `types` | +| [hooks](hooks.md) | Event/source callback registry | `types`, `collections` | +| [logging](logging.md) | Context aware standard logging adapters | Standard library logging | +| [trace](trace.md) | Trace decorators, runtime trace configuration | `types`, `collections`, `strconv`, `config`, `logging` | +| [signal](signal.md) | Signature checked signals and event sockets | Standard library inspect and weakref | + +The package's runtime dependency is `protobuf~=5.29`. `proto/config.proto` defines the configuration +wire format; its Python module and stub generated using `hatch run build-config-proto` are +in `src/firebird/base/`. The protobuf registry also accepts installed descriptors through +the `firebird.base.protobuf` entry point group. `tests/base_test.proto` and files generated +using `hatch run build-test-proto` provide test fixtures. + +## Development workflow + +- `pyproject.toml` is the source for Python support (`>=3.11,<4`), Hatch build settings, + the test matrix (3.11 through 3.14), and Ruff rules. The wheel packages `src/firebird`; + the sdist includes `src`. +- Use `hatch test` from the repository root for the configured matrix, or `hatch run hatch-test.py3.11:pytest tests/test_buffer.py` + for a focused module run when that environment exists. Run `hatch run ruff check src/firebird/base` + for production code and `git diff --check` before finishing. +- Published user documentation is in `docs/docs/`, configured by `docs/zensical.toml`; + `hatch run doc:build` builds it. Command `hatch run doc:docset` generates Dash/Zeal docset + distribution in `dist/` directory. `development/` is for architecture, module boundaries, + and maintenance information. +- Tests mirror modules under `tests/test_*.py`; `config` has one file per option or feature + under `tests/config/`. Some modules hold global registries or managers, so tests that mutate + them must reset their state. +- Treat `proto/config.proto` and its generated outputs as one change. Preserve protobuf + field numbers and meanings when evolving the format. Regenerate bindings with a compatible + protobuf compiler rather than editing generated output by hand. + +The module documents below describe implementation boundaries and change points. For public +API usage, consult the source docstrings and `docs/docs/.md`. diff --git a/development/buffer.md b/development/buffer.md new file mode 100644 index 0000000..3b3dfb6 --- /dev/null +++ b/development/buffer.md @@ -0,0 +1,14 @@ +# `firebird.base.buffer` + +Source: `src/firebird/base/buffer.py`. Tests: `tests/test_buffer.py`. + +`MemoryBuffer` is a cursor over mutable raw storage. `BufferFactory` defines allocation, +clearing, and raw access; `BytesBufferFactory` uses `bytearray`, and `CTypesBufferFactory` +uses a ctypes buffer. Call `get_raw()` for factory-independent access. `pos`, `byteorder`, +`max_size`, and optional `eof_marker` control reads and writes. + +The API handles raw bytes, fixed width integers, strings, Pascal strings, and sized strings; +reads and writes advance `pos`. Writes may resize the buffer unless `max_size` forbids it. +Reads check available space; `is_eof()` also honors the marker. When changing encoding or +length prefixes, test both factories, both byte orders where relevant, position movement, +truncation, and size limits. `ByteOrder` and `UNLIMITED` come from `types`. diff --git a/development/collections.md b/development/collections.md new file mode 100644 index 0000000..58e58f6 --- /dev/null +++ b/development/collections.md @@ -0,0 +1,18 @@ +# `firebird.base.collections` + +Source: `src/firebird/base/collections.py`. Tests: `tests/test_collections.py`. + +`BaseObjectCollection` supplies `filter`, `filterfalse`, `find`, `contains`, `report`, `occurrence`, +`all`, and `any`. Predicates can be callables or string expressions. `make_lambda()` compiles +the latter using `eval`, so expressions must come from trusted input. + +`DataList` extends `list` with optional element `type_spec`, a key expression, keyed `get()`, +sort, split, extract, and a frozen state. Freezing prevents supported mutations and builds +a lookup map when a key expression exists. A `Distinct` element type supplies `item.get_key()` +as the default key expression. Keep ordinary list behavior, type checking, frozen behavior, +and the lookup map aligned when changing mutation or retrieval logic. + +`Registry` stores `Distinct` objects by their `get_key()` values and exposes mapping style +lookup alongside collection query methods. Its iteration yields stored objects, so inspect +its API before substituting a plain `dict`. Other modules use it for convertors, protobuf +types, hooks, and trace metadata. diff --git a/development/config.md b/development/config.md new file mode 100644 index 0000000..1dcf8be --- /dev/null +++ b/development/config.md @@ -0,0 +1,27 @@ +# `firebird.base.config` + +Source: `src/firebird/base/config.py`; wire schema: `proto/config.proto`; generated binding: +`src/firebird/base/config_pb2.py` and stub. Tests: `tests/config/`. + +This module has two related parts. `DirectoryScheme` and its Windows, Linux, and macOS +implementations choose application directories through `get_directory_scheme()`. The configuration +framework uses `Config` as a nested container and `Option[T]` as the base for typed values. +`Config` discovers child options and child configurations assigned as attributes. Options +carry defaults, required status, validation, string conversion, and serialization behavior. + +Concrete options cover strings, integers, floats, decimals, booleans, ZMQ addresses, enums, +flags, UUIDs, MIME types, lists, Python expressions/code/callables, nested configurations +(`ConfigOption`, `ConfigListOption`), dataclasses, and paths. New options must implement +the same value, text, and protobuf paths as comparable options. `ListOption` and `DataclassOption` +rely on `strconv` convertors. + +`load_config()` reads `configparser` sections. `EnvExtendedInterpolation` adds `${env:VAR}` +interpolation. Multiline text has special vertical-bar handling to preserve indentation. +`get_config()` writes an INI representation. `load_proto()` and `save_proto()` exchange values +via `ConfigProto`; `proto/config.proto` defines its `Value` oneof and nested configuration +maps. Preserve field numbering and existing value meanings when changing this format. + +Tests are split by option type and feature, with common parser/protobuf fixtures in +`tests/config/conftest.py`. For a change to an option, verify defaults, required/invalid +values, string/INI round trips, and protobuf round trips where applicable. Directory behavior +varies by platform and environment, so keep tests isolated from the host's real directories. diff --git a/development/hooks.md b/development/hooks.md new file mode 100644 index 0000000..9894a33 --- /dev/null +++ b/development/hooks.md @@ -0,0 +1,17 @@ +# `firebird.base.hooks` + +Source: `src/firebird/base/hooks.py`. Tests: `tests/test_hooks.py`. + +`HookManager` is a `Singleton` that registers hookable classes and their supported events, +optional names for instances, and callbacks. `Hook` objects are stored in a `Registry` keyed +by event, class, and object/name. `ANY` can be used for either the event or the source, +including both together. The manager also uses it internally for the unused parts of a +class or instance key. +`HookFlag` records which lookup forms are in use; `get_callbacks()` resolves callbacks for +a raised event and source. + +`register_class()` establishes supported events before class or instance hooks are added. +`register_name()` uses a weak key map for instances. The module exposes shortcuts such as +`add_hook()` and `get_callbacks()` bound to the global `hook_manager`. Callback signatures +are a caller contract rather than validated by this manager. Tests that register hooks should +reset manager state and cover class, instance, name, and wildcard matching. diff --git a/development/logging.md b/development/logging.md new file mode 100644 index 0000000..bf0d3d3 --- /dev/null +++ b/development/logging.md @@ -0,0 +1,14 @@ +# `firebird.base.logging` + +Source: `src/firebird/base/logging.py`. Tests: `tests/test_logging.py`. + +`LoggingManager` builds standard-library loggers from an agent and optional topic. Its domain, +topic, and agent mappings, `default_domain`, and `logger_fmt` determine the underlying logger +name. `get_logger()` returns a `ContextLoggerAdapter`; the adapter and `ContextFilter` add +agent/domain/topic context to log records. Module-level `logging_manager`, `get_logger()`, +`get_agent_name()`, `set_domain_mapping()`, and `set_agent_mapping()` are shared entry points. + +`FStrMessage`, `BraceMessage`, and `DollarMessage` defer interpolation until a message is +rendered. `FormatElement` and `LogLevel` define formatting elements and levels. Trace uses +this module to obtain loggers, so changes to record context or naming also require trace checks. +Tests should reset manager mappings/factory and standard logging handlers they modify. diff --git a/development/protobuf.md b/development/protobuf.md new file mode 100644 index 0000000..d674ac6 --- /dev/null +++ b/development/protobuf.md @@ -0,0 +1,14 @@ +# `firebird.base.protobuf` + +Source: `src/firebird/base/protobuf.py`. Tests: `tests/test_protobuf.py`; test schema: `tests/base_test.proto`. + +`ProtoMessageType` and `ProtoEnumType` wrap Google protobuf descriptors and live in process-wide +`Registry` instances keyed by fully qualified protobuf names. `create_message()`, +`get_message_factory()`, `get_enum_type()`, and enum helpers provide name-based access. +`struct2dict()` and `dict2struct()` handle `google.protobuf.Struct` conversion. + +The public registration function is `register_descriptor()`. +`load_registered(group)` loads descriptor entry points, normally from `firebird.base.protobuf`. +The package's own `firebird.base.config` descriptor is registered in `pyproject.toml` through +that group. Descriptor registration affects global state; tests should avoid relying on +registration order and cover message and enum lookups. diff --git a/development/signal.md b/development/signal.md new file mode 100644 index 0000000..7679e02 --- /dev/null +++ b/development/signal.md @@ -0,0 +1,15 @@ +# `firebird.base.signal` + +Source: `src/firebird/base/signal.py`. Tests: `tests/test_signal.py`. + +The module provides two callback models. `Signal` validates connected slot signatures against +an `inspect.Signature`, then emits to connected functions, methods, lambdas, or partials. +The `signal` descriptor creates a separate signal for each object instance from a decorated +method signature. `eventsocket` is an optional single-callback descriptor with signature +checking; callers can inspect whether a callback is set. + +Storage deliberately differs by callable type: regular functions use weak references, bound +methods use a weak-key map, while lambdas and partials are retained directly. Changes to +connection logic must check signature compatibility, duplicate connection and disconnection, +garbage collection behavior, and per-instance isolation. Tests should cover both descriptor +models as well as direct `Signal` use. diff --git a/development/strconv.md b/development/strconv.md new file mode 100644 index 0000000..ca18c81 --- /dev/null +++ b/development/strconv.md @@ -0,0 +1,15 @@ +# `firebird.base.strconv` + +Source: `src/firebird/base/strconv.py`. Tests: `tests/test_strconv.py`. + +The module provides symmetric conversion between Python values and strings. A `Convertor` +is keyed by type; `register_convertor()`, `register_class()`, and `update_convertor()` manage +the registry. `convert_to_str()` and `convert_from_str()` are the main operations; `has_convertor()` +and `get_convertor()` expose registration state. Resolution considers type inheritance, so +built-ins include separate registrations for `IntEnum` and `IntFlag` in addition to `Enum` and `int`. + +Built-in convertors are registered during module import for scalar, decimal, UUID, MIME, +ZMQ address, boolean, and enum types. `config.ListOption` and `config.DataclassOption` depend +on this registry for nested values. When adding a supported type, test both directions, +invalid input, and interaction with the configuration options that consume it. Registry +changes affect process-wide behavior. diff --git a/development/trace.md b/development/trace.md new file mode 100644 index 0000000..eebadae --- /dev/null +++ b/development/trace.md @@ -0,0 +1,16 @@ +# `firebird.base.trace` + +Source: `src/firebird/base/trace.py`. Tests: `tests/test_trace.py`. + +The `traced` decorator logs before, after, or failed calls through `firebird.base.logging`; +it can include parameters, return values, elapsed time, and exceptions. `TraceFlag` controls +whether and when logging occurs. `TracedMixin` and `TracedMeta` support automatic class +participation. `TraceManager` registers classes and method trace definitions, can wrap an +object with `trace_object()`, and loads INI settings through the `Config` subclasses `TraceConfig`, +`TracedClassConfig`, and `TracedMethodConfig`. + +The module-level `trace_manager` and aliases `add_trace`, `remove_trace`, and `trace_object` +share state. Trace definitions use `Registry`, and configuration may resolve names through +`types.load()` and values through `strconv`. When changing wrapper behavior, verify return +values, exceptions, signature-sensitive call paths, flags, message contents, and runtime +configuration. Clear manager state between tests. diff --git a/development/types.md b/development/types.md new file mode 100644 index 0000000..182ee21 --- /dev/null +++ b/development/types.md @@ -0,0 +1,20 @@ +# `firebird.base.types` + +Source: `src/firebird/base/types.py`. Tests: `tests/test_types.py`. + +This is the shared foundation for several other modules. `Error` accepts keyword attributes +and returns `None` for unknown attributes, except special cases needed by Python exceptions. +`SingletonMeta` and `Singleton` cache one instance per subclass. `Sentinel` is a metaclass-based +named value system; predefined sentinels such as `DEFAULT`, `UNDEFINED`, `ANY`, `UNLIMITED`, +and `NOT_FOUND` are used as control values in other modules. Preserve sentinel identity comparisons. + +`Distinct` defines equality and hashing through `get_key()`; `CachedDistinct` adds a weak-value +instance cache keyed by `extract_key()`. Changes to either affect `Registry`, hooks, protobuf +descriptors, and trace registrations. `ByteOrder` is consumed by `MemoryBuffer`. The module +also defines `ZMQTransport`, `ZMQDomain`, `ZMQAddress`, `MIME`, and executable string forms +`PyExpr`, `PyCode`, and `PyCallable`; these are used by typed configuration options. +`conjunctive()` combines metaclasses, while `load()` resolves a Python object from a dotted specification. + +When changing a value type, check its matching option in `config.py` and any built-in convertor +in `strconv.py`. Executable string types can evaluate Python code, so keep trust assumptions +explicit in any new caller. diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cb..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/basic.css b/docs/_static/basic.css deleted file mode 100644 index 20b93ce..0000000 --- a/docs/_static/basic.css +++ /dev/null @@ -1,703 +0,0 @@ -/* -- main layout ----------------------------------------------------------- */ - -div.clearer { - clear: both; -} - -/* -- relbar ---------------------------------------------------------------- */ - -div.related { - width: 100%; - font-size: 90%; -} - -div.related h3 { - display: none; -} - -div.related ul { - margin: 0; - padding: 0 0 0 10px; - list-style: none; -} - -div.related li { - display: inline; -} - -div.related li.right { - float: right; - margin-right: 5px; -} - -/* -- sidebar --------------------------------------------------------------- */ - -div.sphinxsidebarwrapper { - padding: 10px 5px 0 10px; -} - -div.sphinxsidebar { - float: left; - width: 230px; - margin-left: -100%; - font-size: 90%; - word-wrap: break-word; - overflow-wrap : break-word; -} - -div.sphinxsidebar ul { - list-style: none; -} - -div.sphinxsidebar ul ul, -div.sphinxsidebar ul.want-points { - margin-left: 20px; - list-style: square; -} - -div.sphinxsidebar ul ul { - margin-top: 0; - margin-bottom: 0; -} - -div.sphinxsidebar form { - margin-top: 10px; -} - -div.sphinxsidebar input { - border: 1px solid #98dbcc; - font-family: sans-serif; - font-size: 1em; -} - -div.sphinxsidebar #searchbox input[type="text"] { - float: left; - width: 80%; - padding: 0.25em; - box-sizing: border-box; -} - -div.sphinxsidebar #searchbox input[type="submit"] { - float: left; - width: 20%; - border-left: none; - padding: 0.25em; - box-sizing: border-box; -} - - -img { - border: 0; - max-width: 100%; -} - -/* -- search page ----------------------------------------------------------- */ - -ul.search { - margin: 10px 0 0 20px; - padding: 0; -} - -ul.search li { - padding: 5px 0 5px 20px; - background-image: url(file.png); - background-repeat: no-repeat; - background-position: 0 7px; -} - -ul.search li a { - font-weight: bold; -} - -ul.search li div.context { - color: #888; - margin: 2px 0 0 30px; - text-align: left; -} - -ul.keywordmatches li.goodmatch a { - font-weight: bold; -} - -/* -- index page ------------------------------------------------------------ */ - -table.contentstable { - width: 90%; - margin-left: auto; - margin-right: auto; -} - -table.contentstable p.biglink { - line-height: 150%; -} - -a.biglink { - font-size: 1.3em; -} - -span.linkdescr { - font-style: italic; - padding-top: 5px; - font-size: 90%; -} - -/* -- general index --------------------------------------------------------- */ - -table.indextable { - width: 100%; -} - -table.indextable td { - text-align: left; - vertical-align: top; -} - -table.indextable ul { - margin-top: 0; - margin-bottom: 0; - list-style-type: none; -} - -table.indextable > tbody > tr > td > ul { - padding-left: 0em; -} - -table.indextable tr.pcap { - height: 10px; -} - -table.indextable tr.cap { - margin-top: 10px; - background-color: #f2f2f2; -} - -img.toggler { - margin-right: 3px; - margin-top: 3px; - cursor: pointer; -} - -div.modindex-jumpbox { - border-top: 1px solid #ddd; - border-bottom: 1px solid #ddd; - margin: 1em 0 1em 0; - padding: 0.4em; -} - -div.genindex-jumpbox { - border-top: 1px solid #ddd; - border-bottom: 1px solid #ddd; - margin: 1em 0 1em 0; - padding: 0.4em; -} - -/* -- domain module index --------------------------------------------------- */ - -table.modindextable td { - padding: 2px; - border-collapse: collapse; -} - -/* -- general body styles --------------------------------------------------- */ - -div.body { - min-width: 450px; - max-width: 1920px; -} - -div.body p, div.body dd, div.body li, div.body blockquote { - -moz-hyphens: auto; - -ms-hyphens: auto; - -webkit-hyphens: auto; - hyphens: auto; -} - -a.headerlink { - visibility: hidden; -} - -h1:hover > a.headerlink, -h2:hover > a.headerlink, -h3:hover > a.headerlink, -h4:hover > a.headerlink, -h5:hover > a.headerlink, -h6:hover > a.headerlink, -dt:hover > a.headerlink, -caption:hover > a.headerlink, -p.caption:hover > a.headerlink, -div.code-block-caption:hover > a.headerlink { - visibility: visible; -} - -div.body p.caption { - text-align: inherit; -} - -div.body td { - text-align: left; -} - -.first { - margin-top: 0 !important; -} - -p.rubric { - margin-top: 30px; - font-weight: bold; -} - -img.align-left, .figure.align-left, object.align-left { - clear: left; - float: left; - margin-right: 1em; -} - -img.align-right, .figure.align-right, object.align-right { - clear: right; - float: right; - margin-left: 1em; -} - -img.align-center, .figure.align-center, object.align-center { - display: block; - margin-left: auto; - margin-right: auto; -} - -.align-left { - text-align: left; -} - -.align-center { - text-align: center; -} - -.align-right { - text-align: right; -} - -/* -- sidebars -------------------------------------------------------------- */ - -div.sidebar { - margin: 0 0 0.5em 1em; - border: 1px solid #ddb; - padding: 7px 7px 0 7px; - background-color: #ffe; - width: 40%; - float: right; -} - -p.sidebar-title { - font-weight: bold; -} - -/* -- topics ---------------------------------------------------------------- */ - -div.topic { - border: 1px solid #ccc; - padding: 7px 7px 0 7px; - margin: 10px 0 10px 0; -} - -p.topic-title { - font-size: 1.1em; - font-weight: bold; - margin-top: 10px; -} - -/* -- admonitions ----------------------------------------------------------- */ - -div.admonition { - margin-top: 10px; - margin-bottom: 10px; - padding: 7px; -} - -div.admonition dt { - font-weight: bold; -} - -div.admonition dl { - margin-bottom: 0; -} - -p.admonition-title { - margin: 0px 10px 5px 0px; - font-weight: bold; -} - -div.body p.centered { - text-align: center; - margin-top: 25px; -} - -/* -- code displays --------------------------------------------------------- */ - -pre { - overflow: auto; - overflow-y: hidden; /* fixes display issues on Chrome browsers */ -} - -span.pre { - -moz-hyphens: none; - -ms-hyphens: none; - -webkit-hyphens: none; - hyphens: none; -} - -td.linenos pre { - padding: 5px 0px; - border: 0; - background-color: transparent; - color: #aaa; -} - -table.highlighttable { - margin-left: 0.5em; -} - -table.highlighttable td { - padding: 0 0.5em 0 0.5em; -} - -div.code-block-caption { - padding: 2px 5px; - font-size: small; -} - -div.code-block-caption code { - background-color: transparent; -} - -div.code-block-caption + div > div.highlight > pre { - margin-top: 0; -} - -div.code-block-caption span.caption-number { - padding: 0.1em 0.3em; - font-style: italic; -} - -div.code-block-caption span.caption-text { -} - -div.literal-block-wrapper { - padding: 1em 1em 0; -} - -div.literal-block-wrapper div.highlight { - margin: 0; -} - -code.descname { - background-color: transparent; - font-weight: bold; - font-size: 1.2em; - border-style: none; - padding: 0; -} - -code.descclassname { - background-color: transparent; - border-style: none; - padding: 0; -} - -code.xref, a code { - background-color: transparent; - font-weight: bold; - border-style: none; - padding: 0; -} - -h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { - background-color: transparent; -} - -.viewcode-link { - float: right; -} - -.viewcode-back { - float: right; - font-family: sans-serif; -} - -div.viewcode-block:target { - margin: -1px -10px; - padding: 0 10px; -} - -/* -- math display ---------------------------------------------------------- */ - -img.math { - vertical-align: middle; -} - -div.body div.math p { - text-align: center; -} - -span.eqno { - float: right; -} - -span.eqno a.headerlink { - position: relative; - left: 0px; - z-index: 1; -} - -div.math:hover a.headerlink { - visibility: visible; -} - -/* -- printout stylesheet --------------------------------------------------- */ - -@media print { - div.document, - div.documentwrapper, - div.bodywrapper { - margin: 0 !important; - width: 100%; - } - - div.sphinxsidebar, - div.related, - div.footer, - #top-link { - display: none; - } -} - -/* -- My additions ---------------------------------------------------------- */ - -div.note { - color: black; - border: 2px solid #7a9eec; - border-right-style: none; - border-left-style: none; - padding: 10px 20px 0px 60px; - background: #e1ecfe url(dialog-note.png) no-repeat 10px 8px; -} - -div.danger { - color: black; - border: 2px solid #fbc2c4; - border-right-style: none; - border-left-style: none; - padding: 10px 20px 0px 60px; - background: #fbe3e4 url(dialog-note.png) no-repeat 10px 8px; -} - -div.attention { - color: black; - border: 2px solid #ffd324; - border-right-style: none; - border-left-style: none; - padding: 10px 20px 0px 60px; - background: #fff6bf url(dialog-note.png) no-repeat 10px 8px; -} - -div.caution { - color: black; - border: 2px solid #ffd324; - border-right-style: none; - border-left-style: none; - padding: 10px 20px 0px 60px; - background: #fff6bf url(dialog-warning.png) no-repeat 10px 8px; -} - -div.important { - color: black; - background: #fbe3e4 url(dialog-seealso.png) no-repeat 10px 8px; - border: 2px solid #fbc2c4; - border-left-style: none; - border-right-style: none; - padding: 10px 20px 0px 60px; -} - -div.seealso { - color: black; - background: #fff6bf url(dialog-seealso.png) no-repeat 10px 8px; - border: 2px solid #ffd324; - border-left-style: none; - border-right-style: none; - padding: 10px 20px 0px 60px; -} - -div.hint, div.tip { - color: black; - background: #eeffcc url(dialog-topic.png) no-repeat 10px 8px; - border: 2px solid #aacc99; - border-left-style: none; - border-right-style: none; - padding: 10px 20px 0px 60px; -} - -div.admonition-example { - color: black; - background: white url(dialog-topic.png) no-repeat 10px 8px; - border: 2px solid #aacc99; - border-left-style: none; - border-right-style: none; - padding: 10px 0px 20px 60px; -} -div.warning, div.error { - color: black; - background: #fbe3e4 url(dialog-warning.png) no-repeat 10px 8px; - border: 2px solid #fbc2c4; - border-right-style: none; - border-left-style: none; - padding: 10px 20px 0px 60px; -} - -p { - text-align: justify; - padding-bottom: 5px; -} - -h1 { - background: #fff6bf; - border: 2px solid #ffd324; - border-left-style: none; - border-right-style: none; - padding: 10px 10px 10px 10px; - text-align: center; -} - -h2 { - /* background: #eeffcc; */ - border: 2px solid #aacc99; - border-left-style: none; - border-right-style: none; - border-top-style: none; - padding: 10px 0px 0px 0px; - /* text-align: center; */ -} - -h3 { - /* background: #eeffcc; */ - border: 1px solid #7a9eec; - border-left-style: none; - border-right-style: none; - border-top-style: none; - padding: 0; - /* text-align: center; */ -} - -h4 { - background: #eeffcc; - /* border: 1px solid #aacc99; */ - border-left-style: none; - border-right-style: none; - border-top-style: none; - padding: 5px 5px 5px 5px; - /* text-align: center; */ -} - -cite { - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; - border: 1px solid #e1e1e8; - background: #f7f7f9; - margin: 0 0 10px; - padding: 0 5px 0 5px; - font-size: 13px; - font-style: italic; -} - -.program { - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; - border: 1px solid #e1e1e8; - background: #f7f7f9; - margin: 0 0 10px; - padding: 0 5px 0 5px; - font-size: 13px; -} - -/* dt/dd on single line */ - -dl.field-list { - display: grid; - grid-template-columns: max-content auto; -} - -dt.field-list { - grid-column-start: 1; -} - -dt.field-odd:after { - content: ':'; -} - -dt.field-even:after { - content: ':'; -} - -dd.field-list { - grid-column-start: 2; -} - -hr.docutils { - font-weight: bold; -/* border: 2px solid #7a9eec; */ - border: 2px solid grey; - border-left-style: none; - border-right-style: none; - border-top-style: none; -} - -div.versionadded { - color: black; -/* padding: 10px 0 0 10px; */ - font-weight: bold; - margin: 5px 0; -} - -div.versionchanged { - color: black; -/* padding: 0 10px 0 0; */ - font-weight: bold; - margin: 5px 0; -} - -span.added { - background: #eeffcc ; - border: 2px solid #aacc99; - border-left-style: none; - border-right-style: none; - padding: 5px; -} - -span.changed { - background: #fbe3e4 ; - border: 2px solid #fbc2c4; - border-left-style: none; - border-right-style: none; - padding: 5px; -} - -dl.class, dl.exception, dl.data, dl.function { -/* background: #fbe3e4 ;*/ - border: 1px solid grey; - border-top-style: none; - border-left-style: none; - border-right-style: none; -/* padding: 3px;*/ -} - -dt.py { - background: #f8f8f8; -/* border: 2px solid #aacc99;*/ -/* border: 2px solid #ffcccc; /*#ffd324;*/ -/* border: 2px solid #fbc2c4;*/ - border: 1px solid #e1e1e8; - border-top-style: none; - border-left-style: none; -/* border-right-style: none; */ - padding: 2px 5px; -} diff --git a/docs/_static/dialog-note.png b/docs/_static/dialog-note.png deleted file mode 100644 index 263fbd5..0000000 Binary files a/docs/_static/dialog-note.png and /dev/null differ diff --git a/docs/_static/dialog-seealso.png b/docs/_static/dialog-seealso.png deleted file mode 100644 index 3eb7b05..0000000 Binary files a/docs/_static/dialog-seealso.png and /dev/null differ diff --git a/docs/_static/dialog-topic.png b/docs/_static/dialog-topic.png deleted file mode 100644 index 2ac5747..0000000 Binary files a/docs/_static/dialog-topic.png and /dev/null differ diff --git a/docs/_static/dialog-warning.png b/docs/_static/dialog-warning.png deleted file mode 100644 index 7233d45..0000000 Binary files a/docs/_static/dialog-warning.png and /dev/null differ diff --git a/docs/buffer.txt b/docs/buffer.txt deleted file mode 100644 index fb8ce88..0000000 --- a/docs/buffer.txt +++ /dev/null @@ -1,23 +0,0 @@ -.. module:: firebird.base.buffer - :synopsis: Memory buffer manager - -############################## -buffer - Memory buffer manager -############################## - -Overview -======== - -This module provides a raw memory buffer manager with convenient methods to read/write -data of various data type. - -MemoryBuffer -============ -.. autoclass:: MemoryBuffer - -Buffer factories -================ -.. autoclass:: BufferFactory -.. autoclass:: BytesBufferFactory -.. autoclass:: CTypesBufferFactory - diff --git a/docs/changelog.txt b/docs/changelog.txt deleted file mode 100644 index fbd25c6..0000000 --- a/docs/changelog.txt +++ /dev/null @@ -1,244 +0,0 @@ -######### -Changelog -######### - -Version 1.8.0 -============= - -* `~firebird.base.config` module: - - - New `.EnvExtendedInterpolation` class that extends `configparser.ExtendedInterpolation` - with special handling for "env" section that returns value of specified environment - variable, or empty string if such variable is not defined. - - -Version 1.7.2 -============= - -- `.Error.getattr()` should not mask the absence of `__notes__` attribute. - -Version 1.7.1 -============= - -- Log record values `module` and `filename` were not assigned properly under Python 3.11, -- Failed tests due to subtle changes in Python 3.11 -- Downgrade dependency to `protobuf>=4.23.4` - -Version 1.7.0 -============= - -* Update dependency to protobuf >=4.24.3 -* Build system changed from setuptools to hatch -* Package version is now defined in firebird.base.__about__.py (__version__) - -Version 1.6.1 -============= - -* `~firebird.base.config` module: - - - Fixed bug with `.Config.get_config()` and `plain` bool argument. - - `.StrOption` now supports preservation of significant leading whitespace for multiline - values (like `.PyCodeOption`). - -Version 1.6.0 -============= - -* `~firebird.base.protobuf` module: - - - Registration of already registered protobuf registration is now ignored instead - raising exception. - -* `~firebird.base.config` module: - - - `.Config.get_config()` and `.Option.get_config()` now provides `plain` bool argument - to return configuration text without comments. Deafult is False. - - `.create_config` is now deprecated, will be removed in version 2.0. - -* `~firebird.base.trace` module: - - - Fixed bug in `.TraceManager.load_config()`. - -Version 1.5.0 -============= - -* Move away from setup.cfg to pyproject.toml, new source tree layout. - -Version 1.4.3 -============= - -* `~firebird.base.types` module: - - - Added internal functions `_decompose` and `_power_of_two` from stdlib `.enum` module, - because they were removed in Python 3.11. - -* `~firebird.base.protobuf` module: - - - Use :ref:`importlib.metadata.entry_points ` instead `pkg_resources.iter_entry_points`. - -* Improved documentation. - -Version 1.4.2 -============= - -* Optimizations. -* Cleanup of pylint warnings. -* Updated documentation. -* Fixed unregistered bugs: - - - Signature in `firebird.base.config.IntOption.clear`. - - Signature in `firebird.base.trace.TraceManager.add_trace`. Keyword argument `decorator` - (with default) could not be used with args/kwargs, so it was removed. New - `~firebird.base.trace.TraceManager.decorator` attribute was added to - `~firebird.base.trace.TraceManager` that could be used to change trace decorator used - for intrumentation. - -Version 1.4.1 -============= - -* Fix: uregistered bug in trace.TraceConfig - redundant `flags` definition. -* New: Documentation is now also provided as Dash_ / Zeal_ docset, downloadable from releases_ at github. - -Version 1.4.0 -============= - -* Upgrade to protobuf 4.21.1. As this upgrade has consequences, please read - https://developers.google.com/protocol-buffers/docs/news/2022-05-06#python-updates - -Version 1.3.1 -============= - -* `~firebird.base.buffer` module: - - - Added `.MemoryBuffer.write_sized_string` for symetry with `.read_sized_string`. - - Now `.MemoryBuffer` string functions has also `errors` parameter in addition to `encoding`. - -* `~firebird.base.config` module: - - - Direct assignment to `.Config` option raises a `ValueError` exception with message - "Cannot assign values to option itself, use `option.value` instead". - -Version 1.3.0 -============= - -* `~firebird.base.config` module: - - - Layout produced by `~firebird.base.config.get_config()` was changed. - - `.Config` has new constructor keyword-only argument `description`. - - Fix: uregistered bug in config.ListOption - value and default was the same instance - -Version 1.2.0 -============= - -* Build scheme changed to `PEP 517`. -* Various changes to documentation and type hint adjustments. -* `~firebird.base.config` module: - - - **BREAKING CHANGE**: `.ApplicationDirectoryScheme` was replaced by - `~firebird.base.config.DirectoryScheme` class, and - `~firebird.base.config.get_directory_scheme()` has changed signature. - - Directory scheme was reworked and now also supports concept of HOME directory. - - New MacOS directory scheme support. As I don't have access to MacOS, this support - should be considered EXPERIMENTAL. Any feedback about it's correctness is welcome. - - Added: New `.Config` constructor keyword-only `bool` argument `optional` and - associated `~Config.optional` read-only property. - - Added: `.Config.has_value()` function. - - New class: `.PathOption` for Configuration options with `pathlib.Path` value. -* `~firebird.base.protobuf` module: - - - Added: function `.get_message_factory`. -* `~firebird.base.signal` module: - - - Fix: Bug in `eventsocket` signature handling. -* `~firebird.base.trace` module: - - - Added: `apply_to_descendants` boolean configuration option to apply configuration also - to all registered descendant classes. The default value is `True`. - -Version 1.1.0 -============= - -* New module: `signal` - Callback system based on Signals and Slots, and "Delphi events" -* `~firebird.base.types` module: - - - `~firebird.base.types.load` function now supports `object_name[.object_name...]` - specifications instead single `object_name`. -* `~firebird.base.config` module: - - - New class `.ApplicationDirectoryScheme` - - `~firebird.base.config.Config.load_config()`: raises error when section is missing, - better error handling when exception is raised while loading options - - `~firebird.base.config.PyCallableOption` `signature` argument could be - `inspect.Signature` or Callable - - Introduced `.PROTO_CONFIG` constant with fully qualified name for `ConfigProto` protobuf - - Optional argument `to_default` in `~firebird.base.config.Option.clear()` is now keyword-only. -* `~firebird.base.logging` module: - - - `.get_logging_id()` uses `__qualname__` instead `__name__` -* `~firebird.base.protobuf` module: - - - Added direct support for key well-known data types `Empty`, `Any`, `Duration`, - `Timestamp`, `Struct`, `Value`, `ListValue` and `FieldMask`. They are automatically - registered. New constants 'PROTO_' with fully qualified names. - - `~firebird.base.protobuf.create_message()` has new optional `serialized` argument with - `bytes` that should be parsed into newly created message instance. - - New functions `~firebird.base.protobuf.struct2dict()` and `~firebird.base.protobuf.dict2struct()` -* `~firebird.base.trace` module: - - - `~firebird.base.trace.TraceFlag` value `DISABLED` was renamed to `NONE`. - - Added support for trace configuration based on `~firebird.base.config`, using new - classes `~firebird.base.trace.BaseTraceConfig`, `~firebird.base.trace.TracedMethodConfig`, - `~firebird.base.trace.TracedClassConfig` and `~firebird.base.trace.TraceConfig`. - - New methods in `~firebird.base.trace.TraceManager`: - - - `~firebird.base.trace.TraceManager.load_config()` to update trace from configuration. - - `~firebird.base.trace.TraceManager.set_flag()` and - `~firebird.base.trace.TraceManager.clear_flag()`. -* `~firebird.base.types` module: - - - `~firebird.base.types.MIME` now handles access to properties more efficiently and faster. - - New function `~firebird.base.types.load()`. -* Changes in documentation. - -Version 1.0.0 -============= - -* Documentation: new examples for :doc:`trace`, :doc:`logging` and :doc:`hooks` -* Documentation: adjustments to css -* DataList is now generic class -* `.DataList.extract()` has new 'copy' argument. - -Version 0.6.1 -============= - -* Promoted to stable -* More documentation - -Version 0.6.0 -============= - -* New module: `~firebird.base.strconv` - Data conversion from/to string -* New module: `~firebird.base.trace` - Trace/audit for class instances -* Reworked module: `~firebird.base.config` - Classes for configuration definitions - - - New class: `.ConfigOption` - Configuration option with `Config` value - - New class: `.ConfigListOption` - Configuration option with list of `Config` values - - New class: `.DataclassOption` - Configuration option with a dataclass value -* Changed module: `~firebird.base.types` - - - New class: `.MIME` - MIME type specification - - New class: `.PyExpr` - Source code for Python expression - - New class: `.PyCode` - Python source code - - New class: `.PyCallable` - Source code for Python callable - - Removed function: str2bool -* Changed module: `~firebird.base.logging` - - - Trace/audit functionality removed (into new module `~firebird.base.trace`) - -Version 0.5.0 -============= - -Initial release. - -.. _releases: https://github.com/FirebirdSQL/python3-base/releases -.. _Dash: https://kapeli.com/dash -.. _Zeal: https://zealdocs.org/ diff --git a/docs/collections.txt b/docs/collections.txt deleted file mode 100644 index 7abe374..0000000 --- a/docs/collections.txt +++ /dev/null @@ -1,52 +0,0 @@ -.. module:: firebird.base.collections - :synopsis: Various collection types - -###################################### -collections - Various collection types -###################################### - -Overview -======== - -This module provides data structures that behave much like builtin `list` and `dict` types, -but with direct support of operations that can use structured data stored in container, and -which would normally require utilization of `operator`, `functools` or other means. - -All containers provide next operations: - -* `filter` and `filterfalse` that return generator that yields items for which `expr` is - evaluated as True (or False). -* `find` that returns first item for which `expr` is evaluated as True, or default. -* `contains` that returns True if there is any item for which `expr` is evaluated as True. -* `occurrence` that returns number of items for which `expr` is evaluated as True. -* `all` and `any` that return True if `expr` is evaluated as True for all or any list element(s). -* `report` that returns generator that yields data produced by expression(s) evaluated on - list items. - -Individual collection types provide additional operations like splitting and extracting -based on expression etc. - -Expressions used by these methods could be strings that contain Python expression -referencing the collection item(s), or lambda functions. - -Types for type hints & annotations -================================== - -.. autodata:: Item -.. autodata:: TypeSpec -.. autodata:: ItemExpr -.. autodata:: FilterExpr -.. autodata:: CheckExpr - -Collections -=========== -.. autoclass:: BaseObjectCollection -.. autoclass:: DataList -.. autoclass:: Registry - -Functions -========= -.. autofunction:: make_lambda - - - diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 52c2b50..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,216 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - -import sphinx_bootstrap_theme -from firebird.base.__about__ import __version__ - -# -- Project information ----------------------------------------------------- - -project = 'Firebird-base' -copyright = '2020-2024, The Firebird Project' -author = 'Pavel Císař' - -# The short X.Y version -version = __version__ - -# The full version, including alpha/beta/rc tags -release = __version__ - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.intersphinx', - 'sphinx.ext.autodoc', - 'sphinx.ext.napoleon', - 'sphinx.ext.viewcode', - 'sphinx.ext.autosectionlabel', - #'sphinx_autodoc_typehints', - 'sphinx.ext.todo', - #'sphinx.ext.coverage', -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.txt' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'requirements.txt'] - -default_role = 'py:obj' - -# -- Options for HTML output ------------------------------------------------- - -html_favicon = '_static/fb-favicon.png' - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -#html_theme = 'alabaster' -html_theme = 'bootstrap' -html_theme_path = sphinx_bootstrap_theme.get_html_theme_path() - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# bootstrap theme config - -# (Optional) Logo. Should be small enough to fit the navbar (ideally 24x24). -# Path should be relative to the ``_static`` files directory. -#html_logo = "my_logo.png" - -# Theme options are theme-specific and customize the look and feel of a -# theme further. -html_theme_options = { - # Navigation bar title. (Default: ``project`` value) - #'navbar_title': "Firebird-base", - - # Tab name for entire site. (Default: "Site") - 'navbar_site_name': "Site", - - # A list of tuples containing pages or urls to link to. - # Valid tuples should be in the following forms: - # (name, page) # a link to a page - # (name, "/aa/bb", 1) # a link to an arbitrary relative url - # (name, "http://example.com", True) # arbitrary absolute url - # Note the "1" or "True" value above as the third argument to indicate - # an arbitrary url. - 'navbar_links': [ - ("Introduction", "introduction"), - ("Modules", "modules"), - ("Index", "genindex"), - ("Changelog", "changelog"), - ("License", "license"), - ], - - # Render the next and previous page links in navbar. (Default: true) - 'navbar_sidebarrel': False, - - # Render the current pages TOC in the navbar. (Default: true) - #'navbar_pagenav': True, - - # Tab name for the current pages TOC. (Default: "Page") - #'navbar_pagenav_name': "Page", - - # Global TOC depth for "site" navbar tab. (Default: 1) - # Switching to -1 shows all levels. - 'globaltoc_depth': 3, - - # Include hidden TOCs in Site navbar? - # - # Note: If this is "false", you cannot have mixed ``:hidden:`` and - # non-hidden ``toctree`` directives in the same page, or else the build - # will break. - # - # Values: "true" (default) or "false" - 'globaltoc_includehidden': "false", - - # HTML navbar class (Default: "navbar") to attach to
element. - # For black navbar, do "navbar navbar-inverse" - 'navbar_class': "navbar navbar-inverse", - - # Fix navigation bar to top of page? - # Values: "true" (default) or "false" - 'navbar_fixed_top': "true", - - # Location of link to source. - # Options are "nav" (default), "footer" or anything else to exclude. - 'source_link_position': "none", - - # Bootswatch (http://bootswatch.com/) theme. - # - # Options are nothing (default) or the name of a valid theme - # such as "cosmo" or "sandstone". - # - # The set of valid themes depend on the version of Bootstrap - # that's used (the next config option). - # - # Currently, the supported themes are: - # - Bootstrap 2: https://bootswatch.com/2 - # - Bootstrap 3: https://bootswatch.com/3 - #'bootswatch_theme': "united", # cerulean, flatly, lumen, materia, united, yeti - 'bootswatch_theme': "cerulean", - - # Choose Bootstrap version. - # Values: "3" (default) or "2" (in quotes) - 'bootstrap_version': "2", -} - -# -- Extension configuration ------------------------------------------------- - -autosectionlabel_prefix_document = True - -# Autodoc options -# --------------- -autodoc_default_options = { - 'content': 'both', - 'members': True, - 'member-order': 'groupwise', - 'undoc-members': True, - 'exclude-members': '__weakref__', - 'show-inheritance': True, - 'no-inherited-members': True, -} -set_type_checking_flag = True -autodoc_class_signature = 'mixed' -always_document_param_types = True -autodoc_typehints = 'both' # default 'signature' -autodoc_typehints_format = 'short' -autodoc_typehints_description_target = 'all' - -autodoc_type_aliases = {'Item': '~firebird.base.collections.Item', - 'TypeSpec': '~firebird.base.collections.TypeSpec', - 'ItemExpr': '~firebird.base.collections.ItemExpr', - 'FilterExpr': '~firebird.base.collections.FilterExpr', - 'CheckExpr': '~firebird.base.collections.CheckExpr', - } - -# Napoleon options -# ---------------- -napoleon_include_init_with_doc = True -napoleon_include_private_with_doc = True -napoleon_include_special_with_doc = True -napoleon_use_admonition_for_examples = False -napoleon_use_admonition_for_notes = True -napoleon_use_admonition_for_references = True -napoleon_use_ivar = False -napoleon_use_rtype = True -napoleon_use_param = True -napoleon_use_keyword = True -napoleon_attr_annotations = True -napoleon_preprocess_types = True - -# -- Options for intersphinx extension --------------------------------------- - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} - -# -- Options for todo extension ---------------------------------------------- - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True diff --git a/docs/config.txt b/docs/config.txt deleted file mode 100644 index f8b218b..0000000 --- a/docs/config.txt +++ /dev/null @@ -1,375 +0,0 @@ -.. module:: firebird.base.config - :synopsis: Configuration definitions - -################################## -config - Configuration definitions -################################## - -Overview -======== - -Complex applications (and some library modules like `logging`) could be often parametrized -via configuration. This module provides a framework for unified structured configuration -that supports: - -* configuration options of various data type, including lists and other complex types -* validation -* direct manipulation of configuration values -* reading from (and writing into) configuration in `configparser` format -* exchanging configuration (for example between processes) using Google protobuf messages - -Architecture ------------- - -The framework is based around two classes: - -* `.Config` - Collection of configuration options and sub-collections. Particular - configuration is then realized as descendant from this class, that defines configuration - options in constructor, and customize the validation when required. -* `.Option` - Abstract base class for configuration options, where descendants implement - handling of particular data type. This module provides implementation for next data - types: `str`, `int`, `float`, `bool`, `~decimal.Decimal`, `~enum.Enum`, `~enum.Flag`, - `~uuid.UUID`, `.MIME`, `.ZMQAddress`, `list`, `~dataclasses.dataclass`, `.PyExpr`, - `.PyCode` and `.PyCallable`. It also provides special options `ConfigOption` and - `ConfigListOption`. - -Additionally, the `.DirectoryScheme` abstract base class defines set of mostly -used application directories. The function `.get_directory_scheme()` could be then used -to obtain instance that implements platform-specific standards for file-system location -for these directories. Currently, only "Windows", "Linux" and "MacOS" directory schemes -are supported. - -.. tip:: - You may use `.get_directory_scheme()` function to get the scheme suitable for platform - where your application is running. - -.. tip:: - If your configurations contain secrets like passwords or access tokens, that would be - read from files via `configparser`, you should consider to use `.EnvExtendedInterpolation` - that has support for option values defined via environment variables. - -Usage ------ -First, you need to define your own configuration. - -.. code-block:: - - from enum import IntEnum - from firebird.base.config import Config, StrOption, IntOption, ListOption - - class SampleEnum(IntEnum): - "Enum for testing" - UNKNOWN = 0 - READY = 1 - RUNNING = 2 - WAITING = 3 - SUSPENDED = 4 - FINISHED = 5 - ABORTED = 6 - - class DbConfig(Config): - "Simple database config" - def __init__(self, name: str): - super().__init__(name) - # options - self.database: StrOption = StrOption('database', 'Database connection string', - required=True) - self.user: StrOption = StrOption('user', 'User name', required=True, - default='SYSDBA') - self.password: StrOption = StrOption('password', 'User password') - - class SampleConfig(Config): - """Sample Config. - - Has three options and two sub-configs. - """ - def __init__(self): - super().__init__('sample-config') - # options - self.opt_str: StrOption = StrOption('opt_str', "Sample string option") - self.opt_int: IntOption = StrOption('opt_int', "Sample int option") - self.enum_list: ListOption = ListOption('enum_list', "List of enum values", - item_type=SampleEnum) - # sub configs - self.master_db: DbConfig = DbConfig('master-db') - self.backup_db: DbConfig = DbConfig('backup-db') - -.. important:: - - Option must be assigned to Config attributes with the same name as option name. - - -Typically you need only one instance of your configuration class in application. - -.. code-block:: - - app_config: SampleConfig = SampleConfig() - -Typically, your application is configured using file(s) in `configparser` format. You may -create initial one using `Config.get_config()` method. - -.. note:: - - `Config.get_config()` works with current configuration values. When called on "empty" - instance it returns "default" configuration. Option values that match the default are - returned as commented out. - -.. code-block:: - - >>> print(app_config.get_config()) - - [sample-config] - ; - ; Sample Config. - ; - ; Has three options and two sub-configs. - ; - - ; opt_str - ; ------- - ; - ; data type: str - ; - ; [optional] Sample string option - ; - ;opt_str = - - ; opt_int - ; ------- - ; - ; data type: str - ; - ; [optional] Sample int option - ; - ;opt_int = - - ; enum_list - ; --------- - ; - ; data type: list - ; - ; [optional] List of enum values - ; - ;enum_list = - - [master-db] - ; - ; Simple DB config - ; - - ; database - ; -------- - ; - ; data type: str - ; - ; [REQUIRED] Database connection string - ; - ;database = - - ; user - ; ---- - ; - ; data type: str - ; - ; [REQUIRED] User name - ; - ;user = SYSDBA - - ; password - ; -------- - ; - ; data type: str - ; - ; [optional] User password - ; - ;password = - - [backup-db] - ; - ; Simple DB config - ; - - ; database - ; -------- - ; - ; data type: str - ; - ; [REQUIRED] Database connection string - ; - ;database = - - ; user - ; ---- - ; - ; data type: str - ; - ; [REQUIRED] User name - ; - ;user = SYSDBA - - ; password - ; -------- - ; - ; data type: str - ; - ; [optional] User password - ; - ;password = - -To read the configuration from file, use the `~configparser.ConfigParser` and pass it -to `Config.load_config()` method. - -Example configuration file:: - - ; myapp.cfg - - [DEFAULT] - password = masterkey - - [sample-config] - opt_str = Lorem ipsum - enum_list = ready, finished, aborted - - [master-db] - database = primary - user = tester - password = lockpick - - [backup-db] - database = secondary - -.. code-block:: - - from configparser import ConfigParser - - cfg = ConfigParser() - cfg.read('myapp.cfg') - app_config.load_config(cfg) - -Access to configuration values is through attributes on your `Config` instance, and -their `value` attribute. - -.. code-block:: - - >>> app_config.opt_str.value - Lorem ipsum - >>> app_config.opt_int.value - >>> app_config.enum_list.value - [READY, FINISHED, ABORTED] - >>> app_config.master_db.database.value - primary - >>> app_config.master_db.user.value - tester - >>> app_config.master_db.password.value - lockpick - >>> app_config.backup_db.database.value - secondary - >>> app_config.backup_db.user.value - SYSDBA - >>> app_config.backup_db.password.value - masterkey - -ConfigProto -=========== - -You can transfer configuration (state) between instances of your `Config` classes using -Google Protocol Buffer message `firebird.base.ConfigProto` and methods -`~Config.save_proto()` and `~Config.load_proto()`. - -The protobuf message is defined in :file:`/proto/config.proto`. - -.. literalinclude:: ../proto/config.proto - :language: proto - :lines: 30- - -.. note:: - - You can use it directly or via `.protobuf` registry. - - .. code-block:: - - # Direct use - from firebird.base.config import ConfigProto - cfg_msg = ConfigProto() - - Because the proto file is NOT registered in `.protobuf` registry, you must register - it manually. The proto file is listed in `pyproject.toml` under *"firebird.base.protobuf"* - entrypoint, so use `load_registered('firebird.base.protobuf')` for its registration. - - .. code-block:: - - from firebird.base.protobuf import load_registered, create_message - load_registered('firebird.base.protobuf') - cfg_msg = create_message('firebird.base.ConfigProto') - -.. important:: - - Although `Option` also provides methods `~Option.save_proto()` and `~Option.load_proto()` - to transfer option value in/out ConfigProto message, you should always use methods - on `Config` instance because option's serialization may relly on `Config` instance that - owns them. - - .. seealso:: `.ConfigOption`, `.ConfigListOption` - -Constants -========= - -.. data:: PROTO_CONFIG - :annotation: Fully qualified name for `ConfigProto`_ protobuf. - -.. tip:: - - To address `ConfigProto`_ in functions like `~firebird.base.protobuf.create_message()`, - use `PROTO_CONFIG` constant. - - -Application Directory Scheme -============================ - -.. versionadded:: 1.1.0 - -.. versionchanged:: 1.2.0 - -.. autoclass:: DirectoryScheme -.. autoclass:: WindowsDirectoryScheme -.. autoclass:: LinuxDirectoryScheme -.. autoclass:: MacOSDirectoryScheme -.. autofunction:: get_directory_scheme - -Configparser interpolation -========================== - -.. autoclass:: EnvExtendedInterpolation - -Config -====== -.. autoclass:: Config - -Options -======= -.. autoclass:: Option -.. autoclass:: StrOption -.. autoclass:: IntOption -.. autoclass:: FloatOption -.. autoclass:: DecimalOption -.. autoclass:: BoolOption -.. autoclass:: ZMQAddressOption -.. autoclass:: EnumOption -.. autoclass:: FlagOption -.. autoclass:: UUIDOption -.. autoclass:: MIMEOption -.. autoclass:: ListOption -.. autoclass:: DataclassOption -.. autoclass:: PathOption -.. autoclass:: PyExprOption -.. autoclass:: PyCodeOption -.. autoclass:: PyCallableOption -.. autoclass:: ConfigOption -.. autoclass:: ConfigListOption - -Functions -========= -.. autofunction:: has_verticals -.. autofunction:: has_leading_spaces -.. autofunction:: create_config - diff --git a/docs/_static/fb-favicon.png b/docs/docs/assets/fb-favicon.png similarity index 100% rename from docs/_static/fb-favicon.png rename to docs/docs/assets/fb-favicon.png diff --git a/docs/docs/buffer.md b/docs/docs/buffer.md new file mode 100644 index 0000000..6c9d070 --- /dev/null +++ b/docs/docs/buffer.md @@ -0,0 +1,47 @@ +# buffer - Memory buffer manager + +## Overview + +::: firebird.base.buffer + options: + members: false + +`MemoryBuffer` starts with a capacity and a position of zero. Writes advance `pos`; +set it back to zero before reading the data you wrote. Number reads and writes use +the selected byte order: + +```python +from firebird.base.buffer import MemoryBuffer +from firebird.base.types import ByteOrder + +buffer = MemoryBuffer(4, byteorder=ByteOrder.BIG) +buffer.write_short(0x1234) +buffer.write(b"OK") +assert buffer.pos == 4 +assert bytes(buffer.get_raw()) == b"\x12\x34OK" + +buffer.pos = 0 +assert buffer.read_short() == 0x1234 +assert bytes(buffer.read(2)) == b"OK" +assert buffer.pos == 4 +``` + +`get_raw()` returns the storage content as bytes or a bytearray regardless of the +chosen buffer factory. A buffer's capacity may exceed the amount of data written, +so use `pos` to track where the next read or write takes place. + +## MemoryBuffer + +::: firebird.base.buffer.MemoryBuffer + +## Buffer factories + +::: firebird.base.buffer.BufferFactory + +::: firebird.base.buffer.BytesBufferFactory + +::: firebird.base.buffer.CTypesBufferFactory + +## Functions + +::: firebird.base.buffer.safe_ord diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md new file mode 120000 index 0000000..699cc9e --- /dev/null +++ b/docs/docs/changelog.md @@ -0,0 +1 @@ +../../CHANGELOG.md \ No newline at end of file diff --git a/docs/docs/collections.md b/docs/docs/collections.md new file mode 100644 index 0000000..9d8e6b1 --- /dev/null +++ b/docs/docs/collections.md @@ -0,0 +1,44 @@ +# collections - Various collection types + +## Overview + +::: firebird.base.collections + options: + members: false + +Methods such as `filter()` accept either a Python callable or a string expression. +String expressions are evaluated as Python code, so use them only when the expression +comes from a trusted source. For input from users or other untrusted sources, choose a +callable defined by your application: + +```python +from firebird.base.collections import DataList + +names = DataList(["Ada", "Bob", "Alex"]) +selected = list(names.filter(lambda name: name.startswith("A"))) +assert selected == ["Ada", "Alex"] +``` + +## Types for type hints & annotations + +::: firebird.base.collections.Item + +::: firebird.base.collections.TypeSpec + +::: firebird.base.collections.ItemExpr + +::: firebird.base.collections.FilterExpr + +::: firebird.base.collections.CheckExpr + +## Collections + +::: firebird.base.collections.BaseObjectCollection + +::: firebird.base.collections.DataList + +::: firebird.base.collections.Registry + +## Functions + +::: firebird.base.collections.make_lambda diff --git a/docs/docs/config.md b/docs/docs/config.md new file mode 100644 index 0000000..943950f --- /dev/null +++ b/docs/docs/config.md @@ -0,0 +1,360 @@ +# config - Configuration definitions + +## Overview + +::: firebird.base.config + options: + members: false + +### Architecture + +The framework is based around two classes: + +* [Config][firebird.base.config.Config] - Collection of configuration options and sub-collections. + Particular configuration is then realized as descendant from this class, that defines configuration + options in constructor, and customize the validation when required. +* [Option][firebird.base.config.Option] - Abstract base class for configuration options, + where descendants implement handling of particular data type. This module provides + implementation for next data types: [str][], [int][], [float][], [bool][], [decimal.Decimal][], + [enum.Enum][], [enum.Flag][], [uuid.UUID][], [pathlib.Path][], [MIME][firebird.base.types.MIME], + [ZMQAddress][firebird.base.types.ZMQAddress], [list][], [dataclasses.dataclass][], + [PyExpr][firebird.base.types.PyExpr], [PyCode][firebird.base.types.PyCode] and + [PyCallable][firebird.base.types.PyCallable]. + It also provides special options [ConfigOption][firebird.base.config.ConfigOption] and + [ConfigListOption][firebird.base.config.ConfigListOption]. + +Use `ConfigOption` for one nested `Config` object whose section name is stored in an +option. Use `ConfigListOption` for a list of nested `Config` objects: its option value +contains their section names, separated by commas or by lines. When loading an INI +file, `Config` loads the options from those referenced sections as well. + +Additionally, the [DirectoryScheme][firebird.base.config.DirectoryScheme] abstract base class +defines set of mostly used application directories. The function +[get_directory_scheme()][firebird.base.config.get_directory_scheme] could be then used +to obtain instance that implements platform-specific standards for file-system location +for these directories. Currently, only "Windows", "Linux" and "MacOS" directory schemes +are supported. + +!!! tip + You may use `get_directory_scheme()` function to get the scheme suitable for platform + where your application is running. + +!!! tip + If your configurations contain secrets like passwords or access tokens, that would be + read from files via [configparser][], you should consider to use + [EnvExtendedInterpolation][firebird.base.config.EnvExtendedInterpolation] + that has support for option values defined via environment variables. + +### Usage +First, you need to define your own configuration. + +```python +from enum import IntEnum +from firebird.base.config import Config, StrOption, IntOption, ListOption + +class SampleEnum(IntEnum): + "Enum for testing" + UNKNOWN = 0 + READY = 1 + RUNNING = 2 + WAITING = 3 + SUSPENDED = 4 + FINISHED = 5 + ABORTED = 6 + +class DbConfig(Config): + "Simple database config" + def __init__(self, name: str): + super().__init__(name) + # options + self.database: StrOption = StrOption('database', 'Database connection string', + required=True) + self.user: StrOption = StrOption('user', 'User name', required=True, + default='SYSDBA') + self.password: StrOption = StrOption('password', 'User password') + +class SampleConfig(Config): + """Sample Config. + +Has three options and two sub-configs. +""" + def __init__(self): + super().__init__('sample-config') + # options + self.opt_str: StrOption = StrOption('opt_str', "Sample string option") + self.opt_int: IntOption = IntOption('opt_int', "Sample int option") + self.enum_list: ListOption = ListOption('enum_list', SampleEnum, "List of enum values") + # sub configs + self.master_db: DbConfig = DbConfig('master-db') + self.backup_db: DbConfig = DbConfig('backup-db') +``` + +!!! important + Option must be assigned to `Config` attributes with the same name as option name. + +Typically you need only one instance of your configuration class in application. + +```python +app_config: SampleConfig = SampleConfig() + +``` +Typically, your application is configured using file(s) in `configparser` format. You may +create initial one using `Config.get_config()` method. + +!!! note + [Config.get_config][firebird.base.config.Config.get_config] works with current configuration + values. When called on "empty" instance it returns "default" configuration. Option values + that match the default are returned as commented out. + +```ini +>>> print(app_config.get_config()) + +[sample-config] +; +; Sample Config. +; +; Has three options and two sub-configs. + +; Sample string option +; Type: str +;opt_str = + +; Sample int option +; Type: int +;opt_int = + +; List of enum values +; Type: list [SampleEnum] +;enum_list = + +[master-db] +; +; Simple database config + +; REQUIRED option. +; Database connection string +; Type: str +;database = + +; REQUIRED option. +; User name +; Type: str +;user = SYSDBA + +; User password +; Type: str +;password = + +[backup-db] +; +; Simple database config + +; REQUIRED option. +; Database connection string +; Type: str +;database = + +; REQUIRED option. +; User name +; Type: str +;user = SYSDBA + +; User password +; Type: str +;password = +``` +To read the configuration from file, use the [configparser.ConfigParser][] and pass it +to [Config.load_config()][firebird.base.config.Config.load_config] method. + +Example configuration file: + +```ini +; myapp.cfg + +[DEFAULT] +password = masterkey + +[sample-config] +opt_str = Lorem ipsum +enum_list = ready, finished, aborted + +[master-db] +database = primary +user = tester +password = lockpick + +[backup-db] +database = secondary +``` + +```python +from configparser import ConfigParser + +cfg = ConfigParser() +cfg.read('myapp.cfg') +app_config.load_config(cfg) + +``` +Access to configuration values is through attributes on your `Config` instance, and +their `value` attribute. + +```python +>>> app_config.opt_str.value +Lorem ipsum +>>> app_config.opt_int.value +>>> app_config.enum_list.value +[READY, FINISHED, ABORTED] +>>> app_config.master_db.database.value +primary +>>> app_config.master_db.user.value +tester +>>> app_config.master_db.password.value +lockpick +>>> app_config.backup_db.database.value +secondary +>>> app_config.backup_db.user.value +SYSDBA +>>> app_config.backup_db.password.value +masterkey + +``` +## ConfigProto + +You can transfer configuration (state) between instances of your `Config` classes using +Google Protocol Buffer message `firebird.base.ConfigProto` and methods +[Config.save_proto()][firebird.base.config.Config.save_proto] and +[Config.load_proto()][firebird.base.config.Config.load_proto]. + +The protobuf message is defined in `/proto/config.proto`. + +```proto +syntax = "proto3"; + +package firebird.base; + +import "google/protobuf/any.proto"; +import "google/protobuf/struct.proto"; + +message Value { + oneof kind { + string as_string = 2 ; + bytes as_bytes = 3 ; + bool as_bool = 4 ; + double as_double = 5 ; + float as_float = 6 ; + sint32 as_sint32 = 7 ; + sint64 as_sint64 = 8 ; + uint32 as_uint32 = 9 ; + uint64 as_uint64 = 10 ; + google.protobuf.Any as_msg = 11 ; + } +} + +message ConfigProto { + map options = 1 ; + map configs = 2 ; +} +``` + +!!! note + You can use it directly or via [firebird.base.protobuf][] registry. + + ```python + # Direct use + from firebird.base.config import ConfigProto + cfg_msg = ConfigProto() + ``` + + Because the proto file is NOT registered in `protobuf` registry, you must register + it manually. The proto file is listed in `pyproject.toml` under **"firebird.base.protobuf"** + entrypoint, so use `load_registered('firebird.base.protobuf')` for its registration. + + ```python + from firebird.base.protobuf import load_registered, create_message + load_registered('firebird.base.protobuf') + cfg_msg = create_message('firebird.base.ConfigProto') + ``` + +!!! important + Although `Option` also provides methods `Config.save_proto()` and `Config.load_proto()` + to transfer option value in/out ConfigProto message, you should always use methods + on `Config` instance because option's serialization may relly on `Config` instance that + owns them. + + See Also: + [ConfigOption][firebird.base.config.ConfigOption], [ConfigListOption][firebird.base.config.ConfigListOption] + +## Constants + +::: firebird.base.config.PROTO_CONFIG + +!!! tip + To address `ConfigProto` in functions like `firebird.base.protobuf.create_message()`, + use `PROTO_CONFIG` constant. + + +## Application Directory Scheme + +::: firebird.base.config.DirectoryScheme + +::: firebird.base.config.WindowsDirectoryScheme + +::: firebird.base.config.LinuxDirectoryScheme + +::: firebird.base.config.MacOSDirectoryScheme + +::: firebird.base.config.get_directory_scheme + +## Configparser interpolation + +::: firebird.base.config.EnvExtendedInterpolation + +## Config + +::: firebird.base.config.Config + +## Options + +::: firebird.base.config.Option + +::: firebird.base.config.StrOption + +::: firebird.base.config.IntOption + +::: firebird.base.config.FloatOption + +::: firebird.base.config.DecimalOption + +::: firebird.base.config.BoolOption + +::: firebird.base.config.ZMQAddressOption + +::: firebird.base.config.EnumOption + +::: firebird.base.config.FlagOption + +::: firebird.base.config.UUIDOption + +::: firebird.base.config.MIMEOption + +::: firebird.base.config.ListOption + +::: firebird.base.config.DataclassOption + +::: firebird.base.config.PathOption + +::: firebird.base.config.PyExprOption + +::: firebird.base.config.PyCodeOption + +::: firebird.base.config.PyCallableOption + +::: firebird.base.config.ConfigOption + +::: firebird.base.config.ConfigListOption + +## Functions + +::: firebird.base.config.has_verticals + +::: firebird.base.config.has_leading_spaces diff --git a/docs/docs/hooks.md b/docs/docs/hooks.md new file mode 100644 index 0000000..e1fb10a --- /dev/null +++ b/docs/docs/hooks.md @@ -0,0 +1,160 @@ +# hooks - Hook manager + +## Overview + +::: firebird.base.hooks + options: + members: false + +### Architecture + +The callback extension mechanism is based on the following: + +* The `Event source` provides one or more "hookable events" that work like connection points. + The event source represents "origin of event" and is always identified by class, class + instance or name. Event sources that are identified by classes (or their instances) must + be registered along with events they provide. +* `Event` is typically linked to particular event source, but it's not mandatory and it's + possible to define global events. Event is represented as value of any type, that must + be unique in used context (particular event source or global). + Each event should be properly documented along with required signature for callback + function. +* `Event provider` is a class or function that implements the event for event source, and + asks the [hook_manager][firebird.base.hooks.hook_manager] for list of event consumers + (callbacks) registered for particular event and source. +* `Event consumer` is a function or class method that implements the callback for particular + event. The callback must be registered in [hook_manager][firebird.base.hooks.hook_manager] before it could be called by + event providers. + +The architecture supports multiple usage strategies: + +* If event provider uses class instance to identify the event source, it's possible to + register callbacks to all instances (by registering to class), or particular instance(s). +* It's possible to register callback to particular instance by name, if instance is associated + with name by [register_name][firebird.base.hooks.register_name] function. +* It's possible to register callback to [ANY][firebird.base.types.ANY] event from particular + source, or particular event from `ANY` source, or even to `ANY` event from `ANY` source. + +### Example + +```python +from __future__ import annotations +from enum import Enum, auto +from firebird.base.types import * +from firebird.base.hooks import hook_manager + +class MyEvents(Enum): + "Sample definition of events" + CREATE = auto() + ACTION = auto() + +class MyHookable: + "Example of hookable class, i.e. a class that calls hooks registered for events." + def __init__(self, name: str): + self.name: str = name + for hook in hook_manager.get_callbacks(MyEvents.CREATE, self): + try: + hook(self, MyEvents.CREATE) + except Exception as e: + print(f"{self.name}.CREATE hook call outcome: ERROR ({e.args[0]})") + else: + print(f"{self.name}.CREATE hook call outcome: OK") + def action(self): + print(f"{self.name}.ACTION!") + for hook in hook_manager.get_callbacks(MyEvents.ACTION, self): + try: + hook(self, MyEvents.ACTION) + except Exception as e: + print(f"{self.name}.ACTION hook call outcome: ERROR ({e.args[0]})") + else: + print(f"{self.name}.ACTION hook call outcome: OK") + +class MyHook: + "Example of hook implementation" + def __init__(self, name: str): + self.name: str = name + def callback(self, subject: MyHookable, event: MyEvents): + print(f"Hook {self.name} event {event.name} called by {subject.name}") + def err_callback(self, subject: MyHookable, event: MyEvents): + self.callback(subject, event) + raise Exception("Error in hook") + + +# Example code that installs and uses hooks + +hook_manager.register_class(MyHookable, MyEvents) +hook_A: MyHook = MyHook('Hook-A') +hook_B: MyHook = MyHook('Hook-B') +hook_C: MyHook = MyHook('Hook-C') + +print("Install hooks") +hook_manager.add_hook(MyEvents.CREATE, MyHookable, hook_A.callback) +hook_manager.add_hook(MyEvents.CREATE, MyHookable, hook_B.err_callback) +hook_manager.add_hook(MyEvents.ACTION, MyHookable, hook_C.callback) + +print("Create event sources, emits CREATE") +src_A: MyHookable = MyHookable('Source-A') +src_B: MyHookable = MyHookable('Source-B') + +print("Install instance hooks") +hook_manager.add_hook(MyEvents.ACTION, src_A, hook_A.callback) +hook_manager.add_hook(MyEvents.ACTION, src_B, hook_B.callback) + +print("And action!") +src_A.action() +src_B.action() + + +``` +Output from sample code: + +```text +Install hooks +Create event sources, emits CREATE +Hook Hook-A event CREATE called by Source-A +Source-A.CREATE hook call outcome: OK +Hook Hook-B event CREATE called by Source-A +Source-A.CREATE hook call outcome: ERROR (Error in hook) +Hook Hook-A event CREATE called by Source-B +Source-B.CREATE hook call outcome: OK +Hook Hook-B event CREATE called by Source-B +Source-B.CREATE hook call outcome: ERROR (Error in hook) +Install instance hooks +And action! +Source-A.ACTION! +Hook Hook-A event ACTION called by Source-A +Source-A.ACTION hook call outcome: OK +Hook Hook-C event ACTION called by Source-A +Source-A.ACTION hook call outcome: OK +Source-B.ACTION! +Hook Hook-B event ACTION called by Source-B +Source-B.ACTION hook call outcome: OK +Hook Hook-C event ACTION called by Source-B +Source-B.ACTION hook call outcome: OK +``` + +## Flags + +::: firebird.base.hooks.HookFlag + +## Functions + +::: firebird.base.hooks.register_class + +::: firebird.base.hooks.register_name + +::: firebird.base.hooks.add_hook + +::: firebird.base.hooks.get_callbacks + +## Classes + +::: firebird.base.hooks.HookManager + +## Globals + +::: firebird.base.hooks.hook_manager + +## Dataclasses + +::: firebird.base.hooks.Hook diff --git a/docs/docs/index.md b/docs/docs/index.md new file mode 100644 index 0000000..97cba65 --- /dev/null +++ b/docs/docs/index.md @@ -0,0 +1,28 @@ +# The Firebird Base library for Python + +The `firebird-base` package provides common Python 3 modules used by Firebird Project +in various development projects. However, these modules have general applicability outside +the scope of development for Firebird RDBMS. + +## Installation + +```console +pip install firebird-base +``` + +!!! note + Driver development is sponsored by [IBPhoenix](http://www.ibphoenix.com). + + [![PyPI - Version](https://img.shields.io/pypi/v/firebird-base.svg)](https://pypi.org/project/firebird-base) + [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/firebird-base.svg)](https://pypi.org/project/firebird-base) + [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) + [![PyPI - Downloads](https://img.shields.io/pypi/dm/firebird-base)](https://pypi.org/project/firebird-base) + [![Libraries.io SourceRank](https://img.shields.io/librariesio/sourcerank/pypi/firebird-base)](https://libraries.io/pypi/firebird-base) + [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/FirebirdSQL/python3-base) + + [Source repository](https://github.com/FirebirdSQL/python3-base) + +!!! tip + You can download docset for [Dash](https://kapeli.com/dash) (MacOS) or + [Zeal](https://zealdocs.org/) (Windows / Linux) documentation + readers from [releases](https://github.com/FirebirdSQL/python3-base/releases) at github. diff --git a/docs/docs/introduction.md b/docs/docs/introduction.md new file mode 100644 index 0000000..b3ab83b --- /dev/null +++ b/docs/docs/introduction.md @@ -0,0 +1,135 @@ +# Introduction + +The firebird-base package is a set of Python 3 modules commonly used by Firebird Project in +various development projects (for example the firebird-driver or Saturnin). However, these +modules have general applicability outside the scope of development for Firebird RDBMS or +Firebird Butler. + +## Common data types + +The [firebird.base.types][] module provides collection of classes and other types that are often used by +other library modules or applications. + +* Exception [Error][firebird.base.types.Error] that is intended to be used as a base class of all application-related + errors. The important difference from [Exception][] class is that [Error][firebird.base.types.Error] accepts keyword + arguments, that are stored into instance attributes with the same name. +* [Singleton][firebird.base.types.Singleton] base class for singletons. +* [Sentinel][firebird.base.types.Sentinel] base class for named sentinel objects that provide meaningful `str` and `repr`, + along with collection of predefined sentinels. +* [Distinct][firebird.base.types.Distinct] abstract base class for classes (incl. dataclasses) with distinct instances. +* Collection of `Enums` and `custom string types`. + +## Various collection types + +The [firebird.base.collections][] module provides data structures that behave much like builtin `list` and +`dict` types, but with direct support of operations that can use structured data stored in +container, and which would normally require utilization of `operator`, `functools` or other +means. + +All containers provide next operations: + +* [filter][firebird.base.collections.BaseObjectCollection.filter] and + [filterfalse][firebird.base.collections.BaseObjectCollection.filterfalse] that return + generator that yields items for which `expr` is evaluated as True (or False). +* [find][firebird.base.collections.BaseObjectCollection.find] that returns first item for + which `expr` is evaluated as True, or default. +* [contains][firebird.base.collections.BaseObjectCollection.contains] that returns True if + there is any item for which `expr` is evaluated as True. +* [occurrence][firebird.base.collections.BaseObjectCollection.occurrence] that returns number + of items for which `expr` is evaluated as True. +* [all][firebird.base.collections.BaseObjectCollection.all] and [any][firebird.base.collections.BaseObjectCollection.any] + that return True if `expr` is evaluated as True for all or any list element(s). +* [report][firebird.base.collections.BaseObjectCollection.report] that returns generator + that yields data produced by expression(s) evaluated on collection items. + +Individual collection types provide additional operations like splitting and extracting +based on expression etc. + +Expressions used by these methods could be strings that contain Python expression referencing +the collection item(s), or lambda functions. + +## Callback systems + +Module [firebird.base.signal][] provides two callback mechanisms: one based on signals and +slots similar to Qt signal/slot, and second based on optional method delegation similar to +events in Delphi. + +In both cases, the callback callables could be functions, instance or class methods, +partials and lambda functions. The [inspect][] module is used to define the signature for +callbacks, and to validate that only compatible callables are assigned. + +## Context-based logging + +Module [firebird.base.logging][] provides context-based logging system built on top of standard +[logging][] module. It also solves the common logging management problem when various modules +use hard-coded separate loggers, and provides several types of message wrappers that allow +lazy message interpolation using f-string, brace (`str.format`) or dollar (`string.Template`) formats. + +The context-based logging: + +1. Adds context information into [logging.LogRecord][], that could be used in logging entry formats. +2. Allows assignment of loggers to specific contexts. + +## Trace/audit for class instances + +Module [firebird.base.trace][] provides trace/audit logging for functions or object methods through +context-based logging provided by [firebird.base.logging][] module. + +The trace logging is performed by [traced][firebird.base.trace.traced] decorator. You can use +this decorator directly, or use [TracedMixin][firebird.base.trace.TracedMixin] class to automatically +decorate methods of class instances on creation. +Each decorated callable could log messages before execution, after successful execution or +on failed execution (when unhandled exception is raised by callable). The trace decorator +can automatically add `agent` and `context` information, and include parameters passed to +callable, execution time, return value, information about raised exception etc. to log messages. + +The trace logging is managed by [TraceManager][firebird.base.trace.TraceManager], that allows dynamic configuration of traced +callables at runtime. + +Trace supports configuration based on [firebird.base.config][]. + +## Symetric data conversion from/to string + +While Python types typically support conversion to string via builtin `str()` function (and +custom `__str__` methods), there is no symetric operation that converts string created by +`str()` back to typed value. Module [firebird.base.strconv][] provides support for such symetric conversion +from/to string for any data type. + +Symetric string conversion is used by [firebird.base.config][] module, notably by +`ListOption` and `DataclassOption`. You can extend the range of data types supported by these +options by registering convertors for required data types. + +## Configuration definitions + +Complex applications could be often parametrized via configuration. Module [firebird.base.config][] +provides a framework for unified structured configuration that supports: + +* configuration options of various data type, including lists and other complex types +* validation +* direct manipulation of configuration values +* reading from (and writing into) configuration in [configparser][] format, including support + for option values defined via environment variables (see [EnvExtendedInterpolation][firebird.base.config.EnvExtendedInterpolation]) +* exchanging configuration (for example between processes) using Google protobuf messages + +Additionally, the [DirectoryScheme][firebird.base.config.DirectoryScheme] abstract base +class defines set of mostly used application directories. The function `get_directory_scheme()` +could be then used to obtain instance that implements platform-specific standards for +file-system location for these directories. Currently, only "Windows", "Linux" and "MacOS" +directory schemes are supported. + +## Memory buffer manager + +Module [firebird.base.buffer][] provides a raw memory buffer manager with convenient methods to read/write +data of various data types. + +## Hook manager + +Module [firebird.base.hooks][] provides a general framework for callbacks and “hookable” events, that +supports multiple usage strategies. + +## Registry for Google Protocol Buffer messages and enums + +Module [firebird.base.protobuf][] provides central registry for Google Protocol Buffer messages and enums. +The generated `*_pb2.py protobuf` files could be registered using `register_descriptor()` or +`load_registered()` function. The registry could be then used to obtain information about +protobuf messages or enum types, or to create message instances or enum values. diff --git a/docs/docs/license.md b/docs/docs/license.md new file mode 100644 index 0000000..9f55092 --- /dev/null +++ b/docs/docs/license.md @@ -0,0 +1,25 @@ +# License + + +MIT License +=========== + +Copyright (c) 2019 The Firebird Project (www.firebirdsql.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/docs/logging.md b/docs/docs/logging.md new file mode 100644 index 0000000..5a56ce1 --- /dev/null +++ b/docs/docs/logging.md @@ -0,0 +1,238 @@ +# logging - Context-based logging + +## Overview + +::: firebird.base.logging + options: + members: false + +### Basics + +Normally, when you want to use a logger, you call the [logging.getLogger][] function and +pass it a logger name (or not to get the `root` logger). The common (and recommended) practice +is to use `getLogger(__name__)` which often leads to logging configuration problems as complex +applications with many modules (including used libraries) may create complex logger hierarchy. + +Our `logging` module solves the problem by replacing the logger name with `agent` identification. +The `agent` is typically an unit of code that works in specific execution contexts. For +example a code that process client request in web application (where request is the `context`), +or executes SQL command (the context could be a `database connection`). In most cases, the +`agent` is an instance of some class. + +So, from user's perspective, the context logging is used similarly to normal Python logging - +but you pass the `agent` identification instead logger name to [get_logger][firebird.base.logging.get_logger] +function. If `agent` identification is a string, it's used as is. If it's an object, it uses +value of its `_agent_name_` attribute if defined, otherwise it uses name in +"MODULE_NAME.CLASS_QUALNAME" format. If `_agent_name_` value is not a string, it's converted to string. + +The typical usage pattern inside a class is therefore: + +```python +logger = get_logger(self) +``` + +or for direct use: + +```python +get_logger(self).debug("message") +``` + +The [get_logger][firebird.base.logging.get_logger] also has an optional `topic` string parameter +that could be used to differentiate between various logging "streams". For example the +[firebird.base.trace][] module uses context logging with topic "trace", so it's possible to +configure the logging system to handle "trace" output in specific way. + +The underlying machinery behind [get_logger][firebird.base.logging.get_logger] function maps +the `agent` and its context to particular [logging.Logger][], and returns a +[firebird.base.logging.ContextLoggerAdapter][] that you can use as normal logger. This adapter is +responsible to add context information into [logging.LogRecord][]. + +### Context information +The context information added by [ContextLoggerAdapter][firebird.base.logging.ContextLoggerAdapter] +into [logging.LogRecord][] consists from next items: + +- **agent**: String representation of the agent identification described above. +- **context**: Agent context that could be defined via `log_context` attribute on `agent` instance, or + by assigning its value directly to `extra['context']` on adapter returned by `get_logger()` + function. +- **domain**: A name assigned to a group of agents (more about that later). +- **topic**: Name of a logging stream. + +The adapter keeps `context` in its `extra` dictionary. When processing a log message, it +copies `agent.log_context` into `extra['context']` only if that key is absent (using `None` +when the agent has no `log_context` attribute). Once set, the value remains in the adapter; +later changes to `agent.log_context` do not overwrite it. You can set or change +`log.extra['context']` yourself at any time, including before the first log message. + +To have the adapter read `agent.log_context` again on its next log message, delete the +cached key: + +```python +log = get_logger(agent) # An existing agent object +log.extra['context'] = 'manual context' +log.info('Uses the manual context') + +agent.log_context = 'current request' +del log.extra['context'] +log.info('Uses the current request context') +``` + +These record fields can be used in [logging.Formatter][] templates. If you want to combine +normal and context logging, assign [ContextFilter][firebird.base.logging.ContextFilter] +to your [logging.Handler][] to add (empty) context information into `LogRecords` that are +produced by normal loggers. + +### LoggingManager +The [firebird.base.logging][] module defines a global [LoggingManager][firebird.base.logging.LoggingManager] +instance [logging_manager][firebird.base.logging.logging_manager] that manages several mappings +and implements the `LoggingManager.get_logger` method. + +Some methods are also provided as global functions for conveniense: `get_logger`, +`set_agent_mapping`, `set_domain_mapping` and `get_agent_name`. + +### Mappings and Logger names + +The [logging.Logger][] wrapped by the `get_logger` function is determined by applying the +values ​​of several parameters to the logger name format. These parameters are: + +- Domain: String used to group output from agents. +- Topic: String identification of particular logging stream. + +The logger name format is a list that can contain any number of string values and at most +one occurrence of `DOMAIN` or `TOPIC` enum values. Empty strings are removed. + +The final [logging.Logger][] name is constructed by joining elements of this list with +dots, and with sentinels replaced with `domain` and `topic` names. + +For example, if values are defined as: + +```python +logger_fmt = ['app', DOMAIN, TOPIC] +domain = 'database' +topic = 'trace' +``` + +the Logger name will be: `"app.database.trace"` + +The logger name format is defined in `LoggingManager.logger_fmt` property, and it's an +empty list by default, which means that `get_logger` function always maps to **root** logger. + +The `domain` is determined from `agent` passed to `get_logger`. You can use `set_domain_mapping` +to assign agent identifications to particular domain. The agents that are not assigned to domain +belong to default domain specifid in `LoggingManager.default_domain`, which is `None` by default. + +It's also possible to change agent identification used for logger name mapping porposes to +different value with `set_agent_mapping` function. + +### Lazy Formatting Messages +This module also provides message wrapper classes (`FStrMessage`, `BraceMessage`, +`DollarMessage`) that defer string formatting until the log record is actually +processed by a handler. This avoids the performance cost of formatting messages +that might be filtered out due to log levels. + +### Basic Setup Example + +```python +import logging +from firebird.base.logging import ( + get_logger, LogLevel, ContextFilter, logging_manager, + DOMAIN, TOPIC # For logger_fmt +) + +# 1. Configure standard logging (handlers, formatters) +log_format = "[%(levelname)-8s] %(asctime)s %(name)s (%(agent)s) - %(message)s" +formatter = logging.Formatter(log_format) +handler = logging.StreamHandler() +handler.setFormatter(formatter) + +# 2. Add ContextFilter to handler(s) to ensure context fields exist +handler.addFilter(ContextFilter()) + +# 3. Get the root logger or specific standard loggers and add the handler +root_logger = logging.getLogger() +root_logger.addHandler(handler) +root_logger.setLevel(LogLevel.DEBUG) # Use LogLevel enum or logging constants + +# 4. (Optional) Configure logging_manager mappings +logging_manager.logger_fmt = ['app', DOMAIN, TOPIC] # Logger name format +logging_manager.default_domain = 'web' # Default domain if not mapped +logging_manager.set_domain_mapping('db', ['myapp.database.Connection']) # Map agent to domain + +# 5. Use in your code +class RequestHandler: + _agent_name_ = 'myapp.web.RequestHandler' # Optional explicit agent name + log_context = None # Can be set per request, e.g., client IP + + def handle(self, request_id): + self.log_context = f"ReqID:{request_id}" + logger = get_logger(self, topic='requests') # Get context logger + logger.info("Handling request...") + # ... processing ... + logger.debug("Request handled successfully.") + +class DbConnection: + _agent_name_ = 'myapp.database.Connection' + log_context = None # e.g., DB user + + def query(self, sql): + self.log_context = "user:admin" + logger = get_logger(self) # Use default topic (None) + logger.debug("Executing query: %s", sql) # Standard formatting works too + # ... execute ... + +# --- Execution --- +handler_instance = RequestHandler() +db_conn = DbConnection() + +handler_instance.handle("12345") +db_conn.query("SELECT * FROM T") + +# --- Example Output --- +# [INFO ] 2023-10-27... app.web.requests (myapp.web.RequestHandler) - Handling request... +# [DEBUG ] 2023-10-27... app.web.requests (myapp.web.RequestHandler) - Request handled successfully. +# [DEBUG ] 2023-10-27... app.db (myapp.database.Connection) - Executing query: SELECT * FROM T +``` + +## Enums & Flags + +::: firebird.base.logging.FormatElement + +::: firebird.base.logging.LogLevel + +## Constants + +::: firebird.base.logging.DOMAIN + +::: firebird.base.logging.TOPIC + +## Functions + +::: firebird.base.logging.get_logger + +::: firebird.base.logging.set_domain_mapping + +::: firebird.base.logging.set_agent_mapping + +::: firebird.base.logging.get_agent_name + +## Adapters and Filters + +::: firebird.base.logging.ContextLoggerAdapter + +::: firebird.base.logging.ContextFilter + +## Logging manager + +::: firebird.base.logging.LoggingManager + +## Messages + +::: firebird.base.logging.FStrMessage + +::: firebird.base.logging.BraceMessage + +::: firebird.base.logging.DollarMessage + +## Globals + +::: firebird.base.logging.logging_manager diff --git a/docs/docs/modules.md b/docs/docs/modules.md new file mode 100644 index 0000000..6d4d8fa --- /dev/null +++ b/docs/docs/modules.md @@ -0,0 +1,14 @@ +# Modules + +## Package modules + +- [Types](types.md) +- [Collections](collections.md) +- [Strconv](strconv.md) +- [Config](config.md) +- [Buffer](buffer.md) +- [Hooks](hooks.md) +- [Logging](logging.md) +- [Trace](trace.md) +- [Protobuf](protobuf.md) +- [Signal](signal.md) \ No newline at end of file diff --git a/docs/docs/protobuf.md b/docs/docs/protobuf.md new file mode 100644 index 0000000..9b4fdf3 --- /dev/null +++ b/docs/docs/protobuf.md @@ -0,0 +1,57 @@ +# protobuf - Registry for Google Protocol Buffer messages and enums + +## Overview + +::: firebird.base.protobuf + options: + members: false + +## Constants + +::: firebird.base.protobuf.PROTO_EMPTY + +::: firebird.base.protobuf.PROTO_ANY + +::: firebird.base.protobuf.PROTO_DURATION + +::: firebird.base.protobuf.PROTO_TIMESTAMP + +::: firebird.base.protobuf.PROTO_STRUCT + +::: firebird.base.protobuf.PROTO_VALUE + +::: firebird.base.protobuf.PROTO_LISTVALUE + +::: firebird.base.protobuf.PROTO_FIELDMASK + +## Functions + +::: firebird.base.protobuf.register_descriptor + +The former name `register_decriptor()` remains available as a deprecated alias until version 3.0. + +::: firebird.base.protobuf.load_registered + +::: firebird.base.protobuf.is_msg_registered + +::: firebird.base.protobuf.is_enum_registered + +::: firebird.base.protobuf.get_enum_type + +::: firebird.base.protobuf.get_enum_value_name + +::: firebird.base.protobuf.create_message + +::: firebird.base.protobuf.get_message_factory + +::: firebird.base.protobuf.get_enum_field_type + +::: firebird.base.protobuf.struct2dict + +::: firebird.base.protobuf.dict2struct + +## Data classes + +::: firebird.base.protobuf.ProtoMessageType + +::: firebird.base.protobuf.ProtoEnumType diff --git a/docs/docs/signal.md b/docs/docs/signal.md new file mode 100644 index 0000000..5c7a1e9 --- /dev/null +++ b/docs/docs/signal.md @@ -0,0 +1,164 @@ +# signal - Callback system based on Signals and Slots, and "Delphi events" + +## Overview + +::: firebird.base.signal + options: + members: false + +### Signals and Slots + +Signals and slots are suitable for 1:N notification schemes. The `Signal` works as a point +to which one or more Slots could be connected. When `Signal` is "emitted", all connected +slots are called (executed). It's possible to pass parameters to slot callables, but any +value returned by slot callable is ignored. The Signal contructor takes [inspect.Signature][] +argument that defines the required signature that callables (slots) must have to connect +to this signal. + +This mechanism is provided in two forms: + +- The `Signal` class to create signal instances for direct use. +- The `signal` decorator to define signals on classes. This decorator works like builtin + `property` (without setter and deleter), where the 'getter' method is used only to define + the signature required for slots. + +**Example:** + +```python +from __future__ import annotations +from firebird.base.signal import signal + +class Emitor: + def __init__(self, name: str): + self.name = name + def showtime(self): + self.signal_a(self, 'They Live!', 42) + @signal + def signal_a(self, source: Emitor, msg: str, value: int) -> None: + "Documentation for signal" + +class Receptor: + def __init__(self, name: str): + self.name = name + def on_signal_a(self, source: Emitor, msg: str, value: int) -> None: + print(f"{self.name} received signal from {source.name} ({msg=}, {value=})") + @classmethod + def cls_on_signal_a(cls, source: Emitor, msg: str, value: int) -> None: + print(f"{cls.__name__} received signal from {source.name} ({msg=}, {value=})") + +def on_signal_a(source: Emitor, msg: str, value: int): + print(f"Function 'on_signal_a' received signal from {source.name} ({msg=}, {value=})") + +e1 = Emitor('e1') +e2 = Emitor('e2') +r1 = Receptor('r1') +r2 = Receptor('r2') +# +e1.signal_a.connect(r1.on_signal_a) +e1.signal_a.connect(r2.on_signal_a) +e1.signal_a.connect(r1.cls_on_signal_a) +e2.signal_a.connect(on_signal_a) +e2.signal_a.connect(r2.on_signal_a) +# +e1.showtime() +e2.showtime() +``` + + +**Output from sample code**: + +```text +r1 received signal from e1 (msg='They Live!', value=42) +r2 received signal from e1 (msg='They Live!', value=42) +Receptor received signal from e1 (msg='They Live!', value=42) +Function 'on_signal_a' received signal from e2 (msg='They Live!', value=42) +r2 received signal from e2 (msg='They Live!', value=42) +``` +### Events + +Events are suitable for optional callbacks that delegate some functionality to other class +or function. + +The 'event' works as a point to which one 'slot' could be connected. The event itself +acts as callable, that executes the connected slot (if assigned). Events may have parameters +and return values. Events could be defined only on classes using `eventsocket` decorator, +that works like builtin `property` (without deleter), where the 'getter' method is used only +to define the signature required for slot, and 'setter' is used to assign the callable. +To disconnect the callable from event, simply assign None to the event. + +**Example:** + +```python +from __future__ import annotations +from firebird.base.signal import eventsocket + +class Component: + def __init__(self, name: str): + self.name = name + @eventsocket + def on_init(self, source: Component, arg: str) -> bool: + "Documentation for event" + @eventsocket + def on_exit(self, source: Component) -> None: + "Documentation for event" + def showtime(self) -> None: + print(f"{self.name}.on_init handler is {'SET' if self.on_init.is_set() else 'NOT SET'}") + print(f"{self.name}.on_exit handler is {'SET' if self.on_exit.is_set() else 'NOT SET'}") + print("Event handler returned", self.on_init(self, 'argument')) + print(f"{self.name} does something...") + self.on_exit(self) + +class Container: + def __init__(self): + self.c1 = Component('C1') + self.c1.on_init = self.event_init + self.c2 = Component('C2') + self.c2.on_init = self.event_init + self.c2.on_exit = self.event_exit + self.c3 = Component('C3') + self.c3.on_exit = self.event_exit + def event_init(self, source: Component, arg: str) -> bool: + print(f"Handlig {source.name}.on_init({arg=})") + return source is self.c2 + def event_exit(self, source: Component) -> None: + print(f"Handlig {source.name}.on_exit()") + def showtime(self) -> None: + self.c1.showtime() + self.c2.showtime() + self.c3.showtime() + +cn = Container() +cn.showtime() +``` +**Output from sample code**: + +```text +C1.on_init handler is SET +C1.on_exit handler is NOT SET +Handlig C1.on_init(arg='argument') +Event handler returned False +C1 does something... +C2.on_init handler is SET +C2.on_exit handler is SET +Handlig C2.on_init(arg='argument') +Event handler returned True +C2 does something... +Handlig C2.on_exit() +C3.on_init handler is NOT SET +C3.on_exit handler is SET +Event handler returned None +C3 does something... +Handlig C3.on_exit() +``` + +## Classes + +::: firebird.base.signal.Signal + +::: firebird.base.signal._EventSocket + +## Decorators + +::: firebird.base.signal.signal + +::: firebird.base.signal.eventsocket diff --git a/docs/docs/strconv.md b/docs/docs/strconv.md new file mode 100644 index 0000000..8ae009f --- /dev/null +++ b/docs/docs/strconv.md @@ -0,0 +1,124 @@ +# strconv - Data conversion from/to string + +## Overview + +::: firebird.base.strconv + options: + members: false + +While Python types typically support conversion to string via builtin `str()` function +(and custom `__str__` methods), there is no symetric operation that converts string created +by `str()` back to typed value. This module provides support for such symetric conversion +from/to string for any data type. + +!!! note + Symetric string conversion is used by [firebird.base.config][] module, notably by + [ListOption][firebird.base.config.ListOption] and [DataclassOption][firebird.base.config.DataclassOption]. + You can extend the range of data types supported by these options by registering convertors + for required data types. + +### Architecture + +Module maintains a global registry of convertors associated with a data type. These +convertor functions may support conversion for multiple data types, and must have signatures: + +```python +# Conversion to string + +def value2str(value: Any) -> str: + ... + +# Conversion from string + +def str2value(cls: Type, value: str) -> Any: + ... +``` + +Convertors should raise [ValueError][] when conversion fails. + +Convertors must be registered using [register_convertor][firebird.base.strconv.register_convertor] +function, or reassigned using [update_convertor][firebird.base.strconv.update_convertor]. + +There are two methods for use of registered convertors: + +1. By using functions [convert_to_str][firebird.base.strconv.convert_to_str] and + [convert_from_str][firebird.base.strconv.convert_from_str]. These functions use + convertor that is registered for particular data type. If there is no such convertor, + they use first convertor registered for any base class. The lookup is performed in Method + Resolution Order. This method is preferred when you will work with various data types. +2. Using [get_convertor][firebird.base.strconv.get_convertor] function to obtain registry + entry with convertors. + + This method is preferred when you will work with single data type repeatedly. + +!!! important + The convertor registry lookup could be done either for a class, or class name. + + Lookup for a class is first performed for specified class. If there is no such entry, + all base classes in Method Resolution Order are used for lookup, and the first entry + found is returned. + + Lookup for a class name could be performed only for a specified class. Because some types + could be converted using convertors registered for their base class, such lookup will + not find the required convertor entry. To circumvent this issue, it's necessary to + register a class for name lookup using [register_class][firebird.base.strconv.register_class] + function. The registry lookup uses this class registry to use the actual type instead type name. + +### Registered data types + +Module registers convertor functions for next data types: + +* `str` +* `int` +* `float` +* `complex` +* `bool`. Uses `TRUE_STR` and `FALSE_STR` list for conversion from string (the case is NOT + significant). Conversion to string always uses first item in these lists. +* `decimal.Decimal` +* `uuid.UUID` +* `MIME` +* `ZMQAddress` +* `enum.Enum` and `enum.Flag`. Supports conversion for all descendants of `Enum`, + `IntEnum`, `Flag` and `IntFlag`. + +!!! tip + Functions [any2str][firebird.base.strconv.any2str] and [str2any][firebird.base.strconv.str2any] + could be used to register conversion for data types that support symetric conversion via + `__str__()` method and class constructor (`__init__()` must accept one positional argument + that could be a string and all other arguments must be keyword arguments with default values). + +## Types for annotations + +::: firebird.base.strconv.TConvertToStr + +::: firebird.base.strconv.TConvertFromStr + +## Globals + +::: firebird.base.strconv.TRUE_STR + +::: firebird.base.strconv.FALSE_STR + +## Functions + +::: firebird.base.strconv.convert_to_str + +::: firebird.base.strconv.convert_from_str + +::: firebird.base.strconv.register_convertor + +::: firebird.base.strconv.update_convertor + +::: firebird.base.strconv.register_class + +::: firebird.base.strconv.has_convertor + +::: firebird.base.strconv.get_convertor + +::: firebird.base.strconv.any2str + +::: firebird.base.strconv.str2any + +## Dataclasses + +::: firebird.base.strconv.Convertor diff --git a/docs/docs/trace.md b/docs/docs/trace.md new file mode 100644 index 0000000..448c646 --- /dev/null +++ b/docs/docs/trace.md @@ -0,0 +1,414 @@ +# trace - Trace/audit for class instances + +## Overview + +::: firebird.base.trace + options: + members: false + +Trace behavior can be configured dynamically at runtime using the `TraceManager`. +This includes: + +* Enabling/disabling tracing globally or for specific aspects (before/after/fail). +* Registering classes whose methods should be traced. +* Adding specific trace configurations (like custom messages or levels) for + individual methods using `TraceManager.add_trace()`. +* Loading comprehensive trace configurations from `ConfigParser` files using + `TraceManager.load_config()`, which allows specifying traced classes, methods, + and decorator parameters via INI-style sections (see `TraceConfig`). + +## Example + +The following program is an example of small but complex enough code that you can use to +experiment with code tracing options. The parts relevant to tracing are highlighted in +the code by embedded comments. + +```python +# test_trace.py +from __future__ import annotations +import logging +from time import monotonic +from decimal import Decimal +from enum import IntEnum, auto +from firebird.base.types import * +from firebird.base.logging import LogLevel, get_logger +from firebird.base.trace import TracedMixin, add_trace, trace_manager, TraceFlag, traced + +class Mood(IntEnum): + "Agent moods" + ANGRY = auto() + SAD = auto() + NEUTRAL = auto() + PLEASED = auto() + HAPPY = auto() + +class Person(TracedMixin): # TRACE + "Sample virtual human agent" + def __init__(self, name: str, mood: Mood=Mood.NEUTRAL): + self.name: str = name + self.mood: Mood = mood + self.partners: list[Person] = [] + # >>> LOGGING & TRACE + @property + def _agent_name_(self) -> str: + return f"{self.mood.name} {self.name}" + # <<< LOGGING & TRACE + def change_mood(self, offset: int) -> None: + result = self.mood + offset + if result < Mood.ANGRY: + self.mood = Mood.ANGRY + elif result > Mood.HAPPY: + self.mood = Mood.HAPPY + else: + self.mood = Mood(result) + def process(self, message: str) -> None: + msg = message.lower() + if msg == "what you are doing here": + self.change_mood(-1) + if 'awful' in msg: + self.change_mood(-1) + if ('nice' in msg) or ('wonderful' in msg) or ('pleased' in msg): + if self.mood != Mood.ANGRY: + self.change_mood(1) + if 'happy' in msg: + if self.mood != Mood.ANGRY: + self.change_mood(2) + if 'very nice' in msg: + if self.mood != Mood.ANGRY: + self.change_mood(1) + if 'get lost' in msg: + self.change_mood(-2) + if self.name.lower() in msg: + if self.mood == Mood.SAD: + self.change_mood(1) + if self.name.lower() not in msg: + if self.mood == Mood.NEUTRAL: + self.change_mood(-1) + def process_response(self, to: str, mood: Mood) -> None: + if to == 'greeting': + if self.mood == Mood.NEUTRAL: + if mood > Mood.NEUTRAL: + self.mood = Mood.PLEASED + elif mood == Mood.ANGRY: + self.mood = Mood.SAD + elif self.mood == Mood.SAD: + if mood == Mood.SAD: + self.mood = Mood.NEUTRAL + elif mood == Mood.HAPPY: + self.mood = Mood.ANGRY + elif self.mood == Mood.ANGRY and mood == Mood.SAD: + self.mood = Mood.NEUTRAL + elif to == 'chat': + if self.mood == Mood.SAD and mood > Mood.NEUTRAL: + self.mood = Mood.NEUTRAL + elif self.mood == Mood.ANGRY and mood == Mood.SAD: + self.mood = Mood.NEUTRAL + elif self.mood == Mood.PLEASED and mood == Mood.ANGRY: + self.mood = Mood.NEUTRAL + elif self.mood == Mood.HAPPY and mood == Mood.ANGRY: + self.mood = Mood.SAD + elif to == 'bye': + if self.mood == Mood.NEUTRAL: + if mood == Mood.ANGRY: + self.mood = Mood.ANGRY + elif mood > Mood.NEUTRAL: + self.mood = Mood.PLEASED + elif self.mood == Mood.HAPPY and mood == Mood.ANGRY: + self.mood = Mood.NEUTRAL + def meet(self, other: Person) -> None: + self.partners.append(other) + self.greeting(other) + def interact(self, other: Person, message: str) -> Mood: + print(f"[{other.name}] {message}") + self.process(message) + return self.mood + def greeting(self, other: Person) -> None: + if self.mood == Mood.NEUTRAL: + msg = f"Hi {other.name}, I'm {self.name}" + elif self.mood == Mood.ANGRY: + msg = "Hi" + elif self.mood == Mood.SAD: + msg = f"Hi {other.name}" + else: + msg = f"Hi {other.name}, I'm {self.name}. I'm {self.mood.name} to meet you." + self.process_response('greeting', other.interact(self, msg)) + def chat(self) -> None: + for other in self.partners: + if self.mood == Mood.ANGRY: + msg = "What you are doing here?" + elif self.mood == Mood.SAD: + msg = "The weather is awful today, don't you think?" + elif self.mood == Mood.NEUTRAL: + msg = "It's a fine day, don't you think?" + elif self.mood == Mood.PLEASED: + msg = "It's a very nice day, don't you think?" + else: + msg = "Today is a wonderful day!" + self.process_response('chat', other.interact(self, msg)) + def bye(self) -> str: + while self.partners: + other = self.partners.pop() + if self.mood == Mood.ANGRY: + msg = "Get lost!" + elif self.mood == Mood.SAD: + msg = "Bye" + elif self.mood == Mood.NEUTRAL: + msg = f"Bye, {other.name}." + elif self.mood == Mood.PLEASED: + msg = f"See you, {other.name}!" + else: + msg = f"Bye, {other.name}. Have a nice day!" + self.process_response('bye', other.interact(self, msg)) + if self.mood == Mood.ANGRY: + result = "I hate this meeting!" + elif self.mood == Mood.SAD: + result = "It was a waste of time!" + elif self.mood == Mood.NEUTRAL: + result = "It was OK." + elif self.mood == Mood.PLEASED: + result = "Nice meeting, I think." + else: + result = "What a wonderful meeting!" + return result + def __repr__(self) -> str: + # Replace "..Person object at .." with something more suitable for trace + return f"Person('{self.name}', {self.mood.name})" + +def meeting(name: str, persons: list[Person]): + "Simulation of virtual agents meeting" + + for person in persons: + person.log_context = name + + start = monotonic() + print("Meeting started...") + print(f"Attendees: {', '.join(f'{x.name} [{x.mood.name}]' for x in persons)}") + + for person in persons: + for other in persons: + if other is not person: + person.meet(other) + + for person in persons: + person.chat() + + for person in persons: + person.bye() + + e = str(Decimal(monotonic() - start)) + print(f"Meeting closed in {e[:e.find('.')+6]} sec.") + print(f"Outcome: {', '.join(f'{x.name} [{x.mood.name}]' for x in persons)}") + + +def test_trace(name: str, first: Mood, second: Mood) -> None: + print("- without trace ----------") + meeting(name, [Person('Alex', first), Person('David', second)]) + + print("- trace ------------------") + # >>> TRACE + add_trace(Person, 'greeting') + add_trace(Person, 'bye') + add_trace(Person, 'chat') + add_trace(Person, 'change_mood') + add_trace(Person, 'process', with_args=False) + add_trace(Person, 'process_response') + # <<< TRACE + meeting(name, [Person('Alex', first), Person('David', second)]) + +if __name__ == '__main__': + # >>> LOGGING + logger = logging.getLogger() + logger.setLevel(LogLevel.NOTSET) + sh = logging.StreamHandler() + sh.setFormatter(logging.Formatter('%(levelname)-10s: [%(topic)s][%(agent)s][%(context)s] %(message)s')) + logger.addHandler(sh) + # <<< LOGGING + # >>> TRACE + trace_manager.flags |= TraceFlag.ACTIVE + trace_manager.flags |= (TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) + # <<< TRACE + test_trace('TEST-1', Mood.SAD, Mood.PLEASED) + +``` + + +**Output from sample code**: + +```text +> python test-trace.py +- without trace ---------- +Meeting started... +Attendees: Alex [SAD], David [PLEASED] +[Alex] Hi David +[David] Hi Alex, I'm David. I'm PLEASED to meet you. +[Alex] It's a fine day, don't you think? +[David] It's a very nice day, don't you think? +[Alex] Bye, David. Have a nice day! +[David] Bye, Alex. Have a nice day! +Meeting closed in 0.00005 sec. +Outcome: Alex [HAPPY], David [HAPPY] +- trace ------------------ +Meeting started... +Attendees: Alex [SAD], David [PLEASED] +DEBUG : [trace][SAD Alex][TEST-1] >>> greeting(other=Person('David', PLEASED)) +[Alex] Hi David +DEBUG : [trace][PLEASED David][TEST-1] >>> process +DEBUG : [trace][PLEASED David][TEST-1] <<< process[0.00001] +DEBUG : [trace][SAD Alex][TEST-1] >>> process_response(to='greeting', mood=) +DEBUG : [trace][SAD Alex][TEST-1] <<< process_response[0.00000] +DEBUG : [trace][SAD Alex][TEST-1] <<< greeting[0.00027] +DEBUG : [trace][PLEASED David][TEST-1] >>> greeting(other=Person('Alex', SAD)) +[David] Hi Alex, I'm David. I'm PLEASED to meet you. +DEBUG : [trace][SAD Alex][TEST-1] >>> process +DEBUG : [trace][SAD Alex][TEST-1] >>> change_mood(offset=1) +DEBUG : [trace][SAD Alex][TEST-1] <<< change_mood[0.00000] +DEBUG : [trace][SAD Alex][TEST-1] <<< process[0.00009] +DEBUG : [trace][PLEASED David][TEST-1] >>> process_response(to='greeting', mood=) +DEBUG : [trace][PLEASED David][TEST-1] <<< process_response[0.00000] +DEBUG : [trace][PLEASED David][TEST-1] <<< greeting[0.00028] +DEBUG : [trace][NEUTRAL Alex][TEST-1] >>> chat() +[Alex] It's a fine day, don't you think? +DEBUG : [trace][PLEASED David][TEST-1] >>> process +DEBUG : [trace][PLEASED David][TEST-1] <<< process[0.00000] +DEBUG : [trace][NEUTRAL Alex][TEST-1] >>> process_response(to='chat', mood=) +DEBUG : [trace][NEUTRAL Alex][TEST-1] <<< process_response[0.00000] +DEBUG : [trace][NEUTRAL Alex][TEST-1] <<< chat[0.00018] +DEBUG : [trace][PLEASED David][TEST-1] >>> chat() +[David] It's a very nice day, don't you think? +DEBUG : [trace][NEUTRAL Alex][TEST-1] >>> process +DEBUG : [trace][NEUTRAL Alex][TEST-1] >>> change_mood(offset=1) +DEBUG : [trace][NEUTRAL Alex][TEST-1] <<< change_mood[0.00000] +DEBUG : [trace][PLEASED Alex][TEST-1] >>> change_mood(offset=1) +DEBUG : [trace][PLEASED Alex][TEST-1] <<< change_mood[0.00000] +DEBUG : [trace][NEUTRAL Alex][TEST-1] <<< process[0.00017] +DEBUG : [trace][PLEASED David][TEST-1] >>> process_response(to='chat', mood=) +DEBUG : [trace][PLEASED David][TEST-1] <<< process_response[0.00000] +DEBUG : [trace][PLEASED David][TEST-1] <<< chat[0.00036] +DEBUG : [trace][HAPPY Alex][TEST-1] >>> bye() +[Alex] Bye, David. Have a nice day! +DEBUG : [trace][PLEASED David][TEST-1] >>> process +DEBUG : [trace][PLEASED David][TEST-1] >>> change_mood(offset=1) +DEBUG : [trace][PLEASED David][TEST-1] <<< change_mood[0.00000] +DEBUG : [trace][PLEASED David][TEST-1] <<< process[0.00008] +DEBUG : [trace][HAPPY Alex][TEST-1] >>> process_response(to='bye', mood=) +DEBUG : [trace][HAPPY Alex][TEST-1] <<< process_response[0.00000] +DEBUG : [trace][HAPPY Alex][TEST-1] <<< bye[0.00026] Result: 'What a wonderful meeting!' +DEBUG : [trace][HAPPY David][TEST-1] >>> bye() +[David] Bye, Alex. Have a nice day! +DEBUG : [trace][HAPPY Alex][TEST-1] >>> process +DEBUG : [trace][HAPPY Alex][TEST-1] >>> change_mood(offset=1) +DEBUG : [trace][HAPPY Alex][TEST-1] <<< change_mood[0.00000] +DEBUG : [trace][HAPPY Alex][TEST-1] <<< process[0.00008] +DEBUG : [trace][HAPPY David][TEST-1] >>> process_response(to='bye', mood=) +DEBUG : [trace][HAPPY David][TEST-1] <<< process_response[0.00000] +DEBUG : [trace][HAPPY David][TEST-1] <<< bye[0.00025] Result: 'What a wonderful meeting!' +Meeting closed in 0.00223 sec. +Outcome: Alex [HAPPY], David [HAPPY] +``` + +## Trace configuration + +Trace supports configuration based on [firebird.base.config][]. + +**Sample configuration file**: + +```ini +[trace] +flags = ACTIVE | FAIL +;flags = ACTIVE | BEFORE | AFTER | FAIL +classes = + trace_ChannelManager + trace_Channel + trace_DealerChannel + trace_SimpleService + trace_TextIOServiceImpl + trace_PipeServerHandler + +[trace_PipeServerHandler] +source = saturnin.core.protocol.fbdp.PipeServerHandler +methods = close, send_ready, send_close + +[trace_ChannelManager] +source = saturnin.core.base.ChannelManager +special = trace_defer + +[trace_Channel] +source = saturnin.core.base.Channel +methods = send, receive, close, bind, unbind, connect, disconnect + +[trace_DealerChannel] +source = saturnin.core.base.DealerChannel +methods = send, receive, close, bind, unbind, connect, disconnect + +[trace_SimpleService] +source = saturnin.core.classic.SimpleService +methods = validate, run, initialize, start +with_args = no + +[trace_TextIOServiceImpl] +source = saturnin.sdk.micro.textio.service.TextIOServiceImpl +methods = initialize, configure, validate, finalize +with_args = no + +[trace_defer] +method = defer +max_param_length = 50 +``` + +`[trace] classes` names the class sections that are loaded. Each class section's `source` +identifies the Python class to trace; `methods` names methods to trace with that section's +settings. A `special` entry names another section with a `method` and its own settings, +as `[trace_ChannelManager]` does for `[trace_defer]`. Sections that are not referenced +by `classes` or `special` are ignored. + +## Enums & Flags + +::: firebird.base.trace.TraceFlag + +## Functions + +::: firebird.base.trace.add_trace + +::: firebird.base.trace.remove_trace + +::: firebird.base.trace.trace_object + +## Trace manager + +::: firebird.base.trace.TraceManager + +## Trace/audit decorator + +::: firebird.base.trace.traced + +## Mixins + +::: firebird.base.trace.TracedMixin + +## Globals + +::: firebird.base.trace.trace_manager + +## Trace configuration classes + +::: firebird.base.trace.BaseTraceConfig + options: + inherited_members: false + +::: firebird.base.trace.TracedMethodConfig + options: + inherited_members: false + +::: firebird.base.trace.TracedClassConfig + options: + inherited_members: false + +::: firebird.base.trace.TraceConfig + options: + inherited_members: false + +## Dataclasses + +::: firebird.base.trace.TracedItem + +::: firebird.base.trace.TracedClass diff --git a/docs/docs/types.md b/docs/docs/types.md new file mode 100644 index 0000000..4667d5b --- /dev/null +++ b/docs/docs/types.md @@ -0,0 +1,144 @@ + +# types - Common data types + +## Overview + +::: firebird.base.types + options: + members: false + +## Exceptions + +::: firebird.base.types.Error +## Singletons + +Singleton is a pattern that restricts the instantiation of a class to one "single" instance. +This is useful when exactly one object is needed to coordinate actions across the system. + +Common uses: + +- The abstract factory, factory method, builder, and prototype patterns can use singletons + in their implementation. +- Facade objects are often singletons because only one facade object is required. +- State objects are often singletons. +- Singletons are often preferred to global variables because: + + - They do not pollute the global namespace with unnecessary variables. + - They permit lazy allocation and initialization. + +To create your own singletons, use [Singleton][firebird.base.types.Singleton] as the base class. + +!!! example + ```python + >>> class MySingleton(Singleton): + ... "Description" + ... ... + ... + >>> obj1 = MySingleton() + >>> obj2 = MySingleton() + >>> obj1 is obj2 + True + ``` + +::: firebird.base.types.Singleton + +## Sentinels + +The Sentinel Object pattern is a standard Pythonic approach that’s used both in the +Standard Library and beyond. The pattern most often uses Python’s built-in `None` object, +but in situations where None might be a useful value, a unique sentinel `object()` can be +used instead to indicate missing or unspecified data, or other specific condition. + +However, the plain `object()` sentinel has not very useful `str` and `repr` values. +The [Sentinel][firebird.base.types.Sentinel] class provides named sentinels, with meaningful `str` and `repr`. + +::: firebird.base.types.Sentinel + +### Predefined sentinels + +::: firebird.base.types.DEFAULT + +::: firebird.base.types.INFINITY + +::: firebird.base.types.UNLIMITED + +::: firebird.base.types.UNKNOWN + +::: firebird.base.types.NOT_FOUND + +::: firebird.base.types.UNDEFINED + +::: firebird.base.types.ANY + +::: firebird.base.types.ALL + +::: firebird.base.types.SUSPEND + +::: firebird.base.types.RESUME + +::: firebird.base.types.STOP + +## Distinct objects + +Some complex data structures or data processing algorithms require unique object +identification (ie object identity). In Python, an object identity is defined internally +as unique instance identity that is not suitable for complex objects whose identity is +derived from content. + +The [Distinct][firebird.base.types.Distinct] abstract base class is intended as a unified solution to these needs. + +!!! seealso + module [firebird.base.collections][] + +::: firebird.base.types.Distinct + +::: firebird.base.types.CachedDistinct + +## Enums + +::: firebird.base.types.ByteOrder + +::: firebird.base.types.ZMQTransport + +::: firebird.base.types.ZMQDomain + +## Custom string types + +Some string values have unified structure and carry specific information (like network +address or database connection string). Typical repeating operation with these values +are validation and parsing. It makes sense to put these operations under one roof. +One such approach uses custom descendants of builtin `str` type. + +!!! caution + Custom string types have an inherent weakness. They support all inherited string methods, + but any method that returns string value return a base `str` type, not the descendant class + type. That same apply when you assign strings to variables that should be of custom + string type. + + !!! tip + Module [firebird.base.strconv][] could help you to safely translate strings stored + externally to typed strings. + +::: firebird.base.types.ZMQAddress + +::: firebird.base.types.MIME + +::: firebird.base.types.PyExpr + +::: firebird.base.types.PyCode + +::: firebird.base.types.PyCallable + +## Meta classes + +::: firebird.base.types.SingletonMeta + +::: firebird.base.types._SentinelMeta + +::: firebird.base.types.CachedDistinctMeta + +::: firebird.base.types.conjunctive + +## Functions + +::: firebird.base.types.load diff --git a/docs/hooks.txt b/docs/hooks.txt deleted file mode 100644 index 5fe38eb..0000000 --- a/docs/hooks.txt +++ /dev/null @@ -1,157 +0,0 @@ -.. module:: firebird.base.hooks - :synopsis: Hook manager - -#################### -hooks - Hook manager -#################### - -Overview -======== - -This module provides a general framework for callbacks and "hookable" events. - -Architecture ------------- - -The callback extension mechanism is based on the following: - -* The `Event source` provides one or more "hookable events" that work like connection points. - The event source represents "origin of event" and is always identified by class, class - instance or name. Event sources that are identified by classes (or their instances) must - be registered along with events they provide. -* `Event` is typically linked to particular event source, but it's not mandatory and it's - possible to define global events. Event is represented as value of any type, that must - be unique in used context (particular event source or global). - - Each event should be properly documented along with required signature for callback - function. -* `Event provider` is a class or function that implements the event for event source, and - asks the `.hook_manager` for list of event consumers (callbacks) registered for particular - event and source. -* `Event consumer` is a function or class method that implements the callback for particular - event. The callback must be registered in `.hook_manager` before it could be called by - event providers. - - -The architecture supports multiple usage strategies: - -* If event provider uses class instance to identify the event source, it's possible to - register callbacks to all instances (by registering to class), or particular instance(s). -* It's possible to register callback to particular instance by name, if instance is associated - with name by `register_name()` function. -* It's possible to register callback to `.ANY` event from particular source, or particular - event from `.ANY` source, or even to `.ANY` event from `.ANY` source. - -Example -------- - -.. code-block:: python - - from __future__ import annotations - from enum import Enum, auto - from firebird.base.types import * - from firebird.base.hooks import hook_manager - - class MyEvents(Enum): - "Sample definition of events" - CREATE = auto() - ACTION = auto() - - class MyHookable: - "Example of hookable class, i.e. a class that calls hooks registered for events." - def __init__(self, name: str): - self.name: str = name - for hook in hook_manager.get_callbacks(MyEvents.CREATE, self): - try: - hook(self, MyEvents.CREATE) - except Exception as e: - print(f"{self.name}.CREATE hook call outcome: ERROR ({e.args[0]})") - else: - print(f"{self.name}.CREATE hook call outcome: OK") - def action(self): - print(f"{self.name}.ACTION!") - for hook in hook_manager.get_callbacks(MyEvents.ACTION, self): - try: - hook(self, MyEvents.ACTION) - except Exception as e: - print(f"{self.name}.ACTION hook call outcome: ERROR ({e.args[0]})") - else: - print(f"{self.name}.ACTION hook call outcome: OK") - - class MyHook: - "Example of hook implementation" - def __init__(self, name: str): - self.name: str = name - def callback(self, subject: MyHookable, event: MyEvents): - print(f"Hook {self.name} event {event.name} called by {subject.name}") - def err_callback(self, subject: MyHookable, event: MyEvents): - self.callback(subject, event) - raise Exception("Error in hook") - - - # Example code that installs and uses hooks - - hook_manager.register_class(MyHookable, MyEvents) - hook_A: MyHook = MyHook('Hook-A') - hook_B: MyHook = MyHook('Hook-B') - hook_C: MyHook = MyHook('Hook-C') - - print("Install hooks") - hook_manager.add_hook(MyEvents.CREATE, MyHookable, hook_A.callback) - hook_manager.add_hook(MyEvents.CREATE, MyHookable, hook_B.err_callback) - hook_manager.add_hook(MyEvents.ACTION, MyHookable, hook_C.callback) - - print("Create event sources, emits CREATE") - src_A: MyHookable = MyHookable('Source-A') - src_B: MyHookable = MyHookable('Source-B') - - print("Install instance hooks") - hook_manager.add_hook(MyEvents.ACTION, src_A, hook_A.callback) - hook_manager.add_hook(MyEvents.ACTION, src_B, hook_B.callback) - - print("And action!") - src_A.action() - src_B.action() - - -Output from sample code:: - - Install hooks - Create event sources, emits CREATE - Hook Hook-A event CREATE called by Source-A - Source-A.CREATE hook call outcome: OK - Hook Hook-B event CREATE called by Source-A - Source-A.CREATE hook call outcome: ERROR (Error in hook) - Hook Hook-A event CREATE called by Source-B - Source-B.CREATE hook call outcome: OK - Hook Hook-B event CREATE called by Source-B - Source-B.CREATE hook call outcome: ERROR (Error in hook) - Install instance hooks - And action! - Source-A.ACTION! - Hook Hook-A event ACTION called by Source-A - Source-A.ACTION hook call outcome: OK - Hook Hook-C event ACTION called by Source-A - Source-A.ACTION hook call outcome: OK - Source-B.ACTION! - Hook Hook-B event ACTION called by Source-B - Source-B.ACTION hook call outcome: OK - Hook Hook-C event ACTION called by Source-B - Source-B.ACTION hook call outcome: OK - - -Functions -========= -.. autofunction:: register_class -.. autofunction:: register_name -.. autofunction:: add_hook -.. autofunction:: get_callbacks - -Classes -======= -.. autoclass:: HookManager - -Globals -======= -.. autodata:: hook_manager - :no-value: diff --git a/docs/index.txt b/docs/index.txt deleted file mode 100644 index 86384c6..0000000 --- a/docs/index.txt +++ /dev/null @@ -1,48 +0,0 @@ - -#################################### -The Firebird Base library for Python -#################################### - -The `firebird-base` package provides common Python 3 modules used by `Firebird Project`_ -in various development projects. However, these modules have general applicability outside -the scope of development for Firebird RDBMS. - -Topic covered by `firebird-base` package: - -- General data types like `singletons`, `sentinels` and objects with identity. -- Unified system for data conversion from/to string. -- `DataList` and `Registry` collection types with advanced data-processing cappabilities. -- Work with structured binary buffers. -- Global registry of Google `protobuf` messages and enumerations. -- Extended configuration system based on `ConfigParser`. -- Context-based logging. -- Trace/audit for class instances. -- General "hook" mechanism. -- Callback system based on Signals and Slots, and "Delphi events". - - -.. note:: Requires Python 3.8+ - -.. tip:: You can download docset for Dash_ (MacOS) or Zeal_ (Windows / Linux) documentation - readers from releases_ at github. - -.. toctree:: - :maxdepth: 1 - :includehidden: - -.. toctree:: - :maxdepth: 1 - :hidden: - - introduction - modules - changelog - license - -| -| - -.. _Firebird Project: http://www.firebirdsql.org -.. _releases: https://github.com/FirebirdSQL/python3-base/releases -.. _Dash: https://kapeli.com/dash -.. _Zeal: https://zealdocs.org/ diff --git a/docs/introduction.txt b/docs/introduction.txt deleted file mode 100644 index 1edf58b..0000000 --- a/docs/introduction.txt +++ /dev/null @@ -1,147 +0,0 @@ -############ -Introduction -############ - -The firebird-base package is a set of Python 3 modules commonly used by Firebird Project in -various development projects (for example the firebird-driver or Saturnin). However, these -modules have general applicability outside the scope of development for Firebird RDBMS or -Firebird Butler. - -Common data types -================= - -The `.types` module provides collection of classes and other types that are often used by -other library modules or applications. - -* Exception `.Error` that is intended to be used as a base class of all application-related - errors. The important difference from `Exception` class is that `.Error` accepts keyword - arguments, that are stored into instance attributes with the same name. -* `.Singleton` base class for singletons. -* `.Sentinel` base class for named sentinel objects that provide meaningful `str` and `repr`, - along with collection of predefined sentinels. -* `.Distinct` abstract base class for classes (incl. dataclasses) with distinct instances. -* Collection of `Enums` and `custom string types`. - -Various collection types -======================== - -The `.collections` module provides data structures that behave much like builtin `list` and -`dict` types, but with direct support of operations that can use structured data stored in -container, and which would normally require utilization of `operator`, `functools` or other -means. - -All containers provide next operations: - -* `.filter` and `.filterfalse` that return generator that yields items for which expr is - evaluated as True (or False). -* `.find` that returns first item for which expr is evaluated as True, or default. -* `.contains` that returns True if there is any item for which expr is evaluated as True. -* `.occurrence` that returns number of items for which expr is evaluated as True. -* `.all` and `any` that return True if expr is evaluated as True for all or any collection element(s). -* `.report` that returns generator that yields data produced by expression(s) evaluated on collection items. - -Individual collection types provide additional operations like splitting and extracting -based on expression etc. - -Expressions used by these methods could be strings that contain Python expression referencing -the collection item(s), or lambda functions. - -Data conversion from/to string -============================== - -While Python types typically support conversion to string via builtin `str()` function (and -custom `__str__` methods), there is no symetric operation that converts string created by -`str()` back to typed value. Module `.strconv` provides support for such symetric conversion -from/to string for any data type. - -Symetric string conversion is used by `~firebird.base.config` module, notably by -`~firebird.base.config.ListOption` and `~firebird.base.config.DataclassOption`. You can -extend the range of data types supported by these options by registering convertors for -required data types. - -Configuration definitions -========================= - -Complex applications (and some library modules like `logging`) could be often parametrized -via configuration. Module `~firebird.base.config` provides a framework for unified structured -configuration that supports: - -* configuration options of various data type, including lists and other complex types -* validation -* direct manipulation of configuration values -* reading from (and writing into) configuration in `configparser` format, including support - for option values defined via environment variables (see `.EnvExtendedInterpolation`) -* exchanging configuration (for example between processes) using Google protobuf messages - -Additionally, the `.DirectoryScheme` abstract base class defines set of mostly -used application directories. The function `.get_directory_scheme()` could be then used -to obtain instance that implements platform-specific standards for file-system location -for these directories. Currently, only "Windows", "Linux" and "MacOS" directory schemes -are supported. - -Memory buffer manager -===================== - -Module `.buffer` provides a raw memory buffer manager with convenient methods to read/write -data of various data types. - -Hook manager -============ - -Module `.hooks` provides a general framework for callbacks and “hookable” events, that -supports multiple usage strategies. - -Context-based logging -===================== - -Module `.logging` provides context-based logging system built on top of standard `logging` -module. - -The context-based logging: - -* Adds context information (defined as combination of topic, agent and context string values) - into `~logging.LogRecord`, that could be used in logging message. -* Adds support for f-string message format. -* Allows assignment of loggers to specific contexts. The `.LoggingManager` class maintains - a set of bindings between `Logger` objects and combination of `agent`, `context` and `topic` - specifications. It’s possible to bind loggers to exact combination of values, or whole - sets of values using `.ANY` sentinel. It means that is possible to assign specific Logger - to log messages for particular agent in any context, or any agent operating in specific - context etc. - -Trace/audit for class instances -=============================== - -Module `.trace` provides trace/audit logging for functions or object methods through -context-based logging provided by `.logging` module. - -The trace logging is performed by `.traced` decorator. You can use this decorator directly, -or use `.TracedMixin` class to automatically decorate methods of class instances on creation. -Each decorated callable could log messages before execution, after successful execution or -on failed execution (when unhandled exception is raised by callable). The trace decorator -can automatically add `agent` and `context` information, and include parameters passed to -callable, execution time, return value, information about raised exception etc. to log messages. - -The trace logging is managed by `.TraceManager`, that allows dynamic configuration of traced -callables at runtime. - -Trace supports configuration based on `~firebird.base.config`. - -Registry for Google Protocol Buffer messages and enums -====================================================== - -Module `.protobuf` provides central registry for Google Protocol Buffer messages and enums. -The generated `*_pb2.py protobuf` files could be registered using `.register_decriptor` or -`.load_registered` function. The registry could be then used to obtain information about -protobuf messages or enum types, or to create message instances or enum values. - -Callback systems -================ - -Module `~firebird.base.signal` provides two callback mechanisms: one based on signals and -slots similar to Qt signal/slot, and second based on optional method delegation similar to -events in Delphi. - -In both cases, the callback callables could be functions, instance or class methods, -partials and lambda functions. The `inspect` module is used to define the signature for -callbacks, and to validate that only compatible callables are assigned. diff --git a/docs/license.txt b/docs/license.txt deleted file mode 100644 index 935e89d..0000000 --- a/docs/license.txt +++ /dev/null @@ -1,6 +0,0 @@ -####### -License -####### - -.. include:: ../LICENSE - diff --git a/docs/logging.txt b/docs/logging.txt deleted file mode 100644 index c869a27..0000000 --- a/docs/logging.txt +++ /dev/null @@ -1,310 +0,0 @@ -.. module:: firebird.base.logging - :synopsis: Context-based logging - -############################### -logging - Context-based logging -############################### - -Overview -======== - -This module provides context-based logging system built on top of standard `logging` module. - -The context-based logging: - -1. Adds context information into `~logging.LogRecord`, that could be used in logging message. -2. Adds support for f-string message format. -3. Allows assignment of loggers to specific contexts. - -The logging context -------------------- -The logging context is defined as combination of `topic`, `agent` and `context` string values. - -The `agent` is typically an unit of code that works in specific execution contexts. For -example a code that process client request in web application (where request is the context), -or executes SQL command (the context could be a database connection, or transaction). -The `topic` is optional. It could be any string value that can be used as secondary context. - -Agent and Context identification --------------------------------- -Agents and contexts could be identified by string value, or by any object (i.e. you can use -the object that implement the agent or context). If object is used, the ID could be -provided as `logging_id` attribute, `__name__` attribute or by `__str__()` return value. - -A `LoggingIdMixin` class could be used to add `logging_id` support to any class. - -The LoggingManager ------------------- -The `LoggingManager` class maintains a set of bindings between `~logging.Logger` objects and -combination of `agent`, `context` and `topic` specifications. It's possible to bind loggers -to exact combination of values, or whole sets of values using `.ANY` sentinel. It means that -is possible to assign specific `~logging.Logger` to log messages for particular agent in -any context, or any agent operating in specific context etc. - -To log `agent` activities, use a logger returned by `.get_logger()` function/method. - -Example -------- - -The following program is an example of small but complex enough code that you can use to experiment with contextual logging options. Parts relevant to logging are highlighted in the code by embedded comments. The program is very simple simulation of virtual "human" agents that can change their mood during mutual interaction. - -.. code-block:: python - - # test-logging.py - from __future__ import annotations - import logging - from time import monotonic - from decimal import Decimal - from enum import IntEnum, auto - from firebird.base.types import * - from firebird.base.logging import LogLevel, LoggingIdMixin, get_logger - - class Mood(IntEnum): - "Agent moods" - ANGRY = auto() - SAD = auto() - NEUTRAL = auto() - PLEASED = auto() - HAPPY = auto() - - class Person(LoggingIdMixin): # LOGGING - "Sample virtual human agent" - def __init__(self, name: str, mood: Mood=Mood.NEUTRAL): - self.name: str = name - self.mood: Mood = mood - self.partners: List[Person] = [] - # >>> LOGGING - @property - def _logging_id_(self) -> str: - return f"{self.mood.name} {self.name}" - # <<< LOGGING - def change_mood(self, offset: int) -> None: - result = self.mood + offset - if result < Mood.ANGRY: - self.mood = Mood.ANGRY - elif result > Mood.HAPPY: - self.mood = Mood.HAPPY - else: - self.mood = Mood(result) - def process(self, message: str) -> None: - msg = message.lower() - if msg == "what you are doing here": - self.change_mood(-1) - if 'awful' in msg: - self.change_mood(-1) - if ('nice' in msg) or ('wonderful' in msg) or ('pleased' in msg): - if self.mood != Mood.ANGRY: - self.change_mood(1) - if 'happy' in msg: - if self.mood != Mood.ANGRY: - self.change_mood(2) - if 'very nice' in msg: - if self.mood != Mood.ANGRY: - self.change_mood(1) - if 'get lost' in msg: - self.change_mood(-2) - if self.name.lower() in msg: - if self.mood == Mood.SAD: - self.change_mood(1) - if self.name.lower() not in msg: - if self.mood == Mood.NEUTRAL: - self.change_mood(-1) - def process_response(self, to: str, mood: Mood) -> None: - if to == 'greeting': - if self.mood == Mood.NEUTRAL: - if mood > Mood.NEUTRAL: - self.mood = Mood.PLEASED - elif mood == Mood.ANGRY: - self.mood = Mood.SAD - elif self.mood == Mood.SAD: - if mood == Mood.SAD: - self.mood = Mood.NEUTRAL - elif mood == Mood.HAPPY: - self.mood = Mood.ANGRY - elif self.mood == Mood.ANGRY and mood == Mood.SAD: - self.mood = Mood.NEUTRAL - elif to == 'chat': - if self.mood == Mood.SAD and mood > Mood.NEUTRAL: - self.mood = Mood.NEUTRAL - elif self.mood == Mood.ANGRY and mood == Mood.SAD: - self.mood = Mood.NEUTRAL - elif self.mood == Mood.PLEASED and mood == Mood.ANGRY: - self.mood = Mood.NEUTRAL - elif self.mood == Mood.HAPPY and mood == Mood.ANGRY: - self.mood = Mood.SAD - elif to == 'bye': - if self.mood == Mood.NEUTRAL: - if mood == Mood.ANGRY: - self.mood = Mood.ANGRY - elif mood > Mood.NEUTRAL: - self.mood = Mood.PLEASED - elif self.mood == Mood.HAPPY and mood == Mood.ANGRY: - self.mood = Mood.NEUTRAL - def meet(self, other: Person) -> None: - self.partners.append(other) - self.greeting(other) - def interact(self, other: Person, message: str) -> Mood: - print(f"[{other.name}] {message}") - # >>> LOGGING - get_logger(self, topic='Person').debug(f'Processing "{message}" from [{other.name}]') - # <<< LOGGING - self.process(message) - return self.mood - def greeting(self, other: Person) -> None: - if self.mood == Mood.NEUTRAL: - msg = f"Hi {other.name}, I'm {self.name}" - elif self.mood == Mood.ANGRY: - msg = "Hi" - elif self.mood == Mood.SAD: - msg = f"Hi {other.name}" - else: - msg = f"Hi {other.name}, I'm {self.name}. I'm {self.mood.name} to meet you." - self.process_response('greeting', other.interact(self, msg)) - def chat(self) -> None: - for other in self.partners: - if self.mood == Mood.ANGRY: - msg = "What you are doing here?" - elif self.mood == Mood.SAD: - msg = "The weather is awful today, don't you think?" - elif self.mood == Mood.NEUTRAL: - msg = "It's a fine day, don't you think?" - elif self.mood == Mood.PLEASED: - msg = "It's a very nice day, don't you think?" - else: - msg = "Today is a wonderful day!" - self.process_response('chat', other.interact(self, msg)) - def bye(self) -> str: - while self.partners: - other = self.partners.pop() - if self.mood == Mood.ANGRY: - msg = "Get lost!" - elif self.mood == Mood.SAD: - msg = "Bye" - elif self.mood == Mood.NEUTRAL: - msg = f"Bye, {other.name}." - elif self.mood == Mood.PLEASED: - msg = f"See you, {other.name}!" - else: - msg = f"Bye, {other.name}. Have a nice day!" - self.process_response('bye', other.interact(self, msg)) - if self.mood == Mood.ANGRY: - result = "I hate this meeting!" - elif self.mood == Mood.SAD: - result = "It was a waste of time!" - elif self.mood == Mood.NEUTRAL: - result = "It was OK." - elif self.mood == Mood.PLEASED: - result = "Nice meeting, I think." - else: - result = "What a wonderful meeting!" - return result - def __repr__(self) -> str: - return f"{self.name} [{self.mood.name}]" - - def meeting(name: str, persons: List[Person]): - "Simulation of virtual agents meeting" - - for person in persons: - person.log_context = name - - start = monotonic() - print("Meeting started...") - print(f"Attendees: {', '.join(f'{x.name} [{x.mood.name}]' for x in persons)}") - - for person in persons: - for other in persons: - if other is not person: - person.meet(other) - - for person in persons: - person.chat() - - for person in persons: - person.bye() - - e = str(Decimal(monotonic() - start)) - print(f"Meeting closed in {e[:e.find('.')+6]} sec.") - print(f"Outcome: {', '.join(f'{x.name} [{x.mood.name}]' for x in persons)}") - - def test_loggig(name: str, first: Mood, second: Mood) -> None: - meeting(name, [Person('Alex', first), Person('David', second)]) - - if __name__ == '__main__': - # >>> LOGGING - logger = logging.getLogger() - logger.setLevel(LogLevel.NOTSET) - sh = logging.StreamHandler() - sh.setFormatter(logging.Formatter('%(levelname)-10s: [%(topic)s][%(agent)s][%(context)s] %(message)s')) - logger.addHandler(sh) - # <<< LOGGING - test_loggig('TEST-1', Mood.SAD, Mood.PLEASED) - print('-'*20) - test_loggig('TEST-2', Mood.HAPPY, Mood.ANGRY) - -| - -**Output from sample code**:: - - > python test-logging.py - Meeting started... - Attendees: Alex [SAD], David [PLEASED] - [Alex] Hi David - DEBUG : [Person][PLEASED David][TEST-1] Processing "Hi David" from [Alex] - [David] Hi Alex, I'm David. I'm PLEASED to meet you. - DEBUG : [Person][SAD Alex][TEST-1] Processing "Hi Alex, I'm David. I'm PLEASED to meet you." from [David] - [Alex] It's a fine day, don't you think? - DEBUG : [Person][PLEASED David][TEST-1] Processing "It's a fine day, don't you think?" from [Alex] - [David] It's a very nice day, don't you think? - DEBUG : [Person][NEUTRAL Alex][TEST-1] Processing "It's a very nice day, don't you think?" from [David] - [Alex] Bye, David. Have a nice day! - DEBUG : [Person][PLEASED David][TEST-1] Processing "Bye, David. Have a nice day!" from [Alex] - [David] Bye, Alex. Have a nice day! - DEBUG : [Person][HAPPY Alex][TEST-1] Processing "Bye, Alex. Have a nice day!" from [David] - Meeting closed in 0.00132 sec. - Outcome: Alex [HAPPY], David [HAPPY] - -------------------- - Meeting started... - Attendees: Alex [HAPPY], David [ANGRY] - [Alex] Hi David, I'm Alex. I'm HAPPY to meet you. - DEBUG : [Person][ANGRY David][TEST-2] Processing "Hi David, I'm Alex. I'm HAPPY to meet you." from [Alex] - [David] Hi - DEBUG : [Person][HAPPY Alex][TEST-2] Processing "Hi" from [David] - [Alex] Today is a wonderful day! - DEBUG : [Person][ANGRY David][TEST-2] Processing "Today is a wonderful day!" from [Alex] - [David] What you are doing here? - DEBUG : [Person][SAD Alex][TEST-2] Processing "What you are doing here?" from [David] - [Alex] Bye - DEBUG : [Person][NEUTRAL David][TEST-2] Processing "Bye" from [Alex] - [David] Bye - DEBUG : [Person][SAD Alex][TEST-2] Processing "Bye" from [David] - Meeting closed in 0.00050 sec. - Outcome: Alex [SAD], David [SAD] - -Enums & Flags -============= -.. autoclass:: LogLevel -.. autoclass:: BindFlag - -Functions -========= -.. autofunction:: bind_logger -.. autofunction:: get_logger -.. autofunction:: get_logging_id -.. autofunction:: install_null_logger - -Logger adapter -============== -.. autoclass:: FBLoggerAdapter - -Logging manager -=============== -.. autoclass:: LoggingManager - -Mixins -====== -.. autoclass:: LoggingIdMixin - -Globals -======= -.. autodata:: logging_manager - :no-value: diff --git a/docs/make-docset b/docs/make-docset deleted file mode 100755 index b023f8c..0000000 --- a/docs/make-docset +++ /dev/null @@ -1,2 +0,0 @@ -doc2dash -u https://firebird-base.readthedocs.io/en/latest/ -f -i ./_static/fb-favicon.png -n firebird-base ./_build/html/ -tar --exclude='.DS_Store' -cvzf ../dist/firebird-base-docset.tgz firebird-base.docset diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 2119f51..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/modules.txt b/docs/modules.txt deleted file mode 100644 index d6e0484..0000000 --- a/docs/modules.txt +++ /dev/null @@ -1,20 +0,0 @@ -####### -Modules -####### - -Package modules -=============== - -.. toctree:: - :maxdepth: 2 - - types - collections - strconv - config - buffer - hooks - logging - trace - protobuf - signal diff --git a/docs/protobuf.txt b/docs/protobuf.txt deleted file mode 100644 index 9cd4ba4..0000000 --- a/docs/protobuf.txt +++ /dev/null @@ -1,43 +0,0 @@ -.. module:: firebird.base.protobuf - :synopsis: Registry for Google Protocol Buffer messages and enums - -################################################################# -protobuf - Registry for Google Protocol Buffer messages and enums -################################################################# - -Overview -======== - -This module provides central registry for Google Protocol Buffer messages and enums. -The generated `*_pb2.py` protobuf files could be registered using `register_decriptor` -or `load_registered` function. The registry could be then used to obtain information -about protobuf messages or enum types, or to create message instances or enum values. - -Constants -========= -.. autodata:: PROTO_EMPTY -.. autodata:: PROTO_ANY -.. autodata:: PROTO_DURATION -.. autodata:: PROTO_TIMESTAMP -.. autodata:: PROTO_STRUCT -.. autodata:: PROTO_VALUE -.. autodata:: PROTO_LISTVALUE -.. autodata:: PROTO_FIELDMASK - -Functions -========= -.. autofunction:: register_decriptor -.. autofunction:: load_registered -.. autofunction:: is_msg_registered -.. autofunction:: is_enum_registered -.. autofunction:: get_enum_type -.. autofunction:: get_enum_value_name -.. autofunction:: create_message -.. autofunction:: get_enum_field_type -.. autofunction:: struct2dict -.. autofunction:: dict2struct - -Data classes -============ -.. autoclass:: ProtoMessageType -.. autoclass:: ProtoEnumType diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 613891a..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -sphinx-bootstrap-theme>=0.8.1 -sphinx-autodoc-typehints>=1.24.0 -sphinx==7.2.6 -. diff --git a/docs/signal.txt b/docs/signal.txt deleted file mode 100644 index ecc3035..0000000 --- a/docs/signal.txt +++ /dev/null @@ -1,190 +0,0 @@ -.. module:: firebird.base.signal - :synopsis: Callback system based on Signals and Slots, and "Delphi events" - -######################################################################## -signal - Callback system based on Signals and Slots, and "Delphi events" -######################################################################## - -Overview -======== - -This module provides two callback mechanisms: one based on signals and slots similar to Qt -signal/slot, and second based on optional method delegation similar to events in Delphi. - -In both cases, the callback callables could be functions, instance or class methods, -partials and lambda functions. The `inspect` module is used to define the signature for -callbacks, and to validate that only compatible callables are assigned. - -.. important:: - - All type annotations in signatures are significant, so callbacks must have exactly the - same annotations as signatures used by signals or events. The sole exception are excess - keyword arguments with default values defined on connected callable. - -.. tip:: - - You may use `functools.partial` to adapt callable with different signatures. However, - you can "mask" only keyword arguments (without default) and leading positional arguments - (as any positional argument binded by name will not mask-out parameter from signature - introspection). - -Signals and Slots ------------------ - -Signals and slots are suitable for 1:N notification schemes. The `Signal` works as a point -to which one or more Slots could be connected. When `Signal` is "emitted", all connected -slots are called (executed). It's possible to pass parameters to slot callables, but any -value returned by slot callable is ignored. The Signal contructor takes `inspect.Signature` -argument that defines the required signature that callables (slots) must have to connect -to this signal. - -This mechanism is provided in two forms: - -- The `Signal` class to create signal instances for direct use. -- The `signal` decorator to define signals on classes. This decorator works like builtin - `property` (without setter and deleter), where the 'getter' method is used only to define - the signature required for slots. - -**Example:** - -.. code-block:: - - class Emitor: - def __init__(self, name: str): - self.name = name - def showtime(self): - self.signal_a(self, 'They Live!', 42) - @signal - def signal_a(self, source: Emitor, msg: str, value: int) -> None: - "Documentation for signal" - - class Receptor: - def __init__(self, name: str): - self.name = name - def on_signal_a(self, source: Emitor, msg: str, value: int) -> None: - print(f"{self.name} received signal from {source.name} ({msg=}, {value=})") - @classmethod - def cls_on_signal_a(cls, source: Emitor, msg: str, value: int) -> None: - print(f"{cls.__name__} received signal from {source.name} ({msg=}, {value=})") - - def on_signal_a(source: Emitor, msg: str, value: int): - print(f"Function 'on_signal_a' received signal from {source.name} ({msg=}, {value=})") - - e1 = Emitor('e1') - e2 = Emitor('e2') - r1 = Receptor('r1') - r2 = Receptor('r2') - # - e1.signal_a.connect(r1.on_signal_a) - e1.signal_a.connect(r2.on_signal_a) - e1.signal_a.connect(r1.cls_on_signal_a) - e2.signal_a.connect(on_signal_a) - e2.signal_a.connect(r2.on_signal_a) - # - e1.showtime() - e2.showtime() - -| - -**Output from sample code**:: - - r1 received signal from e1 (msg='They Live!', value=42) - r2 received signal from e1 (msg='They Live!', value=42) - Receptor received signal from e1 (msg='They Live!', value=42) - Function 'on_signal_a' received signal from e2 (msg='They Live!', value=42) - r2 received signal from e2 (msg='They Live!', value=42) - - -Events ------- - -Events are suitable for optional callbacks that delegate some functionality to other class -or function. - -The 'event' works as a point to which one 'slot' could be connected. The event itself -acts as callable, that executes the connected slot (if assigned). Events may have parameters -and return values. Events could be defined only on classes using `eventsocket` decorator, -that works like builtin `property` (without deleter), where the 'getter' method is used only -to define the signature required for slot, and 'setter' is used to assign the callable. -To disconnect the callable from event, simply assign None to the event. - -**Example:** - -.. code-block:: - - class Component: - def __init__(self, name: str): - self.name = name - @eventsocket - def on_init(self, source: Component, arg: str) -> bool: - "Documentation for event" - @eventsocket - def on_exit(self, source: Component) -> None: - "Documentation for event" - def showtime(self) -> None: - print(f"{self.name}.on_init handler is {'SET' if self.on_init.is_set() else 'NOT SET'}") - print(f"{self.name}.on_exit handler is {'SET' if self.on_exit.is_set() else 'NOT SET'}") - print("Event handler returned", self.on_init(self, 'argument')) - print(f"{self.name} does something...") - self.on_exit(self) - - class Container: - def __init__(self): - self.c1 = Component('C1') - self.c1.on_init = self.event_init - self.c2 = Component('C2') - self.c2.on_init = self.event_init - self.c2.on_exit = self.event_exit - self.c3 = Component('C3') - self.c3.on_exit = self.event_exit - def event_init(self, source: Component, arg: str) -> bool: - print(f"Handlig {source.name}.on_init({arg=})") - return source is self.c2 - def event_exit(self, source: Component) -> None: - print(f"Handlig {source.name}.on_exit()") - def showtime(self) -> None: - self.c1.showtime() - self.c2.showtime() - self.c3.showtime() - - cn = Container() - cn.showtime() - -| - -**Output from sample code**:: - - C1.on_init handler is SET - C1.on_exit handler is NOT SET - Handlig C1.on_init(arg='argument') - Event handler returned False - C1 does something... - C2.on_init handler is SET - C2.on_exit handler is SET - Handlig C2.on_init(arg='argument') - Event handler returned True - C2 does something... - Handlig C2.on_exit() - C3.on_init handler is NOT SET - C3.on_exit handler is SET - Event handler returned None - C3 does something... - Handlig C3.on_exit() - -Classes -======= - -.. autoclass:: Signal - ------------- - -.. autoclass:: _EventSocket - -Decorators -========== - -.. autoclass:: signal - ------------ - -.. autoclass:: eventsocket diff --git a/docs/strconv.txt b/docs/strconv.txt deleted file mode 100644 index 1eb7308..0000000 --- a/docs/strconv.txt +++ /dev/null @@ -1,150 +0,0 @@ -.. module:: firebird.base.strconv - :synopsis: Data conversion from/to string - -######################################## -strconv - Data conversion from/to string -######################################## - -Overview -======== - -While Python types typically support conversion to string via builtin `str()` function -(and custom `__str__` methods), there is no symetric operation that converts string created -by `str()` back to typed value. This module provides support for such symetric conversion -from/to string for any data type. - -.. note:: - - Symetric string conversion is used by `~firebird.base.config` module, notably by - `~firebird.base.config.ListOption` and `~firebird.base.config.DataclassOption`. You can - extend the range of data types supported by these options by registering convertors - for required data types. - -Architecture ------------- - -Module maintains a global registry of convertors associated with a data type. These -convertor functions may support conversion for multiple data types, and must have signatures: - -.. code-block:: - - # Conversion to string - - def value2str(value: Any) -> str: - ... - - # Conversion from string - - def str2value(cls: Type, value: str) -> Any: - ... - -Convertors should raise `ValueError` when conversion fails. - -Convertors must be registered using `register_convertor()` function, or reassigned using -`update_convertor()`. - -There are two methods for use of registered convertors: - -1. By using functions `convert_to_str()` and `convert_from_str()`. These functions use - convertor that is registered for particular data type. If there is no such convertor, - they use first convertor registered for any base class. The lookup is performed in Method - Resolution Order. - - This method is preferred when you will work with various data types. -2. Using `get_convertor()` function to obtain registry entry with convertors. - - This method is preferred when you will work with single data type repeatedly. - -.. important:: - - The convertor registry lookup could be done either for a class, or class name. - - Lookup for a class is first performed for specified class. If there is no such entry, - all bases classes in Method Resolution Order are used for lookup, and the first entry - found is returned. - - Lookup for a class name could be performed only for a specified class. Because some types - could be converted using convertors registered for their base class, such lookup will - not find the required convertor entry. To circumvent this issue, it's necessary to - register a class for name lookup using `register_class()` function. The registry lookup - uses this class registry to use the actual type instead type name. - -Registered data types ---------------------- - -Module registers convertor functions for next data types: - -* `str` -* `int` -* `float` -* `complex` -* `bool`. Uses `TRUE_STR` and `FALSE_STR` list for conversion from string (the case is NOT - significant). Conversion to string always uses first item in these lists. -* `~decimal.Decimal` -* `~uuid.UUID` -* `.MIME` -* `.ZMQAddress` -* `~enum.Enum` and `~enum.Flag`. Supports conversion for all descendants of `Enum`, - `IntEnum`, `Flag` and `IntFlag`. - -.. tip:: - - Functions `any2str()` and `str2any()` could be used to register conversion for data - types that support symetric conversion via `__str__()` method and class constructor - (`__init__()` must accept one positional argument that could be a string and all other - arguments must be keyword arguments with default values). - - -Types for annotations -===================== - -.. autodata:: TConvertToStr -.. autodata:: TConvertFromStr - -Globals -======= - -.. autodata:: TRUE_STR -.. autodata:: FALSE_STR - -Functions -========= - -.. autofunction:: convert_to_str - ----------------- - -.. autofunction:: convert_from_str - ------------------- - -.. autofunction:: register_convertor - ----------------- - -.. autofunction:: update_convertor - --------------- - -.. autofunction:: register_class - -------------- - -.. autofunction:: has_convertor - -------------- - -.. autofunction:: get_convertor - -------- - -.. autofunction:: any2str - -------- - -.. autofunction:: str2any - -Dataclasses -=========== - -.. autoclass:: Convertor diff --git a/docs/trace.txt b/docs/trace.txt deleted file mode 100644 index 86513d4..0000000 --- a/docs/trace.txt +++ /dev/null @@ -1,435 +0,0 @@ -.. module:: firebird.base.trace - :synopsis: Trace/audit for class instances - -####################################### -trace - Trace/audit for class instances -####################################### - -Overview -======== - -This module provides trace/audit logging for functions or object methods through context-based -logging provided by `.logging` module. - -The trace logging is performed by `traced` decorator. You can use this decorator directly, -or use `TracedMixin` class to automatically decorate methods of class instances on creation. -Each decorated callable could log messages before execution, after successful execution or -on failed execution (when unhandled execption is raised by callable). The trace decorator -can automatically add `agent` and `context` information, and include parameters passed to -callable, execution time, return value, information about raised exception etc. to log messages. - -The trace logging is managed by `TraceManager`, that allows dynamic configuration of traced -callables at runtime. - -Example -======= - -The following program is an example of small but complex enough code that you can use to -experiment with code tracing options. The parts relevant to tracing are highlighted in -the code by embedded comments. - -.. code-block:: python - - # test-trace.py - from __future__ import annotations - import logging - from time import monotonic - from decimal import Decimal - from enum import IntEnum, auto - from firebird.base.types import * - from firebird.base.logging import LogLevel, LoggingIdMixin, get_logger - from firebird.base.trace import TracedMixin, add_trace, trace_manager, TraceFlag, traced - - class Mood(IntEnum): - "Agent moods" - ANGRY = auto() - SAD = auto() - NEUTRAL = auto() - PLEASED = auto() - HAPPY = auto() - - class Person(LoggingIdMixin, TracedMixin): # LOGGING & TRACE - "Sample virtual human agent" - def __init__(self, name: str, mood: Mood=Mood.NEUTRAL): - self.name: str = name - self.mood: Mood = mood - self.partners: List[Person] = [] - # >>> LOGGING & TRACE - @property - def _logging_id_(self) -> str: - return f"{self.mood.name} {self.name}" - # <<< LOGGING & TRACE - def change_mood(self, offset: int) -> None: - result = self.mood + offset - if result < Mood.ANGRY: - self.mood = Mood.ANGRY - elif result > Mood.HAPPY: - self.mood = Mood.HAPPY - else: - self.mood = Mood(result) - def process(self, message: str) -> None: - msg = message.lower() - if msg == "what you are doing here": - self.change_mood(-1) - if 'awful' in msg: - self.change_mood(-1) - if ('nice' in msg) or ('wonderful' in msg) or ('pleased' in msg): - if self.mood != Mood.ANGRY: - self.change_mood(1) - if 'happy' in msg: - if self.mood != Mood.ANGRY: - self.change_mood(2) - if 'very nice' in msg: - if self.mood != Mood.ANGRY: - self.change_mood(1) - if 'get lost' in msg: - self.change_mood(-2) - if self.name.lower() in msg: - if self.mood == Mood.SAD: - self.change_mood(1) - if self.name.lower() not in msg: - if self.mood == Mood.NEUTRAL: - self.change_mood(-1) - def process_response(self, to: str, mood: Mood) -> None: - if to == 'greeting': - if self.mood == Mood.NEUTRAL: - if mood > Mood.NEUTRAL: - self.mood = Mood.PLEASED - elif mood == Mood.ANGRY: - self.mood = Mood.SAD - elif self.mood == Mood.SAD: - if mood == Mood.SAD: - self.mood = Mood.NEUTRAL - elif mood == Mood.HAPPY: - self.mood = Mood.ANGRY - elif self.mood == Mood.ANGRY and mood == Mood.SAD: - self.mood = Mood.NEUTRAL - elif to == 'chat': - if self.mood == Mood.SAD and mood > Mood.NEUTRAL: - self.mood = Mood.NEUTRAL - elif self.mood == Mood.ANGRY and mood == Mood.SAD: - self.mood = Mood.NEUTRAL - elif self.mood == Mood.PLEASED and mood == Mood.ANGRY: - self.mood = Mood.NEUTRAL - elif self.mood == Mood.HAPPY and mood == Mood.ANGRY: - self.mood = Mood.SAD - elif to == 'bye': - if self.mood == Mood.NEUTRAL: - if mood == Mood.ANGRY: - self.mood = Mood.ANGRY - elif mood > Mood.NEUTRAL: - self.mood = Mood.PLEASED - elif self.mood == Mood.HAPPY and mood == Mood.ANGRY: - self.mood = Mood.NEUTRAL - def meet(self, other: Person) -> None: - self.partners.append(other) - self.greeting(other) - def interact(self, other: Person, message: str) -> Mood: - print(f"[{other.name}] {message}") - # >>> LOGGING - get_logger(self, topic='Person').debug(f'Processing "{message}" from [{other.name}]') - # <<< LOGGING - self.process(message) - return self.mood - def greeting(self, other: Person) -> None: - if self.mood == Mood.NEUTRAL: - msg = f"Hi {other.name}, I'm {self.name}" - elif self.mood == Mood.ANGRY: - msg = "Hi" - elif self.mood == Mood.SAD: - msg = f"Hi {other.name}" - else: - msg = f"Hi {other.name}, I'm {self.name}. I'm {self.mood.name} to meet you." - self.process_response('greeting', other.interact(self, msg)) - def chat(self) -> None: - for other in self.partners: - if self.mood == Mood.ANGRY: - msg = "What you are doing here?" - elif self.mood == Mood.SAD: - msg = "The weather is awful today, don't you think?" - elif self.mood == Mood.NEUTRAL: - msg = "It's a fine day, don't you think?" - elif self.mood == Mood.PLEASED: - msg = "It's a very nice day, don't you think?" - else: - msg = "Today is a wonderful day!" - self.process_response('chat', other.interact(self, msg)) - def bye(self) -> str: - while self.partners: - other = self.partners.pop() - if self.mood == Mood.ANGRY: - msg = "Get lost!" - elif self.mood == Mood.SAD: - msg = "Bye" - elif self.mood == Mood.NEUTRAL: - msg = f"Bye, {other.name}." - elif self.mood == Mood.PLEASED: - msg = f"See you, {other.name}!" - else: - msg = f"Bye, {other.name}. Have a nice day!" - self.process_response('bye', other.interact(self, msg)) - if self.mood == Mood.ANGRY: - result = "I hate this meeting!" - elif self.mood == Mood.SAD: - result = "It was a waste of time!" - elif self.mood == Mood.NEUTRAL: - result = "It was OK." - elif self.mood == Mood.PLEASED: - result = "Nice meeting, I think." - else: - result = "What a wonderful meeting!" - return result - def __repr__(self) -> str: - return f"{self.name} [{self.mood.name}]" - - def meeting(name: str, persons: List[Person]): - "Simulation of virtual agents meeting" - - for person in persons: - person.log_context = name - - start = monotonic() - print("Meeting started...") - print(f"Attendees: {', '.join(f'{x.name} [{x.mood.name}]' for x in persons)}") - - for person in persons: - for other in persons: - if other is not person: - person.meet(other) - - for person in persons: - person.chat() - - for person in persons: - person.bye() - - e = str(Decimal(monotonic() - start)) - print(f"Meeting closed in {e[:e.find('.')+6]} sec.") - print(f"Outcome: {', '.join(f'{x.name} [{x.mood.name}]' for x in persons)}") - - - def test_trace(name: str, first: Mood, second: Mood) -> None: - print(f"- without trace ----------") - meeting(name, [Person('Alex', first), Person('David', second)]) - - print(f"- trace ------------------") - # >>> TRACE - add_trace(Person, 'greeting', traced) - add_trace(Person, 'bye', traced) - add_trace(Person, 'chat', traced) - add_trace(Person, 'change_mood', traced) - add_trace(Person, 'process', traced, with_args=False) - add_trace(Person, 'process_response', traced) - # <<< TRACE - meeting(name, [Person('Alex', first), Person('David', second)]) - - if __name__ == '__main__': - # >>> LOGGING - logger = logging.getLogger() - logger.setLevel(LogLevel.NOTSET) - sh = logging.StreamHandler() - sh.setFormatter(logging.Formatter('%(levelname)-10s: [%(topic)s][%(agent)s][%(context)s] %(message)s')) - logger.addHandler(sh) - # <<< LOGGING - # >>> TRACE - trace_manager.trace |= (TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) - trace_manager.trace_active = True - # <<< TRACE - test_trace('TEST-1', Mood.SAD, Mood.PLEASED) - -| - -**Output from sample code**:: - - > python test-trace.py - - without trace ---------- - Meeting started... - Attendees: Alex [SAD], David [PLEASED] - [Alex] Hi David - DEBUG : [Person][PLEASED David][TEST-1] Processing "Hi David" from [Alex] - [David] Hi Alex, I'm David. I'm PLEASED to meet you. - DEBUG : [Person][SAD Alex][TEST-1] Processing "Hi Alex, I'm David. I'm PLEASED to meet you." from [David] - [Alex] It's a fine day, don't you think? - DEBUG : [Person][PLEASED David][TEST-1] Processing "It's a fine day, don't you think?" from [Alex] - [David] It's a very nice day, don't you think? - DEBUG : [Person][NEUTRAL Alex][TEST-1] Processing "It's a very nice day, don't you think?" from [David] - [Alex] Bye, David. Have a nice day! - DEBUG : [Person][PLEASED David][TEST-1] Processing "Bye, David. Have a nice day!" from [Alex] - [David] Bye, Alex. Have a nice day! - DEBUG : [Person][HAPPY Alex][TEST-1] Processing "Bye, Alex. Have a nice day!" from [David] - Meeting closed in 0.00071 sec. - Outcome: Alex [HAPPY], David [HAPPY] - - trace ------------------ - Meeting started... - Attendees: Alex [SAD], David [PLEASED] - DEBUG : [trace][SAD Alex][TEST-1] >>> greeting(other=David [PLEASED]) - [Alex] Hi David - DEBUG : [Person][PLEASED David][TEST-1] Processing "Hi David" from [Alex] - DEBUG : [trace][PLEASED David][TEST-1] >>> process - DEBUG : [trace][PLEASED David][TEST-1] <<< process[0.00002] - DEBUG : [trace][SAD Alex][TEST-1] >>> process_response(to='greeting', mood=) - DEBUG : [trace][SAD Alex][TEST-1] <<< process_response[0.00000] - DEBUG : [trace][SAD Alex][TEST-1] <<< greeting[0.00050] - DEBUG : [trace][PLEASED David][TEST-1] >>> greeting(other=Alex [SAD]) - [David] Hi Alex, I'm David. I'm PLEASED to meet you. - DEBUG : [Person][SAD Alex][TEST-1] Processing "Hi Alex, I'm David. I'm PLEASED to meet you." from [David] - DEBUG : [trace][SAD Alex][TEST-1] >>> process - DEBUG : [trace][SAD Alex][TEST-1] >>> change_mood(offset=1) - DEBUG : [trace][SAD Alex][TEST-1] <<< change_mood[0.00000] - DEBUG : [trace][SAD Alex][TEST-1] <<< process[0.00018] - DEBUG : [trace][PLEASED David][TEST-1] >>> process_response(to='greeting', mood=) - DEBUG : [trace][PLEASED David][TEST-1] <<< process_response[0.00000] - DEBUG : [trace][PLEASED David][TEST-1] <<< greeting[0.00061] - DEBUG : [trace][NEUTRAL Alex][TEST-1] >>> chat() - [Alex] It's a fine day, don't you think? - DEBUG : [Person][PLEASED David][TEST-1] Processing "It's a fine day, don't you think?" from [Alex] - DEBUG : [trace][PLEASED David][TEST-1] >>> process - DEBUG : [trace][PLEASED David][TEST-1] <<< process[0.00000] - DEBUG : [trace][NEUTRAL Alex][TEST-1] >>> process_response(to='chat', mood=) - DEBUG : [trace][NEUTRAL Alex][TEST-1] <<< process_response[0.00000] - DEBUG : [trace][NEUTRAL Alex][TEST-1] <<< chat[0.00045] - DEBUG : [trace][PLEASED David][TEST-1] >>> chat() - [David] It's a very nice day, don't you think? - DEBUG : [Person][NEUTRAL Alex][TEST-1] Processing "It's a very nice day, don't you think?" from [David] - DEBUG : [trace][NEUTRAL Alex][TEST-1] >>> process - DEBUG : [trace][NEUTRAL Alex][TEST-1] >>> change_mood(offset=1) - DEBUG : [trace][NEUTRAL Alex][TEST-1] <<< change_mood[0.00000] - DEBUG : [trace][PLEASED Alex][TEST-1] >>> change_mood(offset=1) - DEBUG : [trace][PLEASED Alex][TEST-1] <<< change_mood[0.00000] - DEBUG : [trace][NEUTRAL Alex][TEST-1] <<< process[0.00035] - DEBUG : [trace][PLEASED David][TEST-1] >>> process_response(to='chat', mood=) - DEBUG : [trace][PLEASED David][TEST-1] <<< process_response[0.00000] - DEBUG : [trace][PLEASED David][TEST-1] <<< chat[0.00077] - DEBUG : [trace][HAPPY Alex][TEST-1] >>> bye() - [Alex] Bye, David. Have a nice day! - DEBUG : [Person][PLEASED David][TEST-1] Processing "Bye, David. Have a nice day!" from [Alex] - DEBUG : [trace][PLEASED David][TEST-1] >>> process - DEBUG : [trace][PLEASED David][TEST-1] >>> change_mood(offset=1) - DEBUG : [trace][PLEASED David][TEST-1] <<< change_mood[0.00000] - DEBUG : [trace][PLEASED David][TEST-1] <<< process[0.00017] - DEBUG : [trace][HAPPY Alex][TEST-1] >>> process_response(to='bye', mood=) - DEBUG : [trace][HAPPY Alex][TEST-1] <<< process_response[0.00000] - DEBUG : [trace][HAPPY Alex][TEST-1] <<< bye[0.00060] Result: What a wonderful meeting! - DEBUG : [trace][HAPPY David][TEST-1] >>> bye() - [David] Bye, Alex. Have a nice day! - DEBUG : [Person][HAPPY Alex][TEST-1] Processing "Bye, Alex. Have a nice day!" from [David] - DEBUG : [trace][HAPPY Alex][TEST-1] >>> process - DEBUG : [trace][HAPPY Alex][TEST-1] >>> change_mood(offset=1) - DEBUG : [trace][HAPPY Alex][TEST-1] <<< change_mood[0.00000] - DEBUG : [trace][HAPPY Alex][TEST-1] <<< process[0.00018] - DEBUG : [trace][HAPPY David][TEST-1] >>> process_response(to='bye', mood=) - DEBUG : [trace][HAPPY David][TEST-1] <<< process_response[0.00000] - DEBUG : [trace][HAPPY David][TEST-1] <<< bye[0.00059] Result: What a wonderful meeting! - Meeting closed in 0.00466 sec. - Outcome: Alex [HAPPY], David [HAPPY] - -Trace configuration -=================== - -.. versionadded:: 1.1.0 - -Trace supports configuration based on `~firebird.base.config`. - -**Sample configuration file**:: - - [trace] - flags = ACTIVE | FAIL - ;flags = ACTIVE | BEFORE | AFTER | FAIL - classes = trace_ChannelManager, trace_Channel, trace_TextIOServiceImpl, trace_PipeServerHandler - - [trace_PipeServerHandler] - source = saturnin.core.protocol.fbdp.PipeServerHandler - methods = close, send_ready, send_close - - [trace_ChannelManager] - source = saturnin.core.base.ChannelManager - special = trace_defer - - [trace_Channel] - source = saturnin.core.base.Channel - methods = send, receive, close, bind, unbind, connect, disconnect - - [trace_DealerChannel] - source = saturnin.core.base.DealerChannel - methods = send, receive, close, bind, unbind, connect, disconnect - - [trace_SimpleService] - source = saturnin.core.classic.SimpleService - methods = validate, run, initialize, start - with_args = no - - [trace_TextIOServiceImpl] - source = saturnin.sdk.micro.textio.service.TextIOServiceImpl - methods = initialize, configure, validate, finalize - with_args = no - - [trace_defer] - method = defer - max_param_length = 50 - - -.. seealso:: - - `~TraceManager.load_config()`, `~firebird.base.trace.BaseTraceConfig`, - `~firebird.base.trace.TracedMethodConfig`, `~firebird.base.trace.TracedClassConfig` and - `~firebird.base.trace.TraceConfig`. - -Enums & Flags -============= - -.. autoclass:: TraceFlag - -Functions -========= - -.. autofunction:: add_trace - ------------- - -.. autofunction:: remove_trace - ------------- - -.. autofunction:: trace_object - -Trace manager -============= - -.. autoclass:: TraceManager - -Trace/audit decorator -===================== - -.. autoclass:: traced - -Mixins -====== - -.. autoclass:: TracedMixin - -Globals -======= - -.. autodata:: trace_manager - :no-value: - -Trace configuration classes -=========================== - -.. autoclass:: BaseTraceConfig - ------------------- - -.. autoclass:: TracedMethodConfig - :no-inherited-members: - ------------------ - -.. autoclass:: TracedClassConfig - :no-inherited-members: - ------------ - -.. autoclass:: TraceConfig - :no-inherited-members: - diff --git a/docs/types.txt b/docs/types.txt deleted file mode 100644 index dbf65ed..0000000 --- a/docs/types.txt +++ /dev/null @@ -1,177 +0,0 @@ -.. module:: firebird.base.types - :synopsis: Common data types - -######################### -types - Common data types -######################### - -Overview -======== - -This module provides collection of classes that are often used by other library modules or -applications. - -Exceptions -========== - -.. autoexception:: Error - :show-inheritance: - :no-inherited-members: - -Singletons -========== - -Singleton is a pattern that restricts the instantiation of a class to one "single" instance. -This is useful when exactly one object is needed to coordinate actions across the system. - -Common uses: - -- The abstract factory, factory method, builder, and prototype patterns can use singletons - in their implementation. -- Facade objects are often singletons because only one facade object is required. -- State objects are often singletons. -- Singletons are often preferred to global variables because: - - - They do not pollute the global namespace with unnecessary variables. - - They permit lazy allocation and initialization. - -To create your own singletons, use `Singleton` as the base class. - -.. admonition:: example - - >>> class MySingleton(Singleton): - ... "Description" - ... ... - ... - >>> obj1 = MySingleton() - >>> obj1 = MySingleton() - >>> obj1 is obj2 - True - -.. autoclass:: Singleton - -Sentinels -========= - -The Sentinel Object pattern is a standard Pythonic approach that’s used both in the -Standard Library and beyond. The pattern most often uses Python’s built-in `None` object, -but in situations where None might be a useful value, a unique sentinel `object()` can be -used instead to indicate missing or unspecified data, or other specific condition. - -However, the plain `object()` sentinel has not very useful `str` and `repr` values. -The `Sentinel` class provides named sentinels, with meaningful `str` and `repr`. - --------- - -.. autoclass:: Sentinel - -Predefined sentinels --------------------- - -.. autodata:: DEFAULT -.. autodata:: INFINITY -.. autodata:: UNLIMITED -.. autodata:: UNKNOWN -.. autodata:: NOT_FOUND -.. autodata:: UNDEFINED -.. autodata:: ANY -.. autodata:: ALL -.. autodata:: SUSPEND -.. autodata:: RESUME -.. autodata:: STOP - -Distinct objects -================ - -Some complex data structures or data processing algorithms require unique object -identification (ie object identity). In Python, an object identity is defined internally -as unique instance identity that is not suitable for complex objects whose identity is -derived from content. - -The `Distinct` abstract base class is intended as a unified solution to these needs. - -.. seealso:: module `firebird.base.collections` - - --------- - -.. autoclass:: Distinct - --------------- - -.. autoclass:: CachedDistinct - -Enums -===== - -.. autoclass:: ByteOrder - ------------- - -.. autoclass:: ZMQTransport - ---------- - -.. autoclass:: ZMQDomain - -Custom string types -=================== - -Some string values have unified structure and carry specific information (like network -address or database connection string). Typical repeating operation with these values -are validation and parsing. It makes sense to put these operations under one roof. -One such approach uses custom descendants of builtin `str` type. - -.. caution:: - - Custom string types have an inherent weakness. They support all inherited string methods, - but any method that returns string value return a base `str` type, not the decendant class - type. That same apply when you assign strings to variables that should be of custom - string type. - - .. tip:: - - Module `~firebird.base.strconv` could help you to safely translate strings stored - externally to typed strings. - --------------- - -.. autoclass:: ZMQAddress - ------- - -.. autoclass:: MIME - ------- - -.. autoclass:: PyExpr - ------- - -.. autoclass:: PyCode - ----------- - -.. autoclass:: PyCallable - -Meta classes -============ - -.. autoclass:: SingletonMeta - ------------- - -.. autoclass:: SentinelMeta - ------------------- - -.. autoclass:: CachedDistinctMeta - ------------ - -.. autofunction:: Conjunctive - -Functions -========= - -.. autofunction:: load diff --git a/docs/zensical.toml b/docs/zensical.toml new file mode 100644 index 0000000..9095feb --- /dev/null +++ b/docs/zensical.toml @@ -0,0 +1,83 @@ +[project] +site_name = "The Firebird Base library for Python" +site_author = "Pavel Císař" +copyright = "2020-2026, The Firebird Project" +site_url = "https://firebird-base.readthedocs.io/" + + +nav = [ + { "Home" = "index.md" }, + { "Introduction" = "introduction.md" }, + { "Modules" = [ + { "types" = "types.md" }, + { "collections" = "collections.md" }, + { "strconv" = "strconv.md" }, + { "config" = "config.md" }, + { "buffer" = "buffer.md" }, + { "hooks" = "hooks.md" }, + { "logging" = "logging.md" }, + { "trace" = "trace.md" }, + { "protobuf" = "protobuf.md" }, + { "signal" = "signal.md" } + ]}, + { "Changelog" = "changelog.md" }, + { "License" = "license.md" } +] + +[[project.theme.palette]] +scheme = "slate" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +scheme = "default" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[project.theme] +features = [ + "navigation.instant", + "navigation.tracking", + "navigation.tabs", + "navigation.tabs.sticky", + "navigation.sections", + "navigation.expand", + "navigation.path", + "navigation.top", +] + +[project.plugins.mkdocstrings.handlers.python] +paths = ["../src", "src"] +inventories = ["https://docs.python.org/3/objects.inv"] + +[project.plugins.mkdocstrings.handlers.python.options] +docstring_style = "google" +inherited_members = true +show_root_heading = true +show_source = true +backlinks = "tree" +find_stubs_package = true +show_attribute_values = false +show_signature_annotations = true + +[project.plugins.llmstxt] +enabled = true +markdown_description = "For version 2.0.3" +sections = { +"Home" = ["index.md"], +"Introduction" = [ + "introduction.md", +], +"Modules" = [ + "buffer.md", + "collections.md", + "config.md", + "hooks.md", + "logging.md", + "protobuf.md", + "signal.md", + "strconv.md", + "trace.md", + "types.md", +], +} diff --git a/proto/config.proto b/proto/config.proto index d3bce32..653f1ad 100644 --- a/proto/config.proto +++ b/proto/config.proto @@ -32,6 +32,7 @@ syntax = "proto3"; package firebird.base; import "google/protobuf/any.proto"; +import "google/protobuf/struct.proto"; message Value { oneof kind { diff --git a/pyproject.toml b/pyproject.toml index 27d3bfe..fa988f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "firebird-base" dynamic = ["version"] description = "Firebird base modules for Python" readme = "README.md" -requires-python = ">=3.8, <4" +requires-python = ">=3.11, <4" license = { file = "LICENSE" } authors = [ { name = "Pavel Cisar", email = "pcisar@users.sourceforge.net"}, @@ -19,18 +19,16 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: POSIX :: Linux", "Operating System :: Microsoft :: Windows", "Operating System :: MacOS", "Topic :: Software Development", ] dependencies = [ - "protobuf>=4.23.4", + "protobuf~=5.29", ] [project.urls] @@ -56,100 +54,42 @@ packages = ["src/firebird"] dependencies = [ ] -[tool.hatch.envs.test] +[tool.hatch.envs.default.scripts] +build-config-proto = "protoc -I../protobuf --python_out=src --pyi_out=src firebird/base/config.proto" +build-test-proto = "protoc -Itests --python_out=./tests --pyi_out=./tests base_test.proto" + +[tool.hatch.envs.hatch-test] +extra-args = ["-vv"] dependencies = [ "coverage[toml]>=6.5", "pytest", ] -[tool.hatch.envs.test.scripts] -test = "pytest {args:tests}" -test-cov = "coverage run -m pytest {args:tests}" -cov-report = [ - "- coverage combine", - "coverage report", -] -cov = [ - "test-cov", - "cov-report", -] -version = "python --version" -[[tool.hatch.envs.test.matrix]] -python = ["3.8", "3.9", "3.10", "3.11", "3.12"] +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.11", "3.12", "3.13", "3.14"] [tool.hatch.envs.doc] detached = false -platforms = ["linux"] dependencies = [ - "Sphinx==7.2.6", - "sphinx-bootstrap-theme>=0.8.1", - "sphinx-autodoc-typehints>=1.24.0", + "zensical", + "mkdocstrings-python", "doc2dash>=3.0.0" ] [tool.hatch.envs.doc.scripts] -build = "cd docs ; make html" +build = "cd docs && zensical build {args}" +serve = "cd docs && zensical serve {args}" docset = [ - "cd docs ; doc2dash -u https://firebird-base.readthedocs.io/en/latest/ -f -i ./_static/fb-favicon.png -n firebird-base ./_build/html/", - "cd docs; VERSION=`hatch version` ; tar --exclude='.DS_Store' -cvzf ../dist/firebird-base-$VERSION-docset.tgz firebird-base.docset", -] - -[tool.hatch.envs.lint] -detached = true -dependencies = [ - "black>=23.1.0", - "mypy>=1.0.0", - "ruff>=0.0.243", -] -[tool.hatch.envs.lint.scripts] -typing = "mypy --install-types --non-interactive {args:src/firebird/base tests}" -style = [ - "ruff {args:.}", - "black --check --diff {args:.}", -] -fmt = [ - "black {args:.}", - "ruff --fix {args:.}", - "style", -] -all = [ - "style", - "typing", + "cd docs ; doc2dash -u https://firebird-base.readthedocs.io/en/latest/ -f -i ./site/assets/fb-favicon.png -n firebird-base ./site/", + "cd docs; VERSION=$(python -c 'from firebird.base.__about__ import __version__; print(__version__)') && tar --exclude='.DS_Store' -cvzf ../dist/firebird-base-$VERSION-docset.tgz firebird-base.docset", ] -[tool.black] -target-version = ["py38"] -line-length = 120 -skip-string-normalization = true - [tool.ruff] -target-version = "py38" +target-version = "py311" line-length = 120 -select = [ - "A", - "ARG", - "B", - "C", - "DTZ", - "E", - "EM", - "F", - "FBT", - "I", - "ICN", - "ISC", - "N", - "PLC", - "PLE", - "PLR", - "PLW", - "Q", - "RUF", - "S", - "T", - "TID", - "UP", - "W", - "YTT", + +[tool.ruff.lint] +select = ["A", "ARG", "B", "C", "DTZ", "E", "EM", "F", "FBT", "I", "ICN", "ISC", "N", + "PLC", "PLE", "PLR", "PLW", "Q", "RUF", "S", "T", "TID", "UP", "W", "YTT", ] ignore = [ # Allow non-abstract empty methods in abstract base classes @@ -160,19 +100,30 @@ ignore = [ "S105", "S106", "S107", # Ignore complexity "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", + # + "E741", + # Allow relative imports + "TID252", + # Allow literals in exceptions + "EM101", "EM102", + # Single quotes instead double + "Q000" ] unfixable = [ # Don't touch unused imports "F401", + # Don't change single quotes to double + "Q000" ] +exclude = ["*_pb2.py", "*.pyi", "tests/*", "docs/*", "work/*"] -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["firebird.base"] -[tool.ruff.flake8-tidy-imports] +[tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" -[tool.ruff.per-file-ignores] +[tool.ruff.lint.extend-per-file-ignores] # Tests can use magic values, assertions, and relative imports "tests/**/*" = ["PLR2004", "S101", "TID252"] diff --git a/skills/firebird-base/SKILL.md b/skills/firebird-base/SKILL.md new file mode 100644 index 0000000..adc0176 --- /dev/null +++ b/skills/firebird-base/SKILL.md @@ -0,0 +1,38 @@ +--- +name: firebird-base +description: Use the firebird-base Python library in application code, including its configuration, collections, conversion, callbacks, logging, tracing, buffers, protobuf registry, and value types. Load only the reference for the module involved. +--- + +# Firebird Base application use + +`firebird-base` is a collection of independent modules. Import the needed module from `firebird.base.`; +the package root does not reexport its APIs. Do not add other modules to a solution unless their features are needed. + +Choose the relevant reference below and read only that file. If a task crosses module boundaries, +read those references too. Each reference records practical use, likely mistakes, and a link +to its published API documentation. + +| Need | Reference | +| --- | --- | +| Errors, sentinels, identities, validated string types | [types](references/types.md) | +| Queryable lists or keyed object registries | [collections](references/collections.md) | +| Bidirectional value/string conversion | [strconv](references/strconv.md) | +| Typed application settings, INI, directories, config protobuf | [config](references/config.md) | +| Binary reads and writes with a cursor | [buffer](references/buffer.md) | +| Source/event callback registry | [hooks](references/hooks.md) | +| Agent/context based standard logging | [logging](references/logging.md) | +| Decorator or runtime method tracing | [trace](references/trace.md) | +| Named protobuf message and enum lookup | [protobuf](references/protobuf.md) | +| Signature checked signals or optional event handlers | [signal](references/signal.md) | + +## Documentation and version checks + +The published [llms.txt](https://firebird-base.rtfd.io/llms.txt) is the current documentation +index. It links to separate Markdown pages for library modules; use the relevant page for +exact signatures and detailed API behavior. Check the version named in that index against +the installed library before copying examples, especially when working with older releases. + +String expressions in collections and executable values in `types`/`config` execute Python +code. Use them only with trusted input. Global registries and managers in `strconv`, `hooks`, +`logging`, `trace`, and `protobuf` affect other users in the same process; isolate changes +in tests and avoid repeated registration during ordinary calls. diff --git a/skills/firebird-base/references/buffer.md b/skills/firebird-base/references/buffer.md new file mode 100644 index 0000000..8b70d84 --- /dev/null +++ b/skills/firebird-base/references/buffer.md @@ -0,0 +1,12 @@ +# `firebird.base.buffer` + +[Published API](https://firebird-base.readthedocs.io/buffer/index.md) + +Use `MemoryBuffer` for cursor based binary I/O. Choose `ByteOrder` explicitly for a wire +format and pair each read with the corresponding write and string encoding. Reads and writes +advance `pos`; reset it before rereading written bytes. Use `get_raw()` for factory independent +storage access. Account for `max_size`, automatic growth, and `eof_marker` when parsing a protocol. + +Avoid assuming buffer capacity equals the amount written; track the cursor or protocol length. +Avoid relying on the default byte order or default ASCII encoding for a specified external +format. Do not read past available data or expect a fixed size buffer to grow beyond `max_size`. diff --git a/skills/firebird-base/references/collections.md b/skills/firebird-base/references/collections.md new file mode 100644 index 0000000..6c12283 --- /dev/null +++ b/skills/firebird-base/references/collections.md @@ -0,0 +1,15 @@ +# `firebird.base.collections` + +[Published API](https://firebird-base.readthedocs.io/collections/index.md) + +Use `DataList` when list behavior plus filtering, reporting, type checks, keyed lookup, or +freezing helps. Supply a stable key expression for keyed `get()`; `Distinct` items can use +their `get_key()` by default. Use `Registry` for `Distinct` objects keyed by identity. Its +iteration yields objects, so check mapping behavior before treating it like a plain `dict`. +`filter()`, `filterfalse()`, and `report()` return generators; consume them when a concrete +result is needed. + +Avoid string predicates or key expressions derived from untrusted input: `make_lambda()` +evaluates them as Python code. Prefer callables for external criteria. Do not mutate a frozen +`DataList`, or change an item's key while it is indexed. Do not assume `Registry` iteration +yields keys. diff --git a/skills/firebird-base/references/config.md b/skills/firebird-base/references/config.md new file mode 100644 index 0000000..aa9822f --- /dev/null +++ b/skills/firebird-base/references/config.md @@ -0,0 +1,20 @@ +# `firebird.base.config` + +[Published API](https://firebird-base.readthedocs.io/config/index.md) + +Define a `Config` subclass and assign each `Option` to an attribute with the same name as +the option. Read and write values through `option.value`. Use `Config.get_config()` for an +INI template, `Config.load_config()` with `ConfigParser` to load it, and +`Config.save_proto()` / `Config.load_proto()` to exchange state. `ConfigOption` references +one nested section; `ConfigListOption` references several sections. Use `get_directory_scheme()` +for platform conventions. Register `strconv` convertors before using custom values in list +or dataclass options. + +Avoid assigning an option under a different attribute name: discovery depends on the match. +Do not serialize a nested option in isolation; use its owning `Config`, which may need +surrounding sections. Do not treat commented defaults in `get_config()` output as active +settings. `EnvExtendedInterpolation` supports `${env:VAR}` but an absent variable yields +an empty string, so validate required secrets. Construct `PyExprOption`, `PyCodeOption`, +and `PyCallableOption` only from trusted configuration. For registry lookup of `ConfigProto`, +call `load_registered('firebird.base.protobuf')` first; direct import of `ConfigProto` needs +no registry. diff --git a/skills/firebird-base/references/hooks.md b/skills/firebird-base/references/hooks.md new file mode 100644 index 0000000..a487c09 --- /dev/null +++ b/skills/firebird-base/references/hooks.md @@ -0,0 +1,14 @@ +# `firebird.base.hooks` + +[Published API](https://firebird-base.readthedocs.io/hooks/index.md) + +Use hooks when callbacks must be selected by event and source class, instance, or registered +instance name. Register the source class and its supported events before adding its hooks. +An event provider calls `get_callbacks(event, source)` and invokes the returned callbacks +using a documented signature. Use `ANY` deliberately for wildcard event or source registrations. +Remove hooks or reset the manager during teardown in tests and short lived integrations. + +Avoid expecting `HookManager` to invoke callbacks or validate their signatures: the event +provider owns both. Avoid registering hooks repeatedly inside event dispatch; the manager +is shared process state. Do not use a source name until `register_name()` associates it with +an instance. diff --git a/skills/firebird-base/references/logging.md b/skills/firebird-base/references/logging.md new file mode 100644 index 0000000..c3c7757 --- /dev/null +++ b/skills/firebird-base/references/logging.md @@ -0,0 +1,16 @@ +# `firebird.base.logging` + +[Published API](https://firebird-base.readthedocs.io/logging/index.md) + +Use `get_logger(agent, topic=...)` when records need agent, context, domain, or topic. +An object agent uses `_agent_name_` if present, otherwise its qualified class name. +Configure standard `logging` handlers and formatters; add `ContextFilter` to a handler +when its formatter expects context fields from both ordinary and context loggers. +Set `logging_manager.logger_fmt` and mappings when separate logger names are needed; its +default empty format maps to the root logger. Use message wrappers when deferred +interpolation is useful. + +Avoid assuming `agent.log_context` refreshes automatically on a retained adapter: the first +value is cached in `adapter.extra['context']`; update or delete that key for a new context. +Do not use `%(agent)s` and related fields with ordinary records unless the handler supplies +`ContextFilter`. Avoid changing global mappings per request; they affect all agents in the process. diff --git a/skills/firebird-base/references/protobuf.md b/skills/firebird-base/references/protobuf.md new file mode 100644 index 0000000..6c17652 --- /dev/null +++ b/skills/firebird-base/references/protobuf.md @@ -0,0 +1,13 @@ +# `firebird.base.protobuf` + +[Published API](https://firebird-base.readthedocs.io/protobuf/index.md) + +Use `register_descriptor()` for a generated module's file descriptor, or `load_registered(group)` +for descriptors advertised by installed entry points. The package uses the `firebird.base.protobuf` +group for its own config descriptor. After registration, use fully qualified names with +`create_message()`, `get_message_factory()`, and enum helpers. `struct2dict()` and `dict2struct()` +exchange values with `google.protobuf.Struct`. + +Avoid calling registry lookup before descriptor registration. Do not use the deprecated +misspelling `register_decriptor()` in new code. Avoid relying on registration order or +treating the registry as local to one caller; names and registrations are process wide. diff --git a/skills/firebird-base/references/signal.md b/skills/firebird-base/references/signal.md new file mode 100644 index 0000000..27fad7f --- /dev/null +++ b/skills/firebird-base/references/signal.md @@ -0,0 +1,14 @@ +# `firebird.base.signal` + +[Published API](https://firebird-base.readthedocs.io/signal/index.md) + +Use `Signal` or the `@signal` descriptor for one to many notifications. Define the expected +signature and connect compatible slots; slot return values are ignored. A decorated signal +belongs to each instance. Use `@eventsocket` for one optional handler whose return value +matters; assign a compatible callable, inspect `is_set()` if needed, and assign `None` to +disconnect it. + +Avoid using a signal to collect callback results. Do not connect a callback with an incompatible +signature. Regular functions and bound methods are weakly referenced; keep their owners +alive while connected. Lambdas and partials are retained directly, so manage their lifetime +and disconnect when appropriate. diff --git a/skills/firebird-base/references/strconv.md b/skills/firebird-base/references/strconv.md new file mode 100644 index 0000000..3f7f9de --- /dev/null +++ b/skills/firebird-base/references/strconv.md @@ -0,0 +1,15 @@ +# `firebird.base.strconv` + +[Published API](https://firebird-base.readthedocs.io/strconv/index.md) + +Use `convert_to_str()` and `convert_from_str()` for supported types; lookups for a class +follow its MRO. Use `get_convertor()` when repeatedly converting one type. For a custom +type, register a pair of functions that round trip its values and raise `ValueError` for +invalid input. Register the class with `register_class()` if it must be resolved by name, +as in some configuration uses. Built-in support includes scalars, decimals, UUIDs, enums +and flags, `MIME`, and `ZMQAddress`. + +Avoid assuming a name lookup inherits a base class convertor automatically. Avoid `str(value)` +as a substitute for a reversible representation, especially for `ListOption` or `DataclassOption`. +Registry updates are process wide: register deliberately and use `update_convertor()` for +an intentional replacement rather than repeating `register_convertor()`. diff --git a/skills/firebird-base/references/trace.md b/skills/firebird-base/references/trace.md new file mode 100644 index 0000000..b8a3d22 --- /dev/null +++ b/skills/firebird-base/references/trace.md @@ -0,0 +1,14 @@ +# `firebird.base.trace` + +[Published API](https://firebird-base.readthedocs.io/trace/index.md) + +Use `traced` for a specific callable. Use `TracedMixin` and `TraceManager` when methods +need registration or runtime control. Trace output uses `firebird.base.logging`; configure +logging before expecting records. Choose `TraceFlag` phases deliberately and limit argument +or result detail where values may be large or sensitive. With INI configuration, list class +sections in `[trace] classes`; only referenced class and `special` sections are loaded. + +Avoid assuming a decorated callable is automatically active under every flag or logging level. +Avoid logging secrets through `with_args` or return values. Do not expect an unreferenced +INI section to configure tracing. Treat manager registration and flags as shared state and +clear them in isolated tests. diff --git a/skills/firebird-base/references/types.md b/skills/firebird-base/references/types.md new file mode 100644 index 0000000..51a0f2a --- /dev/null +++ b/skills/firebird-base/references/types.md @@ -0,0 +1,17 @@ +# `firebird.base.types` + +[Published API](https://firebird-base.readthedocs.io/types/index.md) + +Use `Error` when an application exception needs named attributes. Use a named `Sentinel` +when `None` is a valid value and you need a distinct control value. Compare sentinels by +identity. Do not define new sentinel if one defined in this module fits the purpose. + +Implement `Distinct.get_key()` with a stable, hashable identity when instances +represent the same logical object; `Registry` depends on that key. `CachedDistinct` +additionally caches instances by `extract_key()`. + +Avoid using `None` to mean both absent and a real value. Do not mutate fields that contribute +to a `Distinct` key after adding the object to a registry. `MIME`, `ZMQAddress`, `PyExpr`, +`PyCode`, and `PyCallable` are string subclasses, but inherited string operations can return +plain `str`; reconstruct the validated type when needed. Treat executable string types as +code and construct them only from trusted text. diff --git a/src/firebird/base/__about__.py b/src/firebird/base/__about__.py index f1b01e8..c3412b3 100644 --- a/src/firebird/base/__about__.py +++ b/src/firebird/base/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2020-present The Firebird Projects # # SPDX-License-Identifier: MIT -__version__ = "1.8.0" +__version__ = "2.0.4.dev0" diff --git a/src/firebird/base/buffer.py b/src/firebird/base/buffer.py index 0233a53..7f0630d 100644 --- a/src/firebird/base/buffer.py +++ b/src/firebird/base/buffer.py @@ -33,42 +33,100 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________ -"""Firebird Base - Memory buffer manager -This module provides a raw memory buffer manager with convenient methods to read/write -data of various data type. +"""This module provides a `MemoryBuffer` class for managing raw memory buffers, +offering a convenient and consistent API for reading and writing various data types +(integers of different sizes, strings with different termination/prefixing styles, raw bytes). +It's particularly useful for tasks involving binary data serialization/deserialization, +such as implementing network protocols or handling custom file formats. + +The underlying memory storage can be customized via a `BufferFactory`. Two factories +are provided: + +- `BytesBufferFactory`: Uses Python's built-in `bytearray`. +- `CTypesBufferFactory`: Uses `ctypes.create_string_buffer` for potentially different + memory characteristics or C-level interoperability. + +Example: + ```python + from firebird.base.buffer import MemoryBuffer, ByteOrder + + # Create a buffer (default uses bytearray) + buf = MemoryBuffer(10) # Initial size 10 bytes + + # Write data + buf.write_short(258) # Write 2 bytes (0x0102 in little-endian) + buf.write_pascal_string("Hi") # Write 1 byte length (2) + "Hi" + buf.write(b'\\x0A\\x0B') # Write raw bytes + + # Reset position to read + buf.pos = 0 + + # Read data + num = buf.read_short() + s = buf.read_pascal_string() + extra = buf.read(2) + + print(f"Number: {num}") # Output: Number: 258 + print(f"String: '{s}'") # Output: String: 'Hi' + print(f"Extra bytes: {extra}") # Output: Extra bytes: b'\\n\\x0b' + print(f"Final position: {buf.pos}") # Output: Final position: 7 + print(f"Raw buffer: {buf.get_raw()}") # Output: Raw buffer: bytearray(b'\\x02\\x01\\x02Hi\\n\\x0b\\x00\\x00\\x00') + ``` """ from __future__ import annotations -from typing import runtime_checkable, Protocol, Type, Union, Any -from ctypes import memset, create_string_buffer -from .types import Sentinel, UNLIMITED, ByteOrder + +from ctypes import create_string_buffer, memset +from typing import Any, Protocol, runtime_checkable + +from .types import UNLIMITED, ByteOrder, Sentinel + @runtime_checkable class BufferFactory(Protocol): # pragma: no cover - """BufferFactory Protocol definition. - """ - def create(self, init_or_size: Union[int, bytes], size: int=None) -> Any: - """This function must create and return a mutable character buffer. + """Protocol defining the interface for creating and managing memory buffers. - Arguments: - init_or_size: Must be an integer which specifies the size of the array, or a bytes - object which will be used to initialize the array items. - size: Size of the array. + Allows `MemoryBuffer` to work with different underlying buffer types + (like `bytearray` or `ctypes` arrays). """ + def create(self, init_or_size: int | bytes, size: int | None=None) -> Any: + """Create and return a mutable byte buffer object. + + Arguments: + init_or_size: An integer specifying the buffer size, or a bytes + object for initializing the buffer content. + size: Optional integer size, primarily used when `init_or_size` + is bytes to specify a potentially different final size. + + Returns: + The created mutable buffer object (e.g., [bytearray][] or a ctypes array of characters). + """ def clear(self, buffer: Any) -> None: - """Fills the buffer with zero. + """Fill the buffer entirely with null bytes (zeros). - Argument: - buffer: A memory buffer previously created by `BufferFactory.create()` method. - """ + Arguments: + buffer: A memory buffer previously created by this factory's `create()` method. + """ + def get_raw(self, buffer: Any) -> bytes | bytearray: + """Return the buffer's content as a standard [bytes][] or [bytearray][]. + + This method is necessary to provide a consistent way to access the raw + byte sequence, as the buffer object returned by `create` might be of a + different type (e.g., [ctypes][] arrays have a `raw` attribute). + + Arguments: + buffer: A memory buffer previously created by this factory's `create()` method. + + Returns: + The raw byte content of the buffer. + """ class BytesBufferFactory: - """Buffer factory for `bytearray` buffers. - """ - def create(self, init_or_size: Union[int, bytes], size: int=None) -> bytearray: + """Buffer factory using Python's [bytearray][] for storage.""" + def create(self, init_or_size: int | bytes, size: int | None=None) -> bytearray: """This function creates a mutable character buffer. The returned object is a - `bytearray`. + [bytearray][]. Arguments: init_or_size: Must be an integer which specifies the size of the array, @@ -76,13 +134,13 @@ def create(self, init_or_size: Union[int, bytes], size: int=None) -> bytearray: size: Size of the array. Important: - Although arguments are the same as for `ctypes.create_string_buffer`, + Although arguments are the same as for [ctypes.create_string_buffer][], the behavior is different when new buffer is initialized from bytes: 1. If there are more bytes than specified `size`, this function copies only - `size` bytes into new buffer. The `~ctypes.create_string_buffer` raises + `size` bytes into new buffer. The [ctypes.create_string_buffer][] raises an excpetion. - 2. Unlike `~ctypes.create_string_buffer` when `size` is NOT specified, + 2. Unlike [ctypes.create_string_buffer][] when `size` is NOT specified, the buffer is NOT made one item larger than its length so that the last element in the array is a NUL termination character. """ @@ -94,16 +152,17 @@ def create(self, init_or_size: Union[int, bytes], size: int=None) -> bytearray: buffer[:limit] = init_or_size[:limit] return buffer def clear(self, buffer: bytearray) -> None: - """Fills the buffer with zero. - """ + """Fills the bytearray buffer with zero bytes.""" buffer[:] = b'\x00' * len(buffer) + def get_raw(self, buffer: Any) -> bytes | bytearray: + """Returns the `bytearray` buffer itself.""" + return buffer class CTypesBufferFactory: - """Buffer factory for `ctypes` array of `~ctypes.c_char` buffers. - """ - def create(self, init_or_size: Union[int, bytes], size: int=None) -> bytearray: - """This function creates a `ctypes` mutable character buffer. The returned object - is an array of `ctypes.c_char`. + """Buffer factory using [ctypes.create_string_buffer][] (array of `c_char`).""" + def create(self, init_or_size: int | bytes, size: int | None=None) -> bytearray: + """This function creates a [ctypes][] mutable character buffer. The returned object + is an array of [ctypes.c_char][]. Arguments: init_or_size: Must be an integer which specifies the size of the array, @@ -111,13 +170,13 @@ def create(self, init_or_size: Union[int, bytes], size: int=None) -> bytearray: size: Size of the array. Important: - Although arguments are the same as for `ctypes.create_string_buffer`, + Although arguments are the same as for [ctypes.create_string_buffer][], the behavior is different when new buffer is initialized from bytes: 1. If there are more bytes than specified `size`, this function copies only - `size` bytes into new buffer. The `~ctypes.create_string_buffer` raises + `size` bytes into new buffer. The [ctypes.create_string_buffer][] raises an excpetion. - 2. Unlike `~ctypes.create_string_buffer` when `size` is NOT specified, + 2. Unlike [ctypes.create_string_buffer][] when `size` is NOT specified, the buffer is NOT made one item larger than its length so that the last element in the array is a NUL termination character. """ @@ -129,72 +188,118 @@ def create(self, init_or_size: Union[int, bytes], size: int=None) -> bytearray: buffer[:limit] = init_or_size[:limit] return buffer def clear(self, buffer: bytearray, init: int=0) -> None: - """Fills the buffer with specified value (default). + """Fills the ctypes buffer with a specified byte value using `memset`. + + Arguments: + buffer: The ctypes buffer. + init: The byte value to fill with. """ memset(buffer, init, len(buffer)) + def get_raw(self, buffer: Any) -> bytes | bytearray: + """Returns the raw byte content via the buffer's `.raw` attribute.""" + return buffer.raw + +def safe_ord(byte: bytes | int) -> int: + """Return the integer ordinal of a byte, or the integer itself. -def safe_ord(byte: Union[bytes, int]) -> int: - """If `byte` argument is byte character, returns ord(byte), otherwise returns argument. + Handles inputs that might already be integers (e.g., from iterating + over a `bytes` object) or single-character `bytes` objects. + + Arguments: + byte: A single-character bytes object or an integer. + + Returns: + The integer value. """ return byte if isinstance(byte, int) else ord(byte) class MemoryBuffer: """Generic memory buffer manager. + + Arguments: + init: Must be an integer which specifies the size of the array, or a `bytes` object + which will be used to initialize the array items. + size: Size of the array. The argument value is used only when `init` is a `bytes` object. + factory: Factory object used to create/resize the internal memory buffer. + eof_marker: Value that indicates the end of data. Could be None. + max_size: If specified, the buffer couldn't grow beyond specified number of bytes. + byteorder: The byte order used to read/write numbers. """ - def __init__(self, init: Union[int, bytes], size: int = None, *, - factory: Type[BufferFactory]=BytesBufferFactory, eof_marker: int = None, - max_size: Union[int, Sentinel]=UNLIMITED, byteorder: ByteOrder=ByteOrder.LITTLE): - """ - Arguments: - init: Must be an integer which specifies the size of the array, or a `bytes` object - which will be used to initialize the array items. - size: Size of the array. The argument value is used only when `init` is a `bytes` object. - factory: Factory object used to create/resize the internal memory buffer. - eof_marker: Value that indicates the end of data. Could be None. - max_size: If specified, the buffer couldn't grow beyond specified number of bytes. - byteorder: The byte order used to read/write numbers. - """ - #: Buffer factory instance used by manager [default: `BytesBufferFactory`]. + def __init__(self, init: int | bytes, size: int | None=None, *, + factory: type[BufferFactory]=BytesBufferFactory, eof_marker: int | None=None, + max_size: int | Sentinel=UNLIMITED, byteorder: ByteOrder=ByteOrder.LITTLE): self.factory: BufferFactory = factory() - #: The memory buffer. The actual data type of buffer depends on `buffer factory`, - #: but it must provide direct acces to cells, slices and length like `bytearray`. + "Buffer factory instance used by manager [default: `BytesBufferFactory`]." self.raw: bytearray = self.factory.create(init, size) - #: Current position in buffer, i.e. the next read/writen byte would be at this position. + """The memory buffer. The actual data type of buffer depends on `buffer factory`, + but it must provide direct acces to cells, slices and length like `bytearray`.""" self.pos: int = 0 - #: Value that indicates the end of data. Could be None. + "Current position in buffer, i.e. the next read/writen byte would be at this position." self.eof_marker: int = eof_marker - #: The buffer couldn't grow beyond specified number of bytes [default: `.UNLIMITED`]. - self.max_size: Union[int, Sentinel] = max_size - #: The byte order used to read/write numbers [default: `.LITTLE`]. + "Value that indicates the end of data. Could be None." + self.max_size: int | Sentinel = max_size + "The buffer couldn't grow beyond specified number of bytes [default: `UNLIMITED`]." self.byteorder: ByteOrder = byteorder + "The byte order used to read/write numbers [default: `LITTLE`]." def _ensure_space(self, size: int) -> None: if len(self.raw) < self.pos + size: self.resize(self.pos + size) - def _check_space(self, size: int): + def _check_space(self, size: int) -> None: if len(self.raw) < self.pos + size: - raise IOError("Insufficient buffer size") + raise BufferError("Insufficient buffer size") def clear(self) -> None: """Fills the buffer with zeros and resets the position in buffer to zero. """ self.factory.clear(self.raw) self.pos = 0 def resize(self, size: int) -> None: - """Resize buffer to specified length. + """Resize buffer to the specified length. Content is preserved up to the minimum + of the old and new sizes. New space is uninitialized (depends on factory). + + Arguments: + size: The new size in bytes. + + Raises: + BufferError: On attempt to resize beyond `self.max_size`. """ if self.max_size is not UNLIMITED and self.max_size < size: - raise IOError(f"Cannot resize buffer past max. size {self.max_size} bytes") + raise BufferError(f"Cannot resize buffer past max. size {self.max_size} bytes") self.raw = self.factory.create(self.raw, size) def is_eof(self) -> bool: - """Return True when positioned past the end of buffer or on `.eof_marker` - (if defined). + """Check if the current position is at or past the end of data. + + End of data is defined as being beyond the buffer's current length, + or positioned exactly on a byte matching `self.eof_marker` (if defined). + + Returns: + True if at end-of-data, False otherwise. """ if self.pos >= len(self.raw): return True if self.eof_marker is not None and safe_ord(self.raw[self.pos]) == self.eof_marker: return True return False + def get_raw(self) -> bytes | bytearray: + """Return the underlying buffer's content as [bytes][] or [bytearray][]. + + Use this method for generic access to the raw buffer content instead of + accessing the `raw` attribute directly, as the type of `raw` can vary + depending on the buffer factory used. + + Returns: + The raw content of the buffer. + """ + return self.factory.get_raw(self.raw) def write(self, data: bytes) -> None: - """Write bytes. + """Write raw bytes at the current position and advance position. + + Ensures buffer has enough space, resizing if necessary and allowed. + + Arguments: + data: The bytes to write. + + Raises: + BufferError: If resizing is needed but exceeds `max_size`. """ size = len(data) self._ensure_space(size) @@ -208,39 +313,101 @@ def write_byte(self, byte: int) -> None: self.pos += 1 def write_number(self, value: int, size: int, *, signed: bool=False) -> None: """Write number with specified size (in bytes). + + Arguments: + value: The integer value to write. + size: Value size in bytes. + signed: Write as signed or unsigned integer. + + Raises: + BufferError: If resizing is needed but exceeds `max_size`. """ self.write(value.to_bytes(size, self.byteorder.value, signed=signed)) def write_short(self, value: int) -> None: """Write 2 byte number (c_ushort). + + Arguments: + value: The integer value to write. + + Raises: + BufferError: If resizing is needed but exceeds `max_size`. """ self.write_number(value, 2) def write_int(self, value: int) -> None: """Write 4 byte number (c_uint). + + Arguments: + value: The integer value to write. + + Raises: + BufferError: If resizing is needed but exceeds `max_size`. """ self.write_number(value, 4) def write_bigint(self, value: int) -> None: - """Write tagged 8 byte number (c_ulonglong). + """Write 8 byte number (c_ulonglong). + + Arguments: + value: The integer value to write. + + Raises: + BufferError: If resizing is needed but exceeds `max_size`. """ self.write_number(value, 8) def write_string(self, value: str, *, encoding: str='ascii', errors: str='strict') -> None: - """Write zero-terminated string. + """Encode string, write bytes followed by a null terminator (0x00). + + Arguments: + value: The string to write. + encoding: Encoding to use. + errors: Encoding error handling scheme. + + Raises: + BufferError: If resizing is needed but exceeds `max_size`. + UnicodeEncodeError: If `value` cannot be encoded using `encoding`. """ self.write(value.encode(encoding, errors)) self.write_byte(0) def write_pascal_string(self, value: str, *, encoding: str='ascii', errors: str='strict') -> None: - """Write tagged Pascal string (2 byte length followed by data). + """Write Pascal string (2 byte length followed by data). + + Arguments: + value: The string to write. + encoding: Encoding to use. + errors: Encoding error handling scheme. + + Raises: + BufferError: If resizing is needed but exceeds `max_size`. """ value = value.encode(encoding, errors) self.write_byte(len(value)) self.write(value) def write_sized_string(self, value: str, *, encoding: str='ascii', errors: str='strict') -> None: - """Write string (2 byte length followed by data). + """Write sized string (2 byte length followed by data). + + Arguments: + value: The string to write. + encoding: Encoding to use. + errors: Encoding error handling scheme. + + Raises: + BufferError: If resizing is needed but exceeds `max_size`. """ value = value.encode(encoding, errors) self.write_short(len(value)) self.write(value) def read(self, size: int=-1) -> bytes: - """Reads specified number of bytes, or all remaining data. + """Read specified number of bytes from current position, or all remaining data. + + Advances the position by the number of bytes read. + + Arguments: + size: Number of bytes to read. If negative, reads all data from the + current position to the end of the buffer. + Returns: + The bytes read. + + Raises: + BufferError: If `size` requests more bytes than available from the current position. """ if size < 0: size = self.buffer_size - self.pos @@ -248,8 +415,20 @@ def read(self, size: int=-1) -> bytes: result = self.raw[self.pos: self.pos + size] self.pos += size return result - def read_number(self, size: int, *, signed=False) -> int: - """Read number with specified size in bytes. + def read_number(self, size: int, *, signed: bool=False) -> int: + """Read a number of `size` bytes from current position using `self.byteorder`. + + Advances the position by `size`. + + Arguments: + size: The number of bytes representing the number. + signed: Whether to interpret the bytes as a signed integer. + + Returns: + The integer value read. + + Raises: + BufferError: When `size` is specified, but there is not enough bytes to read. """ self._check_space(size) result = (0).from_bytes(self.raw[self.pos: self.pos + size], self.byteorder.value, signed=signed) @@ -276,7 +455,20 @@ def read_sized_int(self, *, signed: bool=False) -> int: """ return self.read_number(self.read_short(), signed=signed) def read_string(self, *, encoding: str='ascii', errors: str='strict') -> str: - """Read null-terminated string. + """Read bytes until a null terminator (0x00) is found, decode, and return string. + + Advances the position past the null terminator. + + Arguments: + encoding: Encoding to use for decoding. + errors: Decoding error handling scheme. + + Returns: + The decoded string (excluding the null terminator). + + Raises: + BufferError: If the end of the buffer is reached before a null terminator. + UnicodeDecodeError: If the read bytes cannot be decoded using `encoding`. """ i = self.pos while i < self.buffer_size and safe_ord(self.raw[i]) != 0: @@ -286,25 +478,53 @@ def read_string(self, *, encoding: str='ascii', errors: str='strict') -> str: return result def read_pascal_string(self, *, encoding: str='ascii', errors: str='strict') -> str: """Read Pascal string (1 byte length followed by string data). + + Arguments: + encoding: Encoding to use for decoding. + errors: Decoding error handling scheme. + + Returns: + The decoded string. + + Raises: + BufferError: If the end of the buffer is reached before end of string. + UnicodeDecodeError: If the read bytes cannot be decoded using `encoding`. """ return self.read(self.read_byte()).decode(encoding, errors) def read_sized_string(self, *, encoding: str='ascii', errors: str='strict') -> str: - """Read string (2 byte length followed by data). + """Read sized string (2 byte length followed by data). + + Arguments: + encoding: Encoding to use for decoding. + errors: Decoding error handling scheme. + + Returns: + The decoded string. + + Raises: + BufferError: If the end of the buffer is reached before end of string. + UnicodeDecodeError: If the read bytes cannot be decoded using `encoding`. """ return self.read(self.read_short()).decode(encoding, errors) - def read_bytes(self) -> bytes: + def read_bytes(self) -> bytes | bytearray: """Read content of binary cluster (2 bytes data length followed by data). + + + Returns: + The bytes read. + + Raises: + BufferError: If the end of the buffer is reached before end of data. """ return self.read(self.read_short()) # Properties @property def buffer_size(self) -> int: - """Current buffer size in bytes. - """ + """Current allocated buffer size in bytes.""" return len(self.raw) @property def last_data(self) -> int: - """Index of first non-zero byte when searched from the end of buffer. + """Index of the last non-zero byte in the buffer (-1 if all zeros). """ i = len(self.raw) - 1 while i >= 0: diff --git a/src/firebird/base/collections.py b/src/firebird/base/collections.py index 719166a..9aa690a 100644 --- a/src/firebird/base/collections.py +++ b/src/firebird/base/collections.py @@ -33,22 +33,27 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________. -"""Firebird Base - Various collection types - -This module provides data structures that behave much like builtin `list` and `dict` types, -but with direct support of operations that can use structured data stored in container, and -which would normally require utilization of `operator`, `functools` or other means. +"""This module provides data structures like [DataList][firebird.base.collections.DataList] +and [Registry][firebird.base.collections.Registry] that behave much like builtin [list][] and +[dict][] types, respectively, but with direct support of operations that can use structured +data stored in container, and which would normally require utilization of [operator][], +[functools][] or other means. All containers provide next operations: -* `filter` and `filterfalse` that return generator that yields items for which `expr` is - evaluated as True (or False). -* `find` that returns first item for which `expr` is evaluated as True, or default. -* `contains` that returns True if there is any item for which `expr` is evaluated as True. -* `occurrence` that returns number of items for which `expr` is evaluated as True. -* `all` and `any` that return True if `expr` is evaluated as True for all or any list element(s). -* `report` that returns generator that yields data produced by expression(s) evaluated on - list items. +* [filter][firebird.base.collections.BaseObjectCollection.filter] and + [filterfalse][firebird.base.collections.BaseObjectCollection.filterfalse] that return + generator that yields items for which `expr` is evaluated as True (or False). +* [find][firebird.base.collections.BaseObjectCollection.find] that returns first item for + which `expr` is evaluated as True, or default. +* [contains][firebird.base.collections.BaseObjectCollection.contains] that returns True if + there is any item for which `expr` is evaluated as True. +* [occurrence][firebird.base.collections.BaseObjectCollection.occurrence] that returns number + of items for which `expr` is evaluated as True. +* [all][firebird.base.collections.BaseObjectCollection.all] and [any][firebird.base.collections.BaseObjectCollection.any] + that return True if `expr` is evaluated as True for all or any list element(s). +* [report][firebird.base.collections.BaseObjectCollection.report] that returns generator + that yields data produced by expression(s) evaluated on collection items. Individual collection types provide additional operations like splitting and extracting based on expression etc. @@ -58,34 +63,41 @@ """ from __future__ import annotations -from typing import Type, Union, Any, Dict, List, Tuple, Mapping, Sequence, Generator, \ - Iterable, Callable, cast + +from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence from operator import attrgetter -import copy as std_copy -from .types import Error, Distinct, Sentinel, UNDEFINED +from typing import Any, TypeAlias, TypeVar, cast +from .types import UNDEFINED, Distinct, Error, Sentinel -def make_lambda(expr: str, params: str='item', context: Dict[str, Any]=None): +_T = TypeVar("_T") + +def make_lambda(expr: str, params: str='item', context: dict[str, Any] | None=None) -> Callable[..., Any]: """Makes lambda function from expression. Arguments: expr: Python expression as string. params: Comma-separated list of names that should be used as lambda parameters context: Dictionary passed as `context` to `eval`. + + Danger: + Uses `eval`. Ensure that the `expr` string comes from a trusted source + if used in security-sensitive contexts. """ - return eval(f'lambda {params}:{expr}', context) if context else eval(f'lambda {params}:{expr}') + return eval(f"lambda {params}:{expr}", context) if context \ + else eval(f"lambda {params}:{expr}") # noqa: S307 -#: Collection Item Item = Any -#: Collection Item type specification -TypeSpec = Union[Type, Tuple[Type]] -#: Collection Item sort expression -ItemExpr = Union[str, Callable[[Item], Item]] -#: Filter expression -FilterExpr = Union[str, Callable[[Item], bool]] -#: Check expression -CheckExpr = Union[str, Callable[[Item, Any], bool]] +"Collection Item" +TypeSpec: TypeAlias = type | tuple[type, ...] +"Collection Item type specification" +ItemExpr: TypeAlias = str | Callable[[Item], Any] +"Collection Item sort expression" +FilterExpr: TypeAlias = str | Callable[[Item], bool] +"Filter expression" +CheckExpr: TypeAlias = str | Callable[[Item, Any], bool] +"Check expression" class BaseObjectCollection: """Base class for collection of objects. @@ -98,10 +110,10 @@ def filter(self, expr: FilterExpr) -> Generator[Item, None, None]: bool expression as string referencing list item as `item`. Example: - .. code-block:: python - - L.filter(lambda x: x.name.startswith("ABC")) - L.filter('item.name.startswith("ABC")') + ```python + L.filter(lambda x: x.name.startswith("ABC")) + L.filter('item.name.startswith("ABC")') + ``` """ fce = expr if callable(expr) else make_lambda(expr) return (item for item in self if fce(item)) @@ -113,14 +125,14 @@ def filterfalse(self, expr: FilterExpr) -> Generator[Item, None, None]: bool expression as string referencing list item as `item`. Example: - .. code-block:: python - - L.filterfalse(lambda x: x.name.startswith("ABC")) - L.filterfalse('item.name.startswith("ABC")') + ```python + L.filterfalse(lambda x: x.name.startswith("ABC")) + L.filterfalse('item.name.startswith("ABC")') + ``` """ fce = expr if callable(expr) else make_lambda(expr) return (item for item in self if not fce(item)) - def find(self, expr: FilterExpr, default: Any=None) -> Item: + def find(self, expr: FilterExpr, default: _T=None) -> Item | _T: """Returns first item for which `expr` is evaluated as True, or default. Arguments: @@ -129,15 +141,15 @@ def find(self, expr: FilterExpr, default: Any=None) -> Item: default: Default value returned when Items is not found. Example: - .. code-block:: python - - L.find(lambda x: x.name.startswith("ABC")) - L.find('item.name.startswith("ABC")') + ```python + L.find(lambda x: x.name.startswith("ABC")) + L.find('item.name.startswith("ABC")') + ``` """ for item in self.filter(expr): return item return default - def contains(self, expr: FilterExpr=None) -> bool: + def contains(self, expr: FilterExpr) -> bool: """Returns True if there is any item for which `expr` is evaluated as True. Arguments: @@ -145,14 +157,18 @@ def contains(self, expr: FilterExpr=None) -> bool: bool expression as string referencing list item as `item`. Example: - .. code-block:: python - - if L.contains(lambda x: x.name.startswith("ABC")): - ... - if L.contains('item.name.startswith("ABC")'): - ... + ```python + if L.contains(lambda x: x.name.startswith("ABC")): + ... + if L.contains('item.name.startswith("ABC")'): + ... + ``` """ - return self.find(expr) is not None + fce = expr if callable(expr) else make_lambda(expr) + for item in self: + if fce(item): + return True + return False def report(self, *args) -> Generator[Any, None, None]: """Returns generator that yields data produced by expression(s) evaluated on list items. @@ -163,17 +179,17 @@ def report(self, *args) -> Generator[Any, None, None]: - One or more expressions as string referencing item as `item`. Example: - .. code-block:: python + ```python + # generator of tuples with item.name and item.size - # generator of tuples with item.name and item.size + L.report(lambda x: (x.name, x.size)) + L.report('item.name','item.size') - L.report(lambda x: (x.name, x.size)) - L.report('item.name','item.size') + # generator of item names - # generator of item names - - L.report(lambda x: x.name) - L.report('item.name') + L.report(lambda x: x.name) + L.report('item.name') + ``` """ if len(args) == 1 and callable(args[0]): fce = args[0] @@ -189,10 +205,10 @@ def occurrence(self, expr: FilterExpr) -> int: bool expression as string referencing list item as `item`. Example: - .. code-block:: python - - L.occurrence(lambda x: x.name.startswith("ABC")) - L.occurrence('item.name.startswith("ABC")') + ```python + L.occurrence(lambda x: x.name.startswith("ABC")) + L.occurrence('item.name.startswith("ABC")') + ``` """ return sum(1 for item in self.filter(expr)) def all(self, expr: FilterExpr) -> bool: @@ -203,10 +219,10 @@ def all(self, expr: FilterExpr) -> bool: bool expression as string referencing list item as `item`. Example: - .. code-block:: python - - L.all(lambda x: x.name.startswith("ABC")) - L.all('item.name.startswith("ABC")') + ```python + L.all(lambda x: x.name.startswith("ABC")) + L.all('item.name.startswith("ABC")') + ``` """ fce = expr if callable(expr) else make_lambda(expr) for item in self: @@ -220,11 +236,14 @@ def any(self, expr: FilterExpr) -> bool: expr: Bool expression, a callable accepting one parameter and returnin bool or bool expression as string referencing list item as `item`. - Example: - .. code-block:: python + Note: + Functionally equivalent to the `contains` method in this class. - L.any(lambda x: x.name.startswith("ABC")) - L.any('item.name.startswith("ABC")') + Example: + ```python + L.any(lambda x: x.name.startswith("ABC")) + L.any('item.name.startswith("ABC")') + ``` """ fce = expr if callable(expr) else make_lambda(expr) for item in self: @@ -232,26 +251,25 @@ def any(self, expr: FilterExpr) -> bool: return True return False -class DataList(List[Item], BaseObjectCollection): +class DataList(list[Item], BaseObjectCollection): """List of data (objects) with additional functionality. - """ - def __init__(self, items: Iterable=None, type_spec: TypeSpec=UNDEFINED, - key_expr: str=None, frozen: bool=False): - """ - Arguments: - items: Sequence to initialize the collection. - type_spec: Reject instances that are not instances of specified types. - key_expr: Key expression. Must contain item referrence as `item`, for example - `item.attribute_name`. If **all** classes specified in `type_spec` - are descendants of `.Distinct`, the default value is `item.get_key()`, - otherwise the default is `None`. - frozen: Create frozen list. - Raises: - ValueError: When initialization sequence contains invalid instance. - """ - assert key_expr is None or isinstance(key_expr, str) - assert key_expr is None or make_lambda(key_expr) is not None + Arguments: + items: Sequence to initialize the collection. + type_spec: Reject instances that are not instances of specified types. + key_expr: Key expression. Must contain item referrence as `item`, for example + `item.attribute_name`. If **all** classes specified in `type_spec` + are descendants of `Distinct`, the default value is `item.get_key()`, + otherwise the default is `None`. + frozen: Create frozen list. + + Raises: + ValueError: When initialization sequence contains invalid instance. + """ + def __init__(self, items: Iterable | None=None, type_spec: TypeSpec=UNDEFINED, + key_expr: str | None=None, *, frozen: bool=False): + assert key_expr is None or isinstance(key_expr, str) # noqa: S101 + assert key_expr is None or make_lambda(key_expr) is not None # noqa: S101 if items is not None: super().__init__(items) else: @@ -268,7 +286,7 @@ def __init__(self, items: Iterable=None, type_spec: TypeSpec=UNDEFINED, self.__key_expr: str = key_expr self.__frozen: bool = False self._type_spec: TypeSpec = type_spec - self.__map: Dict = None + self.__map: dict = None if frozen: self.freeze() def __valchk(self, value: Item) -> None: @@ -277,7 +295,8 @@ def __valchk(self, value: Item) -> None: def __updchk(self) -> None: if self.__frozen: raise TypeError("Cannot modify frozen DataList") - def __setitem__(self, index, value) -> None: + def __setitem__(self, index: int | slice, value: Item | Iterable[Item]) -> None: + # Set `item[index] = value`. Performs type check and frozen check. self.__updchk() if isinstance(index, slice): for val in value: @@ -285,10 +304,16 @@ def __setitem__(self, index, value) -> None: else: self.__valchk(value) super().__setitem__(index, value) - def __delitem__(self, index) -> None: + def __delitem__(self, index: int | slice) -> None: + # Delete `item[index]`. Performs frozen check. self.__updchk() super().__delitem__(index) - def __contains__(self, o): + def __contains__(self, o) -> bool: + # Return key in self. Optimized for frozen lists with a `key_expr`. + # If the list is frozen and has a `key_expr`, uses an internal map for + # O(1) average time complexity. Otherwise, falls back to standard + # list iteration (O(n)). Handles `Distinct` instances specifically if + # `key_expr` matches `item.get_key()`. if self.__map is not None: if isinstance(o, Distinct) and self.__key_expr == 'item.get_key()': return o.get_key() in self.__map @@ -298,7 +323,8 @@ def insert(self, index: int, item: Item) -> None: """Insert item before index. Raises: - TypeError: When item is not an instance of allowed class, or list is frozen + TypeError: When `item` is not an instance of the allowed `type_spec`, + or if the list is frozen. """ self.__updchk() self.__valchk(item) @@ -307,7 +333,7 @@ def remove(self, item: Item) -> None: """Remove first occurrence of item. Raises: - ValueError: If the value is not present, or list is frozen + ValueError: When `item` is not present, or list is frozen """ self.__updchk() super().remove(item) @@ -315,20 +341,23 @@ def append(self, item: Item) -> None: """Add an item to the end of the list. Raises: - TypeError: When item is not an instance of allowed class, or list is frozen + TypeError: When `item` is not an instance of the allowed `type_spec`, + or if the list is frozen. """ self.__updchk() self.__valchk(item) super().append(item) - def extend(self, iterable: Iterable) -> None: + def extend(self, iterable: Iterable[Item]) -> None: """Extend the list by appending all the items in the given iterable. Raises: - TypeError: When item is not an instance of allowed class, or list is frozen + TypeError: When any `item` in `iterable` is not an instance of the allowed + `type_spec`, or if the list is frozen. """ for item in iterable: self.append(item) - def sort(self, attrs: List=None, expr: ItemExpr=None, reverse: bool=False) -> None: + def sort(self, attrs: list[str] | tuple[str, ...] | None=None, + expr: ItemExpr | None=None, *, reverse: bool=False) -> None: """Sort items in-place, optionaly using attribute values as key or key expression. Arguments: @@ -336,18 +365,18 @@ def sort(self, attrs: List=None, expr: ItemExpr=None, reverse: bool=False) -> No expr: Key expression, a callable accepting one parameter or expression as string referencing list item as `item`. - Important: - Only one parameter (`attrs` or `expr`) could be specified. - If none is present then uses default list sorting rule. + Important: + Only one parameter (`attrs` or `expr`) could be specified. + If none is present then uses default list sorting rule. Example: - .. code-block:: python - - L.sort(attrs=['name','degree']) # Sort by item.name, item.degree - L.sort(expr=lambda x: x.name.upper()) # Sort by upper item.name - L.sort(expr='item.name.upper()') # Sort by upper item.name + ```python + L.sort(attrs=['name','degree']) # Sort by item.name, item.degree + L.sort(expr=lambda x: x.name.upper()) # Sort by upper item.name + L.sort(expr='item.name.upper()') # Sort by upper item.name + ``` """ - assert attrs is None or isinstance(attrs, (list, tuple)) + assert attrs is None or isinstance(attrs, list | tuple) # noqa: S101 if attrs: super().sort(key=attrgetter(*attrs), reverse=reverse) elif expr: @@ -370,14 +399,14 @@ def freeze(self) -> None: Freezing list makes internal map from `key_expr` to item index that significantly speeds up retrieval by key using the `get()` method. - It's not possible to `add`, `delete` or `change` items in frozen list, but `.sort` + It's not possible to `add`, `delete` or `change` items in frozen list, but `sort` is allowed. """ self.__frozen = True if self.__key_expr: fce = make_lambda(self.__key_expr) - self.__map = dict(((key, index) for index, key in enumerate((fce(item) for item in self)))) - def split(self, expr: FilterExpr, frozen: bool=False) -> Tuple[DataList, DataList]: + self.__map = {key: index for index, key in enumerate(fce(item) for item in self)} + def split(self, expr: FilterExpr, *, frozen: bool=False) -> tuple[DataList, DataList]: """Return two new `DataList` instances, first with items for which `expr` is evaluated as True and second for `expr` evaluated as False. @@ -387,14 +416,14 @@ def split(self, expr: FilterExpr, frozen: bool=False) -> Tuple[DataList, DataLis frozen: Create frozen lists. Example: - .. code-block:: python - - below, above = L.split(lambda x: x.size > 100) - below, above = L.split('item.size > 100') + ```python + below, above = L.split(lambda x: x.size > 100) + below, above = L.split('item.size > 100') + ``` """ return DataList(self.filter(expr), self._type_spec, self.__key_expr, frozen=frozen), \ DataList(self.filterfalse(expr), self._type_spec, self.__key_expr, frozen=frozen) - def extract(self, expr: FilterExpr, copy: bool=False) -> DataList: + def extract(self, expr: FilterExpr, *, copy: bool=False) -> DataList: """Move/copy items for which `expr` is evaluated as True into new `DataList`. Arguments: @@ -406,10 +435,10 @@ def extract(self, expr: FilterExpr, copy: bool=False) -> DataList: TypeError: When list is frozen and `copy` is False. Example: - .. code-block:: python - - L.extract(lambda x: x.name.startswith("ABC")) - L.extract('item.name.startswith("ABC")') + ```python + L.extract(lambda x: x.name.startswith("ABC")) + L.extract('item.name.startswith("ABC")') + ``` """ if not copy: self.__updchk() @@ -427,7 +456,7 @@ def extract(self, expr: FilterExpr, copy: bool=False) -> DataList: else: i += 1 return l - def get(self, key: Any, default: Any=None) -> Item: + def get(self, key: Any, default: _T=None) -> Item | _T: """Returns item with given key using default key expression. Returns `default` value if item is not found. @@ -442,10 +471,10 @@ def get(self, key: Any, default: Any=None) -> Item: Error: If key expression is not defined. Example: - .. code-block:: python - - # Search using default key expression (fast for frozen list) - L.get('ITEM_NAME') + ```python + # Search using default key expression (fast for frozen list) + L.get('ITEM_NAME') + ``` """ if not self.__key_expr: raise Error("Key expression required") @@ -463,70 +492,75 @@ def frozen(self) -> bool: """ return self.__frozen @property - def key_expr(self) -> Item: + def key_expr(self) -> Item | None: """Key expression. """ return self.__key_expr @property - def type_spec(self) -> Union[TypeSpec, Sentinel]: - """Specification of valid type(s) for list values, or `.UNDEFINED` if there is - no such constraint. + def type_spec(self) -> TypeSpec | Sentinel: + """Specification of valid type(s) for list values, or [UNDEFINED][firebird.base.types.UNDEFINED] + if there is no such constraint. """ return self._type_spec class Registry(BaseObjectCollection, Mapping[Any, Distinct]): - """Mapping container for `.Distinct` objects. + """Mapping container for [Distinct][firebird.base.types.Distinct] objects. - Any method that expects a `key` also acepts `.Distinct` instance. + Any method that expects a `key` also acepts `Distinct` instance. To store items into registry with existence check, use: - - R.store(item) - - R.extend(items) or R.extend(item) + + - R.store(item) + - R.extend(items) or R.extend(item) To update items in registry (added if not present), use: - - R[key] = item - - R.update(items) or R.update(item) + + - R[key] = item + - R.update(items) or R.update(item) To check presence of item or key in registry, use: - - key in R + + - key in R To retrieve items from registry, use: - - R.get(key, default=None) - - R[key] - - R.pop(key, default=None) - - R.popitem(last=True) + + - R.get(key, default=None) + - R[key] + - R.pop(key, default=None) + - R.popitem(last=True) To delete items from registry, use: - - R.remove(item) - - del R[key] + + - R.remove(item) + - del R[key] Whenever a `key` is required, you can use either a `Distinct` instance, or any value that represens a key value for instances of stored type. + + Arguments: + data: Either a `Distinct` instance, or sequence or mapping of `Distinct` + instances. """ - def __init__(self, data: Union[Mapping, Sequence, Registry]=None): - """ - Arguments: - data: Either a `.Distinct` instance, or sequence or mapping of `.Distinct` - instances. - """ - self._reg: Dict = {} + def __init__(self, data: Mapping[Any, Distinct] | Sequence[Distinct] | Registry=None): + self._reg: dict[Any, Distinct] = {} if data: self.update(data) def __len__(self): return len(self._reg) - def __getitem__(self, key): + def __getitem__(self, key: Any) -> Distinct: + # Return `self[key]`. Accepts a key value or a `Distinct` instance. return self._reg[key.get_key() if isinstance(key, Distinct) else key] - def __setitem__(self, key, value): - assert isinstance(value, Distinct) + def __setitem__(self, key: Any, value: Distinct) -> None: + assert isinstance(value, Distinct) # noqa: S101 self._reg[key.get_key() if isinstance(key, Distinct) else key] = value - def __delitem__(self, key): + def __delitem__(self, key: Any) -> None: del self._reg[key.get_key() if isinstance(key, Distinct) else key] - def __iter__(self): + def __iter__(self) -> Iterator[Distinct]: return iter(self._reg.values()) - def __repr__(self): - return f"{self.__class__.__name__}(" \ - f"[{', '.join(repr(x) for x in self)}])" - def __contains__(self, item): + def __repr__(self) -> str: + return f"{self.__class__.__name__}([{', '.join(repr(x) for x in self)}])" + def __contains__(self, item: Any) -> bool: + # Return key in self. Accepts a key value or a `Distinct` instance. if isinstance(item, Distinct): item = item.get_key() return item in self._reg @@ -534,8 +568,12 @@ def clear(self) -> None: """Remove all items from registry. """ self._reg.clear() - def get(self, key: Any, default: Any=None) -> Distinct: - """ D.get(key[,d]) -> D[key] if key in D else d. d defaults to None. + def get(self, key: Any, default: _T=None) -> Distinct | _T: + """`D.get(key[,d]) -> D[key] if key in D else d`. `d` defaults to None. + + Arguments: + key: The key to retrieve (can be the key value or a `Distinct` instance). + default: Value to return if key is not found. """ return self._reg.get(key.get_key() if isinstance(key, Distinct) else key, default) def store(self, item: Distinct) -> Distinct: @@ -544,21 +582,21 @@ def store(self, item: Distinct) -> Distinct: Raises: ValueError: When item is already registered. """ - assert isinstance(item, Distinct), f"Item is not of type '{Distinct.__name__}'" + assert isinstance(item, Distinct), f"Item is not of type '{Distinct.__name__}'" # noqa: S101 key = item.get_key() if key in self._reg: raise ValueError(f"Item already registered, key: '{key}'") self._reg[key] = item return item - def remove(self, item: Distinct): - """Removes item from registry (same as: del R[item]). + def remove(self, item: Distinct) -> None: + """Removes item from registry (same as: `del R[item]`). """ del self._reg[item.get_key()] - def update(self, _from: Union[Distinct, Mapping, Sequence]) -> None: + def update(self, _from: Distinct | Mapping[Any, Distinct] | Sequence[Distinct]) -> None: """Update items in the registry. Arguments: - _from: Either a `.Distinct` instance, or sequence or mapping of `.Distinct` + _from: Either a `Distinct` instance, or sequence or mapping of `Distinct` instances. """ if isinstance(_from, Distinct): @@ -566,12 +604,18 @@ def update(self, _from: Union[Distinct, Mapping, Sequence]) -> None: else: for item in cast(Mapping, _from).values() if hasattr(_from, 'values') else _from: self[item] = item - def extend(self, _from: Union[Distinct, Mapping, Sequence]) -> None: + def extend(self, _from: Distinct | Mapping[Any, Distinct] | Sequence[Distinct]) -> None: """Store one or more items to the registry. + Unlike `update`, this method requires that the items (or their keys) + do not already exist in the registry. + Arguments: - _from: Either a `.Distinct` instance, or sequence or mapping of `.Distinct` + _from: Either a `Distinct` instance, or sequence or mapping of `Distinct` instances. + + Raises: + ValueError: If an item with the same key is already registered. """ if isinstance(_from, Distinct): self.store(_from) @@ -581,28 +625,31 @@ def extend(self, _from: Union[Distinct, Mapping, Sequence]) -> None: def copy(self) -> Registry: """Shalow copy of the registry. """ - if self.__class__ is Registry: - return Registry(self) - data = self._reg - try: - self._reg = {} - c = std_copy.copy(self) - finally: - self._reg = data - c.update(self) - return c - def pop(self, key: Any, default: Any=None) -> Distinct: - """Remove specified `key` and return the corresponding `.Distinct` object. If `key` - is not found, the `default` is returned if given, otherwise `KeyError` is raised. + return self.__class__(self) + def pop(self, key: Any, default: _T=...) -> Distinct | _T: + """Remove specified `key` and return the corresponding `Distinct` object. + + If `key` is not found, the `default` is returned if given, otherwise + [KeyError][] is raised. + + Arguments: + key: The key to remove (can be the key value or a `Distinct` instance). + default: Value to return if key is not found (if not provided, raises [KeyError][]). """ - return self._reg.pop(key.get_key() if isinstance(key, Distinct) else key, default) - def popitem(self, last: bool=True) -> Distinct: - """Returns and removes a `.Distinct` object. The objects are returned in LIFO order + if default is ...: + return self._reg.pop(key.get_key() if isinstance(key, Distinct) else key) + else: + return self._reg.pop(key.get_key() if isinstance(key, Distinct) else key, default) + def popitem(self, *, last: bool=True) -> Distinct: + """Returns and removes a `Distinct` object. The objects are returned in LIFO order if `last` is true or FIFO order if false. """ if last: _, item = self._reg.popitem() return item - item = next(iter(self)) - self.remove(item) - return item + try: + item = next(iter(self)) + self.remove(item) + return item + except StopIteration: + raise KeyError() # noqa: B904 diff --git a/src/firebird/base/config.py b/src/firebird/base/config.py index 4bf0960..3eb83a0 100644 --- a/src/firebird/base/config.py +++ b/src/firebird/base/config.py @@ -33,51 +33,104 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________. -"""Firebird Base - Classes for configuration definitions -Complex applications (and some library modules like `logging`) could be often parametrized +"""Complex applications (and some library modules like `logging`) could be often parametrized via configuration. This module provides a framework for unified structured configuration that supports: -* configuration options of various data type, including lists and other complex types -* validation -* direct manipulation of configuration values -* reading from (and writing into) configuration in `configparser` format -* exchanging configuration (for example between processes) using Google protobuf messages -* application directory scheme +* Configuration options of various data types (int, str, bool, list, Enum, Path, etc.). +* Nested configuration structures (`Config` containing other `Config` instances). +* Type checking and validation for option values. +* Default values and marking options as required. +* Reading from (and writing to) configuration files in `configparser` format, + with extended interpolation support (including environment variables via `${env:VAR}`). +* Serialization/deserialization using Google protobuf messages (`ConfigProto`). +* Platform-specific application directory schemes (`DirectoryScheme`). + +Example: + ```python + from firebird.base.config import Config, StrOption, IntOption, load_config + from configparser import ConfigParser + import io + + class ServerConfig(Config): + '''Configuration for a server application.''' + def __init__(self): + super().__init__('server') # Section name in config file + self.host = StrOption('host', 'Server hostname or IP address', default='localhost') + self.port = IntOption('port', 'Server port number', required=True, default=8080) + + # Instantiate + my_config = ServerConfig() + + # Load from a string (simulating a file) + config_string = ''' + [server] + host = 192.168.1.100 + port = 9000 + ''' + parser = ConfigParser() + parser.read_string(config_string) + my_config.load_config(parser) + + # Access values + print(f"Host: {my_config.host.value}") # Output: Host: 192.168.1.100 + print(f"Port: {my_config.port.value}") # Output: Port: 9000 + + # Get config file representation + print(my_config.get_config()) + ``` """ from __future__ import annotations -from typing import Generic, Type, Any, List, Dict, Union, Sequence, Callable, Optional, \ - TypeVar, cast, get_type_hints -from abc import ABC, abstractmethod + +import os import platform +from abc import ABC, abstractmethod +from collections.abc import Callable, Sequence +from configparser import ( + DEFAULTSECT, + MAX_INTERPOLATION_DEPTH, + ConfigParser, + ExtendedInterpolation, + InterpolationDepthError, + InterpolationMissingOptionError, + InterpolationSyntaxError, + NoOptionError, + NoSectionError, +) +from decimal import Decimal, DecimalException +from enum import Enum, Flag, _high_bit +from inspect import Parameter, Signature, signature from pathlib import Path +from typing import Any, Generic, TypeVar, cast, get_type_hints from uuid import UUID -from decimal import Decimal, DecimalException -from configparser import (ConfigParser, DEFAULTSECT, ExtendedInterpolation, - MAX_INTERPOLATION_DEPTH, InterpolationDepthError, - InterpolationSyntaxError, NoSectionError, NoOptionError, - InterpolationMissingOptionError) -from inspect import signature, Signature, Parameter -from enum import Enum, Flag -import os + from .config_pb2 import ConfigProto -from .types import Error, MIME, ZMQAddress, PyExpr, PyCode, PyCallable -from .strconv import get_convertor, convert_to_str, Convertor +from .strconv import Convertor, convert_to_str, get_convertor +from .types import MIME, Error, PyCallable, PyCode, PyExpr, ZMQAddress PROTO_CONFIG = 'firebird.base.ConfigProto' def has_verticals(value: str) -> bool: - "Returns True if lines in multiline string contains leading '|' character." + """Returns True if lines in multiline string contains leading `'|'` character. + Used to detect if special vertical bar indentation was used. + """ return any(1 for line in value.split('\n') if line.startswith('|')) def has_leading_spaces(value: str) -> bool: - "Returns True if any line in multiline string starts with space(s)." + """Returns True if any line in multiline string starts with space(s). + Used to determine if vertical bar notation is needed for preservation. + """ return any(1 for line in value.split('\n') if line.startswith(' ')) def unindent_verticals(value: str) -> str: - """Removes leading '|' character from each line in multiline string.""" + """Removes leading `'|'` character and calculated indent from each relevant line. + + This reverses the vertical bar notation used to preserve leading whitespace + in multiline string options when read by [configparser.ConfigParser][], which normally + strips leading whitespace from continuation lines. + """ lines = [] indent = None for line in value.split('\n'): @@ -92,44 +145,27 @@ def unindent_verticals(value: str) -> str: def _eq(a: Any, b: Any) -> bool: return str(a) == str(b) -def create_config(_cls: Type[Config], name: str) -> Config: # pragma: no cover - """Return newly created `Config` instance. Intended to be used with `functools.partial`. - - .. deprecated:: 1.6 - Will be removed in version 2.0 - """ - return _cls(name) - -# Next two functions are copied from stdlib enum module, as they were removed in Python 3.11 +# --- Internal helpers for FlagOption copied from stdlib enum (pre-Python 3.11) --- def _decompose(flag, value): - """ - Extract all members from the value. + """Extract all members from the value. """ # _decompose is only called if the value is not named not_covered = value negative = value < 0 - # issue29167: wrap accesses to _value2member_map_ in a list to avoid race - # conditions between iterating over it and having more pseudo- - # members added to it - if negative: - # only check for named flags - flags_to_check = [ - (m, v) - for v, m in list(flag._value2member_map_.items()) - if m.name is not None - ] - else: - # check for named flags and powers-of-two flags - flags_to_check = [ - (m, v) - for v, m in list(flag._value2member_map_.items()) - if m.name is not None or _power_of_two(v) - ] members = [] - for member, member_value in flags_to_check: + for member in flag: + member_value = member.value if member_value and member_value & value == member_value: members.append(member) not_covered &= ~member_value + if not negative: + tmp = not_covered + while tmp: + flag_value = 2 ** _high_bit(tmp) + if flag_value in flag._value2member_map_: + members.append(flag._value2member_map_[flag_value]) + not_covered &= ~flag_value + tmp &= ~flag_value if not members and value in flag._value2member_map_: members.append(flag._value2member_map_[value]) members.sort(key=lambda m: m._value_, reverse=True) @@ -138,29 +174,23 @@ def _decompose(flag, value): members.pop(0) return members, not_covered -def _power_of_two(value): - if value < 1: - return False - return value == 2 ** (value.bit_length() - 1) - class EnvExtendedInterpolation(ExtendedInterpolation): - """.. versionadded:: 1.8.0 - - Modified version of `configparser.ExtendedInterpolation` class that adds special + """Modified version of `configparser.ExtendedInterpolation` class that adds special handling for "env" section that returns value of specified environment variable, or empty string if such variable is not defined. - Example:: - + Example: + ``` ${env:path} is reference to PATH environment variable. + ``` """ - def _interpolate_some(self, parser, option, accum, rest, section, map, + def _interpolate_some(self, parser, option, accum, rest, section, map, # noqa: A002 depth): rawval = parser.get(section, option, raw=True, fallback=rest) if depth > MAX_INTERPOLATION_DEPTH: raise InterpolationDepthError(option, section, rawval) while rest: - p = rest.find("$") + p = rest.find('$') if p < 0: accum.append(rest) return @@ -169,14 +199,14 @@ def _interpolate_some(self, parser, option, accum, rest, section, map, rest = rest[p:] # p is no longer used c = rest[1:2] - if c == "$": - accum.append("$") + if c == '$': + accum.append('$') rest = rest[2:] - elif c == "{": + elif c == '{': m = self._KEYCRE.match(rest) if m is None: raise InterpolationSyntaxError(option, section, - "bad interpolation variable reference %r" % rest) + f"bad interpolation variable reference {rest!r}") path = m.group(1).split(':') rest = rest[m.end():] sect = section @@ -185,7 +215,7 @@ def _interpolate_some(self, parser, option, accum, rest, section, map, if len(path) == 1: opt = parser.optionxform(path[0]) v = map[opt] - elif len(path) == 2: + elif len(path) == 2:# noqa: PLR2004 sect = path[0] opt = parser.optionxform(path[1]) if sect == 'env': @@ -195,11 +225,11 @@ def _interpolate_some(self, parser, option, accum, rest, section, map, else: raise InterpolationSyntaxError( option, section, - "More than one ':' found: %r" % (rest,)) + f"More than one ':' found: {rest!r}") except (KeyError, NoSectionError, NoOptionError): raise InterpolationMissingOptionError( - option, section, rawval, ":".join(path)) from None - if "$" in v: + option, section, rawval, ':'.join(path)) from None + if '$' in v: self._interpolate_some(parser, opt, accum, v, sect, dict(parser.items(sect, raw=True)), depth + 1) @@ -208,8 +238,7 @@ def _interpolate_some(self, parser, option, accum, rest, section, map, else: raise InterpolationSyntaxError( option, section, - "'$' must be followed by '$' or '{', " - "found: %r" % (rest,)) + f"'$' must be followed by '$' or '{{', found: {rest!r}") class DirectoryScheme: """Class that provide paths to typically used application directories. @@ -222,22 +251,31 @@ class DirectoryScheme: Note: All paths are set when the instance is created and can be changed later. + + Arguments: + name: Appplication name. + version: Application version. + force_home: When True, general directories (i.e. all except user-specific and + TMP) would be always based on HOME directory. + + Example: + ```python + scheme = get_directory_scheme("MyApp", "1.0") + config_path = scheme.config / "settings.ini" + log_file = scheme.logs / "app.log" + user_cache_dir = scheme.user_cache + print(f"Config dir: {scheme.config}") + print(f"User cache: {user_cache_dir}") + ``` """ - def __init__(self, name: str, version: str=None, force_home: bool=False): - """ - Arguments: - name: Appplication name. - version: Application version. - force_home: When True, general directories (i.e. all except user-specific and - TMP) would be always based on HOME directory. - """ + def __init__(self, name: str, version: str | None=None, *, force_home: bool=False): self.name: str = name self.version: str = version self.force_home: bool = force_home - _h = os.getenv(f'{self.name.upper()}_HOME') - self.__home: Path = Path(_h) if _h is not None else Path(os.getcwd()) - home = self.home - self.dir_map: Dict[str, Path] = {'config': home / 'config', + _h = os.getenv(f"{self.name.upper()}_HOME") + self.__home: Path = Path(_h) if _h is not None else Path.cwd() + home: Path = self.home + self.dir_map: dict[str, Path] = {'config': home / 'config', 'run_data': home / 'run_data', 'logs': home / 'logs', 'data': home / 'data', @@ -262,13 +300,13 @@ def home(self) -> Path: When new value is assigned, the general directories (i.e. all except user-specific and TMP) are redefined as subdirectories of new home path ONLY when HOME was initially defined using `_HOME` environment variable, or instance - was created with `force_home` = True. + was created with `force_home = True`. However, all paths could be still changed individually to any value. """ return self.__home @home.setter - def home(self, value: Union[Path, str]) -> None: + def home(self, value: Path | str) -> None: self.__home = value if isinstance(value, Path) else Path(value) if self.has_home_env() or self.force_home: self.dir_map.update({'config': self.__home / 'config', @@ -375,21 +413,20 @@ def user_cache(self, path: Path) -> None: class WindowsDirectoryScheme(DirectoryScheme): - """Directory scheme that conforms to Windows standards. + """Directory scheme conforming to Windows standards (e.g., `APPDATA`, `PROGRAMDATA`). If HOME is defined using "_HOME" environment variable, or `force_home` parameter is True, only user-specific directories and TMP are set according to platform standars, while general directories remain as defined by base `DirectoryScheme`. + + Arguments: + name: Appplication name. + version: Application version. + force_home: When True, general directories (i.e. all except user-specific and + TMP) would be always based on HOME directory. """ - def __init__(self, name: str, version: str=None, force_home: bool=False): - """ - Arguments: - name: Appplication name. - version: Application version. - force_home: When True, general directories (i.e. all except user-specific and - TMP) would be always based on HOME directory. - """ - super().__init__(name, version, force_home) + def __init__(self, name: str, version: str | None=None, *, force_home: bool=False): + super().__init__(name, version, force_home=force_home) app_dir = Path(self.name) if self.version is not None: app_dir /= self.version @@ -419,16 +456,15 @@ class LinuxDirectoryScheme(DirectoryScheme): If HOME is defined using "_HOME" environment variable, or `force_home` parameter is True, only user-specific directories and TMP are set according to platform standars, while general directories remain as defined by base `DirectoryScheme`. + + Arguments: + name: Appplication name. + version: Application version. + force_home: When True, general directories (i.e. all except user-specific and + TMP) would be always based on HOME directory. """ - def __init__(self, name: str, version: str=None, force_home: bool=False): - """ - Arguments: - name: Appplication name. - version: Application version. - force_home: When True, general directories (i.e. all except user-specific and - TMP) would be always based on HOME directory. - """ - super().__init__(name, version, force_home) + def __init__(self, name: str, version: str | None=None, *, force_home: bool=False): + super().__init__(name, version, force_home=force_home) app_dir = Path(self.name) if self.version is not None: app_dir /= self.version @@ -442,7 +478,7 @@ def __init__(self, name: str, version: str=None, force_home: bool=False): 'srv': Path('/srv') / app_dir, }) # Always set user-specific directories and TMP - self.dir_map.update({'tmp': Path('/var/tmp') / app_dir, + self.dir_map.update({'tmp': Path('/var/tmp') / app_dir, # noqa S108 'user_config': Path('~/.config').expanduser() / app_dir, 'user_data': Path('~/.local/share').expanduser() / app_dir, 'user_sync': Path('~/.local/sync').expanduser() / app_dir, @@ -455,14 +491,13 @@ class MacOSDirectoryScheme(DirectoryScheme): If HOME is defined using "_HOME" environment variable, only user-specific directories and TMP are set according to platform standars, while general directories remain as defined by base `DirectoryScheme`. + + Arguments: + name: Appplication name. + version: Application version. """ - def __init__(self, name: str, version: str=None, force_home: bool=False): - """ - Arguments: - name: Appplication name. - version: Application version. - """ - super().__init__(name, version, force_home) + def __init__(self, name: str, version: str | None=None, *, force_home: bool=False): + super().__init__(name, version, force_home=force_home) app_dir = Path(self.name) if self.version is not None: app_dir /= self.version @@ -485,7 +520,7 @@ def __init__(self, name: str, version: str=None, force_home: bool=False): 'user_cache': Path('~/Library/Caches').expanduser() / app_dir / 'cache', }) -def get_directory_scheme(app_name: str, version: str=None, *, force_home: bool=False) -> DirectoryScheme: +def get_directory_scheme(app_name: str, version: str | None=None, *, force_home: bool=False) -> DirectoryScheme: """Returns directory scheme for current platform. Arguments: @@ -497,40 +532,43 @@ def get_directory_scheme(app_name: str, version: str=None, *, force_home: bool=F """ return {'Windows': WindowsDirectoryScheme, 'Linux':LinuxDirectoryScheme, - 'Darwin': MacOSDirectoryScheme}.get(platform.system(), DirectoryScheme)(app_name, version, force_home) + 'Darwin': MacOSDirectoryScheme}.get(platform.system(), + DirectoryScheme)(app_name, version, + force_home=force_home) -T = TypeVar('T') +T = TypeVar("T") +E = TypeVar("E", bound=Enum) +F = TypeVar("F", bound=Flag) class Option(Generic[T], ABC): """Generic abstract base class for configuration options. + + Arguments: + name: Option name. + datatype: Option datatype. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. """ - def __init__(self, name: str, datatype: T, description: str, required: bool=False, - default: T=None): - """ - Arguments: - name: Option name. - datatype: Option datatype. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - """ - assert name and isinstance(name, str), "name required" - assert datatype and isinstance(datatype, type), "datatype required" - assert description and isinstance(description, str), "description required" - assert default is None or isinstance(default, datatype), "default has wrong data type" - #: Option name. + def __init__(self, name: str, datatype: type[T], description: str, *, required: bool=False, + default: T | None =None): + assert name and isinstance(name, str), "name required" # noqa: S101 + assert datatype and isinstance(datatype, type), "datatype required" # noqa: S101 + assert description and isinstance(description, str), "description required" # noqa: S101 + assert default is None or isinstance(default, datatype), "default has wrong data type" # noqa: S101 self.name: str = name - #: Option datatype. - self.datatype: T = datatype - #: Option description. Can span multiple lines. + "Option name." + self.datatype: type[T] = datatype + "Option datatype." self.description: str = description - #: True if option must have a value. + "Option description. Can span multiple lines." self.required: bool = required - #: Default option value. + "True if option must have a value." self.default: T = default + "Default option value." if default is not None: self.set_value(default) - def _check_value(self, value: T) -> None: + def _check_value(self, value: T | None) -> None: if value is None and self.required: raise ValueError(f"Value is required for option '{self.name}'.") if value is not None and not isinstance(value, self.datatype): @@ -539,7 +577,7 @@ def _check_value(self, value: T) -> None: f" not '{type(value).__name__}'") def _get_value_description(self) -> str: return f'{self.datatype.__name__}\n' - def _get_config_lines(self, plain: bool=False) -> List[str]: + def _get_config_lines(self, *, plain: bool=False) -> list[str]: """Returns list of strings containing text lines suitable for use in configuration file processed with `~configparser.ConfigParser`. @@ -571,15 +609,14 @@ def _get_config_lines(self, plain: bool=False) -> List[str]: new_value = [chunks[0]] new_value.extend(f'{nodef}{x}' for x in chunks[1:]) value = ''.join(new_value) - lines.append(f"{nodef}{self.name} = {value}\n") + lines.append(f'{nodef}{self.name} = {value}\n') return lines def load_config(self, config: ConfigParser, section: str) -> None: - """Update option value from `~configparser.ConfigParser` instance. + """Update option value from [configparser.ConfigParser][] instance. Arguments: config: ConfigParser instance. - section: Name of ConfigParser section that should be used to get new option - value. + section: Name of ConfigParser section that should be used to get new option value. Raises: ValueError: When option value cannot be loadded. @@ -599,7 +636,7 @@ def validate(self) -> None: raise Error(f"Missing value for required option '{self.name}'") def get_config(self, *, plain: bool=False) -> str: """Returns string containing text lines suitable for use in configuration file - processed with `~configparser.ConfigParser`. + processed with [configparser.ConfigParser][]. Arguments: plain: When True, it outputs only the option value. When False, it includes also @@ -636,73 +673,89 @@ def get_as_str(self) -> str: """Returns value as string. """ @abstractmethod - def get_value(self) -> T: + def get_value(self) -> T | None: """Returns current option value. """ @abstractmethod - def set_value(self, value: T) -> None: + def set_value(self, value: T | None) -> None: """Set new option value. Arguments: value: New option value. Raises: - TypeError: When the new value is of the wrong type. - ValueError: When the argument is not a valid option value. + TypeError: When the new value is not of the expected `datatype`. + ValueError: When the `value` content is invalid for the specific option type + (e.g., disallowed enum member, negative for unsigned int). """ @abstractmethod def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: - proto: Protobuf message that may contains options value. + proto: Protobuf message that may contain this option's value under `proto.options[self.name]`. Raises: - TypeError: When the new value is of the wrong type. - ValueError: When the argument is not a valid option value. + TypeError: If the protobuf field type is incompatible with the option. + ValueError: If the deserialized value content is invalid for the option. """ @abstractmethod def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize the current value into `ConfigProto` message. + + The value is stored in `proto.options[self.name]` using an appropriate + protobuf field type (e.g., `as_string`, `as_sint64`). If the current + value is `None`, nothing is saved for this option. Arguments: - proto: Protobuf message where option value should be stored. + proto: Protobuf message where the option value should be stored. """ class Config: - """Collection of configuration options. + """Collection of configuration options, potentially nested. + + Arguments: + name: Name associated with Config (default section name). + optional: Whether config is optional (True) or mandatory (False) for + configuration file (see `load_config()` for details). + description: Optional configuration description. Can span multiple lines. Important: Descendants must define individual options and sub configs as instance attributes. + + Attributes defined as instances of `Option` subclasses represent individual + configuration settings. Attributes defined as instances of `Config` subclasses + represent nested configuration sections with fixed names. Attributes defined as + `ConfigOption` or `ConfigListOption` allow for referring to nested sections + whose names (section headers) are themselves configurable. """ - def __init__(self, name: str, *, optional: bool=False, description: str=None): - """ - Arguments: - name: Name associated with Config (default section name). - optional: Whether config is optional (False) or mandatory (True) for - configuration file (see `.load_config()` for details). - description: Optional configuration description. Can span multiple lines. - """ + def __init__(self, name: str, *, optional: bool=False, description: str | None=None): self._name: str = name self._optional: bool = optional - self._description: str = description if description is not None else self.__doc__ - def __setattr__(self, name, value): + self._description: str | None = description if description is not None else self.__doc__ + def __setattr__(self, name, value) -> None: for attr in vars(self).values(): if isinstance(attr, Option) and attr.name == name: - raise ValueError('Cannot assign values to option itself, use `option.value` instead') + raise ValueError("Cannot assign values to option itself, use 'option.value' instead") super().__setattr__(name, value) def validate(self) -> None: - """Checks whether: - - all required options have value other than None. - - all options are defined as config attribute with the same name as option name + """Recursively validates all directly owned options and sub-configs. + + Checks whether: + - all required options have a non-`None` value. + - required `ConfigOption` values have a non-empty section name. + - required `ConfigListOption` values have a non-empty list. + - all options are defined as instance attributes with the same name as `option.name`. + - calls `validate()` on all nested `Config` instances (direct attributes, + values of `ConfigOption`, and items in `ConfigListOption`). - Raises exception when any constraint required by configuration is violated. + Raises: + Error: When any validation constraint is violated. """ for option in self.options: option.validate() if not hasattr(self, option.name): - raise Error(f"Option '{option.name}' is not defined as " - f"attribute with the same name") + raise Error(f"Option '{option.name}' is not defined as attribute with the same name") def clear(self, *, to_default: bool=True) -> None: """Clears all owned options and options in owned sub-configs. @@ -721,14 +774,20 @@ def get_description(self) -> str: return '' if self._description is None else self._description def get_config(self, *, plain: bool=False) -> str: """Returns string containing text lines suitable for use in configuration file - processed with `~configparser.ConfigParser`. + processed with [configparser.ConfigParser][]. + + Important: + When config is optional and the name is an empty string, it returns empty string. Arguments: plain: When True, it outputs only the option values. When False, it includes also option descriptions and other helpful information. """ - lines = [f'[{self.name}]\n', ';\n'] + if self.optional and not self.name: + return '' + lines = [f"[{self.name}]\n"] if not plain: + lines.append(';\n') for line in self.get_description().strip().splitlines(): lines.append(f"; {line}\n") for option in self.options: @@ -736,22 +795,33 @@ def get_config(self, *, plain: bool=False) -> str: lines.append('\n') lines.append(option.get_config(plain=plain)) for config in self.configs: - if not plain: - lines.append('\n') - lines.append(config.get_config(plain=plain)) + if subcfg := config.get_config(plain=plain): + if not plain: + lines.append('\n') + lines.append(subcfg) return ''.join(lines) - def load_config(self, config: ConfigParser, section: str=None) -> None: - """Update configuration. + def load_config(self, config: ConfigParser, section: str | None=None) -> None: + """Update configuration values from a `ConfigParser` instance. Arguments: - config: ConfigParser instance with configuration values. - section: Name of ConfigParser section that should be used to get new - configuration values. If not provided, uses `name`. + config: `ConfigParser` instance containing configuration values. + section: Name of the `ConfigParser` section corresponding to this `Config` + instance. If `None`, uses `self.name`. + + Behavior: + - Reads values for directly owned `Option` instances from the specified `section`. + - Recursively calls `load_config` on directly owned `Config` instances using + their respective `name` attribute as the section name. + - Recursively calls `load_config` on `Config` instances referenced by owned + `ConfigOption` and `ConfigListOption` values, using the section names + stored within those options. Raises: - ValueError: When any option value cannot be loadded. - KeyError: If section does not exists, and config is not `optional` or section is - not `configparser.DEFAULTSECT`. + Error: If `section` does not exist in `config` and `self.optional` is `False` + (unless `section` is `DEFAULTSECT`). Also wraps underlying [ValueError][] + or [KeyError][] from option parsing. + KeyError: Propagated if an invalid section name is used for a nested config. + ValueError: Propagated if an option string cannot be parsed correctly. """ if section is None: section = self.name @@ -770,7 +840,7 @@ def load_config(self, config: ConfigParser, section: str=None) -> None: except Exception as exc: # pragma: no cover raise Error(f"Configuration error: {exc}") from exc def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains option values and sub-configs. @@ -781,7 +851,7 @@ def load_proto(self, proto: ConfigProto) -> None: if subcfg.name in proto.configs: subcfg.load_proto(proto.configs[subcfg.name]) def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option values and sub-configs should be stored. @@ -798,22 +868,25 @@ def name(self) -> str: @property def optional(self) -> bool: """Whether config is optional (False) or mandatory (True) for configuration file - (see `.load_config()` for details). + (see `load_config()` for details). """ return self._optional @property - def options(self) -> List[Option]: - """List of options defined for this Config instance. - """ + def options(self) -> list[Option]: + """List of `Option` instances directly defined as attributes of this `Config` instance.""" return [v for v in vars(self).values() if isinstance(v, Option)] @property - def configs(self) -> List[Config]: - """List of sub-Configs defined for this Config instance. It includes all instance - attributes of `Config` type, and `Config` values of owned `ConfigOption` and - `ConfigListOption` instances. + def configs(self) -> list[Config]: + """List of nested `Config` instances associated with this instance. + + Includes: + + - `Config` instances directly assigned as attributes. + - The `Config` instance held by any `ConfigOption` attribute. + - All `Config` instances within the list held by any `ConfigListOption` attribute. """ result = [v if isinstance(v, Config) else v.value - for v in vars(self).values() if isinstance(v, (Config, ConfigOption))] + for v in vars(self).values() if isinstance(v, Config | ConfigOption)] for opt in (v for v in vars(self).values() if isinstance(v, ConfigListOption)): result.extend(opt.value) return result @@ -822,8 +895,11 @@ def configs(self) -> List[Config]: class StrOption(Option[str]): """Configuration option with string value. - .. versionadded:: 1.6.1 - Support for verticals to preserve leading whitespace. + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. Important: Multiline string values could contain significant leading whitespace, but @@ -833,16 +909,9 @@ class StrOption(Option[str]): characters that are between `|` and first non-whitespace character on first line starting with `|`. """ - def __init__(self, name: str, description: str, *, required: bool=False, default: str=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - """ - self._value: str = None - super().__init__(name, str, description, required, default) + def __init__(self, name: str, description: str, *, required: bool=False, default: str | None=None): + self._value: str | None = None + super().__init__(name, str, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: """Clears the option value. @@ -881,11 +950,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return self._value - def get_value(self) -> str: + def get_value(self) -> str | None: """Returns current option value. """ return self._value - def set_value(self, value: str) -> None: + def set_value(self, value: str | None) -> None: """Set new option value. Arguments: @@ -898,7 +967,7 @@ def set_value(self, value: str) -> None: self._check_value(value) self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -914,31 +983,30 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. """ if self._value is not None: proto.options[self.name].as_string = self._value - value: str = property(get_value, set_value, doc="Current option value") + value: str | None = property(get_value, set_value, doc="Current option value") class IntOption(Option[int]): """Configuration option with integer value. - """ - def __init__(self, name: str, description: str, *, required: bool=False, - default: int=None, signed: bool=False): - """ + Arguments: name: Option name. description: Option description. Can span multiple lines. required: True if option must have a value. default: Default option value. signed: When False, the option value cannot be negative. - """ - self._value: int = None + """ + def __init__(self, name: str, description: str, *, required: bool=False, + default: int | None=None, signed: bool=False): + self._value: int | None = None self.__signed: bool = signed - super().__init__(name, int, description, required, default) + super().__init__(name, int, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: """Clears the option value. @@ -967,11 +1035,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return str(self._value) - def get_value(self) -> int: + def get_value(self) -> int | None: """Returns current option value. """ return self._value - def set_value(self, value: int) -> None: + def set_value(self, value: int | None) -> None: """Set new option value. Arguments: @@ -986,7 +1054,7 @@ def set_value(self, value: int) -> None: raise ValueError("Negative numbers not allowed") self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -1005,7 +1073,7 @@ def load_proto(self, proto: ConfigProto) -> None: else: self.set_value(getattr(opt, oneof)) def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. @@ -1016,21 +1084,21 @@ def save_proto(self, proto: ConfigProto) -> None: opt.as_sint64 = self._value else: opt.as_uint64 = self._value - value: int = property(get_value, set_value, doc="Current option value") + value: int | None = property(get_value, set_value, doc="Current option value") class FloatOption(Option[float]): """Configuration option with float value. + + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. """ - def __init__(self, name: str, description: str, *, required: bool=False, default: float=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - """ - self._value: float = None - super().__init__(name, float, description, required, default) + def __init__(self, name: str, description: str, *, required: bool=False, + default: float | None=None): + self._value: float | None = None + super().__init__(name, float, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: """Clears the option value. @@ -1056,11 +1124,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return str(self._value) - def get_value(self) -> float: + def get_value(self) -> float | None: """Returns current option value. """ return self._value - def set_value(self, value: float) -> None: + def set_value(self, value: float | None) -> None: """Set new option value. Arguments: @@ -1073,7 +1141,7 @@ def set_value(self, value: float) -> None: self._check_value(value) self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -1092,28 +1160,28 @@ def load_proto(self, proto: ConfigProto) -> None: else: self.set_value(getattr(opt, oneof)) def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. """ if self._value is not None: proto.options[self.name].as_double = self._value - value: float = property(get_value, set_value, doc="Current option value") + value: float | None = property(get_value, set_value, doc="Current option value") class DecimalOption(Option[Decimal]): - """Configuration option with decimal.Decimal value. + """Configuration option with [decimal.Decimal][] value. + + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. """ - def __init__(self, name: str, description: str, *, required: bool=False, default: Decimal=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - """ - self._value: Decimal = None - super().__init__(name, Decimal, description, required, default) + def __init__(self, name: str, description: str, *, required: bool=False, + default: Decimal | None=None): + self._value: Decimal | None = None + super().__init__(name, Decimal, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: """Clears the option value. @@ -1137,16 +1205,16 @@ def set_as_str(self, value: str) -> None: try: self._value = Decimal(value) except DecimalException as exc: - raise ValueError(str(exc)) from exc + raise ValueError("Cannot convert string to Decimal") from exc def get_as_str(self) -> str: """Returns value as string. """ return str(self._value) - def get_value(self) -> Decimal: + def get_value(self) -> Decimal | None: """Returns current option value. """ return self._value - def set_value(self, value: Decimal) -> None: + def set_value(self, value: Decimal | None) -> None: """Set new option value. Arguments: @@ -1159,7 +1227,7 @@ def set_value(self, value: Decimal) -> None: self._check_value(value) self._value = value def load_proto(self, proto: ConfigProto): - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -1178,29 +1246,29 @@ def load_proto(self, proto: ConfigProto): else: self.set_value(Decimal(getattr(opt, oneof))) def save_proto(self, proto: ConfigProto): - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. """ if self._value is not None: proto.options[self.name].as_string = str(self._value) - value: Decimal = property(get_value, set_value, doc="Current option value") + value: Decimal | None = property(get_value, set_value, doc="Current option value") class BoolOption(Option[bool]): """Configuration option with boolean value. + + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. """ - def __init__(self, name: str, description: str, *, required: bool=False, default: bool=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - """ - self._value: bool = None + def __init__(self, name: str, description: str, *, required: bool=False, + default: bool | None=None): + self._value: bool | None = None self.from_str = get_convertor(bool).from_str - super().__init__(name, bool, description, required, default) + super().__init__(name, bool, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: """Clears the option value. @@ -1228,11 +1296,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return str(self._value) - def get_value(self) -> bool: + def get_value(self) -> bool | None: """Returns current option value. """ return self._value - def set_value(self, value: bool) -> None: + def set_value(self, value: bool | None) -> None: """Set new option value. Arguments: @@ -1245,7 +1313,7 @@ def set_value(self, value: bool) -> None: self._check_value(value) self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -1264,29 +1332,28 @@ def load_proto(self, proto: ConfigProto) -> None: else: self.set_value(opt.as_bool) def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. """ if self._value is not None: proto.options[self.name].as_bool = self._value - value: bool = property(get_value, set_value, doc="Current option value") + value: bool | None = property(get_value, set_value, doc="Current option value") class ZMQAddressOption(Option[ZMQAddress]): - """Configuration option with `.ZMQAddress` value. + """Configuration option with [firebird.base.types.ZMQAddress][] value. + + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. """ def __init__(self, name: str, description: str, *, required: bool=False, default: ZMQAddress=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - """ - self._value: ZMQAddress = None - super().__init__(name, ZMQAddress, description, required, default) + self._value: ZMQAddress | None = None + super().__init__(name, ZMQAddress, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: """Clears the option value. @@ -1312,11 +1379,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return self._value - def get_value(self) -> ZMQAddress: + def get_value(self) -> ZMQAddress | None: """Returns current option value. """ return self._value - def set_value(self, value: ZMQAddress) -> None: + def set_value(self, value: ZMQAddress | None) -> None: """Set new option value. Arguments: @@ -1329,7 +1396,7 @@ def set_value(self, value: ZMQAddress) -> None: self._check_value(value) self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -1345,34 +1412,33 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. """ if self._value is not None: proto.options[self.name].as_string = self._value - value: ZMQAddress = property(get_value, set_value, doc="Current option value") + value: ZMQAddress | None = property(get_value, set_value, doc="Current option value") -class EnumOption(Option[Enum]): +class EnumOption(Option[E], Generic[E]): """Configuration option with enum value. + + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. + allowed: List of allowed Enum members. When not defined, all members of enum type are + allowed. """ - def __init__(self, name: str, enum_class: Enum, description: str, *, required: bool=False, - default: Enum=None, allowed: List=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - allowed: List of allowed Enum members. When not defined, all members of enum type are - allowed. - """ - self._value: Enum = None - #: List of allowed enum values. - self.allowed: Sequence = enum_class if allowed is None else allowed - self._members: Dict = {i.name.lower(): i for i in self.allowed} - super().__init__(name, enum_class, description, required, default) + def __init__(self, name: str, enum_class: type[E], description: str, *, required: bool=False, + default: E | None=None, allowed: list | None=None): + self._value: E | None = None + self.allowed: Sequence[E] = enum_class if allowed is None else allowed + "List of allowed enum values." + self._members: dict = {i.name.lower(): i for i in self.allowed} + super().__init__(name, enum_class, description, required=required, default=default) def _get_value_description(self) -> str: return f"enum [{', '.join(x.name.lower() for x in self.allowed)}]\n" def clear(self, *, to_default: bool=True) -> None: @@ -1405,11 +1471,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return self._value.name - def get_value(self) -> Enum: + def get_value(self) -> E | None: """Returns current option value. """ return self._value - def set_value(self, value: Enum) -> None: + def set_value(self, value: E | None) -> None: """Set new option value. Arguments: @@ -1421,10 +1487,10 @@ def set_value(self, value: Enum) -> None: """ self._check_value(value) if value is not None and value not in self.allowed: - raise ValueError(f"Value '{value}' not allowed") + raise ValueError(f"Value '{value!r}' not allowed") self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains option value. @@ -1440,34 +1506,33 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. """ if self._value is not None: proto.options[self.name].as_string = self._value.name - value: Enum = property(get_value, set_value, doc="Current option value") + value: E | None = property(get_value, set_value, doc="Current option value") -class FlagOption(Option[Flag]): +class FlagOption(Option[F], Generic[F]): """Configuration option with flag value. + + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. + allowed: List of allowed Flag members. When not defined, all members of flag type are + allowed. """ - def __init__(self, name: str, flag_class: Flag, description: str, *, required: bool=False, - default: Flag=None, allowed: List=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - allowed: List of allowed Flag members. When not defined, all members of flag type are - allowed. - """ - self._value: Flag = None - #: List of allowed flag values. - self.allowed: Sequence = flag_class if allowed is None else allowed - self._members: Dict = {i.name.lower(): i for i in self.allowed} - super().__init__(name, flag_class, description, required, default) + def __init__(self, name: str, flag_class: type[F], description: str, *, required: bool=False, + default: F | None=None, allowed: list | None=None): + self._value: F | None = None + self.allowed: Sequence[F] = flag_class if allowed is None else allowed + "List of allowed flag values." + self._members: dict = {i.name.lower(): i for i in self.allowed} + super().__init__(name, flag_class, description, required=required, default=default) def _get_value_description(self) -> str: return f"flag [{', '.join(x.name.lower() for x in self.allowed)}]\n" def clear(self, *, to_default: bool=True) -> None: @@ -1506,11 +1571,11 @@ def get_as_str(self) -> str: if len(members) == 1 and members[0]._name_ is None: return f'{members[0]._value_}' return ' | '.join([str(m._name_ or m._value_) for m in members]) - def get_value(self) -> Flag: + def get_value(self) -> F | None: """Returns current option value. """ return self._value - def set_value(self, value: Flag) -> None: + def set_value(self, value: F | None) -> None: """Set new option value. Arguments: @@ -1527,7 +1592,7 @@ def set_value(self, value: Flag) -> None: raise ValueError(f"Illegal value '{value!s}' for flag option '{self.name}'") self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains option value. @@ -1546,28 +1611,28 @@ def load_proto(self, proto: ConfigProto) -> None: else: self.set_as_str(opt.as_string) def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. """ if self._value is not None: proto.options[self.name].as_uint64 = self._value.value - value: Flag = property(get_value, set_value, doc="Current option value") + value: F | None = property(get_value, set_value, doc="Current option value") class UUIDOption(Option[UUID]): - """Configuration option with UUID value. + """Configuration option with [uuid.UUID][] value. + + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. """ - def __init__(self, name: str, description: str, *, required: bool=False, default: UUID=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - """ - self._value: UUID = None - super().__init__(name, UUID, description, required, default) + def __init__(self, name: str, description: str, *, required: bool=False, + default: UUID | None=None): + self._value: UUID | None = None + super().__init__(name, UUID, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: """Clears the option value. @@ -1593,11 +1658,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return 'None' if self._value is None else self._value.hex - def get_value(self) -> UUID: + def get_value(self) -> UUID | None: """Returns current option value. """ return self._value - def set_value(self, value: UUID) -> None: + def set_value(self, value: UUID | None) -> None: """Set new option value. Arguments: @@ -1610,7 +1675,7 @@ def set_value(self, value: UUID) -> None: self._check_value(value) self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -1625,28 +1690,27 @@ def load_proto(self, proto: ConfigProto) -> None: else: self.set_value(UUID(opt.as_string)) def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. """ if self._value is not None: proto.options[self.name].as_bytes = self._value.bytes - value: UUID = property(get_value, set_value, doc="Current option value") + value: UUID | None = property(get_value, set_value, doc="Current option value") class MIMEOption(Option[MIME]): - """Configuration option with MIME type specification value. + """Configuration option with [firebird.base.types.MIME][] type specification value. + + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. """ def __init__(self, name: str, description: str, *, required: bool=False, default: MIME=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - """ - self._value: MIME = None - super().__init__(name, MIME, description, required, default) + self._value: MIME | None = None + super().__init__(name, MIME, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: """Clears the option value. @@ -1672,11 +1736,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return 'None' if self._value is None else self._value - def get_value(self) -> MIME: + def get_value(self) -> MIME | None: """Returns current option value. """ return self._value - def set_value(self, value: MIME) -> None: + def set_value(self, value: MIME | None) -> None: """Set new option value. Arguments: @@ -1689,7 +1753,7 @@ def set_value(self, value: MIME) -> None: self._check_value(value) self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -1701,52 +1765,54 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. """ if self._value is not None: proto.options[self.name].as_string = self._value - value: MIME = property(get_value, set_value, doc="Current option value") + value: MIME | None = property(get_value, set_value, doc="Current option value") -class ListOption(Option[List]): +class ListOption(Option[list]): """Configuration option with list of values. + Arguments: + name: Option name. + item_type: Datatype of list items. It could be a type or sequence of types. + If multiple types are provided, each value in config file must + have format: `type_name:value_as_str`. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. + separator: String that separates list item values when options value is read + from `ConfigParser`. It's possible to use a line break as separator. + If separator is `None` [default] and the value contains line breaks, + it uses the line break as separator, otherwise it uses comma as + separator. + Important: When option is read from `ConfigParser`, empty values are ignored. """ - def __init__(self, name: str, item_type: Union[Type, Sequence[Type]], description: str, - *, required: bool=False, default: List=None, separator: str=None): - """ - Arguments: - name: Option name. - item_type: Datatype of list items. It could be a type or sequence of types. - If multiple types are provided, each value in config file must - have format: `type_name:value_as_str`. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - separator: String that separates list item values when options value is read - from `ConfigParser`. It's possible to use a line break as separator. - If separator is `None` [default] and the value contains line breaks, - it uses the line break as separator, otherwise it uses comma as - separator. - """ - self._value: List = None - #: Datatypes of list items. If there is more than one type, each value in - #: config file must have format: `type_name:value_as_str`. - self.item_types: Sequence[Type] = (item_type, ) if isinstance(item_type, type) else item_type - #: String that separates list item values when options value is read from - #: `ConfigParser`. Default separator is None. It's possible to use a line break as - #: separator. If separator is `None` and the value contains line breaks, it uses - #: the line break as separator, otherwise it uses comma as separator. - self.separator: Optional[str] = separator - self._convertor: Convertor = get_convertor(item_type) if isinstance(item_type, type) else None - super().__init__(name, list, description, required, default) + def __init__(self, name: str, item_type: type | Sequence[type], description: str, + *, required: bool=False, default: list | None=None, separator: str | None=None): + self._value: list | None = None + self.item_types: Sequence[type] = item_type if isinstance(item_type, Sequence) else (item_type, ) + """Datatypes of list items. If there is more than one type, each value in + config file must have format: `type_name:value_as_str`.""" + self.separator: str | None = separator + """String that separates list item values when options value is read from + `ConfigParser`. Default separator is None. It's possible to use a line break as + separator. If separator is `None` and the value contains line breaks, it uses + the line break as separator, otherwise it uses comma as separator.""" + self._convertor: Convertor = get_convertor(item_type) if not isinstance(item_type, Sequence) else None + super().__init__(name, list, description, required=required, default=default) + # Value fixup, store copy of default list instead direct assignment + if default is not None: + self.set_value(list(default)) def _get_value_description(self) -> str: return f"list [{', '.join(x.__name__ for x in self.item_types)}]\n" - def _check_value(self, value: List) -> None: + def _check_value(self, value: list) -> None: super()._check_value(value) if value is not None: i = 0 @@ -1765,19 +1831,19 @@ def clear(self, *, to_default: bool=True) -> None: Arguments: to_default: If True, sets the option value to default value, else to None. """ - self._value = self.default if to_default else None + self._value = list(self.default) if to_default and self.default is not None else None def get_formatted(self) -> str: """Returns value formatted for use in config file. """ if self._value is None: return '' - result = [convert_to_str(i) for i in self._value] + result = [self._get_as_typed_str(i) for i in self._value] sep = self.separator if sep is None: - sep = '\n' if sum(len(i) for i in result) > 80 else ',' + sep = '\n' if sum(len(i) for i in result) > 80 else ',' # noqa: PLR2004 if sep == '\n': x = '\n ' - return f"\n {x.join(result)}" + return f'\n {x.join(result)}' return f'{sep} '.join(result) def set_as_str(self, value: str) -> None: """Set new option value from string. @@ -1799,7 +1865,7 @@ def set_as_str(self, value: str) -> None: fullname_map = {f'{cls.__module__}.{cls.__name__}': cls for cls in self.item_types} for item in (i for i in value.split(separator) if i.strip()): if name_map: - itype_name, item = item.split(':', 1) + itype_name, item = item.split(':', 1) # noqa: PLW2901 itype_name = itype_name.strip() itype = fullname_map.get(itype_name) if '.' in itype_name else name_map.get(itype_name) if itype is None: @@ -1810,16 +1876,16 @@ def set_as_str(self, value: str) -> None: def get_as_str(self) -> str: """Returns value as string. """ - result = [convert_to_str(i) for i in self._value] + result = [self._get_as_typed_str(i) for i in self._value] sep = self.separator if sep is None: - sep = '\n' if sum(len(i) for i in result) > 80 else ',' + sep = '\n' if sum(len(i) for i in result) > 80 else ',' # noqa: PLR2004 return sep.join(result) - def get_value(self) -> List: + def get_value(self) -> list | None: """Returns current option value. """ return self._value - def set_value(self, value: List) -> None: + def set_value(self, value: list | None) -> None: """Set new option value. Arguments: @@ -1832,7 +1898,7 @@ def set_value(self, value: List) -> None: self._check_value(value) self._value = None if value is None else list(value) def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -1848,7 +1914,7 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. @@ -1857,23 +1923,23 @@ def save_proto(self, proto: ConfigProto) -> None: result = [self._get_as_typed_str(i) for i in self._value] sep = self.separator if sep is None: - sep = '\n' if sum(len(i) for i in result) > 80 else ',' + sep = '\n' if sum(len(i) for i in result) > 80 else ',' # noqa: PLR2004 proto.options[self.name].as_string = sep.join(result) - value: List = property(get_value, set_value, doc="Current option value") + value: list = property(get_value, set_value, doc="Current option value") class PyExprOption(Option[PyExpr]): """String configuration option with Python expression value. + + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. """ - def __init__(self, name: str, description: str, *, required: bool=False, default: PyExpr=None): - self._value: PyExpr = None - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - """ - super().__init__(name, PyExpr, description, required, default) + def __init__(self, name: str, description: str, *, required: bool=False, + default: PyExpr | None=None): + self._value: PyExpr | None = None + super().__init__(name, PyExpr, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: """Clears the option value. @@ -1910,11 +1976,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return self._value - def get_value(self) -> PyExpr: + def get_value(self) -> PyExpr | None: """Returns current option value. """ return self._value - def set_value(self, value: PyExpr) -> None: + def set_value(self, value: PyExpr | None) -> None: """Set new option value. Arguments: @@ -1927,7 +1993,7 @@ def set_value(self, value: PyExpr) -> None: self._check_value(value) self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -1943,18 +2009,24 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. """ if self._value is not None: proto.options[self.name].as_string = self._value - value: PyExpr = property(get_value, set_value, doc="Current option value") + value: PyExpr | None = property(get_value, set_value, doc="Current option value") class PyCodeOption(Option[PyCode]): """String configuration option with Python code value. + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + required: True if option must have a value. + default: Default option value. + Important: Python code must be properly indented, but ConfigParser multiline string values have leading whitespace removed. To circumvent this, the `PyCodeOption` supports assignment @@ -1962,16 +2034,10 @@ class PyCodeOption(Option[PyCode]): with any number of subsequent whitespace characters that are between `|` and first non-whitespace character on first line starting with `|`. """ - def __init__(self, name: str, description: str, *, required: bool=False, default: PyCode=None): - self._value: PyCode = None - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - """ - super().__init__(name, PyCode, description, required, default) + def __init__(self, name: str, description: str, *, required: bool=False, + default: PyCode | None=None): + self._value: PyCode | None = None + super().__init__(name, PyCode, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: """Clears the option value. @@ -2009,11 +2075,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return self._value - def get_value(self) -> PyCode: + def get_value(self) -> PyCode | None: """Returns current option value. """ return self._value - def set_value(self, value: PyCode) -> None: + def set_value(self, value: PyCode | None) -> None: """Set new option value. Arguments: @@ -2026,7 +2092,7 @@ def set_value(self, value: PyCode) -> None: self._check_value(value) self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -2042,40 +2108,43 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. """ if self._value is not None: proto.options[self.name].as_string = self._value - value: PyCode = property(get_value, set_value, doc="Current option value") + value: PyCode | None = property(get_value, set_value, doc="Current option value") class PyCallableOption(Option[PyCallable]): """String configuration option with Python callable value. + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + signature: Callable signature, callable or string with callable signature (function header). + required: True if option must have a value. + default: Default option value. + Important: Python code must be properly indented, but `ConfigParser` multiline string values have - leading whitespace removed. To circumvent this, the `PyCodeOption` supports assignment + leading whitespace removed. To circumvent this, the `PyCallableOption` supports assignment of text values where lines start with `|` character. This character is removed, along with any number of subsequent whitespace characters that are between `|` and first non-whitespace character on first line starting with `|`. """ - # pylint: disable=[W0621] - def __init__(self, name: str, description: str, signature: Union[Signature, Callable], * , - required: bool=False, default: PyCallable=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - signature: Callable signature or callable. - required: True if option must have a value. - default: Default option value. - """ - self._value: PyCallable = None - #: Callable signature. - if not isinstance(signature, Signature): + def __init__(self, name: str, description: str, signature: Signature | Callable | str, + * , required: bool=False, default: PyCallable | None=None): + self._value: PyCallable | None = None + "Callable signature." + if isinstance(signature, str): + if not signature.startswith('def'): + signature = 'def ' + signature + signature += ': pass' if not signature.endswith(':') else ' pass' signature = Signature.from_callable(PyCallable(signature)._callable_) + elif not isinstance(signature, Signature): + signature = Signature.from_callable(signature) self.signature: Signature = signature super().__init__(name, PyCallable, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: @@ -2115,11 +2184,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return self._value - def get_value(self) -> PyCallable: + def get_value(self) -> PyCallable | None: """Returns current option value. """ return self._value - def set_value(self, value: PyCallable) -> None: + def set_value(self, value: PyCallable | None) -> None: """Set new option value. Arguments: @@ -2146,7 +2215,7 @@ def set_value(self, value: PyCallable) -> None: raise ValueError(f"Wrong parameter kind, parameter '{val_par.name}'") self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -2162,7 +2231,7 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. @@ -2172,35 +2241,39 @@ def save_proto(self, proto: ConfigProto) -> None: value: PyCallable = property(get_value, set_value, doc="Current option value") class ConfigOption(Option[str]): - """Configuration option with `Config` value. + """Option whose 'value' is a Config instance, but stores/parses its section name. - Important: - This option is intended for sub-configs that should have *configurable* name (i.e. the - section name that holds sub-config values). To create sub-configs with fixed section - names, simply assign them to instance attributes of `Config` instance that owns them - (preferably in constructor). + This allows having nested configuration sections where the section *name* + itself is configurable. The actual `Config` object must be passed during + initialization. The `value` property returns this `Config` object, while + methods like `set_as_str`, `get_as_str`, `get_formatted`, `load_proto`, + `save_proto` operate on the `Config` object's **name** (the section name). - While the `value` attribute for this option is an instance of any class inherited from - `Config`, in other ways it behaves like `StrOption` that loads/saves only name of its - `Config` value (i.e. the section name). The actual I/O for sub-config's options is - delegated to `Config` instance that owns this option. + Loading/saving the *contents* of the referenced `Config` object is handled + by the parent `Config`'s `load_config`/`save_proto` methods. + Arguments: + name: Option name. + description: Option description. Can span multiple lines. + config: Option's value. + required: True if option must have a value. + default: Default `Config.name` value. + + Important: + Assigning directly to the `value` property is not supported like other + options; use `set_as_str` or assign to the `Config` object's `.name` + attribute indirectly if needed (though typically done via `load_config`). + + Note: The "empty" value for this option is not `None` (because the `Config` instance always exists), but an empty string for `Config.name` attribute. """ - def __init__(self, name: str, description: str, config: Config, *, required: bool=False, - default: str=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - config: Option's value. - required: True if option must have a value. - default: Default `Config.name` value. - """ - assert isinstance(config, Config) + def __init__(self, name: str, config: Config, description: str, *, required: bool=False, + default: str | None=None): + assert isinstance(config, Config) # noqa: S101 self._value: Config = config - super().__init__(name, str, description, required, default) + config._optional = not required + super().__init__(name, str, description, required=required, default=default) def _get_value_description(self) -> str: return "configuration section name\n" def validate(self) -> None: @@ -2211,17 +2284,19 @@ def validate(self) -> None: """ if self.required and self.get_value().name == '': raise Error(f"Missing value for required option '{self.name}'") + if self.get_value().name != '': + self.value.validate() def clear(self, *, to_default: bool=True) -> None: """Clears the option value. Note: - This method calls `~Config.clear(to_default)`. + This method calls `Config.clear(to_default)`. Arguments: to_default: If True, sets the `Config.name` to default value, else to empty string. """ self._value.clear(to_default=to_default) - self._value.name = self.default if to_default else '' + self._value._name = self.default if to_default else '' def get_formatted(self) -> str: """Return value formatted for use in config file. @@ -2229,59 +2304,38 @@ def get_formatted(self) -> str: """ return self._value.name def set_as_str(self, value: str) -> None: - """Set new option value from string. + """Sets the section name for the associated `Config` instance. Arguments: - value: New `Config.name` value. - - Important: - Because the actual value is a `Config` instance, the string must contain the - `Config.name` value (which is the section name used to store `Config` options). - Beware that multiple Config instances with the same (section) name may cause - collision when configuration is written to protobuf message or configuration file. + value: The new section name (string). """ - self._value.name = value + self._value._name = value def get_as_str(self) -> str: - """Return value as string. - - Important: - Because the actual value is a `Config` instance, the returned string is the section - name used to store `Config` options. - """ + """Returns the current section name of the associated `Config` instance.""" return self._value.name def get_value(self) -> Config: - """Returns current option value. - """ + """Returns the associated `Config` instance itself.""" return self._value - def set_value(self, value: str) -> None: - """Set new option value. + def set_value(self, value: str | None) -> None: + """Sets the section name (indirectly). **Does not accept a Config object.** - This option type does not support direct assignment of `Config` value. Because this method - is also used to assign default value (which is a `Config.name`), it accepts None or string - argument that is interpreted as new Config name. `None` value is translated to empty string. + This method primarily handles setting the default section name during init. + Setting the name post-init is typically done via `load_config` or `set_as_str`. + Passing `None` sets the name to empty string (if not required). Arguments: - value: New `Config` name. + value: The new section name (string) or None. Raises: - TypeError: When the new value is of the wrong type. - ValueError: When None or empty string is passed and option value is required. + ValueError: If `value` is None or empty string and the option is required. """ if value is None: value = '' if value == '' and self.required: raise ValueError(f"Value is required for option '{self.name}'.") - self._value.name = value + self._value._name = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. - - Arguments: - proto: Protobuf message that may contains options value. - - Raises: - TypeError: When the new value is of the wrong type. - ValueError: When the argument is not a valid option value. - """ + """Deserialize section name from `proto.options[self.name].as_string`.""" if self.name in proto.options: opt = proto.options[self.name] if opt.HasField('as_string'): @@ -2289,90 +2343,164 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. - - Arguments: - proto: Protobuf message where option value should be stored. - """ + """Serialize section name into `proto.options[self.name].as_string`.""" if self._value is not None: proto.options[self.name].as_string = self._value.name value: Config = property(get_value, set_value, doc="Current option value") -class ConfigListOption(Option[List]): - """Configuration option with list of `Config` values. +class ConfigListOption(Option[list]): + """Option holding a list of Config instances, parsing/storing their section names. - Important: - This option is intended for configurable set of sub-configs of fixed type. + This option manages a list of `Config` objects, all of the **same** specified + `item_type`. However, in configuration files (`ConfigParser`) and Protobuf + messages, it stores and parses a *list of strings*, where each string is the + section name corresponding to one of the `Config` instances in the list. + + Loading/saving the *contents* (options) of each referenced `Config` section + is handled by the parent `Config`'s `load_config`/`save_proto` methods when + they iterate through the main configuration structure. This option itself + only deals with the list of *names* that identify which sections belong here. - While the `value` attribute for this option is a list of instances of single class - inherited from `Config`, in other ways it behaves like `ListOption` with `str` items - that loads/saves only names of its `Config` items (i.e. the section names). The actual - I/O for sub-config options is delegated to `Config` instance that owns this option. + When `set_as_str` or `load_config` processes the string list of names, it + creates new instances of `item_type` (the specified `Config` subclass) + for each name found. Important: - When option is read from `ConfigParser`, empty values are ignored. + When read from `ConfigParser`, empty values in the list of names are ignored. + + Arguments: + name: Option name identifying where the **list of section names** is stored. + item_type: The specific `Config` subclass for items in the list. All items + will be instances of this type. + description: Option description. Can span multiple lines. + required: If True, the list of section names cannot be empty. + separator: String separating section names in the config file value. + Handles line breaks automatically if `None`. See class docs. + + Example: + ```python + from firebird.base.config import Config, StrOption, ConfigListOption + from configparser import ConfigParser + import io + + class WorkerConfig(Config): + '''Configuration for a worker process.''' + def __init__(self, name: str): + super().__init__(name) + self.task_type = StrOption('task_type', 'Type of task', default='generic') + + class MainAppConfig(Config): + '''Main application configuration.''' + def __init__(self): + super().__init__('main_app') + self.workers = ConfigListOption('workers', WorkerConfig, + 'List of worker configurations (section names)') + ``` + ```ini + # --- Configuration File Content --- + config_data = ''' + [main_app] + workers = worker_alpha, worker_beta ; List of section names + + [worker_alpha] + task_type = processing + + [worker_beta] + task_type = reporting + ''' + ``` + ```python + # --- Loading --- + app_config = MainAppConfig() + parser = ConfigParser() + parser.read_string(config_data) + app_config.load_config(parser) # Loads 'workers' list and worker sections + + # --- Accessing --- + print(f"Worker section names: {app_config.workers.get_as_str()}") + # Output: Worker section names: worker_alpha, worker_beta + + worker_list = app_config.workers.value + print(f"Number of workers: {len(worker_list)}") # Output: 2 + print(f"First worker name: {worker_list[0].name}") # Output: worker_alpha + print(f"First worker task: {worker_list[0].task_type.value}") # Output: processing + print(f"Second worker task: {worker_list[1].task_type.value}") # Output: reporting + + # --- Getting Config String --- + # print(app_config.get_config()) would regenerate the structure + ``` """ - def __init__(self, name: str, description: str, item_type: Type[Config], *, - required: bool=False, separator: str=None): - """ - Arguments: - name: Option name. - description: Option description. Can span multiple lines. - item_type: Datatype of list items. Must be subclass of `Config`. - required: True if option must have a value. - separator: String that separates values when options value is read from `ConfigParser`. - It's possible to use a line break as separator. - If separator is `None` [default] and the value contains line breaks, it uses - the line break as separator, otherwise it uses comma as separator. - """ - assert issubclass(item_type, Config) - self._value: List = [] - #: Datatype of list items. - self.item_type: Type[Config] = item_type - #: String that separates values when options value is read from `ConfigParser`. - #: Default separator is None. It's possible to use a line break as separator. - #: If separator is `None` and the value contains line breaks, it uses the line - #: break as separator, otherwise it uses comma as separator. - self.separator: Optional[str] = separator - super().__init__(name, list, description, required, []) + def __init__(self, name: str, item_type: type[Config], description: str, *, + required: bool=False, separator: str | None=None): + assert issubclass(item_type, Config) # noqa: S101 + self._value: list = [] + self.item_type: type[Config] = item_type + "Datatype of list items." + self.separator: str | None = separator + """String that separates values when options value is read from `ConfigParser`. + Default separator is None. It's possible to use a line break as separator. + If separator is `None` and the value contains line breaks, it uses the line + break as separator, otherwise it uses comma as separator.""" + super().__init__(name, list, description, required=required, default=[]) def _get_value_description(self) -> str: - return "list of configuration section names\n" - def _check_value(self, value: List) -> None: - super()._check_value(value) + return f"list of configuration section names (for sections of type '{self.item_type.__name__}')\n" + def _check_value(self, value: list) -> None: + # Checks if 'value' is a list and all items are instances of self.item_type + super()._check_value(value) # Checks if it's a list (and None if required) if value is not None: - i = 0 - for item in value: - if item.__class__ is not self.item_type: - raise ValueError(f"List item[{i}] has wrong type") - i += 1 - def clear(self, *, to_default: bool=True) -> None: - """Clears the option value. + for i, item in enumerate(value): + if not isinstance(item, self.item_type): + raise ValueError(f"List item[{i}] has wrong type: " + f"Expected '{self.item_type.__name__}', " + f"got '{type(item).__name__}'") + def clear(self, *, to_default: bool=True) -> None: # noqa: ARG002 + """Clears the list of `Config` instances. Arguments: - to_default: If True, sets the option value to default value, else to None. + to_default: This parameter is ignored as there's no default list content. + The list is simply emptied. """ self._value.clear() - def get_formatted(self) -> str: - """Returns value formatted for use in config file. + def validate(self) -> None: + """Validates the option state. + + Checks if the list is non-empty if required. Calls `validate()` on each + `Config` instance currently in the list. + + Raises: + Error: When required and the list is empty, or if any contained + `Config` instance fails its own validation. """ - if self._value is None: + if self.required and not self._value: + raise Error(f"Missing value for required option '{self.name}'") + for item in self._value: + item.validate() + def get_formatted(self) -> str: + """Returns the list of section names formatted for use in a config file.""" + if not self._value: return '' result = [i.name for i in self._value] sep = self.separator if sep is None: - sep = '\n' if sum(len(i) for i in result) > 80 else ',' + sep = '\n' if sum(len(i) for i in result) > 80 else ',' # noqa: PLR2004 if sep == '\n': x = '\n ' - return f"\n {x.join(result)}" + return f'\n {x.join(result)}' return f'{sep} '.join(result) def set_as_str(self, value: str) -> None: - """Set new option value from string. + """Populates the list with new `Config` instances based on section names in string. + + Parses the input string `value` (using the defined `separator` logic) + to get a list of section names. For each non-empty name, creates a new + instance of `self.item_type` with that name and adds it to the internal list, + replacing any previous list content. Arguments: - value: New option value. + value: String containing separator-defined list of section names. Raises: - ValueError: When the argument is not a valid option value. + ValueError: If the string parsing encounters issues (though typically just + results in fewer items if format is odd). """ new = [] if value.strip(): @@ -2381,26 +2509,27 @@ def set_as_str(self, value: str) -> None: new.append(self.item_type(item.strip())) self._value = new def get_as_str(self) -> str: - """Returns value as string. - """ + """Returns the list of contained section names as a separator-joined string.""" result = [i.name for i in self._value] sep = self.separator if sep is None: - sep = '\n' if sum(len(i) for i in result) > 80 else ',' + sep = '\n' if sum(len(i) for i in result) > 80 else ', ' # noqa: PLR2004 return sep.join(result) - def get_value(self) -> List: - """Returns current option value. - """ + def get_value(self) -> list: + """Returns the current list of `Config` instances.""" return self._value - def set_value(self, value: List) -> None: - """Set new option value. + def set_value(self, value: list | None) -> None: + """Sets the list of `Config` instances. + + Replaces the current list with the provided one. Ensures all items in the + new list are of the correct `item_type`. Passing `None` clears the list. Arguments: - value: New option value. + value: A new list of `Config` instances (must be of `self.item_type`), or `None`. Raises: - TypeError: When the new value is of the wrong type. - ValueError: When the argument is not a valid option value. + TypeError: If `value` is not a list or contains items of the wrong type. + ValueError: If `value` is None or empty and the option is required. """ self._check_value(value) if value is None: @@ -2408,15 +2537,7 @@ def set_value(self, value: List) -> None: else: self._value = list(value) def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. - - Arguments: - proto: Protobuf message that may contains options value. - - Raises: - TypeError: When the new value is of the wrong type. - ValueError: When the argument is not a valid option value. - """ + """Deserialize list of section names from `proto.options[self.name].as_string`.""" if self.name in proto.options: opt = proto.options[self.name] if opt.HasField('as_string'): @@ -2424,69 +2545,115 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. - - Arguments: - proto: Protobuf message where option value should be stored. - """ + """Serialize list of section names into `proto.options[self.name].as_string`.""" result = [i.name for i in self._value] sep = self.separator if sep is None: - sep = '\n' if sum(len(i) for i in result) > 80 else ',' + sep = '\n' if sum(len(i) for i in result) > 80 else ',' # noqa: PLR2004 proto.options[self.name].as_string = sep.join(result) - value: List = property(get_value, set_value, doc="Current option value") + value: list = property(get_value, set_value, doc="Current option value") class DataclassOption(Option[Any]): - """Configuration option with a dataclass value. - - The `ConfigParser` format for this option is a list of values, where each list items - defines value for dataclass field in `field_name:value_as_str` format. The configuration - must contain values for all fields for the dataclass that does not have default value. + """Configuration option holding an instance of a Python dataclass. - Important: - This option uses type annotation for dataclass to determine the actual data type for - conversion from string. It means that: + Parses configuration from a string representation where each field of the + dataclass is defined on its own line or separated by a defined `separator`. + The format for each field within the string is `field_name: value_as_str`. - 1. If type annotation contains "typing" types, it's necessary to specify "real" types - for all dataclass fields using the `fields` argument. - 2. All used data types must have string convertors registered in `strconv` module. + Relies on the [firebird.base.strconv][] module to convert the `value_as_str` + part for each field into the appropriate Python type based on the dataclass's + type hints or the explicitly provided `fields` mapping. Important: - When option is read from `ConfigParser`, empty values are ignored. + - Ensure type hints in the dataclass are concrete types (or provide the + `fields` mapping) and that `strconv` has registered convertors for all + field types used. + - When read from `ConfigParser`, empty field definitions in the value string + might be ignored or cause errors depending on parsing. + + Arguments: + name: Option name. + dataclass: The dataclass type this option holds an instance of. + description: Option description. + required: If True, the option must have a value (cannot be None). + default: Default instance of the dataclass. + separator: String separating `field:value` pairs in the config file string. + Handles line breaks automatically if `None`. See class docs. + fields: Optional override mapping field names to types. Useful if type hints + are complex or need overriding. If None, uses `get_type_hints`. + + Example: + ```python + from dataclasses import dataclass, field + from firebird.base.config import Config, DataclassOption + from firebird.base.strconv import register_convertor # If custom types needed + from configparser import ConfigParser + import io + + @dataclass + class DBInfo: + host: str + user: str + port: int = 5432 # Field with default + ssl_mode: bool = field(default=False) + + class AppSettings(Config): + def __init__(self): + super().__init__('app') + self.database = DataclassOption('database', DBInfo, + 'Database connection details') + ``` + ```ini + # --- Configuration File Content --- + config_data = ''' + [app] + database = + host: db.example.com + user: app_user + port: 15432 + ''' + # Note: ssl_mode uses its default (False) as it's not specified. + ``` + ```python + # --- Loading --- + app_config = AppSettings() + parser = ConfigParser() + parser.read_string(config_data) + app_config.load_config(parser) + + # --- Accessing --- + db_info = app_config.database.value + print(f"Is DBInfo instance: {isinstance(db_info, DBInfo)}") # Output: True + print(f"DB Host: {db_info.host}") # Output: db.example.com + print(f"DB Port: {db_info.port}") # Output: 15432 (overrode default) + print(f"DB User: {db_info.user}") # Output: app_user + print(f"DB SSL: {db_info.ssl_mode}") # Output: False (used default) + + # --- Getting Config String --- + # print(app_config.get_config()) would regenerate the structure + ``` """ - def __init__(self, name: str, dataclass: Type, description: str, *, required: bool=False, - default: Any=None, separator: str=None, fields: Dict[str, Type]=None): - """ - Arguments: - name: Option name. - dataclass: Dataclass type. - description: Option description. Can span multiple lines. - required: True if option must have a value. - default: Default option value. - separator: String that separates dataclass field values when options value is read - from `ConfigParser`. It's possible to use a line break as separator. - If separator is `None` [default] and the value contains line breaks, it - uses the line break as separator, otherwise it uses comma as separator. - fields: Dictionary that maps dataclass field names to data types. - """ - assert hasattr(dataclass, '__dataclass_fields__') - self._fields: Dict[str, Type] = get_type_hints(dataclass) if fields is None else fields + def __init__(self, name: str, dataclass: type, description: str, *, required: bool=False, + default: Any | None=None, separator: str | None=None, fields: dict[str, type] | None=None): + assert hasattr(dataclass, '__dataclass_fields__') # noqa: S101 + self._fields: dict[str, type] = get_type_hints(dataclass) if fields is None else fields if __debug__: for ftype in self._fields.values(): - assert get_convertor(ftype) is not None + assert get_convertor(ftype) is not None # noqa: S101 self._value: Any = None - #: Dataclass type. - self.dataclass: Type = dataclass - #: String that separates dataclass field values when options value is read from - #: `ConfigParser`. Default separator is None. It's possible to use a line break - #: as separator. If separator is `None` and the value contains line breaks, it - #: uses the line break as separator, otherwise it uses comma as separator. - self.separator: Optional[str] = separator - super().__init__(name, dataclass, description, required, default) + self.dataclass: type = dataclass + "Dataclass type." + self.separator: str | None = separator + """String that separates dataclass field values when options value is read from + `ConfigParser`. Default separator is None. It's possible to use a line break + as separator. If separator is `None` and the value contains line breaks, it + uses the line break as separator, otherwise it uses comma as separator.""" + super().__init__(name, dataclass, description, required=required, default=default) def _get_value_description(self) -> str: - return "list of values, where each list item defines value for a dataclass field.\n" \ - "Item format: field_name:value_as_str\n" - def _get_str_fields(self) -> List[str]: + return """list of values, where each list item defines value for a dataclass field. +Item format: field_name:value_as_str +""" + def _get_str_fields(self) -> list[str]: result = [] if self._value is not None: for fname in self._fields: @@ -2507,19 +2674,28 @@ def get_formatted(self) -> str: result = self._get_str_fields() sep = self.separator if sep is None: - sep = '\n' if sum(len(i) for i in result) > 80 else ',' + sep = '\n' if sum(len(i) for i in result) > 80 else ',' # noqa: PLR2004 if sep == '\n': x = '\n ' - return f"\n {x.join(result)}" + return f'\n {x.join(result)}' return f'{sep} '.join(result) def set_as_str(self, value: str) -> None: - """Set new option value from string. + """Creates and sets the dataclass instance from its string representation. + + Parses the `value` string expecting `field_name: value_as_str` items, + separated according to the `separator` logic. Uses `strconv` to convert + each `value_as_str` to the required field type. Finally, instantiates + the dataclass using the parsed field values. Arguments: - value: New option value. + value: String containing the dataclass representation. Raises: - ValueError: When the argument is not a valid option value. + ValueError: If the string format is invalid, a field name is unknown, + a value cannot be converted by `strconv`, or the resulting + dictionary of values cannot instantiate the dataclass + (e.g., missing required fields without defaults). + TypeError: If `strconv` conversion fails with a type error. """ new = {} if value.strip(): @@ -2535,45 +2711,35 @@ def set_as_str(self, value: str) -> None: raise ValueError(f"Unknown data field '{field_name}' for option '{self.name}'") convertor = get_convertor(ftype) new[field_name] = convertor.from_str(ftype, field_value.strip()) - try: - new_val = self.dataclass(**new) - except Exception as exc: - raise ValueError(f"Illegal value '{value}' for option '{self.name}'") from exc + try: + new_val = self.dataclass(**new) + except Exception as exc: + raise ValueError(f"Illegal value '{value}' for option '{self.name}'") from exc self._value = new_val def get_as_str(self) -> str: - """Returns value as string. - """ + """Returns the string representation of the current dataclass value.""" result = self._get_str_fields() sep = self.separator if sep is None: - sep = '\n' if sum(len(i) for i in result) > 80 else ',' + sep = '\n' if sum(len(i) for i in result) > 80 else ',' # noqa: PLR2004 return sep.join(result) def get_value(self) -> Any: - """Returns current option value. - """ + """Returns the current dataclass instance (or None).""" return self._value def set_value(self, value: Any) -> None: - """Set new option value. + """Sets the option value to the provided dataclass instance. Arguments: - value: New option value. + value: An instance of the option's `dataclass` type, or `None`. Raises: - TypeError: When the new value is of the wrong type. - ValueError: When the argument is not a valid option value. + TypeError: If `value` is not None and not an instance of the expected `dataclass`. + ValueError: If `value` is None and the option is required. """ self._check_value(value) self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. - - Arguments: - proto: Protobuf message that may contains options value. - - Raises: - TypeError: When the new value is of the wrong type. - ValueError: When the argument is not a valid option value. - """ + """Deserialize dataclass from `proto.options[self.name].as_string`.""" if self.name in proto.options: opt = proto.options[self.name] if opt.HasField('as_string'): @@ -2581,32 +2747,28 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. - - Arguments: - proto: Protobuf message where option value should be stored. - """ + """Serialize dataclass into `proto.options[self.name].as_string`.""" if self._value is not None: result = self._get_str_fields() sep = self.separator if sep is None: - sep = '\n' if sum(len(i) for i in result) > 80 else ',' + sep = '\n' if sum(len(i) for i in result) > 80 else ',' # noqa: PLR2004 proto.options[self.name].as_string = sep.join(result) value: Any = property(get_value, set_value, doc="Current option value") class PathOption(Option[str]): - """Configuration option with `pathlib.Path` value. - """ - def __init__(self, name: str, description: str, *, required: bool=False, default: Path=None): - """ + """Configuration option with [pathlib.Path][] value. + Arguments: name: Option name. description: Option description. Can span multiple lines. required: True if option must have a value. default: Default option value. - """ - self._value: Path = None - super().__init__(name, Path, description, required, default) + """ + def __init__(self, name: str, description: str, *, required: bool=False, + default: Path | None=None): + self._value: Path | None = None + super().__init__(name, Path, description, required=required, default=default) def clear(self, *, to_default: bool=True) -> None: """Clears the option value. @@ -2632,11 +2794,11 @@ def get_as_str(self) -> str: """Returns value as string. """ return str(self._value) - def get_value(self) -> Path: + def get_value(self) -> Path | None: """Returns current option value. """ return self._value - def set_value(self, value: Path) -> None: + def set_value(self, value: Path | None) -> None: """Set new option value. Arguments: @@ -2649,7 +2811,7 @@ def set_value(self, value: Path) -> None: self._check_value(value) self._value = value def load_proto(self, proto: ConfigProto) -> None: - """Deserialize value from `.ConfigProto` message. + """Deserialize value from `ConfigProto` message. Arguments: proto: Protobuf message that may contains options value. @@ -2665,7 +2827,7 @@ def load_proto(self, proto: ConfigProto) -> None: else: raise TypeError(f"Wrong value type: {opt.WhichOneof('kind')[3:]}") def save_proto(self, proto: ConfigProto) -> None: - """Serialize value into `.ConfigProto` message. + """Serialize value into `ConfigProto` message. Arguments: proto: Protobuf message where option value should be stored. diff --git a/src/firebird/base/config_pb2.py b/src/firebird/base/config_pb2.py index 3dce762..3726013 100644 --- a/src/firebird/base/config_pb2.py +++ b/src/firebird/base/config_pb2.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: firebird/base/config.proto +# Protobuf Python Version: 4.25.1 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -12,26 +13,26 @@ from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2 +from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1a\x66irebird/base/config.proto\x12\rfirebird.base\x1a\x19google/protobuf/any.proto\"\xf0\x01\n\x05Value\x12\x13\n\tas_string\x18\x02 \x01(\tH\x00\x12\x12\n\x08\x61s_bytes\x18\x03 \x01(\x0cH\x00\x12\x11\n\x07\x61s_bool\x18\x04 \x01(\x08H\x00\x12\x13\n\tas_double\x18\x05 \x01(\x01H\x00\x12\x12\n\x08\x61s_float\x18\x06 \x01(\x02H\x00\x12\x13\n\tas_sint32\x18\x07 \x01(\x11H\x00\x12\x13\n\tas_sint64\x18\x08 \x01(\x12H\x00\x12\x13\n\tas_uint32\x18\t \x01(\rH\x00\x12\x13\n\tas_uint64\x18\n \x01(\x04H\x00\x12&\n\x06\x61s_msg\x18\x0b \x01(\x0b\x32\x14.google.protobuf.AnyH\x00\x42\x06\n\x04kind\"\x93\x02\n\x0b\x43onfigProto\x12\x38\n\x07options\x18\x01 \x03(\x0b\x32\'.firebird.base.ConfigProto.OptionsEntry\x12\x38\n\x07\x63onfigs\x18\x02 \x03(\x0b\x32\'.firebird.base.ConfigProto.ConfigsEntry\x1a\x44\n\x0cOptionsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12#\n\x05value\x18\x02 \x01(\x0b\x32\x14.firebird.base.Value:\x02\x38\x01\x1aJ\n\x0c\x43onfigsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.firebird.base.ConfigProto:\x02\x38\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1a\x66irebird/base/config.proto\x12\rfirebird.base\x1a\x19google/protobuf/any.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xf0\x01\n\x05Value\x12\x13\n\tas_string\x18\x02 \x01(\tH\x00\x12\x12\n\x08\x61s_bytes\x18\x03 \x01(\x0cH\x00\x12\x11\n\x07\x61s_bool\x18\x04 \x01(\x08H\x00\x12\x13\n\tas_double\x18\x05 \x01(\x01H\x00\x12\x12\n\x08\x61s_float\x18\x06 \x01(\x02H\x00\x12\x13\n\tas_sint32\x18\x07 \x01(\x11H\x00\x12\x13\n\tas_sint64\x18\x08 \x01(\x12H\x00\x12\x13\n\tas_uint32\x18\t \x01(\rH\x00\x12\x13\n\tas_uint64\x18\n \x01(\x04H\x00\x12&\n\x06\x61s_msg\x18\x0b \x01(\x0b\x32\x14.google.protobuf.AnyH\x00\x42\x06\n\x04kind\"\x93\x02\n\x0b\x43onfigProto\x12\x38\n\x07options\x18\x01 \x03(\x0b\x32\'.firebird.base.ConfigProto.OptionsEntry\x12\x38\n\x07\x63onfigs\x18\x02 \x03(\x0b\x32\'.firebird.base.ConfigProto.ConfigsEntry\x1a\x44\n\x0cOptionsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12#\n\x05value\x18\x02 \x01(\x0b\x32\x14.firebird.base.Value:\x02\x38\x01\x1aJ\n\x0c\x43onfigsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.firebird.base.ConfigProto:\x02\x38\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'firebird.base.config_pb2', _globals) if _descriptor._USE_C_DESCRIPTORS == False: - DESCRIPTOR._options = None - _CONFIGPROTO_OPTIONSENTRY._options = None - _CONFIGPROTO_OPTIONSENTRY._serialized_options = b'8\001' - _CONFIGPROTO_CONFIGSENTRY._options = None - _CONFIGPROTO_CONFIGSENTRY._serialized_options = b'8\001' - _globals['_VALUE']._serialized_start=73 - _globals['_VALUE']._serialized_end=313 - _globals['_CONFIGPROTO']._serialized_start=316 - _globals['_CONFIGPROTO']._serialized_end=591 - _globals['_CONFIGPROTO_OPTIONSENTRY']._serialized_start=447 - _globals['_CONFIGPROTO_OPTIONSENTRY']._serialized_end=515 - _globals['_CONFIGPROTO_CONFIGSENTRY']._serialized_start=517 - _globals['_CONFIGPROTO_CONFIGSENTRY']._serialized_end=591 + _globals['_CONFIGPROTO_OPTIONSENTRY']._options = None + _globals['_CONFIGPROTO_OPTIONSENTRY']._serialized_options = b'8\001' + _globals['_CONFIGPROTO_CONFIGSENTRY']._options = None + _globals['_CONFIGPROTO_CONFIGSENTRY']._serialized_options = b'8\001' + _globals['_VALUE']._serialized_start=103 + _globals['_VALUE']._serialized_end=343 + _globals['_CONFIGPROTO']._serialized_start=346 + _globals['_CONFIGPROTO']._serialized_end=621 + _globals['_CONFIGPROTO_OPTIONSENTRY']._serialized_start=477 + _globals['_CONFIGPROTO_OPTIONSENTRY']._serialized_end=545 + _globals['_CONFIGPROTO_CONFIGSENTRY']._serialized_start=547 + _globals['_CONFIGPROTO_CONFIGSENTRY']._serialized_end=621 # @@protoc_insertion_point(module_scope) diff --git a/src/firebird/base/config_pb2.pyi b/src/firebird/base/config_pb2.pyi index 30ab9fa..f44ec31 100644 --- a/src/firebird/base/config_pb2.pyi +++ b/src/firebird/base/config_pb2.pyi @@ -1,4 +1,5 @@ from google.protobuf import any_pb2 as _any_pb2 +from google.protobuf import struct_pb2 as _struct_pb2 from google.protobuf.internal import containers as _containers from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message @@ -7,7 +8,7 @@ from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Opti DESCRIPTOR: _descriptor.FileDescriptor class Value(_message.Message): - __slots__ = ["as_string", "as_bytes", "as_bool", "as_double", "as_float", "as_sint32", "as_sint64", "as_uint32", "as_uint64", "as_msg"] + __slots__ = ("as_string", "as_bytes", "as_bool", "as_double", "as_float", "as_sint32", "as_sint64", "as_uint32", "as_uint64", "as_msg") AS_STRING_FIELD_NUMBER: _ClassVar[int] AS_BYTES_FIELD_NUMBER: _ClassVar[int] AS_BOOL_FIELD_NUMBER: _ClassVar[int] @@ -31,16 +32,16 @@ class Value(_message.Message): def __init__(self, as_string: _Optional[str] = ..., as_bytes: _Optional[bytes] = ..., as_bool: bool = ..., as_double: _Optional[float] = ..., as_float: _Optional[float] = ..., as_sint32: _Optional[int] = ..., as_sint64: _Optional[int] = ..., as_uint32: _Optional[int] = ..., as_uint64: _Optional[int] = ..., as_msg: _Optional[_Union[_any_pb2.Any, _Mapping]] = ...) -> None: ... class ConfigProto(_message.Message): - __slots__ = ["options", "configs"] + __slots__ = ("options", "configs") class OptionsEntry(_message.Message): - __slots__ = ["key", "value"] + __slots__ = ("key", "value") KEY_FIELD_NUMBER: _ClassVar[int] VALUE_FIELD_NUMBER: _ClassVar[int] key: str value: Value def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[Value, _Mapping]] = ...) -> None: ... class ConfigsEntry(_message.Message): - __slots__ = ["key", "value"] + __slots__ = ("key", "value") KEY_FIELD_NUMBER: _ClassVar[int] VALUE_FIELD_NUMBER: _ClassVar[int] key: str diff --git a/src/firebird/base/hooks.py b/src/firebird/base/hooks.py index 09efec2..e5a3736 100644 --- a/src/firebird/base/hooks.py +++ b/src/firebird/base/hooks.py @@ -33,103 +33,169 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________ -"""Firebird Base - Hook manager - -This module provides a general framework for callbacks and "hookable" events. +"""This module provides a general framework for callbacks and "hookable" events, +implementing a variation of the publish-subscribe pattern. It allows different +parts of an application to register interest in events triggered by specific +objects or classes and execute custom code (callbacks) when those events occur. """ from __future__ import annotations -from typing import Union, Any, Type, Dict, List, Set, Callable, cast + +from collections.abc import Callable +from dataclasses import dataclass, field from enum import Enum, Flag, auto +from typing import Any, cast from weakref import WeakKeyDictionary -from dataclasses import dataclass, field -from .types import Distinct, ANY, Singleton + from .collections import Registry +from .types import ANY, Distinct, Singleton + @dataclass(order=True, frozen=True) class Hook(Distinct): - """Hook registration info. + """Represents a registered hook subscription. + + Instances of this class store the details of a callback registered + for a specific event and source combination within the `HookManager`. + + Arguments: + event: The specific event this hook subscribes to (can be `ANY`). + cls: The specific class this hook targets. `ANY` if targeting an instance/name directly or globally. + instance: The specific instance or instance name this hook targets. `ANY` if targeting a class or globally. + callbacks: A list of callable functions to be executed when the specified event occurs for the specified source. """ - #: Event identification event: Any - #: Hookable class + "The specific event this hook subscribes to (can be `ANY`)." cls: type = ANY - #: Instance of registered hookable class + "The specific class this hook targets. `ANY` if targeting an instance/name directly or globally." instance: Any = ANY - #: List of callbacks - callbacks: List[Callable] = field(default_factory=list) + "The specific instance or instance name this hook targets. `ANY` if targeting a class or globally." + callbacks: list[Callable] = field(default_factory=list) + "A list of callable functions to be executed when the specified event occurs for the specified source." def get_key(self) -> Any: - """Returns hook key. + """Returns the unique key for this hook registration used by the Registry. + + The key is a tuple of (event, class, instance/name). """ return (self.event, self.cls, self.instance) class HookFlag(Flag): - """Internally used flags. + """Internal flags used by HookManager to optimize callback lookups. + + These flags track the *types* of registrations present (e.g., if any + instance hooks, class hooks, or ANY_EVENT hooks exist) to potentially + speed up `get_callbacks` by avoiding unnecessary checks. """ NONE = 0 + "NONE value" INSTANCE = auto() + "A hook targets a specific object instance" CLASS = auto() + "A hook targets a class (applies to all instances)" NAME = auto() + "A hook targets a registered instance name" ANY_EVENT = auto() + "A hook targets ANY event" + ANY_SOURCE = auto() + "A hook targets ANY source" class HookManager(Singleton): - """Hook manager. + """Manages the registration and retrieval of hooks (callbacks). + + This singleton class acts as the central registry for hookable classes, + named instances, and the hooks themselves. It provides methods to add, + remove, and retrieve callbacks based on event and source specifications. """ def __init__(self): - self.obj_map: WeakKeyDictionary = WeakKeyDictionary() - self.hookables: Dict[Type, Set[Any]] = {} + self.obj_map: WeakKeyDictionary[Any, str] = WeakKeyDictionary() + self.hookables: dict[type, set[Any]] = {} self.hooks: Registry = Registry() self.flags: HookFlag = HookFlag.NONE - def _update_flags(self, event: Any, cls: Any, obj: Any) -> None: + def _update_flags(self, event: Any, cls: type, obj: Any) -> None: if event is ANY: self.flags |= HookFlag.ANY_EVENT + if cls is ANY and obj is ANY: + self.flags |= HookFlag.ANY_SOURCE if cls is not ANY: self.flags |= HookFlag.CLASS if obj is not ANY: - if isinstance(obj, str): - self.flags |= HookFlag.NAME - else: - self.flags |= HookFlag.INSTANCE - def register_class(self, cls: Type, events: Union[Type[Enum], Set]=None) -> None: - """Register hookable class. + self.flags |= HookFlag.NAME if isinstance(obj, str) else HookFlag.INSTANCE + def register_class(self, cls: type, events: type[Enum] | set | None=None) -> None: + """Register a class as being capable of generating hookable events. + + Registration is necessary for validation when adding hooks and potentially + for optimizing callback lookups. Arguments: - cls: Class that supports hooks. - events: Supported events. + cls: The class that acts as an event source. + events: The set of events this class (and its instances) can trigger. + Can be specified using an `~enum.Enum` type (recommended), + a `set` of event identifiers, or `None` if events are not + statically known or validated at registration time. - Events could be specified using an `~enum.Enum` type or set of event identificators. - When Enum is used (recommended), all enum values are registered as hookable events. + Raises: + TypeError: If `events` is provided but is not an Enum type or a set. """ - if isinstance(events, type) and issubclass(events, Enum): - events = set(events.__members__.values()) - self.hookables[cls] = events + event_set = set() + if events is not None: + if isinstance(events, type) and issubclass(events, Enum): + event_set = set(events.__members__.values()) + elif isinstance(events, set): + event_set = events + else: + raise TypeError("`events` must be an Enum type or a set") + self.hookables[cls] = event_set # Store the processed set def register_name(self, instance: Any, name: str) -> None: - """Associate name with hookable instance. + """Associate a unique string name with an instance of a hookable class. + + This allows registering hooks specifically for this named instance using + the name string as the `source`. Arguments: - instance: Instance of registered hookable class. - name: Unique name assigned to instance. + instance: An instance of a class previously registered via `register_class`. + name: A unique string name to assign to the instance. + + Raises: + TypeError: If `instance` is not an instance of any registered hookable class. """ if not isinstance(instance, tuple(self.hookables.keys())): raise TypeError("The instance is not of hookable type") self.obj_map[instance] = name def add_hook(self, event: Any, source: Any, callback: Callable) -> None: - """Add new hook. + """Register a callback function (hook) for a specific event and source. Arguments: - event: Event identificator. - source: Hookable class or instance, or instance name. - callback: Callback function. + event: The event identifier the callback subscribes to. Can be `ANY` + to subscribe to all events from the specified source. + source: The source of the event. Can be: + + - A hookable class (registered via `register_class`): The callback + will trigger for this event from *any* instance of this class. + - An instance of a hookable class: The callback will trigger + only for this event from **this specific** instance. + - A string name (registered via `register_name`): The callback + will trigger only for this event from the instance associated + with this name. + - `ANY`: The callback will trigger for this event from *any* source. + callback: The function or method to be called when the event occurs. Important: - The signature of `callback` must conform to requirements for particular hookable event. + The signature of the `callback` function must match the signature expected + by the code that *triggers* the event (the event provider). This framework + does not enforce signature matching; it's the responsibility of the event + provider and consumer documentation. Raises: - TypeError: When `subject` is not registered as hookable. - ValueError: When `event` is not supported by specified `subject`. + TypeError: If `source` is a class/instance type not registered as hookable, + or if `source` is not a class, instance, name, or `ANY`. + ValueError: If `event` is not `ANY` and is not declared as a supported event + by the specified `source` class (during `register_class`). """ - cls = obj = ANY - if isinstance(source, type): + cls: type = ANY + obj: Any = ANY + if source is ANY: + pass + elif isinstance(source, type): if source in self.hookables: cls = source if event is not ANY: @@ -161,26 +227,28 @@ def add_hook(self, event: Any, source: Any, callback: Callable) -> None: hook: Hook = self.hooks[key] if key in self.hooks else self.hooks.store(Hook(*key)) hook.callbacks.append(callback) def remove_hook(self, event: Any, source: Any, callback: Callable) -> None: - """Remove hook callback installed by `add_hook()`. + """Remove a previously registered hook callback. Arguments: - event: Event identificator. - source: Hookable class or instance. - callback: Callback function. + event: The event identifier used when registering the hook. + source: The hookable class, instance, name, or `ANY` used when registering. + callback: The specific callback function instance that was registered. Important: - For successful removal, the argument values must be exactly the same as used in - `add_hook()` call. + To successfully remove a hook, all arguments (`event`, `source`, `callback`) + must *exactly* match the values used in the original `add_hook()` call. + Comparing function objects requires using the **same** function object. - The method does nothing if described hook is not installed. + This method does nothing if no matching hook registration is found. """ - cls = obj = ANY + cls: type = ANY + obj: Any = ANY if isinstance(source, type): cls = source else: obj = source key = (event, cls, obj) - hook: Hook = self.hooks.get(key) + hook: Hook | None = self.hooks.get(key) if hook is not None: hook.callbacks.remove(callback) if not hook.callbacks: @@ -199,14 +267,49 @@ def reset(self) -> None: self.remove_all_hooks() self.hookables.clear() self.obj_map.clear() - def get_callbacks(self, event: Any, source: Any) -> List: - """Returns list of all callbacks installed for specified event and hookable subject. + def get_callbacks(self, event: Any, source: Any) -> list: + """Return a list of all callbacks applicable to the specified event and source. + + The method searches for matching hook registrations based on the provided + `event` and `source`, considering class hierarchy, registered names, and + the `ANY` sentinel for broader matches. Arguments: - event: Event identificator. - source: Hookable class or instance, or name. + event: The specific event identifier being triggered. + source: The source triggering the event. Can be: + + - An instance of a hookable class. + - A hookable class itself (e.g., for class-level events). + - A registered string name. + + Note: + Using `ANY` as the `source` here is generally not meaningful, + as event triggers typically originate from a specific source. + + Returns: + A list of `Callable` objects. The order reflects the lookup process but + is not guaranteed between different calls or manager states. + + Lookup Logic: + The returned list aggregates callbacks from registrations matching: + + 1. Specific Instance: Hooks registered for (`event`, `ANY`, `source` instance). + 2. Specific Name: Hooks for (`event`, `ANY`, `source` name) if `source` instance + has a registered name. + 3. Specific Class: Hooks for (`event`, `cls`, `ANY`) for every class `cls` in + the `source` instance's Method Resolution Order (MRO) that is registered. + 4. ANY Event on Instance: Hooks for (`ANY`, `ANY`, `source` instance). + 5. ANY Event on Name: Hooks for (`ANY`, `ANY`, `source` name`) if applicable. + 6. ANY Event on Class: Hooks for (`ANY`, `cls`, `ANY`) for applicable classes `cls` + in the MRO. + 7. ANY Source: Hooks for (`event`, `ANY`, `ANY`) and (`ANY`, `ANY`, `ANY`). + + Note: + If `source` is a class or name directly, only relevant parts of the + above logic apply. """ - result = [] + result: list[Callable] = [] + hook: Hook | None if isinstance(source, type): if HookFlag.CLASS in self.flags: if (hook := self.hooks.get((event, source, ANY))) is not None: @@ -221,31 +324,47 @@ def get_callbacks(self, event: Any, source: Any) -> List: result.extend(cast(Hook, hook).callbacks) else: if HookFlag.INSTANCE in self.flags: - if (hook := self.hooks.get((event, ANY, source))) is not None: - result.extend(cast(Hook, hook).callbacks) - if HookFlag.ANY_EVENT in self.flags and (hook := self.hooks.get((ANY, ANY, source))) is not None: - result.extend(cast(Hook, hook).callbacks) - if HookFlag.NAME in self.flags and (name := self.obj_map.get(source)) is not None: - if (hook := self.hooks.get((event, ANY, name))) is not None: - result.extend(cast(Hook, hook).callbacks) - if HookFlag.ANY_EVENT in self.flags and (hook := self.hooks.get((ANY, ANY, name))) is not None: - result.extend(cast(Hook, hook).callbacks) + try: + hash(source) + except TypeError: + pass # An unhashable source cannot have an instance-specific hook. + else: + if (hook := self.hooks.get((event, ANY, source))) is not None: + result.extend(cast(Hook, hook).callbacks) + if HookFlag.ANY_EVENT in self.flags and (hook := self.hooks.get((ANY, ANY, source))) is not None: + result.extend(cast(Hook, hook).callbacks) + if HookFlag.NAME in self.flags: + try: + name = self.obj_map.get(source) + except TypeError: + name = None # The source cannot be a weak key and has no registered name. + if name is not None: + if (hook := self.hooks.get((event, ANY, name))) is not None: + result.extend(cast(Hook, hook).callbacks) + if HookFlag.ANY_EVENT in self.flags and (hook := self.hooks.get((ANY, ANY, name))) is not None: + result.extend(cast(Hook, hook).callbacks) if HookFlag.CLASS in self.flags: for cls in (c for c in self.hookables if isinstance(source, c)): if (hook := self.hooks.get((event, cls, ANY))) is not None: result.extend(cast(Hook, hook).callbacks) if HookFlag.ANY_EVENT in self.flags and (hook := self.hooks.get((ANY, cls, ANY))) is not None: result.extend(cast(Hook, hook).callbacks) + if HookFlag.ANY_SOURCE in self.flags: + if (hook := self.hooks.get((event, ANY, ANY))) is not None: + result.extend(cast(Hook, hook).callbacks) + if event is not ANY and HookFlag.ANY_EVENT in self.flags \ + and (hook := self.hooks.get((ANY, ANY, ANY))) is not None: + result.extend(cast(Hook, hook).callbacks) return result -#: Hook manager hook_manager: HookManager = HookManager() +"Hook manager singleton instance." -#: shortcut for `hook_manager.register_class()` register_class = hook_manager.register_class -#: shortcut for `hook_manager.register_name()` +"Shortcut for `hook_manager.register_class()`" register_name = hook_manager.register_name -#: shortcut for `hook_manager.add_hook()` +"shortcut for `hook_manager.register_name()`" add_hook = hook_manager.add_hook -#: shortcut for `hook_manager.get_callbacks()` +"shortcut for `hook_manager.add_hook()`" get_callbacks = hook_manager.get_callbacks +"shortcut for `hook_manager.get_callbacks()`" diff --git a/src/firebird/base/logging.py b/src/firebird/base/logging.py index 7dd21f2..6a6544b 100644 --- a/src/firebird/base/logging.py +++ b/src/firebird/base/logging.py @@ -33,22 +33,45 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________ -"""firebird-base - Context-based logging +"""This module provides context-based logging system built on top of standard `logging` module. +It also solves the common logging management problem when various modules use hard-coded +separate loggers, and provides several types of message wrappers that allow lazy message +interpolation using f-string, brace (`str.format`) or dollar (`string.Template`) formats. +The context-based logging: + +1. Adds context information into `logging.LogRecord`, that could be used in logging entry formats. +2. Allows assignment of loggers to specific contexts. + +This module also provides message wrapper classes (`FStrMessage`, `BraceMessage`, +`DollarMessage`) that defer string formatting until the log record is actually +processed by a handler. This avoids the performance cost of formatting messages +that might be filtered out due to log levels. """ from __future__ import annotations -from typing import Any, Dict, Tuple, Union, Hashable -from enum import IntEnum, Flag, auto -from collections.abc import Mapping -from dataclasses import dataclass -from logging import Logger, LoggerAdapter, getLogger, lastResort, Formatter -from platform import python_version_tuple -from .types import UNDEFINED, DEFAULT, ANY, ALL, Distinct, CachedDistinct, Sentinel -from .collections import Registry + +import logging +from collections.abc import Callable, Iterable, Mapping +from enum import Enum, IntEnum +from typing import Any + + +class FormatElement(Enum): + """Sentinels used within `LoggingManager.logger_fmt` list.""" + DOMAIN = 1 + TOPIC = 2 + +DOMAIN: FormatElement = FormatElement.DOMAIN +"Sentinel representing the domain element in `LoggingManager.logger_fmt`." +TOPIC: FormatElement = FormatElement.TOPIC +"Sentinel representing the topic element in `LoggingManager.logger_fmt`." class LogLevel(IntEnum): - """Shadow enumeration for logging levels. + """Mirrors standard `logging` levels for convenience and type hinting. + + Provides symbolic names (e.g., `LogLevel.DEBUG`) corresponding to the + integer values used by the standard `logging` module (`logging.DEBUG`). """ NOTSET = 0 DEBUG = 10 @@ -59,263 +82,437 @@ class LogLevel(IntEnum): FATAL = CRITICAL WARN = WARNING -class BindFlag(Flag): - """Internal flags used by `LoggingManager`. +class FStrMessage: + """Lazy logging message wrapper using f-string semantics via `eval`. + + Defers the evaluation of the f-string until the message is actually + formatted by a handler, improving performance if the message might be + filtered out by log level settings. + + Danger: + Uses `eval()` internally. Ensure the format string and arguments + do not contain untrusted user input. + + Example: + ```python + logger.debug(FStrMessage("Processing item {item_id} for user {user!r}", + item_id=123, user="Alice")) + # Formatting only happens if DEBUG level is enabled for the logger/handler. + ``` """ - DIRECT = auto() - ANY_AGENT = auto() - ANY_CTX = auto() - ANY_ANY = auto() - -class FBLoggerAdapter(LoggerAdapter, CachedDistinct): - """`~logging.LoggerAdapter` that injects information about context, agent and topic - into `extra` and with **f-string** log message support. + def __init__(self, fmt: str, /, *args, **kwargs): + self.fmt: str = fmt + self.args: tuple[Any, ...] = args + self.kwargs: dict[str, Any] = kwargs + if (args and len(args) == 1 and isinstance(args[0], Mapping) and args[0]): + self.kwargs = args[0] + else: + self.kwargs = kwargs + if args: + self.kwargs['args'] = args + def __str__(self) -> str: + return eval(f'f"""{self.fmt}"""', globals(), self.kwargs) # noqa: S307 + +class BraceMessage: + """Lazy logging message wrapper using brace (`str.format`) style formatting. + + Defers the call to `str.format()` until the message is actually formatted + by a handler, improving performance for potentially filtered messages. + + Example: + ```python + logger.warning(BraceMessage("Connection failed: host={0}, port={1}", + 'server.com', 8080)) + logger.warning(BraceMessage(("Message with coordinates: ({point.x:.2f}, {point.y:.2f})", + point=point)) + ``` """ - def __init__(self, logger: Logger, agent: Any=UNDEFINED, context: Any=UNDEFINED, topic: str=''): - """ - Arguments: - logger: Adapted Logger instance. - agent: Agent for logger - context: Context for logger - topic: Topic of recorded information. - """ - #: Adapted Logger instance. - self.logger: Logger = logger - #: Agent for logger. - self.agent: Any = agent - #: Context for logger. - self.context: Any = context - #: Topic for logger. - self.topic: str = topic - @classmethod - def extract_key(cls, *args, **kwargs) -> Hashable: - """Returns instance key extracted from constructor arguments. - """ - return (args[1], args[2]) - def get_key(self) -> Hashable: # pragma: no cover - """Returns instance key. - """ - return (self.topic, self.agent, self.context) - def process(self, msg, kwargs) -> Tuple[str, Dict]: - """Process the logging message and keyword arguments passed into - a logging call to insert contextual information. You can either - manipulate the message itself, the keyword args or both. Return - the message and kwargs modified (or not) to suit your needs. - """ - return msg, kwargs - def log(self, level, msg, *args, **kwargs): - """Delegate a log call to the underlying logger after processing. + def __init__(self, fmt: str, /, *args, **kwargs): + self.fmt: str = fmt + self.args: tuple[Any, ...] = args + self.kwargs: dict[str, Any] = kwargs + def __str__(self) -> str: + return self.fmt.format(*self.args, **self.kwargs) - Interpolates the message as **f-string** using either `kwargs` or dict passed as - only one positional argument. If sole positional argument is not dictionary or - `args` has more than one item, adds `args` into namespace for interpolation. +class DollarMessage: + """Lazy logging message wrapper using dollar (`string.Template`) style formatting. - Moves 'context', 'agent' and 'topic' keyword arguments into `extra`. + Defers the substitution using `string.Template` until the message is actually + formatted by a handler, improving performance for potentially filtered messages. - Strips out all keyword arguments not expected by `logging.Logger`. - """ - if self.isEnabledFor(level): - msg, kwargs = self.process(msg, kwargs) - if (args and len(args) == 1 and isinstance(args[0], Mapping) and args[0]): - ns = args[0] - else: - ns = kwargs - if args: - ns['args'] = args - msg = eval(f'f"""{msg}"""', globals(), ns) - args = () - if 'stacklevel' not in kwargs: - kwargs['stacklevel'] = 3 if int(python_version_tuple()[1]) < 11 else 2 - kwargs.setdefault('extra', {}).update(topic=self.topic, agent=self.agent, - context=self.context) - self.logger.log(level, msg, *args, **{k: v for k, v in kwargs.items() - if k in ['exc_info', 'stack_info', - 'stacklevel', 'extra']}) - -@dataclass(order=True, frozen=True) -class BindInfo(Distinct): - """Information about Logger binding. + Example: + ```python + from string import Template # Not strictly needed for caller + logger.info(DollarMessage("Task $name completed with status $status", + name='Cleanup', status='Success')) + ``` """ - topic: str - agent: str - context: str - logger: FBLoggerAdapter - def get_key(self) -> Any: - "Returns distinct key value = Tuple(topic, agent, context)." - return (self.topic, self.agent, self.context) - -def get_logging_id(obj: Any) -> Any: - """Returns logging ID for object. - - Arguments: - obj: Any object - - Returns: - 1. `logging_id` attribute if `obj` does have it, or.. - 2. `__qualname__` attribute if `obj` does have it, or.. - 3. `str(obj)` + def __init__(self, fmt: str, /, **kwargs): + self.fmt: str = fmt + self.kwargs: dict[str, Any] = kwargs + def __str__(self) -> str: + from string import Template + return Template(self.fmt).substitute(**self.kwargs) + +class ContextFilter(logging.Filter): + """Logging filter ensuring context fields exist on `LogRecord` instances. + + Checks for `domain`, `topic`, `agent`, and `context` attributes on each + log record. If any are missing (e.g., for records from standard loggers + not using `ContextLoggerAdapter`), it adds them with a value of `None`. + + Usage: + Attach an instance of this filter to `logging.Handler` objects to ensure + formatters expecting these fields do not raise `AttributeError`. + + Example: + ```python + handler = logging.StreamHandler() + handler.addFilter(ContextFilter()) + # ... add handler to logger ... + ``` """ - return getattr(obj, 'logging_id', getattr(obj, '__qualname__', str(obj))) + def filter(self, record) -> bool: + for attr in ('domain', 'topic', 'agent', 'context'): + if not hasattr(record, attr): + setattr(record, attr, None) + return True + +class ContextLoggerAdapter(logging.LoggerAdapter): + """Logger adapter injecting context (`domain`, `topic`, `agent`, `context`) info. -class LoggingIdMixin: - """Mixin class that adds `logging_id` property and `__str__` that returns `logging_id`. + Wraps a standard [logging.Logger][]. When a logging method (e.g., `info`, `debug`) + is called, it adds the context information into the `extra` dictionary, making + it available as attributes on the resulting [logging.LogRecord][]. + + Parameters: + logger: The standard `logging.Logger` instance to wrap. + domain: Context Domain name (or None). + topic: Context Topic name (or None). + agent: The original agent object or string passed to `get_logger`. + agent_name: The resolved string name for the agent. """ - def __str__(self): - return self.logging_id - @property - def logging_id(self) -> str: - """Returns `_logging_id_` attribute if defined, else returns qualified class name. + def __init__(self, logger, domain: str | None, topic: str | None, agent: Any, agent_name: str): + self.agent = agent + super().__init__(logger, + {'domain': domain, + 'topic': topic, + 'agent': agent_name} + ) + def process(self, msg: Any, kwargs: dict[str, Any]) -> tuple[Any, dict[str, Any]]: + """Process the logging message and keyword arguments passed in to + a logging call to insert contextual information. + + - Ensures `self.extra` contains `domain`, `topic`, and `agent` (from init). + - Adds `context` to `self.extra`, taking it from `self.agent.log_context` + if available, otherwise `None`. + - Merges the adapter's `extra` dictionary with any `extra` dictionary + passed in `kwargs`, giving precedence to keys in `kwargs['extra']`. + - Stores the final merged `extra` dictionary into `kwargs['extra']`. + + Returns: + The possibly modified `msg` and `kwargs`. """ - return getattr(self, '_logging_id_', self.__class__.__qualname__) + if 'context' not in self.extra: + self.extra['context'] = getattr(self.agent, 'log_context', None) + #if "stacklevel" not in kwargs: + #kwargs["stacklevel"] = 1 + kwargs['extra'] = dict(self.extra, **kwargs['extra']) if 'extra' in kwargs else self.extra + return msg, kwargs class LoggingManager: - """Logger manager. + """Logging manager. """ def __init__(self): - self.loggers: Registry = Registry() - self.topics: Dict[str, int] = {} - self.bindings: BindFlag = BindFlag(0) - def _update_bindings(self, agent: Any, context: Any) -> None: - if agent is ANY: - self.bindings |= BindFlag.ANY_AGENT - if context is ANY: - self.bindings |= BindFlag.ANY_CTX - if (agent is ANY) and (context is ANY): - self.bindings |= BindFlag.ANY_ANY - if (agent is not ANY) and (context is not ANY): - self.bindings |= BindFlag.DIRECT - def _update_topics(self, topic: str) -> None: - if topic in self.topics: - self.topics[topic] += 1 + self._agent_domain_map: dict[str, str] = {} + self._domain_agent_map: dict[str, set] = {} + self._topic_map: dict[str, str] = {} + self._agent_map: dict[str, str] = {} + self.__logger_fmt: list[str | FormatElement] = [] + self.__default_domain: str | None = None + self._logger_factory: Callable = logging.getLogger + def get_logger_factory(self) -> Callable: + """Return a callable which is used to create a Logger. + """ + return self._logger_factory + def set_logger_factory(self, factory) -> None: + """Set a callable which is used to create a Logger. + + Parameters: + factory: The factory callable to be used to instantiate a logger. + + The factory has the following signature: `factory(name, *args, **kwargs)` + """ + self._logger_factory = factory + def reset(self) -> None: + """Resets manager to "factory defaults": no mappings, no `logger_fmt` and undefined + `default_domain`. + """ + self._agent_domain_map.clear() + self._domain_agent_map.clear() + self._topic_map.clear() + self._agent_map.clear() + self.__logger_fmt.clear() + self.__default_domain = None + @property + def logger_fmt(self) -> list[str | FormatElement]: + """Logger format. + + The list can contain any number of string values and at most one occurrence of `DOMAIN` + or `TOPIC` enum values. Empty strings are removed. + + The final `logging.Logger` name is constructed by joining elements of this list with + dots, and with sentinels replaced with `domain` and `topic` names. + + Example: + ```python + logger_fmt = ['app', DOMAIN, TOPIC] + domain = 'database' + topic = 'trace' + + Logger name will be: "app.database.trace" + ``` + """ + return self.__logger_fmt + @logger_fmt.setter + def logger_fmt(self, value: list[str | FormatElement]) -> None: + """Sets the logger name format list. + + Validates the list to ensure it contains only non-empty strings and + at most one `DOMAIN` and one `TOPIC` sentinel. + + :param value: The list defining the logger name format. + :raises ValueError: If the format list is invalid (e.g., multiple DOMAINs). + """ + def validated(seq): + domain_found = False + topic_found = False + for item in seq: + match item: + case x if isinstance(x, str): + if x: + yield item + case FormatElement.DOMAIN: + if domain_found: + raise ValueError("Only one occurence of sentinel DOMAIN allowed") + domain_found = True + yield item + case FormatElement.TOPIC: + if topic_found: + raise ValueError("Only one occurence of sentinel TOPIC allowed") + topic_found = True + yield item + case _: + raise ValueError(f"Unsupported item type {type(item)}") + + self.__logger_fmt = list(validated(value)) + @property + def default_domain(self) -> str | None: + """Default domain. Could be either a string or `None`. + + Important: + When assigned, it does not validate the value type, but converts it to string. + """ + return self.__default_domain + @default_domain.setter + def default_domain(self, value: str | None) -> None: + self.__default_domain = None if value is None else str(value) + def _get_logger_name(self, domain: str | None, topic: str | None) -> str: + """Returns `logging.Logger` name. + """ + result = [] + for item in self.logger_fmt: + match item: + case x if isinstance(x, str): + result.append(item) + case x if x is DOMAIN: + if domain: + result.append(domain) + case x if x is TOPIC: + if topic: + result.append(topic) + return '.'.join(result) + def set_topic_mapping(self, topic: str, new_topic: str | None) -> None: + """Sets or removes the mapping of an topic name to another name. + + Arguments: + topic: Topic name. + new_topic: Either `None` or new topic name. + + - When `new_topic` is a string, it maps `topic` to `new_topic`. Empty string is + like `None`. + - When `new_topic` is `None`, it removes any mapping. + + Important: + Does not validate the `new_topic` value type, instead it's converted to string. + """ + if new_topic: + self._topic_map[topic] = str(new_topic) else: - self.topics[topic] = 1 - def bind_logger(self, agent: Any, context: Any, logger: Union[str, Logger], topic: str='') -> None: - """Bind agent and context to specific logger. + self._topic_map.pop(topic, None) + def get_topic_mapping(self, topic: str) -> str | None: + """Returns current name mapping for topic. Arguments: - agent: Agent identification - context: Context identification - logger: Loger (instance or name) - topic: Topic of recorded information + topic: Topic name. - The identification of agent and context could be: + Returns: + Reassigned topic name or `None`. + """ + return self._topic_map.get(topic) + def get_agent_name(self, agent: Any) -> str: + """Determine the canonical string name for a given agent identifier. - 1. String - 2. Object instance. Uses `get_logging_id()` to retrieve its logging ID. - 3. Sentinel. The ANY sentinel matches any particular agent or context. You can - use sentinel `.UNDEFINED` to register a logger for cases when agent or - context are not specified in logger lookup. + Parameters: + agent: Agent identifier (string, or object). - Important: - You SHOULD NOT use sentinel `.ALL` for `agent` or `context` identification! This - sentinel is used by `.unbind()`, so bindings that use ALL could not be removed - by `.unbind()`. + Returns: + The resolved agent name (string). + + Logic: + 1. If `agent` is a string, it's used directly. + 2. If `agent` is an object: + - Uses `agent._agent_name_` if defined (converting to string if needed). + - Otherwise, constructs name as `module.ClassQualname`. + 3. Applies any agent name mapping defined via `set_agent_mapping` to the + name determined in steps 1 or 2. + 4. Ensures the final result is a string. + + Example: + ``` + > from firebird.base.logging import logging_manager + > logging_manager.get_agent_name(logging_manager) + 'firebird.base.logging.LoggingManager' + ``` """ - if isinstance(logger, str): - logger = getLogger(logger) - if not isinstance(agent, (str, Sentinel)): - agent = get_logging_id(agent) - if not isinstance(context, (str, Sentinel)): - context = get_logging_id(context) - if agent is not ANY and context is not ANY: - logger = FBLoggerAdapter(logger, agent, context) - self._update_bindings(agent, context) - self._update_topics(topic) - self.loggers.update(BindInfo(topic, agent, context, logger)) - def unbind(self, agent: Any, context: Any, topic: str='') -> int: - """Drops logger bindings. + agent_name: Any = agent + if not isinstance(agent, str): + if not (agent_name := getattr(agent, '_agent_name_', None)): + agent_name = f'{agent.__class__.__module__}.{agent.__class__.__qualname__}' + agent_name = self._agent_map.get(agent_name, agent_name) + return str(agent_name) + def set_agent_mapping(self, agent: str, new_agent: str | None) -> None: + """Sets or removes the mapping of an agent name to another name. + + Parameters: + agent: Agent name. + new_agent: New agent name or `None` to remove the mapping. Empty string is like `None`. + + Important: + Does not validate the `new_agent` value type, instead it's converted to string. """ - if not isinstance(agent, (str, Sentinel)): - agent = get_logging_id(agent) - if not isinstance(context, (str, Sentinel)): - context = get_logging_id(context) - if topic in self.topics: - rm = [i for i in self.loggers - if i.topic == topic and ((i.agent == agent) or agent is ALL) - and ((i.context == context) or context is ALL)] - for item in rm: - self.loggers.remove(item) - # recalculate optimizations - self.topics.clear() - self.bindings = BindFlag(0) - for item in self.loggers: - self._update_bindings(item.agent, item.context) - self._update_topics(item.topic) - return len(rm) - return 0 - def clear(self) -> None: - """Remove all logger bindings. + if new_agent: + self._agent_map[agent] = str(new_agent) + else: + self._agent_map.pop(agent, None) + def get_agent_mapping(self, agent: str) -> str | None: + """Returns current name mapping for agent. + + Arguments: + agent: Agent name. + + Returns: + Reassigned agent name or `None`. """ - self.loggers.clear() - self.topics.clear() - self.bindings = BindFlag(0) - def get_logger(self, agent: Any=UNDEFINED, context: Any=DEFAULT, topic: str='') -> FBLoggerAdapter: - """Return a logger for the specified agent and context combination. + return self._agent_map.get(agent) + def get_agent_domain(self, agent: str) -> str | None: + """Returns domain name assigned to agent. Arguments: - agent: Agent identification. - context: Context identification. - topic: Topic of recorded information. + agent: Agent name. - The identification of agent and context could be: + Returns: + Domain assigned to agent or `None`. + """ + return self._agent_domain_map.get(agent) + def set_domain_mapping(self, domain: str, agents: Iterable[str] | str | None, *, + replace: bool=False) -> None: + """Sets, updates, or removes agent name mappings to a domain. - 1. String - 2. Object instance. Uses `get_logging_id()` to retrieve its logging ID. - 3. Sentinel `.UNDEFINED` - 4. When `context` is sentinel `.DEFAULT`, uses `agent` attribute `log_context` - (if defined) or sentinel `.UNDEFINED` otherwise. + Parameters: + domain: Domain name. + agents: Iterable with agent names, single agent name, or `None`. + replace: When True, the new mapping replaces the current one, otherwise the mapping is updated. - The search for a suitable topic logger proceeds as follows: + Important: + Passing `None` to `agents` removes all agent mappings for specified domain, + regardless of `replace` value. + """ + # Remove agents that are already mapped + if agents is not None: + for agent in set([agents] if isinstance(agents, str) else agents): + current_domain = self._agent_domain_map.pop(agent, None) + if current_domain: + self._domain_agent_map[current_domain].discard(agent) + if not self._domain_agent_map[current_domain]: + del self._domain_agent_map[current_domain] + if (replace or agents is None) and domain in self._domain_agent_map: + for agent in self._domain_agent_map[domain]: + del self._agent_domain_map[agent] + if agents is None: + del self._domain_agent_map[domain] + return + if replace or domain not in self._domain_agent_map: + self._domain_agent_map[domain] = set() + agents = set([agents] if isinstance(agents, str) else agents) + self._domain_agent_map[domain].update(agents) + for agent in agents: + self._agent_domain_map[agent] = domain + def get_domain_mapping(self, domain: str) -> set[str] | None: + """Returns current agent mapping for domain. + + Arguments: + domain: Domain name. - 1. Return logger registered for specified agent and context, or... - 2. Return logger registered for ANY agent and specified context, or... - 3. Return logger registered for specified agent and ANY context, or... - 4. Return logger registered for ANY agent and ANY context, or... - 5. Return the root logger. + Returns: + Set of agent names assigned to domain or `None`. """ - if context is DEFAULT: - context = getattr(agent, 'log_context', UNDEFINED) - if agent is not UNDEFINED and not isinstance(agent, str): - agent = get_logging_id(agent) - if context is not UNDEFINED and not isinstance(context, str): - context = get_logging_id(context) - result: BindInfo = None - if topic in self.topics: - if BindFlag.DIRECT in self.bindings and \ - (result := self.loggers.get((topic, agent, context))) is not None: - result = result.logger - elif BindFlag.ANY_AGENT in self.bindings and \ - (result := self.loggers.get((topic, ANY, context))) is not None: - result = result.logger - elif BindFlag.ANY_CTX in self.bindings and \ - (result := self.loggers.get((topic, agent, ANY))) is not None: - result = result.logger - elif BindFlag.ANY_ANY in self.bindings and \ - (result := self.loggers.get((topic, ANY, ANY))) is not None: - result = result.logger - else: - result = getLogger(topic) - else: - result = getLogger(topic) - return result if isinstance(result, FBLoggerAdapter) \ - else FBLoggerAdapter(result, agent, context, topic) + return self._domain_agent_map.get(domain) + def get_logger(self, agent: Any, topic: str | None=None) -> ContextLoggerAdapter: + """Get a ContextLoggerAdapter configured for the specified agent and topic. -#: Logging Manager -logging_manager: LoggingManager = LoggingManager() + This is the primary function for obtaining a logger in the context logging system. + It determines the appropriate underlying `logging.Logger` based on the agent's + domain and the topic, then wraps it in a `ContextLoggerAdapter` to inject + context information. -#: shortcut for `logging_manager.bind_logger()` -bind_logger = logging_manager.bind_logger -#: shortcut for `logging_manager.get_logger()` -get_logger = logging_manager.get_logger + Arguments: + agent: The agent identifier (object or string). Used to determine the + `agent_name` and `domain`. + topic: Optional topic string for the logging stream (e.g., 'network', 'db'). -# Install simple formatter for lastResort handler -if lastResort is not None and lastResort.formatter is None: - lastResort.setFormatter(Formatter('%(levelname)s: %(message)s')) + Returns: + A `ContextLoggerAdapter` instance ready for logging. -def install_null_logger(): - """Installs 'null' logger. - """ - log = getLogger('null') - log.propagate = False - log.disabled = True + Process Flow: + + 1. Determine `agent_name` using `get_agent_name(agent)`. + 2. Determine `domain` by looking up `agent_name` in the domain mapping, + falling back to `self.default_domain`. + 3. Apply topic mapping to the input `topic` (if any). + 4. Construct the final underlying `logging.Logger` name using `self.logger_fmt`, + substituting the determined `domain` and mapped `topic`. + 5. Get/create the `logging.Logger` instance using `self._logger_factory` + with the constructed name. + 6. Create and return a `ContextLoggerAdapter` wrapping the logger and + carrying the `domain`, mapped `topic`, original `agent`, and `agent_name`. + """ + agent_name = self.get_agent_name(agent) + agent_name = self._agent_map.get(agent_name, agent_name) + domain = self._agent_domain_map.get(agent_name, self.default_domain) + topic = self._topic_map.get(topic, topic) + # Get logger + logger = self._logger_factory(self._get_logger_name(domain, topic)) + return ContextLoggerAdapter(logger, domain, topic, agent, agent_name) + +logging_manager: LoggingManager = LoggingManager() +"Context logging manager." +get_logger = logging_manager.get_logger +"Shortcut to global `LoggingManager.get_logger` function." +get_agent_name = logging_manager.get_agent_name +"Shortcut to global `LoggingManager.get_agent_name` function." +set_domain_mapping = logging_manager.set_domain_mapping +"Shortcut to global `LoggingManager.set_domain_mapping` function." +set_agent_mapping = logging_manager.set_agent_mapping +"Shortcut to global `LoggingManager.set_agent_mapping` function." diff --git a/src/firebird/base/protobuf.py b/src/firebird/base/protobuf.py index fca7c90..e43b095 100644 --- a/src/firebird/base/protobuf.py +++ b/src/firebird/base/protobuf.py @@ -33,85 +33,201 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________. -"""Firebird Base - Registry for Google Protocol Buffer messages and enums + +"""This module provides a central registry for Google Protocol Buffer message types +and enum types generated from `.proto` files. It allows creating message instances +and accessing enum information using their fully qualified names (e.g., +"my.package.MyMessage", "my.package.MyEnum") without needing to directly import +the corresponding generated `_pb2.py` modules throughout the codebase. + +Benefits: + +* Decouples code using protobuf messages from the specific generated modules. +* Provides a single point for managing and discovering available message/enum types. +* Facilitates dynamic loading of protobuf definitions via entry points. + +Core Features: + +* Register message/enum types using their file DESCRIPTOR object. +* Create new message instances by name using `create_message()`. +* Access enum descriptors and values by name using `get_enum_type()`. +* Load protobuf definitions registered by other installed packages via entry points + using `load_registered()`. +* Helpers for common types like `google.protobuf.Struct`. + +Example: + ```python + # Assume you have my_proto_pb2.py generated from my_proto.proto + # containing: + # message Sample { required string name = 1; } + # enum Status { UNKNOWN = 0; OK = 1; ERROR = 2; } + + from firebird.base.protobuf import ( + register_descriptor, create_message, get_enum_type, is_msg_registered + ) + # Import the generated descriptor (only needed once, e.g., at startup) + try: + from . import my_proto_pb2 # Replace with actual import path + HAS_MY_PROTO = True + except ImportError: + HAS_MY_PROTO = False + + # 1. Register the types from the descriptor + if HAS_MY_PROTO: + register_descriptor(my_proto_pb2.DESCRIPTOR) + print(f"Is 'my_proto.Sample' registered? {is_msg_registered('my_proto.Sample')}") + + # 2. Create a message instance by name + if HAS_MY_PROTO: + try: + msg = create_message('my_proto.Sample') + msg.name = "Example" + print(f"Created message: {msg}") + + # 3. Access enum type and values by name + status_enum = get_enum_type('my_proto.Status') + print(f"Status enum name: {status_enum.name}") + print(f"OK value: {status_enum.OK}") # Access like attribute + print(f"Name for value 2: {status_enum.get_value_name(2)}") # Access via method + print(f"Available status keys: {status_enum.keys()}") + + except KeyError as e: + print(f"Error accessing registered proto type: {e}") + ``` """ from __future__ import annotations -from typing import Dict, Any, Callable, cast + +from collections.abc import Callable from dataclasses import dataclass from importlib.metadata import entry_points -from google.protobuf.message import Message as ProtoMessage +from typing import Any, cast + +from google.protobuf import any_pb2, duration_pb2, empty_pb2, field_mask_pb2, json_format, struct_pb2, timestamp_pb2 from google.protobuf.descriptor import EnumDescriptor -from google.protobuf.struct_pb2 import Struct as StructProto # pylint: disable=[E0611] -from google.protobuf import json_format, struct_pb2, any_pb2, duration_pb2, empty_pb2, \ - timestamp_pb2, field_mask_pb2 -from .types import Distinct +from google.protobuf.message import Message as ProtoMessage +from google.protobuf.struct_pb2 import Struct as StructProto + from .collections import Registry +from .types import Distinct -#: Name of well-known EMPTY protobuf message (for use with `.create_message()`) -PROTO_EMPTY = 'google.protobuf.Empty' -#: Name of well-known ANY protobuf message (for use with `.create_message()`) -PROTO_ANY = 'google.protobuf.Any' -#: Name of well-known DURATION protobuf message (for use with `.create_message()`) -PROTO_DURATION = 'google.protobuf.Duration' -#: Name of well-known TIMESTAMP protobuf message (for use with `.create_message()`) -PROTO_TIMESTAMP = 'google.protobuf.Timestamp' -#: Name of well-known STRUCT protobuf message (for use with `.create_message()`) -PROTO_STRUCT = 'google.protobuf.Struct' -#: Name of well-known VALUE protobuf message (for use with `.create_message()`) -PROTO_VALUE = 'google.protobuf.Value' -#: Name of well-known LISTVALUE protobuf message (for use with `.create_message()`) -PROTO_LISTVALUE = 'google.protobuf.ListValue' -#: Name of well-known FIELDMASK protobuf message (for use with `.create_message()`) -PROTO_FIELDMASK = 'google.protobuf.FieldMask' +PROTO_EMPTY: str = 'google.protobuf.Empty' +"Name of well-known EMPTY protobuf message (for use with `create_message()`)" +PROTO_ANY: str = 'google.protobuf.Any' +"Name of well-known ANY protobuf message (for use with `create_message()`)" +PROTO_DURATION: str = 'google.protobuf.Duration' +"Name of well-known DURATION protobuf message (for use with `create_message()`)" +PROTO_TIMESTAMP: str = 'google.protobuf.Timestamp' +"Name of well-known TIMESTAMP protobuf message (for use with `create_message()`)" +PROTO_STRUCT: str = 'google.protobuf.Struct' +"Name of well-known STRUCT protobuf message (for use with `create_message()`)" +PROTO_VALUE: str = 'google.protobuf.Value' +"Name of well-known VALUE protobuf message (for use with `create_message()`)" +PROTO_LISTVALUE: str = 'google.protobuf.ListValue' +"Name of well-known LISTVALUE protobuf message (for use with `create_message()`)" +PROTO_FIELDMASK: str = 'google.protobuf.FieldMask' +"Name of well-known FIELDMASK protobuf message (for use with `create_message()`)" # Classes @dataclass(eq=True, order=True, frozen=True) class ProtoMessageType(Distinct): - """Google protobuf message type. + """Registry entry representing a registered Protocol Buffer message type. + + Stores the fully qualified name and the constructor (the generated class) + for a message type, allowing instantiation via the registry. + + Arguments: + name: Fully qualified message type name (e.g., "package.Message"). + constructor: The callable (generated message class) used to create instances. """ name: str + "Fully qualified message type name (e.g., 'package.Message')." constructor: Callable - def get_key(self) -> Any: - """Returns `name`. - """ + "The callable (generated message class) used to create instances." + def get_key(self) -> str: + """Returns the message name, used as the key in the registry.""" return self.name @dataclass(eq=True, order=True, frozen=True) class ProtoEnumType(Distinct): - """Google protobuf enum type + """Registry entry providing access to a registered Protocol Buffer enum type. + + Wraps the `EnumDescriptor` and provides an API similar to generated enum + types, allowing access to names and values without direct import of the + generated `_pb2` module. + + Arguments: + descriptor: The `google.protobuf.descriptor.EnumDescriptor` for the enum type. + + Example: + ```python + # Assuming 'my_proto.Status' enum (UNKNOWN=0, OK=1) is registered + status_enum = get_enum_type('my_proto.Status') + + print(status_enum.OK) # Output: 1 (Access value by name) + print(status_enum.get_value_name(1)) # Output: 'OK' (Get name by value) + print(status_enum.keys()) # Output: ['UNKNOWN', 'OK'] + print(status_enum.values()) # Output: [0, 1] + print(status_enum.items()) # Output: [('UNKNOWN', 0), ('OK', 1)] + + try: + print(status_enum.NONEXISTENT) + except AttributeError as e: + print(e) # Output: Enum my_proto.Status has no value with name 'NONEXISTENT' + + try: + print(status_enum.get_value_name(99)) + except KeyError as e: + print(e) # Output: "Enum my_proto.Status has no name defined for value 99" + ``` """ descriptor: EnumDescriptor - def get_key(self) -> Any: - """Returns `name`. - """ + "The `google.protobuf.descriptor.EnumDescriptor` for the enum type." + def get_key(self) -> str: + """Returns the full enum name, used as the key in the registry.""" return self.name - def __getattr__(self, name): - """Returns the value corresponding to the given enum name.""" + def __getattr__(self, name: str): + """Return the integer value corresponding to the enum member name `name`. + + Arguments: + name: The string name of the enum member. + + Returns: + The integer value of the enum member. + + Raises: + AttributeError: If `name` is not a valid member name for this enum. + """ if name in self.descriptor.values_by_name: return self.descriptor.values_by_name[name].number raise AttributeError(f"Enum {self.name} has no value with name '{name}'") - def keys(self): + def keys(self) -> list[str]: """Return a list of the string names in the enum. - These are returned in the order they were defined in the .proto file. + These are returned in the order they were defined in the `.proto` file. """ return [value_descriptor.name for value_descriptor in self.descriptor.values] - def values(self): + def values(self) -> list[int]: """Return a list of the integer values in the enum. - These are returned in the order they were defined in the .proto file. + These are returned in the order they were defined in the `.proto` file. """ return [value_descriptor.number for value_descriptor in self.descriptor.values] - def items(self): + def items(self) -> list[tuple[str, int]]: """Return a list of the (name, value) pairs of the enum. - These are returned in the order they were defined in the .proto file. + These are returned in the order they were defined in the `.proto` file. """ return [(value_descriptor.name, value_descriptor.number) for value_descriptor in self.descriptor.values] def get_value_name(self, number: int) -> str: - """Returns a string containing the name of an enum value. + """Return the string name corresponding to the enum member value `number`. + + Arguments: + number: The integer value of the enum member. + + Returns: + The string name of the enum member. Raises: KeyError: If there is no value for specified name. @@ -121,35 +237,56 @@ def get_value_name(self, number: int) -> str: raise KeyError(f"Enum {self.name} has no name defined for value {number}") @property def name(self) -> str: - """Full enum type name. - """ + """The fully qualified name of the enum type (e.g., "package.MyEnum").""" return self.descriptor.full_name +#: Internal registry storing ProtoMessageType instances. _msgreg: Registry = Registry() +#: Internal registry storing ProtoEnumType instances. _enumreg: Registry = Registry() -def struct2dict(struct: StructProto) -> Dict: - """Unpacks `google.protobuf.Struct` message to Python dict value. +def struct2dict(struct: StructProto) -> dict[str, Any]: + """Unpack a `google.protobuf.Struct` message into a Python dictionary. + + Uses `google.protobuf.json_format.MessageToDict`. + + Arguments: + struct: The `Struct` message instance. + + Returns: + A Python dictionary representing the struct's content. """ return json_format.MessageToDict(struct) -def dict2struct(value: Dict) -> StructProto: - """Returns dict packed into `google.protobuf.Struct` message. +def dict2struct(value: dict[str, Any]) -> StructProto: + """Pack a Python dictionary into a `google.protobuf.Struct` message. + + Arguments: + value: The Python dictionary. + + Returns: + A `Struct` message instance containing the dictionary's data. """ struct = StructProto() struct.update(value) return struct -def create_message(name: str, serialized: bytes = None) -> ProtoMessage: - """Returns new protobuf message instance. +def create_message(name: str, serialized: bytes | None=None) -> ProtoMessage: + """Create a new instance of a registered protobuf message type by name. + + Optionally initializes the message by parsing serialized data. Arguments: - name: Fully qualified protobuf message name. - serialized: Serialized message. + name: Fully qualified name of the registered protobuf message type. + serialized: Optional bytes containing the serialized message data. + + Returns: + An instance of the requested protobuf message class. Raises: - KeyError: When message type is not registered. - google.protobuf.message.DecodeError: When deserializations fails. + KeyError: If `name` does not correspond to a registered message type. + google.protobuf.message.DecodeError: If `serialized` data is provided + but cannot be parsed correctly for the message type. """ if (msg := _msgreg.get(name)) is None: raise KeyError(f"Unregistered protobuf message '{name}'") @@ -159,97 +296,161 @@ def create_message(name: str, serialized: bytes = None) -> ProtoMessage: return result def get_message_factory(name: str) -> Callable: - """Returns callable that creates new protobuf messages of specified name. + """Return the constructor (factory callable) for a registered message type. + + Allows creating multiple instances without repeated registry lookups. Arguments: - name: Fully qualified protobuf message name. + name: Fully qualified name of the registered protobuf message type. + + Returns: + The callable (message class) used to construct instances. Raises: - KeyError: When message type is not registered. + KeyError: If `name` does not correspond to a registered message type. """ if (msg := _msgreg.get(name)) is None: raise KeyError(f"Unregistered protobuf message '{name}'") return cast(ProtoMessageType, msg).constructor def is_msg_registered(name: str) -> bool: - """Returns True if specified `name` refers to registered protobuf message type. + """Check if a protobuf message type with the given name is registered. + + Arguments: + name: Fully qualified message type name. + + Returns: + True if registered, False otherwise. """ return name in _msgreg def is_enum_registered(name: str) -> bool: - """Returns True if specified `name` refers to registered protobuf enum type. + """Check if a protobuf enum type with the given name is registered. + + Arguments: + name: Fully qualified enum type name. + + Returns: + True if registered, False otherwise. """ return name in _enumreg def get_enum_type(name: str) -> ProtoEnumType: - """Returns wrapper instance for protobuf enum type with specified `name`. + """Return the `ProtoEnumType` wrapper for a registered enum type by name. + + Provides access to enum members and values via the wrapper object. + + Arguments: + name: Fully qualified name of the registered protobuf enum type. + + Returns: + The `ProtoEnumType` instance for the requested enum. Raises: - KeyError: When enum type is not registered. + KeyError: If `name` does not correspond to a registered enum type. """ if (e := _enumreg.get(name)) is None: raise KeyError(f"Unregistered protobuf enum type '{name}'") return e def get_enum_field_type(msg, field_name: str) -> str: - """Returns name of enum type for message enum field. + """Return the fully qualified name of the enum type for a message field. + + Arguments: + msg: An *instance* of a protobuf message. + field_name: The string name of the field within the message. + + Returns: + The fully qualified name of the enum type used by the field. Raises: - KeyError: When message does not have specified field. + KeyError: If `msg` does not have a field named `field_name`. + TypeError: If the specified field is not an enum field. """ if (fdesc := msg.DESCRIPTOR.fields_by_name.get(field_name)) is None: raise KeyError(f"Message does not have field '{field_name}'") + if fdesc.enum_type is None: + raise TypeError(f"Field '{field_name}' in message type '{msg.DESCRIPTOR.full_name}' is not an enum field.") return fdesc.enum_type.full_name def get_enum_value_name(enum_type_name: str, value: int) -> str: - """Returns name for the enum value. + """Return the string name corresponding to a value within a registered enum type. + + Convenience function equivalent to `get_enum_type(enum_type_name).get_value_name(value)`. + + Arguments: + enum_type_name: Fully qualified name of the registered enum type. + value: The integer value of the enum member. + + Returns: + The string name of the enum member. + + Raises: + KeyError: If `enum_type_name` is not registered, or if `value` is not + defined within that enum. """ return get_enum_type(enum_type_name).get_value_name(value) -def register_decriptor(file_descriptor) -> None: - """Registers enums and messages defined by protobuf file DESCRIPTOR. +def register_descriptor(file_descriptor: 'google.protobuf.descriptor.FileDescriptor') -> None: + """Register all message and enum types defined within a protobuf file descriptor. + + This is the primary mechanism for adding types to the registry. The descriptor + object is typically accessed as `DESCRIPTOR` from a generated `_pb2.py` module. + + Arguments: + file_descriptor: The `google.protobuf.descriptor.FileDescriptor` object + (e.g., `my_proto_pb2.DESCRIPTOR`). """ for msg_desc in file_descriptor.message_types_by_name.values(): - if not msg_desc.full_name in _msgreg: + if msg_desc.full_name not in _msgreg: _msgreg.store(ProtoMessageType(msg_desc.full_name, msg_desc._concrete_class)) for enum_desc in file_descriptor.enum_types_by_name.values(): - if not enum_desc.full_name in _enumreg: + if enum_desc.full_name not in _enumreg: _enumreg.store(ProtoEnumType(enum_desc)) +register_decriptor = register_descriptor # Deprecated alias, to be removed in 3.0 + def load_registered(group: str) -> None: # pragma: no cover - """Load registered protobuf packages. + """Load and register protobuf types defined via package entry points. + + Searches for installed packages that register entry points under the specified + `group`. Each entry point should load a `FileDescriptor` object. This allows + packages to automatically make their protobuf types available to the registry + upon installation. - Protobuf packages must register the pb2-file DESCRIPTOR in `entry_points` section of - `setup.cfg` or `pyproject.toml` file. + This function is typically called once during application initialization. Arguments: - group: Entry-point group name. + group: The name of the entry-point group to scan (e.g., 'firebird.base.protobuf'). Example: - :: - - # setup.cfg: - - [options.entry_points] - firebird.base.protobuf = - firebird.base.lib_a = firebird.base.lib_a_pb2:DESCRIPTOR - firebird.base.lib_b = firebird.base.lib_b_pb2:DESCRIPTOR - firebird.base.lib_c = firebird.base.lib_c_pb2:DESCRIPTOR - - # pyproject.toml - - [project.entry-points."firebird.base.protobuf"] - "firebird.base.lib_a" = "firebird.base.lib_a_pb2:DESCRIPTOR" - "firebird.base.lib_b" = "firebird.base.lib_b_pb2:DESCRIPTOR" - "firebird.base.lib_c" = "firebird.base.lib_c_pb2:DESCRIPTOR" - - # will be loaded with: - - load_registered('firebird.base.protobuf') + ```ini + # setup.cfg: + + [options.entry_points] + firebird.base.protobuf = + firebird.base.lib_a = firebird.base.lib_a_pb2:DESCRIPTOR + firebird.base.lib_b = firebird.base.lib_b_pb2:DESCRIPTOR + firebird.base.lib_c = firebird.base.lib_c_pb2:DESCRIPTOR + + # pyproject.toml + + [project.entry-points."firebird.base.protobuf"] + "firebird.base.lib_a" = "firebird.base.lib_a_pb2:DESCRIPTOR" + "firebird.base.lib_b" = "firebird.base.lib_b_pb2:DESCRIPTOR" + "firebird.base.lib_c" = "firebird.base.lib_c_pb2:DESCRIPTOR" + ``` + + Usage: + ``` + # In your application's startup code: + load_registered('firebird.base.protobuf') + # Now messages/enums registered via entry points are available + ``` """ - for desc in (entry.load() for entry in entry_points().get(group, [])): - register_decriptor(desc) + for desc in (entry.load() for entry in entry_points().select(group=group)): + register_descriptor(desc) for well_known in [any_pb2, struct_pb2, duration_pb2, empty_pb2, timestamp_pb2, field_mask_pb2]: - register_decriptor(well_known.DESCRIPTOR) + register_descriptor(well_known.DESCRIPTOR) del any_pb2, struct_pb2, duration_pb2, empty_pb2, timestamp_pb2, field_mask_pb2 diff --git a/src/firebird/base/signal.py b/src/firebird/base/signal.py index e75720b..5605546 100644 --- a/src/firebird/base/signal.py +++ b/src/firebird/base/signal.py @@ -32,58 +32,87 @@ # Copyright (c) 2020 Firebird Project (www.firebirdsql.org), after fork # All Rights Reserved. # -# Contributor(s): PySignal 1.1.4 contributors: John Hood, Jason Viloria, Adric Worley, -# Alex Widener -# Pavel Císař - fork and reduction & adaptation for firebird-base and Python 3.8 +# Contributor(s): Based on PySignal 1.1.4 contributors: John Hood, Jason Viloria, +# Adric Worley, Alex Widener +# Pavel Císař - fork and reduction & adaptation for firebird-base and +# Python 3.8, added Delphi events # ______________________________________ -"""firebird-base - Callback system based on Signals and Slots, and "Delphi events" +"""This module provides two callback mechanisms: +1. **Signals and Slots (`Signal`, `signal` decorator):** Inspired by Qt, a signal + can be connected to multiple slots (callbacks). When the signal is emitted, + all connected slots are called. Return values from slots are ignored. +2. **Eventsockets (`eventsocket` decorator):** Similar to Delphi events, an + eventsocket holds a reference to a **single** slot (callback). Assigning a new + slot replaces the previous one. Calling the eventsocket delegates the call + directly to the connected slot. Return values are passed back from the slot. +In both cases, slots can be functions, instance/class methods, `functools.partial` +objects, or lambda functions. The `inspect` module is used to enforce signature +matching between the signal/eventsocket definition and the connected slots. + +Important: + All type annotations in signatures are significant, so callbacks must have exactly the + same annotations as signatures used by signals or events. The sole exception are excess + keyword arguments with default values defined on connected callable. + +Tip: + You may use [functools.partial][] to adapt callable with different signatures. However, + you can "mask" only keyword arguments (without default) and leading positional arguments + (as any positional argument binded by name will not mask-out parameter from signature + introspection). """ from __future__ import annotations -from typing import Callable, List -from inspect import Signature, ismethod -from weakref import ref, WeakKeyDictionary + +from collections.abc import Callable from functools import partial +from inspect import Signature, ismethod +from typing import Any +from weakref import ReferenceType, WeakKeyDictionary, ref + class Signal: - """The Signal is the core object that handles connection with slots and emission. + """Handles connections between a signal and multiple slots (callbacks). + + When the signal is emitted, all connected slots are called with the + provided arguments. Return values from slots are ignored. + + Arguments: + signature: The `inspect.Signature` object defining the expected parameters for + connected slots. + + Important: + Only slots that match the signature could be connected to signal. The check is + performed only on parameters, and not on return value type (as signals does + not have/ignore return values). + + The match must be exact, including type annotations, parameter names, order, + parameter type etc. The sole exception to this rule are excess slot keyword + arguments with default values. - Slots are callables that are called when signal is emitted (the return value is ignored). - They could be functions, instance or class methods, partials and lambda functions. + Note: + Signal functions with signatures different from signal could be adapted using + `functools.partial`. However, you can "mask" only keyword arguments (without + default) and leading positional arguments (as any positional argument binded + by name will not mask-out parameter from signature introspection). """ def __init__(self, signature: Signature): - """ - Arguments: - signature: Signature for slots. - - Important: - Only slots that match the signature could be connected to signal. The check is - performed only on parameters, and not on return value type (as signals does - not have/ignore return values). - - The match must be exact, including type annotations, parameter names, order, - parameter type etc. The sole exception to this rule are excess slot keyword - arguments with default values. - - Note: - Signal functions with signatures different from signal could be adapted using - `functools.partial`. However, you can "mask" only keyword arguments (without - default) and leading positional arguments (as any positional argument binded - by name will not mask-out parameter from signature introspection). - """ self._sig: Signature = signature.replace(parameters=[p for p in signature.parameters.values() if p.name != 'self'], return_annotation=Signature.empty) - #: Toggle to block / unblock signal transmission self.block: bool = False - self._slots: List[Callable] = [] + "Toggle to block / unblock signal transmission" + self._slots: list[Callable | ReferenceType[Callable]] = [] self._islots: WeakKeyDictionary = WeakKeyDictionary() def __call__(self, *args, **kwargs): + """Shortcut for `emit(*args, **kwargs)`.""" self.emit(*args, **kwargs) def _kw_test(self, sig: Signature) -> bool: + """Internal helper to check if the only difference between `sig` and `self._sig` + is the presence of extra keyword arguments with default values in `sig`. + """ p = sig.parameters result = False for k in set(p).difference(set(self._sig.parameters)): @@ -92,20 +121,20 @@ def _kw_test(self, sig: Signature) -> bool: return False return result def emit(self, *args, **kwargs) -> None: - """Calls all the connected slots with the provided args and kwargs unless block - is activated. + """Emit the signal, calling all connected slots with the given arguments. + + Does nothing if `self.block` is True. Handles different storage types + (functions, methods, lambdas, partials) correctly. + + Arguments: + *args: Positional arguments to pass to the slots. + **kwargs: Keyword arguments to pass to the slots. """ if self.block: return for slot in self._slots: - if not slot: - continue if isinstance(slot, partial): slot(*args, **kwargs) - elif isinstance(slot, WeakKeyDictionary): - # For class methods, get the class object and call the method accordingly. - for obj, method in slot.items(): - method(obj, *args, **kwargs) elif isinstance(slot, ref): # If it's a weakref, call the ref to get the instance and then call the func # Don't wrap in try/except so we don't risk masking exceptions from the actual func call @@ -117,13 +146,26 @@ def emit(self, *args, **kwargs) -> None: for obj, method in self._islots.items(): method(obj, *args, **kwargs) def connect(self, slot: Callable) -> None: - """Connects the signal to callable that will receive the signal when emitted. + """Connect a callable slot to this signal. + + The slot will be called whenever the signal is emitted. Arguments: - slot: Callable with signature that match the signature defined for signal. + slot: The callable (function, method, lambda, partial) to connect. + Its signature must match the signal's signature (see class docs). Raises: - ValueError: When callable signature does not match the signature of signal. + ValueError: If `slot` is not callable or if its signature does not match + the signal's signature (parameters and their types/names/kinds, + excluding return type and allowing extra keyword args with defaults). + + Storage Note: + - Regular functions are stored using `weakref.ref` to avoid preventing + garbage collection if the signal outlives the function's scope. + - Instance methods are stored using a `WeakKeyDictionary` mapping the + instance (weakly) to the unbound function. + - Lambdas and `functools.partial` objects are stored directly, as weak + references to them are often problematic. """ if not callable(slot): raise ValueError(f"Connection to non-callable '{slot.__class__.__name__}' object failed") @@ -134,7 +176,7 @@ def connect(self, slot: Callable) -> None: if not self._kw_test(sig): raise ValueError("Callable signature does not match the signal signature") if isinstance(slot, partial) or slot.__name__ == '': - # If it's a partial, a Signal or a lambda. + # If it's a partial or a lambda. if slot not in self._slots: self._slots.append(slot) elif ismethod(slot): @@ -142,11 +184,17 @@ def connect(self, slot: Callable) -> None: self._islots[slot.__self__] = slot.__func__ else: # If it's just a function then just store it as a weakref. - newSlotRef = ref(slot) - if newSlotRef not in self._slots: - self._slots.append(newSlotRef) - def disconnect(self, slot) -> None: - """Disconnects the slot from the signal. + new_slot_ref = ref(slot) + if new_slot_ref not in self._slots: + self._slots.append(new_slot_ref) + def disconnect(self, slot: Callable) -> None: + """Disconnect a previously connected slot from the signal. + + Attempts to remove the specified slot. Does nothing if the slot + is not currently connected or not callable. + + Arguments: + slot: The callable that was previously passed to `connect()`. """ if not callable(slot): return @@ -170,19 +218,35 @@ def clear(self) -> None: """Clears the signal of all connected slots. """ self._slots.clear() + self._islots.clear() + +class signal: # noqa: N801 + """Decorator to define a `Signal` instance as a read-only property on a class. -class signal: - """Decorator that defines signal as read-only property. The decorated function/method - is used to define the signature required for slots to successfuly register to signal, - and does not need to have a body as it's never executed. + The decorated function's signature (excluding 'self') defines the required + signature for slots connecting to this signal. The body of the decorated + function is never executed. - The usage is similar to builtin `property`, except that it does not support custom - setter and deleter. + A unique `Signal` instance is lazily created for each object instance the + first time the signal property is accessed. + + Example: + ```python + class MyClass: + @signal + def value_changed(self, new_value: int): + # This signature dictates slots must accept (new_value: int) + pass # Body is ignored + + instance = MyClass() + instance.value_changed.connect(my_slot_function) + instance.value_changed.emit(10) + ``` """ def __init__(self, fget, doc=None): self._sig_ = Signature.from_callable(fget) - self._map = WeakKeyDictionary() + self._map: WeakKeyDictionary[Any, Signal] = WeakKeyDictionary() if doc is None and fget is not None: doc = fget.__doc__ self.__doc__ = doc @@ -193,16 +257,16 @@ def __get__(self, obj, objtype): self._map[obj] = Signal(self._sig_) return self._map[obj] def __set__(self, obj, val): - raise AttributeError("can't set signal") + raise AttributeError("Can't assign to signal") def __delete__(self, obj): - raise AttributeError("can't delete signal") + raise AttributeError("Can't delete signal") class _EventSocket: """Internal EventSocket handler. """ - def __init__(self, slot: Callable=None): - self._slot: Callable = None - self._weak = False + def __init__(self, slot: Callable | None=None): + self._slot: Callable | None = None + self._weak: bool | ReferenceType[Callable] = False if slot is not None: if isinstance(slot, partial) or slot.__name__ == '': self._slot = slot @@ -213,7 +277,7 @@ def __init__(self, slot: Callable=None): else: self._slot = ref(slot) self._weak = True - def __call__(self, *args, **kwargs): # pylint: disable=[R1710] + def __call__(self, *args, **kwargs): if self._slot is not None: if isinstance(self._weak, ref): if (obj := self._weak()): @@ -231,51 +295,67 @@ def is_set(self) -> bool: return self._slot() is not None return self._slot is not None -class eventsocket: - """The `eventsocket` is like read/write property that handles connection and call - delegation to single slot. It basically works like Delphi event. +class eventsocket: # noqa: N801 + """Decorator defining a property that holds a single callable slot (like a Delphi event). - The Slot could be function, instance or class method, partial and lambda function. + Assigning a callable (function, method, lambda, partial) to the property connects it + as the event handler. Assigning `None` disconnects the current handler. Calling the + property like a method invokes the currently connected handler, passing through + arguments and returning its result. - Important: - Only slot that match the signature could be connected to eventsocket. The check is - performed on parameters and return value type (as events may have return values). + The decorated function's signature (excluding 'self' but including the return + type annotation) defines the required signature for the assigned slot. - The match must be exact, including type annotations, parameter names, order, - parameter type etc. The sole exception to this rule are excess slot keyword - arguments with default values. + Use the `is_set()` method on the property access to check if a handler is assigned. - Note: - Eventsocket functions with signatures different from event could be adapted - using `functools.partial`. However, you can "mask" only keyword arguments - (without default) and leading positional arguments (as any positional argument - binded by name will not mask-out parameter from signature introspection). - - To call the event, simply call the eventsocket property with required parameters. - To check whether slot is assigned to eventsocket, use `is_set()` bool function - defined on property. + Example: + ```python + class MyComponent: + @eventsocket + def on_update(self, data: dict) -> None: + # Slots must match (data: dict) -> None + pass + + def do_update(self)->: + data = {'value': 1} + self.on_update(data) # Call the assigned handler (or pass if not set) + + def my_handler(data: dict) -> None: + print(f"Handler received: {data}") + + comp = MyComponent() + comp.on_update = my_handler # Connect handler + comp.do_update() # Calls my_handler + comp.on_update = None # Disconnect handler + ``` + + Important: + Signature matching includes parameter names, types, kinds, order, *and* the + return type annotation. The only exception is that the assigned slot may have + extra keyword arguments if they have default values. + + Storage Note: + Similar to `Signal`, functions and methods are stored using weak references + where appropriate to prevent memory leaks. Lambdas/partials are stored directly. """ - _empty = _EventSocket() - def __init__(self, fget, doc=None): - s = Signature.from_callable(fget) - # Remove 'self' from list of parameters - self._sig: Signature = s.replace(parameters=[v for k,v in s.parameters.items() if k.lower() != 'self']) + _empty: _EventSocket = _EventSocket() + def __init__(self, fget: Callable, doc: str | None=None): + # Store callable for later signature inspection + self._callable = fget # Key: instance of class where this eventsocket instance is used to define a property # Value: _EventSocket self._map = WeakKeyDictionary() if doc is None and fget is not None: doc = fget.__doc__ self.__doc__ = doc - def _kw_test(self, sig: Signature) -> bool: - set_p = set(sig.parameters) - set_t = set(self._sig.parameters) - for k in set_p.difference(set_t): - if sig.parameters[k].default is Signature.empty: - return False - for k in set_t.difference(set_p): - if self._sig.parameters[k].default is Signature.empty: + def _kw_test(self, given: Signature, expected: Signature) -> bool: + p = given.parameters + result = False + for k in set(p).difference(set(expected.parameters)): + result = True + if p[k].default is Signature.empty: return False - return sig.return_annotation == self._sig.return_annotation + return result def __get__(self, obj, objtype): if obj is None: return self @@ -288,11 +368,16 @@ def __set__(self, obj, value): if not callable(value): raise ValueError(f"Connection to non-callable '{value.__class__.__name__}' object failed") # Verify signatures - sig = Signature.from_callable(value) - if str(sig) != str(self._sig): + expected_sig: Signature = Signature.from_callable(self._callable, eval_str=True) + # Remove 'self' from list of parameters + expected_sig = expected_sig.replace(parameters=[v for k,v in expected_sig.parameters.items() + if k.lower() != 'self']) + + given_sig = Signature.from_callable(value, eval_str=True) + if str(given_sig) != str(expected_sig): # Check if the difference is only in keyword arguments with defaults. - if not self._kw_test(sig): + if not self._kw_test(given_sig, expected_sig): raise ValueError("Callable signature does not match the event signature") self._map[obj] = _EventSocket(value) def __delete__(self, obj): - raise AttributeError("can't delete eventsocket") + raise AttributeError("Can't delete eventsocket") diff --git a/src/firebird/base/strconv.py b/src/firebird/base/strconv.py index 1c81eff..c190d6b 100644 --- a/src/firebird/base/strconv.py +++ b/src/firebird/base/strconv.py @@ -33,211 +33,406 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________ -"""firebird-base - Data conversion from/to string +"""This module provides a centralized mechanism for converting various Python data types +to and from their string representations. It allows registering custom conversion +functions for specific types, making the conversion process extensible and +decoupled from the types themselves. + +Core features include: + +- Registration of type-specific string conversion functions. +- Lookup of convertors based on type or type name (simple or full). +- Helper functions (`convert_to_str`, `convert_from_str`) for easy conversion. +- Built-in support for common types ([str][], [int][], [float][], [bool][], + [Decimal][decimal.Decimal], [UUID][uuid.UUID], [Enum][enum.Enum], etc.). + +Example: + ```python + from firebird.base.strconv import convert_to_str, convert_from_str + from decimal import Decimal + + # Convert Decimal to string + s = convert_to_str(Decimal('123.45')) + print(s) # Output: 123.45 + + # Convert string back to Decimal + d = convert_from_str(Decimal, '123.45') + print(d) # Output: Decimal('123.45') + + # Boolean conversion + b = convert_from_str(bool, 'yes') + print(b) # Output: True + s = convert_to_str(False) + print(s) # Output: no + ``` """ from __future__ import annotations -from typing import Hashable, Callable, Any, Type, Union + +from collections.abc import Callable, Hashable from dataclasses import dataclass from decimal import Decimal, DecimalException from enum import Enum, IntEnum, IntFlag +from typing import Any, TypeAlias from uuid import UUID -from .types import Distinct, MIME, ZMQAddress + from .collections import Registry +from .types import MIME, Distinct, ZMQAddress #: Function that converts typed value to its string representation. -TConvertToStr = Callable[[Any], str] +TConvertToStr: TypeAlias = Callable[[Any], str] #: Function that converts string representation of typed value to typed value. -TConvertFromStr = Callable[[Type, str], Any] +TConvertFromStr: TypeAlias = Callable[[type, str], Any] @dataclass class Convertor(Distinct): """Data convertor registry entry. + + Holds the functions responsible for converting a specific data type + to and from its string representation. Instances of this class are + stored in the internal registry. + + Arguments: + cls: The data type (class) this convertor handles. + to_str: The function converting an instance of `cls` to a string. + from_str: The function converting a string back to an instance of `cls`. """ - cls: Type + cls: type + "The data type (class) this convertor handles." to_str: TConvertToStr + "The function converting an instance of `cls` to a string." from_str: TConvertFromStr + "The function converting a string back to an instance of `cls`." def get_key(self) -> Hashable: - """Returns instance key. - """ + """Returns instance key (the class itself), used by the `Registry`.""" return self.cls @property def name(self) -> str: - """Type name. - """ + """Simple type name (e.g., [int][], [Decimal][decimal.Decimal]).""" return self.cls.__name__ @property def full_name(self) -> str: - """Type name including source module. - """ + """Type name including source module (e.g., [decimal.Decimal][]).""" return f'{self.cls.__module__}.{self.cls.__name__}' _convertors: Registry = Registry() -_classes = {} +_classes: dict[str, type] = {} # Convertors -#: Valid string literals for True value. -TRUE_STR = ['yes', 'true', 'on', 'y', '1'] -#: Valid string literals for False value. -FALSE_STR = ['no', 'false', 'off', 'n', '0'] +TRUE_STR: list[str] = ['yes', 'true', 'on', 'y', '1'] +"Valid string literals for True value." +FALSE_STR: list[str] = ['no', 'false', 'off', 'n', '0'] +"Valid string literals for False value." def any2str(value: Any) -> str: """Converts value to string using `str(value)`. + + This is the default `to_str` convertor function. + + Arguments: + value: The value to convert. + + Returns: + The string representation of the value. """ return str(value) -def str2any(cls: Type, value: str) -> Any: +def str2any(cls: type, value: str) -> Any: """Converts string to data type value using `type(value)`. + + This is the default `from_str` convertor function. It assumes the + type's constructor can handle a single string argument. + + Arguments: + cls: The target data type. + value: The string representation to convert. + + Returns: + An instance of `cls` created from the string value. """ return cls(value) -def register_convertor(cls: Type, *, - to_str: TConvertToStr=any2str, - from_str: TConvertFromStr=str2any): - """Registers convertor function(s). +def register_convertor(cls: type, *, to_str: TConvertToStr=any2str, + from_str: TConvertFromStr=str2any) -> None: + """Registers convertor function(s) for a specific data type. + + If `to_str` or `from_str` are not provided, default convertors (`any2str`, + `str2any`) based on `str()` and `cls()` are used. Arguments: - cls: Class or class name - to_str: Function that converts `cls` value to `str` - from_str: Function that converts `str` to value of `cls` data type + cls: Class to register convertor for. + to_str: Optional function that converts an instance of `cls` to `str`. + Defaults to `any2str`. + from_str: Optional function that converts `str` to value of `cls` data type. + Defaults to `str2any`. + + Example: + ```python + from datetime import date + from firebird.base.strconv import register_convertor, convert_to_str, convert_from_str + + # Register custom convertors for date + def date_to_iso(value: date) -> str: + return value.isoformat() + + def iso_to_date(cls: type, value: str) -> date: + return cls.fromisoformat(value) + + register_convertor(date, to_str=date_to_iso, from_str=iso_to_date) + + d = date(2023, 10, 27) + s = convert_to_str(d) # Uses date_to_iso -> '2023-10-27' + d2 = convert_from_str(date, s) # Uses iso_to_date -> date(2023, 10, 27) + ``` """ _convertors.store(Convertor(cls, to_str, from_str)) -def register_class(cls: Type) -> None: - """Registers class for name lookup. +def register_class(cls: type) -> None: + """Registers a class name for lookup, primarily for string-based conversions. + + This allows functions like `has_convertor`, `get_convertor`, and `convert_from_str` + to find the correct convertor when given a simple class name (e.g., "MyClass") + as a string, instead of the class object itself. Registration is particularly + useful when: + + 1. Performing lookups based on class names stored as strings. + 2. Resolving potential ambiguity if multiple classes with the same simple name + exist in different modules (though using full names like 'module.MyClass' + is generally safer in such cases). + 3. Enabling MRO (Method Resolution Order) lookup for base class convertors + when the lookup starts with a string name. - .. seealso:: `has_convertor()`, `get_convertor()` + See Also: + `has_convertor`, `get_convertor`, `convert_from_str` + + Arguments: + cls: Class to be registered. Raises: - TypeError: When class name is already registered. + TypeError: When the simple class name (`cls.__name__`) is already registered. """ if cls.__name__ in _classes: raise TypeError(f"Class '{cls.__name__}' already registered as '{_classes[cls.__name__]!r}'") _classes[cls.__name__] = cls -def _get_convertor(cls: Union[Type, str]) -> Convertor: +def _get_convertor(cls: type | str) -> Convertor | None: if isinstance(cls, str): cls = _classes.get(cls, cls) if isinstance(cls, str): conv = list(_convertors.filter(f"item.{'full_name' if '.' in cls else 'name'} == '{cls}'")) conv = conv.pop(0) if conv else None - else: - if (conv := _convertors.get(cls)) is None: - for base in cls.__mro__: - conv = _convertors.get(base) - if conv is not None: - break + elif (conv := _convertors.get(cls)) is None: + for base in cls.__mro__: + conv = _convertors.get(base) + if conv is not None: + break return conv -def has_convertor(cls: Union[Type, str]) -> bool: - """Returns True if class has a convertor. +def has_convertor(cls: type | str) -> bool: + """Returns True if a convertor is registered for the class or its bases. Arguments: - cls: Type or type name. The name could be simple class name, or full name that includes - the module name. + cls: Type object or type name. The name could be a simple class name + (e.g., "MyClass") or a full name including the module + (e.g., "my_module.MyClass"). Note: When `cls` is a name: - 1. If class name is NOT registered via `register_class()`, it's not possible to perform - lookup for bases classes. - 2. If simple class name is provided and multiple classes of the same name but from - different modules have registered convertors, the first one found is used. If you - want to avoid this situation, use full names. + 1. If the class name is NOT registered via `register_class()`, it's not + possible to perform MRO lookup for base class convertors. Only an exact + match on the name (simple or full) will work. + 2. If a simple class name is provided and multiple classes of the same + name but from different modules have registered convertors (or been + registered via `register_class`), the lookup might be ambiguous. Using + full names is recommended in such scenarios. + + Example: + ```python + from decimal import Decimal + from firebird.base.strconv import register_convertor, has_convertor, register_class + + print(has_convertor(Decimal)) # Output: True (built-in) + print(has_convertor('Decimal')) # Output: True (built-in, simple name works) + print(has_convertor('decimal.Decimal')) # Output: True (full name) + + class MyData: pass + class MySubData(MyData): pass + + register_convertor(MyData) + register_class(MySubData) # Register subclass name + + print(has_convertor(MySubData)) # Output: True (finds MyData via MRO) + print(has_convertor('MySubData')) # Output: True (finds MyData via MRO because name is registered) + print(has_convertor('NonExistent')) # Output: False + ``` """ return _get_convertor(cls) is not None -def update_convertor(cls: Union[Type, str], *, - to_str: TConvertToStr=None, - from_str: TConvertFromStr=None): - """Update convertor function(s). +def update_convertor(cls: type | str, *, + to_str: TConvertToStr | None=None, + from_str: TConvertFromStr | None=None) -> None: + """Update the `to_str` and/or `from_str` functions for an existing convertor. Arguments: - cls: Class or class name - to_str: Function that converts `cls` value to `str` - from_str: Function that converts `str` to value of `cls` data type + cls: Class or class name whose convertor needs updating. + to_str: Optional new function that converts `cls` value to `str`. + from_str: Optional new function that converts `str` to value of `cls` data type. Raises: - KeyError: If data type has not registered convertor. + TypeError: If the data type (or its name) has no registered convertor. + + Example: + ```python + from firebird.base.strconv import update_convertor, convert_to_str + + # Assume BoolConvertor exists and uses 'yes'/'no' + # Change bool to output 'TRUE'/'FALSE' + update_convertor(bool, to_str=lambda v: 'TRUE' if v else 'FALSE') + print(convert_to_str(True)) # Output: TRUE + ``` """ - conv = get_convertor(cls) + conv: Convertor = get_convertor(cls) if to_str: conv.to_str = to_str if from_str: conv.from_str = from_str def convert_to_str(value: Any) -> str: - """Converts value to string using registered convertor. + """Converts a value to its string representation using its registered convertor. - Arguments: - value: Value to be converted. + Looks up the convertor based on the value's class (`value.__class__`). + If there is no direct convertor registered for the value's specific class, + it searches the Method Resolution Order (MRO) for a convertor registered + for a base class. - If there is no convertor for value's class, uses MRO to locate alternative convertor. + Arguments: + value: The value to be converted to a string. Raises: - TypeError: If there is no convertor for value's class or any from its bases classes. + TypeError: If no convertor is found for the value's class or any of + its base classes in the MRO. + + Example: + ```python + from decimal import Decimal + from uuid import uuid4 + from firebird.base.strconv import convert_to_str, register_convertor + + print(convert_to_str(123)) # Output: '123' + print(convert_to_str(Decimal('1.2'))) # Output: '1.2' + print(convert_to_str(True)) # Output: 'yes' + my_uuid = uuid4() + print(convert_to_str(my_uuid)) # Output: UUID string representation + + class MyBase: pass + class MyDerived(MyBase): pass + register_convertor(MyBase, to_str=lambda v: "BaseStr") + instance = MyDerived() + print(convert_to_str(instance)) # Output: 'BaseStr' (uses MyBase convertor) + ``` """ return get_convertor(value.__class__).to_str(value) - -def convert_from_str(cls: Union[Type, str], value: str) -> Any: - """Converts value from string to data type using registered convertor. +def convert_from_str(cls: type | str, value: str) -> Any: + """Converts a string representation back to a typed value using a registered convertor. Arguments: - cls: Type or type name. The name could be simple class name, or full name that includes - the module name. - value: String value to be converted + cls: The target type object or type name (simple or full) to convert to. + value: The string value to be converted. Note: When `cls` is a type name: - 1. If class name is NOT registered via `register_class()`, it's not possible to perform - lookup for bases classes. - 2. If simple class name is provided and multiple classes of the same name but from - different modules have registered convertors, the first one found is used. If you - want to avoid this situation, use full names. + 1. If the class name is NOT registered via `register_class()`, MRO lookup for + base class convertors is not possible if an exact name match isn't found. + 2. If a simple class name is provided and is ambiguous (multiple registered + classes with the same name), the first match found is used. Use full names + ('module.ClassName') for clarity in such cases. Raises: - TypeError: If there is no convertor for `cls` or any from its bases classes. + TypeError: If no convertor is found for `cls` or any of its base classes (when MRO lookup is possible). + ValueError: Often raised by the underlying `from_str` function if the string + `value` is not in the expected format for the target type (e.g., + converting 'abc' to int). + + Example: + ```python + from decimal import Decimal + from uuid import UUID + from firebird.base.strconv import convert_from_str + + num = convert_from_str(int, '123') # Output: 123 (int) + dec = convert_from_str(Decimal, '1.2') # Output: Decimal('1.2') + flag = convert_from_str(bool, 'off') # Output: False (bool) + uid = convert_from_str(UUID, '...') # Output: UUID object + # Using string name + dec_from_name = convert_from_str('Decimal', '3.14') # Output: Decimal('3.14') + + try: + convert_from_str(int, 'not-a-number') + except ValueError as e: + print(e) # Example: invalid literal for int() with base 10: 'not-a-number' + ``` """ return get_convertor(cls).from_str(cls, value) -def get_convertor(cls: Union[Type, str]) -> Convertor: - """Returns Convertor for data type. +def get_convertor(cls: type | str) -> Convertor: + """"Returns the Convertor object registered for a data type or its bases. + + This function performs the lookup based on the type or type name, including + MRO search for base classes if necessary and possible. It is used internally + by `convert_to_str` and `convert_from_str`, but can be called directly + if you need access to the `Convertor` instance itself, for example, + for introspection or direct access to the `to_str`/`from_str` functions. Arguments: - cls: Type or type name. The name could be simple class name, or full name that includes - the module name. + cls: Type object or type name. The name could be a simple class name + (e.g., "MyClass") or a full name including the module + (e.g., "my_module.MyClass"). Note: - When `cls` is a type name: + When `cls` is a name: - 1. If class name is NOT registered via `register_class()`, it's not possible to perform - lookup for bases classes. - 2. If simple class name is provided and multiple classes of the same name but from - different modules have registered convertors, the first one found is used. If you - want to avoid this situation, use full names. + 1. If the class name is NOT registered via `register_class()`, MRO lookup for + base class convertors is not possible if an exact name match isn't found. + 2. If a simple class name is provided and is ambiguous (multiple registered + classes with the same name), the first match found is used. Use full names + for clarity. Raises: - TypeError: If there is no convertor for `cls` or any from its bases classes. + TypeError: If no convertor is found for `cls` or any of its base classes. + + Example: + ```python + from decimal import Decimal + from firebird.base.strconv import get_convertor + + decimal_conv = get_convertor(Decimal) + print(decimal_conv.name) # Output: Decimal + print(decimal_conv.to_str(Decimal('9.87'))) # Output: 9.87 + + bool_conv = get_convertor('bool') # Lookup by name + print(bool_conv.from_str(bool, 'TRUE')) # Output: True + ``` """ if (conv := _get_convertor(cls)) is None: raise TypeError(f"Type '{cls.__name__ if isinstance(cls, type) else cls}' has no Convertor") return conv -def _register(): +def _register() -> None: """Internal function for registration of builtin converters.""" - def bool2str(value: bool) -> str: + def bool2str(value: bool) -> str: # noqa: FBT001 return TRUE_STR[0] if value else FALSE_STR[0] - def str2bool(type_: Type, value: str) -> bool: # pylint: disable=[W0613] + def str2bool(type_: type, value: str) -> bool: # noqa: ARG001 if (v := value.lower()) in TRUE_STR: return True if v not in FALSE_STR: raise ValueError("Value is not a valid bool string constant") return False - def str2decimal(type_: Type, value: str) -> Decimal: + def str2decimal(type_: type, value: str) -> Decimal: try: return type_(value) except DecimalException as exc: @@ -245,9 +440,24 @@ def str2decimal(type_: Type, value: str) -> Decimal: def enum2str(value: Enum) -> str: "Converts any Enum/Flag value to string" return value.name - def str2enum(cls: Type, value: str) -> Enum: - "Converts string to Enum/Flag value" - return {k.lower(): v for k, v in cls.__members__.items()}[value.lower()] + def str2enum(cls: type, value: str) -> Enum: + "Converts string to Enum/Flag value (case-insensitive)." + # Use get for better error message if key not found + members_lower = {k.lower(): v for k, v in cls.__members__.items()} + member = members_lower.get(value.lower()) + if member is None: + raise ValueError(f"'{value}' is not a valid member of enum {cls.__name__}") + return member + def str2flag(cls: type, value: str) -> Enum: + "Converts pipe-separated string to IntFlag value (case-insensitive)." + result = None + for item in value.lower().split('|'): + value = {k.lower(): v for k, v in cls.__members__.items()}[item.strip()] + if result: + result |= value + else: + result = value + return result register_convertor(str) register_convertor(int) @@ -261,7 +471,7 @@ def str2enum(cls: Type, value: str) -> Enum: register_convertor(Enum, to_str=enum2str, from_str=str2enum) # We must register IntEnum and IntFlag because 'int' is before Enum in MRO register_convertor(IntEnum, to_str=enum2str, from_str=str2enum) - register_convertor(IntFlag, to_str=enum2str, from_str=str2enum) + register_convertor(IntFlag, to_str=enum2str, from_str=str2flag) _register() del _register diff --git a/src/firebird/base/trace.py b/src/firebird/base/trace.py index 95dc902..a24ef4e 100644 --- a/src/firebird/base/trace.py +++ b/src/firebird/base/trace.py @@ -33,180 +33,243 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________ -"""firebird-base - Trace/audit for class instances +"""This module provides trace/audit logging for functions or object methods through context-based +logging provided by [firebird.base.logging][] module. + +The trace logging is performed by traced decorator. You can use this decorator directly, +or use `TracedMixin` class to automatically decorate methods of class instances on creation. +Each decorated callable could log messages before execution, after successful execution or +on failed execution (when unhandled execption is raised by callable). The trace decorator +can automatically add agent and context information, and include parameters passed to callable, +execution time, return value, information about raised exception etc. to log messages. + +The trace logging is managed by `TraceManager`, that allows dynamic configuration of traced +callables at runtime. """ from __future__ import annotations -from typing import Any, Type, Hashable, List, Dict, Callable + import os -from inspect import signature, Signature, isfunction +from collections.abc import Callable, Hashable +from configparser import ConfigParser from dataclasses import dataclass, field +from decimal import Decimal from enum import IntFlag, auto -from functools import wraps, partial +from functools import partial, wraps +from inspect import Signature, isfunction, signature from time import monotonic -from decimal import Decimal -from configparser import ConfigParser -from .types import Error, Distinct, DEFAULT, UNLIMITED, load -from .collections import Registry -from .strconv import convert_from_str -from .config import StrOption, IntOption, BoolOption, ListOption, FlagOption, EnumOption, \ - ConfigListOption, Config -from .logging import LogLevel, FBLoggerAdapter, get_logger +from typing import Any + +from firebird.base.collections import Registry +from firebird.base.config import ( + BoolOption, + Config, + ConfigListOption, + EnumOption, + FlagOption, + IntOption, + ListOption, + StrOption, +) +from firebird.base.logging import ContextLoggerAdapter, FStrMessage, LogLevel, get_logger +from firebird.base.strconv import convert_from_str +from firebird.base.types import DEFAULT, UNLIMITED, Distinct, Error, load class TraceFlag(IntFlag): - """`LoggingManager` trace/audit flags. + """Flags controlling the behavior of the `traced` decorator and `TraceManager`. + + These flags determine whether tracing is active and which parts of a call + (before, after success, after failure) should be logged. """ NONE = 0 + "No tracing enabled by default flags." ACTIVE = auto() + "Master switch; tracing is performed only if ACTIVE is set." BEFORE = auto() + "Log message before the decorated callable executes." AFTER = auto() + "Log message after the decorated callable successfully returns." FAIL = auto() + "Log message if the decorated callable raises an exception." @dataclass class TracedItem(Distinct): - """Class method trace specification. + """Holds the trace specification for a single method within a registered class. + + Stored by `TraceManager` for each method configured via `add_trace` or + `load_config`. Applied by `trace_object`. + + Arguments: + method: The name of the method to be traced. + decorator: The decorator callable (usually `traced` or a custom one) to apply. + args: Positional arguments to pass to the decorator factory. + kwargs: Keyword arguments to pass to the decorator factory. """ method: str + "The name of the method to be traced." decorator: Callable - args: List = field(default_factory=list) - kwargs: Dict = field(default_factory=dict) + "The decorator callable (usually `traced` or a custom one) to apply." + args: list[Any] = field(default_factory=list) + "Positional arguments to pass to the decorator factory." + kwargs: dict[str, Any] = field(default_factory=dict) + "Keyword arguments to pass to the decorator factory." def get_key(self) -> Hashable: """Returns Distinct key for traced item [method].""" return self.method @dataclass class TracedClass(Distinct): - """Traced class registry entry. + """Represents a class registered for tracing within the `TraceManager`. + + Holds a registry (`Registry[TracedItem]`) of trace specifications for + methods belonging to this class. + + Arguments: + cls: The class type registered for tracing. + traced: A registry mapping method names to `TracedItem` specifications. """ - cls: Type + cls: type + "The class type registered for tracing." traced: Registry = field(default_factory=Registry) + "A registry mapping method names to `TracedItem` specifications." def get_key(self) -> Hashable: """Returns Distinct key for traced item [cls].""" return self.cls -_traced: Registry = Registry() - class TracedMeta(type): """Metaclass that instruments instances on creation. """ - def __call__(cls: Type, *args, **kwargs): + def __call__(cls: type, *args, **kwargs): return trace_object(super().__call__(*args, **kwargs), strict=True) class TracedMixin(metaclass=TracedMeta): - """Mixin class that automatically registers descendants for trace and instruments - instances on creation. + """Mixin class to automatically enable tracing for descendants. + + Subclasses inheriting from `TracedMixin` are automatically registered with the + `trace_manager` upon definition. When instances of these subclasses are created, + their methods are automatically instrumented by `trace_object` according to the + currently active trace specifications in the `trace_manager`. """ - def __init_subclass__(cls: Type, /, **kwargs) -> None: + def __init_subclass__(cls: type, /, **kwargs) -> None: super().__init_subclass__(**kwargs) trace_manager.register(cls) -class traced: # pylint: disable=[R0902] - """Base decorator for logging of callables, suitable for trace/audit. - - It's not applied on decorated function/method if `FBASE_TRACE` environment variable is - set to False, or if `FBASE_TRACE` is not defined and `__debug__` is False (optimized - Python code). - - Both positional and keyword arguments of decorated callable are available by name for - f-string type message interpolation as `dict` passed to logger as positional argument. +class traced: # noqa: N801 + """Decorator factory for adding trace/audit logging to callables. + + Creates a decorator that wraps a function or method to log messages + before execution, after successful execution, and/or upon failure, + based on configured flags and messages. Integrates with the + [firebird.base.logging][] context logger. + + Note: + The decorator is **only applied** if tracing is globally enabled via + the `FBASE_TRACE` environment variable or if `__debug__` is true + (i.e., Python is not run with -O). If disabled globally, the original + un-decorated function is returned. Runtime behavior (whether logs + are actually emitted) is further controlled by `TraceManager.flags`. + + Arguments: + agent: Agent identifier for logging context (object or string). + If `DEFAULT`, uses `self` for methods or `'function'` otherwise. + topic: Logging topic. + msg_before: Format string (f-string style) for log message before execution. + If `DEFAULT`, a standard message is generated. + msg_after: Format string for log message after successful execution. Available + context includes `_etime_` (execution time string) and `_result_` + (return value, if `has_result` is true). If `DEFAULT`, a standard + message is generated. + msg_failed: Format string for log message on exception. Available context includes + `_etime_` and `_exc_` (exception string). If `DEFAULT`, a standard + message is generated. + flags: `TraceFlag` values to override `TraceManager.flags` for this specific + decorator instance. Allows fine-grained control per traced callable. + level: `LogLevel` for trace messages. + max_param_length: Max length for string representation of parameters/result + in logs. Longer values are truncated. + extra: Dictionary of extra data to add to the `LogRecord`. + callback: Optional callable `func(agent) -> bool`. If provided, it's called + before logging to check if tracing is permitted for this specific call. + has_result: Boolean or `DEFAULT`. If True, include result in `msg_after`. + If `DEFAULT`, inferred from function's return type annotation + (considered True unless annotation is `None`). + with_args: If True (default), make function arguments available by name for + interpolation in `msg_before`. """ - def __init__(self, *, agent: Any=DEFAULT, context: Any=DEFAULT, topic: str='trace', - msg_before: str=DEFAULT, msg_after: str=DEFAULT, msg_failed: str=DEFAULT, - flags: TraceFlag=TraceFlag(0), level: LogLevel=LogLevel.DEBUG, - max_param_length: int=UNLIMITED, extra: Dict=None, - callback: Callable[[Any], bool]=None, has_result: bool=DEFAULT, - with_args: bool=True): - """ - Arguments: - agent: Agent identification - context: Context identification - topic: Trace/audit logging topic - msg_before: Trace/audit message logged before decorated function - msg_after: Trace/audit message logged after decorated function - msg_failed: Trace/audit message logged when decorated function raises an exception - flags: Trace flags override - level: Logging level for trace/audit messages - max_param_length: Max. length of parameters (longer will be trimmed) - extra: Extra data for `LogRecord` - callback: Callback function that gets the agent identification as argument, - and must return True/False indicating whether trace is allowed. - has_result: Indicator whether function has result value. If True, `_result_` - is available for interpolation in `msg_after`. The `DEFAULT` value means, - that value for this argument should be decided from function return value - annotation. - with_args: If True, function arguments are available for interpolation in - `msg_before`. - """ - #: Trace/audit message logged before decorated function - self.msg_before: str = msg_before - #: Trace/audit message logged after decorated function - self.msg_after: str = msg_after - #: Trace/audit message logged when decorated function raises an exception - self.msg_failed: str = msg_failed - #: Agent identification - self.agent: Any = agent - #: Context identification - self.context: Any = context - #: Trace/audit logging topic + def __init__(self, *, agent: Any | DEFAULT=DEFAULT, topic: str='trace', + msg_before: str | DEFAULT=DEFAULT, msg_after: str | DEFAULT=DEFAULT, + msg_failed: str | DEFAULT=DEFAULT, flags: TraceFlag=TraceFlag.NONE, + level: LogLevel=LogLevel.DEBUG, max_param_length: int | UNLIMITED=UNLIMITED, + extra: dict | None=None, callback: Callable[[Any], bool] | None=None, + has_result: bool | DEFAULT=DEFAULT, with_args: bool=True): + self.msg_before: str | DEFAULT = msg_before + "Trace/audit message logged before decorated function" + self.msg_after: str | DEFAULT = msg_after + "Trace/audit message logged after decorated function" + self.msg_failed: str | DEFAULT = msg_failed + "Trace/audit message logged when decorated function raises an exception" + self.agent: Any | DEFAULT = agent + "Agent identification" self.topic: str = topic - #: Trace flags override + "Trace/audit logging topic" self.flags: TraceFlag = flags - #: Logging level for trace/audit messages + "Trace flags override" self.level: LogLevel = level - #: Max. length of parameters (longer will be trimmed) - self.max_len: int = max_param_length - #: Extra data for `LogRecord` - self.extra: Dict = extra - #: Callback function that gets the agent identification as argument, - #: and must return True/False indicating whether trace is allowed. + "Logging level for trace/audit messages" + self.max_len: int | UNLIMITED = max_param_length + "Max. length of parameters (longer will be trimmed)" + self.extra: dict[str, Any] = extra + "Extra data for `LogRecord`" self.callback: Callable[[Any], bool] = self.__callback if callback is None else callback - #: Indicator whether function has result value. If True, `_result_` is available - #: for interpolation in `msg_after`. - self.has_result: bool = has_result - #: If True, function arguments are available for interpolation in `msg_before` + """Callback function that gets the agent identification as argument, + and must return True/False indicating whether trace is allowed.""" + self.has_result: bool | DEFAULT= has_result + """Indicator whether function has result value. If True, `_result_` is available + for interpolation in `msg_after`.""" self.with_args: bool = with_args - def __callback(self, agent: Any) -> bool: # pylint: disable=[W0613] - """Default callback, does nothing. - """ + "If True, function arguments are available for interpolation in `msg_before`" + def __callback(self, agent: Any) -> bool: # noqa: ARG002 return True def set_before_msg(self, fn: Callable, sig: Signature) -> None: - """Sets the DEFAULT before message f-string template. - """ + """Generate the default log message template for before execution.""" if self.with_args: self.msg_before = f">>> {fn.__name__}({', '.join(f'{{{x}=}}' for x in sig.parameters if x != 'self')})" else: self.msg_before = f">>> {fn.__name__}" - def set_after_msg(self, fn: Callable, sig: Signature) -> None: # pylint: disable=[W0613] - """Sets the DEFAULT after message f-string template. - """ - self.msg_after = f"<<< {fn.__name__}[{{_etime_}}] Result: {{_result_}}" \ + def set_after_msg(self, fn: Callable, sig: Signature) -> None: # noqa: ARG002 + """Generate the default log message template for successful execution.""" + self.msg_after = f"<<< {fn.__name__}[{{_etime_}}] Result: {{_result_!r}}" \ if self.has_result else f"<<< {fn.__name__}[{{_etime_}}]" - def set_fail_msg(self, fn: Callable, sig: Signature) -> None: # pylint: disable=[W0613] - """Sets the DEFAULT fail message f-string template. - """ + def set_fail_msg(self, fn: Callable, sig: Signature) -> None: # noqa: ARG002 + """Generate the default log message template for failed execution.""" self.msg_failed = f"<-- {fn.__name__}[{{_etime_}}] {{_exc_}}" - def log_before(self, logger: FBLoggerAdapter, params: Dict) -> None: - """Executed before decorated callable. - """ - logger.log(self.level, self.msg_before, params, stacklevel=2) - def log_after(self, logger: FBLoggerAdapter, params: Dict) -> None: - """Executed after decorated callable. - """ - logger.log(self.level, self.msg_after, params, stacklevel=2) - def log_failed(self, logger: FBLoggerAdapter, params: Dict) -> None: - """Executed when decorated callable raises an exception. - """ - logger.log(self.level, self.msg_failed, params, stacklevel=2) - def __call__(self, fn: Callable): # pylint: disable=[R0915] + def log_before(self, logger: ContextLoggerAdapter, params: dict) -> None: + """Log the 'before' message using the configured template and logger.""" + logger.log(self.level, FStrMessage(self.msg_before, params)) + def log_after(self, logger: ContextLoggerAdapter, params: dict) -> None: + """Log the 'after' message using the configured template and logger.""" + logger.log(self.level, FStrMessage(self.msg_after, params)) + def log_failed(self, logger: ContextLoggerAdapter, params: dict) -> None: + """Log the 'failed' message using the configured template and logger.""" + logger.log(self.level, FStrMessage(self.msg_failed, params)) + def __call__(self, fn: Callable) -> Callable: @wraps(fn) - def wrapper(*args, **kwargs): # pylint: disable=[R0912] + def wrapper(*args, **kwargs) -> Any: + """The actual wrapper function applied to the decorated callable. + + Checks runtime flags, prepares parameters, logs messages according + to flags (before/after/fail), measures execution time, and handles + exceptions for logging purposes before re-raising them. + """ + # Combine global flags with decorator-specific overrides flags = trace_manager.flags | self.flags - if enabled := ((TraceFlag.ACTIVE in flags) and int(flags) > 1): # pylint: disable=[R1702] + # Check if ACTIVE flag is set AND at least one logging flag (BEFORE/AFTER/FAIL) is set + if enabled := ((TraceFlag.ACTIVE in flags) and int(flags) > 1): params = {} bound = sig.bind_partial(*args, **kwargs) # If it's not a bound method, look for 'self' log = get_logger(bound.arguments.get('self', 'function') if self.agent is None - else self.agent, self.context, self.topic) + else self.agent, self.topic) if enabled := (log.isEnabledFor(self.level) and self.callback(self.agent)): if self.with_args: bound.apply_defaults() @@ -265,120 +328,144 @@ def wrapper(*args, **kwargs): # pylint: disable=[R0912] self.set_fail_msg(fn, sig) return wrapper - -class BaseTraceConfig(Config): # pylint: disable=[R0902] - """Base configuration for trace. +class BaseTraceConfig(Config): + """Base class defining common configuration options for trace settings. + + Used as a base for global trace config, per-class config, and per-method config. + Corresponds typically to settings within a section of a configuration file. + + Options: + - agent: StrOption -> Agent identification + - topic: StrOption -> Trace/audit logging topic + - msg_before: StrOption -> Trace/audit message logged before decorated function + - msg_after: StrOption -> Trace/audit message logged after decorated function + - msg_failed: StrOption -> Trace/audit message logged when decorated function raises an exception + - flags: FlagOption[TraceFlag] -> Trace flags override + - level: EnumOption[LogLevel] -> Logging level for trace/audit messages + - max_param_length: IntOption -> Max. length of parameters (longer will be trimmed) + - has_result: BoolOption -> Indicator whether function has result value + - with_args: BoolOption -> If True, function arguments are available for interpolation in `msg_before` """ def __init__(self, name: str): super().__init__(name) - #: Agent identification self.agent: StrOption = \ StrOption('agent', "Agent identification") - #: Context identification - self.context: StrOption = \ - StrOption('context', "Context identification") - #: Trace/audit logging topic self.topic: StrOption = \ StrOption('topic', "Trace/audit logging topic") - #: Trace/audit message logged before decorated function self.msg_before: StrOption = \ StrOption('msg_before', "Trace/audit message logged before decorated function") - #: Trace/audit message logged after decorated function self.msg_after: StrOption = \ StrOption('msg_after', "Trace/audit message logged after decorated function") - #: Trace/audit message logged when decorated function raises an exception self.msg_failed: StrOption = \ StrOption('msg_failed', "Trace/audit message logged when decorated function raises an exception") - #: Trace flags override self.flags: FlagOption = \ FlagOption('flags', TraceFlag, "Trace flags override") - #: Logging level for trace/audit messages self.level: EnumOption = \ EnumOption('level', LogLevel, "Logging level for trace/audit messages") - #: Max. length of parameters (longer will be trimmed) self.max_param_length: IntOption = \ IntOption('max_param_length', "Max. length of parameters (longer will be trimmed)") - #: Indicator whether function has result value self.has_result: BoolOption = \ BoolOption('has_result', "Indicator whether function has result value") - #: If True, function arguments are available for interpolation in `msg_before` self.with_args: BoolOption = \ BoolOption('with_args', "If True, function arguments are available for interpolation in `msg_before`") class TracedMethodConfig(BaseTraceConfig): - """Configuration of traced Python method. + """Defines the structure for a configuration section specifying trace + settings specific to a single class method. + + Used within `TracedClassConfig.special` list. The section name itself is + referenced in the parent `TracedClassConfig` section. + + Options: + - method: StrOption -> Class method name [required] """ def __init__(self, name: str): super().__init__(name) - #: Class method name [required] self.method: StrOption = \ StrOption('method', "Class method name", required=True) class TracedClassConfig(BaseTraceConfig): - """Configuration of traced Python class. + """Defines the structure for a configuration section specifying trace + settings for a Python class and its methods. + + The section name itself is referenced in the main `TraceConfig` section. + See the module documentation for an example INI structure. + + Options: + - source: StrOption -> Fully qualified class name [required] + - methods: ListOption[str] -> Names of traced class methods + - special: ConfigListOption[TracedMethodConfig] -> Configuration sections with extended config of traced class methods + - apply_to_descendants: BoolOption -> Configuration should be applied also to all registered descendant classes """ def __init__(self, name: str): super().__init__(name) - #: Fully qualified class name [required] self.source: StrOption = \ StrOption('source', "Fully qualified class name", required=True) - #: Names of traced class methods self.methods: ListOption = \ ListOption('methods', str, "Names of traced class methods") - #: Configuration sections with extended config of traced class methods self.special: ConfigListOption = \ - ConfigListOption('special', - "Configuration sections with extended config of traced class methods", - TracedMethodConfig) - #: Wherher configuration should be applied also to all registered descendant classes [default: True]. + ConfigListOption('special', TracedMethodConfig, + "Configuration sections with extended config of traced class methods") self.apply_to_descendants: BoolOption = \ BoolOption('apply_to_descendants', "Configuration should be applied also to all registered descendant classes", default=True) class TraceConfig(BaseTraceConfig): - """Trace manager configuration. + """Defines the structure for the main trace configuration section (typically `[trace]`). + + Holds global default trace settings and lists the sections defining specific + traced classes. See the module documentation for an example INI structure. + + Options: + - autoregister: BoolOption -> When True, unregistered classes are registered automatically + - classes: ConfigListOption[TracedClassConfig] -> Configuration sections with traced Python classes """ def __init__(self, name: str): super().__init__(name) - #: When True, unregistered classes are registered automatically [default: True]. self.autoregister: BoolOption = \ BoolOption('autoregister', "When True, unregistered classes are registered automatically", default=True) - #: Configuration sections with traced Python classes [required]. self.classes: ConfigListOption = \ - ConfigListOption('classes', + ConfigListOption('classes', TracedClassConfig, "Configuration sections with traced Python classes", - TracedClassConfig, required=True) + required=True) class TraceManager: """Trace manager. """ def __init__(self): - #: Decorator that should be used for trace instrumentation (via `add_trace`), - #: default: `traced`. self.decorator: Callable = traced + "Decorator factory used by `add_trace` (default: `traced`). Can be replaced." self._traced: Registry = Registry() + "Internal registry storing `TracedClass` specifications." self._flags: TraceFlag = TraceFlag.NONE - self.trace_active = convert_from_str(bool, os.getenv('FBASE_TRACE', str(__debug__))) + "Current runtime trace flags, controlling overall behavior." + # Initialize flags based on environment variables (FBASE_TRACE_*) and __debug__ + # Active flag + self.trace_active: bool = convert_from_str(bool, os.getenv('FBASE_TRACE', str(__debug__))) + "Active flag" + # Specific logging flags if convert_from_str(bool, os.getenv('FBASE_TRACE_BEFORE', 'no')): # pragma: no cover self.set_flag(TraceFlag.BEFORE) if convert_from_str(bool, os.getenv('FBASE_TRACE_AFTER', 'no')): # pragma: no cover self.set_flag(TraceFlag.AFTER) + # Note: FAIL is enabled by default unless FBASE_TRACE_FAIL is explicitly 'no' if convert_from_str(bool, os.getenv('FBASE_TRACE_FAIL', 'yes')): self.set_flag(TraceFlag.FAIL) - def is_registered(self, cls: Type) -> bool: + def is_registered(self, cls: type) -> bool: """Return True if class is registered. """ return cls in self._traced def clear(self) -> None: """Removes all trace specifications. """ + cls: TracedClass for cls in self._traced: cls.traced.clear() - def register(self, cls: Type) -> None: + def register(self, cls: type) -> None: """Register class for trace. Arguments: @@ -388,17 +475,22 @@ def register(self, cls: Type) -> None: """ if cls not in self._traced: self._traced.store(TracedClass(cls)) - def add_trace(self, cls: Type, method: str, / , *args, **kwargs) -> None: - """Add/update trace specification for class method. + def add_trace(self, cls: type, method: str, / , *args, **kwargs) -> None: + """Store or update the trace specification for a specific class method. + + Registers how a method should be decorated (using `self.decorator`) when + `trace_object` is called on an instance of `cls` or its registered descendants. + This specification can be overridden or augmented by settings loaded via + `load_config`. Arguments: - cls: Registered traced class - method: Name of class method that should be instrumented for trace - args: Positional arguments for decorator - kwargs: Keyword arguments for decorator + cls: Registered traced class type. + method: The name of the method within `cls` to trace. + *args: Positional arguments for the decorator factory (`self.decorator`). + **kwargs: Keyword arguments for the decorator factory (`self.decorator`). """ self._traced[cls].traced.update(TracedItem(method, self.decorator, args, kwargs)) - def remove_trace(self, cls: Type, method: str) -> None: + def remove_trace(self, cls: type, method: str) -> None: """Remove trace specification for class method. Arguments: @@ -407,19 +499,23 @@ def remove_trace(self, cls: Type, method: str) -> None: """ del self._traced[cls].traced[method] def trace_object(self, obj: Any, *, strict: bool=False) -> Any: - """Instruments object's methods with decorator according to trace configuration. + """Apply registered trace decorators to the methods of an object instance. - Arguments: - strict: Determines the response if the object class is not registered for trace. - Raises exception when True, or return the instance as is when False [default]. + Iterates through the trace specifications (`TracedItem`) registered for the + object's class (via `add_trace` or `load_config`). For each specification, + it wraps the corresponding method on the `obj` instance using the specified + decorator and arguments. Modifies the object *in place*. - Only methods registered with `.add_trace()` are instrumented. + Arguments: + obj: The object instance whose methods should be instrumented. + strict: If True, raise TypeError if `obj`'s class is not registered. + If False (default), return `obj` unmodified if not registered. Returns: - Decorated instance. + The (potentially modified) object instance `obj`. Raises: - TypeError: When object class is not registered and `strict` is True. + TypeError: If `obj`'s class is not registered and `strict` is True. """ if (trace := os.getenv('FBASE_TRACE')) is not None: if not convert_from_str(bool, trace): @@ -431,26 +527,35 @@ def trace_object(self, obj: Any, *, strict: bool=False) -> Any: if strict: raise TypeError(f"Class '{obj.__class__.__name__}' not registered for trace!") return obj + item: TracedItem for item in entry.traced: setattr(obj, item.method, item.decorator(*item.args, **item.kwargs)(getattr(obj, item.method))) return obj def load_config(self, config: ConfigParser, section: str='trace') -> None: - """Update trace from configuration. + """Load and apply trace configurations from a `ConfigParser` instance. - Arguments: - config: ConfigParser instance with trace configuration. - section: Name of ConfigParser section that should be used to get trace - configuration. + Parses the specified `section` (and referenced sub-sections) using the + `TraceConfig`, `TracedClassConfig`, and `TracedMethodConfig` structures. + Updates the `TraceManager`'s flags and trace specifications (`add_trace`). - Uses `.TraceConfig`, `.TracedClassConfig` and `.TracedMethodConfig` to process - the configuration. + Arguments: + config: `ConfigParser` instance containing the trace configuration. + section: Name of the main trace configuration section. Note: - Does not `.clear()` existing trace specifications. + This method **adds to or updates** existing trace specifications. It does + not clear previous configurations unless the loaded configuration explicitly + overwrites specific settings. + + Raises: + Error: If configuration references a class that is not registered and + `autoregister` is False, or if the class cannot be loaded via `load()`. + KeyError, ValueError: If the configuration file structure is invalid or + contains invalid values according to the `Option` types. """ - def build_kwargs(from_cfg: BaseTraceConfig) -> Dict[str, Any]: + def build_kwargs(from_cfg: BaseTraceConfig) -> dict[str, Any]: result = {} - for item in ['agent', 'context', 'topic', 'msg_before', 'msg_after', + for item in ['agent', 'topic', 'msg_before', 'msg_after', 'msg_failed', 'flags', 'level', 'max_param_length', 'has_result', 'with_args']: if (value := getattr(from_cfg, item).value) is not None: @@ -482,6 +587,7 @@ def with_name(name: str, obj: Any) -> bool: cls_kwargs = {} cls_kwargs.update(global_kwargs) cls_kwargs.update(build_kwargs(cls_cfg)) + cls_desc: TracedClass if (cls_desc := self._traced.find(partial(with_name, cls_name))) is None: if cfg.autoregister.value: cls = load(':'.join(cls_name.rsplit('.', 1))) @@ -523,12 +629,12 @@ def trace_active(self, value: bool) -> None: else: self._flags &= ~TraceFlag.ACTIVE -#: Trace manager trace_manager: TraceManager = TraceManager() +"Trace manager singleton instance." -#: shortcut for `trace_manager.add_trace()` add_trace = trace_manager.add_trace -#: shortcut for `trace_manager.remove_trace()` +"Shortcut for `trace_manager.add_trace()`" remove_trace = trace_manager.remove_trace -#: shortcut for `trace_manager.trace_object()` +"Shortcut for `trace_manager.remove_trace()`" trace_object = trace_manager.trace_object +"Shortcut for `trace_manager.trace_object()`" diff --git a/src/firebird/base/types.py b/src/firebird/base/types.py index 2fa8c2a..0c1b6e8 100644 --- a/src/firebird/base/types.py +++ b/src/firebird/base/types.py @@ -31,32 +31,59 @@ # All Rights Reserved. # # Contributor(s): Pavel Císař (original code) +# Tom Bulled (new Sentinels) # ______________________________________ -"""Firebird Base - Types +"""This module provides fundamental building blocks used across the `firebird-base` +package and potentially other Firebird Python projects. It includes: + +- A custom base exception class ([Error][firebird.base.types.Error]). +- Utilities for creating Singletons ([Singleton][firebird.base.types.Singleton]). +- A robust implementation for Sentinel objects ([Sentinel][firebird.base.types.Sentinel]) + and common predefined sentinels. +- Base classes for objects with distinct identities based on keys + ([Distinct][firebird.base.types.Distinct], [CachedDistinct][firebird.base.types.CachedDistinct]). +- Enumerations for specific concepts ([ByteOrder][firebird.base.types.ByteOrder], + [ZMQTransport][firebird.base.types.ZMQTransport], [ZMQDomain][firebird.base.types.ZMQDomain]). +- Enhanced string types with validation and added functionality ([ZMQAddress][firebird.base.types.ZMQAddress], + [MIME][firebird.base.types.MIME], [PyExpr][firebird.base.types.PyExpr], [PyCode][firebird.base.types.PyCode], + [PyCallable][firebird.base.types.PyCallable]). +- Metaclass utilities ([conjunctive][firebird.base.types.conjunctive]). +- Helper functions ([load][firebird.base.types.load]). """ from __future__ import annotations -from typing import Any, Dict, Hashable, Callable, AnyStr, cast, Type -from abc import ABC, ABCMeta, abstractmethod + import sys -from importlib import import_module +import types +from abc import ABC, ABCMeta, abstractmethod +from collections.abc import Callable, Hashable from enum import Enum, IntEnum +from importlib import import_module +from typing import Any, AnyStr, ClassVar, Self, cast from weakref import WeakValueDictionary # Exceptions class Error(Exception): - """Exception that is intended to be used as a base class of all **application-related** - errors. The important difference from `Exception` class is that `Error` accepts keyword - arguments, that are stored into instance attributes with the same name. + """Exception intended as a base for application-related errors. + + Unlike the standard [Exception][], this class accepts arbitrary keyword + arguments during initialization. These keyword arguments are stored as + attributes on the exception instance. + + Attribute lookup on instances of [Error][firebird.base.types.Error] (or its subclasses) will return + `None` for any attribute that was not explicitly set via keyword arguments + during `__init__`, preventing [AttributeError][] for common checks. Important: Attribute lookup on this class never fails, as all attributes that are not actually - set, have `None` value. - - Example:: + set, have `None` value. The special attribute `__notes__` (used by `add_note` + since Python 3.11) is explicitly excluded from this behavior to ensure + compatibility. + Example: + ```python try: if condition: raise Error("Error message", err_code=1) @@ -67,31 +94,36 @@ class Error(Exception): ... elif e.err_code == 1: ... + ``` Note: - Warnings are not considered errors and thus should not use this class as base. + Warnings are not errors and should typically derive from [Warning][], + not this class. """ def __init__(self, *args, **kwargs): super().__init__(*args) for name, value in kwargs.items(): setattr(self, name, value) - def __getattr__(self, name): + def __getattr__(self, name) -> Any | None: + # Prevent AttributeError for unset attributes, default to None. + # Explicitly raise AttributeError for __notes__ to allow standard + # exception note handling to work correctly. if name == '__notes__': raise AttributeError - return None + return None # Default value for attributes not set in __init__ # Singletons _singletons_ = {} class SingletonMeta(type): - """Metaclass for `Singleton` classes. + """Metaclass for [Singleton][firebird.base.types.Singleton] classes. Manages internal cache of class instances. If instance for a class is in cache, it's returned without calling the constructor, otherwise the instance is created normally and stored in cache for later use. """ - def __call__(cls: Singleton, *args, **kwargs): + def __call__(cls: type[Singleton], *args, **kwargs) -> Singleton: name = f"{cls.__module__}.{cls.__qualname__}" obj = _singletons_.get(name) if obj is None: @@ -102,94 +134,268 @@ def __call__(cls: Singleton, *args, **kwargs): class Singleton(metaclass=SingletonMeta): """Base class for singletons. + Ensures that only one instance of a class derived from [Singleton][firebird.base.types.Singleton] exists. + Subsequent attempts to 'create' an instance will return the existing one. + Important: - If you create a descendant class that uses constructor arguments, these arguments - are meaningful ONLY on first call, because all subsequent calls simply return - an instance stored in cache without calling the constructor. + If a descendant class's `__init__` method accepts arguments, these + arguments are only used the **first** time the instance is created. + Subsequent calls that retrieve the cached instance will **not** invoke + `__init__` again. + + Example: + ```python + class MyService(Singleton): + def __init__(self, config_param=None): + if hasattr(self, '_initialized'): # Prevent re-init + return + print("Initializing MyService...") + self.config = config_param + self._initialized = True + + def do_something(self): + print(f"Doing something with config: {self.config}") + + service1 = MyService("config1") # Prints "Initializing MyService..." + service2 = MyService("config2") # Does *not* print, returns existing instance + + print(service1 is service2) # Output: True + service2.do_something() # Output: Doing something with config: config1 + ``` """ # Sentinels -class SentinelMeta(type): - """Metaclass for `Sentinel`. +class _SentinelMeta(type): + """Metaclass for Sentinel objects. + + This metaclass ensures that classes defined using it behave as + proper sentinels: + + - They cannot be instantiated directly (e.g., `MySentinel()`). + - They cannot be subclassed after initial definition. + - Provides a basic `__repr__` and `__str__` based on the class name. + - Allows defining sentinels via class definition (`class NAME(Sentinel): ...`) + or potentially a functional call (though class definition is preferred). + - Neuters `__call__` inherited from `type` to prevent unintended behavior. """ - def __call__(cls: Sentinel, *args, **kwargs): - name = args[0].upper() - obj = cls.instances.get(name) - if obj is None: - obj = super().__call__(*args, **kwargs) - cls.instances[name] = obj - return obj -class Sentinel(metaclass=SentinelMeta): - """Simple sentinel object. + def __new__(metaclass, name, bases, namespace): # noqa: N804 + def __new__(cls, *args, **kwargs): # noqa: N807, ARG001 + raise TypeError(f'Cannot initialise or subclass sentinel {cls.__name__!r}') + cls = super().__new__(metaclass, name, bases, namespace) + # We are creating a sentinel, neuter it appropriately + if type(metaclass) is metaclass: + cls_call = getattr(cls, '__call__', None) # noqa B004 + metaclass_call = getattr(metaclass, '__call__', None) # noqa B004 + # If the class did not provide it's own `__call__` + # and therefore inherited the `__call__` belongining + # to it's metaclass, get rid of it. + # This prevents sentinels inheriting the Functional API. + if cls_call is not None and cls_call is metaclass_call: + cls.__call__ = super().__call__ + # Neuter the sentinel's `__new__` to prevent it + # from being initialised or subclassed + cls.__new__ = __new__ + # Sentinel classes must derive from their metaclass, + # otherwise the object layout will differ + if not issubclass(cls, metaclass): + raise TypeError(f'{metaclass.__name__!r} must also be derived from when provided as a metaclass') + cls.__class__ = cls + return cls + def __call__(cls, name, bases=None, namespace=None, /, *, repr=None) -> type[Sentinel]: # noqa: A002 + # Attempts to subclass/initialise derived classes will end up + # arriving here. + # In these cases, we simply redirect to `__new__` + if bases is not None: + return cls.__new__(cls, name, bases, namespace) + bases = (cls,) + namespace = {} + # If a custom `repr` was provided, create an appropriate + # `__repr__` method to be added to the sentinel class + if repr is not None: + def __repr__(cls): # noqa: ARG001, N807 + return repr + namespace['__repr__'] =__repr__ + return cls.__new__(cls, name, bases, namespace) + def __str__(cls): + return cls.__name__ + def __repr__(cls): + return cls.__name__ + @property + def name(cls): + return cls.__name__ + +class Sentinel(_SentinelMeta, metaclass=_SentinelMeta): + """Base class for creating unique sentinel objects. - Important: - All sentinels have name, that is **always in capital letters**. Sentinels with - the same name are singletons. + Sentinels are special singleton objects used to signal unique states or + conditions, particularly useful when `None` might be a valid data value. + They offer a more explicit and readable alternative to magic constants + or using `object()`. + + You can define specific sentinels in two primary ways: + + 1. **By Subclassing:** Inherit directly from `Sentinel`. The name of the + subclass becomes the sentinel's identity. + + ```python + class DEFAULT(Sentinel): + "Represents a default value placeholder." + + class ALL(Sentinel): + "Represents all possible values." + ``` + + This creates classes `DEFAULT` and `ALL`, each acting as a unique + sentinel object. + + 2. **Using the Functional Call:** Use the `Sentinel` base class itself + as a factory function. + + ```python + # Signature: Sentinel(name: str, *, repr: str | None = None) -> Sentinel + NOT_FOUND = Sentinel("NOT_FOUND", repr="") + UNKNOWN = Sentinel("UNKNOWN") + ``` + + - The required `name` argument (e.g., `"NOT_FOUND"`) specifies the + `__name__` of the dynamically created sentinel class. + - The optional `repr` keyword argument provides a custom string + to be returned by `repr()` for this specific sentinel. If omitted, + `repr()` defaults to the sentinel's name. + + This dynamically creates new classes derived from `Sentinel`, assigns + them to the variables (`NOT_FOUND`, `UNKNOWN`), and sets a custom + `__repr__` if provided. + + **Behavior:** + + Regardless of the creation method: + + - Each sentinel is a unique object (a class behaving as a singleton). + - Sentinels are identified using the `is` operator. + - They cannot be instantiated (e.g., `DEFAULT()` raises [TypeError][]). + - They cannot be subclassed further after their initial definition. + - `str(MySentinel)` returns the sentinel's name (`MySentinel.__name__`). + - `repr(MySentinel)` returns the custom `repr` if provided via the + functional call, otherwise it defaults to the sentinel's name. + + **Example Usage:** + + ```python + # Define using subclassing + class DEFAULT_SETTING(Sentinel): + "Indicates a setting should use its compiled-in default." + + # Define using functional call with custom repr + NOT_APPLICABLE = Sentinel("NOT_APPLICABLE", repr="") + + def get_config(key, user_override=NOT_APPLICABLE): + if user_override is NOT_APPLICABLE: + # User did not provide an override, check stored config + value = read_stored_config(key, default=DEFAULT_SETTING) + if value is DEFAULT_SETTING: + return get_hardcoded_default(key) + return value + else: + # User provided an override (which could be None) + return user_override + + config1 = get_config("timeout") # Uses stored or hardcoded default + config2 = get_config("retries", user_override=None) # Explicitly set to None + config3 = get_config("feature_flag", user_override=NOT_APPLICABLE) # Same as providing nothing + + print(repr(DEFAULT_SETTING)) # Output: DEFAULT_SETTING + print(repr(NOT_APPLICABLE)) # Output: + ``` """ - #: Class attribute with defined sentinels. There is no need to access or manipulate it. - instances = {} - def __init__(self, name: str): - """ - Arguments: - name: Sentinel name. - """ - #: Sentinel name. - self.name = name.upper() - def __str__(self): - """Returns name. - """ - return self.name - def __repr__(self): - """Returns Sentinel('name'). - """ - return f"Sentinel('{self.name}')" + # Note: The actual implementation relies on _SentinelMeta for the behaviors described. + # The methods like __str__, __repr__, name property are defined on the metaclass. # Useful sentinel objects -#: Sentinel that denotes default value -DEFAULT: Sentinel = Sentinel('DEFAULT') -#: Sentinel that denotes infinity value -INFINITY: Sentinel = Sentinel('INFINITY') -#: Sentinel that denotes unlimited value -UNLIMITED: Sentinel = Sentinel('UNLIMITED') -#: Sentinel that denotes unknown value -UNKNOWN: Sentinel = Sentinel('UNKNOWN') -#: Sentinel that denotes a condition when value was not found -NOT_FOUND: Sentinel = Sentinel('NOT_FOUND') -#: Sentinel that denotes explicitly undefined value -UNDEFINED: Sentinel = Sentinel('UNDEFINED') -#: Sentinel that denotes any value -ANY: Sentinel = Sentinel('ANY') -#: Sentinel that denotes all possible values -ALL: Sentinel = Sentinel('ALL') -#: Sentinel that denotes suspend request (in message queue) -SUSPEND: Sentinel = Sentinel('SUSPEND') -#: Sentinel that denotes resume request (in message queue) -RESUME: Sentinel = Sentinel('RESUME') -#: Sentinel that denotes stop request (in message queue) -STOP: Sentinel = Sentinel('STOP') +class DEFAULT(Sentinel): + "Sentinel that denotes default value" + +class INFINITY(Sentinel): + "Sentinel that denotes infinity value" + +class UNLIMITED(Sentinel): + "Sentinel that denotes unlimited value" + +class UNKNOWN(Sentinel): + "Sentinel that denotes unknown value" + +class NOT_FOUND(Sentinel): # noqa: N801 + "Sentinel that denotes a condition when value was not found" + +class UNDEFINED(Sentinel): + "Sentinel that denotes explicitly undefined value" + +class ANY(Sentinel): + "Sentinel that denotes any value" + +class ALL(Sentinel): + "Sentinel that denotes all possible values" + +class SUSPEND(Sentinel): + "Sentinel that denotes suspend request (in message queue)" + +class RESUME(Sentinel): + "Sentinel that denotes resume request (in message queue)" + +class STOP(Sentinel): + "Sentinel that denotes stop request (in message queue)" # Distinct objects class Distinct(ABC): - """Abstract base class for classes (incl. dataclasses) with distinct instances. + """Abstract base class for objects with distinct instances based on a key. + + Instances are considered equal (`==`) if their keys, returned by + `get_key()`, are equal. The hash of an instance is derived from the + hash of its key by default. + + Important: + If used with `@dataclass`, it must be defined with `eq=False` + to prevent overriding the custom `__eq__` and `__hash__` methods: + + Example: + ```python + from dataclasses import dataclass + + @dataclass(eq=False) + class MyDistinctData(Distinct): + id: int + name: str + + def get_key(self) -> Hashable: + return self.id + ``` """ @abstractmethod def get_key(self) -> Hashable: - """Returns instance key. + """Return the unique key identifying this instance. - Important: - The key is used for instance hash computation that by default uses the `hash` - function. If the key is not suitable argument for `hash`, you must provide your - own `__hash__` implementation as well! + The key must be hashable. It determines equality and hashing + behavior unless `__eq__` or `__hash__` are explicitly overridden. """ - __hash__ = lambda self: hash(self.get_key()) # pylint: disable=[C3001] + def __hash(self) -> int: + return hash(self.get_key()) + def __eq__(self, other) -> bool: + if isinstance(other, Distinct): + return self.get_key() == other.get_key() + return False + __hash__ = __hash class CachedDistinctMeta(ABCMeta): - """Metaclass for CachedDistinct. + """Metaclass for `CachedDistinct`. + + Intercepts class instantiation (`__call__`) to implement the instance + caching mechanism based on the key extracted by `cls.extract_key()`. + Ensures that only one instance exists per unique key. """ - def __call__(cls: CachedDistinct, *args, **kwargs): + def __call__(cls: type[CachedDistinct], *args, **kwargs) -> CachedDistinct: key = cls.extract_key(*args, **kwargs) obj = cls._instances_.get(key) if obj is None: @@ -198,16 +404,56 @@ def __call__(cls: CachedDistinct, *args, **kwargs): return obj class CachedDistinct(Distinct, metaclass=CachedDistinctMeta): - """Abstract `Distinct` descendant that caches instances. + """Abstract [Distinct][firebird.base.types.Distinct] descendant that caches instances. + + Behaves like [Distinct][firebird.base.types.Distinct], but ensures only one instance + is created per unique key. Subsequent attempts to create an instance with the same key + (as determined by `extract_key` from the constructor arguments) will + return the cached instance instead of creating a new one. + + Instances are stored in a class-level [weakref.WeakValueDictionary][], + allowing them to be garbage-collected if no longer referenced elsewhere. - All created instances are cached in `~weakref.WeakValueDictionary`. + Requires implementation of both `get_key()` (for instance equality/hashing) + and `extract_key()` (for retrieving the key from constructor arguments + **before** instance creation). These two methods should conceptually return + the same identifier for a given object identity. + + Important: + Like `Distinct`, if used with `@dataclass`, define with `eq=False`. + + Example: + ```python + from dataclasses import dataclass + + @dataclass(eq=False) # Important! + class User(CachedDistinct): + user_id: int + name: str + + def get_key(self) -> int: + return self.user_id + + @classmethod + def extract_key(cls, user_id: int, name: str) -> int: + # Extracts the key from __init__ args + return user_id + + user1 = User(1, "Alice") + user2 = User(2, "Bob") + user3 = User(1, "Alice") # Name might be different here, but key is the same + + print(user1 is user3) # Output: True (cached instance returned) + print(user1 == user3) # Output: True (equality based on get_key) + print(user1 is user2) # Output: False + ``` """ - def __init_subclass__(cls: Type, /, **kwargs) -> None: + def __init_subclass__(cls: type, /, **kwargs) -> None: super().__init_subclass__(**kwargs) - setattr(cls, '_instances_', WeakValueDictionary()) + cls._instances_ = WeakValueDictionary() @classmethod @abstractmethod - def extract_key(cls, *args, **kwargs) -> Hashable: + def extract_key(cls: type[CachedDistinct], *args, **kwargs) -> Hashable: """Returns key from arguments passed to `__init__()`. Important: @@ -217,7 +463,7 @@ def extract_key(cls, *args, **kwargs) -> Hashable: # Enums class ByteOrder(Enum): - """Byte order for storing numbers in binary `.MemoryBuffer`. + """Byte order for storing numbers in binary [MemoryBuffer][firebird.base.buffer.MemoryBuffer]. """ LITTLE = 'little' BIG = 'big' @@ -251,10 +497,27 @@ class ZMQAddress(str): Raises: ValueError: When string value passed to constructor is not a valid ZMQ endpoint address. + + Example: + ```python + addr_str = "tcp://127.0.0.1:5555" + zmq_addr = ZMQAddress(addr_str) + + print(zmq_addr) # Output: tcp://127.0.0.1:5555 + print(repr(zmq_addr)) # Output: ZMQAddress('tcp://127.0.0.1:5555') + print(zmq_addr.protocol) # Output: ZMQTransport.TCP + print(zmq_addr.address) # Output: 127.0.0.1:5555 + print(zmq_addr.domain) # Output: ZMQDomain.NODE + + try: + invalid = ZMQAddress("myfile.txt") + except ValueError as e: + print(e) # Output: Protocol specification required + ``` """ - def __new__(cls, value: AnyStr): + def __new__(cls, value: AnyStr, encoding: str = 'utf8') -> Self: if isinstance(value, bytes): - value = cast(bytes, value).decode('utf8') + value = cast(bytes, value).decode(encoding) if '://' in value: protocol, _ = value.split('://', 1) if protocol.upper() not in ZMQTransport._member_map_: @@ -268,20 +531,17 @@ def __repr__(self): return f"ZMQAddress('{self}')" @property def protocol(self) -> ZMQTransport: - """Transport protocol. - """ + """Transport protocol (e.g., TCP, IPC, INPROC).""" protocol, _ = self.split('://', 1) return ZMQTransport._member_map_[protocol.upper()] @property def address(self) -> str: - """Endpoint address. - """ + """Endpoint address part (following '://').""" _, address = self.split('://', 1) return address @property def domain(self) -> ZMQDomain: - """Endpoint address domain. - """ + """Endpoint address domain (LOCAL, NODE, NETWORK).""" if self.protocol == ZMQTransport.INPROC: return ZMQDomain.LOCAL if self.protocol == ZMQTransport.IPC: @@ -294,24 +554,58 @@ def domain(self) -> ZMQDomain: return ZMQDomain.NETWORK class MIME(str): - """MIME type specification. + """MIME type specification string (e.g., `text/plain; charset=utf-8`). - It behaves like `str`, but checks that value is valid MIME type specification, has - additional R/O properties and meaningful `repr()`. + Behaves like `str`, but validates the input format (`type/subtype[;params]`) + upon creation and provides convenient read-only properties to access parts + of the specification. + + Raises: + ValueError: If the input string is not a valid MIME type specification + (missing '/', unsupported type, invalid parameters). + + Example: + ```python + mime1_str = "application/json" + mime1 = MIME(mime1_str) + print(mime1) # Output: application/json + print(repr(mime1)) # Output: MIME('application/json') + print(mime1.type) # Output: application + print(mime1.subtype) # Output: json + print(mime1.params) # Output: {} + + mime2_str = "text/html; charset=UTF-8" + mime2 = MIME(mime2_str) + print(mime2.mime_type) # Output: text/html + print(mime2.params) # Output: {'charset': 'UTF-8'} + + try: + invalid_mime = MIME("application") + except ValueError as e: + print(e) # Output: MIME type specification must be 'type/subtype[;param=value;...]' + try: + invalid_mime = MIME("myapp/data") # 'myapp' is not a standard type + except ValueError as e: + print(e) # Output: MIME type 'myapp' not supported + ``` """ - #: Supported MIME types - MIME_TYPES = ['text', 'image', 'audio', 'video', 'application', 'multipart', 'message'] - def __new__(cls, value: AnyStr): + #: Supported base MIME types + MIME_TYPES: ClassVar[list[str]] = ['text', 'image', 'audio', 'video', 'application', 'multipart', 'message'] + def __new__(cls, value: str) -> Self: dfm = list(value.split(';')) - mime_type: str = dfm.pop(0) + mime_type: str = dfm.pop(0).strip() if (i := mime_type.find('/')) == -1: raise ValueError("MIME type specification must be 'type/subtype[;param=value;...]'") if mime_type[:i] not in cls.MIME_TYPES: raise ValueError(f"MIME type '{mime_type[:i]}' not supported") if [i for i in dfm if '=' not in i]: raise ValueError("Wrong specification of MIME type parameters") + # Check parameters format + if any('=' not in p for p in dfm if p.strip()): # Check non-empty params + raise ValueError("Wrong specification of MIME type parameters (should be key=value)") obj = str.__new__(cls, value) + # Store indices after validation and potential stripping obj._bs_: int = obj.find('/') obj._fp_: int = obj.find(';') return obj @@ -319,103 +613,222 @@ def __repr__(self): return f"MIME('{self}')" @property def mime_type(self) -> str: - """MIME type specification: /. - """ + """The base MIME type specification: '/'.""" if self._fp_ != -1: return self[:self._fp_] return self @property def type(self) -> str: - """MIME type. - """ + """The main MIME type (e.g., 'text', 'application').""" return self[:self._bs_] @property def subtype(self) -> str: - """MIME subtype. - """ + """The MIME subtype (e.g., 'plain', 'json').""" if self._fp_ != -1: return self[self._bs_ + 1:self._fp_] return self[self._bs_ + 1:] @property - def params(self) -> Dict[str, str]: - """MIME parameters. - """ + def params(self) -> dict[str, str]: + """MIME parameters as a dictionary (e.g., {'charset': 'utf-8'}).""" if self._fp_ != -1: + # Split parameters, then split each into key/value, stripping whitespace return {k.strip(): v.strip() for k, v in (x.split('=') for x in self[self._fp_+1:].split(';'))} return {} class PyExpr(str): - """Source code for Python expression. + """Source code string representing a single Python expression. - It behaves like `str`, but checks that value is a valid Python expression, and provides - direct access to compiled code. + Behaves like `str`, but validates that the content is a syntactically + valid Python expression during initialization by attempting to compile it + in `eval` mode. Provides access to the compiled code object and a helper + to create a callable function from the expression. Raises: - SyntaxError: When string value is not a valid Python expression. + SyntaxError: If the string value is not a valid Python expression. + + Example: + ```python + expr_str = "a + b * 2" + py_expr = PyExpr(expr_str) + + print(py_expr) # Output: a + b * 2 + print(repr(py_expr)) # Output: PyExpr('a + b * 2') + + # Get the compiled code object + code_obj = py_expr.expr + print(eval(code_obj, {'a': 10, 'b': 5})) # Output: 20 + + # Get a callable function + func = py_expr.get_callable(arguments='a, b') + print(func(a=3, b=4)) # Output: 11 + + # Using a namespace + import math + log_expr = PyExpr("math.log10(x)") + log_func = log_expr.get_callable(arguments='x', namespace={'math': math}) + print(log_func(x=100)) # Output: 2.0 + + try: + invalid_expr = PyExpr("a = 5") # Assignment is not an expression + except SyntaxError as e: + print(e) # Output: invalid syntax (, line 1) or similar + ``` """ - _expr_ = None - def __new__(cls, value: str): - expr = compile(value, "PyExpr", 'eval') + _expr_: types.CodeType = None # Compiled code object + def __new__(cls, value: str) -> Self: new = str.__new__(cls, value) - new._expr_ = expr + # Validate by compiling in 'eval' mode + new._expr_ = compile(value, '', 'eval') return new def __repr__(self): return f"PyExpr('{self}')" - def get_callable(self, arguments: str='', namespace: Dict[str, Any]=None) -> Callable: - """Returns expression as callable function ready for execution. + def get_callable(self, arguments: str='', namespace: dict[str, Any] | None=None) -> Callable: + """Returns the expression wrapped in a callable function. Arguments: - arguments: String with arguments (names separated by coma) for returned function. - namespace: Dictionary with namespace elements available for expression. + arguments: Comma-separated string of argument names for the function signature. + namespace: Optional dictionary providing the execution namespace for the expression. + Can be used to provide access to modules or specific values. + + Returns: + A callable function that takes the specified arguments and returns + the result of evaluating the expression. """ ns = {} if namespace: ns.update(namespace) - code = compile(f"def expr({arguments}):\n return {self}", - "PyExpr", 'exec') - eval(code, ns) # pylint: disable=[W0123] + # Create function definition string dynamically + func_def = f"def expr({arguments}):\n return {self}" + # Compile the function definition in 'exec' mode + code = compile(func_def, '', 'exec') + # Execute the compiled code to define the function in the namespace 'ns' + eval(code, ns) # noqa: S307 Using eval safely with controlled input + # Return the defined function return ns['expr'] @property - def expr(self): - "Expression code ready to be pased to `eval`." + def expr(self) -> types.CodeType: + """The compiled expression code object, ready for `eval()`.""" return self._expr_ class PyCode(str): - """Python source code. + """Source code string representing a block of Python statements. - It behaves like `str`, but checks that value is a valid Python code block, and provides - direct access to compiled code. + Behaves like `str`, but validates that the content is a syntactically + valid Python code block (potentially multiple statements) during + initialization by attempting to compile it in `exec` mode. Provides access + to the compiled code object. Raises: - SyntaxError: When string value is not a valid Python code block. + SyntaxError: If the string value is not a valid Python code block. + + Example: + ```python + code_str = ''' + import math + result = math.sqrt(x * y) + print(f"Result: {result}") + ''' + py_code = PyCode(code_str) + + print(py_code[:20]) # Output: import math\\nresult + print(repr(py_code)) # Output: PyCode('import math\\nresult = ...') + + # Get the compiled code object + code_obj = py_code.code + + # Execute the code block + exec_namespace = {'x': 4, 'y': 9} + exec(code_obj, exec_namespace) # Output: Result: 6.0 + print(exec_namespace['result']) # Output: 6.0 + + try: + # Invalid syntax (e.g., unmatched parenthesis) + invalid_code = PyCode("print('Hello'") + except SyntaxError as e: + print(e) # Output: unexpected EOF while parsing (, line 1) or similar + ``` """ - _code_ = None - def __new__(cls, value: str): - code = compile(value, "PyCode", 'exec') + _code_: types.CodeType = None # Compiled code object + def __new__(cls, value: str) -> Self: + # Validate by compiling in 'exec' mode + code = compile(value, '', 'exec') new = str.__new__(cls, value) new._code_ = code return new + def __repr__(self) -> str: + # Truncate long strings in repr for readability + limit = 50 + ellipsis = "..." if len(self) > limit else "" + return f"PyCode('{self[:limit]}{ellipsis}')" @property - def code(self): - """Python code ready to be pased to `exec`. - """ + def code(self) -> types.CodeType: + """The compiled Python code object, ready for `exec()`.""" return self._code_ class PyCallable(str): - """Source code for Python callable. + """Source code string representing a Python callable (function or class definition). + + Behaves like `str`, but validates that the content is a syntactically + valid Python function or class definition during initialization. It compiles + and executes the definition to capture the resulting callable object. - It behaves like `str`, but checks that value is a valid Python callable (function of class - definition), and acts like a callable (i.e. you can directly call the PyCallable value). + Instances of [PyCallable][firebird.base.types.PyCallable] are themselves callable, acting as a proxy to the + defined function or class. Raises: - ValueError: When string value does not contains the function or class definition. - SyntaxError: When string value is not a valid Python callable. + ValueError: If the string does not contain a recognizable `def ` or `class` + definition at the top level. + SyntaxError: If the string contains syntactically invalid Python code. + NameError: If the definition relies on names not available during its execution. + + Example: + ```python + func_str = ''' + def greet(name): + "Greets the person." + return f"Hello, {name}!" + ''' + py_func = PyCallable(func_str) + + print(py_func.name) # Output: greet + print(py_func.__doc__) # Output: Greets the person. + print(repr(py_func)) # Output: PyCallable('def greet(name):\\n ...') + + # Call the instance directly + message = py_func(name="World") + print(message) # Output: Hello, World! + + class_str = ''' + class MyNumber: + def __init__(self, value): + self.value = value + def double(self): + return self.value * 2 + ''' + py_class = PyCallable(class_str) + + print(py_class.name) # Output: MyNumber + instance = py_class(value=10) # Instantiate the class via the PyCallable object + print(instance.double()) # Output: 20 + + try: + # Missing 'def' or 'class' + invalid = PyCallable("print('Hello')") + except ValueError as e: + print(e) # Output: Python function or class definition not found + + try: + # Syntax error in definition + invalid = PyCallable("def my_func(x:") + except SyntaxError as e: + print(e) # Output: invalid syntax (, line 1) or similar + ``` """ - _callable_ = None - #: Name of the callable (function). + _callable_: Callable | type = None + #: Name of the defined function or class. name: str = None - def __new__(cls, value: str): + def __new__(cls, value: str) -> Self: callable_name = None for line in value.split('\n'): if line.lower().startswith('def '): @@ -428,23 +841,44 @@ def __new__(cls, value: str): break if callable_name is None: raise ValueError("Python function or class definition not found") + # Compile and execute the code to define the callable in a temporary namespace ns = {} - eval(compile(value, "PyCallable", 'exec'), ns) # pylint: disable=[W0123] + try: + code_obj = compile(value, '', 'exec') + eval(code_obj, ns) # noqa: S307 Use eval cautiously; input should be trusted/validated + except SyntaxError as e: + raise SyntaxError(f"Invalid syntax in callable definition: {e}") from e + except Exception as e: # Catch other potential errors during definition execution (e.g., NameError) + raise RuntimeError(f"Error executing callable definition: {e}") from e + + if callable_name not in ns: + # This might happen if the parsed name doesn't match the actual definition + raise ValueError(f"Could not find defined callable named '{callable_name}' after execution. Check definition.") # noqa: E501 + new = str.__new__(cls, value) new._callable_ = ns[callable_name] new.name = callable_name + # Copy docstring if present + new.__doc__ = getattr(new._callable_, '__doc__', None) return new - def __call__(self, *args, **kwargs): + def __call__(self, *args, **kwargs) -> Any: + """Calls the wrapped function or instantiates the wrapped class.""" return self._callable_(*args, **kwargs) + def __repr__(self) -> str: + limit = 50 + ellipsis = "..." if len(self) > limit else "" + # Show the beginning of the code string + string = self[:limit].replace('\\n', '\\\\n') + return f"PyCallable('{string}{ellipsis}')" # Metaclasses -def Conjunctive(name, bases, attrs): +def conjunctive(name, bases, attrs) -> type: """Returns a metaclass that is conjunctive descendant of all metaclasses used by parent classes. It's necessary to create a class with multiple inheritance, where multiple parent classes use different metaclasses. Example: - + ```python class A(type): pass class B(type): pass @@ -454,22 +888,43 @@ class AA(metaclass=A):pass class BB(metaclass=B):pass class CC(AA, BB, metaclass=Conjunctive): pass + ``` """ basemetaclasses = [] for base in bases: metacls = type(base) - if isinstance(metacls, type) and metacls is not type and not metacls in basemetaclasses: + if isinstance(metacls, type) and metacls is not type and metacls not in basemetaclasses: basemetaclasses.append(metacls) dynamic = type(''.join(b.__name__ for b in basemetaclasses), tuple(basemetaclasses), {}) return dynamic(name, bases, attrs) # Functions def load(spec: str) -> Any: - """Return object from module. Module is imported if necessary. + """Dynamically load an object (class, function, variable) from a module. + + The module is imported automatically if it hasn't been already. Arguments: - spec: Object specification in format `module[.submodule...]:object_name[.object_name...]` + spec: Object specification string in the format + `'module[.submodule...]:object_name[.attribute...]'`. + + Returns: + The loaded object. + Raises: + ImportError: If the module cannot be imported. + AttributeError: If the specified object cannot be found within the module. + + Example: + ```python + # Assuming 'my_package/my_module.py' contains: class MyClass: pass + MyClassRef = load("my_package.my_module:MyClass") + instance = MyClassRef() + + # Load a function + pprint_func = load("pprint:pprint") + pprint_func({"a": 1}) + ``` """ module_spec, name = spec.split(':') if module_spec in sys.modules: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/base_test_pb2.py b/tests/base_test_pb2.py index 822507e..93e99e0 100644 --- a/tests/base_test_pb2.py +++ b/tests/base_test_pb2.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! -# source: firebird/base/base_test.proto +# source: base_test.proto +# Protobuf Python Version: 4.25.1 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -15,20 +16,19 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1d\x66irebird/base/base_test.proto\x12\rfirebird.base\x1a\x19google/protobuf/any.proto\x1a\x1cgoogle/protobuf/struct.proto\"@\n\tTestState\x12\x0c\n\x04name\x18\x01 \x01(\t\x12%\n\x04test\x18\x02 \x01(\x0e\x32\x17.firebird.base.TestEnum\"\xc8\x01\n\x0eTestCollection\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\'\n\x05tests\x18\x02 \x03(\x0b\x32\x18.firebird.base.TestState\x12(\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12+\n\nannotation\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\nsupplement\x18\x05 \x03(\x0b\x32\x14.google.protobuf.Any*\xd8\x01\n\x08TestEnum\x12\x10\n\x0cTEST_UNKNOWN\x10\x00\x12\x0e\n\nTEST_READY\x10\x01\x12\x10\n\x0cTEST_RUNNING\x10\x02\x12\x10\n\x0cTEST_WAITING\x10\x03\x12\x12\n\x0eTEST_SUSPENDED\x10\x04\x12\x11\n\rTEST_FINISHED\x10\x05\x12\x10\n\x0cTEST_ABORTED\x10\x06\x12\x10\n\x0cTEST_CREATED\x10\x01\x12\x10\n\x0cTEST_BLOCKED\x10\x03\x12\x10\n\x0cTEST_STOPPED\x10\x04\x12\x13\n\x0fTEST_TERMINATED\x10\x06\x1a\x02\x10\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x62\x61se_test.proto\x12\rfirebird.base\x1a\x19google/protobuf/any.proto\x1a\x1cgoogle/protobuf/struct.proto\"@\n\tTestState\x12\x0c\n\x04name\x18\x01 \x01(\t\x12%\n\x04test\x18\x02 \x01(\x0e\x32\x17.firebird.base.TestEnum\"\xc8\x01\n\x0eTestCollection\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\'\n\x05tests\x18\x02 \x03(\x0b\x32\x18.firebird.base.TestState\x12(\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12+\n\nannotation\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\nsupplement\x18\x05 \x03(\x0b\x32\x14.google.protobuf.Any*\xd8\x01\n\x08TestEnum\x12\x10\n\x0cTEST_UNKNOWN\x10\x00\x12\x0e\n\nTEST_READY\x10\x01\x12\x10\n\x0cTEST_RUNNING\x10\x02\x12\x10\n\x0cTEST_WAITING\x10\x03\x12\x12\n\x0eTEST_SUSPENDED\x10\x04\x12\x11\n\rTEST_FINISHED\x10\x05\x12\x10\n\x0cTEST_ABORTED\x10\x06\x12\x10\n\x0cTEST_CREATED\x10\x01\x12\x10\n\x0cTEST_BLOCKED\x10\x03\x12\x10\n\x0cTEST_STOPPED\x10\x04\x12\x13\n\x0fTEST_TERMINATED\x10\x06\x1a\x02\x10\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'firebird.base.base_test_pb2', _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'base_test_pb2', _globals) if _descriptor._USE_C_DESCRIPTORS == False: - DESCRIPTOR._options = None - _TESTENUM._options = None - _TESTENUM._serialized_options = b'\020\001' - _globals['_TESTENUM']._serialized_start=375 - _globals['_TESTENUM']._serialized_end=591 - _globals['_TESTSTATE']._serialized_start=105 - _globals['_TESTSTATE']._serialized_end=169 - _globals['_TESTCOLLECTION']._serialized_start=172 - _globals['_TESTCOLLECTION']._serialized_end=372 + _globals['_TESTENUM']._options = None + _globals['_TESTENUM']._serialized_options = b'\020\001' + _globals['_TESTENUM']._serialized_start=361 + _globals['_TESTENUM']._serialized_end=577 + _globals['_TESTSTATE']._serialized_start=91 + _globals['_TESTSTATE']._serialized_end=155 + _globals['_TESTCOLLECTION']._serialized_start=158 + _globals['_TESTCOLLECTION']._serialized_end=358 # @@protoc_insertion_point(module_scope) diff --git a/tests/base_test_pb2.pyi b/tests/base_test_pb2.pyi index c5b646f..6b2c272 100644 --- a/tests/base_test_pb2.pyi +++ b/tests/base_test_pb2.pyi @@ -9,7 +9,7 @@ from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Map DESCRIPTOR: _descriptor.FileDescriptor class TestEnum(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = [] + __slots__ = () TEST_UNKNOWN: _ClassVar[TestEnum] TEST_READY: _ClassVar[TestEnum] TEST_RUNNING: _ClassVar[TestEnum] @@ -34,7 +34,7 @@ TEST_STOPPED: TestEnum TEST_TERMINATED: TestEnum class TestState(_message.Message): - __slots__ = ["name", "test"] + __slots__ = ("name", "test") NAME_FIELD_NUMBER: _ClassVar[int] TEST_FIELD_NUMBER: _ClassVar[int] name: str @@ -42,7 +42,7 @@ class TestState(_message.Message): def __init__(self, name: _Optional[str] = ..., test: _Optional[_Union[TestEnum, str]] = ...) -> None: ... class TestCollection(_message.Message): - __slots__ = ["name", "tests", "context", "annotation", "supplement"] + __slots__ = ("name", "tests", "context", "annotation", "supplement") NAME_FIELD_NUMBER: _ClassVar[int] TESTS_FIELD_NUMBER: _ClassVar[int] CONTEXT_FIELD_NUMBER: _ClassVar[int] diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config/conftest.py b/tests/config/conftest.py new file mode 100644 index 0000000..00bc702 --- /dev/null +++ b/tests/config/conftest.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/conftest.py +# DESCRIPTION: Common fixtures for firebird.base.config tests +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +from __future__ import annotations + +from configparser import ConfigParser + +import pytest + +from firebird.base import config + +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" +EMPTY_S = "empty" + + +@pytest.fixture +def proto() -> config.ConfigProto: + """Returns config protobuf message. + """ + return config.ConfigProto() + +@pytest.fixture +def base_conf() -> ConfigParser: + """Returns configparser with `EnvExtendedInterpolation`. + """ + return ConfigParser(interpolation=config.EnvExtendedInterpolation()) + diff --git a/tests/config/test_cfg_bool.py b/tests/config/test_cfg_bool.py new file mode 100644 index 0000000..57df6d2 --- /dev/null +++ b/tests/config/test_cfg_bool.py @@ -0,0 +1,340 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_bool.py +# DESCRIPTION: Tests for firebird.base.config BoolOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the BoolOption configuration option class.""" + +from __future__ import annotations + +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" +EMPTY_S = "empty" + +# --- Constants for Test Values --- +YES = True +NO = False +PRESENT_VAL = YES +DEFAULT_VAL = NO +DEFAULT_OPT_VAL = NO # Default for the option itself +NEW_VAL = YES + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with boolean test data.""" + conf_str = """ +[%(DEFAULT)s] +# Option defined in DEFAULT section +option_name = no +[%(PRESENT)s] +# Option present in its own section +option_name = yes +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Option present but with an invalid boolean string +option_name = bad_value +[%(EMPTY)s] +# Option present but empty +option_name = +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic BoolOption functionality: init, load, value access, clear, default handling.""" + opt = config.BoolOption("option_name", "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == bool + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + assert opt.get_as_str() == "True" # String representation of boolean + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "yes" # Config file format + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "no" + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + +def test_required(conf: ConfigParser): + """Tests BoolOption with the 'required' flag.""" + opt = config.BoolOption("option_name", "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid boolean string values.""" + opt = config.BoolOption("option_name", "description") + + # Load from section with bad value + with pytest.raises(ValueError, match="Value is not a valid bool string constant"): + opt.load_config(conf, BAD_S) + assert opt.value is None # Value should remain unchanged (None) + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'bool', not 'float'"): + opt.set_value(10.0) # type: ignore + + # Test assigning invalid string via set_as_str + with pytest.raises(ValueError, match="Value is not a valid bool string constant"): + opt.set_as_str("nope") + +def test_default(conf: ConfigParser): + """Tests BoolOption with a defined default value.""" + opt = config.BoolOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.BoolOption("option_name", "description", default=DEFAULT_OPT_VAL) + proto_value = YES # Use True for this test + + # Set value and serialize + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_bool') + assert proto.options["option_name"].as_bool == proto_value + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value + assert isinstance(opt.value, opt.datatype) + + # Test deserializing from string representation in proto + proto.Clear() + proto.options["option_name"].as_string = "no" # Represents False + opt.load_proto(proto) + assert opt.value is False + + proto.Clear() + proto.options["option_name"].as_string = "ON" # Represents True (case-insensitive) + opt.load_proto(proto) + assert opt.value is True + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(True) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is True # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint64 = 1 # Invalid type for BoolOption + with pytest.raises(TypeError, match="Wrong value type: uint64"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string for bool) + proto.Clear() + proto.options["option_name"].as_string = "maybe" + with pytest.raises(ValueError, match="Value is not a valid bool string constant"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.BoolOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out) + expected_lines_default = """; description +; Type: bool +;option_name = no +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value (True) + opt.set_value(True) + expected_lines_true = """; description +; Type: bool +option_name = yes +""" + assert opt.get_config() == expected_lines_true + + # Test output with explicitly set value (False) + opt.set_value(False) + expected_lines_false = """; description +; Type: bool +;option_name = no +""" + assert opt.get_config() == expected_lines_false + + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: bool +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(True) + assert opt.get_config(plain=True) == "option_name = yes\n" + opt.set_value(False) + assert opt.get_config(plain=True) == ";option_name = no\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/config/test_cfg_conf.py b/tests/config/test_cfg_conf.py new file mode 100644 index 0000000..e815b66 --- /dev/null +++ b/tests/config/test_cfg_conf.py @@ -0,0 +1,518 @@ +# SPDX-FileCopyrightText: 2019-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_conf.py +# DESCRIPTION: Tests for firebird.base.config Config, ConfigOption and ConfigListOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the Config, ConfigOption, and ConfigListOption classes +in firebird.base.config.""" + +from __future__ import annotations + +from enum import IntEnum +from configparser import ConfigParser # Import for type hinting + +import pytest + +from firebird.base import config +from firebird.base.types import Error +from firebird.base.config_pb2 import ConfigProto # Import for proto tests + +# --- Constants --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" +EMPTY_S = "empty" + +# --- Test Helper Classes --- + +class SimpleEnum(IntEnum): + """Enum for testing ListOption inside Config.""" + UNKNOWN = 0 + READY = 1 + RUNNING = 2 + WAITING = 3 + SUSPENDED = 4 + FINISHED = 5 + ABORTED = 6 + # Aliases + CREATED = 1 + BLOCKED = 3 + STOPPED = 4 + TERMINATED = 6 + +class DbConfig(config.Config): + """Simple database configuration structure for testing nested configs.""" + def __init__(self, name: str, *, optional: bool = False, description: str | None = None): + """Initializes DbConfig.""" + super().__init__(name, optional=optional, description=description) + self.database: config.StrOption = config.StrOption("database", "Database connection string", + required=True) # Made required for validation test + self.user: config.StrOption = config.StrOption("user", "User name", required=True, + default="SYSDBA") + self.password: config.StrOption = config.StrOption("password", "User password") + +class SimpleConfig(config.Config): + """Main configuration structure for testing hierarchical configs. + + Includes various option types and nested Config instances. + """ + def __init__(self, *, optional: bool = False): + """Initializes SimpleConfig.""" + super().__init__("simple-config", optional=optional) + # Options + self.opt_str: config.StrOption = config.StrOption("opt_str", "Simple string option") + # Corrected opt_int type + self.opt_int: config.IntOption = config.IntOption("opt_int", "Simple int option") + self.enum_list: config.ListOption = config.ListOption("enum_list", SimpleEnum, "List of enum values") + # ConfigOption for dynamically named sub-config + self.main_db: config.ConfigOption = config.ConfigOption("main_db", DbConfig(""), "Main database config section name") + # ConfigListOption for list of dynamically named sub-configs + self.opt_cfgs: config.ConfigListOption = config.ConfigListOption("opt_cfgs", DbConfig, "List of optional database sections") + # Fixed-name sub-configs as direct attributes + self.master_db: DbConfig = DbConfig("master-db") + self.backup_db: DbConfig = DbConfig("backup-db") + +class ConfigWithDocstring(config.Config): + """Config class with docstring but no explicit description.""" + def __init__(self, name: str): + # Note: super().__init__ does *not* get description here + super().__init__(name) + self.option1: config.StrOption = config.StrOption("option1", "Option 1") + + +# --- Fixtures --- + +@pytest.fixture +def base_conf_data() -> str: + """Provides the raw string data for the base ConfigParser fixture.""" + # Added password to DEFAULT section for testing default inheritance + # Added section for missing required value test + return """ +[%(DEFAULT)s] +password = masterkey + +[%(PRESENT)s] +opt_str = Lorem ipsum +opt_int = 123 +enum_list = ready, finished, aborted +main_db = my-main-db +opt_cfgs = db-one, db-two + +[master-db] +database = primary:/path/master.fdb +user = tester +password = lockpick + +[backup-db] +database = secondary:/path/backup.fdb +# user uses DEFAULT (SYSDBA) +# password uses DEFAULT (masterkey) + +[my-main-db] +database = main:/path/main.fdb +# user uses DEFAULT (SYSDBA) +# password uses DEFAULT (masterkey) + +[db-one] +database = /path/db1.fdb +user = user1 + +[db-two] +database = /path/db2.fdb +# user uses DEFAULT (SYSDBA) + +[%(ABSENT)s] +# Section exists but is empty + +[%(BAD)s] +# Used for option-specific bad value tests + +[missing_req_sub] +opt_str = Subconfig present but required value missing +opt_int = 456 +main_db = sub-config-missing-db-req + +[sub-config-missing-db-req] +# Missing the required 'database' option +user = bad_user +""" + +@pytest.fixture +def conf(base_conf: ConfigParser, base_conf_data: str) -> ConfigParser: + """Returns a ConfigParser initialized with test data.""" + conf_str = base_conf_data % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S} + base_conf.read_string(conf_str) + return base_conf + +# --- Test Cases --- + +def test_basics(conf: ConfigParser): + """Tests basic Config initialization, structure, and attribute access.""" + cfg = SimpleConfig() + + # Test basic attributes + assert cfg.name == "simple-config" + assert not cfg.optional + + # Test discovery of options and configs + assert len(cfg.options) == 5, "Should find 5 direct Option attributes" + assert cfg.opt_str in cfg.options + assert cfg.opt_int in cfg.options + assert cfg.enum_list in cfg.options + assert cfg.main_db in cfg.options # ConfigOption counts as an option + assert cfg.opt_cfgs in cfg.options # ConfigListOption counts as an option + + # Initial state: main_db points to empty-named config, opt_cfgs is empty list + assert len(cfg.configs) == 3, "Should find 2 direct Config attributes + 1 empty from main_db" + assert cfg.master_db in cfg.configs + assert cfg.backup_db in cfg.configs + assert cfg.main_db.value in cfg.configs # The actual DbConfig instance from ConfigOption + + # Check initial values (before loading) + assert cfg.opt_str.value is None + assert cfg.opt_int.value is None + assert cfg.enum_list.value is None + assert isinstance(cfg.master_db, DbConfig) + assert isinstance(cfg.backup_db, DbConfig) + assert isinstance(cfg.main_db.value, DbConfig) + assert isinstance(cfg.opt_cfgs.value, list) and not cfg.opt_cfgs.value + + # Check sub-config initial state (defaults should apply) + assert cfg.main_db.value.database.value is None # Required, no default + assert cfg.main_db.value.user.value == "SYSDBA" # Has default + assert cfg.main_db.value.password.value is None # Optional, no default + assert cfg.master_db.database.value is None + assert cfg.master_db.user.value == "SYSDBA" + assert cfg.master_db.password.value is None + + # Test ConfigOption specific methods + assert cfg.main_db.get_value() is cfg.main_db._value # get_value returns the Config instance + assert cfg.main_db.get_as_str() == "" # Initial name is empty + + # Test ConfigListOption specific methods + assert cfg.opt_cfgs.get_value() == [] + assert cfg.opt_cfgs.get_formatted() == "" # Representation of empty list + + # Test direct assignment prevention + with pytest.raises(ValueError, match="Cannot assign values to option itself"): + cfg.opt_str = "value" # type: ignore + + # Test changing ConfigListOption value and reflected configs + test_db_instance = DbConfig("test-db") + cfg.opt_cfgs.value = [test_db_instance] + assert len(cfg.configs) == 4, "configs should now include the one from opt_cfgs" + assert len(cfg.opt_cfgs.value) == 1 + assert cfg.opt_cfgs.value[0].name == "test-db" + assert test_db_instance in cfg.configs + + # Test assigning invalid type to ConfigListOption + with pytest.raises(ValueError, match="List item\\[0\\] has wrong type: Expected 'DbConfig', got 'list'"): + cfg.opt_cfgs.value = [list()] # type: ignore + + +def test_load_config(conf: ConfigParser): + """Tests loading hierarchical configuration using Config.load_config. + + Verifies that options in the main config and nested Config instances (both + direct attributes and via ConfigOption/ConfigListOption) are correctly populated + from the ConfigParser object, including handling of defaults from DEFAULTSECT. + """ + ocfg = SimpleConfig(optional=True) + # Loading optional config from non-existent section should do nothing + ocfg.load_config(conf, "no-such-section") + assert ocfg.optional + assert ocfg.opt_str.value is None + + cfg = SimpleConfig() + # Loading mandatory config from non-existent section should fail + with pytest.raises(Error, match="Configuration error: section 'no-such-section' not found!"): + cfg.load_config(conf, "no-such-section") + + # Load from the PRESENT section + cfg.load_config(conf, PRESENT_S) + cfg.validate() # Should pass now + + # Check main config options + assert cfg.opt_str.value == "Lorem ipsum" + assert cfg.opt_int.value == 123 + assert cfg.enum_list.value == [SimpleEnum.READY, SimpleEnum.FINISHED, SimpleEnum.ABORTED] + + # Check ConfigOption (main_db) - name loaded, sub-config loaded + assert cfg.main_db.value.name == "my-main-db" + assert cfg.main_db.value.database.value == "main:/path/main.fdb" + assert cfg.main_db.value.user.value == "SYSDBA" # Default + assert cfg.main_db.value.password.value == "masterkey" # From DEFAULTSECT + + # Check fixed sub-configs (master_db, backup_db) + assert cfg.master_db.database.value == "primary:/path/master.fdb" + assert cfg.master_db.user.value == "tester" + assert cfg.master_db.password.value == "lockpick" + + assert cfg.backup_db.database.value == "secondary:/path/backup.fdb" + assert cfg.backup_db.user.value == "SYSDBA" # Default + assert cfg.backup_db.password.value == "masterkey" # From DEFAULTSECT + + # Check ConfigListOption (opt_cfgs) - list of names loaded, sub-configs loaded + assert cfg.opt_cfgs.get_as_str() == "db-one, db-two" + assert len(cfg.opt_cfgs.value) == 2 + assert cfg.opt_cfgs.value[0].name == "db-one" + assert cfg.opt_cfgs.value[0].database.value == "/path/db1.fdb" + assert cfg.opt_cfgs.value[0].user.value == "user1" + assert cfg.opt_cfgs.value[0].password.value == "masterkey" # From DEFAULTSECT + + assert cfg.opt_cfgs.value[1].name == "db-two" + assert cfg.opt_cfgs.value[1].database.value == "/path/db2.fdb" + assert cfg.opt_cfgs.value[1].user.value == "SYSDBA" # Default + assert cfg.opt_cfgs.value[1].password.value == "masterkey" # From DEFAULTSECT + + # Check total number of discovered Config instances after loading + assert len(cfg.configs) == 5 # master, backup, main_db's target, opt_cfg[0], opt_cfg[1] + + +def test_clear(conf: ConfigParser): + """Tests the clear method, ensuring it resets options and nested configs.""" + cfg = SimpleConfig() + cfg.load_config(conf, PRESENT_S) + + # Verify some values are set before clear + assert cfg.opt_str.value is not None + assert cfg.master_db.user.value == "tester" + assert len(cfg.opt_cfgs.value) > 0 + assert cfg.main_db.value.name == "my-main-db" + + # Clear to None/Defaults + cfg.clear(to_default=True) + + # Check main options reset + assert cfg.opt_str.value is None # No default defined + assert cfg.opt_int.value is None # No default defined + assert cfg.enum_list.value is None # No default defined + + # Check ConfigOption reset (sub-config values cleared to defaults) + assert cfg.main_db.value.database.value is None # No default + assert cfg.main_db.value.user.value == "SYSDBA" # Reset to default + assert cfg.main_db.value.password.value is None # No default + + # Check ConfigListOption reset (list cleared) + assert len(cfg.opt_cfgs.value) == 0 + + # Check fixed sub-configs reset to defaults + assert cfg.master_db.database.value is None + assert cfg.master_db.user.value == "SYSDBA" + assert cfg.master_db.password.value is None + + assert cfg.backup_db.database.value is None + assert cfg.backup_db.user.value == "SYSDBA" + assert cfg.backup_db.password.value is None + + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + cfg_write = SimpleConfig() + cfg_write.load_config(conf, PRESENT_S) + + # Serialize to proto + cfg_write.save_proto(proto) + + # Deserialize into a new, empty config instance + cfg_read = SimpleConfig() + cfg_read.load_proto(proto) + + # Verify values match the originally loaded config + assert cfg_read.opt_str.value == "Lorem ipsum" + assert cfg_read.opt_int.value == 123 + assert cfg_read.enum_list.value == [SimpleEnum.READY, SimpleEnum.FINISHED, SimpleEnum.ABORTED] + + assert cfg_read.main_db.value.name == "my-main-db" + assert cfg_read.main_db.value.database.value == "main:/path/main.fdb" + assert cfg_read.main_db.value.user.value == "SYSDBA" + assert cfg_read.main_db.value.password.value == "masterkey" + + assert cfg_read.master_db.database.value == "primary:/path/master.fdb" + assert cfg_read.master_db.user.value == "tester" + assert cfg_read.master_db.password.value == "lockpick" + + assert cfg_read.backup_db.database.value == "secondary:/path/backup.fdb" + assert cfg_read.backup_db.user.value == "SYSDBA" + assert cfg_read.backup_db.password.value == "masterkey" + + assert cfg_read.opt_cfgs.get_as_str() == "db-one, db-two" + assert len(cfg_read.opt_cfgs.value) == 2 + assert cfg_read.opt_cfgs.value[0].name == "db-one" + assert cfg_read.opt_cfgs.value[0].database.value == "/path/db1.fdb" + assert cfg_read.opt_cfgs.value[0].user.value == "user1" + assert cfg_read.opt_cfgs.value[0].password.value == "masterkey" + + assert cfg_read.opt_cfgs.value[1].name == "db-two" + assert cfg_read.opt_cfgs.value[1].database.value == "/path/db2.fdb" + assert cfg_read.opt_cfgs.value[1].user.value == "SYSDBA" + assert cfg_read.opt_cfgs.value[1].password.value == "masterkey" + + # Test loading from incomplete proto (e.g., missing sub-config) + proto_incomplete = ConfigProto() + cfg_write.save_proto(proto_incomplete) + del proto_incomplete.configs["master-db"] # Remove one sub-config + + cfg_read_incomplete = SimpleConfig() + cfg_read_incomplete.load_proto(proto_incomplete) + # Check that the loaded config reflects the missing part (values should be default/None) + assert cfg_read_incomplete.master_db.database.value is None + assert cfg_read_incomplete.master_db.user.value == "SYSDBA" + # Other parts should still be loaded + assert cfg_read_incomplete.opt_str.value == "Lorem ipsum" + assert cfg_read_incomplete.backup_db.database.value == "secondary:/path/backup.fdb" + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + cfg = SimpleConfig() + # Get config for default, empty instance + default_config_str = cfg.get_config() + assert "[simple-config]" in default_config_str + assert "; Main configuration structure for testing hierarchical configs." in default_config_str # Description + assert ";opt_str = " in default_config_str # Option default indication + assert "main_db =" in default_config_str and "my-main-db" not in default_config_str + assert ";opt_cfgs = " in default_config_str + assert "[master-db]" in default_config_str + assert ";user = SYSDBA" in default_config_str # Default in sub-config + + # Load data and get config again + cfg.load_config(conf, PRESENT_S) + loaded_config_str = cfg.get_config() + assert "[simple-config]" in loaded_config_str + assert "opt_str = Lorem ipsum" in loaded_config_str + assert "opt_int = 123" in loaded_config_str # Check corrected type + assert "enum_list = READY, FINISHED, ABORTED" in loaded_config_str # Check comma default separator + assert "main_db = my-main-db" in loaded_config_str + assert "opt_cfgs = db-one, db-two" in loaded_config_str + assert "[my-main-db]" in loaded_config_str # Section for ConfigOption target + assert "database = main:/path/main.fdb" in loaded_config_str + assert ";user = SYSDBA" in loaded_config_str # Still shows default in target section + assert "password = masterkey" in loaded_config_str + assert "[master-db]" in loaded_config_str + assert "user = tester" in loaded_config_str # Overridden default + assert "password = lockpick" in loaded_config_str + assert "[backup-db]" in loaded_config_str + assert ";user = SYSDBA" in loaded_config_str # Shows default + assert "password = masterkey" in loaded_config_str # Shows inherited default + assert "[db-one]" in loaded_config_str # Section for ConfigListOption item + assert "[db-two]" in loaded_config_str # Section for ConfigListOption item + + # Test get_config(plain=True) + plain_config_str = cfg.get_config(plain=True) + # Check no comments/descriptions (adjust for default value) + assert ";" not in plain_config_str.replace(";user = SYSDBA", "user = SYSDBA") + # Check options are present + assert "opt_str = Lorem ipsum" in plain_config_str + assert "opt_int = 123" in plain_config_str + assert "enum_list = READY, FINISHED, ABORTED" in plain_config_str + assert "main_db = my-main-db" in plain_config_str + assert "opt_cfgs = db-one, db-two" in plain_config_str + # Check sections for sub-configs are included + assert "[my-main-db]" in plain_config_str + assert "database = main:/path/main.fdb" in plain_config_str + assert ";user = SYSDBA" in plain_config_str # Defaults shown plainly + assert "password = masterkey" in plain_config_str + assert "[master-db]" in plain_config_str + assert "[backup-db]" in plain_config_str + assert "[db-one]" in plain_config_str + assert "[db-two]" in plain_config_str + + +def test_validate_subconfig_failure(conf: ConfigParser): + """Tests that Config.validate fails if a nested config fails validation.""" + cfg = SimpleConfig() + # Load config where the 'sub-config-missing-db-req' section is missing 'database' + cfg.load_config(conf, "missing_req_sub") + + # main_db now points to 'sub-config-missing-db-req' which is invalid + assert cfg.main_db.value.name == "sub-config-missing-db-req" + assert cfg.main_db.value.database.value is None # Missing required value + + with pytest.raises(Error, match="Missing value for required option 'database'"): + cfg.validate() # Should fail because main_db.value fails validation + + +def test_get_description_fallback(): + """Tests that Config.get_description falls back to the class docstring.""" + cfg = ConfigWithDocstring("test_doc") + assert cfg.get_description() == "Config class with docstring but no explicit description." + +def test_load_config_missing_required_section(conf: ConfigParser): + """Tests Error when load_config points a required ConfigOption to a missing section.""" + # main_db ConfigOption *itself* is required (as it's not optional) + cfg = SimpleConfig() + cfg.load_config(conf, PRESENT_S) + + # Load from a config that specifies a section name, but that section doesn't exist + with pytest.raises(Error, match="Configuration error: section 'missing_req_section_cfg' not found!"): + # The error occurs when SimpleConfig tries to load the *target* section 'non-existent-section' + cfg.load_config(conf, "missing_req_section_cfg") + + +def test_config_option_required(conf: ConfigParser): + """Tests the 'required' flag on ConfigOption.""" + cfg = SimpleConfig() + # Make the ConfigOption itself required + cfg.main_db.required = True + + # Validation should fail if value is empty after init/clear + cfg.main_db.clear(to_default=False) + with pytest.raises(Error, match="Missing value for required option 'main_db'"): + cfg.validate() + + # Should pass after loading a value + cfg.main_db.clear() # Necessary to restore default values + cfg.load_config(conf, PRESENT_S) + assert cfg.main_db.value.name == "my-main-db" + cfg.validate() + +def test_config_list_option_required(conf: ConfigParser): + """Tests the 'required' flag on ConfigListOption.""" + cfg = SimpleConfig() + cfg.load_config(conf, ABSENT_S) + # Make the ConfigListOption itself required + cfg.opt_cfgs.required = True + + # Validation should fail if list is empty after init/clear + #cfg.opt_cfgs.clear() + assert cfg.opt_cfgs.value == [] + with pytest.raises(Error, match="Missing value for required option 'opt_cfgs'"): + cfg.validate() + + # Should pass after loading a value + cfg.load_config(conf, PRESENT_S) + assert len(cfg.opt_cfgs.value) > 0 + cfg.validate() diff --git a/tests/config/test_cfg_dcls.py b/tests/config/test_cfg_dcls.py new file mode 100644 index 0000000..5a1372a --- /dev/null +++ b/tests/config/test_cfg_dcls.py @@ -0,0 +1,450 @@ +# SPDX-FileCopyrightText: 2019-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_dcls.py +# DESCRIPTION: Tests for firebird.base.config DataclassOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the DataclassOption configuration option class.""" + +from __future__ import annotations + +from dataclasses import dataclass, field # Added field for testing defaults +from enum import IntEnum +from typing import Optional # For testing complex type hints + +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" +EMPTY_S = "empty" +PARTIAL_S = "partial" +BAD_FIELD_NAME_S = "bad_field_name" +BAD_FIELD_VALUE_S = "bad_field_value" + +# --- Test Helper Classes --- + +class SimpleEnum(IntEnum): + """Enum used within the test dataclass.""" + UNKNOWN = 0 + READY = 1 + RUNNING = 2 + # ... (other members as needed) ... + +@dataclass +class SimpleDataclass: + """Dataclass used for testing DataclassOption.""" + name: str # Required field + priority: int = 1 # Field with default + state: SimpleEnum = SimpleEnum.READY # Field with enum default + +@dataclass +class ComplexHintDataclass: + """Dataclass with more complex type hints for testing 'fields' override.""" + label: str + count: Optional[int] = None + status: SimpleEnum = SimpleEnum.UNKNOWN + +# --- Constants for Test Values --- +DEFAULT_VAL = SimpleDataclass("main") # name required, others default +PRESENT_VAL = SimpleDataclass("master", 3, SimpleEnum.RUNNING) +DEFAULT_OPT_VAL = SimpleDataclass("default_obj", 5) # Uses default state=READY +NEW_VAL = SimpleDataclass("master", 99, SimpleEnum.UNKNOWN) + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with dataclass test data.""" + conf_str = """ +[%(DEFAULT)s] +# Defines a default object using only the required field +option_name = name:main +[%(PRESENT)s] +# Defines a complete object, multiline format +option_name = + name:master + priority:3 + state:RUNNING +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Invalid format - missing colon +option_name = bad_value +[%(PARTIAL)s] +# Only defines one field, relies on defaults for others +option_name = name:partial_obj +[%(BAD_FIELD_NAME)s] +# Includes a field name not present in the dataclass +option_name = name:badfield, non_existent_field:abc +[%(BAD_FIELD_VALUE)s] +# Includes a value that cannot be converted to the field's type +option_name = name:badvalue, priority:not_an_int +[%(EMPTY)s] +# Option present but empty +option_name = +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S, + "PARTIAL": PARTIAL_S, + "BAD_FIELD_NAME": BAD_FIELD_NAME_S, + "BAD_FIELD_VALUE": BAD_FIELD_VALUE_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic DataclassOption: init, load, value access, clear, default handling.""" + opt = config.DataclassOption("option_name", SimpleDataclass, "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == SimpleDataclass + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section (multiline format) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + # Test get_as_str (uses comma separator by default if value short enough) + assert opt.get_as_str() == "name:master,priority:3,state:RUNNING" + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "name:master, priority:3, state:RUNNING" # Config file format (adds space) + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL # name=main, priority=1, state=READY + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "name:main, priority:1, state:READY" + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + + # Load partial definition (should use dataclass defaults) + opt.load_config(conf, PARTIAL_S) + expected_partial = SimpleDataclass("partial_obj") # Uses defaults for priority/state + assert opt.value == expected_partial + assert opt.get_formatted() == "name:partial_obj, priority:1, state:READY" + +def test_required(conf: ConfigParser): + """Tests DataclassOption with the 'required' flag.""" + opt = config.DataclassOption("option_name", SimpleDataclass, "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid string values or invalid types.""" + opt = config.DataclassOption("option_name", SimpleDataclass, "description") + + # Load from section with bad format (missing colon) + with pytest.raises(ValueError, match="Illegal value 'bad_value' for option 'option_name'"): + opt.load_config(conf, BAD_S) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with unknown field name + with pytest.raises(ValueError, match="Unknown data field 'non_existent_field'"): + opt.load_config(conf, BAD_FIELD_NAME_S) + assert opt.value is None + + # Load from section with bad field value (cannot convert) + with pytest.raises(ValueError) as excinfo: # Check underlying error + opt.load_config(conf, BAD_FIELD_VALUE_S) + assert isinstance(excinfo.value, ValueError) # Check conversion error + assert "invalid literal for int()" in str(excinfo.value) + assert opt.value is None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'SimpleDataclass', not 'float'"): + opt.set_value(10.0) # type: ignore + + # Test setting invalid string via set_as_str + with pytest.raises(ValueError, match="Illegal value 'invalid-format' for option 'option_name'"): + opt.set_as_str("invalid-format") + + with pytest.raises(ValueError, match="Unknown data field 'badfield'"): + opt.set_as_str("badfield:value") + + with pytest.raises(ValueError) as excinfo: + opt.set_as_str("name:test, priority:invalid") + assert isinstance(excinfo.value, ValueError) + assert "invalid literal for int()" in str(excinfo.value) + + +def test_default(conf: ConfigParser): + """Tests DataclassOption with a defined default value.""" + opt = config.DataclassOption("option_name", SimpleDataclass, "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.DataclassOption("option_name", SimpleDataclass, "description", default=DEFAULT_OPT_VAL) + proto_value = SimpleDataclass("backup", 2, SimpleEnum.UNKNOWN) + proto_value_str = "name:backup,priority:2,state:UNKNOWN" # Expected string format + + # Set value and serialize + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_string') + # Serialized string might use different separator based on length, reconstruct for check + assert proto.options["option_name"].as_string == opt.get_as_str() + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value + assert isinstance(opt.value, opt.datatype) + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint64 = 1 # Invalid type for DataclassOption + with pytest.raises(TypeError, match="Wrong value type: uint64"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string format) + proto.Clear() + proto.options["option_name"].as_string = "name:bad, priority:invalid_int" + with pytest.raises(ValueError) as excinfo: + opt.load_proto(proto) + assert isinstance(excinfo.value, ValueError) + assert "invalid literal for int()" in str(excinfo.value) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.DataclassOption("option_name", SimpleDataclass, "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out) + expected_lines_default = """; description +; Type: list of values, where each list item defines value for a dataclass field. +; Item format: field_name:value_as_str +;option_name = name:default_obj, priority:5, state:READY +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value + opt.set_value(NEW_VAL) # name=master, priority=99, state=UNKNOWN + expected_lines_new = """; description +; Type: list of values, where each list item defines value for a dataclass field. +; Item format: field_name:value_as_str +option_name = name:master, priority:99, state:UNKNOWN +""" + assert opt.get_config() == expected_lines_new + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: list of values, where each list item defines value for a dataclass field. +; Item format: field_name:value_as_str +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test multiline formatting for long values + long_name = "a_very_long_dataclass_instance_name_that_should_cause_wrapping" + long_val = SimpleDataclass(long_name, 123456789, SimpleEnum.UNKNOWN) + opt.set_value(long_val) + expected_lines_long = f"""; description +; Type: list of values, where each list item defines value for a dataclass field. +; Item format: field_name:value_as_str +option_name = + name:{long_name} + priority:123456789 + state:UNKNOWN +""".replace("option_name =", "option_name = ") # Necessary due to editor trailing white cleanup + assert opt.get_config() == expected_lines_long + + # Test plain output + opt.set_value(NEW_VAL) + assert opt.get_config(plain=True) == "option_name = name:master, priority:99, state:UNKNOWN\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" + + +def test_fields_override(): + """Tests using the 'fields' parameter to override type hints.""" + # Dataclass has Optional[int], but we want to treat it just as 'int' for config + opt = config.DataclassOption( + "complex_option", + ComplexHintDataclass, + "description", + fields={'label': str, 'count': int, 'status': SimpleEnum} # Override 'count' + ) + + # Test setting string value that needs conversion based on overridden type + opt.set_as_str("label:Test, count:123, status:RUNNING") + assert opt.value == ComplexHintDataclass("Test", 123, SimpleEnum.RUNNING) + assert isinstance(opt.value.count, int) # Should be int, not Optional[int] internally + + # Test get_config reflects the actual value's type + assert opt.get_config() == """; description +; Type: list of values, where each list item defines value for a dataclass field. +; Item format: field_name:value_as_str +complex_option = label:Test, count:123, status:RUNNING +""" + + # Test case where config string is missing an optional field defined in 'fields' + # The dataclass __init__ should handle the default if the field is omitted + opt.set_as_str("label:OnlyLabel") + assert opt.value == ComplexHintDataclass("OnlyLabel", None, SimpleEnum.UNKNOWN) # Dataclass defaults used + + # Test case where config provides 'None' or empty for the overridden optional field + # This depends on how the base type's str->value convertor handles None/empty + # For int, it would likely raise an error. + with pytest.raises(ValueError) as excinfo: + opt.set_as_str("label:LabelNone, count:") # Empty string for int + assert isinstance(excinfo.value, ValueError) + + # Test error if 'fields' dict doesn't cover all dataclass fields (unlikely use case) + # Or if 'fields' dict refers to a field not in the dataclass + with pytest.raises(ValueError, match="Unknown data field 'non_field'"): + opt.set_as_str("label:X, non_field:Y") diff --git a/tests/config/test_cfg_decimal.py b/tests/config/test_cfg_decimal.py new file mode 100644 index 0000000..2c56eb8 --- /dev/null +++ b/tests/config/test_cfg_decimal.py @@ -0,0 +1,340 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_decimal.py +# DESCRIPTION: Tests for firebird.base.config DecimalOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the DecimalOption configuration option class.""" + +from __future__ import annotations + +from decimal import Decimal, InvalidOperation # Import specific exception +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" +EMPTY_S = "empty" + +# --- Constants for Test Values --- +PRESENT_VAL = Decimal("500.0") +DEFAULT_VAL = Decimal("10.5") +DEFAULT_OPT_VAL = Decimal("3000.0") # Default for the option itself +NEW_VAL = Decimal("0.0") + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with Decimal test data.""" + conf_str = """ +[%(DEFAULT)s] +# Option defined in DEFAULT section +option_name = 10.5 +[%(PRESENT)s] +# Option present (as integer string, should convert to Decimal) +option_name = 500 +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Option present but with an invalid decimal string +option_name = bad_value +[%(EMPTY)s] +# Option present but empty +option_name = +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic DecimalOption functionality: init, load, value access, clear, default handling.""" + opt = config.DecimalOption("option_name", "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == Decimal + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section (was "500", converts to Decimal("500")) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL # Decimal("500") == Decimal("500.0") + assert opt.get_as_str() == "500" # String conversion doesn't add .0 for integers + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "500" # Config file format + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL # Decimal("10.5") + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "10.5" + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL # Decimal("0.0") + assert isinstance(opt.value, opt.datatype) + +def test_required(conf: ConfigParser): + """Tests DecimalOption with the 'required' flag.""" + opt = config.DecimalOption("option_name", "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid decimal string values.""" + opt = config.DecimalOption("option_name", "description") + + # Load from section with bad value + with pytest.raises(ValueError, match="Cannot convert string to Decimal"): + opt.load_config(conf, BAD_S) + # Check underlying cause is InvalidOperation + with pytest.raises(ValueError) as excinfo: + opt.load_config(conf, BAD_S) + assert isinstance(excinfo.value.__cause__, InvalidOperation) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with empty value + with pytest.raises(ValueError, match="Cannot convert string to Decimal"): + opt.load_config(conf, EMPTY_S) + with pytest.raises(ValueError) as excinfo: + opt.load_config(conf, EMPTY_S) + assert isinstance(excinfo.value.__cause__, InvalidOperation) + assert opt.value is None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'Decimal', not 'float'"): + opt.set_value(10.0) # type: ignore + + # Test setting invalid string via set_as_str + with pytest.raises(ValueError, match="Cannot convert string to Decimal"): + opt.set_as_str("not-a-decimal") + +def test_default(conf: ConfigParser): + """Tests DecimalOption with a defined default value.""" + opt = config.DecimalOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.DecimalOption("option_name", "description", default=DEFAULT_OPT_VAL) + proto_value = Decimal("800000.123") + proto_value_str = "800000.123" + + # Set value and serialize (saves as string) + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_string') + assert proto.options["option_name"].as_string == proto_value_str + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from string + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value + assert isinstance(opt.value, opt.datatype) + + # Test deserializing from integer types in proto + proto.Clear() + proto.options["option_name"].as_uint64 = 12345 + opt.load_proto(proto) + assert opt.value == Decimal("12345") + + proto.Clear() + proto.options["option_name"].as_sint64 = -54321 + opt.load_proto(proto) + assert opt.value == Decimal("-54321") + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_float = 1.23 # Invalid type for DecimalOption + with pytest.raises(TypeError, match="Wrong value type: float"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string for decimal) + proto.Clear() + proto.options["option_name"].as_string = "not-a-decimal" + with pytest.raises(ValueError, match="Cannot convert string to Decimal"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.DecimalOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out) + expected_lines_default = """; description +; Type: Decimal +;option_name = 3000.0 +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value + opt.set_value(Decimal("500.120")) # Keep trailing zero + expected_lines_set = """; description +; Type: Decimal +option_name = 500.120 +""" + assert opt.get_config() == expected_lines_set + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: Decimal +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(Decimal("123.45")) + assert opt.get_config(plain=True) == "option_name = 123.45\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/config/test_cfg_enum.py b/tests/config/test_cfg_enum.py new file mode 100644 index 0000000..aa6df73 --- /dev/null +++ b/tests/config/test_cfg_enum.py @@ -0,0 +1,404 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_enum.py +# DESCRIPTION: Tests for firebird.base.config EnumOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the EnumOption configuration option class.""" + +from __future__ import annotations + +from enum import IntEnum +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" +EMPTY_S = "empty" +ILLEGAL_VAL_S = "illegal" # Section for testing loading an integer string + +# --- Test Helper Classes --- + +class SimpleEnum(IntEnum): + """Enum for testing EnumOption.""" + UNKNOWN = 0 + READY = 1 + RUNNING = 2 + WAITING = 3 + SUSPENDED = 4 + FINISHED = 5 + ABORTED = 6 + # Aliases + CREATED = 1 + BLOCKED = 3 + STOPPED = 4 # Alias for SUSPENDED + TERMINATED = 6 # Alias for ABORTED + +# --- Constants for Test Values --- +DEFAULT_VAL = SimpleEnum.UNKNOWN +PRESENT_VAL = SimpleEnum.RUNNING +DEFAULT_OPT_VAL = SimpleEnum.READY # Default for the option itself +NEW_VAL = SimpleEnum.SUSPENDED # Will test setting via STOPPED alias too + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with Enum test data.""" + conf_str = """ +[%(DEFAULT)s] +# Enum is defined by name in DEFAULT section +option_name = UNKNOWN +[%(PRESENT)s] +# Enum defined by name in specific section (case-insensitive load) +option_name = RuNnInG +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Option present but with a name not in the Enum +option_name = bad_value +[%(EMPTY)s] +# Option present but empty +option_name = +[%(ILLEGAL)s] +# Tries to load an integer string, which is invalid for EnumOption +option_name = 3 +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S, + "ILLEGAL": ILLEGAL_VAL_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic EnumOption functionality: init, load, value access, clear, default handling.""" + opt = config.EnumOption("option_name", SimpleEnum, "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == SimpleEnum + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + assert opt.allowed == SimpleEnum # Allowed should default to the enum type + opt.validate() # Should pass as not required + + # Load value from [present] section (case-insensitive) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL # SimpleEnum.RUNNING + assert opt.get_as_str() == "RUNNING" # String representation is the member name + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "running" # Config file format uses lowercase + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL # SimpleEnum.UNKNOWN + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "unknown" + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually using member + opt.set_value(NEW_VAL) # SimpleEnum.SUSPENDED + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "suspended" + + # Set value manually using alias member (STOPPED -> SUSPENDED) + opt.set_value(SimpleEnum.STOPPED) + assert opt.value == NEW_VAL # Should resolve to the primary member SUSPENDED + assert opt.get_formatted() == "suspended" # Output uses primary member name + +def test_required(conf: ConfigParser): + """Tests EnumOption with the 'required' flag.""" + opt = config.EnumOption("option_name", SimpleEnum, "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid enum string values.""" + opt = config.EnumOption("option_name", SimpleEnum, "description") + + # Load from section with bad value (not an enum member name) + with pytest.raises(ValueError, match="Illegal value 'bad_value' for enum type 'SimpleEnum'"): + opt.load_config(conf, BAD_S) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with empty value (should also be illegal) + with pytest.raises(ValueError, match="Illegal value '' for enum type 'SimpleEnum'"): + opt.load_config(conf, EMPTY_S) + assert opt.value is None + + # Load from section with integer string (illegal for EnumOption) + with pytest.raises(ValueError, match="Illegal value '3' for enum type 'SimpleEnum'"): + opt.load_config(conf, ILLEGAL_VAL_S) + assert opt.value is None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'SimpleEnum', not 'float'"): + opt.set_value(10.0) # type: ignore + + # Test setting invalid string via set_as_str + with pytest.raises(ValueError, match="Illegal value 'invalid_name' for enum type 'SimpleEnum'"): + opt.set_as_str("invalid_name") + + +def test_allowed_values(conf: ConfigParser): + """Tests EnumOption with the 'allowed' parameter restricting valid members.""" + allowed_members = [SimpleEnum.READY, SimpleEnum.RUNNING, SimpleEnum.FINISHED] + opt = config.EnumOption("option_name", SimpleEnum, "description", + allowed=allowed_members) + + # Verify allowed list is set + assert opt.allowed == allowed_members + + # Load a value that *is* allowed + opt.load_config(conf, PRESENT_S) # Value is RUNNING + assert opt.value == SimpleEnum.RUNNING + + # Load a value that is *not* allowed (UNKNOWN from DEFAULT section) + opt.value = None # Reset before loading + with pytest.raises(ValueError, match="Illegal value 'UNKNOWN' for enum type 'SimpleEnum'"): + # Note: set_as_str raises the error internally during load_config + opt.load_config(conf, DEFAULT_S) + assert opt.value is None # Should remain None + + # Try setting a disallowed value manually + with pytest.raises(ValueError, match="Value '' not allowed"): + opt.set_value(SimpleEnum.SUSPENDED) + + # Test get_config shows only allowed members in description + expected_config_desc = """; description +; Type: enum [ready, running, finished] +;option_name = +""" + opt.value = None # Reset value + assert opt.get_config() == expected_config_desc + + +def test_default(conf: ConfigParser): + """Tests EnumOption with a defined default value.""" + opt = config.EnumOption("option_name", SimpleEnum, "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL # SimpleEnum.READY + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL # SimpleEnum.RUNNING + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default # Should be SimpleEnum.READY + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL # SimpleEnum.UNKNOWN + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL # SimpleEnum.UNKNOWN + + # Set value manually + opt.set_value(NEW_VAL) # SimpleEnum.SUSPENDED + assert opt.value == NEW_VAL + + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.EnumOption("option_name", SimpleEnum, "description", default=DEFAULT_OPT_VAL) + proto_value = SimpleEnum.FINISHED # Use a specific value for testing + + # Set value and serialize (saves as string name) + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_string') + assert proto.options["option_name"].as_string == "FINISHED" + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from string + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value + assert isinstance(opt.value, opt.datatype) + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint32 = 1 # Invalid type for EnumOption (expects string) + with pytest.raises(TypeError, match="Wrong value type: uint32"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string for enum) + proto.Clear() + proto.options["option_name"].as_string = "not_a_member" + with pytest.raises(ValueError, match="Illegal value 'not_a_member' for enum type 'SimpleEnum'"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.EnumOption("option_name", SimpleEnum, "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (READY, should be commented out) + expected_lines_default = """; description +; Type: enum [unknown, ready, running, waiting, suspended, finished, aborted] +;option_name = ready +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value (SUSPENDED) + opt.set_value(SimpleEnum.SUSPENDED) + expected_lines_set = """; description +; Type: enum [unknown, ready, running, waiting, suspended, finished, aborted] +option_name = suspended +""" + assert opt.get_config() == expected_lines_set + + # Test output with alias value (STOPPED -> SUSPENDED) + opt.set_value(SimpleEnum.STOPPED) + assert opt.get_config() == expected_lines_set # Should still output primary name + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: enum [unknown, ready, running, waiting, suspended, finished, aborted] +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(SimpleEnum.RUNNING) + assert opt.get_config(plain=True) == "option_name = running\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" + + # Test with 'allowed' restriction + opt_allowed = config.EnumOption("option_name", SimpleEnum, "description", + allowed=[SimpleEnum.READY, SimpleEnum.RUNNING]) + opt_allowed.set_value(SimpleEnum.RUNNING) + expected_lines_allowed = """; description +; Type: enum [ready, running] +option_name = running +""" + assert opt_allowed.get_config() == expected_lines_allowed diff --git a/tests/config/test_cfg_env.py b/tests/config/test_cfg_env.py new file mode 100644 index 0000000..a6ae3d5 --- /dev/null +++ b/tests/config/test_cfg_env.py @@ -0,0 +1,204 @@ +# SPDX-FileCopyrightText: 2019-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_env.py +# DESCRIPTION: Tests for firebird.base.config EnvExtendedInterpolation +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the EnvExtendedInterpolation class in firebird.base.config.""" + +from __future__ import annotations + +import os +import pytest +from configparser import ConfigParser, InterpolationMissingOptionError, InterpolationSyntaxError # Added errors + +from firebird.base import config # Assuming config.py is accessible +# Assuming types.py is accessible if needed for Error, though not strictly required here +# from firebird.base.types import Error + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: # Use the fixture from conftest.py + """Provides a ConfigParser instance using EnvExtendedInterpolation, initialized with test data.""" + conf_str = """ +[base] +# Base value for standard interpolation testing +base_value = BASE_SECTION_VALUE +home_dir = /base/home + +[my-config] +# Standard value +value_str = VALUE + +# Standard interpolation from another section +base_value_interp = ${base:base_value} + +# Interpolation using existing environment variables +value_env_present = ${env:MYSECRET} +value_env_path = ${env:PATH} + +# Interpolation using non-existent environment variable (should resolve to empty string) +value_env_absent = ${env:NOT_A_REAL_ENV_VAR} + +# Interpolation using default section (works like standard interpolation) +value_from_default = ${DEFAULT:default_var} + +# Nested interpolation involving environment variables +nested_env = ${env:MYSECRET}/subpath +nested_mix = ${base:home_dir}/${env:MYSECRET} + +# Case-insensitivity test for env var name +value_env_mixed_case = ${env:mYsEcReT} + +[DEFAULT] +# Default value used in interpolation test +default_var = DEFAULT_VALUE +""" + # Read the string into the ConfigParser instance provided by base_conf + base_conf.read_string(conf_str) + return base_conf + +# --- Test Cases --- + +def test_env_interpolation(conf: ConfigParser, monkeypatch): + """Tests successful interpolation of environment variables.""" + # Set environment variables for the test + secret_value = "secret_test_value" + monkeypatch.setenv("MYSECRET", secret_value) + # PATH is usually set, but ensure it exists for robustness + original_path = os.getenv("PATH", "/usr/bin:/bin") + monkeypatch.setenv("PATH", original_path) + + # --- Assertions --- + # Standard value + assert conf["my-config"]["value_str"] == "VALUE" + + # Standard interpolation + assert conf["my-config"]["base_value_interp"] == "BASE_SECTION_VALUE" + + # Environment variable interpolation (present) + assert conf["my-config"]["value_env_present"] == secret_value + + # Environment variable interpolation (PATH) + assert conf["my-config"]["value_env_path"] == original_path + + # Environment variable interpolation (absent - should be empty string) + assert conf["my-config"]["value_env_absent"] == "" + + # Interpolation from DEFAULT section + assert conf["my-config"]["value_from_default"] == "DEFAULT_VALUE" + + # Nested interpolation involving environment variables + assert conf["my-config"]["nested_env"] == f"{secret_value}/subpath" + assert conf["my-config"]["nested_mix"] == f"/base/home/{secret_value}" + + # Case-insensitivity (env var names are typically case-insensitive on Windows, case-sensitive elsewhere, + # but os.getenv usually handles this. We test if our interpolation treats the *lookup key* case-insensitively). + # The interpolation logic uses optionxform which lowercases by default. Let's test the uppercase env var. + monkeypatch.setenv("MYUPPERSECRET", "upper_secret") + conf.read_string("[my-config]\nupper_test = ${env:MYUPPERSECRET}") # Add test case + assert conf["my-config"]["upper_test"] == "upper_secret" + # Test mixed case lookup key (should work due to optionxform lowercasing) + assert conf["my-config"]["value_env_mixed_case"] == secret_value + + +def test_env_interpolation_errors(base_conf: ConfigParser): # Use base_conf to start clean + """Tests error conditions during interpolation.""" + # Test Missing Option Error (standard interpolation) + conf_missing_std = """ +[section_a] +ref = ${section_b:missing_option} +[section_b] +exists = yes +""" + base_conf.read_string(conf_missing_std) + with pytest.raises(InterpolationMissingOptionError): + _ = base_conf["section_a"]["ref"] + + # Test Missing Option Error (env var - should NOT error, returns "") + # This confirms the special handling for 'env' section + base_conf.clear() + conf_missing_env = """ +[section_a] +ref = ${env:missing_env_var} +""" + base_conf.read_string(conf_missing_env) + # Should *not* raise InterpolationMissingOptionError + assert base_conf["section_a"]["ref"] == "" + + # Test Syntax Error (bad format) + base_conf.clear() + conf_syntax_bad_format = """ +[section_a] +ref = ${env:missing_close_brace +""" + base_conf.read_string(conf_syntax_bad_format) + with pytest.raises(InterpolationSyntaxError, match="bad interpolation variable reference"): + _ = base_conf["section_a"]["ref"] + + # Test Syntax Error (too many colons) + base_conf.clear() + conf_syntax_colons = """ +[section_a] +ref = ${env:too:many:colons} +""" + base_conf.read_string(conf_syntax_colons) + with pytest.raises(InterpolationSyntaxError, match="More than one ':' found"): + _ = base_conf["section_a"]["ref"] + + # Test Syntax Error (invalid char after $) + base_conf.clear() + conf_syntax_bad_char = """ +[section_a] +ref = $invalid +""" + base_conf.read_string(conf_syntax_bad_char) + with pytest.raises(InterpolationSyntaxError, match="'\\$' must be followed by"): + _ = base_conf["section_a"]["ref"] + + # Test Depth Error (standard interpolation - requires setup) + base_conf.clear() + conf_depth = """ +[a] +val = ${b:val} +[b] +val = ${a:val} +""" + base_conf.read_string(conf_depth) + # Need to configure MAX_INTERPOLATION_DEPTH lower for easy testing if possible, + # otherwise rely on default limit triggering the error. + # configparser doesn't easily expose setting MAX_INTERPOLATION_DEPTH externally. + # This error is less critical to test for the 'env' extension specifically. + # with pytest.raises(InterpolationDepthError): + # _ = base_conf["a"]["val"] + # Skipping direct DepthError test as it's hard to trigger reliably without modifying stdlib internals. \ No newline at end of file diff --git a/tests/config/test_cfg_flag.py b/tests/config/test_cfg_flag.py new file mode 100644 index 0000000..4b8c974 --- /dev/null +++ b/tests/config/test_cfg_flag.py @@ -0,0 +1,438 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_flag.py +# DESCRIPTION: Tests for firebird.base.config FlagOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the FlagOption configuration option class.""" + +from __future__ import annotations + +# Use STRICT boundary behavior for IntFlag tests to catch invalid integer values. +from enum import STRICT, Flag, IntFlag, auto +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" +EMPTY_S = "empty" +ILLEGAL_INT_S = "illegal_int" # Section for testing loading an integer string +MIXED_SEP_S = "mixed_sep" # Section for testing mixed separators + +# --- Test Helper Classes --- + +class SimpleIntFlag(IntFlag, boundary=STRICT): + """IntFlag for testing, using STRICT boundary.""" + NONE = 0 # Explicit zero member often useful + ONE = auto() + TWO = auto() + THREE = auto() + FOUR = auto() + FIVE = auto() + +class SimpleFlag(Flag): + """Standard Flag for comparison.""" + ONE = auto() + TWO = auto() + THREE = auto() + +# --- Constants for Test Values --- +DEFAULT_VAL = SimpleIntFlag.ONE +PRESENT_VAL = SimpleIntFlag.TWO | SimpleIntFlag.THREE +DEFAULT_OPT_VAL = SimpleIntFlag.THREE | SimpleIntFlag.FOUR # Default for the option itself +NEW_VAL = SimpleIntFlag.FIVE + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with Flag test data.""" + conf_str = """ +[%(DEFAULT)s] +# Flag is defined by single name in DEFAULT section +option_name = ONE +[%(PRESENT)s] +# Flag defined by multiple names (comma separated, case-insensitive load) +option_name = TwO, tHrEe +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Option present but with a name not in the Flag +option_name = bad_value +[%(EMPTY)s] +# Option present but empty (should result in Flag(0)) +option_name = +[%(ILLEGAL_INT)s] +# Tries to load an integer string (invalid for FlagOption string parsing) +option_name = 8 +[%(MIXED_SEP)s] +# Uses mixed separators (pipe and comma) +option_name = ONE | two, THREE +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S, + "ILLEGAL_INT": ILLEGAL_INT_S, + "MIXED_SEP": MIXED_SEP_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic FlagOption: init, load, value access, clear, default handling.""" + opt = config.FlagOption("option_name", SimpleIntFlag, "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == SimpleIntFlag + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + assert opt.allowed == SimpleIntFlag # Allowed defaults to the enum type + opt.validate() # Should pass as not required + + # Load value from [present] section (comma separated, case-insensitive) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL # SimpleIntFlag.TWO | SimpleIntFlag.THREE + # get_as_str() should produce pipe-separated canonical names + assert opt.get_as_str() == "TWO|THREE" + assert isinstance(opt.value, opt.datatype) + # get_formatted() uses lowercase names + assert opt.get_formatted() == "two|three" + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL # SimpleIntFlag.ONE + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "one" + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually using member + opt.set_value(NEW_VAL) # SimpleIntFlag.FIVE + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "five" + + # Test loading mixed separators + with pytest.raises(ValueError, match="Illegal value 'two, three' for flag option"): + opt.load_config(conf, MIXED_SEP_S) + + # Test loading empty value + with pytest.raises(ValueError, match="Illegal value '' for flag option"): + opt.load_config(conf, EMPTY_S) + +def test_required(conf: ConfigParser): + """Tests FlagOption with the 'required' flag.""" + opt = config.FlagOption("option_name", SimpleIntFlag, "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid flag string values or invalid types.""" + opt = config.FlagOption("option_name", SimpleIntFlag, "description") + + # Load from section with bad value (not a flag member name) + with pytest.raises(ValueError, match="'bad_value'"): # Internal lookup fails + opt.load_config(conf, BAD_S) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with integer string (invalid for string parsing) + with pytest.raises(ValueError, match="'8'"): + opt.load_config(conf, ILLEGAL_INT_S) + assert opt.value is None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'SimpleIntFlag', not 'SimpleFlag'"): + opt.set_value(SimpleFlag.ONE) # Type mismatch + + # Test setting invalid string via set_as_str + with pytest.raises(ValueError, match="'invalid_name'"): + opt.set_as_str("one | invalid_name") + + +def test_allowed_values(conf: ConfigParser): + """Tests FlagOption with the 'allowed' parameter restricting valid members.""" + allowed_members = [SimpleIntFlag.ONE, SimpleIntFlag.TWO, SimpleIntFlag.FOUR] + opt = config.FlagOption("option_name", SimpleIntFlag, "description", + allowed=allowed_members) + + # Verify allowed list is set + assert opt.allowed == allowed_members + + # Load value where all members are allowed + opt.load_config(conf, DEFAULT_S) # Value is ONE + assert opt.value == SimpleIntFlag.ONE + + # Load value where some members are *not* allowed + with pytest.raises(ValueError, match="'three'"): # PRESENT_S contains TWO and THREE + opt.load_config(conf, PRESENT_S) + assert opt.value == SimpleIntFlag.ONE # Should remain unchanged + + # Try setting a value containing disallowed members manually + disallowed_val = SimpleIntFlag.ONE | SimpleIntFlag.THREE # THREE is not allowed + with pytest.raises(ValueError, match="Illegal value.*for flag option 'option_name'"): + opt.set_value(disallowed_val) + + # Try setting a value that is completely disallowed + with pytest.raises(ValueError, match="Illegal value.*for flag option 'option_name'"): + opt.set_value(SimpleIntFlag.FIVE) + + # Test get_config shows only allowed members in description + expected_config_desc = """; description +; Type: flag [one, two, four] +;option_name = +""" + opt.value = None # Reset value + assert opt.get_config() == expected_config_desc + + +def test_default(conf: ConfigParser): + """Tests FlagOption with a defined default value.""" + opt = config.FlagOption("option_name", SimpleIntFlag, "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL # SimpleIntFlag.THREE | SimpleIntFlag.FOUR + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL # SimpleIntFlag.TWO | SimpleIntFlag.THREE + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default # Should be THREE | FOUR + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL # SimpleIntFlag.ONE + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL # SimpleIntFlag.ONE + + # Set value manually + opt.set_value(NEW_VAL) # SimpleIntFlag.FIVE + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.FlagOption("option_name", SimpleIntFlag, "description", default=DEFAULT_OPT_VAL) + proto_value_flag = SimpleIntFlag.ONE | SimpleIntFlag.FIVE + proto_value_int = proto_value_flag.value # Integer representation + proto_value_str = "ONE | FIVE" # String representation + + # Set value and serialize (saves as uint64) + opt.set_value(proto_value_flag) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_uint64') + assert proto.options["option_name"].as_uint64 == proto_value_int + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from uint64 + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value_flag + assert isinstance(opt.value, opt.datatype) + + # Test deserializing from string representation in proto + proto.Clear() + proto.options["option_name"].as_string = proto_value_str + opt.load_proto(proto) + assert opt.value == proto_value_flag + + proto.Clear() + proto.options["option_name"].as_string = "two, FOUR" # Mixed case, comma sep + opt.load_proto(proto) + assert opt.value == (SimpleIntFlag.TWO | SimpleIntFlag.FOUR) + + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_float = 1.23 # Invalid type for FlagOption + with pytest.raises(TypeError, match="Wrong value type: float"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid integer value for STRICT flag) + proto.Clear() + proto.options["option_name"].as_uint64 = 1000 # Not a valid flag combination + with pytest.raises(ValueError, match="invalid value 1000"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string for flag) + proto.Clear() + proto.options["option_name"].as_string = "one | non_member" + with pytest.raises(ValueError, match="'non_member'"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.FlagOption("option_name", SimpleIntFlag, "description", default=DEFAULT_OPT_VAL) + all_members_str = "one, two, three, four, five" # Assuming NONE=0 exists + + # Test output with default value (THREE | FOUR, should be commented out) + expected_lines_default = f"""; description +; Type: flag [{all_members_str}] +;option_name = three|four +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value (FIVE) + opt.set_value(SimpleIntFlag.FIVE) + expected_lines_set = f"""; description +; Type: flag [{all_members_str}] +option_name = five +""" + assert opt.get_config() == expected_lines_set + + # Test output with combined value (ONE | TWO) + opt.set_value(SimpleIntFlag.ONE | SimpleIntFlag.TWO) + expected_lines_comb = f"""; description +; Type: flag [{all_members_str}] +option_name = one|two +""" + assert opt.get_config() == expected_lines_comb + + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = f"""; description +; Type: flag [{all_members_str}] +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(SimpleIntFlag.ONE | SimpleIntFlag.THREE) + assert opt.get_config(plain=True) == "option_name = one|three\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" + + # Test with 'allowed' restriction + opt_allowed = config.FlagOption("option_name", SimpleIntFlag, "description", + allowed=[SimpleIntFlag.ONE, SimpleIntFlag.TWO]) + opt_allowed.set_value(SimpleIntFlag.TWO) + expected_lines_allowed = """; description +; Type: flag [one, two] +option_name = two +""" + assert opt_allowed.get_config() == expected_lines_allowed diff --git a/tests/config/test_cfg_float.py b/tests/config/test_cfg_float.py new file mode 100644 index 0000000..c114147 --- /dev/null +++ b/tests/config/test_cfg_float.py @@ -0,0 +1,335 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_float.py +# DESCRIPTION: Tests for firebird.base.config FloatOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the FloatOption configuration option class.""" + +from __future__ import annotations + +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" +EMPTY_S = "empty" + +# --- Constants for Test Values --- +PRESENT_VAL = 500.0 # Loaded from "500" +DEFAULT_VAL = 10.5 +DEFAULT_OPT_VAL = 3000.0 # Default for the option itself +NEW_VAL = 0.0 + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with float test data.""" + conf_str = """ +[%(DEFAULT)s] +# Option defined in DEFAULT section +option_name = 10.5 +[%(PRESENT)s] +# Option present (as integer string, should convert to float) +option_name = 500 +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Option present but with an invalid float string +option_name = bad_value +[%(EMPTY)s] +# Option present but empty +option_name = +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic FloatOption functionality: init, load, value access, clear, default handling.""" + opt = config.FloatOption("option_name", "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == float + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section (was "500", converts to 500.0) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + assert opt.get_as_str() == "500.0" # String representation of float + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "500.0" # Config file format + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL # 10.5 + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "10.5" + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL # 0.0 + assert isinstance(opt.value, opt.datatype) + +def test_required(conf: ConfigParser): + """Tests FloatOption with the 'required' flag.""" + opt = config.FloatOption("option_name", "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid float string values.""" + opt = config.FloatOption("option_name", "description") + + # Load from section with bad value + with pytest.raises(ValueError, match="could not convert string to float: 'bad_value'"): + opt.load_config(conf, BAD_S) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with empty value + with pytest.raises(ValueError, match="could not convert string to float: ''"): + opt.load_config(conf, EMPTY_S) + assert opt.value is None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'float', not 'int'"): + opt.set_value(10) # type: ignore + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'float', not 'bool'"): + opt.set_value(True) # type: ignore + + # Test setting invalid string via set_as_str + with pytest.raises(ValueError, match="could not convert string to float: 'not-a-float'"): + opt.set_as_str("not-a-float") + +def test_default(conf: ConfigParser): + """Tests FloatOption with a defined default value.""" + opt = config.FloatOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.FloatOption("option_name", "description", default=DEFAULT_OPT_VAL) + proto_value = 800000.125 # Use a float value for testing + opt.set_value(proto_value) + + # Serialize (saves as double) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_double') + assert proto.options["option_name"].as_double == proto_value + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from double + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value + assert isinstance(opt.value, opt.datatype) + + # Test deserializing from float field in proto + proto.Clear() + proto.options["option_name"].as_float = -123.5 # Use as_float field + opt.load_proto(proto) + # pytest.approx needed due to potential float precision issues + assert opt.value == pytest.approx(-123.5) + + # Test deserializing from string representation in proto + proto.Clear() + proto.options["option_name"].as_string = "987.654" + opt.load_proto(proto) + assert opt.value == pytest.approx(987.654) + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_bytes = b'abc' # Invalid type for FloatOption + with pytest.raises(TypeError, match="Wrong value type: bytes"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string for float) + proto.Clear() + proto.options["option_name"].as_string = "not-a-float" + with pytest.raises(ValueError, match="could not convert string to float: 'not-a-float'"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.FloatOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out) + expected_lines_default = """; description +; Type: float +;option_name = 3000.0 +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value + opt.set_value(500.75) + expected_lines_set = """; description +; Type: float +option_name = 500.75 +""" + assert opt.get_config() == expected_lines_set + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: float +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(123.45) + assert opt.get_config(plain=True) == "option_name = 123.45\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/config/test_cfg_int.py b/tests/config/test_cfg_int.py new file mode 100644 index 0000000..072f077 --- /dev/null +++ b/tests/config/test_cfg_int.py @@ -0,0 +1,409 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_int.py +# DESCRIPTION: Tests for firebird.base.config IntOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the IntOption configuration option class.""" + +from __future__ import annotations + +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" +EMPTY_S = "empty" +NEGATIVE_S = "negative" # Section for testing signed values + +# --- Constants for Test Values --- +PRESENT_VAL = 500 +DEFAULT_VAL = 10 +DEFAULT_OPT_VAL = 3000 # Default for the option itself +NEW_VAL = 0 +NEGATIVE_VAL = -99 + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with integer test data.""" + conf_str = """ +[%(DEFAULT)s] +# Option defined in DEFAULT section +option_name = 10 +[%(PRESENT)s] +# Option present in its own section +option_name = 500 +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Option present but with an invalid integer string +option_name = bad_value +[%(EMPTY)s] +# Option present but empty +option_name = +[%(NEGATIVE)s] +# Option with a negative value +option_name = -99 +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S, + "NEGATIVE": NEGATIVE_S}) + return base_conf + +# --- Test Cases --- + +def test_simple_unsigned(conf: ConfigParser): + """Tests basic *unsigned* IntOption: init, load, value access, clear, default handling.""" + # Default is unsigned (signed=False) + opt = config.IntOption("option_name", "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == int + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + assert opt.get_as_str() == "500" # String representation + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "500" # Config file format + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL # 10 + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == "10" + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL # 0 + assert isinstance(opt.value, opt.datatype) + + # Test setting negative value (should fail for unsigned) + with pytest.raises(ValueError, match="Negative numbers not allowed"): + opt.set_value(-1) + with pytest.raises(ValueError, match="Negative numbers not allowed"): + opt.value = -1 # type: ignore + with pytest.raises(ValueError, match="Negative numbers not allowed"): + opt.set_as_str("-1") + # Loading negative value from config should also fail + with pytest.raises(ValueError, match="Negative numbers not allowed"): + opt.load_config(conf, NEGATIVE_S) + + +def test_signed(conf: ConfigParser): + """Tests IntOption with signed=True.""" + opt = config.IntOption("option_name", "description", signed=True) + + # Verify initial state + assert opt.value is None + + # Load positive value + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Load negative value + opt.load_config(conf, NEGATIVE_S) + assert opt.value == NEGATIVE_VAL + assert opt.get_formatted() == "-99" + + # Set negative value manually + opt.set_value(-123) + assert opt.value == -123 + + # Set negative value via string + opt.set_as_str("-456") + assert opt.value == -456 + + +def test_required(conf: ConfigParser): + """Tests IntOption with the 'required' flag.""" + opt = config.IntOption("option_name", "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid integer string values.""" + opt = config.IntOption("option_name", "description") # Unsigned + + # Load from section with bad value + with pytest.raises(ValueError, match="invalid literal for int\\(\\) with base 10: 'bad_value'"): + opt.load_config(conf, BAD_S) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with empty value + with pytest.raises(ValueError, match="invalid literal for int\\(\\) with base 10: ''"): + opt.load_config(conf, EMPTY_S) + assert opt.value is None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'int', not 'float'"): + opt.set_value(10.0) # type: ignore + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'int', not 'str'"): + opt.set_value("10") # type: ignore + + # Test setting invalid string via set_as_str + with pytest.raises(ValueError, match="invalid literal for int\\(\\) with base 10: 'not-an-int'"): + opt.set_as_str("not-an-int") + + +def test_default(conf: ConfigParser): + """Tests IntOption with a defined default value.""" + opt = config.IntOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages for IntOption.""" + # --- Unsigned --- + opt_unsigned = config.IntOption("option_name", "description", default=DEFAULT_OPT_VAL) + proto_value_unsigned = 800000 + opt_unsigned.set_value(proto_value_unsigned) + + # Serialize (saves as uint64) + opt_unsigned.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_uint64') + assert proto.options["option_name"].as_uint64 == proto_value_unsigned + proto_dump_unsigned = proto.SerializeToString() + + # Clear option and deserialize from uint64 + opt_unsigned.clear(to_default=False) + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump_unsigned) + opt_unsigned.load_proto(proto_read) + assert opt_unsigned.value == proto_value_unsigned + assert isinstance(opt_unsigned.value, opt_unsigned.datatype) + + # --- Signed --- + opt_signed = config.IntOption("option_name", "description signed", signed=True) + proto_value_signed = -500000 + opt_signed.set_value(proto_value_signed) + proto.Clear() # Clear proto for signed test + + # Serialize (saves as sint64) + opt_signed.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_sint64') + assert proto.options["option_name"].as_sint64 == proto_value_signed + proto_dump_signed = proto.SerializeToString() + + # Clear option and deserialize from sint64 + opt_signed.clear(to_default=False) + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump_signed) + opt_signed.load_proto(proto_read) + assert opt_signed.value == proto_value_signed + assert isinstance(opt_signed.value, opt_signed.datatype) + + # --- Common Tests --- + opt = opt_unsigned # Use one instance for remaining tests + + # Test deserializing from various compatible proto int types + proto.Clear() + proto.options["option_name"].as_sint32 = 123 # Load sint32 into unsigned int option + opt.load_proto(proto) + assert opt.value == 123 + + proto.Clear() + proto.options["option_name"].as_uint32 = 456 # Load uint32 into unsigned int option + opt.load_proto(proto) + assert opt.value == 456 + + # Test deserializing from string representation in proto + proto.Clear() + proto.options["option_name"].as_string = "789" + opt.load_proto(proto) + assert opt.value == 789 + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_bytes = b'abc' # Invalid type for IntOption + with pytest.raises(TypeError, match="Wrong value type: bytes"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string for int) + proto.Clear() + proto.options["option_name"].as_string = "not-an-int" + with pytest.raises(ValueError, match="invalid literal for int\\(\\) with base 10: 'not-an-int'"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.IntOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out) + expected_lines_default = """; description +; Type: int +;option_name = 3000 +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value + opt.set_value(500) + expected_lines_set = """; description +; Type: int +option_name = 500 +""" + assert opt.get_config() == expected_lines_set + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: int +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(12345) + assert opt.get_config(plain=True) == "option_name = 12345\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/config/test_cfg_list.py b/tests/config/test_cfg_list.py new file mode 100644 index 0000000..a397c93 --- /dev/null +++ b/tests/config/test_cfg_list.py @@ -0,0 +1,727 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_list.py +# DESCRIPTION: Tests for firebird.base.config ListOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the ListOption configuration option class.""" + +from __future__ import annotations + +from decimal import Decimal +from enum import IntEnum +from uuid import UUID +import pytest +from configparser import ConfigParser # Import for type hinting +from collections.abc import Sequence + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.strconv import convert_to_str, register_class # For multi-type test setup +from firebird.base.types import MIME, Error, ZMQAddress # For multi-type test setup + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" +EMPTY_S = "empty" + +# --- Test Helper Classes --- + +class SimpleEnum(IntEnum): + """Enum used for testing within lists.""" + UNKNOWN = 0 + READY = 1 + RUNNING = 2 + +class ParamBase: + """Base class for test parameter sets.""" + # Values used in tests + DEFAULT_VAL = [] + PRESENT_VAL = [] + DEFAULT_OPT_VAL = [] + NEW_VAL = [] + PROTO_VALUE = [] + LONG_VAL = [] # For testing multiline formatting + + # String representations expected/used + DEFAULT_PRINT = "" # How DEFAULT_OPT_VAL prints in get_config comment + PRESENT_AS_STR = "" # How PRESENT_VAL serializes to string + NEW_PRINT = "" # How NEW_VAL prints in get_config non-commented + PROTO_VALUE_STR = "" # How PROTO_VALUE serializes to string for proto + + # Config option parameters + ITEM_TYPE: type | tuple[type, ...] = None # type: ignore + TYPE_NAMES: str = "" # Generated string like "int, str" + SEPARATOR: str | None = None # Separator override for ListOption + + # Expected error message for bad conversion from config string + BAD_MSG: tuple | None = None + + # Raw config string template for this type + conf_str: str = "" + + # Derived value used in tests + LONG_PRINT: str = "" # Generated multiline string from LONG_VAL + + def __init__(self): + """Initializes derived values after subclass defines bases.""" + self.conf: ConfigParser = None + self.prepare() # Allow subclasses to modify values before generating strings + # Generate TYPE_NAMES string + type_tuple = (self.ITEM_TYPE, ) if isinstance(self.ITEM_TYPE, type) else self.ITEM_TYPE + self.TYPE_NAMES = ", ".join(t.__name__ for t in type_tuple) + # Generate LONG_PRINT (multiline format) + x = "\n " + self.LONG_PRINT = f"\n {x.join(self._format_item(item) for item in self.LONG_VAL)}" + + def prepare(self): + """Placeholder for subclass modifications before string generation.""" + pass + + def _format_item(self, item) -> str: + """Helper to format list items, handling multi-type.""" + result = convert_to_str(item) + if not isinstance(self.ITEM_TYPE, type): # Multi-type case + result = f"{item.__class__.__name__}:{result}" + return result + +# --- Parameter Sets for Different List Item Types --- + +class StrParams(ParamBase): + """Parameters for ListOption[str].""" + DEFAULT_VAL = ["DEFAULT_value"] + PRESENT_VAL = ["present_value_1", "present_value_2"] + DEFAULT_OPT_VAL = ["DEFAULT_1", "DEFAULT_2", "DEFAULT_3"] + NEW_VAL = ["NEW"] + PROTO_VALUE = ["proto_value_1", "proto_value_2"] + LONG_VAL = ["long" * 3, "verylong" * 3, "veryverylong" * 5] + + DEFAULT_PRINT = "DEFAULT_1, DEFAULT_2, DEFAULT_3" + PRESENT_AS_STR = "present_value_1,present_value_2" # Loaded multiline + NEW_PRINT = "NEW" + PROTO_VALUE_STR = "proto_value_1,proto_value_2" # Default comma for short proto + + ITEM_TYPE = str + BAD_MSG = None # No conversion error expected for string + + conf_str = """ +[%(DEFAULT)s] +[%(PRESENT)s] +option_name = + present_value_1 + present_value_2 +[%(ABSENT)s] +[%(BAD)s] +# Bad value test not applicable for str, use EMPTY instead +[%(EMPTY)s] +option_name = +""" + +class IntParams(ParamBase): + """Parameters for ListOption[int].""" + DEFAULT_VAL = [0] + PRESENT_VAL = [10, 20] + DEFAULT_OPT_VAL = [1, 2, 3] + NEW_VAL = [100] + PROTO_VALUE = [30, 40, 50] + LONG_VAL = [x for x in range(50)] + + DEFAULT_PRINT = "1, 2, 3" + PRESENT_AS_STR = "10,20" # Loaded comma-separated + NEW_PRINT = "100" + PROTO_VALUE_STR = "30,40,50" + + ITEM_TYPE = int + BAD_MSG = ("invalid literal for int() with base 10: 'invalid'",) + + conf_str = """ +[%(DEFAULT)s] +option_name = 0 +[%(PRESENT)s] +option_name = 10, 20 +[%(ABSENT)s] +[%(BAD)s] +option_name = invalid +[%(EMPTY)s] +option_name = +""" + +class FloatParams(ParamBase): + """Parameters for ListOption[float].""" + DEFAULT_VAL = [0.0] + PRESENT_VAL = [10.1, 20.2] + DEFAULT_OPT_VAL = [1.11, 2.22, 3.33] + NEW_VAL = [100.101] + PROTO_VALUE = [30.3, 40.4, 50.5] + LONG_VAL = [x / 1.5 for x in range(50)] + + DEFAULT_PRINT = "1.11, 2.22, 3.33" + PRESENT_AS_STR = "10.1,20.2" + NEW_PRINT = "100.101" + PROTO_VALUE_STR = "30.3,40.4,50.5" + + ITEM_TYPE = float + BAD_MSG = ("could not convert string to float: 'invalid'",) + + conf_str = """ +[%(DEFAULT)s] +option_name = 0.0 +[%(PRESENT)s] +option_name = 10.1, 20.2 +[%(ABSENT)s] +[%(BAD)s] +option_name = invalid +[%(EMPTY)s] +option_name = +""" + +class DecimalParams(ParamBase): + """Parameters for ListOption[Decimal].""" + DEFAULT_VAL = [Decimal("0.0")] + PRESENT_VAL = [Decimal("10.1"), Decimal("20.2")] + DEFAULT_OPT_VAL = [Decimal("1.11"), Decimal("2.22"), Decimal("3.33")] + NEW_VAL = [Decimal("100.101")] + PROTO_VALUE = [Decimal("30.3"), Decimal("40.4"), Decimal("50.5")] + LONG_VAL = [Decimal(str(x / 1.5)) for x in range(50)] + + DEFAULT_PRINT = "1.11, 2.22, 3.33" + PRESENT_AS_STR = "10.1,20.2" + NEW_PRINT = "100.101" + PROTO_VALUE_STR = "30.3,40.4,50.5" + + ITEM_TYPE = Decimal + BAD_MSG = ("could not convert string to Decimal: 'invalid'",) + + conf_str = """ +[%(DEFAULT)s] +option_name = 0.0 +[%(PRESENT)s] +option_name = 10.1, 20.2 +[%(ABSENT)s] +[%(BAD)s] +option_name = invalid +[%(EMPTY)s] +option_name = +""" + +class BoolParams(ParamBase): + """Parameters for ListOption[bool].""" + DEFAULT_VAL = [False] # From "0" + PRESENT_VAL = [True, False] + DEFAULT_OPT_VAL = [True, False, True] + NEW_VAL = [True] + PROTO_VALUE = [False, True, False] + LONG_VAL = [bool(x % 2) for x in range(40)] # Alternating True/False + + DEFAULT_PRINT = "yes, no, yes" + PRESENT_AS_STR = "yes,no" + NEW_PRINT = "yes" + PROTO_VALUE_STR = "no,yes,no" + + ITEM_TYPE = bool + BAD_MSG = ("Value is not a valid bool string constant",) + + conf_str = """ +[%(DEFAULT)s] +option_name = 0 +[%(PRESENT)s] +option_name = yes, no +[%(ABSENT)s] +[%(BAD)s] +option_name = this is not a bool +[%(EMPTY)s] +option_name = +""" + +class UUIDParams(ParamBase): + """Parameters for ListOption[UUID].""" + DEFAULT_VAL = [UUID("eeb7f94a-256d-11ea-ad1d-5404a6a1fd6e")] + PRESENT_VAL = [UUID("0a7fd53a-256e-11ea-ad1d-5404a6a1fd6e"), + UUID("0551feb2-256e-11ea-ad1d-5404a6a1fd6e")] + DEFAULT_OPT_VAL = [UUID("2f02868c-256e-11ea-ad1d-5404a6a1fd6e"), + UUID("3521db30-256e-11ea-ad1d-5404a6a1fd6e"), + UUID("3a3e68cc-256e-11ea-ad1d-5404a6a1fd6e")] + NEW_VAL = [UUID("3e8a4ce8-256e-11ea-ad1d-5404a6a1fd6e")] + PROTO_VALUE = [UUID("3a3e68cc-256e-11ea-ad1d-5404a6a1fd6e"), UUID("3521db30-256e-11ea-ad1d-5404a6a1fd6e")] + LONG_VAL = [UUID("2f02868c-256e-11ea-ad1d-5404a6a1fd6e") for x in range(10)] + + DEFAULT_PRINT = "\n; 2f02868c-256e-11ea-ad1d-5404a6a1fd6e\n; 3521db30-256e-11ea-ad1d-5404a6a1fd6e\n; 3a3e68cc-256e-11ea-ad1d-5404a6a1fd6e" # Multiline default + PRESENT_AS_STR = "0a7fd53a-256e-11ea-ad1d-5404a6a1fd6e,0551feb2-256e-11ea-ad1d-5404a6a1fd6e" + NEW_PRINT = "3e8a4ce8-256e-11ea-ad1d-5404a6a1fd6e" + PROTO_VALUE_STR = "3a3e68cc-256e-11ea-ad1d-5404a6a1fd6e,3521db30-256e-11ea-ad1d-5404a6a1fd6e" + + ITEM_TYPE = UUID + BAD_MSG = ("badly formed hexadecimal UUID string",) + + conf_str = """ +[%(DEFAULT)s] +option_name = eeb7f94a-256d-11ea-ad1d-5404a6a1fd6e +[%(PRESENT)s] +# Mixed formats allowed by UUID constructor +option_name = 0a7fd53a256e11eaad1d5404a6a1fd6e, 0551feb2-256e-11ea-ad1d-5404a6a1fd6e +[%(ABSENT)s] +[%(BAD)s] +option_name = this is not an uuid +[%(EMPTY)s] +option_name = +""" + +class MIMEParams(ParamBase): + """Parameters for ListOption[MIME].""" + DEFAULT_VAL = [MIME("application/octet-stream")] + PRESENT_VAL = [MIME("text/plain;charset=utf-8"), MIME("text/csv")] + DEFAULT_OPT_VAL = [MIME("text/html;charset=utf-8"), MIME("video/mp4"), MIME("image/png")] + NEW_VAL = [MIME("audio/mpeg")] + PROTO_VALUE = [MIME("application/octet-stream"), MIME("video/mp4")] + LONG_VAL = [MIME("text/html;charset=win1250") for x in range(10)] + + DEFAULT_PRINT = "text/html;charset=utf-8, video/mp4, image/png" + PRESENT_AS_STR = "text/plain;charset=utf-8,text/csv" # Loaded multiline + NEW_PRINT = "audio/mpeg" + PROTO_VALUE_STR = "application/octet-stream,video/mp4" + + ITEM_TYPE = MIME + BAD_MSG = ("MIME type specification must be 'type/subtype[;param=value;...]'",) + + conf_str = """ +[%(DEFAULT)s] +option_name = application/octet-stream +[%(PRESENT)s] +option_name = + text/plain;charset=utf-8 + text/csv +[%(ABSENT)s] +[%(BAD)s] +option_name = wrong mime specification +[%(EMPTY)s] +option_name = +""" + +class ZMQAddressParams(ParamBase): + """Parameters for ListOption[ZMQAddress].""" + DEFAULT_VAL = [ZMQAddress("tcp://127.0.0.1:*")] + PRESENT_VAL = [ZMQAddress("ipc://@my-address"), ZMQAddress("inproc://my-address"), ZMQAddress("tcp://127.0.0.1:9001")] + DEFAULT_OPT_VAL = [ZMQAddress("tcp://127.0.0.1:8001")] + NEW_VAL = [ZMQAddress("inproc://my-address")] + PROTO_VALUE = [ZMQAddress("tcp://www.firebirdsql.org:8001"), ZMQAddress("tcp://www.firebirdsql.org:9001")] + LONG_VAL = [ZMQAddress("tcp://www.firebirdsql.org:500") for x in range(10)] + + DEFAULT_PRINT = "tcp://127.0.0.1:8001" + PRESENT_AS_STR = "ipc://@my-address,inproc://my-address,tcp://127.0.0.1:9001" + NEW_PRINT = "inproc://my-address" + PROTO_VALUE_STR = "tcp://www.firebirdsql.org:8001,tcp://www.firebirdsql.org:9001" + + ITEM_TYPE = ZMQAddress + BAD_MSG = ("Protocol specification required",) + + conf_str = """ +[%(DEFAULT)s] +option_name = tcp://127.0.0.1:* +[%(PRESENT)s] +option_name = ipc://@my-address, inproc://my-address, tcp://127.0.0.1:9001 +[%(ABSENT)s] +[%(BAD)s] +option_name = bad_value +[%(EMPTY)s] +option_name = +""" + +class MultiTypeParams(ParamBase): + """Parameters for ListOption with multiple item types.""" + DEFAULT_VAL = ["DEFAULT_value"] # From str:DEFAULT_value + PRESENT_VAL = [1, 1.1, Decimal("1.01"), True, + UUID("eeb7f94a-256d-11ea-ad1d-5404a6a1fd6e"), + MIME("application/octet-stream"), + ZMQAddress("tcp://127.0.0.1:*"), + SimpleEnum.RUNNING] + DEFAULT_OPT_VAL = ["DEFAULT_1", 1, False] + NEW_VAL = [MIME("text/plain;charset=utf-8")] + PROTO_VALUE = [UUID("2f02868c-256e-11ea-ad1d-5404a6a1fd6e"), MIME("application/octet-stream")] + LONG_VAL = [ZMQAddress("tcp://www.firebirdsql.org:500"), + UUID("2f02868c-256e-11ea-ad1d-5404a6a1fd6e"), + MIME("application/octet-stream"), + "=" * 30, 1, True, 10.1, Decimal("20.20")] + + DEFAULT_PRINT = "str:DEFAULT_1, int:1, bool:no" # Needs type prefix + # Config is multiline, so default separator is newline for get_as_str + PRESENT_AS_STR = "int:1\nfloat:1.1\nDecimal:1.01\nbool:yes\nUUID:eeb7f94a-256d-11ea-ad1d-5404a6a1fd6e\nMIME:application/octet-stream\nZMQAddress:tcp://127.0.0.1:*\nSimpleEnum:RUNNING" + NEW_PRINT = "MIME:text/plain;charset=utf-8" # Needs type prefix + PROTO_VALUE_STR = "UUID:2f02868c-256e-11ea-ad1d-5404a6a1fd6e,MIME:application/octet-stream" + + ITEM_TYPE = (str, int, float, Decimal, bool, UUID, MIME, ZMQAddress, SimpleEnum) + # Register classes used in multi-type list if not built-in or already registered + register_class(SimpleEnum) + + BAD_MSG = ("Item type 'bin' not supported",) # From the bad config string below + + conf_str = """ +[%(DEFAULT)s] +option_name = str:DEFAULT_value +[%(PRESENT)s] +option_name = + int: 1 + float: 1.1 + Decimal: 1.01 + bool: yes + UUID: eeb7f94a-256d-11ea-ad1d-5404a6a1fd6e + # Test using full name lookup + firebird.base.types.MIME: application/octet-stream + # Test simple name lookup (requires prior register_class) + ZMQAddress: tcp://127.0.0.1:* + SimpleEnum:RUNNING +[%(ABSENT)s] +[%(BAD)s] +# Contains an unsupported type prefix 'bin' +option_name = str:this is string, int:20, bin:100110111 +[%(EMPTY)s] +option_name = +""" + +# List of parameter classes to use with pytest.mark.parametrize +params = [StrParams, IntParams, FloatParams, DecimalParams, BoolParams, UUIDParams, + MIMEParams, ZMQAddressParams, MultiTypeParams] + +@pytest.fixture(params=params) +def test_params(base_conf: ConfigParser, request) -> ParamBase: + """Fixture providing parameterized test data for ListOption tests.""" + param_class = request.param + data = param_class() + data.conf = base_conf # Attach the base config parser + # Read the specific config string for this parameter set + data.conf.read_string(data.conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S}) + return data + +# --- Test Cases --- + +def test_simple(test_params: ParamBase): + """Tests basic ListOption: init, load, value access, clear, default handling.""" + opt = config.ListOption("option_name", test_params.ITEM_TYPE, "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == list + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + assert opt.item_types == test_params.ITEM_TYPE if isinstance(test_params.ITEM_TYPE, Sequence) else (test_params.ITEM_TYPE, ) + opt.validate() # Should pass as not required + + # Load value from [present] section + opt.load_config(test_params.conf, PRESENT_S) + assert opt.value == test_params.PRESENT_VAL + # get_as_str() depends on default separator logic (newline if loaded multiline) + assert opt.get_as_str() == test_params.PRESENT_AS_STR + assert isinstance(opt.value, opt.datatype) + # get_formatted() depends on default separator logic + if '\n' in test_params.PRESENT_AS_STR: # Check if it was multiline + expected_format = f"\n {test_params.PRESENT_AS_STR.replace(chr(10), chr(10) + ' ')}" + assert opt.get_formatted() == expected_format + else: + assert opt.get_formatted() == ", ".join(opt._get_as_typed_str(i) for i in test_params.PRESENT_VAL) + + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Set value manually + opt.set_value(test_params.NEW_VAL) + assert opt.value == test_params.NEW_VAL + assert isinstance(opt.value, opt.datatype) + + # Test assigning list with wrong item type (only if ITEM_TYPE is single) + if isinstance(test_params.ITEM_TYPE, type) and test_params.ITEM_TYPE is not str: + with pytest.raises(ValueError, match="List item\\[1\\] has wrong type"): + opt.value = [test_params.NEW_VAL[0], "a_string"] + elif isinstance(test_params.ITEM_TYPE, type) and test_params.ITEM_TYPE is str: + with pytest.raises(ValueError, match="List item\\[1\\] has wrong type"): + opt.value = [test_params.NEW_VAL[0], 123] # Assign int to str list + + +def test_required(test_params: ParamBase): + """Tests ListOption with the 'required' flag.""" + opt = config.ListOption("option_name", test_params.ITEM_TYPE, "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(test_params.conf, PRESENT_S) + assert opt.value == test_params.PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Set value manually + opt.set_value(test_params.NEW_VAL) + assert opt.value == test_params.NEW_VAL + opt.validate() + +def test_bad_value(test_params: ParamBase): + """Tests loading invalid list string values.""" + opt = config.ListOption("option_name", test_params.ITEM_TYPE, "description") + + # Load from section with bad value + if test_params.BAD_MSG: + with pytest.raises(ValueError) as excinfo: + opt.load_config(test_params.conf, BAD_S) + # Check if the specific underlying error matches + if isinstance(excinfo.value, Exception): + assert excinfo.value.args == test_params.BAD_MSG + else: + # For multi-type error which isn't from cause + assert excinfo.value.args == test_params.BAD_MSG + + assert opt.value is None # Value should remain unchanged (None) + else: + # For string list, BAD_S might be empty or contain convertible strings + opt.load_config(test_params.conf, BAD_S) + # Depending on conf_str for StrParams, value might be None or [''] + assert opt.value is None or opt.value == [''] + + + # Load from section with empty value (should result in None or empty list) + opt.load_config(test_params.conf, EMPTY_S) + assert opt.value is None # Empty config value results in None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'list', not 'float'"): + opt.set_value(10.0) # type: ignore + + # Test setting invalid string via set_as_str + if test_params.BAD_MSG: + with pytest.raises(ValueError) as excinfo: + opt.set_as_str("invalid" if not isinstance(test_params.ITEM_TYPE, Sequence) else "bin:invalid") + if isinstance(excinfo.value, Exception): + assert excinfo.value.args == test_params.BAD_MSG + elif test_params.ITEM_TYPE is bool: # Bool error isn't nested + assert excinfo.value.args == test_params.BAD_MSG + + +def test_default(test_params: ParamBase): + """Tests ListOption with a defined default list value.""" + opt = config.ListOption("option_name", test_params.ITEM_TYPE, "description", + default=test_params.DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == test_params.DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == test_params.DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(test_params.conf, PRESENT_S) + assert opt.value == test_params.PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + # Default is copied, should be equal but not the same instance + assert opt.value == opt.default + assert opt.value is not opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Set value manually + opt.set_value(test_params.NEW_VAL) + assert opt.value == test_params.NEW_VAL + + # Ensure default list wasn't modified if value was appended to + opt.value.append(test_params.DEFAULT_VAL[0]) # Modify the current value list + assert opt.default == test_params.DEFAULT_OPT_VAL # Original default should be unchanged + +def test_proto(test_params: ParamBase, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.ListOption("option_name", test_params.ITEM_TYPE, "description", + default=test_params.DEFAULT_OPT_VAL) + proto_value = test_params.PROTO_VALUE + proto_value_str = test_params.PROTO_VALUE_STR + + # Set value and serialize (saves as string) + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_string') + # Serialized string uses default separator logic (comma unless long) + assert proto.options["option_name"].as_string == proto_value_str + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from string + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value + assert isinstance(opt.value, opt.datatype) + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(test_params.DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value == test_params.DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint64 = 1 # Invalid type for ListOption (expects string) + with pytest.raises(TypeError, match="Wrong value type: uint64"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string format for item type) + if test_params.BAD_MSG: + proto.Clear() + # Construct a bad string based on expected error + bad_item_str = "invalid" + proto.options["option_name"].as_string = bad_item_str if len(opt.item_types) == 1 \ + else f"bin:{bad_item_str}" # Need prefix for multi + with pytest.raises(ValueError) as excinfo: + opt.load_proto(proto) + if isinstance(excinfo.value, Exception): + assert excinfo.value.args == test_params.BAD_MSG + # Handle multi-type case where error isn't nested + elif test_params is MultiTypeParams and test_params.BAD_MSG[0].startswith("Item type"): + assert excinfo.value.args == test_params.BAD_MSG + + +def test_get_config(test_params: ParamBase): + """Tests the get_config method for generating config file string representation.""" + opt = config.ListOption("option_name", test_params.ITEM_TYPE, "description", + default=test_params.DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out) + expected_lines_default = f"""; description +; Type: list [{test_params.TYPE_NAMES}] +;option_name = {test_params.DEFAULT_PRINT} +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value + opt.set_value(test_params.NEW_VAL) + expected_lines_set = f"""; description +; Type: list [{test_params.TYPE_NAMES}] +option_name = {test_params.NEW_PRINT} +""" + assert opt.get_config() == expected_lines_set + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = f"""; description +; Type: list [{test_params.TYPE_NAMES}] +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test multiline formatting for long values + opt.set_value(test_params.LONG_VAL) + expected_lines_long = f"""; description +; Type: list [{test_params.TYPE_NAMES}] +option_name = {test_params.LONG_PRINT} +""" + assert opt.get_config() == expected_lines_long + + # Test plain output + opt.set_value(test_params.NEW_VAL) + assert opt.get_config(plain=True) == f"option_name = {test_params.NEW_PRINT}\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" + +def test_separator_override(test_params: ParamBase): + """Tests ListOption with an explicit separator.""" + # Use semicolon as separator + opt = config.ListOption("option_name", test_params.ITEM_TYPE, "description", + separator='|') + assert opt.separator == '|' + + # Set value + opt.set_value(test_params.PRESENT_VAL) + + # Check get_formatted uses the specified separator + expected_format = "| ".join(opt._get_as_typed_str(i) for i in test_params.PRESENT_VAL) + assert opt.get_formatted() == expected_format + + # Check get_as_str uses the specified separator + expected_str = "|".join(opt._get_as_typed_str(i) for i in test_params.PRESENT_VAL) + assert opt.get_as_str() == expected_str + + # Test set_as_str with the specified separator + opt.set_value(None) # Clear first + opt.set_as_str(expected_str) + assert opt.value == test_params.PRESENT_VAL + + # Test set_as_str with a *different* separator (should likely fail or parse incorrectly) + opt.set_value(None) + if test_params.BAD_MSG: # Expect parsing error if items are not simple strings + with pytest.raises(ValueError): + opt.set_as_str("item1, item2") # Using comma instead of semicolon + else: # For string list, it will just parse as one item + opt.set_as_str("item1, item2") + assert opt.value == ["item1, item2"] diff --git a/tests/config/test_cfg_mime.py b/tests/config/test_cfg_mime.py new file mode 100644 index 0000000..a7ce0d9 --- /dev/null +++ b/tests/config/test_cfg_mime.py @@ -0,0 +1,367 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_mime.py +# DESCRIPTION: Tests for firebird.base.config MIMEOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the MIMEOption configuration option class.""" + +from __future__ import annotations + +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import MIME, Error + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_FORMAT_S = "bad_format" +BAD_TYPE_S = "unsupported_mime_type" +BAD_PARAMS_S = "bad_mime_parameters" +EMPTY_S = "empty" + +# --- Constants for Test Values --- +PRESENT_VAL = MIME("text/plain;charset=utf-8") +PRESENT_TYPE = "text/plain" +PRESENT_PARS = {"charset": "utf-8"} +DEFAULT_VAL = MIME("application/octet-stream") +DEFAULT_TYPE = "application/octet-stream" +DEFAULT_PARS = {} +DEFAULT_OPT_VAL = MIME("text/plain;charset=win1250") # Default for the option itself +DEFAULT_OPT_TYPE = "text/plain" +DEFAULT_OPT_PARS = {"charset": "win1250"} +NEW_VAL = MIME("application/x.fb.proto;type=firebird.butler.fbsd.ErrorDescription") +NEW_TYPE = "application/x.fb.proto" +NEW_PARS = {"type": "firebird.butler.fbsd.ErrorDescription"} + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with MIME test data.""" + conf_str = """ +[%(DEFAULT)s] +# Option defined in DEFAULT section +option_name = application/octet-stream +[%(PRESENT)s] +# Option present in its own section +option_name = text/plain;charset=utf-8 +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD_FORMAT)s] +# Invalid format (missing slash) +option_name = wrong mime specification +[%(BAD_TYPE)s] +# Unsupported MIME primary type +option_name = model/vml +[%(BAD_PARAMS)s] +# Invalid parameter format (should be key=value) +option_name = text/plain;charset/utf-8 +[%(EMPTY)s] +# Option present but empty +option_name = +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD_FORMAT": BAD_FORMAT_S, + "BAD_TYPE": BAD_TYPE_S, "BAD_PARAMS": BAD_PARAMS_S, + "EMPTY": EMPTY_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic MIMEOption functionality: init, load, value access, clear, default handling.""" + opt: config.MIMEOption = config.MIMEOption("option_name", "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == MIME + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + assert str(opt.value) == "text/plain;charset=utf-8" # Check string value + assert opt.get_as_str() == str(PRESENT_VAL) # Should be the same + assert isinstance(opt.value, opt.datatype) + assert opt.value.mime_type == PRESENT_TYPE + assert opt.value.params == PRESENT_PARS + assert opt.value.params.get("charset") == "utf-8" + assert opt.get_formatted() == str(PRESENT_VAL) # Config format is same as string + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + assert opt.value.mime_type == DEFAULT_TYPE + assert opt.value.params == DEFAULT_PARS + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + assert opt.value.mime_type == NEW_TYPE + assert opt.value.params == NEW_PARS + +def test_required(conf: ConfigParser): + """Tests MIMEOption with the 'required' flag.""" + opt = config.MIMEOption("option_name", "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid MIME string values or invalid types.""" + opt: config.MIMEOption = config.MIMEOption("option_name", "description") + + # Load from section with bad format (missing slash) + with pytest.raises(ValueError, match="MIME type specification must be"): + opt.load_config(conf, BAD_FORMAT_S) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with unsupported type + with pytest.raises(ValueError, match="MIME type 'model' not supported"): + opt.load_config(conf, BAD_TYPE_S) + assert opt.value is None + + # Load from section with bad parameters + with pytest.raises(ValueError, match="Wrong specification of MIME type parameters"): + opt.load_config(conf, BAD_PARAMS_S) + assert opt.value is None + + # Load from section with empty value + with pytest.raises(ValueError, match="MIME type specification must be"): + opt.load_config(conf, EMPTY_S) + assert opt.value is None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'MIME', not 'float'"): + opt.set_value(10.0) # type: ignore + + # Test setting invalid string via set_as_str + with pytest.raises(ValueError, match="MIME type specification must be"): + opt.set_as_str("invalid-mime-string") + + +def test_default(conf: ConfigParser): + """Tests MIMEOption with a defined default value.""" + opt = config.MIMEOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + assert opt.value.mime_type == DEFAULT_OPT_TYPE + assert opt.value.params == DEFAULT_OPT_PARS + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + assert opt.value.mime_type == PRESENT_TYPE + assert opt.value.params == PRESENT_PARS + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + assert opt.value.mime_type == DEFAULT_TYPE + assert opt.value.params == DEFAULT_PARS + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert opt.value.mime_type == NEW_TYPE + assert opt.value.params == NEW_PARS + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.MIMEOption("option_name", "description", default=DEFAULT_OPT_VAL) + proto_value = NEW_VAL # application/x.fb.proto;type=... + proto_value_str = str(proto_value) + + # Set value and serialize (saves as string) + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_string') + assert proto.options["option_name"].as_string == proto_value_str + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from string + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value + assert opt.value.mime_type == NEW_TYPE + assert opt.value.params == NEW_PARS + assert isinstance(opt.value, opt.datatype) + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint32 = 1000 # Invalid type for MIMEOption + with pytest.raises(TypeError, match="Wrong value type: uint32"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string for MIME) + proto.Clear() + proto.options["option_name"].as_string = "invalid mime" + with pytest.raises(ValueError, match="MIME type specification must be"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.MIMEOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out) + expected_lines_default = """; description +; Type: MIME +;option_name = text/plain;charset=win1250 +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value + opt.set_value(NEW_VAL) + expected_lines_new = """; description +; Type: MIME +option_name = application/x.fb.proto;type=firebird.butler.fbsd.ErrorDescription +""" + assert opt.get_config() == expected_lines_new + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: MIME +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(NEW_VAL) + assert opt.get_config(plain=True) == f"option_name = {str(NEW_VAL)}\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/config/test_cfg_path.py b/tests/config/test_cfg_path.py new file mode 100644 index 0000000..8928b43 --- /dev/null +++ b/tests/config/test_cfg_path.py @@ -0,0 +1,323 @@ +# SPDX-FileCopyrightText: 2019-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_path.py +# DESCRIPTION: Tests for firebird.base.config PathOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the PathOption configuration option class.""" + +from __future__ import annotations + +import platform +from pathlib import Path +import os +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" # Not applicable for Path, will result in empty Path +EMPTY_S = "empty" + +# --- Constants for Test Values --- +# Use platform-specific path separators for realism +sep = os.path.sep +PRESENT_VAL = Path(f"c:{sep}home{sep}present") if platform.system() == "Windows" else Path(f"{sep}home{sep}present") +DEFAULT_VAL = Path(f"c:{sep}home{sep}default") if platform.system() == "Windows" else Path(f"{sep}home{sep}default") +DEFAULT_OPT_VAL = Path(f"c:{sep}home{sep}default-opt") if platform.system() == "Windows" else Path(f"{sep}home{sep}default-opt") # Default for the option itself +NEW_VAL = Path(f"c:{sep}home{sep}new") if platform.system() == "Windows" else Path(f"{sep}home{sep}new") + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with Path test data.""" + # Use os.path.join or f-strings with sep for platform independence in config string + conf_str = f""" +[%(DEFAULT)s] +# Option defined in DEFAULT section +option_name = {DEFAULT_VAL} +[%(PRESENT)s] +# Option present in its own section +option_name = {PRESENT_VAL} +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Path() accepts almost anything, so bad value isn't really testable for errors +option_name = /?*\\<>|:" +[%(EMPTY)s] +# Option present but empty (results in Path('.')) +option_name = +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic PathOption functionality: init, load, value access, clear, default handling.""" + opt = config.PathOption("option_name", "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == Path + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + assert opt.get_as_str() == str(PRESENT_VAL) # String representation + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == str(PRESENT_VAL) # Config file format + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == str(DEFAULT_VAL) + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + +def test_required(conf: ConfigParser): + """Tests PathOption with the 'required' flag.""" + opt = config.PathOption("option_name", "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + +def test_bad_value(conf: ConfigParser): + """Tests loading potentially problematic path strings.""" + opt = config.PathOption("option_name", "description") + + # Load from section with technically valid but problematic characters + opt.load_config(conf, BAD_S) + # Path() constructor accepts these, but filesystem operations might fail later + assert opt.value == Path('/?*\\<>|:"') # Or platform equivalent if Path normalizes + assert opt.value is not None + + # Load from section with empty value + opt.load_config(conf, EMPTY_S) + # Path('') results in Path('.') + assert opt.value == Path('.') + assert opt.value is not None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'Path', not 'float'"): + opt.set_value(10.0) # type: ignore + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'Path', not 'str'"): + # PathOption requires Path object, not string, for set_value + opt.set_value("/some/path") # type: ignore + + +def test_default(conf: ConfigParser): + """Tests PathOption with a defined default Path value.""" + opt = config.PathOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.PathOption("option_name", "description", default=DEFAULT_OPT_VAL) + proto_value = Path("c:/proto/path" if platform.system() == "Windows" else "/proto/path") + proto_value_str = str(proto_value) + + # Set value and serialize (saves as string) + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_string') + assert proto.options["option_name"].as_string == proto_value_str + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from string + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value + assert isinstance(opt.value, opt.datatype) + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint64 = 1000 # Invalid type for PathOption + with pytest.raises(TypeError, match="Wrong value type: uint64"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.PathOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out) + expected_lines_default = f"""; description +; Type: Path +;option_name = {DEFAULT_OPT_VAL} +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value + opt.set_value(NEW_VAL) + expected_lines_new = f"""; description +; Type: Path +option_name = {NEW_VAL} +""" + assert opt.get_config() == expected_lines_new + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: Path +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(NEW_VAL) + assert opt.get_config(plain=True) == f"option_name = {NEW_VAL}\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/config/test_cfg_pycall.py b/tests/config/test_cfg_pycall.py new file mode 100644 index 0000000..2a5ac36 --- /dev/null +++ b/tests/config/test_cfg_pycall.py @@ -0,0 +1,399 @@ +# SPDX-FileCopyrightText: 2019-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_pycall.py +# DESCRIPTION: Tests for firebird.base.config PyCallableOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the PyCallableOption configuration option class.""" + +from __future__ import annotations + +from inspect import signature, Signature # Import Signature for type hint +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error, PyCallable + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" # Invalid Python code / structure +BAD_SIGNATURE_S = "bad_signature" # Valid code, wrong signature +EMPTY_S = "empty" + +# --- Test Helper Classes / Functions --- + +def foo_func(value: int) -> int: + """Target signature for testing PyCallableOption.""" + # The body is irrelevant, only the signature matters + return 0 # pragma: no cover + +# --- Constants for Test Values --- +DEFAULT_VAL = PyCallable("\ndef foo(value: int) -> int:\n return value * 2") +PRESENT_VAL = PyCallable("\n# Some comment\ndef foo(value: int) -> int:\n # Some comment\n return value * 5") +DEFAULT_OPT_VAL = PyCallable("\ndef foo(value: int) -> int:\n return value") # Default for the option itself +NEW_VAL = PyCallable("\ndef foo(value: int) -> int:\n return value * 3") + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with PyCallable test data.""" + conf_str = """ +[%(DEFAULT)s] +# Callable definition in DEFAULT section +option_name = + | def foo(value: int) -> int: + | return value * 2 +[%(PRESENT)s] +# Callable definition in specific section +option_name = + | # Some comment + | def foo(value: int) -> int: + | # Some comment + | return value * 5 +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Not a valid function/class definition +option_name = This is not a valid Python function/procedure definition +[%(BAD_SIGNATURE)s] +# Valid Python, but wrong signature (different param name, extra param) +option_name = + | def foo(val: str, extra: bool = False) -> int: + | return int(len(val)) +[%(EMPTY)s] +# Option present but empty +option_name = +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S, + "BAD_SIGNATURE": BAD_SIGNATURE_S}) + return base_conf + +# --- Test Cases --- + +# Parameterize to test different ways of providing the signature +@pytest.mark.parametrize("sig_arg", [signature(foo_func), foo_func, "foo_func(value: int) -> int"]) +def test_simple(conf: ConfigParser, sig_arg: Signature | callable | str): + """Tests basic PyCallableOption: init (with various signature inputs), load, value access, clear.""" + opt = config.PyCallableOption("option_name", "description", signature=sig_arg) + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == PyCallable + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + assert isinstance(opt.signature, Signature) # Ensure signature was processed + assert str(opt.signature) == "(value: 'int') -> 'int'" # Verify processed signature + opt.validate() # Should pass as not required + + # Load value from [present] section + opt.load_config(conf, PRESENT_S) + # Note: Equality check compares the string source code + assert opt.value == PRESENT_VAL + assert opt.get_as_str() == str(PRESENT_VAL) # String representation + assert isinstance(opt.value, opt.datatype) + assert opt.value.name == "foo" # Check callable name extraction + assert opt.get_formatted().strip().endswith("| return value * 5") # Check config format with verticals + + # Check the loaded callable works as expected + assert opt.value(1) == 5 # Call the loaded function + assert opt.value(10) == 50 + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + assert opt.value(10) == 20 # Check default callable works + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually using PyCallable instance + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + assert opt.value(10) == 30 # Check new callable works + +def test_required(conf: ConfigParser): + """Tests PyCallableOption with the 'required' flag.""" + opt = config.PyCallableOption("option_name", "description", signature=signature(foo_func), + required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid callable definitions or code with wrong signatures.""" + opt = config.PyCallableOption("option_name", "description", signature=signature(foo_func)) + + # Load from section with bad value (not function/class def) + with pytest.raises(ValueError, match="Python function or class definition not found"): + opt.load_config(conf, BAD_S) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with empty value + with pytest.raises(ValueError, match="Python function or class definition not found"): + opt.load_config(conf, EMPTY_S) + assert opt.value is None + + # Load from section with syntactically valid code but wrong signature + with pytest.raises(ValueError, match="Wrong number of parameters"): + opt.load_config(conf, BAD_SIGNATURE_S) + assert opt.value is None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'PyCallable', not 'float'"): + opt.set_value(10.0) # type: ignore + + # Test setting invalid string via set_as_str (bad Python syntax) + with pytest.raises(SyntaxError): + opt.set_as_str("def foo(:") + + # Test setting invalid string via set_as_str (not function/class def) + with pytest.raises(ValueError, match="Python function or class definition not found"): + opt.set_as_str("a = 1") + + # Test setting string with wrong signature via set_as_str + with pytest.raises(ValueError, match="Wrong type, parameter 'value'"): + opt.set_as_str("\ndef foo(value: str) -> int:\n return 1") + with pytest.raises(ValueError, match="Wrong callable return type"): + opt.set_as_str("\ndef foo(value: int) -> str:\n return 'a'") + with pytest.raises(ValueError, match="Wrong number of parameters"): + opt.set_as_str("\ndef foo(value: int, extra: int) -> int:\n return 1") + + +def test_default(conf: ConfigParser): + """Tests PyCallableOption with a defined default PyCallable value.""" + opt = config.PyCallableOption("option_name", "description", signature=signature(foo_func), + default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + assert opt.value(10) == 10 # Default function returns input + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + assert opt.value(10) == 50 + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + assert opt.value(10) == 10 + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + assert opt.value(10) == 20 + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert opt.value(10) == 30 + + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.PyCallableOption("option_name", "description", signature=signature(foo_func), + default=DEFAULT_OPT_VAL) + proto_value_str = "\ndef foo(value: int) -> int:\n return value * 100" + proto_value = PyCallable(proto_value_str) + + # Set value and serialize (saves as string) + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_string') + assert proto.options["option_name"].as_string == proto_value_str + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from string + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value # String equality + assert opt.value(10) == 1000 # Functional equality + assert isinstance(opt.value, opt.datatype) + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint32 = 1000 # Invalid type for PyCallableOption + with pytest.raises(TypeError, match="Wrong value type: uint32"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string for callable) + proto.Clear() + proto.options["option_name"].as_string = "def foo(:" # Syntax error + with pytest.raises(SyntaxError): + opt.load_proto(proto) + + proto.Clear() + proto.options["option_name"].as_string = "a = 1" # Not def/class + with pytest.raises(ValueError, match="Python function or class definition not found"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.PyCallableOption("option_name", "description", signature=signature(foo_func), + default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out, with vertical bars) + expected_lines_default = """; description +; Type: PyCallable +;option_name = +; | def foo(value: int) -> int: +; | return value""" + # Need to strip trailing whitespace for comparison as get_config adds it + assert "\n".join(x.rstrip() for x in opt.get_config().splitlines()) == expected_lines_default + + # Test output with explicitly set value (PRESENT_VAL) + opt.set_value(PRESENT_VAL) + expected_lines_present = """; description +; Type: PyCallable +option_name = + | # Some comment + | def foo(value: int) -> int: + | # Some comment + | return value * 5""" + assert "\n".join(x.rstrip() for x in opt.get_config().splitlines()) == expected_lines_present + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: PyCallable +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(NEW_VAL) + # Plain output shouldn't have vertical bars or comments + expected_plain_new = """option_name = + | def foo(value: int) -> int: + | return value * 3 +""".replace("option_name =", "option_name = ") # Fix editor trailing white cleanup + # Normalize whitespace for comparison as plain output might have different leading space + assert opt.get_config(plain=True).strip() == expected_plain_new.strip() + + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/config/test_cfg_pycode.py b/tests/config/test_cfg_pycode.py new file mode 100644 index 0000000..9323619 --- /dev/null +++ b/tests/config/test_cfg_pycode.py @@ -0,0 +1,366 @@ +# SPDX-FileCopyrightText: 2019-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_pycode.py +# DESCRIPTION: Tests for firebird.base.config PyCodeOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the PyCodeOption configuration option class.""" + +from __future__ import annotations + +import io # For capturing output during code execution test +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error, PyCode + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" # Invalid Python syntax +EMPTY_S = "empty" + +# --- Constants for Test Values --- +DEFAULT_VAL_STR = 'print("Default value")' +DEFAULT_VAL = PyCode(DEFAULT_VAL_STR) +PRESENT_VAL_STR = '\ndef pp(value):\n print("Value:",value,file=output)\n\nfor i in [1,2,3]:\n pp(i)' +PRESENT_VAL = PyCode(PRESENT_VAL_STR) +DEFAULT_OPT_VAL_STR = "print('Option Default')" +DEFAULT_OPT_VAL = PyCode(DEFAULT_OPT_VAL_STR) # Default for the option itself +NEW_VAL_STR = 'print("NEW value")' +NEW_VAL = PyCode(NEW_VAL_STR) + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with PyCode test data.""" + conf_str = """ +[%(DEFAULT)s] +# Simple code block in DEFAULT section +option_name = print("Default value") +[%(PRESENT)s] +# Multiline code block using vertical bar indentation +option_name = + | def pp(value): + | print("Value:",value,file=output) + | + | for i in [1,2,3]: + | pp(i) +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Invalid Python syntax +option_name = This is not a valid Python code block +[%(EMPTY)s] +# Option present but empty +option_name = +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic PyCodeOption: init, load, value access, clear, default handling, execution.""" + opt = config.PyCodeOption("option_name", "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == PyCode + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section (multiline with verticals) + opt.load_config(conf, PRESENT_S) + # Equality check compares the source code string + assert opt.value == PRESENT_VAL + # get_as_str returns the source code string + assert opt.get_as_str() == PRESENT_VAL_STR + assert isinstance(opt.value, opt.datatype) + # get_formatted should add back the vertical bars for config output + assert opt.get_formatted().strip().endswith("| pp(i)") + + # Check the loaded code executes correctly + out = io.StringIO() + exec_namespace = {"output": out} + exec(opt.value.code, exec_namespace) # Use the compiled code object + assert out.getvalue() == "Value: 1\nValue: 2\nValue: 3\n" + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + # Check default code execution + out = io.StringIO() + exec(opt.value.code, {"print": lambda *a, **kw: print(*a, file=out, **kw)}) # Capture print + assert out.getvalue() == "Default value\n" + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually using PyCode instance + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + # Check new code execution + out = io.StringIO() + exec(opt.value.code, {"print": lambda *a, **kw: print(*a, file=out, **kw)}) + assert out.getvalue() == "NEW value\n" + +def test_required(conf: ConfigParser): + """Tests PyCodeOption with the 'required' flag.""" + opt = config.PyCodeOption("option_name", "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid Python code strings.""" + opt = config.PyCodeOption("option_name", "description") + + # Load from section with bad syntax + with pytest.raises(SyntaxError, match="invalid syntax"): + opt.load_config(conf, BAD_S) + # Verify error details if possible (line/offset might vary slightly) + # assert cm.value.args == ("invalid syntax", ("PyCode", 1, 15, "This is not a valid Python code block\n", 1, 20)) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with empty value (should be valid empty code) + opt.load_config(conf, EMPTY_S) + assert opt.value == PyCode("") + exec(opt.value.code) # Empty code should execute without error + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'PyCode', not 'float'"): + opt.set_value(10.0) # type: ignore + + # Test setting invalid syntax string via set_as_str + with pytest.raises(SyntaxError): + opt.set_as_str("def foo(:") + + +def test_default(conf: ConfigParser): + """Tests PyCodeOption with a defined default PyCode value.""" + opt = config.PyCodeOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + # Check default code execution + out = io.StringIO() + exec(opt.value.code, {"print": lambda *a, **kw: print(*a, file=out, **kw)}) + assert out.getvalue() == "Option Default\n" + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.PyCodeOption("option_name", "description", default=DEFAULT_OPT_VAL) + proto_value_str = "for i in range(3): print(i)" + proto_value = PyCode(proto_value_str) + + # Set value and serialize (saves as string) + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_string') + assert proto.options["option_name"].as_string == proto_value_str + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from string + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value # String equality + # Check loaded code works + out = io.StringIO() + exec(opt.value.code, {"print": lambda *a, **kw: print(*a, file=out, **kw)}) + assert out.getvalue() == "0\n1\n2\n" + assert isinstance(opt.value, opt.datatype) + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint32 = 1000 # Invalid type for PyCodeOption + with pytest.raises(TypeError, match="Wrong value type: uint32"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string syntax) + proto.Clear() + proto.options["option_name"].as_string = "def foo(:" + with pytest.raises(SyntaxError): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.PyCodeOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out, no verticals needed for simple string) + expected_lines_default = """; description +; Type: PyCode +;option_name = print('Option Default') +""" + assert opt.get_config() == expected_lines_default + + # Test output with multiline value (PRESENT_VAL, should have verticals) + opt.set_value(PRESENT_VAL) + expected_lines_present = """; description +; Type: PyCode +option_name = + | def pp(value): + | print("Value:",value,file=output) + | + | for i in [1,2,3]: + | pp(i)""" + # Compare stripped lines due to potential trailing whitespace differences + assert "\n".join(x.rstrip() for x in opt.get_config().splitlines()) == expected_lines_present + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: PyCode +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(NEW_VAL) + # Plain output shouldn't have vertical bars or comments + expected_plain_new = "option_name = print(\"NEW value\")\n" + assert opt.get_config(plain=True) == expected_plain_new + + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/config/test_cfg_pyexpr.py b/tests/config/test_cfg_pyexpr.py new file mode 100644 index 0000000..95be8fe --- /dev/null +++ b/tests/config/test_cfg_pyexpr.py @@ -0,0 +1,417 @@ +# SPDX-FileCopyrightText: 2019-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_pyexpr.py +# DESCRIPTION: Tests for firebird.base.config PyExprOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the PyExprOption configuration option class.""" + +from __future__ import annotations + +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error, PyExpr + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" # Invalid Python syntax +EMPTY_S = "empty" + +# --- Constants for Test Values --- +PRESENT_VAL_STR = "this.value in [1, 2, 3]" +PRESENT_VAL = PyExpr(PRESENT_VAL_STR) +DEFAULT_VAL_STR = "this.value is None" +DEFAULT_VAL = PyExpr(DEFAULT_VAL_STR) +DEFAULT_OPT_VAL_STR = "True" # Simple default expression +DEFAULT_OPT_VAL = PyExpr(DEFAULT_OPT_VAL_STR) # Default for the option itself +NEW_VAL_STR = 'this.value == "VALUE"' +NEW_VAL = PyExpr(NEW_VAL_STR) +MULTI_VAL_STR = """this.value in [ + 1, + 2, + 3 +]""" +MULTI_VAL = PyExpr(MULTI_VAL_STR) # Multiline expression +# Expected format for multiline in get_formatted() +MULTIFMT_VAL_STR = """this.value in [ + 1, + 2, + 3 + ]""" + +# --- Test Helper Classes --- + +class ValueHolder: + """Simple object used for evaluating test expressions.""" + value: int | str | None = None + x: int = 100 + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with PyExpr test data.""" + conf_str = """ +[%(DEFAULT)s] +# Expression defined in DEFAULT section +option_name = this.value is None +[%(PRESENT)s] +# Expression defined in specific section +option_name = this.value in [1, 2, 3] +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Invalid Python syntax +option_name = This is not a valid Python expression +[%(EMPTY)s] +# Option present but empty (invalid expression) +option_name = +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic PyExprOption: init, load, value access, clear, default handling, evaluation.""" + opt = config.PyExprOption("option_name", "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == PyExpr + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL # String equality check + assert opt.get_as_str() == PRESENT_VAL_STR # String representation + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == PRESENT_VAL_STR # Config format for single line + + # Check the loaded expression evaluates correctly + obj = ValueHolder() + obj.value = 2 + assert eval(opt.value.expr, {"this": obj}) is True # Use compiled .expr + fce = opt.value.get_callable("this") + assert fce(obj) is True + obj.value = 4 + assert eval(opt.value.expr, {"this": obj}) is False + assert fce(obj) is False + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + # Check default expression evaluation + obj.value = None + assert eval(opt.value.expr, {"this": obj}) is True + obj.value = 1 + assert eval(opt.value.expr, {"this": obj}) is False + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually using PyExpr instance + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + # Check new expression evaluation + obj.value = "VALUE" + assert eval(opt.value.expr, {"this": obj}) is True + obj.value = "OTHER" + assert eval(opt.value.expr, {"this": obj}) is False + + # Test multiline expression formatting + opt.value = MULTI_VAL + assert opt.value == MULTI_VAL_STR + assert opt.get_as_str() == MULTI_VAL_STR + # get_formatted adds indentation for multiline + assert opt.get_formatted() == MULTIFMT_VAL_STR + +def test_required(conf: ConfigParser): + """Tests PyExprOption with the 'required' flag.""" + opt = config.PyExprOption("option_name", "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid Python expression strings.""" + opt = config.PyExprOption("option_name", "description") + + # Load from section with bad syntax + with pytest.raises(SyntaxError, match="invalid syntax"): + opt.load_config(conf, BAD_S) + # Verify error details if possible (line/offset might vary slightly) + # assert cm.value.args == ("invalid syntax", ("PyExpr", 1, 15, "This is not a valid Python expression", 1, 20)) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with empty value (invalid expression) + with pytest.raises(SyntaxError): + opt.load_config(conf, EMPTY_S) + assert opt.value is None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'PyExpr', not 'float'"): + opt.set_value(10.0) # type: ignore + + # Test setting invalid syntax string via set_as_str + with pytest.raises(SyntaxError): + opt.set_as_str("a +") + + +def test_default(conf: ConfigParser): + """Tests PyExprOption with a defined default PyExpr value.""" + opt = config.PyExprOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL # PyExpr("True") + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + assert eval(opt.value.expr) is True # Check default expression evaluates + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + assert eval(opt.value.expr) is True + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.PyExprOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test with single-line expression + proto_value_single = PyExpr("item.x > 10") + proto_value_single_str = "item.x > 10" + opt.set_value(proto_value_single) + + # Serialize (saves as string) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_string') + assert proto.options["option_name"].as_string == proto_value_single_str + proto_dump_single = proto.SerializeToString() + + # Clear option and deserialize from string + opt.clear(to_default=False) + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump_single) + opt.load_proto(proto_read) + assert opt.value == proto_value_single # String equality + assert isinstance(opt.value, opt.datatype) + # Check evaluation + assert eval(opt.value.expr, {"item": ValueHolder()}) # Assumes item.x access doesn't fail + + # Test with multi-line expression + proto.Clear() + proto_value_multi = MULTI_VAL + proto_value_multi_str = MULTI_VAL_STR + opt.set_value(proto_value_multi) + opt.save_proto(proto) + assert proto.options["option_name"].as_string == proto_value_multi_str + proto_dump_multi = proto.SerializeToString() + + opt.clear(to_default=False) + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump_multi) + opt.load_proto(proto_read) + assert opt.value == proto_value_multi + + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint32 = 1000 # Invalid type for PyExprOption + with pytest.raises(TypeError, match="Wrong value type: uint32"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string syntax) + proto.Clear() + proto.options["option_name"].as_string = "a +" + with pytest.raises(SyntaxError): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.PyExprOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out) + expected_lines_default = """; description +; Type: PyExpr +;option_name = True +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set single-line value + opt.set_value(NEW_VAL) # 'this.value == "VALUE"' + expected_lines_new = """; description +; Type: PyExpr +option_name = this.value == "VALUE" +""" + assert opt.get_config() == expected_lines_new + + # Test output with explicitly set multi-line value + opt.set_value(MULTI_VAL) + # Expected format includes indentation for subsequent lines + expected_lines_multi = """; description +; Type: PyExpr +option_name = this.value in [ + 1, + 2, + 3 + ]\n""" + assert opt.get_config() == expected_lines_multi + + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: PyExpr +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(NEW_VAL) + assert opt.get_config(plain=True) == f"option_name = {NEW_VAL_STR}\n" + opt.set_value(MULTI_VAL) + # Plain output for multiline shouldn't have leading indent on first line + expected_plain_multi = f"""option_name = this.value in [ + 1, + 2, + 3 + ] +""" + assert opt.get_config(plain=True) == expected_plain_multi + + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/config/test_cfg_scheme.py b/tests/config/test_cfg_scheme.py new file mode 100644 index 0000000..b4c250c --- /dev/null +++ b/tests/config/test_cfg_scheme.py @@ -0,0 +1,206 @@ +# SPDX-FileCopyrightText: 2019-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_scheme.py +# DESCRIPTION: Tests for firebird.base.config ApplicationDirectoryScheme +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +from __future__ import annotations + +import os +import platform +from pathlib import Path + +import pytest + +from firebird.base import config + +_pd = "c:\\ProgramData" +_ap = "C:\\Users\\username\\AppData" +_lap = "C:\\Users\\username\\AppData\\Local" +app_name = "test_app" + +@pytest.mark.skipif(platform.system() != "Linux", reason="Only for Linux") +def test_linux_default(): + # Without version + scheme = config.get_directory_scheme(app_name) + assert scheme.config == Path("/etc/test_app") + assert scheme.run_data == Path("/run/test_app") + assert scheme.logs == Path("/var/log/test_app") + assert scheme.data == Path("/var/lib/test_app") + assert scheme.tmp == Path("/var/tmp/test_app") + assert scheme.cache == Path("/var/cache/test_app") + assert scheme.srv == Path("/srv/test_app") + assert scheme.user_config == Path("~/.config/test_app").expanduser() + assert scheme.user_data == Path("~/.local/share/test_app").expanduser() + assert scheme.user_sync == Path("~/.local/sync/test_app").expanduser() + assert scheme.user_cache == Path("~/.cache/test_app").expanduser() + # With version + scheme = config.get_directory_scheme(app_name, "1.0") + assert scheme.config == Path("/etc/test_app/1.0") + assert scheme.run_data == Path("/run/test_app/1.0") + assert scheme.logs == Path("/var/log/test_app/1.0") + assert scheme.data == Path("/var/lib/test_app/1.0") + assert scheme.tmp == Path("/var/tmp/test_app/1.0") + assert scheme.cache == Path("/var/cache/test_app/1.0") + assert scheme.srv == Path("/srv/test_app/1.0") + assert scheme.user_config == Path("~/.config/test_app/1.0").expanduser() + assert scheme.user_data == Path("~/.local/share/test_app/1.0").expanduser() + assert scheme.user_sync == Path("~/.local/sync/test_app/1.0").expanduser() + assert scheme.user_cache == Path("~/.cache/test_app/1.0").expanduser() + +@pytest.mark.skipif(platform.system() != "Linux", reason="Only for Linux") +def test_linux_home_env(monkeypatch): + monkeypatch.setenv(f"{app_name.upper()}_HOME", "/mydir/apphome/") + scheme = config.get_directory_scheme(app_name) + assert scheme.config == Path("/mydir/apphome/config") + assert scheme.run_data == Path("/mydir/apphome/run_data") + assert scheme.logs == Path("/mydir/apphome/logs") + assert scheme.data == Path("/mydir/apphome/data") + assert scheme.tmp == Path("/var/tmp/test_app") + assert scheme.cache == Path("/mydir/apphome/cache") + assert scheme.srv == Path("/mydir/apphome/srv") + assert scheme.user_config == Path("~/.config/test_app").expanduser() + assert scheme.user_data == Path("~/.local/share/test_app").expanduser() + assert scheme.user_sync == Path("~/.local/sync/test_app").expanduser() + assert scheme.user_cache == Path("~/.cache/test_app").expanduser() + +@pytest.mark.skipif(platform.system() != "Linux", reason="Only for Linux") +def test_linux_home_forced(monkeypatch): + def fake_cwd(): + return "/mydir/apphome/" + monkeypatch.setattr(os, "getcwd", fake_cwd) + scheme = config.get_directory_scheme(app_name, force_home=True) + assert scheme.config == Path("/mydir/apphome/config") + assert scheme.run_data == Path("/mydir/apphome/run_data") + assert scheme.logs == Path("/mydir/apphome/logs") + assert scheme.data == Path("/mydir/apphome/data") + assert scheme.tmp == Path("/var/tmp/test_app") + assert scheme.cache == Path("/mydir/apphome/cache") + assert scheme.srv == Path("/mydir/apphome/srv") + assert scheme.user_config == Path("~/.config/test_app").expanduser() + assert scheme.user_data == Path("~/.local/share/test_app").expanduser() + assert scheme.user_sync == Path("~/.local/sync/test_app").expanduser() + assert scheme.user_cache == Path("~/.cache/test_app").expanduser() + +@pytest.mark.skipif(platform.system() != "Linux", reason="Only for Linux") +def test_linux_home_change(): + scheme = config.get_directory_scheme(app_name, force_home=True) + scheme.home = "/mydir/apphome/" + assert scheme.config == Path("/mydir/apphome/config") + assert scheme.run_data == Path("/mydir/apphome/run_data") + assert scheme.logs == Path("/mydir/apphome/logs") + assert scheme.data == Path("/mydir/apphome/data") + assert scheme.tmp == Path("/var/tmp/test_app") + assert scheme.cache == Path("/mydir/apphome/cache") + assert scheme.srv == Path("/mydir/apphome/srv") + assert scheme.user_config == Path("~/.config/test_app").expanduser() + assert scheme.user_data == Path("~/.local/share/test_app").expanduser() + assert scheme.user_sync == Path("~/.local/sync/test_app").expanduser() + assert scheme.user_cache == Path("~/.cache/test_app").expanduser() + +@pytest.mark.skipif(platform.system() != "Windows", reason="Only for Windows") +def test_widnows_default(): + # Without version + scheme = config.get_directory_scheme(app_name) + assert scheme.config == Path("c:/ProgramData/test_app/config") + assert scheme.run_data == Path("c:/ProgramData/test_app/run") + assert scheme.logs == Path("c:/ProgramData/test_app/log") + assert scheme.data == Path("c:/ProgramData/test_app/data") + assert scheme.tmp == Path("~/AppData/Local/test_app/tmp").expanduser() + assert scheme.cache == Path("c:/ProgramData/test_app/cache") + assert scheme.srv == Path("c:/ProgramData/test_app/srv") + assert scheme.user_config == Path("~/AppData/Local/test_app/config").expanduser() + assert scheme.user_data == Path("~/AppData/Local/test_app/data").expanduser() + assert scheme.user_sync == Path("~/AppData/Roaming/test_app").expanduser() + assert scheme.user_cache == Path("~/AppData/Local/test_app/cache").expanduser() + # With version + assert scheme.config == Path("c:/ProgramData/test_app/1.0/config") + assert scheme.run_data == Path("c:/ProgramData/test_app/1.0/run") + assert scheme.logs == Path("c:/ProgramData/test_app/1.0/log") + assert scheme.data == Path("c:/ProgramData/test_app/1.0/data") + assert scheme.tmp == Path("~/AppData/Local/test_app/1.0/tmp").expanduser() + assert scheme.cache == Path("c:/ProgramData/test_app/1.0/cache") + assert scheme.srv == Path("c:/ProgramData/test_app/1.0/srv") + assert scheme.user_config == Path("~/AppData/Local/test_app/1.0/config").expanduser() + assert scheme.user_data == Path("~/AppData/Local/test_app/1.0/data").expanduser() + assert scheme.user_sync == Path("~/AppData/Roaming/test_app/1.0").expanduser() + assert scheme.user_cache == Path("~/AppData/Local/test_app/1.0/cache").expanduser() + +@pytest.mark.skipif(platform.system() != "Windows", reason="Only for Windows") +def test_widnows_home_env(monkeypatch): + monkeypatch.setenv(f"{app_name.upper()}_HOME", "c:/mydir/apphome/") + scheme = config.get_directory_scheme(app_name) + assert scheme.config == Path("c:/mydir/apphome/config") + assert scheme.run_data == Path("c:/mydir/apphome/run_data") + assert scheme.logs == Path("c:/mydir/apphome/logs") + assert scheme.data == Path("c:/mydir/apphome/data") + assert scheme.tmp == Path("~/AppData/Local/test_app/tmp").expanduser() + assert scheme.cache == Path("c:/mydir/apphome/cache") + assert scheme.srv == Path("c:/mydir/apphome/srv") + assert scheme.user_config == Path("~/AppData/Local/test_app/config").expanduser() + assert scheme.user_data == Path("~/AppData/Local/test_app/data").expanduser() + assert scheme.user_sync == Path("~/AppData/Roaming/test_app").expanduser() + assert scheme.user_cache == Path("~/AppData/Local/test_app/cache").expanduser() + +@pytest.mark.skipif(platform.system() != "Windows", reason="Only for Windows") +def test_widnows_home_forced(monkeypatch): + def fake_cwd(): + return "c:/mydir/apphome/" + monkeypatch.setattr(os, "getcwd", fake_cwd) + scheme = config.get_directory_scheme(app_name) + assert scheme.config == Path("c:/mydir/apphome/config") + assert scheme.run_data == Path("c:/mydir/apphome/run_data") + assert scheme.logs == Path("c:/mydir/apphome/logs") + assert scheme.data == Path("c:/mydir/apphome/data") + assert scheme.tmp == Path("~/AppData/Local/test_app/tmp").expanduser() + assert scheme.cache == Path("c:/mydir/apphome/cache") + assert scheme.srv == Path("c:/mydir/apphome/srv") + assert scheme.user_config == Path("~/AppData/Local/test_app/config").expanduser() + assert scheme.user_data == Path("~/AppData/Local/test_app/data").expanduser() + assert scheme.user_sync == Path("~/AppData/Roaming/test_app").expanduser() + assert scheme.user_cache == Path("~/AppData/Local/test_app/cache").expanduser() + +@pytest.mark.skipif(platform.system() != "Windows", reason="Only for Windows") +def test_04_widnows_home_change(): + scheme = config.get_directory_scheme(app_name) + scheme.home = "c:/mydir/apphome/" + assert scheme.config == Path("c:/mydir/apphome/config") + assert scheme.run_data == Path("c:/mydir/apphome/run_data") + assert scheme.logs == Path("c:/mydir/apphome/logs") + assert scheme.data == Path("c:/mydir/apphome/data") + assert scheme.tmp == Path("~/AppData/Local/test_app/tmp").expanduser() + assert scheme.cache == Path("c:/mydir/apphome/cache") + assert scheme.srv == Path("c:/mydir/apphome/srv") + assert scheme.user_config == Path("~/AppData/Local/test_app/config").expanduser() + assert scheme.user_data == Path("~/AppData/Local/test_app/data").expanduser() + assert scheme.user_sync == Path("~/AppData/Roaming/test_app").expanduser() + assert scheme.user_cache == Path("~/AppData/Local/test_app/cache").expanduser() diff --git a/tests/config/test_cfg_str.py b/tests/config/test_cfg_str.py new file mode 100644 index 0000000..7b596d2 --- /dev/null +++ b/tests/config/test_cfg_str.py @@ -0,0 +1,363 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_str.py +# DESCRIPTION: Tests for firebird.base.config StrOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the StrOption configuration option class.""" + +from __future__ import annotations + +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" # Not applicable for StrOption, use EMPTY +EMPTY_S = "empty" +VERTICALS_S = "VERTICALS" # Section with vertical bar indentation + +# --- Constants for Test Values --- +PRESENT_VAL_STR = "present_value\ncan be multiline" # Loaded from multiline config +PRESENT_VAL = PRESENT_VAL_STR # For StrOption, loaded value is the string +DEFAULT_VAL = "DEFAULT_value" +DEFAULT_OPT_VAL = "DEFAULT_OPTION_VALUE" # Default for the option itself +NEW_VAL = "new_value" +VERTICALS_VAL_STR = '\ndef pp(value):\n print("Value:",value,file=output)\n\nfor i in [1,2,3]:\n pp(i)' # Code intended for PyCode test, used here to test verticals +VERTICALS_VAL = VERTICALS_VAL_STR # StrOption just stores the string after unindenting + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with string test data.""" + conf_str = """ +[%(DEFAULT)s] +# Option defined in DEFAULT section +option_name = DEFAULT_value +[%(PRESENT)s] +# Option present in its own section (multiline) +option_name = present_value + can be multiline +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Not applicable, use EMPTY +[%(EMPTY)s] +# Option present but empty +option_name = +[%(VERTICALS)s] +# Option with vertical bars for preserving leading whitespace +option_name = + | def pp(value): + | print("Value:",value,file=output) + | + | for i in [1,2,3]: + | pp(i) +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S, + "VERTICALS": VERTICALS_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic StrOption functionality: init, load, value access, clear, default handling.""" + opt = config.StrOption("option_name", "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == str + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section (multiline) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + assert opt.get_as_str() == PRESENT_VAL_STR # String representation + assert isinstance(opt.value, opt.datatype) + # get_formatted adds indentation for multiline output + assert opt.get_formatted() == "present_value\n can be multiline" + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == DEFAULT_VAL # Single line format + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + + # Test loading value with vertical bars (should unindent) + opt.load_config(conf, VERTICALS_S) + assert opt.value == VERTICALS_VAL + assert opt.get_as_str() == VERTICALS_VAL_STR + # Check formatted output adds vertical bars back if needed (due to leading space) + assert opt.get_formatted().strip().startswith("| def pp(value):") + +def test_required(conf: ConfigParser): + """Tests StrOption with the 'required' flag.""" + opt = config.StrOption("option_name", "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + + # Set value to empty string (should pass validation even if required) + opt.set_value("") + assert opt.value == "" + opt.validate() # Empty string is considered a value + +def test_bad_value(conf: ConfigParser): + """Tests loading edge cases like empty values.""" + opt = config.StrOption("option_name", "description") + + # Load from section with empty value + opt.load_config(conf, EMPTY_S) + assert opt.value == "" # Empty value in config results in empty string + assert opt.value is not None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'str', not 'float'"): + opt.set_value(10.0) # type: ignore + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'str', not 'int'"): + opt.set_value(123) # type: ignore + + +def test_default(conf: ConfigParser): + """Tests StrOption with a defined default string value.""" + opt = config.StrOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.StrOption("option_name", "description", default=DEFAULT_OPT_VAL) + proto_value = "value from proto test" + + # Set value and serialize (saves as string) + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_string') + assert proto.options["option_name"].as_string == proto_value + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from string + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value + assert isinstance(opt.value, opt.datatype) + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint64 = 1000 # Invalid type for StrOption + with pytest.raises(TypeError, match="Wrong value type: uint64"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.StrOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out) + expected_lines_default = f"""; description +; Type: str +;option_name = {DEFAULT_OPT_VAL} +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set single-line value + opt.set_value(NEW_VAL) + expected_lines_new = f"""; description +; Type: str +option_name = {NEW_VAL} +""" + assert opt.get_config() == expected_lines_new + + # Test output with explicitly set multi-line value (no leading spaces) + opt.set_value(PRESENT_VAL) + expected_lines_multi = """; description +; Type: str +option_name = present_value + can be multiline +""" + assert opt.get_config() == expected_lines_multi + + # Test output with multi-line value needing vertical bars + opt.set_value(VERTICALS_VAL) + expected_lines_verticals = """; description +; Type: str +option_name = + | def pp(value): + | print("Value:",value,file=output) + | + | for i in [1,2,3]: + | pp(i)""" + # Compare stripped lines due to potential trailing whitespace differences + assert "\n".join(x.rstrip() for x in opt.get_config().splitlines()) == expected_lines_verticals + + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: str +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(NEW_VAL) + assert opt.get_config(plain=True) == f"option_name = {NEW_VAL}\n" + # Plain output for multiline shouldn't have leading indent on first line + opt.set_value(PRESENT_VAL) + expected_plain_multi = """option_name = present_value + can be multiline +""" + assert opt.get_config(plain=True) == expected_plain_multi + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/config/test_cfg_uuid.py b/tests/config/test_cfg_uuid.py new file mode 100644 index 0000000..2edca53 --- /dev/null +++ b/tests/config/test_cfg_uuid.py @@ -0,0 +1,344 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_uuid.py +# DESCRIPTION: Tests for firebird.base.config UUIDOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the UUIDOption configuration option class.""" + +from __future__ import annotations + +from uuid import UUID +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_S = "bad_value" # Invalid UUID format +EMPTY_S = "empty" + +# --- Constants for Test Values --- +PRESENT_VAL_STR_HEX = "fbcdd0acde0d11e99b5b5404a6a1fd6e" +PRESENT_VAL = UUID(PRESENT_VAL_STR_HEX) +DEFAULT_VAL_STR = "e3a57070-de0d-11e9-9b5b-5404a6a1fd6e" +DEFAULT_VAL = UUID(DEFAULT_VAL_STR) +DEFAULT_OPT_VAL_STR = "ede5cc42-de0d-11e9-9b5b-5404a6a1fd6e" +DEFAULT_OPT_VAL = UUID(DEFAULT_OPT_VAL_STR) # Default for the option itself +NEW_VAL_STR = "92ef5c08-de0e-11e9-9b5b-5404a6a1fd6e" +NEW_VAL = UUID(NEW_VAL_STR) + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with UUID test data.""" + conf_str = """ +[%(DEFAULT)s] +# Option defined in DEFAULT section (standard format) +option_name = e3a57070-de0d-11e9-9b5b-5404a6a1fd6e +[%(PRESENT)s] +# Option present (hex format without dashes) +option_name = fbcdd0acde0d11e99b5b5404a6a1fd6e +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD)s] +# Option present but with an invalid UUID string +option_name = BAD_UID-string-not-hex +[%(EMPTY)s] +# Option present but empty +option_name = +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD": BAD_S, "EMPTY": EMPTY_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic UUIDOption functionality: init, load, value access, clear, default handling.""" + opt = config.UUIDOption("option_name", "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == UUID + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section (hex format) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + assert opt.get_as_str() == PRESENT_VAL_STR_HEX # String representation is hex + assert isinstance(opt.value, opt.datatype) + # get_formatted uses standard hyphenated format + assert opt.get_formatted() == "fbcdd0ac-de0d-11e9-9b5b-5404a6a1fd6e" + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == DEFAULT_VAL_STR + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + +def test_required(conf: ConfigParser): + """Tests UUIDOption with the 'required' flag.""" + opt = config.UUIDOption("option_name", "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid UUID string values.""" + opt = config.UUIDOption("option_name", "description") + + # Load from section with bad value + with pytest.raises(ValueError, match="badly formed hexadecimal UUID string"): + opt.load_config(conf, BAD_S) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with empty value + with pytest.raises(ValueError, match="badly formed hexadecimal UUID string"): + opt.load_config(conf, EMPTY_S) + assert opt.value is None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'UUID', not 'float'"): + opt.set_value(10.0) # type: ignore + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'UUID', not 'str'"): + # Requires UUID object, not string + opt.set_value("fbcdd0ac-de0d-11e9-9b5b-5404a6a1fd6e") # type: ignore + + # Test setting invalid string via set_as_str + with pytest.raises(ValueError, match="badly formed hexadecimal UUID string"): + opt.set_as_str("not-a-uuid") + + +def test_default(conf: ConfigParser): + """Tests UUIDOption with a defined default UUID value.""" + opt = config.UUIDOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.UUIDOption("option_name", "description", default=DEFAULT_OPT_VAL) + proto_value = UUID("bcd80916-de0e-11e9-9b5b-5404a6a1fd6e") + proto_value_bytes = proto_value.bytes + proto_value_hex = proto_value.hex + + # Set value and serialize (saves as bytes) + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_bytes') + assert proto.options["option_name"].as_bytes == proto_value_bytes + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from bytes + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value + assert isinstance(opt.value, opt.datatype) + + # Test deserializing from string representation in proto + proto.Clear() + proto.options["option_name"].as_string = proto_value_hex # Use hex string + opt.load_proto(proto) + assert opt.value == proto_value + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint32 = 1000 # Invalid type for UUIDOption + with pytest.raises(TypeError, match="Wrong value type: uint32"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string for UUID) + proto.Clear() + proto.options["option_name"].as_string = "not-a-uuid" + with pytest.raises(ValueError, match="badly formed hexadecimal UUID string"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid bytes length for UUID) + proto.Clear() + proto.options["option_name"].as_bytes = b'\x01\x02\x03' # Too short + with pytest.raises(ValueError, match="bytes is not a 16-char string"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.UUIDOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out, standard format) + expected_lines_default = f"""; description +; Type: UUID +;option_name = {DEFAULT_OPT_VAL_STR} +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value + opt.set_value(NEW_VAL) + expected_lines_new = f"""; description +; Type: UUID +option_name = {NEW_VAL_STR} +""" + assert opt.get_config() == expected_lines_new + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: UUID +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(NEW_VAL) + assert opt.get_config(plain=True) == f"option_name = {NEW_VAL_STR}\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/config/test_cfg_zmq.py b/tests/config/test_cfg_zmq.py new file mode 100644 index 0000000..546f058 --- /dev/null +++ b/tests/config/test_cfg_zmq.py @@ -0,0 +1,335 @@ +# SPDX-FileCopyrightText: 2025-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/config/test_cfg_zmq.py +# DESCRIPTION: Tests for firebird.base.config ZMQAddressOption +# CREATED: 28.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +"""Unit tests for the ZMQAddressOption configuration option class.""" + +from __future__ import annotations + +import pytest +from configparser import ConfigParser # Import for type hinting + +from firebird.base import config +from firebird.base.config_pb2 import ConfigProto # Import for proto tests +from firebird.base.types import Error, ZMQAddress + +# --- Constants for Test Sections --- +DEFAULT_S = "DEFAULT" +PRESENT_S = "present" +ABSENT_S = "absent" +BAD_FORMAT_S = "bad_format" # Missing protocol +BAD_PROTO_S = "bad_protocol" # Unknown protocol +EMPTY_S = "empty" + +# --- Constants for Test Values --- +PRESENT_VAL = ZMQAddress("ipc://@my-address") +DEFAULT_VAL = ZMQAddress("tcp://127.0.0.1:*") +DEFAULT_OPT_VAL = ZMQAddress("tcp://127.0.0.1:8001") # Default for the option itself +NEW_VAL = ZMQAddress("inproc://my-address") + +# --- Fixtures --- + +@pytest.fixture +def conf(base_conf: ConfigParser) -> ConfigParser: + """Provides a ConfigParser instance initialized with ZMQAddress test data.""" + conf_str = """ +[%(DEFAULT)s] +# Option defined in DEFAULT section +option_name = tcp://127.0.0.1:* +[%(PRESENT)s] +# Option present in its own section +option_name = ipc://@my-address +[%(ABSENT)s] +# Section exists, but option is absent (will inherit from DEFAULT) +[%(BAD_FORMAT)s] +# Invalid format (missing protocol) +option_name = 127.0.0.1:5555 +[%(BAD_PROTOCOL)s] +# Unknown protocol +option_name = unknownproto://some_host +[%(EMPTY)s] +# Option present but empty +option_name = +""" + # Format the string with section names and read into the config parser + base_conf.read_string(conf_str % {"DEFAULT": DEFAULT_S, "PRESENT": PRESENT_S, + "ABSENT": ABSENT_S, "BAD_FORMAT": BAD_FORMAT_S, + "BAD_PROTOCOL": BAD_PROTO_S, "EMPTY": EMPTY_S}) + return base_conf + +# --- Test Cases --- + +def test_simple(conf: ConfigParser): + """Tests basic ZMQAddressOption: init, load, value access, clear, default handling.""" + opt = config.ZMQAddressOption("option_name", "description") + + # Verify initial state + assert opt.name == "option_name" + assert opt.datatype == ZMQAddress + assert opt.description == "description" + assert not opt.required + assert opt.default is None + assert opt.value is None # Initial value without default is None + opt.validate() # Should pass as not required + + # Load value from [present] section + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + assert opt.get_as_str() == str(PRESENT_VAL) # String representation + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == str(PRESENT_VAL) # Config file format is same as string + + # Clear value (should reset to None as no default) + opt.clear(to_default=False) + assert opt.value is None + + # Clear value to default (should still be None) + opt.clear(to_default=True) + assert opt.value is None + + # Load value from [DEFAULT] section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + assert opt.get_formatted() == str(DEFAULT_VAL) + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from section where option is absent (should inherit from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + assert isinstance(opt.value, opt.datatype) + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + assert isinstance(opt.value, opt.datatype) + +def test_required(conf: ConfigParser): + """Tests ZMQAddressOption with the 'required' flag.""" + opt = config.ZMQAddressOption("option_name", "description", required=True) + + # Verify initial state (required, no default) + assert opt.required + assert opt.default is None + assert opt.value is None + # Validation should fail when value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load value, validation should pass + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + opt.validate() + + # Clear to default (which is None), validation should fail again + opt.clear(to_default=True) + assert opt.value is None + with pytest.raises(Error, match="Missing value for required option 'option_name'"): + opt.validate() + + # Load from DEFAULT section + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + opt.validate() # Should pass + + # Setting value to None should raise ValueError for required option + with pytest.raises(ValueError, match="Value is required for option 'option_name'"): + opt.set_value(None) + + # Load from absent section (inherits from DEFAULT) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + opt.validate() + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + opt.validate() + +def test_bad_value(conf: ConfigParser): + """Tests loading invalid ZMQ address string values.""" + opt = config.ZMQAddressOption("option_name", "description") + + # Load from section with bad format (missing protocol) + with pytest.raises(ValueError, match="Protocol specification required"): + opt.load_config(conf, BAD_FORMAT_S) + assert opt.value is None # Value should remain unchanged (None) + + # Load from section with unknown protocol + with pytest.raises(ValueError, match="Unknown protocol 'unknownproto'"): + opt.load_config(conf, BAD_PROTO_S) + assert opt.value is None + + # Load from section with empty value + with pytest.raises(ValueError, match="Protocol specification required"): + opt.load_config(conf, EMPTY_S) + assert opt.value is None + + # Test assigning invalid type via set_value + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'ZMQAddress', not 'float'"): + opt.set_value(10.0) # type: ignore + with pytest.raises(TypeError, match="Option 'option_name' value must be a 'ZMQAddress', not 'str'"): + # Requires ZMQAddress object, not string, for set_value + opt.set_value("tcp://localhost:5555") # type: ignore + + # Test setting invalid string via set_as_str + with pytest.raises(ValueError, match="Protocol specification required"): + opt.set_as_str("invalid-address-string") + + +def test_default(conf: ConfigParser): + """Tests ZMQAddressOption with a defined default ZMQAddress value.""" + opt = config.ZMQAddressOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Verify initial state (default value should be set) + assert not opt.required + assert opt.default == DEFAULT_OPT_VAL + assert isinstance(opt.default, opt.datatype) + assert opt.value == DEFAULT_OPT_VAL # Initial value is the default + assert isinstance(opt.value, opt.datatype) + opt.validate() # Should pass + + # Load value from [present] section (overrides default) + opt.load_config(conf, PRESENT_S) + assert opt.value == PRESENT_VAL + + # Clear to default + opt.clear(to_default=True) + assert opt.value == opt.default + + # Clear to None + opt.clear(to_default=False) + assert opt.value is None + + # Load from [DEFAULT] section (overrides option default) + opt.load_config(conf, DEFAULT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually to None + opt.set_value(None) + assert opt.value is None + + # Load from absent section (inherits from DEFAULT, overrides option default) + opt.load_config(conf, ABSENT_S) + assert opt.value == DEFAULT_VAL + + # Set value manually + opt.set_value(NEW_VAL) + assert opt.value == NEW_VAL + +def test_proto(conf: ConfigParser, proto: ConfigProto): + """Tests serialization to and deserialization from Protobuf messages.""" + opt = config.ZMQAddressOption("option_name", "description", default=DEFAULT_OPT_VAL) + proto_value = ZMQAddress("inproc://proto-address") + proto_value_str = str(proto_value) + + # Set value and serialize (saves as string) + opt.set_value(proto_value) + opt.save_proto(proto) + assert "option_name" in proto.options + assert proto.options["option_name"].HasField('as_string') + assert proto.options["option_name"].as_string == proto_value_str + proto_dump = proto.SerializeToString() # Save serialized state + + # Clear option and deserialize from string + opt.clear(to_default=False) + assert opt.value is None + proto_read = ConfigProto() + proto_read.ParseFromString(proto_dump) + opt.load_proto(proto_read) + assert opt.value == proto_value + assert isinstance(opt.value, opt.datatype) + + # Test saving None value (should not add option to proto) + proto.Clear() + opt.set_value(None) + opt.save_proto(proto) + assert "option_name" not in proto.options + + # Test loading from empty proto (value should remain unchanged) + opt.set_value(DEFAULT_OPT_VAL) # Set a known value + proto.Clear() + opt.load_proto(proto) + assert opt.value is DEFAULT_OPT_VAL # Should not change to None + + # Test loading bad proto value (wrong type) + proto.Clear() + proto.options["option_name"].as_uint64 = 1000 # Invalid type for ZMQAddressOption + with pytest.raises(TypeError, match="Wrong value type: uint64"): + opt.load_proto(proto) + + # Test loading bad proto value (invalid string for ZMQAddress) + proto.Clear() + proto.options["option_name"].as_string = "invalid address" + with pytest.raises(ValueError, match="Protocol specification required"): + opt.load_proto(proto) + + +def test_get_config(conf: ConfigParser): + """Tests the get_config method for generating config file string representation.""" + opt = config.ZMQAddressOption("option_name", "description", default=DEFAULT_OPT_VAL) + + # Test output with default value (should be commented out) + expected_lines_default = f"""; description +; Type: ZMQAddress +;option_name = {str(DEFAULT_OPT_VAL)} +""" + assert opt.get_config() == expected_lines_default + + # Test output with explicitly set value + opt.set_value(NEW_VAL) + expected_lines_new = f"""; description +; Type: ZMQAddress +option_name = {str(NEW_VAL)} +""" + assert opt.get_config() == expected_lines_new + + # Test output when value is None (should show ) + opt.set_value(None) + expected_lines_none = """; description +; Type: ZMQAddress +option_name = +""" + assert opt.get_config() == expected_lines_none + # Check get_formatted directly for None case + assert opt.get_formatted() == "" + + # Test plain output + opt.set_value(NEW_VAL) + assert opt.get_config(plain=True) == f"option_name = {str(NEW_VAL)}\n" + opt.set_value(None) + assert opt.get_config(plain=True) == "option_name = \n" diff --git a/tests/test_buffer.py b/tests/test_buffer.py index a351da0..b1a66d0 100644 --- a/tests/test_buffer.py +++ b/tests/test_buffer.py @@ -3,8 +3,8 @@ # SPDX-License-Identifier: MIT # # PROGRAM/MODULE: firebird-base -# FILE: test/test_buffer.py -# DESCRIPTION: Unit tests for firebird.base.buffer +# FILE: tests/test_buffer.py +# DESCRIPTION: Tests for firebird.base.buffer # CREATED: 14.5.2020 # # The contents of this file are subject to the MIT License @@ -34,282 +34,632 @@ # ______________________________________. from __future__ import annotations -import unittest +import ctypes # For CTypesBufferFactory type check + +import pytest + +# Assuming buffer.py is importable as below from firebird.base.buffer import * +from firebird.base.types import UNLIMITED, ByteOrder # Make sure these are imported if needed + +factories = [BytesBufferFactory, CTypesBufferFactory] + +@pytest.fixture(params=factories) +def factory(request): + """Fixture providing both BytesBufferFactory and CTypesBufferFactory instances.""" + return request.param + +# --- New/Improved Tests --- + +def test_safe_ord(): + """Tests the safe_ord helper function.""" + assert safe_ord(b'A') == 65 + assert safe_ord(97) == 97 + with pytest.raises(TypeError): # Should fail on multi-byte + safe_ord(b'AB') + +def test_factory_bytes_create(): + """Tests buffer creation edge cases for BytesBufferFactory.""" + bf = BytesBufferFactory() + # Size specified, init shorter + buf = bf.create(b'ABC', 5) + assert isinstance(buf, bytearray) + assert len(buf) == 5 + assert buf == b'ABC\x00\x00' + # Size specified, init longer + buf = bf.create(b'ABCDEFGHI', 5) + assert isinstance(buf, bytearray) + assert len(buf) == 5 + assert buf == b'ABCDE' + # No size specified + buf = bf.create(b'ABC') + assert isinstance(buf, bytearray) + assert len(buf) == 3 + assert buf == b'ABC' + # Size only + buf = bf.create(5) + assert isinstance(buf, bytearray) + assert len(buf) == 5 + assert buf == b'\x00' * 5 + # Raw type + assert isinstance(bf.get_raw(buf), bytearray) + +def test_factory_ctypes_create(): + """Tests buffer creation edge cases for CTypesBufferFactory.""" + cbf = CTypesBufferFactory() + # Size specified, init shorter + buf = cbf.create(b'ABC', 5) + assert isinstance(buf, ctypes.Array) + assert len(buf) == 5 + assert buf.raw == b'ABC\x00\x00' + # Size specified, init longer - CTypes create_string_buffer raises error here usually, + # but our factory wrapper truncates. + buf = cbf.create(b'ABCDEFGHI', 5) + assert isinstance(buf, ctypes.Array) + assert len(buf) == 5 + assert buf.raw == b'ABCDE' + # No size specified - create_string_buffer adds NUL terminator + buf_orig = ctypes.create_string_buffer(b'ABC') + assert len(buf_orig) == 4 # Includes NUL + # Our factory wrapper does *not* add the extra NUL if size is omitted + buf = cbf.create(b'ABC') + assert isinstance(buf, ctypes.Array) + assert len(buf) == 3 + assert buf.raw == b'ABC' + # Size only + buf = cbf.create(5) + assert isinstance(buf, ctypes.Array) + assert len(buf) == 5 + assert buf.raw == b'\x00' * 5 + # Raw type + assert isinstance(cbf.get_raw(buf), bytes) + +def test_resize(factory): + """Tests explicit buffer resizing.""" + buf = MemoryBuffer(5, max_size=15, factory=factory) + assert buf.buffer_size == 5 + # Resize up + buf.resize(10) + assert buf.buffer_size == 10 + assert buf.get_raw() == b'\x00' * 10 # Assuming initial content was preserved up to old size + # Resize down + buf.write(b'0123456789') + buf.resize(7) + assert buf.buffer_size == 7 + assert buf.get_raw() == b'0123456' # Content truncated + # Resize past max_size + with pytest.raises(BufferError, match="Cannot resize buffer past max. size 15 bytes"): + buf.resize(20) + # Resize exactly to max_size + buf.resize(15) + assert buf.buffer_size == 15 + +def test_signed_numbers(factory): + """Tests writing and reading signed numbers.""" + # Signed byte (-128 to 127) + buf = MemoryBuffer(0, factory=factory) + buf.write_number(-10, 1, signed=True) + buf.pos = 0 + assert buf.read_number(1, signed=True) == -10 + buf.pos = 0 + assert buf.read_byte(signed=True) == -10 + buf.pos = 0 + assert buf.read_number(1, signed=False) == 246 # Unsigned interpretation + + # Signed short (-32768 to 32767) - Little Endian + buf = MemoryBuffer(0, factory=factory, byteorder=ByteOrder.LITTLE) + buf.write_number(-500, 2, signed=True) # -500 = 0xFE0C (little endian 0C FE) + assert buf.get_raw() == b'\x0C\xFE' + buf.pos = 0 + assert buf.read_number(2, signed=True) == -500 + buf.pos = 0 + assert buf.read_short(signed=True) == -500 + buf.pos = 0 + assert buf.read_number(2, signed=False) == 65036 # Unsigned interpretation + + # Signed short (-32768 to 32767) - Big Endian + buf = MemoryBuffer(0, factory=factory, byteorder=ByteOrder.BIG) + buf.write_number(-500, 2, signed=True) # -500 = 0xFE0C (big endian FE 0C) + assert buf.get_raw() == b'\xFE\x0C' + buf.pos = 0 + assert buf.read_number(2, signed=True) == -500 + buf.pos = 0 + assert buf.read_short(signed=True) == -500 + buf.pos = 0 + assert buf.read_number(2, signed=False) == 65036 # Unsigned interpretation + + # Signed int + buf = MemoryBuffer(0, factory=factory) + buf.write_number(-100000, 4, signed=True) + buf.pos = 0 + assert buf.read_number(4, signed=True) == -100000 + buf.pos = 0 + assert buf.read_int(signed=True) == -100000 + + # Signed bigint + buf = MemoryBuffer(0, factory=factory) + buf.write_number(-5000000000, 8, signed=True) + buf.pos = 0 + assert buf.read_number(8, signed=True) == -5000000000 + buf.pos = 0 + assert buf.read_bigint(signed=True) == -5000000000 + + +def test_string_encodings(factory): + """Tests writing and reading strings with different encodings and error handlers.""" + utf8_str = "你好世界" # Hello world in Chinese + utf8_bytes = utf8_str.encode('utf-8') + latin1_str = "Élément" + latin1_bytes = latin1_str.encode('latin-1') + + # UTF-8 Write/Read (null-terminated) + buf = MemoryBuffer(0, factory=factory) + buf.write_string(utf8_str, encoding='utf-8') + assert buf.get_raw() == utf8_bytes + b'\x00' + buf.pos = 0 + assert buf.read_string(encoding='utf-8') == utf8_str + assert buf.is_eof() + + # UTF-8 Write/Read (Pascal) + buf = MemoryBuffer(0, factory=factory) + buf.write_pascal_string(utf8_str, encoding='utf-8') + expected_bytes = bytes([len(utf8_bytes)]) + utf8_bytes + assert buf.get_raw() == expected_bytes + buf.pos = 0 + assert buf.read_pascal_string(encoding='utf-8') == utf8_str + assert buf.is_eof() + + # UTF-8 Write/Read (Sized) + buf = MemoryBuffer(0, factory=factory) + buf.write_sized_string(utf8_str, encoding='utf-8') + expected_bytes = len(utf8_bytes).to_bytes(2, 'little') + utf8_bytes + assert buf.get_raw() == expected_bytes + buf.pos = 0 + assert buf.read_sized_string(encoding='utf-8') == utf8_str + assert buf.is_eof() + + # Encoding Errors - write + buf = MemoryBuffer(0, factory=factory) + with pytest.raises(UnicodeEncodeError): # Default 'strict' errors + buf.write_string(utf8_str, encoding='ascii') + # Encoding Errors - read + buf = MemoryBuffer(utf8_bytes + b'\x00', factory=factory) + with pytest.raises(UnicodeDecodeError): # Default 'strict' errors + buf.read_string(encoding='ascii') + + # Error handling 'ignore' - read + buf = MemoryBuffer(utf8_bytes + b'\x00', factory=factory) + # Cannot reliably test ignore/replace on read as the exact output depends on Python version details + # For example, reading utf-8 as ascii might result in empty string or partial data with 'ignore' + # assert buf.read_string(encoding='ascii', errors='ignore') == "" # Or some subset? Test is flaky. + + # Error handling 'replace' - read + buf = MemoryBuffer(latin1_bytes + b'\x00', factory=factory) + assert buf.read_string(encoding='ascii', errors='replace') == "�l�ment" # Replacement char � + + # Error handling 'ignore' - write (difficult to test reliably for write) + # buf = MemoryBuffer(0, factory=factory) + # buf.write_string(utf8_str, encoding='ascii', errors='ignore') + # assert buf.get_raw() == b'\x00' # Or some subset? Test is flaky. + + +def test_init_eof_marker(factory): + """Tests that the eof_marker is correctly stored during initialization.""" + marker = 0xFF + buf = MemoryBuffer(10, eof_marker=marker, factory=factory) + assert buf.eof_marker == marker + +def test_last_data_property(factory): + """Tests the last_data property.""" + buf = MemoryBuffer(b'\x01\x02\x00\x03\x00\x00', factory=factory) + assert buf.last_data == 3 # Index of the byte '0x03' + buf.clear() + assert buf.last_data == -1 + buf.write(b'\x00\x00\x05') + assert buf.last_data == 2 + buf.write(b'\x00\x00') + assert buf.last_data == 2 # Trailing zeros ignored + buf.write_byte(1) + assert buf.last_data == 5 + +# --- Existing Tests with Docstrings --- + +def test_create_empty(factory): + """Tests creating an empty MemoryBuffer.""" + buf = MemoryBuffer(0, factory=factory) + assert buf.pos == 0 + assert len(buf.raw) == 0 + assert buf.eof_marker is None + assert buf.max_size is UNLIMITED + assert buf.byteorder is ByteOrder.LITTLE + assert buf.is_eof() + assert buf.buffer_size == 0 + assert buf.last_data == -1 + +def test_create_sized(factory): + """Tests creating a MemoryBuffer with a specific initial size.""" + buf = MemoryBuffer(10, factory=factory) + assert buf.pos == 0 + assert len(buf.raw) == 10 + assert buf.get_raw() == b"\x00" * 10 + assert buf.eof_marker is None + assert buf.max_size is UNLIMITED + assert buf.byteorder is ByteOrder.LITTLE + assert not buf.is_eof() + assert buf.buffer_size == 10 + assert buf.last_data == -1 + +def test_create_initialized(factory): + """Tests creating a MemoryBuffer initialized with a bytes object.""" + init_data = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x00\x00" + buf = MemoryBuffer(init_data, factory=factory) + assert buf.pos == 0 + assert len(buf.raw) == 12 + assert buf.get_raw() == init_data + assert buf.eof_marker is None + assert buf.max_size is UNLIMITED + assert buf.byteorder is ByteOrder.LITTLE + assert not buf.is_eof() + assert buf.buffer_size == 12 + assert buf.last_data == 9 + +def test_create_max_size(factory): + """Tests creating a MemoryBuffer with a maximum size limit.""" + buf = MemoryBuffer(10, max_size=20, factory=factory) + assert buf.buffer_size == 10 + assert buf.max_size == 20 + +def test_create_byte_order(factory): + """Tests creating a MemoryBuffer with a specific byte order.""" + buf = MemoryBuffer(10, byteorder=ByteOrder.BIG, factory=factory) + assert buf.byteorder == ByteOrder.BIG + +def test_clear_empty(factory): + """Tests clearing a MemoryBuffer that was initially empty but written to.""" + buf = MemoryBuffer(0, factory=factory) + buf.write(b"0123456789") + buf.clear() + assert buf.pos == 0 + assert len(buf.raw) == 10 # Size increased during write + assert buf.get_raw() == b"\x00" * 10 + assert not buf.is_eof() # Should not be EOF after clear if size > 0 + assert buf.buffer_size == 10 + assert buf.last_data == -1 + +def test_clear_sized(factory): + """Tests clearing a MemoryBuffer that was initialized with a size.""" + buf = MemoryBuffer(10, factory=factory) + # Fill buffer with non-zero data + if isinstance(buf.raw, bytearray): + buf.raw[:] = b"\xff" * 10 + else: # ctypes buffer + ctypes.memset(buf.raw, 0xFF, 10) + assert buf.get_raw() == b"\xff" * 10 + buf.clear() + assert buf.pos == 0 + assert len(buf.raw) == 10 + assert buf.get_raw() == b"\x00" * 10 + assert not buf.is_eof() + assert buf.buffer_size == 10 + assert buf.last_data == -1 + +def test_write(factory): + """Tests the basic write method.""" + buf = MemoryBuffer(0, factory=factory) + buf.write(b"ABCDE") + assert buf.pos == 5 + assert buf.get_raw() == b"ABCDE" + assert buf.is_eof() + +def test_write_byte(factory): + """Tests writing a single byte.""" + buf = MemoryBuffer(0, factory=factory) + buf.write_byte(1) + assert buf.pos == 1 + assert buf.get_raw() == b"\x01" + assert buf.is_eof() + +def test_write_short(factory): + """Tests writing a 2-byte short integer (unsigned).""" + buf = MemoryBuffer(0, factory=factory) + buf.write_short(2) # Assumes little endian default + assert buf.pos == 2 + assert buf.get_raw() == b"\x02\x00" + assert buf.is_eof() + +def test_write_int(factory): + """Tests writing a 4-byte integer (unsigned).""" + buf = MemoryBuffer(0, factory=factory) + buf.write_int(3) # Assumes little endian default + assert buf.pos == 4 + assert buf.get_raw() == b"\x03\x00\x00\x00" + assert buf.is_eof() + +def test_write_bigint(factory): + """Tests writing an 8-byte long long integer (unsigned).""" + buf = MemoryBuffer(0, factory=factory) + buf.write_bigint(4) # Assumes little endian default + assert buf.pos == 8 + assert buf.get_raw() == b"\x04\x00\x00\x00\x00\x00\x00\x00" + assert buf.is_eof() + +def test_write_number(factory): + """Tests writing numbers of various sizes using write_number (unsigned, little endian).""" + buf = MemoryBuffer(0, factory=factory) + buf.write_number(255, 1) + assert buf.pos == 1 + assert buf.get_raw() == b"\xff" + assert buf.is_eof() + # + buf = MemoryBuffer(0, factory=factory) + buf.write_number(255, 2) + assert buf.pos == 2 + assert buf.get_raw() == b"\xff\x00" + assert buf.is_eof() + # + buf = MemoryBuffer(0, factory=factory) + buf.write_number(255, 4) + assert buf.pos == 4 + assert buf.get_raw() == b"\xff\x00\x00\x00" + assert buf.is_eof() + # + buf = MemoryBuffer(0, factory=factory) + buf.write_number(255, 8) + assert buf.pos == 8 + assert buf.get_raw() == b"\xff\x00\x00\x00\x00\x00\x00\x00" + assert buf.is_eof() + # Atypical sizes + buf = MemoryBuffer(0, factory=factory) + buf.write_number(255, 3) + assert buf.pos == 3 + assert buf.get_raw() == b"\xff\x00\x00" + assert buf.is_eof() + buf = MemoryBuffer(0, factory=factory) + buf.write_number(255, 12) + assert buf.pos == 12 + assert buf.get_raw() == b"\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + assert buf.is_eof() + +def test_write_number_big_endian(factory): + """Tests writing numbers of various sizes using write_number (unsigned, big endian).""" + buf = MemoryBuffer(0, factory=factory, byteorder=ByteOrder.BIG) + buf.write_number(255, 1) + assert buf.pos == 1 + assert buf.get_raw() == b"\xff" + assert buf.is_eof() + # + buf = MemoryBuffer(0, factory=factory, byteorder=ByteOrder.BIG) + buf.write_number(255, 2) + assert buf.pos == 2 + assert buf.get_raw() == b"\x00\xff" + assert buf.is_eof() + # + buf = MemoryBuffer(0, factory=factory, byteorder=ByteOrder.BIG) + buf.write_number(255, 4) + assert buf.pos == 4 + assert buf.get_raw() == b"\x00\x00\x00\xff" + assert buf.is_eof() + # + buf = MemoryBuffer(0, factory=factory, byteorder=ByteOrder.BIG) + buf.write_number(255, 8) + assert buf.pos == 8 + assert buf.get_raw() == b"\x00\x00\x00\x00\x00\x00\x00\xff" + assert buf.is_eof() + # Atypical sizes + buf = MemoryBuffer(0, factory=factory, byteorder=ByteOrder.BIG) + buf.write_number(255, 3) + assert buf.pos == 3 + assert buf.get_raw() == b"\x00\x00\xff" + assert buf.is_eof() + buf = MemoryBuffer(0, factory=factory, byteorder=ByteOrder.BIG) + buf.write_number(255, 12) + assert buf.pos == 12 + assert buf.get_raw() == b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff" + assert buf.is_eof() + +def test_write_string(factory): + """Tests writing a null-terminated string.""" + buf = MemoryBuffer(0, factory=factory) + buf.write_string("string") + assert buf.pos == 7 + assert buf.get_raw() == b"string\x00" + assert buf.is_eof() + +def test_write_pascal_string(factory): + """Tests writing a Pascal-style string (1-byte length prefix).""" + buf = MemoryBuffer(0, factory=factory) + buf.write_pascal_string("string") + assert buf.pos == 7 + assert buf.get_raw() == b"\x06string" + assert buf.is_eof() + +def test_write_sized_string(factory): + """Tests writing a string with a 2-byte length prefix.""" + buf = MemoryBuffer(0, factory=factory) + buf.write_sized_string("string") # Assumes little endian default for size + assert buf.pos == 8 + assert buf.get_raw() == b"\x06\x00string" + assert buf.is_eof() + +def test_write_past_size(factory): + """Tests that writing past the max_size limit raises BufferError.""" + buf = MemoryBuffer(0, max_size=5, factory=factory) + buf.write(b"ABCDE") # Fill buffer exactly + with pytest.raises(BufferError, match="Cannot resize buffer past max. size 5 bytes"): + buf.write(b"F") # Attempt to write one more byte + +def test_read(factory): + """Tests the basic read method for sized and remaining reads.""" + buf = MemoryBuffer(b"ABCDE", factory=factory) + assert buf.read(3) == b"ABC" + assert buf.pos == 3 + assert not buf.is_eof() + assert buf.read() == b"DE" # Read remaining + assert buf.pos == 5 + assert buf.is_eof() + +def test_read_byte(factory): + """Tests reading a single byte (unsigned).""" + buf = MemoryBuffer(b"\x01", factory=factory) + assert buf.read_byte() == 1 + assert buf.pos == 1 + assert buf.is_eof() + +def test_read_short(factory): + """Tests reading a 2-byte short integer (unsigned).""" + buf = MemoryBuffer(b"\x02\x00", factory=factory) # Little endian + assert buf.read_short() == 2 + assert buf.pos == 2 + assert buf.is_eof() + +def test_read_int(factory): + """Tests reading a 4-byte integer (unsigned).""" + buf = MemoryBuffer(b"\x03\x00\x00\x00", factory=factory) # Little endian + assert buf.read_int() == 3 + assert buf.pos == 4 + assert buf.is_eof() + +def test_read_bigint(factory): + """Tests reading an 8-byte long long integer (unsigned).""" + buf = MemoryBuffer(b"\x04\x00\x00\x00\x00\x00\x00\x00", factory=factory) # Little endian + assert buf.read_bigint() == 4 + # Corrected assertion: buffer content remains after read + assert buf.get_raw() == b"\x04\x00\x00\x00\x00\x00\x00\x00" + assert buf.pos == 8 + assert buf.is_eof() + +def test_read_number(factory): + """Tests reading numbers of various sizes using read_number (unsigned, little endian).""" + buf = MemoryBuffer(b"\xff", factory=factory) + assert buf.read_number(1) == 255 + assert buf.pos == 1 + assert buf.is_eof() + # + buf = MemoryBuffer(b"\xff\x00", factory=factory) + assert buf.read_number(2) == 255 + assert buf.pos == 2 + assert buf.is_eof() + # + buf = MemoryBuffer(b"\xff\x00\x00\x00", factory=factory) + assert buf.read_number(4) == 255 + assert buf.pos == 4 + assert buf.is_eof() + # + buf = MemoryBuffer(b"\xff\x00\x00\x00\x00\x00\x00\x00", factory=factory) + assert buf.read_number(8) == 255 + assert buf.pos == 8 + assert buf.is_eof() + # Atypical sizes + buf = MemoryBuffer(b"\xff\x00\x00", factory=factory) + assert buf.read_number(3) == 255 + assert buf.pos == 3 + assert buf.is_eof() + # + buf = MemoryBuffer(b"\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", factory=factory) + assert buf.read_number(12) == 255 + assert buf.pos == 12 + assert buf.is_eof() + +def test_read_number_big_endian(factory): + """Tests reading numbers of various sizes using read_number (unsigned, big endian).""" + buf = MemoryBuffer(b"\xff", factory=factory, byteorder=ByteOrder.BIG) + assert buf.read_number(1) == 255 + assert buf.pos == 1 + assert buf.is_eof() + # + buf = MemoryBuffer(b"\x00\xff", factory=factory, byteorder=ByteOrder.BIG) + assert buf.read_number(2) == 255 + assert buf.pos == 2 + assert buf.is_eof() + # + buf = MemoryBuffer(b"\x00\x00\x00\xff", factory=factory, byteorder=ByteOrder.BIG) + assert buf.read_number(4) == 255 + assert buf.pos == 4 + assert buf.is_eof() + # + buf = MemoryBuffer(b"\x00\x00\x00\x00\x00\x00\x00\xff", factory=factory, + byteorder=ByteOrder.BIG) + assert buf.read_number(8) == 255 + assert buf.pos == 8 + assert buf.is_eof() + # Atypical sizes + buf = MemoryBuffer(b"\x00\x00\xff", factory=factory, byteorder=ByteOrder.BIG) + assert buf.read_number(3) == 255 + assert buf.pos == 3 + assert buf.is_eof() + # + buf = MemoryBuffer(b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff", factory=factory, + byteorder=ByteOrder.BIG) + assert buf.read_number(12) == 255 + assert buf.pos == 12 + assert buf.is_eof() + +def test_read_sized_int(factory): + """Tests reading an integer prefixed with its 2-byte size.""" + buf = MemoryBuffer(b"\x04\x00\x03\x00\x00\x00", factory=factory) # Size 4, Value 3 (little endian) + assert buf.read_sized_int() == 3 + assert buf.pos == 6 # Read 2 bytes for size + 4 bytes for int + assert buf.is_eof() + +def test_read_string(factory): + """Tests reading a null-terminated string.""" + buf = MemoryBuffer(b"string 1\x00string 2\x00", factory=factory) + assert buf.read_string() == "string 1" + assert buf.pos == 9 # Position after the null terminator + assert not buf.is_eof() + # Test reading string at the end without null terminator (should read to end) + buf = MemoryBuffer(b"string", factory=factory) + assert buf.read_string() == "string" + assert buf.pos == 7 # Position after where the null would be + assert buf.is_eof() + +def test_read_pascal_string(factory): + """Tests reading a Pascal-style string (1-byte length prefix).""" + buf = MemoryBuffer(b"\x06stringAnd another data", factory=factory) + assert buf.read_pascal_string() == "string" + assert buf.pos == 7 # Position after the string data (1 byte size + 6 bytes data) + assert not buf.is_eof() + +def test_read_sized_string(factory): + """Tests reading a string prefixed with its 2-byte size.""" + buf = MemoryBuffer(b"\x08\x00string 1\x08\x00string 2", factory=factory) # Size 8, "string 1", Size 8, "string 2" + assert buf.read_sized_string() == "string 1" + assert buf.pos == 10 # Position after the string data (2 bytes size + 8 bytes data) + assert not buf.is_eof() + +def test_read_bytes(factory): + """Tests reading a byte sequence prefixed with its 2-byte size.""" + buf = MemoryBuffer(b"\x08\x00ABCDEFGH\x08\x00string 2", factory=factory) # Size 8, b"ABCDEFGH", Size 8, "string 2" + assert buf.read_bytes() == b"ABCDEFGH" + assert buf.pos == 10 # Position after the byte data (2 bytes size + 8 bytes data) + assert not buf.is_eof() + +def test_read_past_size(factory): + """Tests that reading past the buffer end raises BufferError.""" + buf = MemoryBuffer(b"ABCDE", factory=factory) + buf.read(3) # pos = 3 + with pytest.raises(BufferError, match="Insufficient buffer size"): + buf.read(3) # Tries to read 3 bytes, only 2 remaining + # Ensure specific read methods also fail + buf.pos = 4 # Position at 'E' + with pytest.raises(BufferError, match="Insufficient buffer size"): + buf.read_short() # Needs 2 bytes, only 1 remaining + with pytest.raises(BufferError, match="Insufficient buffer size"): + buf.read_int() -class TestBuffer(unittest.TestCase): - """Unit tests for firebird.base.buffer with BytesBufferFactory""" - def __init__(self, methodName='runTest'): - super().__init__(methodName) - self.factory = BytesBufferFactory - def setUp(self) -> None: - pass - def tearDown(self): - pass - def assertBuffer(self, buffer, content): - self.assertEqual(buffer.raw, content) - def test_create(self): - # Empty buffer - buf = MemoryBuffer(0, factory=self.factory) - self.assertEqual(buf.pos, 0) - self.assertEqual(len(buf.raw), 0) - self.assertIsNone(buf.eof_marker) - self.assertIs(buf.max_size, UNLIMITED) - self.assertIs(buf.byteorder, ByteOrder.LITTLE) - self.assertTrue(buf.is_eof()) - self.assertEqual(buf.buffer_size, 0) - self.assertEqual(buf.last_data, -1) - # Sized - buf = MemoryBuffer(10, factory=self.factory) - self.assertEqual(buf.pos, 0) - self.assertEqual(len(buf.raw), 10) - self.assertBuffer(buf, b'\x00' * 10) - #self.assertEqual(buf.raw, b'\x00' * 10) - self.assertIsNone(buf.eof_marker) - self.assertIs(buf.max_size, UNLIMITED) - self.assertIs(buf.byteorder, ByteOrder.LITTLE) - self.assertFalse(buf.is_eof()) - self.assertEqual(buf.buffer_size, 10) - self.assertEqual(buf.last_data, -1) - # Initialized - buf = MemoryBuffer(b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x00\x00', factory=self.factory) - self.assertEqual(buf.pos, 0) - self.assertEqual(len(buf.raw), 12) - self.assertBuffer(buf, b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x00\x00') - self.assertIsNone(buf.eof_marker) - self.assertIs(buf.max_size, UNLIMITED) - self.assertIs(buf.byteorder, ByteOrder.LITTLE) - self.assertFalse(buf.is_eof()) - self.assertEqual(buf.buffer_size, 12) - self.assertEqual(buf.last_data, 9) - # Max. Size - buf = MemoryBuffer(10, max_size=20, factory=self.factory) - self.assertEqual(buf.buffer_size, 10) - self.assertIs(buf.max_size, 20) - # Byte order - buf = MemoryBuffer(10, byteorder=ByteOrder.BIG, factory=self.factory) - self.assertIs(buf.byteorder, ByteOrder.BIG) - def test_clear(self): - # Empty - buf = MemoryBuffer(0, factory=self.factory) - buf.write(b'0123456789') - buf.clear() - self.assertEqual(buf.pos, 0) - self.assertEqual(len(buf.raw), 10) - self.assertBuffer(buf, b'\x00' * 10) - self.assertFalse(buf.is_eof()) - self.assertEqual(buf.buffer_size, 10) - self.assertEqual(buf.last_data, -1) - # Sized - buf = MemoryBuffer(10, factory=self.factory) - for i in range(buf.buffer_size): - buf.raw[i] = 255 - self.assertBuffer(buf, b'\xff' * 10) - buf.clear() - self.assertEqual(buf.pos, 0) - self.assertEqual(len(buf.raw), 10) - self.assertBuffer(buf, b'\x00' * 10) - self.assertFalse(buf.is_eof()) - self.assertEqual(buf.buffer_size, 10) - self.assertEqual(buf.last_data, -1) - def test_write(self): - buf = MemoryBuffer(0, factory=self.factory) - buf.write(b'ABCDE') - self.assertEqual(buf.pos, 5) - self.assertBuffer(buf, b'ABCDE') - self.assertTrue(buf.is_eof()) - def test_write_byte(self): - buf = MemoryBuffer(0, factory=self.factory) - buf.write_byte(1) - self.assertEqual(buf.pos, 1) - self.assertBuffer(buf, b'\x01') - self.assertTrue(buf.is_eof()) - def test_write_short(self): - buf = MemoryBuffer(0, factory=self.factory) - buf.write_short(2) - self.assertEqual(buf.pos, 2) - self.assertBuffer(buf, b'\x02\x00') - self.assertTrue(buf.is_eof()) - def test_write_int(self): - buf = MemoryBuffer(0, factory=self.factory) - buf.write_int(3) - self.assertEqual(buf.pos, 4) - self.assertBuffer(buf, b'\x03\x00\x00\x00') - self.assertTrue(buf.is_eof()) - def test_write_bigint(self): - buf = MemoryBuffer(0, factory=self.factory) - buf.write_bigint(4) - self.assertEqual(buf.pos, 8) - self.assertBuffer(buf, b'\x04\x00\x00\x00\x00\x00\x00\x00') - self.assertTrue(buf.is_eof()) - def test_write_number(self): - buf = MemoryBuffer(0, factory=self.factory) - buf.write_number(255, 1) - self.assertEqual(buf.pos, 1) - self.assertBuffer(buf, b'\xff') - self.assertTrue(buf.is_eof()) - # - buf = MemoryBuffer(0, factory=self.factory) - buf.write_number(255, 2) - self.assertEqual(buf.pos, 2) - self.assertBuffer(buf, b'\xff\x00') - self.assertTrue(buf.is_eof()) - # - buf = MemoryBuffer(0, factory=self.factory) - buf.write_number(255, 4) - self.assertEqual(buf.pos, 4) - self.assertBuffer(buf, b'\xff\x00\x00\x00') - self.assertTrue(buf.is_eof()) - # - buf = MemoryBuffer(0, factory=self.factory) - buf.write_number(255, 8) - self.assertEqual(buf.pos, 8) - self.assertBuffer(buf, b'\xff\x00\x00\x00\x00\x00\x00\x00') - self.assertTrue(buf.is_eof()) - # Atypical sizes - buf = MemoryBuffer(0, factory=self.factory) - buf.write_number(255, 3) - self.assertEqual(buf.pos, 3) - self.assertBuffer(buf, b'\xff\x00\x00') - self.assertTrue(buf.is_eof()) - buf = MemoryBuffer(0, factory=self.factory) - buf.write_number(255, 12) - self.assertEqual(buf.pos, 12) - self.assertBuffer(buf, b'\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') - self.assertTrue(buf.is_eof()) - def test_write_string(self): - buf = MemoryBuffer(0, factory=self.factory) - buf.write_string('string') - self.assertEqual(buf.pos, 7) - self.assertBuffer(buf, b'string\x00') - self.assertTrue(buf.is_eof()) - def test_write_pascal_string(self): - buf = MemoryBuffer(0, factory=self.factory) - buf.write_pascal_string('string') - self.assertEqual(buf.pos, 7) - self.assertBuffer(buf, b'\x06string') - self.assertTrue(buf.is_eof()) - def test_write_sized_string(self): - buf = MemoryBuffer(0, factory=self.factory) - buf.write_sized_string('string') - self.assertEqual(buf.pos, 8) - self.assertBuffer(buf, b'\x06\x00string') - self.assertTrue(buf.is_eof()) - def test_write_past_size(self): - buf = MemoryBuffer(0, max_size=5, factory=self.factory) - buf.write(b'ABCDE') - with self.assertRaises(IOError) as cm: - buf.write(b'exceeds size') - self.assertEqual(cm.exception.args, ("Cannot resize buffer past max. size 5 bytes",)) - def test_read(self): - buf = MemoryBuffer(b'ABCDE', factory=self.factory) - self.assertEqual(buf.read(3), b'ABC') - self.assertEqual(buf.pos, 3) - self.assertFalse(buf.is_eof()) - self.assertEqual(buf.read(), b'DE') - self.assertEqual(buf.pos, 5) - self.assertTrue(buf.is_eof()) - def test_read_byte(self): - buf = MemoryBuffer(b'\x01', factory=self.factory) - self.assertEqual(buf.read_byte(), 1) - self.assertEqual(buf.pos, 1) - self.assertTrue(buf.is_eof()) - def test_read_short(self): - buf = MemoryBuffer(b'\x02\x00', factory=self.factory) - self.assertEqual(buf.read_short(), 2) - self.assertEqual(buf.pos, 2) - self.assertTrue(buf.is_eof()) - def test_read_int(self): - buf = MemoryBuffer(b'\x03\x00\x00\x00', factory=self.factory) - self.assertEqual(buf.read_int(), 3) - self.assertEqual(buf.pos, 4) - self.assertTrue(buf.is_eof()) - def test_read_bigint(self): - buf = MemoryBuffer(b'\x04\x00\x00\x00\x00\x00\x00\x00', factory=self.factory) - self.assertEqual(buf.read_bigint(), 4) - self.assertEqual(buf.pos, 8) - self.assertBuffer(buf, b'\x04\x00\x00\x00\x00\x00\x00\x00') - self.assertTrue(buf.is_eof()) - def test_read_number(self): - buf = MemoryBuffer(b'\xff', factory=self.factory) - self.assertEqual(buf.read_number(1), 255) - self.assertEqual(buf.pos, 1) - self.assertTrue(buf.is_eof()) - # - buf = MemoryBuffer(b'\xff\x00', factory=self.factory) - self.assertEqual(buf.read_number(2), 255) - self.assertEqual(buf.pos, 2) - self.assertTrue(buf.is_eof()) - # - buf = MemoryBuffer(b'\xff\x00\x00\x00', factory=self.factory) - self.assertEqual(buf.read_number(4), 255) - self.assertEqual(buf.pos, 4) - self.assertTrue(buf.is_eof()) - # - buf = MemoryBuffer(b'\xff\x00\x00\x00\x00\x00\x00\x00', factory=self.factory) - self.assertEqual(buf.read_number(8), 255) - self.assertEqual(buf.pos, 8) - self.assertTrue(buf.is_eof()) - # Atypical sizes - buf = MemoryBuffer(b'\xff\x00\x00', factory=self.factory) - self.assertEqual(buf.read_number(3), 255) - self.assertEqual(buf.pos, 3) - self.assertTrue(buf.is_eof()) - buf = MemoryBuffer(b'\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', factory=self.factory) - self.assertEqual(buf.read_number(12), 255) - self.assertEqual(buf.pos, 12) - self.assertTrue(buf.is_eof()) - def test_read_sized_int(self): - buf = MemoryBuffer(b'\x04\x00\x03\x00\x00\x00', factory=self.factory) - self.assertEqual(buf.read_sized_int(), 3) - self.assertEqual(buf.pos, 6) - self.assertTrue(buf.is_eof()) - def test_read_string(self): - buf = MemoryBuffer(b'string 1\x00string 2\x00', factory=self.factory) - self.assertEqual(buf.read_string(), 'string 1') - self.assertEqual(buf.pos, 9) - self.assertFalse(buf.is_eof()) - # No zero-terminator - buf = MemoryBuffer(b'string', factory=self.factory) - self.assertEqual(buf.read_string(), 'string') - self.assertEqual(buf.pos, 7) - self.assertTrue(buf.is_eof()) - def test_read_pascal_string(self): - buf = MemoryBuffer(b'\x06string', factory=self.factory) - self.assertEqual(buf.read_pascal_string(), 'string') - self.assertEqual(buf.pos, 7) - self.assertTrue(buf.is_eof()) - def test_read_sized_string(self): - buf = MemoryBuffer(b'\x08\x00string 1\x08\x00string 2', factory=self.factory) - self.assertEqual(buf.read_sized_string(), 'string 1') - self.assertEqual(buf.pos, 10) - self.assertFalse(buf.is_eof()) - def test_read_bytes(self): - buf = MemoryBuffer(b'\x08\x00ABCDEFGH\x08\x00string 2', factory=self.factory) - self.assertEqual(buf.read_bytes(), b'ABCDEFGH') - self.assertEqual(buf.pos, 10) - self.assertFalse(buf.is_eof()) - def test_read_past_size(self): - buf = MemoryBuffer(b'ABCDE', factory=self.factory) - with self.assertRaises(IOError) as cm: - buf.read_bigint() - self.assertEqual(cm.exception.args, ("Insufficient buffer size",)) - def test_eof_marker(self): - buf = MemoryBuffer(b'\x08\x00ABCDEFGH\xFF\x00\x00\x00\x00\x00\x00', eof_marker=255, - factory=self.factory) - while not buf.is_eof(): - buf.pos += 1 - self.assertLess(buf.pos, buf.buffer_size) - self.assertEqual(buf.pos, 10) - self.assertEqual(safe_ord(buf.raw[buf.pos]), buf.eof_marker) - -class TestCBuffer(TestBuffer): - """Unit tests for firebird.base.buffer with CTypesBufferFactory""" - def __init__(self, methodName='runTest'): - super().__init__(methodName) - self.factory = CTypesBufferFactory - def assertBuffer(self, buffer, content): - self.assertEqual(buffer.raw.raw, content) - -if __name__ == '__main__': - unittest.main() +def test_eof_marker(factory): + """Tests the is_eof() method with an eof_marker defined.""" + marker = 0xFF + # Buffer ends exactly at marker + buf = MemoryBuffer(b"\x08\x00ABCDEFGH\xFF", eof_marker=marker, factory=factory) + buf.pos = 10 + assert buf.is_eof() # Should detect EOF at the marker + # Buffer has data after marker + buf = MemoryBuffer(b"\x08\x00ABCDEFGH\xFF\x01\x02", eof_marker=marker, factory=factory) + buf.pos = 10 + assert buf.is_eof() # Should detect EOF at the marker, ignoring subsequent data + # Test reading up to marker + buf.pos = 0 + assert buf.read_bytes() == b"ABCDEFGH" + assert buf.pos == 10 + assert buf.is_eof() diff --git a/tests/test_collections.py b/tests/test_collections.py index f43b098..8a52064 100644 --- a/tests/test_collections.py +++ b/tests/test_collections.py @@ -3,8 +3,8 @@ # SPDX-License-Identifier: MIT # # PROGRAM/MODULE: firebird-base -# FILE: test/test_collections.py -# DESCRIPTION: Unit tests for firebird.base.collections +# FILE: tests/test_collections.py +# DESCRIPTION: Tests for firebird.base.collections # CREATED: 20.9.2019 # # The contents of this file are subject to the MIT License @@ -36,702 +36,1177 @@ """Firebird Base - Unit tests for firebird.base.collections.""" from __future__ import annotations -from types import GeneratorType -import unittest + from dataclasses import dataclass -from firebird.base.types import Error, Distinct, UNDEFINED -from firebird.base.collections import DataList, Registry +from types import GeneratorType + +import pytest + +# Assuming collections.py is importable as below +from firebird.base.collections import DataList, Registry, make_lambda +from firebird.base.types import UNDEFINED, Distinct, Error + +# --- Test Setup & Fixtures --- -@dataclass +KEY_ITEM = "item.key" +KEY_SPEC = "item.key" # Seems redundant with KEY_ITEM, maybe intended for different tests? + +@dataclass(eq=False) class Item(Distinct): + """Simple Distinct item for testing collections.""" key: int name: str + # Make items mutable for shallow copy tests + mutable_list: list = None # type: ignore + + def __post_init__(self): + if self.mutable_list is None: + self.mutable_list = [] + def get_key(self): + """Returns the key for the Distinct item.""" return self.key -@dataclass +@dataclass(eq=False) class Desc(Distinct): + """Another Distinct item type for testing collections.""" key: int item: Item description: str def get_key(self): + """Returns the key for the Distinct item.""" return self.key +class NonDistinctItem: + """A class that does not inherit from Distinct.""" + def __init__(self, key, name): + self.key = key + self.name = name + # No get_key method + class MyRegistry(Registry): + """Subclass of Registry for testing copy behavior.""" pass -class TestDataList(unittest.TestCase): - """Unit tests for firebird.base.collection.DataList""" - def setUp(self): - self.data_items = [Item(1, 'Item 01'), Item(2, 'Item 02'), Item(3, 'Item 03'), - Item(4, 'Item 04'), Item(5, 'Item 05'), Item(6, 'Item 06'), - Item(7, 'Item 07'), Item(8, 'Item 08'), Item(9, 'Item 09'), - Item(10, 'Item 10')] - self.data_desc = [Desc(item.key, item, f"This is item '{item.name}'") for item - in self.data_items] - self.key_item = 'item.key' - self.key_spec = 'item.key' - def tearDown(self): - pass - def test_create(self): - l = DataList() - # Simple - self.assertListEqual(l, [], "Simple") - self.assertFalse(l.frozen, "Simple") - self.assertIsNone(l.key_expr, "Simple") - self.assertIs(l.type_spec, UNDEFINED, "Simple") - # From items - with self.assertRaises(TypeError): - DataList(object) - l = DataList(self.data_items) - self.assertListEqual(l, self.data_items, "From items") - self.assertFalse(l.frozen, "From items") - self.assertIsNone(l.key_expr, "From items") - self.assertIs(l.type_spec, UNDEFINED, "From items") - # With type spec (Non-Distinct) - l = DataList(type_spec=int) - self.assertFalse(l.frozen, "With type spec (Non-Distinct)") - self.assertIsNone(l.key_expr, "With type spec (Non-Distinct)") - # With type spec (Distinct) - l = DataList(type_spec=Item) - self.assertFalse(l.frozen, "With type spec (Distinct)") - self.assertEqual(l.key_expr, 'item.get_key()', "With type spec (Distinct)") - self.assertEqual(l.type_spec, Item, "With type spec (Distinct)") - l = DataList(type_spec=(Item, Desc)) - self.assertEqual(l.key_expr, 'item.get_key()', "With type spec (Distinct)") - self.assertEqual(l.type_spec, (Item, Desc), "With type spec (Distinct)") - # With key expr - if __debug__: - with self.assertRaises(AssertionError, msg="With key expr"): - DataList(key_expr=object) - with self.assertRaises(SyntaxError, msg="With key expr"): - DataList(key_expr='wrong key expression') - l = DataList(key_expr=self.key_item) - self.assertFalse(l.frozen, "With key expr") - self.assertEqual(l.key_expr, self.key_item, "With key expr") - self.assertIs(l.type_spec, UNDEFINED, "With key expr") - # With frozen - l = DataList(frozen=True) - self.assertTrue(l.frozen, "With frozen") - # With all - l = DataList(self.data_items, Item, self.key_item) - self.assertEqual(l, self.data_items, "With all") - def test_insert(self): - i1, i2, i3 = self.data_items[:3] - l = DataList() - # Simple - l.insert(0, i1) - self.assertListEqual(l, [i1], "Simple") - l.insert(0, i2) - self.assertListEqual(l, [i2, i1], "Simple") - l.insert(1, i3) - self.assertListEqual(l, [i2, i3, i1], "Simple") - l.insert(5, i3) - self.assertListEqual(l, [i2, i3, i1, i3], "Simple") - # With type_spec - l = DataList(type_spec=Item) - l.insert(0, i1) - with self.assertRaises(TypeError, msg="With type_spec"): - l.insert(0, self.data_desc[0]) - # With key expr - l = DataList(key_expr=self.key_item) - l.insert(0, i1) - self.assertListEqual(l, [i1], "With key expr") - # Frozen - with self.assertRaises(TypeError): - l.freeze() - l.insert(0, i1) - def test_append(self): - i1, i2 = self.data_items[:2] - l = DataList() - # Simple - l.append(i1) - self.assertListEqual(l, [i1], "Simple") - l.append(i2) - self.assertListEqual(l, [i1, i2], "Simple") - # With type_spec - l = DataList(type_spec=Item) - l.append(i1) - with self.assertRaises(TypeError, msg="With type_spec"): - l.insert(0, self.data_desc[0]) - # With key expr - l = DataList(key_expr=self.key_item) - l.append(i1) - self.assertListEqual(l, [i1], "With key expr") - # Frozen - with self.assertRaises(TypeError): - l.freeze() - l.append(i1) - def test_extend(self): - l = DataList() - # Simple - l.extend(self.data_items) - self.assertListEqual(l, self.data_items) - # With type_spec - l = DataList(type_spec=Item) - l.extend(self.data_items) - self.assertListEqual(l, self.data_items) - with self.assertRaises(TypeError, msg="With type_spec"): - l.extend(self.data_desc) - # With key expr - l = DataList(key_expr=self.key_item) - l.extend(self.data_items) - self.assertListEqual(l, self.data_items, "With key expr") - # Frozen - with self.assertRaises(TypeError): - l.freeze() - l.extend(self.data_items[0]) - def test_list_acess(self): - l = DataList(self.data_items) - # Simple - self.assertEqual(l[2], self.data_items[2]) - with self.assertRaises(IndexError): - l[20] - # With type_spec - l = DataList(self.data_items, type_spec=Item) - self.assertEqual(l[2], self.data_items[2]) - # With key expr - l = DataList(self.data_items, key_expr=self.key_item) - self.assertEqual(l[2], self.data_items[2]) - def test_list_update(self): - i1 = self.data_items[0] - l = DataList(self.data_items) - # Simple - l[3] = i1 - self.assertEqual(l[3], i1) - l = DataList(self.data_items, type_spec=Item) - # With type_spec - l[3] = i1 - self.assertEqual(l[3], i1) - with self.assertRaises(TypeError, msg="With type_spec"): - l[3] = self.data_desc[0] - # With key expr - l = DataList(self.data_items, key_expr=self.key_item) +@pytest.fixture +def data_items(): + """Provides a list of Item instances for tests.""" + return [Item(1, "Item 01"), Item(2, "Item 02"), Item(3, "Item 03"), Item(4, "Item 04"), + Item(5, "Item 05"), Item(6, "Item 06"), Item(7, "Item 07"), Item(8, "Item 08"), + Item(9, "Item 09"), Item(10, "Item 10")] + +@pytest.fixture +def data_desc(data_items): + """Provides a list of Desc instances linked to data_items.""" + return [Desc(item.key, item, f"This is item '{item.name}'") for item in data_items] + +@pytest.fixture +def dict_items(data_items): + """Provides a dictionary mapping keys to Item instances.""" + return {i.key: i for i in data_items} + +@pytest.fixture +def dict_desc(data_desc): + """Provides a dictionary mapping keys to Desc instances.""" + return {i.key: i for i in data_desc} + +# --- Test Functions --- + +def test_make_lambda(): + """Tests the make_lambda helper function.""" + # Simple case + f1 = make_lambda("item + 1") + assert f1(5) == 6 + # With parameters + f2 = make_lambda("x * y", params="x, y") + assert f2(3, 4) == 12 + # With context + ctx = {"multiplier": 10} + f3 = make_lambda("val * multiplier", params="val", context=ctx) + assert f3(7) == 70 + # Syntax Error + with pytest.raises(SyntaxError): + make_lambda("item +") + +def test_datalist_create(): + """Tests DataList initialization variations.""" + l = DataList() + assert l == [] + assert not l.frozen + assert l.key_expr is None + assert l.type_spec is UNDEFINED + +def test_datalist_create_from_items(data_items): + """Tests DataList initialization with an iterable.""" + # Should not accept non-iterable + with pytest.raises(TypeError): + DataList(object) # type: ignore + # Initialize with list + l = DataList(data_items) + assert l == data_items + assert not l.frozen + assert l.key_expr is None # No type_spec, so no default key_expr + assert l.type_spec is UNDEFINED + +def test_datalist_create_with_typespec(data_items): + """Tests DataList initialization with type_spec.""" + # With type spec (Non-Distinct) - key_expr should remain None + l_int = DataList(type_spec=int) + assert not l_int.frozen + assert l_int.key_expr is None + assert l_int.type_spec == int + + # With type spec (Single Distinct) - key_expr should default + l_item = DataList(type_spec=Item) + assert not l_item.frozen + assert l_item.key_expr == "item.get_key()" + assert l_item.type_spec == Item + + # With type spec (Tuple of Distinct) - key_expr should default + l_multi = DataList(type_spec=(Item, Desc)) + assert l_multi.key_expr == "item.get_key()" + assert l_multi.type_spec == (Item, Desc) + + # With type spec (Tuple mixed Distinct/Non-Distinct) - key_expr should be None + l_mixed = DataList(type_spec=(Item, int)) + assert l_mixed.key_expr is None + assert l_mixed.type_spec == (Item, int) + + +def test_datalist_create_with_keyexpr(): + """Tests DataList initialization with an explicit key_expr.""" + # Invalid key_expr type + with pytest.raises(AssertionError): + DataList(key_expr=object) # type: ignore + # Invalid key_expr syntax + with pytest.raises(SyntaxError): + DataList(key_expr="item.") + # Valid key_expr + l = DataList(key_expr=KEY_ITEM) + assert not l.frozen + assert l.key_expr == KEY_ITEM + assert l.type_spec is UNDEFINED + +def test_datalist_create_frozen(): + """Tests DataList initialization with frozen=True.""" + l = DataList(frozen=True) + assert l.frozen + +def test_datalist_create_all_args(data_items): + """Tests DataList initialization with items, type_spec, and key_expr.""" + l = DataList(data_items, Item, KEY_ITEM) + assert l == data_items + assert l.type_spec == Item + assert l.key_expr == KEY_ITEM + +# --- DataList Modification Tests --- + +def test_datalist_insert(data_items): + """Tests the insert method.""" + i1, i2, i3 = data_items[:3] + l = DataList() + l.insert(0, i1) + assert l == [i1] + l.insert(0, i2) + assert l == [i2, i1] + l.insert(1, i3) + assert l == [i2, i3, i1] + # Insert past end behaves like append + l.insert(5, i3) + assert l == [i2, i3, i1, i3] + +def test_datalist_insert_with_typespec(data_items, data_desc): + """Tests insert with type specification enforcement.""" + i1 = data_items[0] + l = DataList(type_spec=Item) + l.insert(0, i1) + # Should fail with wrong type + with pytest.raises(TypeError, match="Value is not an instance of allowed class"): + l.insert(0, data_desc[0]) + # Should succeed with correct type + l.insert(0, Item(0, "New Item")) + assert len(l) == 2 + +def test_datalist_insert_to_frozen(data_items): + """Tests that insert raises TypeError on a frozen list.""" + l = DataList(data_items) + l.freeze() + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): + l.insert(0, data_items[0]) + +def test_datalist_append(data_items): + """Tests the append method.""" + i1, i2 = data_items[:2] + l = DataList() + l.append(i1) + assert l == [i1] + l.append(i2) + assert l == [i1, i2] + +def test_datalist_append_with_typespec(data_items, data_desc): + """Tests append with type specification enforcement.""" + i1 = data_items[0] + l = DataList(type_spec=Item) + l.append(i1) + # Should fail with wrong type + with pytest.raises(TypeError, match="Value is not an instance of allowed class"): + l.append(data_desc[0]) + # Should succeed with correct type + l.append(Item(0, "New Item")) + assert len(l) == 2 + +def test_datalist_append_to_frozen(data_items): + """Tests that append raises TypeError on a frozen list.""" + l = DataList() + l.freeze() + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): + l.append(data_items[0]) + +def test_datalist_extend(data_items): + """Tests the extend method.""" + l = DataList() + l.extend(data_items[:5]) + assert l == data_items[:5] + l.extend(data_items[5:]) + assert l == data_items + +def test_datalist_extend_with_typespec(data_items, data_desc): + """Tests extend with type specification enforcement.""" + l = DataList(type_spec=Item) + l.extend(data_items) + assert l == data_items + # Should fail if any item in iterable is wrong type + with pytest.raises(TypeError, match="Value is not an instance of allowed class"): + l.extend([data_items[0], data_desc[0]]) # Only second item is wrong + +def test_datalist_extend_frozen(data_items): + """Tests that extend raises TypeError on a frozen list.""" + l = DataList() + l.freeze() + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): + l.extend(data_items) + +def test_datalist_list_access(data_items): + """Tests accessing items by index (__getitem__).""" + l = DataList(data_items) + assert l[2] is data_items[2] + assert l[-1] is data_items[-1] + with pytest.raises(IndexError): + l[len(data_items)] # Index out of range + +def test_datalist_list_update(data_items): + """Tests updating items by index (__setitem__).""" + i1 = data_items[0] + l = DataList(data_items) + original_item = l[3] + l[3] = i1 + assert l[3] is i1 + assert l[3] is not original_item + +def test_datalist_list_update_with_typespec(data_items, data_desc): + """Tests __setitem__ with type specification enforcement.""" + i1 = data_items[0] + l = DataList(data_items, type_spec=Item) + l[3] = i1 # OK + assert l[3] is i1 + # Should fail with wrong type + with pytest.raises(TypeError, match="Value is not an instance of allowed class"): + l[3] = data_desc[0] + +def test_datalist_list_update_frozen(data_items): + """Tests that __setitem__ raises TypeError on a frozen list.""" + i1 = data_items[0] + l = DataList(data_items) + l.freeze() + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): l[3] = i1 - self.assertEqual(l[3], i1) - # Frozen - with self.assertRaises(TypeError): - l.freeze() - l[3] = i1 - def test_list_delete(self): - i1, i2, i3 = self.data_items[:3] - l = DataList(self.data_items[:3]) - # + +def test_datalist_list_delete(data_items): + """Tests deleting items by index (__delitem__).""" + i1, i2, i3 = data_items[:3] + l = DataList(data_items[:3]) + del l[1] # Delete i2 + assert l == [i1, i3] + +def test_datalist_list_delete_frozen(data_items): + """Tests that __delitem__ raises TypeError on a frozen list.""" + l = DataList(data_items) + l.freeze() + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): del l[1] - self.assertListEqual(l, [i1, i3]) - # Frozen - with self.assertRaises(TypeError): - l.freeze() - del l[1] - def test_remove(self): - i1, i2, i3 = self.data_items[:3] - l = DataList(self.data_items[:3]) - # + +def test_datalist_remove(data_items): + """Tests the remove method.""" + i1, i2, i3 = data_items[:3] + l = DataList(data_items[:3]) + l.remove(i2) # Remove by value + assert l == [i1, i3] + # Test removing item not present + with pytest.raises(ValueError): l.remove(i2) - self.assertListEqual(l, [i1, i3]) - # Frozen - with self.assertRaises(TypeError): - l.freeze() - l.remove(i1) - def test_slice(self): - i1 = self.data_items[0] - expect = self.data_items.copy() - expect[5:6] = [i1] - l = DataList(self.data_items) - # Slice read - self.assertListEqual(l[:], self.data_items[:]) - self.assertListEqual(l[:1],self.data_items[:1]) - self.assertListEqual(l[1:], self.data_items[1:]) - self.assertListEqual(l[2:2], self.data_items[2:2]) - self.assertListEqual(l[2:3], self.data_items[2:3]) - self.assertListEqual(l[2:4], self.data_items[2:4]) - self.assertListEqual(l[-1:], self.data_items[-1:]) - self.assertListEqual(l[:-1], self.data_items[:-1]) - # Slice set - l[5:6] = [i1] - self.assertListEqual(l, expect) - # With type_spec - l = DataList(self.data_items, Item) - with self.assertRaises(TypeError): - l[5:6] = [self.data_desc[0]] - l[5:6] = [i1] - self.assertListEqual(l, expect) - # Slice remove - l = DataList(self.data_items) - del l[:] - self.assertListEqual(l, []) - # Frozen - l = DataList(self.data_items) - with self.assertRaises(TypeError): - l.freeze() - del l[:] - def test_sort(self): - i1, i2, i3 = self.data_items[:3] - unsorted = [i3, i1, i2] - l = DataList(unsorted) - # Simple - with self.assertRaises(TypeError): - l.sort() - if __debug__: - with self.assertRaises(AssertionError): - l.sort(attrs= 'key') - l.sort(attrs=['key']) - self.assertListEqual(l, [i1, i2, i3]) - l.sort(attrs=['key'], reverse=True) - self.assertListEqual(l, [i3, i2, i1]) - - l = DataList(unsorted) - l.sort(expr=lambda x: x.key) - self.assertListEqual(l, [i1, i2, i3]) - l.sort(expr=lambda x: x.key, reverse=True) - self.assertListEqual(l, [i3, i2, i1]) - - l = DataList(unsorted) - l.sort(expr='item.key') - self.assertListEqual(l, [i1, i2, i3]) - l.sort(expr='item.key', reverse=True) - self.assertListEqual(l, [i3, i2, i1]) - # With key expr - l = DataList(unsorted, key_expr=self.key_item) - l.sort() - self.assertListEqual(l, [i1, i2, i3]) - l.sort(reverse=True) - self.assertListEqual(l, [i3, i2, i1]) - def test_reverse(self): - revers = list(reversed(self.data_items)) - l = DataList(self.data_items) - # - l.reverse() - self.assertListEqual(l, revers) - def test_clear(self): - l = DataList(self.data_items) - # + +def test_datalist_remove_frozen(data_items): + """Tests that remove raises TypeError on a frozen list.""" + l = DataList(data_items) + l.freeze() + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): + l.remove(data_items[0]) + +def test_datalist_slice_read(data_items): + """Tests reading slices.""" + l = DataList(data_items) + assert l[:] == data_items[:] + assert l[:3] == data_items[:3] + assert l[5:] == data_items[5:] + assert l[2:5] == data_items[2:5] + assert l[-3:] == data_items[-3:] + assert l[:-2] == data_items[:-2] + assert l[::2] == data_items[::2] + assert l[::-1] == data_items[::-1] + +def test_datalist_slice_set(data_items): + """Tests setting slices (__setitem__ with slice).""" + i1 = data_items[0] + l = DataList(data_items) + original_len = len(l) + # Replace slice + l[2:5] = [i1, i1] + assert len(l) == original_len - 1 # 3 removed, 2 added + assert l[2] is i1 + assert l[3] is i1 + # Insert slice + l[1:1] = [data_items[5], data_items[6]] + assert len(l) == original_len + 1 # 2 added + assert l[1] is data_items[5] + assert l[2] is data_items[6] + +def test_datalist_slice_set_with_typespec(data_items, data_desc): + """Tests __setitem__ with slice and type checking.""" + i1 = data_items[0] + l = DataList(data_items, Item) + # OK + l[2:4] = [i1] + assert l[2] is i1 + # Fail with wrong type in iterable + with pytest.raises(TypeError, match="Value is not an instance of allowed class"): + l[5:6] = [data_desc[0]] + +def test_datalist_slice_set_frozen(data_items): + """Tests that setting a slice raises TypeError on a frozen list.""" + l = DataList(data_items) + l.freeze() + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): + l[1:3] = [data_items[0]] + +def test_datalist_slice_delete(data_items): + """Tests deleting slices (__delitem__ with slice).""" + l = DataList(data_items) + expected = data_items[:2] + data_items[5:] + del l[2:5] + assert l == expected + +def test_datalist_slice_delete_frozen(data_items): + """Tests that deleting a slice raises TypeError on a frozen list.""" + l = DataList(data_items) + l.freeze() + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): + del l[2:5] + +def test_datalist_sort(data_items): + """Tests the sort method with various key types.""" + i1, i2, i3 = data_items[:3] + # Unsortable items without a key + class Unsortable: pass + l_unsortable = DataList([Unsortable(), Unsortable()]) + with pytest.raises(TypeError): + l_unsortable.sort() # Should fail + + unsorted = [i3, i1, i2] + l = DataList(unsorted) + + # Sort by attributes + l.sort(attrs=["key"]) + assert l == [i1, i2, i3] + l.sort(attrs=["key"], reverse=True) + assert l == [i3, i2, i1] + + # Sort by lambda expression + l = DataList(unsorted) # Reset + l.sort(expr=lambda x: x.key) + assert l == [i1, i2, i3] + l.sort(expr=lambda x: x.key, reverse=True) + assert l == [i3, i2, i1] + + # Sort by string expression + l = DataList(unsorted) # Reset + l.sort(expr="item.key") + assert l == [i1, i2, i3] + l.sort(expr="item.key", reverse=True) + assert l == [i3, i2, i1] + + # Sort using default key expression + l = DataList(unsorted, key_expr=KEY_ITEM) + l.sort() + assert l == [i1, i2, i3] + l.sort(reverse=True) + assert l == [i3, i2, i1] + +def test_datalist_reverse(data_items): + """Tests the reverse method.""" + revers = list(reversed(data_items)) + l = DataList(data_items) + l.reverse() + assert l == revers + +def test_datalist_clear(data_items): + """Tests the clear method.""" + l = DataList(data_items) + l.clear() + assert l == [] + +def test_datalist_clear_frozen(data_items): + """Tests that clear raises TypeError on a frozen list.""" + l = DataList(data_items) + l.freeze() + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): l.clear() - self.assertListEqual(l, []) - def test_freeze(self): - l = DataList(self.data_items) - # - l.freeze() - self.assertTrue(l.frozen) - with self.assertRaises(TypeError): - l[0] = self.data_items[0] - def test_filter(self): - l = DataList(self.data_items) - # - result = l.filter(lambda x: x.key > 5) - self.assertIsInstance(result, GeneratorType) - self.assertListEqual(list(result), self.data_items[5:]) - # - result = l.filter('item.key > 5') - self.assertListEqual(list(result), self.data_items[5:]) - def test_filterfalse(self): - l = DataList(self.data_items) - # - result = l.filterfalse(lambda x: x.key > 5) - self.assertIsInstance(result, GeneratorType) - self.assertListEqual(list(result), self.data_items[:5]) - # - result = l.filterfalse('item.key > 5') - self.assertListEqual(list(result), self.data_items[:5]) - def test_report(self): - l = DataList(self.data_desc[:2]) - expect = [(1, 'Item 01', "This is item 'Item 01'"), - (2, 'Item 02', "This is item 'Item 02'")] - # - rpt = l.report(lambda x: (x.key, x.item.name, x.description)) - self.assertIsInstance(rpt, GeneratorType) - self.assertListEqual(list(rpt), expect) - # - rpt = list(l.report('item.key', 'item.item.name', 'item.description')) - self.assertListEqual(rpt, expect) - def test_occurrence(self): - l = DataList(self.data_items) - expect = sum(1 for x in l if x.key > 5) - # - result = l.occurrence(lambda x: x.key > 5) - self.assertIsInstance(result, int) - self.assertEqual(result, expect) - # - result = l.occurrence('item.key > 5') - self.assertEqual(result, expect) - def test_split(self): - exp_left = [x for x in self.data_items if x.key > 5] - exp_right = [x for x in self.data_items if not x.key > 5] - l = DataList(self.data_items) - # - res_left, res_right = l.split(lambda x: x.key > 5) - self.assertIsInstance(res_left, DataList) - self.assertIsInstance(res_right, DataList) - self.assertListEqual(res_left, exp_left) - self.assertListEqual(res_right, exp_right) - self.assertEqual(len(res_left) + len(res_right), len(l)) - # - res_left, res_right = l.split('item.key > 5') - self.assertIsInstance(res_left, DataList) - self.assertIsInstance(res_right, DataList) - self.assertListEqual(res_left, exp_left) - self.assertListEqual(res_right, exp_right) - self.assertEqual(len(res_left) + len(res_right), len(l)) - def test_extract(self): - exp_return = [x for x in self.data_items if x.key > 5] - exp_remains = [x for x in self.data_items if not x.key > 5] - l = DataList(self.data_items) - # - result = l.extract(lambda x: x.key > 5) - self.assertIsInstance(result, DataList) - self.assertListEqual(result, exp_return) - self.assertListEqual(l, exp_remains) - self.assertEqual(len(result) + len(l), len(self.data_items)) - # - l = DataList(self.data_items) - result = l.extract('item.key > 5') - self.assertListEqual(result, exp_return) - self.assertListEqual(l, exp_remains) - self.assertEqual(len(result) + len(l), len(self.data_items)) - # frozen - with self.assertRaises(TypeError): - l.freeze() - l.extract('item.key > 5') - def test_get(self): - i5 = self.data_items[4] - # Simple - l = DataList(self.data_items) - with self.assertRaises(Error): - l.get(i5.key) - # Distinct type - l = DataList(self.data_items, type_spec=Item) - self.assertEqual(l.get(i5.key), i5) - self.assertIsNone(l.get('NOT IN LIST')) - self.assertEqual(l.get('NOT IN LIST', 'DEFAULT'), 'DEFAULT') - # Key spec - l = DataList(self.data_items, key_expr=self.key_item) - self.assertEqual(l.get(i5.key), i5) - self.assertIsNone(l.get('NOT IN LIST')) - self.assertEqual(l.get('NOT IN LIST', 'DEFAULT'), 'DEFAULT') - # Frozen (fast-path) - # with Distinct - l = DataList(self.data_items, type_spec=Item, frozen=True) - self.assertEqual(l.get(i5.key), i5) - self.assertIsNone(l.get('NOT IN LIST')) - self.assertEqual(l.get('NOT IN LIST', 'DEFAULT'), 'DEFAULT') - # with key_expr - l = DataList(self.data_items, key_expr='item.key', frozen=True) - self.assertEqual(l.get(i5.key), i5) - self.assertIsNone(l.get('NOT IN LIST')) - self.assertEqual(l.get('NOT IN LIST', 'DEFAULT'), 'DEFAULT') - def test_find(self): - i5 = self.data_items[4] - l = DataList(self.data_items) - result = l.find(lambda x: x.key >= 5) - self.assertIsInstance(result, Item) - self.assertEqual(result, i5) - self.assertIsNone(l.find(lambda x: x.key > 100)) - self.assertEqual(l.find(lambda x: x.key > 100, 'DEFAULT'), 'DEFAULT') - - self.assertEqual(l.find('item.key >= 5'), i5) - self.assertIsNone(l.find('item.key > 100')) - self.assertEqual(l.find('item.key > 100', 'DEFAULT'), 'DEFAULT') - def test_contains(self): - # Simple - l = DataList(self.data_items) - self.assertTrue(l.contains('item.key >= 5')) - self.assertTrue(l.contains(lambda x: x.key >= 5)) - self.assertFalse(l.contains('item.key > 100')) - self.assertFalse(l.contains(lambda x: x.key > 100)) - def test_in(self): - # Simple - l = DataList(self.data_items) - self.assertTrue(self.data_items[0] in l) - self.assertTrue(self.data_items[-1] in l) - # Frozen - l.freeze() - self.assertTrue(self.data_items[0] in l) - self.assertTrue(self.data_items[-1] in l) - # Typed - l = DataList(self.data_items, Item) - self.assertTrue(self.data_items[0] in l) - self.assertTrue(self.data_items[-1] in l) - # Frozen - l.freeze() - self.assertTrue(self.data_items[0] in l) - self.assertTrue(self.data_items[-1] in l) - # Keyed - l = DataList(self.data_items, key_expr='item.key') - self.assertTrue(self.data_items[0] in l) - self.assertTrue(self.data_items[-1] in l) - # Frozen - l.freeze() - self.assertTrue(self.data_items[0] in l) - self.assertTrue(self.data_items[-1] in l) - nil = Item(100, "NOT IN LISTS") - i5 = self.data_items[4] - # Simple - l = DataList(self.data_items) - self.assertIn(i5, l) - self.assertNotIn(nil, l) - # Frozen distincts - l = DataList(self.data_items, type_spec=Item, frozen=True) - self.assertIn(i5, l) - self.assertNotIn(nil, l) - # Frozen key_expr - l = DataList(self.data_items, key_expr=self.key_item, frozen=True) - self.assertIn(i5, l) - self.assertNotIn(nil, l) - def test_all(self): - l = DataList(self.data_items) - self.assertTrue(l.all(lambda x: x.name.startswith('Item'))) - self.assertFalse(l.all(lambda x: '1' in x.name)) - self.assertTrue(l.all("item.name.startswith('Item')")) - self.assertFalse(l.all("'1' in item.name")) - def test_any(self): - l = DataList(self.data_items) - self.assertTrue(l.any(lambda x: '05' in x.name)) - self.assertFalse(l.any(lambda x: x.name.startswith('XXX'))) - self.assertTrue(l.any("'05' in item.name")) - self.assertFalse(l.any("item.name.startswith('XXX')")) - -class TestRegistry(unittest.TestCase): - """Unit tests for firebird.base.collection.Registry""" - def setUp(self): - self.data_items = [Item(1, 'Item 01'), Item(2, 'Item 02'), Item(3, 'Item 03'), - Item(4, 'Item 04'), Item(5, 'Item 05'), Item(6, 'Item 06'), - Item(7, 'Item 07'), Item(8, 'Item 08'), Item(9, 'Item 09'), - Item(10, 'Item 10')] - self.data_desc = [Desc(item.key, item, "This is item '%s'" % item.name) for item - in self.data_items] - self.key_item = 'item.key' - self.key_spec = 'item.key' - self.dict_items = dict((i.key, i) for i in self.data_items) - self.dict_desc = dict((i.key, i) for i in self.data_desc) - def tearDown(self): - pass - def test_create(self): - r = Registry() - # Simple - self.assertDictEqual(r._reg, {}) - # From items - with self.assertRaises(TypeError): - Registry(object) - r = Registry(self.data_items) - self.assertSequenceEqual(r._reg.keys(), self.dict_items.keys()) - self.assertListEqual(list(r._reg.values()), list(self.dict_items.values())) - def test_store(self): - i1 = self.data_items[0] - d2 = self.data_desc[1] - r = Registry() - r.store(i1) - self.assertDictEqual(r._reg, {i1.key: i1}) - r.store(d2) - self.assertDictEqual(r._reg, {i1.key: i1, d2.key: d2,}) - with self.assertRaises(ValueError): - r.store(i1) - def test_len(self): - r = Registry(self.data_items) - self.assertEqual(len(r), len(self.data_items)) - def test_dict_access(self): - i5 = self.data_items[4] - r = Registry(self.data_items) - self.assertEqual(r[i5], i5) - self.assertEqual(r[i5.key], i5) - with self.assertRaises(KeyError): - r['NOT IN REGISTRY'] - def test_dict_update(self): - i1 = self.data_items[0] - d1 = self.data_desc[0] - r = Registry(self.data_items) - self.assertEqual(r[i1.key], i1) - r[i1] = d1 - self.assertEqual(r[i1.key], d1) - def test_dict_delete(self): - i1 = self.data_items[0] - r = Registry(self.data_items) - self.assertIn(i1, r) - del r[i1] - self.assertNotIn(i1, r) - r.store(i1) - self.assertIn(i1, r) - del r[i1.key] - self.assertNotIn(i1, r) - def test_dict_iter(self): - r = Registry(self.data_items) - self.assertListEqual(list(r), list(self.dict_items.values())) - def test_remove(self): - i1 = self.data_items[0] - r = Registry(self.data_items) - self.assertIn(i1, r) + +def test_datalist_freeze(data_items): + """Tests the freeze method and its effects.""" + l = DataList(data_items, type_spec=Item) # Need key_expr for map + assert not l.frozen + assert l._DataList__map is None # Check internal map state + + l.freeze() + assert l.frozen + assert isinstance(l._DataList__map, dict) # Map should be created + assert len(l._DataList__map) == len(data_items) + + # Verify write protection + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): + l[0] = data_items[0] + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): + l.append(data_items[0]) + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): + del l[0] + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): + l.clear() + +# --- BaseObjectCollection Method Tests (via DataList) --- + +def test_datalist_filter(data_items): + """Tests the filter method (inherited from BaseObjectCollection).""" + l = DataList(data_items) + # Filter with lambda + result_lambda = l.filter(lambda x: x.key > 5) + assert isinstance(result_lambda, GeneratorType) + assert list(result_lambda) == data_items[5:] + # Filter with string expression + result_str = l.filter("item.key > 5") + assert isinstance(result_str, GeneratorType) + assert list(result_str) == data_items[5:] + +def test_datalist_filterfalse(data_items): + """Tests the filterfalse method (inherited from BaseObjectCollection).""" + l = DataList(data_items) + # Filterfalse with lambda + result_lambda = l.filterfalse(lambda x: x.key > 5) + assert isinstance(result_lambda, GeneratorType) + assert list(result_lambda) == data_items[:5] + # Filterfalse with string expression + result_str = l.filterfalse("item.key > 5") + assert isinstance(result_str, GeneratorType) + assert list(result_str) == data_items[:5] + +def test_datalist_report(data_desc): + """Tests the report method (inherited from BaseObjectCollection).""" + l = DataList(data_desc[:2]) + expect = [(1, "Item 01", "This is item 'Item 01'"), + (2, "Item 02", "This is item 'Item 02'")] + # Report with lambda + rpt_lambda = l.report(lambda x: (x.key, x.item.name, x.description)) + assert isinstance(rpt_lambda, GeneratorType) + assert list(rpt_lambda) == expect + # Report with string expressions + rpt_str = l.report("item.key", "item.item.name", "item.description") + assert isinstance(rpt_str, GeneratorType) + assert list(rpt_str) == expect + # Report with single string expression + rpt_single_str = l.report("item.key") + assert isinstance(rpt_single_str, GeneratorType) + assert list(rpt_single_str) == [1, 2] + + +def test_datalist_occurrence(data_items): + """Tests the occurrence method (inherited from BaseObjectCollection).""" + l = DataList(data_items) + expect = 5 # Items with key > 5 + # Occurrence with lambda + result_lambda = l.occurrence(lambda x: x.key > 5) + assert isinstance(result_lambda, int) + assert result_lambda == expect + # Occurrence with string expression + result_str = l.occurrence("item.key > 5") + assert result_str == expect + +def test_datalist_split(data_items): + """Tests the split method.""" + exp_true = [x for x in data_items if x.key > 5] + exp_false = [x for x in data_items if not x.key > 5] + l = DataList(data_items, type_spec=Item, key_expr=KEY_ITEM) # Ensure spec/key propagate + + # Split with lambda + res_true_l, res_false_l = l.split(lambda x: x.key > 5) + assert isinstance(res_true_l, DataList) + assert isinstance(res_false_l, DataList) + assert res_true_l == exp_true + assert res_false_l == exp_false + assert res_true_l.key_expr == KEY_ITEM # Check propagation + assert res_false_l.type_spec == Item + + # Split with string expression + res_true_s, res_false_s = l.split("item.key > 5") + assert isinstance(res_true_s, DataList) + assert isinstance(res_false_s, DataList) + assert res_true_s == exp_true + assert res_false_s == exp_false + assert res_true_s.key_expr == KEY_ITEM + assert res_false_s.type_spec == Item + + # Split frozen + res_true_f, res_false_f = l.split("item.key > 5", frozen=True) + assert res_true_f.frozen + assert res_false_f.frozen + +def test_datalist_extract(data_items): + """Tests the extract method.""" + exp_extracted = [x for x in data_items if x.key > 5] + exp_remaining = [x for x in data_items if not x.key > 5] + original_len = len(data_items) + + # Extract with lambda (move) + l = DataList(data_items, type_spec=Item, key_expr=KEY_ITEM) # Ensure spec/key propagate + result_lambda = l.extract(lambda x: x.key > 5) + assert isinstance(result_lambda, DataList) + assert result_lambda == exp_extracted + assert l == exp_remaining + assert len(result_lambda) + len(l) == original_len + assert result_lambda.key_expr == KEY_ITEM # Check propagation + assert result_lambda.type_spec == Item + + # Extract with string expression (move) + l = DataList(data_items, type_spec=Item, key_expr=KEY_ITEM) # Reset + result_str = l.extract("item.key > 5") + assert isinstance(result_str, DataList) + assert result_str == exp_extracted + assert l == exp_remaining + assert len(result_str) + len(l) == original_len + assert result_str.key_expr == KEY_ITEM + assert result_str.type_spec == Item + +def test_datalist_extract_copy(data_items): + """Tests the extract method with copy=True.""" + exp_extracted = [x for x in data_items if x.key > 5] + original_len = len(data_items) + l = DataList(data_items, type_spec=Item, key_expr=KEY_ITEM) + + result = l.extract(lambda x: x.key > 5, copy=True) + assert isinstance(result, DataList) + assert result == exp_extracted + assert l == data_items # Original list unchanged + assert len(l) == original_len + assert result.key_expr == KEY_ITEM # Check propagation + assert result.type_spec == Item + + +def test_datalist_extract_from_frozen(data_items): + """Tests that extract (move) raises TypeError on a frozen list.""" + l = DataList(data_items) + l.freeze() + # Extract copy should work + extracted_copy = l.extract("item.key > 5", copy=True) + assert len(extracted_copy) > 0 + assert l == data_items # Original unchanged + # Extract move should fail + with pytest.raises(TypeError, match="Cannot modify frozen DataList"): + l.extract("item.key > 5") # copy=False is default + +def test_datalist_get(data_items): + """Tests the get method for retrieving items by key.""" + i5 = data_items[4] + # Without key_expr defined + l_nokey = DataList(data_items) + with pytest.raises(Error, match="Key expression required"): + l_nokey.get(i5.key) + + # With key_expr (unfrozen) + l_key = DataList(data_items, key_expr=KEY_ITEM) + assert l_key.get(i5.key) is i5 + assert l_key.get(999) is None # Not found + assert l_key.get(999, "DEFAULT") == "DEFAULT" # Not found with default + + # With key_expr (frozen) - uses fast path + l_key.freeze() + assert l_key.get(i5.key) is i5 + assert l_key.get(999) is None # Not found + assert l_key.get(999, "DEFAULT") == "DEFAULT" # Not found with default + + # With default key_expr via Distinct type_spec (frozen) + l_distinct = DataList(data_items, type_spec=Item, frozen=True) + assert l_distinct.get(i5.key) is i5 + assert l_distinct.get(999) is None + assert l_distinct.get(999, "DEFAULT") == "DEFAULT" + +def test_datalist_find(data_items): + """Tests the find method (inherited from BaseObjectCollection).""" + i5 = data_items[4] + l = DataList(data_items) + # Find with lambda + result_lambda = l.find(lambda x: x.key >= 5) + assert isinstance(result_lambda, Item) + assert result_lambda is i5 # Should be the first match + assert l.find(lambda x: x.key > 100) is None # Not found + assert l.find(lambda x: x.key > 100, "DEFAULT") == "DEFAULT" # Not found with default + + # Find with string expression + result_str = l.find("item.key >= 5") + assert result_str is i5 + assert l.find("item.key > 100") is None + assert l.find("item.key > 100", "DEFAULT") == "DEFAULT" + +def test_datalist_contains(data_items): + """Tests the contains method (inherited from BaseObjectCollection).""" + l = DataList(data_items) + # Contains with lambda + assert l.contains(lambda x: x.key == 5) + assert not l.contains(lambda x: x.key == 999) + # Contains with string expression + assert l.contains("item.key == 5") + assert not l.contains("item.key == 999") + +def test_datalist_in(data_items): + """Tests the `in` operator (__contains__) for DataList.""" + nil = Item(100, "NOT IN LISTS") + i5 = data_items[4] + + # Simple DataList (uses standard list __contains__) + l_simple = DataList(data_items) + assert i5 in l_simple + assert nil not in l_simple + + # Frozen distincts (uses fast map lookup via key) + l_frozen_distinct = DataList(data_items, type_spec=Item, frozen=True) + assert i5 in l_frozen_distinct # Looks up i5.get_key() in map + assert nil not in l_frozen_distinct + + # Frozen with key_expr (uses fast map lookup via key) + l_frozen_keyexpr = DataList(data_items, key_expr=KEY_ITEM, frozen=True) + assert i5 in l_frozen_keyexpr # Evaluates key_expr(i5) and looks up in map + assert nil not in l_frozen_keyexpr + +def test_datalist_all(data_items): + """Tests the all method (inherited from BaseObjectCollection).""" + l = DataList(data_items) + # All with lambda + assert l.all(lambda x: x.key > 0) + assert not l.all(lambda x: x.key < 5) + # All with string expression + assert l.all("item.key > 0") + assert not l.all("item.key < 5") + # Test on empty list + assert DataList().all("item.key > 0") # Should be True for empty list + +def test_datalist_any(data_items): + """Tests the any method (inherited from BaseObjectCollection).""" + l = DataList(data_items) + # Any with lambda + assert l.any(lambda x: x.key == 5) + assert not l.any(lambda x: x.key == 999) + # Any with string expression + assert l.any("item.key == 5") + assert not l.any("item.key == 999") + # Test on empty list + assert not DataList().any("item.key > 0") # Should be False for empty list + + +# --- Registry Tests --- + +def test_registry_create(data_items, dict_items): + """Tests Registry initialization.""" + # Empty + r_empty = Registry() + assert r_empty._reg == {} + + # From sequence of Distinct items + r_seq = Registry(data_items) + assert list(r_seq._reg.keys()) == list(dict_items.keys()) # Check keys + assert list(r_seq._reg.values()) == list(dict_items.values()) # Check values + + # From mapping (dict) of Distinct items + r_map = Registry(dict_items) + assert list(r_map._reg.keys()) == list(dict_items.keys()) + assert list(r_map._reg.values()) == list(dict_items.values()) + + # From another Registry + r_other = Registry(r_seq) + assert list(r_other._reg.keys()) == list(dict_items.keys()) + assert list(r_other._reg.values()) == list(dict_items.values()) + + # From non-iterable (should fail) + with pytest.raises(TypeError): + Registry(object()) # type: ignore + +def test_registry_store(data_items, data_desc): + """Tests the store method for adding new items.""" + i1 = data_items[0] + d2 = data_desc[1] + r = Registry() + + # Store new items + r.store(i1) + assert r._reg == {i1.key: i1} + r.store(d2) + assert r._reg == {i1.key: i1, d2.key: d2} + + # Store item with existing key (should fail) + i1_again = Item(i1.key, "Different Name") + with pytest.raises(ValueError, match=f"Item already registered, key: '{i1.key}'"): + r.store(i1_again) + + # Store non-Distinct item (should fail) + with pytest.raises(AssertionError, match="Item is not of type 'Distinct'"): + r.store(NonDistinctItem(99, "Fail")) # type: ignore + +def test_registry_len(data_items): + """Tests the __len__ method.""" + r = Registry(data_items) + assert len(r) == len(data_items) + r_empty = Registry() + assert len(r_empty) == 0 + +def test_registry_dict_access(data_items): + """Tests accessing items by key or Distinct instance (__getitem__).""" + i5 = data_items[4] + r = Registry(data_items) + # Access by Distinct instance + assert r[i5] is i5 + # Access by key + assert r[i5.key] is i5 + # Access non-existent key + with pytest.raises(KeyError): + r[999] + with pytest.raises(KeyError): + r[Item(999, "Not There")] + +def test_registry_dict_update(data_items, data_desc): + """Tests updating/adding items via __setitem__.""" + i1 = data_items[0] + d1 = data_desc[0] # Same key as i1 + d_new = data_desc[1] # New key + r = Registry(data_items) + + # Update existing item using Distinct instance as key + assert r[i1.key] is i1 + r[i1] = d1 # Replace item at key i1.key with d1 + assert r[i1.key] is d1 + assert len(r) == len(data_items) # Length should be unchanged + + # Update existing item using key + r[i1.key] = i1 # Change back + assert r[i1.key] is i1 + + # Add new item using key + r[d_new.key] = d_new + assert r[d_new.key] is d_new + assert len(r) == len(data_items) # Length increased if key was new + + # Add new item using Distinct instance as key + d_newer = data_desc[2] + r[d_newer] = d_newer + assert r[d_newer.key] is d_newer + + # Add non-Distinct item (should fail) + non_distinct = NonDistinctItem(99, "Fail") + with pytest.raises(AssertionError): + r[99] = non_distinct # type: ignore + +def test_registry_dict_delete(data_items): + """Tests deleting items by key or Distinct instance (__delitem__).""" + i1 = data_items[0] + i2_key = data_items[1].key + r = Registry(data_items) + original_len = len(r) + + # Delete by Distinct instance + assert i1 in r + del r[i1] + assert i1 not in r + assert len(r) == original_len - 1 + + # Delete by key + assert i2_key in r + del r[i2_key] + assert i2_key not in r + assert len(r) == original_len - 2 + + # Delete non-existent key + with pytest.raises(KeyError): + del r[999] + with pytest.raises(KeyError): + del r[Item(999, "Not There")] + + +def test_registry_dict_iter(data_items, dict_items): + """Tests iterating over the registry (should yield values).""" + r = Registry(data_items) + # Order isn't guaranteed, but content should match + assert set(r) == set(dict_items.values()) + assert len(list(r)) == len(data_items) + +def test_registry_remove(data_items): + """Tests the remove method (removes by Distinct instance).""" + i1 = data_items[0] + r = Registry(data_items) + assert i1 in r + r.remove(i1) + assert i1 not in r + # Test removing item not present (should raise KeyError via __delitem__) + with pytest.raises(KeyError): r.remove(i1) - self.assertNotIn(i1, r) - def test_in(self): - nil = Item(100, "NOT IN REGISTRY") - i1 = self.data_items[0] - r = Registry(self.data_items) - self.assertIn(i1, r) - self.assertIn(i1.key, r) - self.assertNotIn('NOT IN REGISTRY', r) - self.assertNotIn(nil, r) - def test_clear(self): - r = Registry(self.data_items) - r.clear() - self.assertListEqual(list(r), []) - self.assertEqual(len(r), 0) - def test_get(self): - i5 = self.data_items[4] - r = Registry(self.data_items) - self.assertEqual(r.get(i5), i5) - self.assertEqual(r.get(i5.key), i5) - self.assertIsNone(r.get('NOT IN REGISTRY')) - self.assertEqual(r.get('NOT IN REGISTRY', i5), i5) - def test_update(self): - i1 = self.data_items[0] - d1 = self.data_desc[0] - r = Registry(self.data_items) - # Single item - self.assertEqual(r[i1.key], i1) - r.update(d1) - self.assertEqual(r[i1.key], d1) - # From list - r = Registry(self.data_items) - r.update(self.data_desc) - self.assertListEqual(list(r), list(self.dict_desc.values())) - # From dict - r = Registry(self.data_items) - r.update(self.dict_desc) - self.assertListEqual(list(r), list(self.dict_desc.values())) - # From registry - r = Registry(self.data_items) - r_other = Registry(self.data_desc) - r.update(r_other) - self.assertListEqual(list(r), list(self.dict_desc.values())) - def test_extend(self): - i1 = self.data_items[0] - # Single item - r = Registry() - r.extend(i1) - self.assertListEqual(list(r), [i1]) - # From list - r = Registry(self.data_items[:5]) - r.extend(self.data_items[5:]) - self.assertListEqual(list(r), list(self.dict_items.values())) - # From dict - r = Registry() - r.extend(self.dict_items) - self.assertListEqual(list(r), list(self.dict_items.values())) - # From registry - r = Registry() - r_other = Registry(self.data_items) - r.extend(r_other) - self.assertListEqual(list(r), list(self.dict_items.values())) - def test_copy(self): - r = Registry(self.data_items) - r_other = r.copy() - self.assertListEqual(list(r_other), list(r)) - # Registry descendants - r = MyRegistry(self.data_items) - r_other = r.copy() - self.assertIsInstance(r_other, MyRegistry) - self.assertListEqual(list(r_other), list(r)) - def test_pop(self): - icopy = self.data_items.copy() - i5 = icopy.pop(4) - r = Registry(self.data_items) - result = r.pop(i5.key) - self.assertEqual(result, i5) - self.assertListEqual(list(r), icopy) - - self.assertIsNone(r.pop('NOT IN REGISTRY')) - self.assertListEqual(list(r), icopy) - - r = Registry(self.data_items) - result = r.pop(i5) - self.assertEqual(result, i5) - self.assertListEqual(list(r), icopy) - def test_popitem(self): - icopy = self.data_items.copy() - r = Registry(self.data_items) - self.assertListEqual(list(r), icopy) - # - last = icopy.pop() - result = r.popitem() - self.assertEqual(result, last) - self.assertListEqual(list(r), icopy) - - first = icopy.pop(0) - result = r.popitem(False) - self.assertEqual(result, first) - self.assertListEqual(list(r), icopy) - def test_filter(self): - r = Registry(self.data_items) - # - result = r.filter(lambda x: x.key > 5) - self.assertIsInstance(result, GeneratorType) - self.assertListEqual(list(result), self.data_items[5:]) - # - result = r.filter('item.key > 5') - self.assertListEqual(list(result), self.data_items[5:]) - def test_filterfalse(self): - r = Registry(self.data_items) - # - result = r.filterfalse(lambda x: x.key > 5) - self.assertIsInstance(result, GeneratorType) - self.assertListEqual(list(result), self.data_items[:5]) - # - result = r.filterfalse('item.key > 5') - self.assertListEqual(list(result), self.data_items[:5]) - def test_find(self): - i5 = self.data_items[4] - r = Registry(self.data_items) - result = r.find(lambda x: x.key >= 5) - self.assertIsInstance(result, Item) - self.assertEqual(result, i5) - self.assertIsNone(r.find(lambda x: x.key > 100)) - self.assertEqual(r.find(lambda x: x.key > 100, 'DEFAULT'), 'DEFAULT') - - self.assertEqual(r.find('item.key >= 5'), i5) - self.assertIsNone(r.find('item.key > 100')) - self.assertEqual(r.find('item.key > 100', 'DEFAULT'), 'DEFAULT') - def test_contains(self): - # Simple - r = Registry(self.data_items) - self.assertTrue(r.contains('item.key >= 5')) - self.assertTrue(r.contains(lambda x: x.key >= 5)) - self.assertFalse(r.contains('item.key > 100')) - self.assertFalse(r.contains(lambda x: x.key > 100)) - def test_report(self): - r = Registry(self.data_desc[:2]) - expect = [(1, 'Item 01', "This is item 'Item 01'"), - (2, 'Item 02', "This is item 'Item 02'")] - # - rpt = r.report(lambda x: (x.key, x.item.name, x.description)) - self.assertIsInstance(rpt, GeneratorType) - self.assertListEqual(list(rpt), expect) - # - rpt = list(r.report('item.key', 'item.item.name', 'item.description')) - self.assertListEqual(rpt, expect) - def test_occurrence(self): - r = Registry(self.data_items) - expect = sum(1 for x in r if x.key > 5) - # - result = r.occurrence(lambda x: x.key > 5) - self.assertIsInstance(result, int) - self.assertEqual(result, expect) - # - result = r.occurrence('item.key > 5') - self.assertEqual(result, expect) - def test_all(self): - r = Registry(self.data_items) - self.assertTrue(r.all(lambda x: x.name.startswith('Item'))) - self.assertFalse(r.all(lambda x: '1' in x.name)) - self.assertTrue(r.all("item.name.startswith('Item')")) - self.assertFalse(r.all("'1' in item.name")) - def test_any(self): - r = Registry(self.data_items) - self.assertTrue(r.any(lambda x: '05' in x.name)) - self.assertFalse(r.any(lambda x: x.name.startswith('XXX'))) - self.assertTrue(r.any("'05' in item.name")) - self.assertFalse(r.any("item.name.startswith('XXX')")) - def test_repr(self): - r = Registry(self.data_items) - self.assertEqual(repr(r), """Registry([Item(key=1, name='Item 01'), Item(key=2, name='Item 02'), Item(key=3, name='Item 03'), Item(key=4, name='Item 04'), Item(key=5, name='Item 05'), Item(key=6, name='Item 06'), Item(key=7, name='Item 07'), Item(key=8, name='Item 08'), Item(key=9, name='Item 09'), Item(key=10, name='Item 10')])""") - -if __name__=='__main__': - unittest.main() + +def test_registry_in(data_items): + """Tests the `in` operator (__contains__).""" + nil = Item(100, "NOT IN REGISTRY") + i1 = data_items[0] + r = Registry(data_items) + # Check by Distinct instance + assert i1 in r + assert nil not in r + # Check by key + assert i1.key in r + assert nil.key not in r + # Check by other type (should be False) + assert "random string" not in r + +def test_registry_clear(data_items): + """Tests the clear method.""" + r = Registry(data_items) + assert len(r) > 0 + r.clear() + assert len(r) == 0 + assert r._reg == {} + +def test_registry_get(data_items): + """Tests the get method.""" + i5 = data_items[4] + r = Registry(data_items) + # Get by Distinct instance + assert r.get(i5) is i5 + # Get by key + assert r.get(i5.key) is i5 + # Get non-existent key (no default) + assert r.get(999) is None + # Get non-existent key (with default) + assert r.get(999, "DEFAULT") == "DEFAULT" + # Get non-existent Distinct instance (with default) + assert r.get(Item(999, "Not There"), "DEFAULT") == "DEFAULT" + +def test_registry_update(data_items, data_desc, dict_items, dict_desc): + """Tests the update method with various sources.""" + i1 = data_items[0] + d1 = data_desc[0] # Same key as i1 + d_new = data_desc[1] # New key + + # Update with single Distinct instance (adds or replaces) + r = Registry(data_items) + assert r[i1.key] is i1 + r.update(d1) # Replace i1 with d1 + assert r[i1.key] is d1 + r.update(d_new) # Add d_new + assert r[d_new.key] is d_new + + # Update from sequence + r = Registry(data_items[:5]) + r.update(data_desc[5:]) # Add remaining items as Desc + assert len(r) == len(data_items) + assert isinstance(r[data_items[6].key], Desc) + + # Update from dict + r = Registry(data_items) + r.update(dict_desc) # Replace all items with Desc versions + assert len(r) == len(data_items) + assert isinstance(r[i1.key], Desc) + + # Update from registry + r = Registry(data_items) + r_other = Registry(data_desc) + r.update(r_other) # Replace all items with Desc versions + assert len(r) == len(data_items) + assert isinstance(r[i1.key], Desc) + + # Update with non-Distinct item (should fail) + non_distinct = NonDistinctItem(99, "Fail") + with pytest.raises(AssertionError): + r.update([non_distinct]) # type: ignore + + +def test_registry_extend(data_items, data_desc, dict_items): + """Tests the extend method (only adds new items).""" + i1 = data_items[0] + d1 = data_desc[0] # Same key as i1 + d_new = data_desc[1] # New key + + # Extend with single Distinct instance + r = Registry() + r.extend(i1) + assert r[i1.key] is i1 + # Extend with existing key (should fail) + with pytest.raises(ValueError, match=f"Item already registered, key: '{d1.key}'"): + r.extend(d1) + + # Extend from sequence + r = Registry(data_items[:5]) + with pytest.raises(ValueError, match="Item already registered"): + r.extend(data_items[3:7]) # Fails when trying to add items with keys 3, 4, 5 + # Let's try extending only with new items + r = Registry(data_items[:5]) + r.extend(data_items[5:]) # Add 6 through 10 + assert len(r) == len(data_items) + + # Extend from dict + r = Registry() + r.extend(dict_items) + assert len(r) == len(dict_items) + + # Extend from registry + r = Registry() + r_other = Registry(data_items) + r.extend(r_other) + assert len(r) == len(data_items) + + # Extend with non-Distinct item (should fail) + non_distinct = NonDistinctItem(99, "Fail") + with pytest.raises(AssertionError): + r.extend([non_distinct]) # type: ignore + +def test_registry_copy(data_items): + """Tests the copy method, including subclass and shallow copy behavior.""" + r = Registry(data_items) + r_copy = r.copy() + + # Check type and content + assert isinstance(r_copy, Registry) + assert not isinstance(r_copy, MyRegistry) # Ensure it's not the subclass + assert r_copy is not r # Different objects + assert r_copy._reg is not r._reg # Different underlying dicts + assert list(r_copy) == list(r) # Same values (references) + assert r_copy[data_items[0].key] is r[data_items[0].key] # Items are the same reference + + # Test shallow copy behavior + key_to_modify = data_items[0].key + r_copy[key_to_modify].name = "Modified Name" + assert r[key_to_modify].name == "Modified Name" # Original is affected + r[key_to_modify].mutable_list.append("Modified in Original") + assert "Modified in Original" in r_copy[key_to_modify].mutable_list # Copy is affected + + # Test copy for subclass + r_sub = MyRegistry(data_items) + r_sub_copy = r_sub.copy() + assert isinstance(r_sub_copy, MyRegistry) # Copy is instance of subclass + assert list(r_sub_copy) == list(r_sub) + +def test_registry_pop(data_items): + """Tests the pop method.""" + i5 = data_items[4] + r = Registry(data_items) + original_len = len(r) + + # Pop by key + popped_item = r.pop(i5.key) + assert popped_item is i5 + assert len(r) == original_len - 1 + assert i5.key not in r + + # Pop by Distinct instance + i1 = data_items[0] + popped_item_2 = r.pop(i1) + assert popped_item_2 is i1 + assert len(r) == original_len - 2 + assert i1 not in r + + # Pop non-existent key (with default) + assert r.pop(999, "DEFAULT") == "DEFAULT" + assert len(r) == original_len - 2 # Length unchanged + + # Pop non-existent key (without default - raises KeyError) + with pytest.raises(KeyError): + r.pop(999) + +def test_registry_popitem(data_items): + """Tests the popitem method.""" + r = Registry(data_items) + original_len = len(r) + popped_items = set() + + # Pop last (LIFO) until empty + for _ in range(original_len): + key, item = r._reg.popitem() # Use internal dict popitem LIFO behavior + r._reg[key] = item # Put back temporarily to simulate Registry.popitem + popped = r.popitem() # Default is last=True + assert isinstance(popped, Item) + popped_items.add(popped.key) + assert len(r) == original_len - len(popped_items) + + assert len(r) == 0 + assert popped_items == set(item.key for item in data_items) + + # Pop first (FIFO) until empty + r.update(data_items) # Refill registry + popped_items.clear() + # Need to know the insertion order for FIFO test, dicts preserve it >= 3.7 + ordered_keys = [item.key for item in data_items] + + for i in range(original_len): + popped = r.popitem(last=False) + assert popped.key == ordered_keys[i] # Check FIFO order + popped_items.add(popped.key) + assert len(r) == original_len - len(popped_items) + + assert len(r) == 0 + assert popped_items == set(item.key for item in data_items) + + # Popitem on empty registry + with pytest.raises(KeyError): + r.popitem() + with pytest.raises(KeyError): + r.popitem(last=False) + + +# --- BaseObjectCollection Method Tests (via Registry) --- +# These tests verify that filter, find etc. work on the *values* of the Registry + +def test_registry_filter(data_items): + """Tests the filter method for Registry.""" + r = Registry(data_items) + # Filter with lambda + result_lambda = r.filter(lambda item: item.key > 5) + assert isinstance(result_lambda, GeneratorType) + assert list(result_lambda) == data_items[5:] + # Filter with string expression + result_str = r.filter("item.key > 5") + assert isinstance(result_str, GeneratorType) + assert list(result_str) == data_items[5:] + +def test_registry_filterfalse(data_items): + """Tests the filterfalse method for Registry.""" + r = Registry(data_items) + # Filterfalse with lambda + result_lambda = r.filterfalse(lambda item: item.key > 5) + assert isinstance(result_lambda, GeneratorType) + assert list(result_lambda) == data_items[:5] + # Filterfalse with string expression + result_str = r.filterfalse("item.key > 5") + assert isinstance(result_str, GeneratorType) + assert list(result_str) == data_items[:5] + +def test_registry_find(data_items): + """Tests the find method for Registry.""" + # Need predictable order for find 'first' - use data_items directly + i5 = data_items[4] # Key=5 + i6 = data_items[5] # Key=6 + r = Registry(data_items) + + # Find with lambda + result_lambda = r.find(lambda item: item.key >= 5) + # Order isn't guaranteed by find on dict values, assert presence instead of exact item + assert isinstance(result_lambda, Item) + assert result_lambda.key >= 5 + # Find not found + assert r.find(lambda item: item.key > 100) is None + assert r.find(lambda item: item.key > 100, "DEFAULT") == "DEFAULT" # Not found with default + + # Find with string expression + result_str = r.find("item.key >= 5") + assert isinstance(result_str, Item) + assert result_str.key >= 5 + # Find not found + assert r.find("item.key > 100") is None + assert r.find("item.key > 100", "DEFAULT") == "DEFAULT" + +def test_registry_contains(data_items): + """Tests the contains method for Registry.""" + r = Registry(data_items) + # Contains with lambda + assert r.contains(lambda item: item.key == 5) + assert not r.contains(lambda item: item.key == 999) + # Contains with string expression + assert r.contains("item.key == 5") + assert not r.contains("item.key == 999") + +def test_registry_report(data_desc): + """Tests the report method for Registry.""" + r = Registry(data_desc[:2]) # Items with keys 1, 2 + expect = [(1, "Item 01", "This is item 'Item 01'"), + (2, "Item 02", "This is item 'Item 02'")] + # Report with lambda + rpt_lambda = r.report(lambda x: (x.key, x.item.name, x.description)) + assert isinstance(rpt_lambda, GeneratorType) + # Order isn't guaranteed, sort results for comparison + assert sorted(list(rpt_lambda)) == sorted(expect) + # Report with string expressions + rpt_str = r.report("item.key", "item.item.name", "item.description") + assert isinstance(rpt_str, GeneratorType) + assert sorted(list(rpt_str)) == sorted(expect) + # Report with single string expression + rpt_single_str = r.report("item.key") + assert isinstance(rpt_single_str, GeneratorType) + assert sorted(list(rpt_single_str)) == sorted([1, 2]) + + +def test_registry_occurrence(data_items): + """Tests the occurrence method for Registry.""" + r = Registry(data_items) + expect = 5 # Items with key > 5 + # Occurrence with lambda + result_lambda = r.occurrence(lambda item: item.key > 5) + assert isinstance(result_lambda, int) + assert result_lambda == expect + # Occurrence with string expression + result_str = r.occurrence("item.key > 5") + assert result_str == expect + +def test_registry_all(data_items): + """Tests the all method for Registry.""" + r = Registry(data_items) + # All with lambda + assert r.all(lambda item: item.key > 0) + assert not r.all(lambda item: item.key < 5) + # All with string expression + assert r.all("item.key > 0") + assert not r.all("item.key < 5") + # Test on empty registry + assert Registry().all("item.key > 0") # Should be True + +def test_registry_any(data_items): + """Tests the any method for Registry.""" + r = Registry(data_items) + # Any with lambda + assert r.any(lambda item: item.key == 5) + assert not r.any(lambda item: item.key == 999) + # Any with string expression + assert r.any("item.key == 5") + assert not r.any("item.key == 999") + # Test on empty registry + assert not Registry().any("item.key > 0") # Should be False + +def test_registry_repr(data_items): + """Tests the __repr__ method for Registry.""" + r = Registry(data_items[:2]) # Use fewer items for readability + # Representation depends on the order items are iterated from the dict + # We can check the basic format and the presence of items + repr_str = repr(r) + assert repr_str.startswith("Registry([") + assert repr_str.endswith("])") + assert repr(data_items[0]) in repr_str + assert repr(data_items[1]) in repr_str + assert ", " in repr_str # Separator between items diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index f6b10c3..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,3434 +0,0 @@ -# SPDX-FileCopyrightText: 2019-present The Firebird Projects -# -# SPDX-License-Identifier: MIT -# -# PROGRAM/MODULE: firebird-base -# FILE: test/test_config.py -# DESCRIPTION: Unit tests for firebird.base.config -# CREATED: 20.9.2019 -# -# The contents of this file are subject to the MIT License -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -# Copyright (c) 2019 Firebird Project (www.firebirdsql.org) -# All Rights Reserved. -# -# Contributor(s): Pavel Císař (original code) -# ______________________________________. - -"Firebird Base - Unit tests for firebird.base.config." - -from __future__ import annotations -from unittest import TestCase, mock, main as unittest_main -from uuid import UUID -from decimal import Decimal -import sys -import io -import os -import platform -from pathlib import Path -from enum import IntEnum, IntFlag, Flag, auto -from dataclasses import dataclass -from inspect import signature -from configparser import ConfigParser -from firebird.base.types import Error, ZMQAddress, MIME, PyExpr, PyCode, PyCallable -from firebird.base.strconv import convert_to_str -from firebird.base import config - -DEFAULT_S = 'DEFAULT' -PRESENT_S = 'present' -ABSENT_S = 'absent' -BAD_S = 'bad_value' -EMPTY_S = 'empty' - -class SimpleEnum(IntEnum): - "Enum for testing" - UNKNOWN = 0 - READY = 1 - RUNNING = 2 - WAITING = 3 - SUSPENDED = 4 - FINISHED = 5 - ABORTED = 6 - # Aliases - CREATED = 1 - BLOCKED = 3 - STOPPED = 4 - TERMINATED = 6 - -class SimpleIntFlag(IntFlag): - "Flag for testing" - ONE = auto() - TWO = auto() - THREE = auto() - FOUR = auto() - FIVE = auto() - -class SimpleFlag(Flag): - "Flag for testing" - ONE = auto() - TWO = auto() - THREE = auto() - FOUR = auto() - FIVE = auto() - -@dataclass -class SimpleDataclass: - name: str - priority: int = 1 - state: SimpleEnum = SimpleEnum.READY - -class ValueHolder: - "Simple values holding object" - -def foo_func(value: int) -> int: - ... - -def store_opt(d, o): - d[o.name] = o - -class BaseConfigTest(TestCase): - "Base class for firebird.base.config unit tests" - def setUp(self): - self.proto: config.ConfigProto = config.ConfigProto() - self.conf: ConfigParser = ConfigParser(interpolation=config.EnvExtendedInterpolation()) - def tearDown(self): - pass - def setConf(self, conf_str): - self.conf.read_string(conf_str % {'DEFAULT': DEFAULT_S, 'PRESENT': PRESENT_S, - 'ABSENT': ABSENT_S, 'BAD': BAD_S, 'EMPTY': EMPTY_S,}) - -class TestStrOption(BaseConfigTest): - "Unit tests for firebird.base.config.StrOption" - PRESENT_VAL = 'present_value\ncan be multiline' - DEFAULT_VAL = 'DEFAULT_value' - DEFAULT_OPT_VAL = 'DEFAULT' - NEW_VAL = 'new_value' - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -option_name = DEFAULT_value -[%(PRESENT)s] -option_name = present_value - can be multiline -[%(ABSENT)s] -[%(BAD)s] -option_name = -[VERTICALS] -option_name = - | def pp(value): - | print("Value:",value,file=output) - | - | for i in [1,2,3]: - | pp(i) -""") - def test_simple(self): - opt = config.StrOption('option_name', 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, str) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_formatted(), 'present_value\n can be multiline') - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - # Verticals - opt.load_config(self.conf, 'VERTICALS') - self.assertEqual(opt.get_as_str(), '\ndef pp(value):\n print("Value:",value,file=output)\n\nfor i in [1,2,3]:\n pp(i)') - def test_required(self): - opt = config.StrOption('option_name', 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, str) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.StrOption('option_name', 'description') - opt.load_config(self.conf, BAD_S) - self.assertEqual(opt.value, '') - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'str', not 'float'",)) - def test_default(self): - opt = config.StrOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, str) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.StrOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - proto_value = 'proto_value' - opt.set_value(proto_value) - self.proto.options['option_name'].as_string = proto_value - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_uint64 = 1000 - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: uint64',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.StrOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: str -;option_name = DEFAULT -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: str -option_name = Multiline - value -""" - opt.set_value("Multiline\nvalue") - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: str -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: str -option_name = - | def pp(value): - | print("Value:",value,file=output) - | - | for i in [1,2,3]: - | pp(i)""" - opt.set_value('\ndef pp(value):\n print("Value:",value,file=output)\n\nfor i in [1,2,3]:\n pp(i)') - self.assertEqual('\n'.join(x.rstrip() for x in opt.get_config().splitlines()), lines) - -class TestIntOption(BaseConfigTest): - "Unit tests for firebird.base.config.IntOption" - PRESENT_VAL = 500 - DEFAULT_VAL = 10 - DEFAULT_OPT_VAL = 3000 - NEW_VAL = 0 - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -option_name = 10 -[%(PRESENT)s] -option_name = 500 -[%(ABSENT)s] -[%(BAD)s] -option_name = bad_value -""") - def test_simple(self): - opt = config.IntOption('option_name', 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, int) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), '500') - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.IntOption('option_name', 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, int) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.IntOption('option_name', 'description') - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - self.assertEqual(cm.exception.args, ("invalid literal for int() with base 10: 'bad_value'",)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'int', not 'float'",)) - def test_default(self): - opt = config.IntOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, int) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.IntOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - proto_value = 800000 - opt.set_value(proto_value) - self.proto.options['option_name'].as_uint64 = proto_value - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_string = 'BAD VALUE' - with self.assertRaises(ValueError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ("invalid literal for int() with base 10: 'BAD VALUE'",)) - self.proto.options['option_name'].as_bytes = b'BAD VALUE' - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: bytes',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.IntOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: int -;option_name = 3000 -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: int -option_name = 500 -""" - opt.set_value(500) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: int -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class TestFloatOption(BaseConfigTest): - "Unit tests for firebird.base.config.FloatOption" - PRESENT_VAL = 500.0 - DEFAULT_VAL = 10.5 - DEFAULT_OPT_VAL = 3000.0 - NEW_VAL = 0.0 - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -option_name = 10.5 -[%(PRESENT)s] -option_name = 500 -[%(ABSENT)s] -[%(BAD)s] -option_name = bad_value -""") - def test_simple(self): - opt = config.FloatOption('option_name', 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, float) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), '500.0') - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.FloatOption('option_name', 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, float) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.FloatOption('option_name', 'description') - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - self.assertEqual(cm.exception.args, ("could not convert string to float: 'bad_value'",)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'float', not 'int'",)) - with self.assertRaises(TypeError) as cm: - opt.set_value(0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'float', not 'int'",)) - def test_default(self): - opt = config.FloatOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, float) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.FloatOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - proto_value = 800000.0 - opt.set_value(proto_value) - self.proto.options['option_name'].as_double = proto_value - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_string = 'BAD VALUE' - with self.assertRaises(ValueError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ("could not convert string to float: 'BAD VALUE'",)) - self.proto.options['option_name'].as_bytes = b'BAD VALUE' - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: bytes',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.FloatOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: float -;option_name = 3000.0 -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: float -option_name = 500.0 -""" - opt.set_value(500.0) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: float -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class TestDecimalOption(BaseConfigTest): - "Unit tests for firebird.base.config.DecimalOption" - PRESENT_VAL = Decimal('500.0') - DEFAULT_VAL = Decimal('10.5') - DEFAULT_OPT_VAL = Decimal('3000.0') - NEW_VAL = Decimal('0.0') - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -option_name = 10.5 -[%(PRESENT)s] -option_name = 500 -[%(ABSENT)s] -[%(BAD)s] -option_name = bad_value -""") - def test_simple(self): - opt = config.DecimalOption('option_name', 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, Decimal) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), '500') - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.DecimalOption('option_name', 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, Decimal) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.DecimalOption('option_name', 'description') - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - self.assertEqual(cm.exception.args, ("[]",)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'Decimal', not 'float'",)) - def test_default(self): - opt = config.DecimalOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, Decimal) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.DecimalOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - proto_value = Decimal('800000.0') - opt.set_value(proto_value) - self.proto.options['option_name'].as_string = str(proto_value) - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # - self.proto.options['option_name'].as_uint64 = 10 - opt.load_proto(self.proto) - self.assertEqual(opt.value, Decimal('10')) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_string = 'BAD VALUE' - with self.assertRaises(ValueError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ("[]",)) - self.proto.options['option_name'].as_float = 10.01 - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: float',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.DecimalOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: Decimal -;option_name = 3000.0 -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: Decimal -option_name = 500.120 -""" - opt.set_as_str('500.120') - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: Decimal -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class TestBoolOption(BaseConfigTest): - "Unit tests for firebird.base.config.BoolOption" - YES = True - NO = False - PRESENT_VAL = YES - DEFAULT_VAL = NO - DEFAULT_OPT_VAL = NO - NEW_VAL = YES - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -option_name = no -[%(PRESENT)s] -option_name = yes -[%(ABSENT)s] -[%(BAD)s] -option_name = bad_value -""") - def test_simple(self): - opt = config.BoolOption('option_name', 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, bool) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), 'True') - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.BoolOption('option_name', 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, bool) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.BoolOption('option_name', 'description') - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - self.assertEqual(cm.exception.args, ('Value is not a valid bool string constant',)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'bool', not 'float'",)) - with self.assertRaises(ValueError) as cm: - opt.set_as_str('nope') - self.assertEqual(cm.exception.args, ('Value is not a valid bool string constant',)) - def test_default(self): - opt = config.BoolOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, bool) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.BoolOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - proto_value = self.YES - opt.set_value(proto_value) - self.proto.options['option_name'].as_bool = proto_value - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_string = 'BAD VALUE' - with self.assertRaises(ValueError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Value is not a valid bool string constant',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.BoolOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: bool -;option_name = no -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: bool -option_name = yes -""" - opt.set_value(True) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: bool -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class TestEnumOption(BaseConfigTest): - "Unit tests for firebird.base.config.EnumOption" - DEFAULT_VAL = SimpleEnum.UNKNOWN - PRESENT_VAL = SimpleEnum.RUNNING - DEFAULT_OPT_VAL = SimpleEnum.READY - NEW_VAL = SimpleEnum.STOPPED - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -; Enum is defined by name -option_name = UNKNOWN -[%(PRESENT)s] -; case does not matter -option_name = RuNnInG -[%(ABSENT)s] -[%(BAD)s] -option_name = bad_value -[illegal] -option_name = 1000 -""") - def test_simple(self): - opt = config.EnumOption('option_name', SimpleEnum, 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, SimpleEnum) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - self.assertSequenceEqual(opt.allowed, SimpleEnum) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), 'RUNNING') - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.EnumOption('option_name', SimpleEnum, 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, SimpleEnum) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.EnumOption('option_name', SimpleEnum, 'description') - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - self.assertEqual(cm.exception.args, ("Illegal value 'bad_value' for enum type 'SimpleEnum'",)) - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, 'illegal') - self.assertEqual(cm.exception.args, ("Illegal value '1000' for enum type 'SimpleEnum'",)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'SimpleEnum', not 'float'",)) - def test_allowed_values(self): - opt = config.EnumOption('option_name', SimpleEnum, 'description', - allowed=[SimpleEnum.UNKNOWN, SimpleEnum.RUNNING]) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, SimpleEnum) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(self.NEW_VAL) - self.assertEqual(cm.exception.args, ("Value '4' not allowed",)) - def test_default(self): - opt = config.EnumOption('option_name', SimpleEnum, 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, SimpleEnum) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.EnumOption('option_name', SimpleEnum, 'description', default=self.DEFAULT_OPT_VAL) - proto_value = SimpleEnum.READY - opt.set_value(proto_value) - self.proto.options['option_name'].as_string = proto_value.name - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.proto.options['option_name'].as_string = 'READY' - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_uint32 = 1000 - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: uint32',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.EnumOption('option_name', SimpleEnum, 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: enum [unknown, ready, running, waiting, suspended, finished, aborted] -;option_name = ready -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: enum [unknown, ready, running, waiting, suspended, finished, aborted] -option_name = suspended -""" - # Although NEW_VAL is STOPPED, the printout is SUSPENDED because STOPPED is an alias - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: enum [unknown, ready, running, waiting, suspended, finished, aborted] -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - # Reduced option list - opt = config.EnumOption('option_name', SimpleEnum, 'description', - allowed=[SimpleEnum.UNKNOWN, SimpleEnum.RUNNING]) - lines = """; description -; Type: enum [unknown, running] -;option_name = -""" - self.assertEqual(opt.get_config(), lines) - -class TestFlagOption(BaseConfigTest): - "Unit tests for firebird.base.config.FlagOption" - DEFAULT_VAL = SimpleIntFlag.ONE - PRESENT_VAL = SimpleIntFlag.TWO | SimpleIntFlag.THREE - DEFAULT_OPT_VAL = SimpleIntFlag.THREE | SimpleIntFlag.FOUR - NEW_VAL = SimpleIntFlag.FIVE - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -; Flag is defined by name(s) -option_name = ONE -[%(PRESENT)s] -; case does not matter -option_name = TwO, tHrEe -[%(ABSENT)s] -[%(BAD)s] -option_name = bad_value -[illegal] -option_name = 1000 -""") - def test_simple(self): - opt = config.FlagOption('option_name', SimpleIntFlag, 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, SimpleIntFlag) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - self.assertSequenceEqual(opt.allowed, SimpleIntFlag) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), 'THREE | TWO' if sys.version_info.minor < 11 else 'TWO|THREE') - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.FlagOption('option_name', SimpleIntFlag, 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, SimpleIntFlag) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.FlagOption('option_name', SimpleIntFlag, 'description') - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - self.assertEqual(cm.exception.args, ("Illegal value 'bad_value' for flag option 'option_name'",)) - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, 'illegal') - self.assertEqual(cm.exception.args, ("Illegal value '1000' for flag option 'option_name'",)) - with self.assertRaises(TypeError) as cm: - opt.set_value(SimpleFlag.ONE) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'SimpleIntFlag', not 'SimpleFlag'",)) - with self.assertRaises(ValueError) as cm: - opt.set_as_str('one, two ,three, illegal,four') - self.assertEqual(cm.exception.args, ("Illegal value 'illegal' for flag option 'option_name'",)) - def test_allowed_values(self): - opt = config.FlagOption('option_name', SimpleIntFlag, 'description', - allowed=[SimpleIntFlag.ONE, SimpleIntFlag.TWO]) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, SimpleIntFlag) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(cm.exception.args, ("Illegal value 'three' for flag option 'option_name'",)) - self.assertIsNone(opt.value) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(self.NEW_VAL) - if sys.version_info.minor < 11: - exc_args = ("Illegal value 'SimpleIntFlag.FIVE' for flag option 'option_name'",) - else: - exc_args = ("Illegal value '16' for flag option 'option_name'",) - self.assertEqual(cm.exception.args, exc_args) - def test_default(self): - opt = config.FlagOption('option_name', SimpleIntFlag, 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, SimpleIntFlag) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.FlagOption('option_name', SimpleIntFlag, 'description', default=self.DEFAULT_OPT_VAL) - proto_value = SimpleIntFlag.FIVE - opt.set_value(proto_value) - self.proto.options['option_name'].as_uint64 = proto_value.value - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.proto.options['option_name'].as_string = 'five' - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_uint32 = 1000 - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: uint32',)) - self.proto.Clear() - self.proto.options['option_name'].as_uint64 = 1000 - # Python 3.11 changed how flag boundaries are checked, default is more benevolent - # see https://docs.python.org/3.11/library/enum.html#enum.FlagBoundary.KEEP - if int(platform.python_version_tuple()[1]) < 11: - with self.assertRaises(ValueError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ("Illegal value 'SimpleIntFlag.512|256|128|64|32|FOUR' for flag option 'option_name'",)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.FlagOption('option_name', SimpleIntFlag, 'description', default=self.DEFAULT_OPT_VAL) - if sys.version_info.minor < 11: - lines = """; description -; Type: flag [one, two, three, four, five] -;option_name = four | three -""" - else: - lines = """; description -; Type: flag [one, two, three, four, five] -;option_name = three|four -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: flag [one, two, three, four, five] -option_name = five -""" - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: flag [one, two, three, four, five] -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - # Reduced flag list - opt = config.FlagOption('option_name', SimpleIntFlag, 'description', - allowed=[SimpleIntFlag.ONE, SimpleIntFlag.FOUR]) - lines = """; description -; Type: flag [one, four] -;option_name = -""" - self.assertEqual(opt.get_config(), lines) - -class TestUUIDOption(BaseConfigTest): - "Unit tests for firebird.base.config.UUIDOption" - PRESENT_VAL = UUID('fbcdd0ac-de0d-11e9-9b5b-5404a6a1fd6e') - DEFAULT_VAL = UUID('e3a57070-de0d-11e9-9b5b-5404a6a1fd6e') - DEFAULT_OPT_VAL = UUID('ede5cc42-de0d-11e9-9b5b-5404a6a1fd6e') - NEW_VAL = UUID('92ef5c08-de0e-11e9-9b5b-5404a6a1fd6e') - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -option_name = e3a57070-de0d-11e9-9b5b-5404a6a1fd6e -[%(PRESENT)s] -; as hex -option_name = fbcdd0acde0d11e99b5b5404a6a1fd6e -[%(ABSENT)s] -[%(BAD)s] -option_name = BAD_UID -""") - def test_simple(self): - opt = config.UUIDOption('option_name', 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, UUID) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), 'fbcdd0acde0d11e99b5b5404a6a1fd6e') - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.UUIDOption('option_name', 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, UUID) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.UUIDOption('option_name', 'description') - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - self.assertEqual(cm.exception.args, ('badly formed hexadecimal UUID string',)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'UUID', not 'float'",)) - def test_default(self): - opt = config.UUIDOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, UUID) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.UUIDOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - proto_value = UUID('bcd80916-de0e-11e9-9b5b-5404a6a1fd6e') - opt.set_value(proto_value) - # as_bytes (default) - self.proto.options['option_name'].as_bytes = proto_value.bytes - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - # as_string - self.proto.Clear() - self.proto.options['option_name'].as_string = proto_value.hex - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - # - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_uint32 = 1000 - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: uint32',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.UUIDOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: UUID -;option_name = ede5cc42-de0d-11e9-9b5b-5404a6a1fd6e -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: UUID -option_name = 92ef5c08-de0e-11e9-9b5b-5404a6a1fd6e -""" - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: UUID -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class TestMIMEOption(BaseConfigTest): - "Unit tests for firebird.base.config.MIMEOption" - PRESENT_VAL = MIME('text/plain;charset=utf-8') - PRESENT_TYPE = 'text/plain' - PRESENT_PARS = {'charset': 'utf-8'} - DEFAULT_VAL = MIME('application/octet-stream') - DEFAULT_TYPE = 'application/octet-stream' - DEFAULT_PARS = {} - DEFAULT_OPT_VAL = MIME('text/plain;charset=win1250') - DEFAULT_OPT_TYPE = 'text/plain' - DEFAULT_OPT_PARS = {'charset': 'win1250'} - NEW_VAL = MIME('application/x.fb.proto;type=firebird.butler.fbsd.ErrorDescription') - NEW_TYPE = 'application/x.fb.proto' - NEW_PARS = {'type': 'firebird.butler.fbsd.ErrorDescription'} - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -option_name = application/octet-stream -[%(PRESENT)s] -option_name = text/plain;charset=utf-8 -[%(ABSENT)s] -[%(BAD)s] -option_name = wrong mime specification -[unsupported_mime_type] -option_name = model/vml -[bad_mime_parameters] -option_name = text/plain;charset/utf-8 -""") - def test_simple(self): - opt: config.MIMEOption = config.MIMEOption('option_name', 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, MIME) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.value, 'text/plain;charset=utf-8') - self.assertEqual(opt.get_as_str(), self.PRESENT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - self.assertEqual(opt.value.mime_type, self.PRESENT_TYPE) - self.assertDictEqual(opt.value.params, self.PRESENT_PARS) - self.assertEqual(opt.value.params.get('charset'), 'utf-8') - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - self.assertEqual(opt.value.mime_type, self.DEFAULT_TYPE) - self.assertDictEqual(opt.value.params, self.DEFAULT_PARS) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - self.assertEqual(opt.value.mime_type, self.DEFAULT_TYPE) - self.assertDictEqual(opt.value.params, self.DEFAULT_PARS) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - self.assertEqual(opt.value.mime_type, self.NEW_TYPE) - self.assertDictEqual(opt.value.params, self.NEW_PARS) - def test_required(self): - opt = config.MIMEOption('option_name', 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, MIME) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.value.mime_type, self.PRESENT_TYPE) - self.assertDictEqual(opt.value.params, self.PRESENT_PARS) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertEqual(opt.value.mime_type, self.DEFAULT_TYPE) - self.assertDictEqual(opt.value.params, self.DEFAULT_PARS) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertEqual(opt.value.mime_type, self.DEFAULT_TYPE) - self.assertDictEqual(opt.value.params, self.DEFAULT_PARS) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertEqual(opt.value.mime_type, self.NEW_TYPE) - self.assertDictEqual(opt.value.params, self.NEW_PARS) - def test_bad_value(self): - opt: config.MIMEOption = config.MIMEOption('option_name', 'description') - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - self.assertEqual(cm.exception.args, ("MIME type specification must be 'type/subtype[;param=value;...]'",)) - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, 'unsupported_mime_type') - self.assertEqual(cm.exception.args, ("MIME type 'model' not supported",)) - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, 'bad_mime_parameters') - self.assertEqual(cm.exception.args, ('Wrong specification of MIME type parameters',)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'MIME', not 'float'",)) - def test_default(self): - opt = config.MIMEOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, MIME) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(str(opt.default), str(self.DEFAULT_OPT_VAL)) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(str(opt.value), str(self.DEFAULT_OPT_VAL)) - self.assertIsInstance(opt.value, opt.datatype) - self.assertEqual(opt.value.mime_type, self.DEFAULT_OPT_TYPE) - self.assertDictEqual(opt.value.params, self.DEFAULT_OPT_PARS) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.get_as_str(), str(self.PRESENT_VAL)) - self.assertEqual(opt.value.mime_type, self.PRESENT_TYPE) - self.assertDictEqual(opt.value.params, self.PRESENT_PARS) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertEqual(opt.value.mime_type, self.DEFAULT_TYPE) - self.assertDictEqual(opt.value.params, self.DEFAULT_PARS) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertEqual(opt.value.mime_type, self.DEFAULT_TYPE) - self.assertDictEqual(opt.value.params, self.DEFAULT_PARS) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertEqual(opt.value.mime_type, self.NEW_TYPE) - self.assertDictEqual(opt.value.params, self.NEW_PARS) - def test_proto(self): - opt = config.MIMEOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - proto_value = self.NEW_VAL - opt.set_value(proto_value) - self.proto.options['option_name'].as_string = proto_value - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertEqual(opt.value.mime_type, self.NEW_TYPE) - self.assertDictEqual(opt.value.params, self.NEW_PARS) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_uint32 = 1000 - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: uint32',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.MIMEOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: MIME -;option_name = text/plain;charset=win1250 -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: MIME -option_name = application/x.fb.proto;type=firebird.butler.fbsd.ErrorDescription -""" - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: MIME -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class TestZMQAddressOption(BaseConfigTest): - "Unit tests for firebird.base.config.ZMQAddressOption" - PRESENT_VAL = ZMQAddress('ipc://@my-address') - DEFAULT_VAL = ZMQAddress('tcp://127.0.0.1:*') - DEFAULT_OPT_VAL = ZMQAddress('tcp://127.0.0.1:8001') - NEW_VAL = ZMQAddress('inproc://my-address') - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -option_name = tcp://127.0.0.1:* -[%(PRESENT)s] -option_name = ipc://@my-address -[%(ABSENT)s] -[%(BAD)s] -option_name = bad_value -""") - def test_simple(self): - opt = config.ZMQAddressOption('option_name', 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, ZMQAddress) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), 'ipc://@my-address') - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.ZMQAddressOption('option_name', 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, ZMQAddress) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.ZMQAddressOption('option_name', 'description') - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - self.assertEqual(cm.exception.args, ('Protocol specification required',)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'ZMQAddress', not 'float'",)) - def test_default(self): - opt = config.ZMQAddressOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, ZMQAddress) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.ZMQAddressOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - proto_value = ZMQAddress('inproc://proto-address') - opt.set_value(proto_value) - self.proto.options['option_name'].as_string = proto_value - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_string = 'BAD VALUE' - with self.assertRaises(ValueError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Protocol specification required',)) - self.proto.options['option_name'].as_uint64 = 1000 - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: uint64',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.ZMQAddressOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: ZMQAddress -;option_name = tcp://127.0.0.1:8001 -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: ZMQAddress -option_name = inproc://my-address -""" - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: ZMQAddress -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class TestListOption(BaseConfigTest): - "Unit tests for firebird.base.config.ListOption" - DEFAULT_VAL = ['DEFAULT_value'] - DEFAULT_PRINT = "DEFAULT_1, DEFAULT_2, DEFAULT_3" - PRESENT_VAL = ['present_value_1', 'present_value_2'] - PRESENT_AS_STR = 'present_value_1,present_value_2' - DEFAULT_OPT_VAL = ['DEFAULT_1', 'DEFAULT_2', 'DEFAULT_3'] - NEW_VAL = ['NEW'] - NEW_PRINT = 'NEW' - ITEM_TYPE = str - PROTO_VALUE = ['proto_value_1', 'proto_value_2'] - PROTO_VALUE_STR = 'proto_value_1,proto_value_2' - LONG_VAL = ['long' * 3, 'verylong' * 3, 'veryverylong' * 5] - BAD_MSG = None - def setUp(self): - super().setUp() - self.prepare() - x = (self.ITEM_TYPE, ) if isinstance(self.ITEM_TYPE, type) else self.ITEM_TYPE - self.TYPE_NAMES = ', '.join(t.__name__ for t in x) - def prepare(self): - x = '\n ' - self.LONG_PRINT = f"\n {x.join(self.LONG_VAL)}" - self.setConf("""[%(DEFAULT)s] -option_name = DEFAULT_value -[%(PRESENT)s] -option_name = - present_value_1 - present_value_2 -[%(ABSENT)s] -[%(BAD)s] -option_name = -""") - def test_simple(self): - opt = config.ListOption('option_name', self.ITEM_TYPE, 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, list) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), self.PRESENT_AS_STR) - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.ListOption('option_name', self.ITEM_TYPE, 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, list) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.ListOption('option_name', self.ITEM_TYPE, 'description') - if self.ITEM_TYPE is str: - opt.load_config(self.conf, BAD_S) - self.assertIsNone(opt.value) - else: - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - #print(f'{cm.exception.args}\n') - self.assertEqual(cm.exception.args, self.BAD_MSG) - self.assertIsNone(opt.value) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'list', not 'float'",)) - def test_default(self): - opt = config.ListOption('option_name', self.ITEM_TYPE, 'description', - default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, list) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.ListOption('option_name', self.ITEM_TYPE, 'description', - default=self.DEFAULT_OPT_VAL) - proto_value = self.PROTO_VALUE - opt.set_value(proto_value) - self.proto.options['option_name'].as_string = self.PROTO_VALUE_STR - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_uint32 = 1000 - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: uint32',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.ListOption('option_name', self.ITEM_TYPE, 'description', - default=self.DEFAULT_OPT_VAL) - lines = f"""; description -; Type: list [{self.TYPE_NAMES}] -;option_name = {self.DEFAULT_PRINT} -""" - self.assertEqual(opt.get_config(), lines) - lines = f"""; description -; Type: list [{self.TYPE_NAMES}] -option_name = {self.NEW_PRINT} -""" - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.get_config(), lines) - lines = f"""; description -; Type: list [{self.TYPE_NAMES}] -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - lines = f"""; description -; Type: list [{self.TYPE_NAMES}] -option_name = {self.LONG_PRINT} -""" - opt.set_value(self.LONG_VAL) - self.assertEqual(opt.get_config(), lines) - -class TestListOptionInt(TestListOption): - "Unit tests for firebird.base.config.ListOption with int items" - DEFAULT_VAL = [0] - PRESENT_VAL = [10, 20] - DEFAULT_OPT_VAL = [1, 2, 3] - NEW_VAL = [100] - - DEFAULT_PRINT = '1, 2, 3' - PRESENT_AS_STR = '10,20' - NEW_PRINT = '100' - ITEM_TYPE = int - PROTO_VALUE = [30, 40, 50] - PROTO_VALUE_STR = '30,40,50' - LONG_VAL = [x for x in range(50)] - def prepare(self): - x = '\n ' - self.LONG_PRINT = f"\n {x.join(str(x) for x in self.LONG_VAL)}" - self.BAD_MSG = ("invalid literal for int() with base 10: 'this is not an integer'",) - self.setConf("""[%(DEFAULT)s] -option_name = 0 -[%(PRESENT)s] -option_name = 10, 20 -[%(ABSENT)s] -[%(BAD)s] -option_name = this is not an integer -""") - -class TestListOptionFloat(TestListOption): - "Unit tests for firebird.base.config.ListOption with float items" - DEFAULT_VAL = [0.0] - PRESENT_VAL = [10.1, 20.2] - DEFAULT_OPT_VAL = [1.11, 2.22, 3.33] - NEW_VAL = [100.101] - - DEFAULT_PRINT = '1.11, 2.22, 3.33' - PRESENT_AS_STR = '10.1,20.2' - NEW_PRINT = '100.101' - ITEM_TYPE = float - PROTO_VALUE = [30.3, 40.4, 50.5] - PROTO_VALUE_STR = '30.3,40.4,50.5' - LONG_VAL = [x / 1.5 for x in range(50)] - def prepare(self): - x = '\n ' - self.LONG_PRINT = f"\n {x.join(str(x) for x in self.LONG_VAL)}" - self.BAD_MSG = ("could not convert string to float: 'this is not a float'",) - self.setConf("""[%(DEFAULT)s] -option_name = 0.0 -[%(PRESENT)s] -option_name = 10.1, 20.2 -[%(ABSENT)s] -[%(BAD)s] -option_name = this is not a float -""") - -class TestListOptionDecimal(TestListOption): - "Unit tests for firebird.base.config.ListOption with Decimal items" - DEFAULT_VAL = [Decimal('0.0')] - PRESENT_VAL = [Decimal('10.1'), Decimal('20.2')] - DEFAULT_OPT_VAL = [Decimal('1.11'), Decimal('2.22'), Decimal('3.33')] - NEW_VAL = [Decimal('100.101')] - - DEFAULT_PRINT = '1.11, 2.22, 3.33' - PRESENT_AS_STR = '10.1,20.2' - NEW_PRINT = '100.101' - ITEM_TYPE = Decimal - PROTO_VALUE = [Decimal('30.3'), Decimal('40.4'), Decimal('50.5')] - PROTO_VALUE_STR = '30.3,40.4,50.5' - LONG_VAL = [Decimal(str(x / 1.5)) for x in range(50)] - def prepare(self): - x = '\n ' - self.LONG_PRINT = f"\n {x.join(str(x) for x in self.LONG_VAL)}" - self.BAD_MSG = ("could not convert string to Decimal: 'this is not a decimal'",) - self.setConf("""[%(DEFAULT)s] -option_name = 0.0 -[%(PRESENT)s] -option_name = 10.1, 20.2 -[%(ABSENT)s] -[%(BAD)s] -option_name = this is not a decimal -""") - -class TestListOptionBool(TestListOption): - "Unit tests for firebird.base.config.ListOption with bool items" - DEFAULT_VAL = [0] - PRESENT_VAL = [True, False] - DEFAULT_OPT_VAL = [True, False, True] - NEW_VAL = [True] - - DEFAULT_PRINT = 'yes, no, yes' - PRESENT_AS_STR = 'yes,no' - NEW_PRINT = 'yes' - ITEM_TYPE = bool - PROTO_VALUE = [False, True, False] - PROTO_VALUE_STR = 'no,yes,no' - LONG_VAL = [bool(x % 2) for x in range(40)] - def prepare(self): - x = '\n ' - self.LONG_PRINT = f"\n {x.join(convert_to_str(x) for x in self.LONG_VAL)}" - self.BAD_MSG = ('Value is not a valid bool string constant',) - self.setConf("""[%(DEFAULT)s] -option_name = 0 -[%(PRESENT)s] -option_name = yes, no -[%(ABSENT)s] -[%(BAD)s] -option_name = this is not a bool -""") - -class TestListOptionUUID(TestListOption): - "Unit tests for firebird.base.config.ListOption with UUID items" - DEFAULT_VAL = [UUID('eeb7f94a-256d-11ea-ad1d-5404a6a1fd6e')] - PRESENT_VAL = [UUID('0a7fd53a-256e-11ea-ad1d-5404a6a1fd6e'), - UUID('0551feb2-256e-11ea-ad1d-5404a6a1fd6e')] - DEFAULT_OPT_VAL = [UUID('2f02868c-256e-11ea-ad1d-5404a6a1fd6e'), - UUID('3521db30-256e-11ea-ad1d-5404a6a1fd6e'), - UUID('3a3e68cc-256e-11ea-ad1d-5404a6a1fd6e')] - NEW_VAL = [UUID('3e8a4ce8-256e-11ea-ad1d-5404a6a1fd6e')] - - DEFAULT_PRINT = '\n; 2f02868c-256e-11ea-ad1d-5404a6a1fd6e\n; 3521db30-256e-11ea-ad1d-5404a6a1fd6e\n; 3a3e68cc-256e-11ea-ad1d-5404a6a1fd6e' - PRESENT_AS_STR = '0a7fd53a-256e-11ea-ad1d-5404a6a1fd6e,0551feb2-256e-11ea-ad1d-5404a6a1fd6e' - NEW_PRINT = '3e8a4ce8-256e-11ea-ad1d-5404a6a1fd6e' - ITEM_TYPE = UUID - PROTO_VALUE = [UUID('3a3e68cc-256e-11ea-ad1d-5404a6a1fd6e'), UUID('3521db30-256e-11ea-ad1d-5404a6a1fd6e')] - PROTO_VALUE_STR = '3a3e68cc-256e-11ea-ad1d-5404a6a1fd6e,3521db30-256e-11ea-ad1d-5404a6a1fd6e' - LONG_VAL = [UUID('2f02868c-256e-11ea-ad1d-5404a6a1fd6e') for x in range(10)] - def prepare(self): - x = '\n ' - self.LONG_PRINT = f"\n {x.join(str(x) for x in self.LONG_VAL)}" - self.BAD_MSG = ('badly formed hexadecimal UUID string',) - self.setConf("""[%(DEFAULT)s] -option_name = eeb7f94a-256d-11ea-ad1d-5404a6a1fd6e -[%(PRESENT)s] -option_name = 0a7fd53a256e11eaad1d5404a6a1fd6e, 0551feb2-256e-11ea-ad1d-5404a6a1fd6e -[%(ABSENT)s] -[%(BAD)s] -option_name = this is not an uuid -""") - -class TestListOptionMIME(TestListOption): - "Unit tests for firebird.base.config.ListOption with MIME items" - DEFAULT_VAL = [MIME('application/octet-stream')] - PRESENT_VAL = [MIME('text/plain;charset=utf-8'), - MIME('text/csv')] - DEFAULT_OPT_VAL = [MIME('text/html;charset=utf-8'), - MIME('video/mp4'), - MIME('image/png')] - NEW_VAL = [MIME('audio/mpeg')] - - DEFAULT_PRINT = 'text/html;charset=utf-8, video/mp4, image/png' - PRESENT_AS_STR = 'text/plain;charset=utf-8,text/csv' - NEW_PRINT = 'audio/mpeg' - ITEM_TYPE = MIME - PROTO_VALUE = [MIME('application/octet-stream'), MIME('video/mp4')] - PROTO_VALUE_STR = 'application/octet-stream,video/mp4' - LONG_VAL = [MIME('text/html;charset=win1250') for x in range(10)] - def prepare(self): - x = '\n ' - self.LONG_PRINT = f"\n {x.join(x for x in self.LONG_VAL)}" - self.BAD_MSG = ("MIME type specification must be 'type/subtype[;param=value;...]'",) - self.setConf("""[%(DEFAULT)s] -option_name = application/octet-stream -[%(PRESENT)s] -option_name = - text/plain;charset=utf-8 - text/csv -[%(ABSENT)s] -[%(BAD)s] -option_name = wrong mime specification -""") - -class TestListOptionZMQAddress(TestListOption): - "Unit tests for firebird.base.config.ListOption with ZMQAddress items" - DEFAULT_VAL = [ZMQAddress('tcp://127.0.0.1:*')] - PRESENT_VAL = [ZMQAddress('ipc://@my-address'), - ZMQAddress('inproc://my-address'), - ZMQAddress('tcp://127.0.0.1:9001')] - DEFAULT_OPT_VAL = [ZMQAddress('tcp://127.0.0.1:8001')] - NEW_VAL = [ZMQAddress('inproc://my-address')] - - DEFAULT_PRINT = 'tcp://127.0.0.1:8001' - PRESENT_AS_STR = 'ipc://@my-address,inproc://my-address,tcp://127.0.0.1:9001' - NEW_PRINT = 'inproc://my-address' - ITEM_TYPE = ZMQAddress - PROTO_VALUE = [ZMQAddress('tcp://www.firebirdsql.org:8001'), ZMQAddress('tcp://www.firebirdsql.org:9001')] - PROTO_VALUE_STR = 'tcp://www.firebirdsql.org:8001,tcp://www.firebirdsql.org:9001' - LONG_VAL = [ZMQAddress('tcp://www.firebirdsql.org:500') for x in range(10)] - def prepare(self): - x = '\n ' - self.LONG_PRINT = f"\n {x.join(x for x in self.LONG_VAL)}" - self.BAD_MSG = ('Protocol specification required',) - self.setConf("""[%(DEFAULT)s] -option_name = tcp://127.0.0.1:* -[%(PRESENT)s] -option_name = ipc://@my-address, inproc://my-address, tcp://127.0.0.1:9001 -[%(ABSENT)s] -[%(BAD)s] -option_name = bad_value -""") - -class TestListOptionMultiType(TestListOption): - "Unit tests for firebird.base.config.ListOption with items of various types" - DEFAULT_VAL = ['DEFAULT_value'] - PRESENT_VAL = [1, 1.1, Decimal('1.01'), True, - UUID('eeb7f94a-256d-11ea-ad1d-5404a6a1fd6e'), - MIME('application/octet-stream'), - ZMQAddress('tcp://127.0.0.1:*'), - SimpleEnum.RUNNING] - DEFAULT_OPT_VAL = ['DEFAULT_1', 1, False] - NEW_VAL = [MIME('text/plain;charset=utf-8')] - - DEFAULT_PRINT = 'DEFAULT_1, 1, no' - PRESENT_AS_STR = '1\n1.1\n1.01\nyes\neeb7f94a-256d-11ea-ad1d-5404a6a1fd6e\napplication/octet-stream\ntcp://127.0.0.1:*\nRUNNING' - NEW_PRINT = 'text/plain;charset=utf-8' - ITEM_TYPE = (str, int, float, Decimal, bool, UUID, MIME, ZMQAddress, SimpleEnum) - PROTO_VALUE = [UUID('2f02868c-256e-11ea-ad1d-5404a6a1fd6e'), MIME('application/octet-stream')] - PROTO_VALUE_STR = 'UUID:2f02868c-256e-11ea-ad1d-5404a6a1fd6e,MIME:application/octet-stream' - LONG_VAL = [ZMQAddress('tcp://www.firebirdsql.org:500'), - UUID('2f02868c-256e-11ea-ad1d-5404a6a1fd6e'), - MIME('application/octet-stream'), - '=' * 30, 1, True, 10.1, Decimal('20.20')] - def prepare(self): - x = '\n ' - self.LONG_PRINT = f"\n {x.join(convert_to_str(x) for x in self.LONG_VAL)}" - self.BAD_MSG = ("Item type 'bin' not supported",) - self.setConf("""[%(DEFAULT)s] -option_name = str:DEFAULT_value -[%(PRESENT)s] -option_name = - int: 1 - float: 1.1 - Decimal: 1.01 - bool: yes - UUID: eeb7f94a-256d-11ea-ad1d-5404a6a1fd6e - firebird.base.types.MIME: application/octet-stream - ZMQAddress: tcp://127.0.0.1:* - SimpleEnum:RUNNING -[%(ABSENT)s] -[%(BAD)s] -option_name = str:this is string, int:20, bin:100110111 -""") - -class TestPyExprOption(BaseConfigTest): - "Unit tests for firebird.base.config.PyExprOption" - PRESENT_VAL = PyExpr('this.value in [1, 2, 3]') - DEFAULT_VAL = PyExpr('this.value is None') - DEFAULT_OPT_VAL = PyExpr('DEFAULT') - NEW_VAL = PyExpr('this.value == "VALUE"') - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -option_name = this.value is None -[%(PRESENT)s] -option_name = this.value in [1, 2, 3] -[%(ABSENT)s] -[%(BAD)s] -option_name = This is not a valid Python expression -""") - def test_simple(self): - opt = config.PyExprOption('option_name', 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, PyExpr) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), 'this.value in [1, 2, 3]') - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - # Check expression code - obj = ValueHolder() - obj.value = "VALUE" - self.assertTrue(eval(opt.value, {'this': obj})) - fce = opt.value.get_callable('this') - self.assertTrue(fce(obj)) - obj.value = "OTHER VALUE" - self.assertFalse(eval(opt.value, {'this': obj})) - self.assertFalse(fce(obj)) - def test_required(self): - opt = config.PyExprOption('option_name', 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, PyExpr) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.PyExprOption('option_name', 'description') - with self.assertRaises(SyntaxError) as cm: - opt.load_config(self.conf, BAD_S) - if sys.version_info.minor < 10: - exc_args = ('PyExpr', 1, 15, 'This is not a valid Python expression') - else: - exc_args = ('PyExpr', 1, 15, 'This is not a valid Python expression', 1, 20) - self.assertEqual(cm.exception.args, ('invalid syntax', exc_args)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'PyExpr', not 'float'",)) - def test_default(self): - opt = config.PyExprOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, PyExpr) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.PyExprOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - proto_value = PyExpr('proto_value') - opt.set_value(proto_value) - self.proto.options['option_name'].as_string = proto_value - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_uint32 = 1000 - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: uint32',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.PyExprOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: PyExpr -;option_name = DEFAULT -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: PyExpr -option_name = this.value == "VALUE" -""" - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: PyExpr -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class TestPyCodeOption(BaseConfigTest): - "Unit tests for firebird.base.config.PyCodeOption" - DEFAULT_VAL = PyCode('print("Default value")') - PRESENT_VAL = PyCode('\ndef pp(value):\n print("Value:",value,file=output)\n\nfor i in [1,2,3]:\n pp(i)') - DEFAULT_OPT_VAL = PyCode('DEFAULT') - NEW_VAL = PyCode('print("NEW value")') - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -option_name = print("Default value") -[%(PRESENT)s] -option_name = - | def pp(value): - | print("Value:",value,file=output) - | - | for i in [1,2,3]: - | pp(i) -[%(ABSENT)s] -[%(BAD)s] -option_name = This is not a valid Python code block -""") - def test_simple(self): - opt = config.PyCodeOption('option_name', 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, PyCode) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), '\ndef pp(value):\n print("Value:",value,file=output)\n\nfor i in [1,2,3]:\n pp(i)') - self.assertIsInstance(opt.value, opt.datatype) - # Check expression code - out = io.StringIO() - exec(opt.value.code, {'output': out}) - self.assertEqual(out.getvalue(), 'Value: 1\nValue: 2\nValue: 3\n') - # - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.PyCodeOption('option_name', 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, PyCode) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.PyCodeOption('option_name', 'description') - with self.assertRaises(SyntaxError) as cm: - opt.load_config(self.conf, BAD_S) - if sys.version_info.minor < 10: - exc_args = ('PyCode', 1, 15, 'This is not a valid Python code block\n') - else: - exc_args = ('PyCode', 1, 15, 'This is not a valid Python code block\n', 1, 20) - self.assertEqual(cm.exception.args, ('invalid syntax', exc_args)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'PyCode', not 'float'",)) - def test_default(self): - opt = config.PyCodeOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, PyCode) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.PyCodeOption('option_name', 'description') - proto_value = PyCode('proto_value') - opt.set_value(proto_value) - self.proto.options['option_name'].as_string = proto_value - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - self.proto.Clear() - opt.clear() - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.PyCodeOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: PyCode -;option_name = DEFAULT -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: PyCode -option_name = - | def pp(value): - | print("Value:",value,file=output) - | - | for i in [1,2,3]: - | pp(i)""" - opt.set_value(self.PRESENT_VAL) - self.assertEqual('\n'.join(x.rstrip() for x in opt.get_config().splitlines()), lines) - lines = """; description -; Type: PyCode -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class TestPyCallableOption(BaseConfigTest): - "Unit tests for firebird.base.config.PyCallableOption" - DEFAULT_VAL = PyCallable('\ndef foo(value: int) -> int:\n return value * 2') - PRESENT_VAL = PyCallable('\ndef foo(value: int) -> int:\n return value * 5') - DEFAULT_OPT_VAL = PyCallable('\ndef foo(value: int) -> int:\n return value') - NEW_VAL = PyCallable('\ndef foo(value: int) -> int:\n return value * 3') - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -option_name = - | def foo(value: int) -> int: - | return value * 2 -[%(PRESENT)s] -option_name = - | def foo(value: int) -> int: - | return value * 5 -[%(ABSENT)s] -[%(BAD)s] -option_name = This is not a valid Python function/procedure definition -[bad_signature] -option_name = - | def bad_foo(value, value_2)->int: - | return value * value_2 -""") - def test_simple(self): - opt = config.PyCallableOption('option_name', 'description', signature=signature(foo_func)) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, PyCallable) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_as_str(), self.PRESENT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - self.assertEqual(opt.value.name, 'foo') - # Check expression code - self.assertEqual(opt.value(1), 5) - # - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.PyCallableOption('option_name', 'description', signature=signature(foo_func), - required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, PyCallable) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.PyCallableOption('option_name', 'description', signature=signature(foo_func)) - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - self.assertEqual(cm.exception.args, ('Python function or class definition not found',)) - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, 'bad_signature') - self.assertEqual(cm.exception.args, ('Wrong number of parameters',)) - with self.assertRaises(ValueError) as cm: - opt.set_as_str('\ndef foo(value: int) -> float:\n return value * 3') - self.assertEqual(cm.exception.args, ('Wrong callable return type',)) - with self.assertRaises(ValueError) as cm: - opt.set_as_str('\ndef foo(value: float) -> int:\n return value * 3') - self.assertEqual(cm.exception.args, ("Wrong type, parameter 'value'",)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'PyCallable', not 'float'",)) - def test_default(self): - opt = config.PyCallableOption('option_name', 'description', signature=signature(foo_func), - default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, PyCallable) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.PyCallableOption('option_name', 'description', signature=signature(foo_func), - default=self.DEFAULT_OPT_VAL) - proto_value = '\ndef foo(value: int) -> int:\n return value * 100' - opt.set_value(PyCallable(proto_value)) - self.proto.options['option_name'].as_string = proto_value - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_uint32 = 1000 - with self.assertRaises(TypeError): - opt.load_proto(self.proto) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.PyCallableOption('option_name', 'description', signature=signature(foo_func), - default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: PyCallable -;option_name = -; | def foo(value: int) -> int: -; | return value""" - self.assertEqual('\n'.join(x.rstrip() for x in opt.get_config().splitlines()), lines) - lines = """; description -; Type: PyCallable -option_name = - | def foo(value: int) -> int: - | return value * 5""" - opt.set_value(self.PRESENT_VAL) - self.assertEqual('\n'.join(x.rstrip() for x in opt.get_config().splitlines()), lines) - lines = """; description -; Type: PyCallable -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class TestDataclassOption(BaseConfigTest): - "Unit tests for firebird.base.config.EnumOption" - DEFAULT_VAL = SimpleDataclass('main') - PRESENT_VAL = SimpleDataclass('master', 3, SimpleEnum.RUNNING) - DEFAULT_OPT_VAL = SimpleDataclass('default') - NEW_VAL = SimpleDataclass('master', 3, SimpleEnum.STOPPED) - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -; Enum is defined by name -option_name = name:main -[%(PRESENT)s] -; case does not matter -option_name = - name:master - priority:3 - state:RUNNING -[%(ABSENT)s] -[%(BAD)s] -option_name = bad_value -[illegal] -option_name = 1000 -""") - def _dc_equal(self, first, second): - for fld in first.__dataclass_fields__.values(): - if getattr(first, fld.name) != getattr(second, fld.name): - return False - return True - def test_simple(self): - opt = config.DataclassOption('option_name', SimpleDataclass, 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, SimpleDataclass) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertTrue(self._dc_equal(opt.value, self.PRESENT_VAL)) - self.assertEqual(opt.get_as_str(), 'name:master,priority:3,state:RUNNING') - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertTrue(self._dc_equal(opt.value, self.DEFAULT_VAL)) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertTrue(self._dc_equal(opt.value, self.DEFAULT_VAL)) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertTrue(self._dc_equal(opt.value, self.NEW_VAL)) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.DataclassOption('option_name', SimpleDataclass, 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, SimpleDataclass) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertTrue(self._dc_equal(opt.value, self.PRESENT_VAL)) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertTrue(self._dc_equal(opt.value, self.DEFAULT_VAL)) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertTrue(self._dc_equal(opt.value, self.DEFAULT_VAL)) - opt.set_value(self.NEW_VAL) - self.assertTrue(self._dc_equal(opt.value, self.NEW_VAL)) - def test_bad_value(self): - opt = config.DataclassOption('option_name', SimpleDataclass, 'description') - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, BAD_S) - self.assertEqual(cm.exception.args, ("Illegal value 'bad_value' for option 'option_name'",)) - with self.assertRaises(ValueError) as cm: - opt.load_config(self.conf, 'illegal') - self.assertEqual(cm.exception.args, ("Illegal value '1000' for option 'option_name'",)) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'SimpleDataclass', not 'float'",)) - def test_default(self): - opt = config.DataclassOption('option_name', SimpleDataclass, 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, SimpleDataclass) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertTrue(self._dc_equal(opt.default, self.DEFAULT_OPT_VAL)) - self.assertIsInstance(opt.default, opt.datatype) - self.assertTrue(self._dc_equal(opt.default, self.DEFAULT_OPT_VAL)) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertTrue(self._dc_equal(opt.value, self.PRESENT_VAL)) - opt.clear() - self.assertTrue(self._dc_equal(opt.value, opt.default)) - opt.load_config(self.conf, DEFAULT_S) - self.assertTrue(self._dc_equal(opt.value, self.DEFAULT_VAL)) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertTrue(self._dc_equal(opt.value, self.DEFAULT_VAL)) - opt.set_value(self.NEW_VAL) - self.assertTrue(self._dc_equal(opt.value, self.NEW_VAL)) - def test_proto(self): - opt = config.DataclassOption('option_name', SimpleDataclass, 'description', default=self.DEFAULT_OPT_VAL) - proto_value = SimpleDataclass('backup', 2, SimpleEnum.FINISHED) - opt.set_value(proto_value) - self.proto.options['option_name'].as_string = 'name:backup,priority:2,state:FINISHED' - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertTrue(self._dc_equal(opt.value, proto_value)) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.proto.options['option_name'].as_string = 'name:backup,priority:2,state:FINISHED' - opt.load_proto(self.proto) - self.assertTrue(self._dc_equal(opt.value, proto_value)) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_uint32 = 1000 - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: uint32',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.DataclassOption('option_name', SimpleDataclass, 'description', default=self.DEFAULT_OPT_VAL) - lines = """; description -; Type: list of values, where each list item defines value for a dataclass field. -; Item format: field_name:value_as_str -;option_name = name:default, priority:1, state:READY -""" - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: list of values, where each list item defines value for a dataclass field. -; Item format: field_name:value_as_str -option_name = name:master, priority:3, state:SUSPENDED -""" - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: list of values, where each list item defines value for a dataclass field. -; Item format: field_name:value_as_str -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class TestPathOption(BaseConfigTest): - "Unit tests for firebird.base.config.PathOption" - PRESENT_VAL = Path('c:\\home\\present' if platform.system == 'Windows' else '/home/present') - DEFAULT_VAL = Path('c:\\home\\default' if platform.system == 'Windows' else '/home/default') - DEFAULT_OPT_VAL = Path('c:\\home\\default-opt' if platform.system == 'Windows' else '/home/default-opt') - NEW_VAL = Path('c:\\home\\new' if platform.system == 'Windows' else '/home/new') - def setUp(self): - super().setUp() - self.setConf(f"""[%(DEFAULT)s] -option_name = {self.DEFAULT_VAL} -[%(PRESENT)s] -option_name = {self.PRESENT_VAL} -[%(ABSENT)s] -[%(BAD)s] -option_name = -""") - def test_simple(self): - opt = config.PathOption('option_name', 'description') - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, Path) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - self.assertEqual(opt.get_formatted(), str(self.PRESENT_VAL)) - self.assertIsInstance(opt.value, opt.datatype) - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - self.assertIsInstance(opt.value, opt.datatype) - def test_required(self): - opt = config.PathOption('option_name', 'description', required=True) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, Path) - self.assertEqual(opt.description, 'description') - self.assertTrue(opt.required) - self.assertIsNone(opt.default) - self.assertIsNone(opt.value) - with self.assertRaises(Error) as cm: - opt.validate() - self.assertEqual(cm.exception.args, ("Missing value for required option 'option_name'",)) - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.validate() - opt.clear() - self.assertIsNone(opt.value) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - with self.assertRaises(ValueError) as cm: - opt.set_value(None) - self.assertEqual(cm.exception.args, ("Value is required for option 'option_name'.",)) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_bad_value(self): - opt = config.PathOption('option_name', 'description') - opt.load_config(self.conf, BAD_S) - self.assertEqual(opt.value, Path('')) - with self.assertRaises(TypeError) as cm: - opt.set_value(10.0) - self.assertEqual(cm.exception.args, ("Option 'option_name' value must be a 'Path', not 'float'",)) - def test_default(self): - opt = config.PathOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - self.assertEqual(opt.name, 'option_name') - self.assertEqual(opt.datatype, Path) - self.assertEqual(opt.description, 'description') - self.assertFalse(opt.required) - self.assertEqual(opt.default, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.default, opt.datatype) - self.assertEqual(opt.value, self.DEFAULT_OPT_VAL) - self.assertIsInstance(opt.value, opt.datatype) - opt.validate() - opt.load_config(self.conf, PRESENT_S) - self.assertEqual(opt.value, self.PRESENT_VAL) - opt.clear() - self.assertEqual(opt.value, opt.default) - opt.load_config(self.conf, DEFAULT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(None) - self.assertIsNone(opt.value) - opt.load_config(self.conf, ABSENT_S) - self.assertEqual(opt.value, self.DEFAULT_VAL) - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.value, self.NEW_VAL) - def test_proto(self): - opt = config.PathOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - proto_value = Path('c:\\home\\proto' if platform.system == 'Windows' else '/home/proto') - opt.set_value(proto_value) - self.proto.options['option_name'].as_string = str(proto_value) - proto_dump = str(self.proto) - opt.load_proto(self.proto) - self.assertEqual(opt.value, proto_value) - self.assertIsInstance(opt.value, opt.datatype) - self.proto.Clear() - self.assertFalse('option_name' in self.proto.options) - opt.save_proto(self.proto) - self.assertTrue('option_name' in self.proto.options) - self.assertEqual(str(self.proto), proto_dump) - # empty proto - opt.clear(to_default=False) - self.proto.Clear() - opt.load_proto(self.proto) - self.assertIsNone(opt.value) - # bad proto value - self.proto.options['option_name'].as_uint64 = 1000 - with self.assertRaises(TypeError) as cm: - opt.load_proto(self.proto) - self.assertEqual(cm.exception.args, ('Wrong value type: uint64',)) - self.proto.Clear() - opt.clear(to_default=False) - opt.save_proto(self.proto) - self.assertFalse('option_name' in self.proto.options) - def test_get_config(self): - opt = config.PathOption('option_name', 'description', default=self.DEFAULT_OPT_VAL) - lines = f"""; description -; Type: Path -;option_name = {self.DEFAULT_OPT_VAL} -""" - self.assertEqual(opt.get_config(), lines) - lines = f"""; description -; Type: Path -option_name = {self.NEW_VAL} -""" - opt.set_value(self.NEW_VAL) - self.assertEqual(opt.get_config(), lines) - lines = """; description -; Type: Path -option_name = -""" - opt.set_value(None) - self.assertEqual(opt.get_config(), lines) - -class DbConfig(config.Config): - "Simple DB config for testing" - def __init__(self, name: str): - super().__init__(name) - # options - self.database: config.StrOption = config.StrOption('database', 'Database connection string', - required=True) - self.user: config.StrOption = config.StrOption('user', 'User name', required=True, - default='SYSDBA') - self.password: config.StrOption = config.StrOption('password', 'User password') - -class SimpleConfig(config.Config): - """Simple Config for testing. - -Has three options and two sub-configs. -""" - def __init__(self): - super().__init__('simple-config') - # options - self.opt_str: config.StrOption = config.StrOption('opt_str', "Simple string option") - self.opt_int: config.IntOption = config.StrOption('opt_int', "Simple int option") - self.enum_list: config.ListOption = config.ListOption('enum_list', SimpleEnum, "List of enum values") - # sub configs - self.master_db: DbConfig = DbConfig('master-db') - self.backup_db: DbConfig = DbConfig('backup-db') - -class TestConfig(BaseConfigTest): - "Unit tests for firebird.base.config.Config" - def setUp(self): - super().setUp() - self.setConf("""[%(DEFAULT)s] -password = masterkey -[%(PRESENT)s] -opt_str = Lorem ipsum -enum_list = ready, finished, aborted -[%(ABSENT)s] -[%(BAD)s] - -[master-db] -database = primary -user = tester -password = lockpick - -[backup-db] -database = secondary -""") - def test_1_basics(self): - cfg = SimpleConfig() - self.assertEqual(cfg.name, 'simple-config') - self.assertEqual(len(cfg.options), 3) - self.assertIn(cfg.opt_str, cfg.options) - self.assertIn(cfg.opt_int, cfg.options) - self.assertIn(cfg.enum_list, cfg.options) - self.assertEqual(len(cfg.configs), 2) - self.assertIn(cfg.master_db, cfg.configs) - self.assertIn(cfg.backup_db, cfg.configs) - # - self.assertIsNone(cfg.opt_str.value) - self.assertIsNone(cfg.opt_int.value) - self.assertIsNone(cfg.enum_list.value) - self.assertIsNone(cfg.master_db.database.value) - self.assertEqual(cfg.master_db.user.value, 'SYSDBA') - self.assertIsNone(cfg.master_db.password.value) - self.assertIsNone(cfg.backup_db.database.value) - self.assertEqual(cfg.backup_db.user.value, 'SYSDBA') - self.assertIsNone(cfg.backup_db.password.value) - # - with self.assertRaises(ValueError) as cm: - cfg.opt_str = 'value' - self.assertEqual(cm.exception.args, - ('Cannot assign values to option itself, use `option.value` instead',)) - def test_2_load_config(self): - cfg = SimpleConfig() - # - with self.assertRaises(Error): - cfg.load_config(self.conf) - # - cfg.load_config(self.conf, PRESENT_S) - self.assertEqual(cfg.opt_str.value, 'Lorem ipsum') - self.assertIsNone(cfg.opt_int.value) - self.assertListEqual(cfg.enum_list.value, [SimpleEnum.READY, - SimpleEnum.FINISHED, - SimpleEnum.ABORTED]) - # - self.assertEqual(cfg.master_db.database.value, 'primary') - self.assertEqual(cfg.master_db.user.value, 'tester') - self.assertEqual(cfg.master_db.password.value, 'lockpick') - # - self.assertEqual(cfg.backup_db.database.value, 'secondary') - self.assertEqual(cfg.backup_db.user.value, 'SYSDBA') - self.assertEqual(cfg.backup_db.password.value, 'masterkey') - def test_3_clear(self): - cfg = SimpleConfig() - cfg.load_config(self.conf, PRESENT_S) - cfg.clear() - # - self.assertIsNone(cfg.opt_str.value) - self.assertIsNone(cfg.opt_int.value) - self.assertIsNone(cfg.enum_list.value) - self.assertIsNone(cfg.master_db.database.value) - self.assertEqual(cfg.master_db.user.value, 'SYSDBA') - self.assertIsNone(cfg.master_db.password.value) - self.assertIsNone(cfg.backup_db.database.value) - self.assertEqual(cfg.backup_db.user.value, 'SYSDBA') - self.assertIsNone(cfg.backup_db.password.value) - def test_4_proto(self): - cfg = SimpleConfig() - cfg.load_config(self.conf, PRESENT_S) - # - cfg.save_proto(self.proto) - cfg.clear() - cfg.load_proto(self.proto) - # - self.assertEqual(cfg.opt_str.value, 'Lorem ipsum') - self.assertIsNone(cfg.opt_int.value) - self.assertListEqual(cfg.enum_list.value, [SimpleEnum.READY, - SimpleEnum.FINISHED, - SimpleEnum.ABORTED]) - # - self.assertEqual(cfg.master_db.database.value, 'primary') - self.assertEqual(cfg.master_db.user.value, 'tester') - self.assertEqual(cfg.master_db.password.value, 'lockpick') - # - self.assertEqual(cfg.backup_db.database.value, 'secondary') - self.assertEqual(cfg.backup_db.user.value, 'SYSDBA') - self.assertEqual(cfg.backup_db.password.value, 'masterkey') - def test_5_get_config(self): - cfg = SimpleConfig() - lines = """[simple-config] -; -; Simple Config for testing. -; -; Has three options and two sub-configs. - -; Simple string option -; Type: str -;opt_str = - -; Simple int option -; Type: str -;opt_int = - -; List of enum values -; Type: list [SimpleEnum] -;enum_list = - -[master-db] -; -; Simple DB config for testing - -; REQUIRED option. -; Database connection string -; Type: str -;database = - -; REQUIRED option. -; User name -; Type: str -;user = SYSDBA - -; User password -; Type: str -;password = - -[backup-db] -; -; Simple DB config for testing - -; REQUIRED option. -; Database connection string -; Type: str -;database = - -; REQUIRED option. -; User name -; Type: str -;user = SYSDBA - -; User password -; Type: str -;password = """ - self.maxDiff = None - self.assertEqual('\n'.join(x.strip() for x in cfg.get_config().splitlines()), lines) - # - cfg.load_config(self.conf, PRESENT_S) - lines = """[simple-config] -; -; Simple Config for testing. -; -; Has three options and two sub-configs. - -; Simple string option -; Type: str -opt_str = Lorem ipsum - -; Simple int option -; Type: str -;opt_int = - -; List of enum values -; Type: list [SimpleEnum] -enum_list = READY, FINISHED, ABORTED - -[master-db] -; -; Simple DB config for testing - -; REQUIRED option. -; Database connection string -; Type: str -database = primary - -; REQUIRED option. -; User name -; Type: str -user = tester - -; User password -; Type: str -password = lockpick - -[backup-db] -; -; Simple DB config for testing - -; REQUIRED option. -; Database connection string -; Type: str -database = secondary - -; REQUIRED option. -; User name -; Type: str -;user = SYSDBA - -; User password -; Type: str -password = masterkey""" - self.assertEqual('\n'.join(x.strip() for x in cfg.get_config().splitlines()), lines) - -class TestApplicationDirScheme(BaseConfigTest): - "Unit tests for firebird.base.config.ApplicationDirectoryScheme" - _pd = 'c:\\ProgramData' - _ap = 'C:\\Users\\username\\AppData' - _lap = 'C:\\Users\\username\\AppData\\Local' - app_name = 'test_app' - def setUp(self): - super().setUp() - @mock.patch.dict(os.environ, {'%PROGRAMDATA%': _pd, - '%LOCALAPPDATA%': _lap, - '%APPDATA%': _ap}) - def test_01_widnows(self): - if platform.system() != 'Windows': - self.skipTest("Only for Windows") - scheme = config.get_directory_scheme(self.app_name) - self.assertEqual(scheme.config, Path('c:/ProgramData/test_app/config')) - self.assertEqual(scheme.run_data, Path('c:/ProgramData/test_app/run')) - self.assertEqual(scheme.logs, Path('c:/ProgramData/test_app/log')) - self.assertEqual(scheme.data, Path('c:/ProgramData/test_app/data')) - self.assertEqual(scheme.tmp, Path('~/AppData/Local/test_app/tmp').expanduser()) - self.assertEqual(scheme.cache, Path('c:/ProgramData/test_app/cache')) - self.assertEqual(scheme.srv, Path('c:/ProgramData/test_app/srv')) - self.assertEqual(scheme.user_config, Path('~/AppData/Local/test_app/config').expanduser()) - self.assertEqual(scheme.user_data, Path('~/AppData/Local/test_app/data').expanduser()) - self.assertEqual(scheme.user_sync, Path('~/AppData/Roaming/test_app').expanduser()) - self.assertEqual(scheme.user_cache, Path('~/AppData/Local/test_app/cache').expanduser()) - @mock.patch.dict(os.environ, {f'{app_name.upper()}_HOME': 'c:/mydir/apphome/'}) - def test_02_widnows_home_env(self): - if platform.system() != 'Windows': - self.skipTest("Only for Windows") - scheme = config.get_directory_scheme(self.app_name) - self.assertEqual(scheme.config, Path('c:/mydir/apphome/config')) - self.assertEqual(scheme.run_data, Path('c:/mydir/apphome/run_data')) - self.assertEqual(scheme.logs, Path('c:/mydir/apphome/logs')) - self.assertEqual(scheme.data, Path('c:/mydir/apphome/data')) - self.assertEqual(scheme.tmp, Path('~/AppData/Local/test_app/tmp').expanduser()) - self.assertEqual(scheme.cache, Path('c:/mydir/apphome/cache')) - self.assertEqual(scheme.srv, Path('c:/mydir/apphome/srv')) - self.assertEqual(scheme.user_config, Path('~/AppData/Local/test_app/config').expanduser()) - self.assertEqual(scheme.user_data, Path('~/AppData/Local/test_app/data').expanduser()) - self.assertEqual(scheme.user_sync, Path('~/AppData/Roaming/test_app').expanduser()) - self.assertEqual(scheme.user_cache, Path('~/AppData/Local/test_app/cache').expanduser()) - @mock.patch('os.getcwd', return_value='c:/mydir/apphome/') - def test_03_widnows_home_forced(self, *args): - if platform.system() != 'Windows': - self.skipTest("Only for Windows") - scheme = config.get_directory_scheme(self.app_name) - self.assertEqual(scheme.config, Path('c:/mydir/apphome/config')) - self.assertEqual(scheme.run_data, Path('c:/mydir/apphome/run_data')) - self.assertEqual(scheme.logs, Path('c:/mydir/apphome/logs')) - self.assertEqual(scheme.data, Path('c:/mydir/apphome/data')) - self.assertEqual(scheme.tmp, Path('~/AppData/Local/test_app/tmp').expanduser()) - self.assertEqual(scheme.cache, Path('c:/mydir/apphome/cache')) - self.assertEqual(scheme.srv, Path('c:/mydir/apphome/srv')) - self.assertEqual(scheme.user_config, Path('~/AppData/Local/test_app/config').expanduser()) - self.assertEqual(scheme.user_data, Path('~/AppData/Local/test_app/data').expanduser()) - self.assertEqual(scheme.user_sync, Path('~/AppData/Roaming/test_app').expanduser()) - self.assertEqual(scheme.user_cache, Path('~/AppData/Local/test_app/cache').expanduser()) - def test_04_widnows_home_change(self, *args): - if platform.system() != 'Windows': - self.skipTest("Only for Windows") - scheme = config.get_directory_scheme(self.app_name) - scheme.home = 'c:/mydir/apphome/' - self.assertEqual(scheme.config, Path('c:/mydir/apphome/config')) - self.assertEqual(scheme.run_data, Path('c:/mydir/apphome/run_data')) - self.assertEqual(scheme.logs, Path('c:/mydir/apphome/logs')) - self.assertEqual(scheme.data, Path('c:/mydir/apphome/data')) - self.assertEqual(scheme.tmp, Path('~/AppData/Local/test_app/tmp').expanduser()) - self.assertEqual(scheme.cache, Path('c:/mydir/apphome/cache')) - self.assertEqual(scheme.srv, Path('c:/mydir/apphome/srv')) - self.assertEqual(scheme.user_config, Path('~/AppData/Local/test_app/config').expanduser()) - self.assertEqual(scheme.user_data, Path('~/AppData/Local/test_app/data').expanduser()) - self.assertEqual(scheme.user_sync, Path('~/AppData/Roaming/test_app').expanduser()) - self.assertEqual(scheme.user_cache, Path('~/AppData/Local/test_app/cache').expanduser()) - def test_05_linux_default(self): - if platform.system() != 'Linux': - self.skipTest("Only for Linux") - scheme = config.get_directory_scheme(self.app_name) - self.assertEqual(scheme.config, Path('/etc/test_app')) - self.assertEqual(scheme.run_data, Path('/run/test_app')) - self.assertEqual(scheme.logs, Path('/var/log/test_app')) - self.assertEqual(scheme.data, Path('/var/lib/test_app')) - self.assertEqual(scheme.tmp, Path('/var/tmp/test_app')) - self.assertEqual(scheme.cache, Path('/var/cache/test_app')) - self.assertEqual(scheme.srv, Path('/srv/test_app')) - self.assertEqual(scheme.user_config, Path('~/.config/test_app').expanduser()) - self.assertEqual(scheme.user_data, Path('~/.local/share/test_app').expanduser()) - self.assertEqual(scheme.user_sync, Path('~/.local/sync/test_app').expanduser()) - self.assertEqual(scheme.user_cache, Path('~/.cache/test_app').expanduser()) - @mock.patch.dict(os.environ, {f'{app_name.upper()}_HOME': '/mydir/apphome/'}) - def test_06_linux_home_env(self): - if platform.system() != 'Linux': - self.skipTest("Only for Linux") - scheme = config.get_directory_scheme(self.app_name) - self.assertEqual(scheme.config, Path('/mydir/apphome/config')) - self.assertEqual(scheme.run_data, Path('/mydir/apphome/run_data')) - self.assertEqual(scheme.logs, Path('/mydir/apphome/logs')) - self.assertEqual(scheme.data, Path('/mydir/apphome/data')) - self.assertEqual(scheme.tmp, Path('/var/tmp/test_app')) - self.assertEqual(scheme.cache, Path('/mydir/apphome/cache')) - self.assertEqual(scheme.srv, Path('/mydir/apphome/srv')) - self.assertEqual(scheme.user_config, Path('~/.config/test_app').expanduser()) - self.assertEqual(scheme.user_data, Path('~/.local/share/test_app').expanduser()) - self.assertEqual(scheme.user_sync, Path('~/.local/sync/test_app').expanduser()) - self.assertEqual(scheme.user_cache, Path('~/.cache/test_app').expanduser()) - @mock.patch('os.getcwd', return_value='/mydir/apphome/') - def test_07_linux_home_forced(self, *args): - if platform.system() != 'Linux': - self.skipTest("Only for Linux") - scheme = config.get_directory_scheme(self.app_name, force_home=True) - self.assertEqual(scheme.config, Path('/mydir/apphome/config')) - self.assertEqual(scheme.run_data, Path('/mydir/apphome/run_data')) - self.assertEqual(scheme.logs, Path('/mydir/apphome/logs')) - self.assertEqual(scheme.data, Path('/mydir/apphome/data')) - self.assertEqual(scheme.tmp, Path('/var/tmp/test_app')) - self.assertEqual(scheme.cache, Path('/mydir/apphome/cache')) - self.assertEqual(scheme.srv, Path('/mydir/apphome/srv')) - self.assertEqual(scheme.user_config, Path('~/.config/test_app').expanduser()) - self.assertEqual(scheme.user_data, Path('~/.local/share/test_app').expanduser()) - self.assertEqual(scheme.user_sync, Path('~/.local/sync/test_app').expanduser()) - self.assertEqual(scheme.user_cache, Path('~/.cache/test_app').expanduser()) - def test_08_linux_home_change(self, *args): - if platform.system() != 'Linux': - self.skipTest("Only for Linux") - scheme = config.get_directory_scheme(self.app_name, force_home=True) - scheme.home = '/mydir/apphome/' - self.assertEqual(scheme.config, Path('/mydir/apphome/config')) - self.assertEqual(scheme.run_data, Path('/mydir/apphome/run_data')) - self.assertEqual(scheme.logs, Path('/mydir/apphome/logs')) - self.assertEqual(scheme.data, Path('/mydir/apphome/data')) - self.assertEqual(scheme.tmp, Path('/var/tmp/test_app')) - self.assertEqual(scheme.cache, Path('/mydir/apphome/cache')) - self.assertEqual(scheme.srv, Path('/mydir/apphome/srv')) - self.assertEqual(scheme.user_config, Path('~/.config/test_app').expanduser()) - self.assertEqual(scheme.user_data, Path('~/.local/share/test_app').expanduser()) - self.assertEqual(scheme.user_sync, Path('~/.local/sync/test_app').expanduser()) - self.assertEqual(scheme.user_cache, Path('~/.cache/test_app').expanduser()) - -class TestInterpolation(BaseConfigTest): - "Unit tests for firebird.base.config.EnvExtendedInterpolation" - CONFIG = """[base] -base_value = BASE - -[my-config] -value_str = VALUE -value_int = 1 -base_value = ${base:base_value} -value_env_1 = ${env:mysecret} -value_env_2 = ${env:not-present} -value_env_path = ${env:path} -""" - @mock.patch.dict(os.environ, {'MYSECRET': 'secret'}) - def test_01(self): - cfg = ConfigParser(interpolation=config.EnvExtendedInterpolation()) - cfg.read_string(self.CONFIG) - self.assertEqual(cfg['my-config']['value_str'], 'VALUE') - self.assertEqual(cfg['my-config']['value_int'], '1') - self.assertEqual(cfg['my-config']['base_value'], 'BASE') - self.assertEqual(cfg['my-config']['value_env_1'], 'secret') - self.assertEqual(cfg['my-config']['value_env_2'], '') - self.assertEqual(cfg['my-config']['value_env_path'], os.getenv('PATH')) - - -if __name__ == '__main__': - unittest_main() - -#class TestOption(BaseConfigTest): - #"Unit tests for firebird.base.config.Option" - #def setUp(self): - #super().setUp() - #def test_simple(self): - #pass - #def test_required(self): - #pass - #def test_bad_value(self): - #pass - #def test_default(self): - #pass - #def test_proto(self): - #pass - #def test_get_config(self): - #pass - - - #print(f'{cm.exception.args}\n') - #self.assertEqual(cm.exception.args, None) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 0c4f132..2d39a8f 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -3,8 +3,8 @@ # SPDX-License-Identifier: MIT # # PROGRAM/MODULE: firebird-base -# FILE: test/test_hooks.py -# DESCRIPTION: Unit tests for firebird.base.hooks +# FILE: tests/test_hooks.py +# DESCRIPTION: Tests for firebird.base.hooks # CREATED: 14.5.2020 # # The contents of this file are subject to the MIT License @@ -33,473 +33,529 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________. -"Firebird Base - Unit tests for firebird.base.hooks." - from __future__ import annotations -from typing import Protocol, List, cast -import unittest + from enum import Enum, auto -from firebird.base.hooks import hook_manager, HookFlag +from typing import Protocol + +import pytest + +# Assuming hooks.py is importable as below +from firebird.base.hooks import Hook, HookFlag, HookManager, hook_manager from firebird.base.types import ANY +# --- Test Setup & Fixtures --- + class MyEvents(Enum): + """Sample events for testing.""" CREATE = auto() ACTION = auto() + DELETE = auto() # Added for more variety + +class OtherEvents(Enum): + """Different set of events.""" + START = auto() + STOP = auto() class with_print(Protocol): + """Protocol for test classes needing output collection.""" def print(self, msg: str) -> None: ... +class Output: + """Simple output collector for tests.""" + def __init__(self): + self.output: list[str] = [] + def print(self, msg: str) -> None: + self.output.append(msg) + def clear(self) -> None: + self.output.clear() + class MyHookable: - def __init__(self, owner: with_print, name: str, *, register: bool=False, - use_class: bool=False, use_name: bool=False): + """A sample class that can have hooks attached.""" + def __init__(self, owner: with_print, name: str, *, register_name: bool = False, + trigger_event: MyEvents | None = MyEvents.CREATE): + """ + Args: + owner: The output collector. + name: An identifier for the instance. + register_name: Whether to register this instance with the hook manager by name. + trigger_event: Which event to trigger hooks for during init (or None). + """ self.owner = owner self.name: str = name - if register: + if register_name: hook_manager.register_name(self, name) - subj = self - if use_class: - subj = MyHookable - elif use_name: - subj = name - for hook in hook_manager.get_callbacks(MyEvents.CREATE, subj): - try: - hook(self, MyEvents.CREATE) - except Exception as e: - self.owner.print(f"{self.name}.CREATE hook call outcome: ERROR ({e.args[0]})") - else: - self.owner.print(f"{self.name}.CREATE hook call outcome: OK") + + if trigger_event: + source = self.__class__ if trigger_event == MyEvents.CREATE else self # Simplified logic + for hook in hook_manager.get_callbacks(trigger_event, source): + try: + hook(self, trigger_event) + except Exception as e: + self.owner.print(f"{self.name}.{trigger_event.name} hook call outcome: ERROR ({e})") # Show exception type + else: + self.owner.print(f"{self.name}.{trigger_event.name} hook call outcome: OK") + def action(self): + """Simulates performing an action and triggering ACTION hooks.""" self.owner.print(f"{self.name}.ACTION!") for hook in hook_manager.get_callbacks(MyEvents.ACTION, self): try: hook(self, MyEvents.ACTION) except Exception as e: - self.owner.print(f"{self.name}.ACTION hook call outcome: ERROR ({e.args[0]})") + self.owner.print(f"{self.name}.ACTION hook call outcome: ERROR ({e})") else: self.owner.print(f"{self.name}.ACTION hook call outcome: OK") + def delete(self): + """Simulates deletion and triggering DELETE hooks.""" + self.owner.print(f"{self.name}.DELETE!") + for hook in hook_manager.get_callbacks(MyEvents.DELETE, self): + try: + hook(self, MyEvents.DELETE) + except Exception as e: + self.owner.print(f"{self.name}.DELETE hook call outcome: ERROR ({e})") + else: + self.owner.print(f"{self.name}.DELETE hook call outcome: OK") + + class MySuperHookable(MyHookable): + """A subclass to test hook inheritance.""" def super_action(self): + """Simulates a subclass-specific action.""" self.owner.print(f"{self.name}.SUPER-ACTION!") - for hook in hook_manager.get_callbacks('super-action', self): + # Using a string event name here for testing purposes + for hook in hook_manager.get_callbacks("super-action", self): try: - hook(self, 'super-action') + hook(self, "super-action") except Exception as e: - self.owner.print(f"{self.name}.SUPER-ACTION hook call outcome: ERROR ({e.args[0]})") + self.owner.print(f"{self.name}.SUPER-ACTION hook call outcome: ERROR ({e})") else: self.owner.print(f"{self.name}.SUPER-ACTION hook call outcome: OK") class MyHook: + """A sample hook implementation.""" def __init__(self, owner: with_print, name: str): self.owner = owner self.name: str = name - def callback(self, subject: MyHookable, event: MyEvents): - self.owner.print(f"Hook {self.name} event {event.name if isinstance(event, Enum) else event} called by {subject.name}") - def err_callback(self, subject: MyHookable, event: MyEvents): + def callback(self, subject: MyHookable, event: MyEvents | str): + """Standard callback method.""" + event_name = event.name if isinstance(event, Enum) else event + self.owner.print(f"Hook {self.name} event {event_name} called by {subject.name}") + def err_callback(self, subject: MyHookable, event: MyEvents | str): + """Callback method that raises an exception.""" self.callback(subject, event) - raise Exception("Error in hook") - -def iter_class_properties(cls): - """Iterator function. - - Args: - cls (class): Class object. - - Yields: - `name', 'property` pairs for all properties in class. -""" - for varname in vars(cls): - value = getattr(cls, varname) - if isinstance(value, property): - yield varname, value - -def iter_class_variables(cls): - """Iterator function. - - Args: - cls (class): Class object. - - Yields: - Names of all non-callable attributes in class. -""" - for varname in vars(cls): - value = getattr(cls, varname) - if not (isinstance(value, property) or callable(value)) and not varname.startswith('_'): - yield varname - - -class TestHooks(unittest.TestCase): - """Unit tests for firebird.base.hooks.HookManager""" - def __init__(self, methodName='runTest'): - super().__init__(methodName) - self.output: List = [] - def setUp(self) -> None: - self.output.clear() - hook_manager.reset() - def tearDown(self): + raise ValueError("Error in hook") # Use specific exception + + +@pytest.fixture +def output() -> Output: + """Provides a fresh Output collector for each test.""" + return Output() + +@pytest.fixture(autouse=True) +def manager() -> HookManager: + """Provides the global hook_manager and ensures it's reset before each test.""" + hook_manager.reset() + # Basic registration needed for many tests + hook_manager.register_class(MyHookable, MyEvents) + return hook_manager + +# --- Test Functions --- + +def test_hook_dataclass(): + """Tests the Hook dataclass directly.""" + hook_A: MyHook = MyHook(Output(), "Hook-A") + h1 = Hook(event=MyEvents.CREATE, cls=MyHookable, instance=ANY, callbacks=[hook_A.callback]) + h2 = Hook(event=MyEvents.CREATE, cls=MyHookable) # Defaults instance=ANY, callbacks=[] + + # Test get_key + assert h1.get_key() == (MyEvents.CREATE, MyHookable, ANY) + assert h2.get_key() == (MyEvents.CREATE, MyHookable, ANY) + + # Test basic attributes + assert h1.event == MyEvents.CREATE + assert h1.cls == MyHookable + assert h1.instance is ANY + assert h1.callbacks == [hook_A.callback] + assert h2.callbacks == [] + +def test_register_class_with_set(): + """Tests registering a hookable class with a set of event names.""" + hook_manager.register_class(MySuperHookable, {"event1", "event2"}) + assert MySuperHookable in hook_manager.hookables + assert hook_manager.hookables[MySuperHookable] == {"event1", "event2"} + + # Test adding a hook for a set-registered event + hook_A: MyHook = MyHook(Output(), "Hook-A") + hook_manager.add_hook("event1", MySuperHookable, hook_A.callback) + assert len(hook_manager.hooks) == 1 + # Test adding hook for unsupported event + with pytest.raises(ValueError, match="Event 'event3' is not supported by 'MySuperHookable'"): + hook_manager.add_hook("event3", MySuperHookable, hook_A.callback) + +def test_general_hooking(output: Output, manager: HookManager): + """Tests core hooking functionality: registration, adding hooks (class, instance, name), + triggering, getting callbacks, removing hooks, and manager state reset.""" + + # Initial state checks + assert tuple(manager.hookables.keys()) == (MyHookable, ) + assert manager.hookables[MyHookable] == set(MyEvents.__members__.values()) + assert manager.flags == HookFlag.NONE # No hooks added yet + + # Install hooks + hook_A: MyHook = MyHook(output, "Hook-A") + hook_B: MyHook = MyHook(output, "Hook-B") # Error hook + hook_C: MyHook = MyHook(output, "Hook-C") + hook_N: MyHook = MyHook(output, "Hook-N") # Name hook + + # Add CLASS hook + manager.add_hook(MyEvents.CREATE, MyHookable, hook_A.callback) + assert HookFlag.CLASS in manager.flags + manager.add_hook(MyEvents.CREATE, MyHookable, hook_B.err_callback) + manager.add_hook(MyEvents.ACTION, MyHookable, hook_C.callback) # Another class hook + + # Add NAME hook + manager.add_hook(MyEvents.ACTION, "Source-A", hook_N.callback) + assert HookFlag.NAME in manager.flags + + # Verify hooks registry + key_create = (MyEvents.CREATE, MyHookable, ANY) + assert key_create in manager.hooks + assert hook_A.callback in manager.hooks[key_create].callbacks + assert hook_B.err_callback in manager.hooks[key_create].callbacks + key_action_cls = (MyEvents.ACTION, MyHookable, ANY) + assert key_action_cls in manager.hooks + assert hook_C.callback in manager.hooks[key_action_cls].callbacks + key_action_name = (MyEvents.ACTION, ANY, "Source-A") + assert key_action_name in manager.hooks + assert hook_N.callback in manager.hooks[key_action_name].callbacks + + # Create event sources (triggers CREATE hooks) + output.clear() + src_A: MyHookable = MyHookable(output, "Source-A", register_name=True) + assert output.output == ["Hook Hook-A event CREATE called by Source-A", + "Source-A.CREATE hook call outcome: OK", + "Hook Hook-B event CREATE called by Source-A", + "Source-A.CREATE hook call outcome: ERROR (Error in hook)"] + output.clear() + src_B: MyHookable = MyHookable(output, "Source-B", register_name=True) # Name B + assert output.output == ["Hook Hook-A event CREATE called by Source-B", + "Source-B.CREATE hook call outcome: OK", + "Hook Hook-B event CREATE called by Source-B", + "Source-B.CREATE hook call outcome: ERROR (Error in hook)"] + + # Add INSTANCE hooks + manager.add_hook(MyEvents.ACTION, src_A, hook_A.callback) # Instance hook for src_A + assert HookFlag.INSTANCE in manager.flags + manager.add_hook(MyEvents.ACTION, src_B, hook_B.callback) # Instance hook for src_B (non-error callback) + + # Verify instance hooks registry + key_action_inst_A = (MyEvents.ACTION, ANY, src_A) + assert key_action_inst_A in manager.hooks + assert hook_A.callback in manager.hooks[key_action_inst_A].callbacks + key_action_inst_B = (MyEvents.ACTION, ANY, src_B) + assert key_action_inst_B in manager.hooks + assert hook_B.callback in manager.hooks[key_action_inst_B].callbacks + + # Trigger ACTION hooks and verify combined callbacks (Instance + Name + Class) + output.clear() + src_A.action() + # Expected callbacks for src_A: hook_A (Instance), hook_N (Name), hook_C (Class) + assert output.output == ["Source-A.ACTION!", + "Hook Hook-A event ACTION called by Source-A", # Instance Hook + "Source-A.ACTION hook call outcome: OK", + "Hook Hook-N event ACTION called by Source-A", # Name Hook + "Source-A.ACTION hook call outcome: OK", + "Hook Hook-C event ACTION called by Source-A", # Class Hook + "Source-A.ACTION hook call outcome: OK"] + + output.clear() + src_B.action() + # Expected callbacks for src_B: hook_B (Instance), hook_C (Class) - No name hook for "Source-B" + assert output.output == ["Source-B.ACTION!", + "Hook Hook-B event ACTION called by Source-B", # Instance Hook + "Source-B.ACTION hook call outcome: OK", + "Hook Hook-C event ACTION called by Source-B", # Class Hook + "Source-B.ACTION hook call outcome: OK"] + + # Verify flags are all set + assert HookFlag.CLASS in manager.flags + assert HookFlag.INSTANCE in manager.flags + assert HookFlag.NAME in manager.flags + assert HookFlag.ANY_EVENT not in manager.flags # ANY_EVENT not used yet + + # Test remove_hook and flag updates + # Remove name hook + manager.remove_hook(MyEvents.ACTION, "Source-A", hook_N.callback) + assert key_action_name not in manager.hooks # Hook entry should be gone + assert HookFlag.NAME not in manager.flags # Flag might persist if other name hooks exist (none here, but test resilience) + + # Remove one class hook callback + manager.remove_hook(MyEvents.CREATE, MyHookable, hook_A.callback) + assert key_create in manager.hooks # Hook entry still exists + assert hook_A.callback not in manager.hooks[key_create].callbacks + assert hook_B.err_callback in manager.hooks[key_create].callbacks # Other callback remains + assert HookFlag.CLASS in manager.flags + + # Remove last class hook callback for that key + manager.remove_hook(MyEvents.CREATE, MyHookable, hook_B.err_callback) + assert key_create not in manager.hooks # Hook entry removed + # manager.flags should ideally be recalculated here. Test assumes it's not for simplicity. + + # Remove instance hook + manager.remove_hook(MyEvents.ACTION, src_A, hook_A.callback) + assert key_action_inst_A not in manager.hooks + # manager.flags ideally updated. + + # Test remove_all_hooks + manager.remove_all_hooks() + assert len(manager.hooks) == 0 + assert manager.flags == HookFlag.NONE # Flags should be reset + + # Test reset (also clears hookables) + manager.add_hook(MyEvents.ACTION, MyHookable, hook_C.callback) # Add one back + assert len(manager.hooks) == 1 + assert len(manager.hookables) == 1 + manager.reset() + assert len(manager.hooks) == 0 + assert len(manager.hookables) == 0 + assert len(manager.obj_map) == 0 + assert manager.flags == HookFlag.NONE + +def test_inheritance_specific_hooks(output: Output, manager: HookManager): + """Tests hooks registered specifically for base and subclasses are triggered correctly.""" + # Register both base and subclass, subclass with different/additional events + # manager.register_class(MyHookable, MyEvents) # Done by fixture + manager.register_class(MySuperHookable, {"super-action"}) # Register only subclass-specific event set + + # Hooks for Base + hook_A: MyHook = MyHook(output, "Hook-A-Base") + hook_C: MyHook = MyHook(output, "Hook-C-Base") + manager.add_hook(MyEvents.CREATE, MyHookable, hook_A.callback) + manager.add_hook(MyEvents.ACTION, MyHookable, hook_C.callback) + + # Hooks for Subclass + hook_B: MyHook = MyHook(output, "Hook-B-Super") # Error hook + hook_S: MyHook = MyHook(output, "Hook-S-Super") + manager.add_hook(MyEvents.CREATE, MySuperHookable, hook_B.err_callback) # CREATE hook specific to subclass + manager.add_hook("super-action", MySuperHookable, hook_S.callback) + + # Create subclass instance (triggers CREATE) + output.clear() + src_A: MySuperHookable = MySuperHookable(output, "SuperSource-A") + # Expected: hook_B (Subclass) + assert output.output == ["Hook Hook-B-Super event CREATE called by SuperSource-A", + "SuperSource-A.CREATE hook call outcome: ERROR (Error in hook)"] + + # Trigger base class action + output.clear() + src_A.action() + # Expected: hook_C (Base Class only, as ACTION not registered for MySuperHookable) + assert output.output == ["SuperSource-A.ACTION!", + "Hook Hook-C-Base event ACTION called by SuperSource-A", + "SuperSource-A.ACTION hook call outcome: OK"] + + # Trigger subclass action + output.clear() + src_A.super_action() + # Expected: hook_S (Subclass only) + assert output.output == ["SuperSource-A.SUPER-ACTION!", + "Hook Hook-S-Super event super-action called by SuperSource-A", + "SuperSource-A.SUPER-ACTION hook call outcome: OK"] + +def test_bad_hook_registrations(output: Output, manager: HookManager): + """Tests error handling for invalid arguments during hook registration.""" + # manager.register_class(MyHookable, MyEvents) # Done by fixture + # manager.register_class(MySuperHookable, {"super-action"}) # Assume registered if needed + + bad_hook: MyHook = MyHook(output, "BAD-Hook") + + # Invalid source type for add_hook + with pytest.raises(TypeError, match="Subject must be hookable class or instance, or name"): + manager.add_hook(MyEvents.CREATE, 123, bad_hook.callback) # Invalid type + + # Unregistered class for add_hook + class Unregistered: pass + with pytest.raises(TypeError, match="The type is not registered as hookable"): + manager.add_hook(MyEvents.CREATE, Unregistered, bad_hook.callback) + + # Unsupported event for add_hook + with pytest.raises(ValueError, match="Event 'BAD EVENT' is not supported by 'MyHookable'"): + manager.add_hook("BAD EVENT", MyHookable, bad_hook.callback) + src_A: MyHookable = MyHookable(output, "Source-A", trigger_event=None) + with pytest.raises(ValueError, match="Event 'BAD EVENT' is not supported by 'MyHookable'"): + manager.add_hook("BAD EVENT", src_A, bad_hook.callback) + + # Invalid instance type for register_name + with pytest.raises(TypeError, match="The instance is not of hookable type"): + manager.register_name(output, "BAD_CLASS_INSTANCE") # 'output' is not hookable + +def test_any_event_hooks(output: Output, manager: HookManager): + """Tests hooks registered for ANY event.""" + # manager.register_class(MyHookable, MyEvents) # Done by fixture + + # Hooks + hook_A_ANY: MyHook = MyHook(output, "Hook-A-ANY") + hook_B_ACTION: MyHook = MyHook(output, "Hook-B-ACTION") + hook_C_ANY_Inst: MyHook = MyHook(output, "Hook-C-ANY-Inst") + hook_D_ANY_Name: MyHook = MyHook(output, "Hook-D-ANY-Name") + + # Add ANY event hook for the class + manager.add_hook(ANY, MyHookable, hook_A_ANY.callback) + assert HookFlag.CLASS in manager.flags + assert HookFlag.ANY_EVENT in manager.flags + + # Add specific event hook for comparison + manager.add_hook(MyEvents.ACTION, MyHookable, hook_B_ACTION.callback) + + # Create instance (triggers CREATE) + output.clear() + src_A: MyHookable = MyHookable(output, "Source-A", register_name=True) + # Expected: hook_A_ANY (Class ANY) triggered by CREATE event + assert output.output == ["Hook Hook-A-ANY event CREATE called by Source-A", + "Source-A.CREATE hook call outcome: OK"] + + # Add ANY event hooks for instance and name + manager.add_hook(ANY, src_A, hook_C_ANY_Inst.callback) + manager.add_hook(ANY, "Source-A", hook_D_ANY_Name.callback) + assert HookFlag.INSTANCE in manager.flags + assert HookFlag.NAME in manager.flags + + # Trigger ACTION event + output.clear() + src_A.action() + # Expected: + # - hook_C_ANY_Inst (Instance ANY) + # - hook_D_ANY_Name (Name ANY) + # - hook_B_ACTION (Class ACTION) + # - hook_A_ANY (Class ANY) + assert output.output == ["Source-A.ACTION!", + "Hook Hook-C-ANY-Inst event ACTION called by Source-A", # Instance ANY + "Source-A.ACTION hook call outcome: OK", + "Hook Hook-D-ANY-Name event ACTION called by Source-A", # Name ANY + "Source-A.ACTION hook call outcome: OK", + "Hook Hook-B-ACTION event ACTION called by Source-A", # Class ACTION + "Source-A.ACTION hook call outcome: OK", + "Hook Hook-A-ANY event ACTION called by Source-A", # Class ANY + "Source-A.ACTION hook call outcome: OK"] + + # Test removing ANY hook + manager.remove_hook(ANY, MyHookable, hook_A_ANY.callback) + output.clear() + src_A.action() # Trigger again + # Expected: Same as above, but without hook_A_ANY + assert output.output == ["Source-A.ACTION!", + "Hook Hook-C-ANY-Inst event ACTION called by Source-A", + "Source-A.ACTION hook call outcome: OK", + "Hook Hook-D-ANY-Name event ACTION called by Source-A", + "Source-A.ACTION hook call outcome: OK", + "Hook Hook-B-ACTION event ACTION called by Source-A", + "Source-A.ACTION hook call outcome: OK"] + # Check flags after removal (this part is speculative without internal flag recalc logic) + # assert HookFlag.ANY_EVENT not in manager.flags # Might be false if other ANY hooks remain + + +@pytest.mark.parametrize('event', [MyEvents.ACTION, ANY]) +def test_any_source_hooks_match_all_sources_and_expected_events(output: Output, + manager: HookManager, + event: MyEvents | type): + """A global hook reaches class, instance, and named notifications.""" + calls: list[tuple[object, object]] = [] + + def callback(source: object, actual_event: object) -> None: + calls.append((source, actual_event)) + + source = MyHookable(output, 'Source-A', register_name=True, trigger_event=None) + manager.add_hook(event, ANY, callback) + assert (event, ANY, ANY) in manager.hooks + assert HookFlag.ANY_SOURCE in manager.flags + assert (HookFlag.ANY_EVENT in manager.flags) is (event is ANY) + + for actual_event in (MyEvents.ACTION, MyEvents.DELETE): + for notified_source in (MyHookable, source, 'Source-A', object(), 'Other-Name'): + callbacks = manager.get_callbacks(actual_event, notified_source) + for hook in callbacks: + hook(notified_source, actual_event) + expected = event is ANY or actual_event == event + assert (callback in callbacks) is expected + assert calls == ([(notified_source, actual_event)] if expected else []) + calls.clear() + + manager.remove_hook(event, ANY, callback) + assert manager.get_callbacks(MyEvents.ACTION, source) == [] + assert manager.get_callbacks(MyEvents.DELETE, source) == [] + assert manager.flags == HookFlag.NONE + + +@pytest.mark.parametrize('source_kind', ['class', 'instance', 'name']) +def test_any_event_on_specified_source_excludes_other_sources(output: Output, + manager: HookManager, + source_kind: str): + """An ANY-event hook fires on multiple events only for its chosen source.""" + source = MyHookable(output, 'Source-A', register_name=True, trigger_event=None) + other = MyHookable(output, 'Source-B', register_name=True, trigger_event=None) + target = {'class': MyHookable, 'instance': source, 'name': 'Source-A'}[source_kind] + calls: list[tuple[object, object]] = [] + + def callback(actual_source: object, actual_event: object) -> None: + calls.append((actual_source, actual_event)) + + manager.add_hook(ANY, target, callback) + + for event in (MyEvents.ACTION, MyEvents.DELETE): + callbacks = manager.get_callbacks(event, source) + assert callback in callbacks + for hook in callbacks: + hook(source, event) + assert calls == [(source, event)] + calls.clear() + assert (callback in manager.get_callbacks(event, 'Source-A')) is (source_kind == 'name') + if source_kind != 'class': + assert callback not in manager.get_callbacks(event, other) + assert callback not in manager.get_callbacks(event, 'Source-B') + else: + assert callback in manager.get_callbacks(event, other) + + +def test_any_source_hook_combines_with_scoped_hooks(output: Output, manager: HookManager): + """Global hooks coexist with source-specific callbacks without duplication.""" + source = MyHookable(output, 'Source-A', register_name=True, trigger_event=None) + + def global_callback(*_): + pass + + def specific_global_callback(*_): + pass + + def class_callback(*_): + pass + + def name_callback(*_): + pass + + def instance_callback(*_): pass - def print(self, msg: str) -> None: - self.output.append(msg) - def test_aaa_hooks(self): - # register hookables - hook_manager.register_class(MyHookable, MyEvents) - self.assertTupleEqual(tuple(hook_manager.hookables.keys()), (MyHookable, )) - self.assertSetEqual(hook_manager.hookables[MyHookable], - set(x for x in cast(Enum, MyEvents).__members__.values())) - # Optimizations - self.assertNotIn(HookFlag.CLASS, hook_manager.flags) - self.assertNotIn(HookFlag.INSTANCE, hook_manager.flags) - self.assertNotIn(HookFlag.ANY_EVENT, hook_manager.flags) - self.assertNotIn(HookFlag.NAME, hook_manager.flags) - # Install hooks - hook_A: MyHook = MyHook(self, 'Hook-A') - hook_B: MyHook = MyHook(self, 'Hook-B') - hook_C: MyHook = MyHook(self, 'Hook-C') - hook_N: MyHook = MyHook(self, 'Hook-N') - # - hook_manager.add_hook(MyEvents.CREATE, MyHookable, hook_A.callback) - self.assertIn(HookFlag.CLASS, hook_manager.flags) - hook_manager.add_hook(MyEvents.CREATE, MyHookable, hook_B.err_callback) - hook_manager.add_hook(MyEvents.ACTION, MyHookable, hook_C.callback) - hook_manager.add_hook(MyEvents.ACTION, 'Source-A', hook_N.callback) - self.assertIn(HookFlag.NAME, hook_manager.flags) - # - key = (MyEvents.CREATE, MyHookable, ANY) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_A.callback, hook_manager.hooks[key].callbacks) - self.assertIn(hook_B.err_callback, hook_manager.hooks[key].callbacks) - key = (MyEvents.ACTION, MyHookable, ANY) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_C.callback, hook_manager.hooks[key].callbacks) - # Create event sources, emits CREATE - self.output.clear() - src_A: MyHookable = MyHookable(self, 'Source-A', register=True) - self.assertListEqual(self.output, - ['Hook Hook-A event CREATE called by Source-A', - 'Source-A.CREATE hook call outcome: OK', - 'Hook Hook-B event CREATE called by Source-A', - 'Source-A.CREATE hook call outcome: ERROR (Error in hook)']) - self.output.clear() - src_B: MyHookable = MyHookable(self, 'Source-B', register=True) - self.assertListEqual(self.output, - ['Hook Hook-A event CREATE called by Source-B', - 'Source-B.CREATE hook call outcome: OK', - 'Hook Hook-B event CREATE called by Source-B', - 'Source-B.CREATE hook call outcome: ERROR (Error in hook)']) - # Install instance hooks - hook_manager.add_hook(MyEvents.ACTION, src_A, hook_A.callback) - self.assertIn(HookFlag.INSTANCE, hook_manager.flags) - hook_manager.add_hook(MyEvents.ACTION, src_B, hook_B.callback) - # - key = (MyEvents.ACTION, ANY, src_A) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_A.callback, hook_manager.hooks[key].callbacks) - key = (MyEvents.ACTION, ANY, src_B) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_B.callback, hook_manager.hooks[key].callbacks) - # And action! - self.output.clear() - src_A.action() - self.assertListEqual(self.output, - ['Source-A.ACTION!', - 'Hook Hook-A event ACTION called by Source-A', - 'Source-A.ACTION hook call outcome: OK', - 'Hook Hook-N event ACTION called by Source-A', - 'Source-A.ACTION hook call outcome: OK', - 'Hook Hook-C event ACTION called by Source-A', - 'Source-A.ACTION hook call outcome: OK']) - # - self.output.clear() - src_B.action() - self.assertListEqual(self.output, - ['Source-B.ACTION!', - 'Hook Hook-B event ACTION called by Source-B', - 'Source-B.ACTION hook call outcome: OK', - 'Hook Hook-C event ACTION called by Source-B', - 'Source-B.ACTION hook call outcome: OK']) - # Optimizations - self.assertIn(HookFlag.CLASS, hook_manager.flags) - self.assertIn(HookFlag.INSTANCE, hook_manager.flags) - self.assertNotIn(HookFlag.ANY_EVENT, hook_manager.flags) - self.assertIn(HookFlag.NAME, hook_manager.flags) - # Remove hooks - hook_manager.remove_hook(MyEvents.CREATE, MyHookable, hook_A.callback) - key = (MyEvents.CREATE, MyHookable, ANY) - self.assertTrue(key in hook_manager.hooks) - self.assertNotIn(hook_A.callback, hook_manager.hooks[key].callbacks) - hook_manager.remove_hook(MyEvents.CREATE, MyHookable, hook_B.err_callback) - self.assertFalse(key in hook_manager.hooks) - # - hook_manager.remove_hook(MyEvents.ACTION, src_A, hook_A.callback) - key = (MyEvents.ACTION, ANY, src_A) - self.assertFalse(key in hook_manager.hooks) - # - hook_manager.remove_all_hooks() - self.assertEqual(len(hook_manager.hooks), 0) - # - hook_manager.add_hook(MyEvents.ACTION, MyHookable, hook_C.callback) - hook_manager.reset() - self.assertEqual(len(hook_manager.hookables), 0) - self.assertEqual(len(hook_manager.hooks), 0) - def test_inherited_hookable(self): - # register hookables - hook_manager.register_class(MyHookable, MyEvents) - self.assertTupleEqual(tuple(hook_manager.hookables.keys()), (MyHookable, )) - self.assertSetEqual(hook_manager.hookables[MyHookable], - set(x for x in cast(Enum, MyEvents).__members__.values())) - # Install hooks - hook_A: MyHook = MyHook(self, 'Hook-A') - hook_B: MyHook = MyHook(self, 'Hook-B') - hook_C: MyHook = MyHook(self, 'Hook-C') - # - hook_manager.add_hook(MyEvents.CREATE, MyHookable, hook_A.callback) - hook_manager.add_hook(MyEvents.CREATE, MyHookable, hook_B.err_callback) - hook_manager.add_hook(MyEvents.ACTION, MyHookable, hook_C.callback) - # - key = (MyEvents.CREATE, MyHookable, ANY) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_A.callback, hook_manager.hooks[key].callbacks) - self.assertIn(hook_B.err_callback, hook_manager.hooks[key].callbacks) - key = (MyEvents.ACTION, MyHookable, ANY) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_C.callback, hook_manager.hooks[key].callbacks) - # Create event sources, emits CREATE - self.output.clear() - src_A: MySuperHookable = MySuperHookable(self, 'SuperSource-A') - self.assertListEqual(self.output, - ['Hook Hook-A event CREATE called by SuperSource-A', - 'SuperSource-A.CREATE hook call outcome: OK', - 'Hook Hook-B event CREATE called by SuperSource-A', - 'SuperSource-A.CREATE hook call outcome: ERROR (Error in hook)']) - self.output.clear() - src_B: MySuperHookable = MySuperHookable(self, 'SuperSource-B') - self.assertListEqual(self.output, - ['Hook Hook-A event CREATE called by SuperSource-B', - 'SuperSource-B.CREATE hook call outcome: OK', - 'Hook Hook-B event CREATE called by SuperSource-B', - 'SuperSource-B.CREATE hook call outcome: ERROR (Error in hook)']) - # Install instance hooks - hook_manager.add_hook(MyEvents.ACTION, src_A, hook_A.callback) - hook_manager.add_hook(MyEvents.ACTION, src_B, hook_B.callback) - # - key = (MyEvents.ACTION, ANY, src_A) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_A.callback, hook_manager.hooks[key].callbacks) - key = (MyEvents.ACTION, ANY, src_B) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_B.callback, hook_manager.hooks[key].callbacks) - # And action! - self.output.clear() - src_A.action() - self.assertListEqual(self.output, - ['SuperSource-A.ACTION!', - 'Hook Hook-A event ACTION called by SuperSource-A', - 'SuperSource-A.ACTION hook call outcome: OK', - 'Hook Hook-C event ACTION called by SuperSource-A', - 'SuperSource-A.ACTION hook call outcome: OK']) - # - self.output.clear() - src_B.action() - self.assertListEqual(self.output, - ['SuperSource-B.ACTION!', - 'Hook Hook-B event ACTION called by SuperSource-B', - 'SuperSource-B.ACTION hook call outcome: OK', - 'Hook Hook-C event ACTION called by SuperSource-B', - 'SuperSource-B.ACTION hook call outcome: OK']) - def test_inheritance(self): - # register hookables - hook_manager.register_class(MyHookable, MyEvents) - hook_manager.register_class(MySuperHookable, ('super-action', )) - self.assertTupleEqual(tuple(hook_manager.hookables.keys()), (MyHookable, MySuperHookable)) - self.assertSetEqual(hook_manager.hookables[MyHookable], - set(x for x in cast(Enum, MyEvents).__members__.values())) - self.assertTupleEqual(hook_manager.hookables[MySuperHookable], ('super-action', )) - # Install hooks - hook_A: MyHook = MyHook(self, 'Hook-A') - hook_B: MyHook = MyHook(self, 'Hook-B') - hook_C: MyHook = MyHook(self, 'Hook-C') - hook_S: MyHook = MyHook(self, 'Hook-S') - # - hook_manager.add_hook(MyEvents.CREATE, MyHookable, hook_A.callback) - hook_manager.add_hook(MyEvents.CREATE, MySuperHookable, hook_B.err_callback) - hook_manager.add_hook(MyEvents.ACTION, MyHookable, hook_C.callback) - hook_manager.add_hook('super-action', MySuperHookable, hook_S.callback) - # - key = (MyEvents.CREATE, MyHookable, ANY) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_A.callback, hook_manager.hooks[key].callbacks) - key = (MyEvents.CREATE, MySuperHookable, ANY) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_B.err_callback, hook_manager.hooks[key].callbacks) - key = (MyEvents.ACTION, MyHookable, ANY) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_C.callback, hook_manager.hooks[key].callbacks) - # Create event sources, emits CREATE - self.output.clear() - src_A: MySuperHookable = MySuperHookable(self, 'SuperSource-A') - self.assertListEqual(self.output, - ['Hook Hook-A event CREATE called by SuperSource-A', - 'SuperSource-A.CREATE hook call outcome: OK', - 'Hook Hook-B event CREATE called by SuperSource-A', - 'SuperSource-A.CREATE hook call outcome: ERROR (Error in hook)']) - self.output.clear() - src_B: MySuperHookable = MySuperHookable(self, 'SuperSource-B') - self.assertListEqual(self.output, - ['Hook Hook-A event CREATE called by SuperSource-B', - 'SuperSource-B.CREATE hook call outcome: OK', - 'Hook Hook-B event CREATE called by SuperSource-B', - 'SuperSource-B.CREATE hook call outcome: ERROR (Error in hook)']) - # Install instance hooks - hook_manager.add_hook(MyEvents.ACTION, src_A, hook_A.callback) - hook_manager.add_hook(MyEvents.ACTION, src_B, hook_B.callback) - # - key = (MyEvents.ACTION, ANY, src_A) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_A.callback, hook_manager.hooks[key].callbacks) - key = (MyEvents.ACTION, ANY, src_B) - self.assertTrue(key in hook_manager.hooks) - self.assertIn(hook_B.callback, hook_manager.hooks[key].callbacks) - # And action! - self.output.clear() - src_A.action() - self.assertListEqual(self.output, - ['SuperSource-A.ACTION!', - 'Hook Hook-A event ACTION called by SuperSource-A', - 'SuperSource-A.ACTION hook call outcome: OK', - 'Hook Hook-C event ACTION called by SuperSource-A', - 'SuperSource-A.ACTION hook call outcome: OK']) - # - self.output.clear() - src_B.action() - self.assertListEqual(self.output, - ['SuperSource-B.ACTION!', - 'Hook Hook-B event ACTION called by SuperSource-B', - 'SuperSource-B.ACTION hook call outcome: OK', - 'Hook Hook-C event ACTION called by SuperSource-B', - 'SuperSource-B.ACTION hook call outcome: OK']) - # - self.output.clear() - src_B.super_action() - self.assertListEqual(self.output, - ['SuperSource-B.SUPER-ACTION!', - 'Hook Hook-S event super-action called by SuperSource-B', - 'SuperSource-B.SUPER-ACTION hook call outcome: OK']) - def test_bad_hooks(self): - # register hookables - hook_manager.register_class(MyHookable, MyEvents) - hook_manager.register_class(MySuperHookable, ('super-action', )) - src_A: MyHookable = MyHookable(self, 'Source-A') - src_B: MySuperHookable = MySuperHookable(self, 'SuperSource-B') - # Install hooks - bad_hook: MyHook = MyHook(self, 'BAD-Hook') - # Wrong hookables - with self.assertRaises(TypeError) as cm: - hook_manager.add_hook(MyEvents.CREATE, ANY, bad_hook.callback) # hook object - self.assertEqual(cm.exception.args, ("Subject must be hookable class or instance, or name",)) - with self.assertRaises(TypeError) as cm: - hook_manager.add_hook(MyEvents.CREATE, Enum, bad_hook.callback) # hook class - self.assertEqual(cm.exception.args, ('The type is not registered as hookable',)) - self.assertDictEqual(hook_manager.hooks._reg, {}) - # Wrong events - with self.assertRaises(ValueError) as cm: - hook_manager.add_hook('BAD EVENT', MyHookable, bad_hook.callback) - self.assertEqual(cm.exception.args, ("Event 'BAD EVENT' is not supported by 'MyHookable'",)) - with self.assertRaises(ValueError) as cm: - hook_manager.add_hook('BAD EVENT', MySuperHookable, bad_hook.callback) - self.assertEqual(cm.exception.args, ("Event 'BAD EVENT' is not supported by 'MySuperHookable'",)) - # - with self.assertRaises(ValueError) as cm: - hook_manager.add_hook('BAD EVENT', src_A, bad_hook.callback) - self.assertEqual(cm.exception.args, ("Event 'BAD EVENT' is not supported by 'MyHookable'",)) - with self.assertRaises(ValueError) as cm: - hook_manager.add_hook('BAD EVENT', src_B, bad_hook.callback) - self.assertEqual(cm.exception.args, ("Event 'BAD EVENT' is not supported by 'MySuperHookable'",)) - # Bad hookable instances - with self.assertRaises(TypeError) as cm: - hook_manager.register_name(self, 'BAD_CLASS') - self.assertEqual(cm.exception.args, ("The instance is not of hookable type",)) - def test_any_event(self): - # register hookables - hook_manager.register_class(MyHookable, MyEvents) - # Install hooks - hook_A: MyHook = MyHook(self, 'Hook-A') - hook_B: MyHook = MyHook(self, 'Hook-B') - hook_C: MyHook = MyHook(self, 'Hook-C') - hook_D: MyHook = MyHook(self, 'Hook-D') - hook_manager.add_hook(ANY, MyHookable, hook_A.callback) - hook_manager.add_hook(ANY, MyHookable, hook_B.err_callback) - # Create event sources, emits CREATE - self.output.clear() - src_A: MyHookable = MyHookable(self, 'Source-A', register=True) - self.assertListEqual(self.output, - ['Hook Hook-A event CREATE called by Source-A', - 'Source-A.CREATE hook call outcome: OK', - 'Hook Hook-B event CREATE called by Source-A', - 'Source-A.CREATE hook call outcome: ERROR (Error in hook)']) - # Install instance hooks - hook_manager.add_hook(ANY, src_A, hook_C.callback) - hook_manager.add_hook(ANY, 'Source-A', hook_D.callback) - # And action! - self.output.clear() - src_A.action() - self.assertListEqual(self.output, - ['Source-A.ACTION!', - 'Hook Hook-C event ACTION called by Source-A', - 'Source-A.ACTION hook call outcome: OK', - 'Hook Hook-D event ACTION called by Source-A', - 'Source-A.ACTION hook call outcome: OK', - 'Hook Hook-A event ACTION called by Source-A', - 'Source-A.ACTION hook call outcome: OK', - 'Hook Hook-B event ACTION called by Source-A', - 'Source-A.ACTION hook call outcome: ERROR (Error in hook)']) - # Optimizations - self.assertIn(HookFlag.CLASS, hook_manager.flags) - self.assertIn(HookFlag.INSTANCE, hook_manager.flags) - self.assertIn(HookFlag.ANY_EVENT, hook_manager.flags) - self.assertIn(HookFlag.NAME, hook_manager.flags) - def test_class_hooks(self): - # register hookables - hook_manager.register_class(MyHookable, MyEvents) - # Install hooks - hook_A: MyHook = MyHook(self, 'Hook-A') - hook_B: MyHook = MyHook(self, 'Hook-B') - hook_C: MyHook = MyHook(self, 'Hook-C') - hook_D: MyHook = MyHook(self, 'Hook-D') - hook_manager.add_hook(MyEvents.CREATE, MyHookable, hook_A.callback) - hook_manager.add_hook(ANY, MyHookable, hook_B.err_callback) - hook_manager.add_hook(MyEvents.CREATE, 'Source-A', hook_C.callback) - hook_manager.add_hook(ANY, 'Source-A', hook_D.callback) - # Create event sources, emits CREATE - self.output.clear() - MyHookable(self, 'Source-A', use_class=True) - self.assertListEqual(self.output, - ['Hook Hook-A event CREATE called by Source-A', - 'Source-A.CREATE hook call outcome: OK', - 'Hook Hook-B event CREATE called by Source-A', - 'Source-A.CREATE hook call outcome: ERROR (Error in hook)']) - def test_name_hooks(self): - # register hookables - hook_manager.register_class(MyHookable, MyEvents) - # Install hooks - hook_A: MyHook = MyHook(self, 'Hook-A') - hook_B: MyHook = MyHook(self, 'Hook-B') - hook_C: MyHook = MyHook(self, 'Hook-C') - hook_D: MyHook = MyHook(self, 'Hook-D') - hook_manager.add_hook(MyEvents.CREATE, MyHookable, hook_A.callback) - hook_manager.add_hook(ANY, MyHookable, hook_B.err_callback) - hook_manager.add_hook(MyEvents.CREATE, 'Source-A', hook_C.callback) - hook_manager.add_hook(ANY, 'Source-A', hook_D.err_callback) - # Create event sources, emits CREATE - self.output.clear() - MyHookable(self, 'Source-A', use_name=True) - self.assertListEqual(self.output, - ['Hook Hook-C event CREATE called by Source-A', - 'Source-A.CREATE hook call outcome: OK', - 'Hook Hook-D event CREATE called by Source-A', - 'Source-A.CREATE hook call outcome: ERROR (Error in hook)']) + manager.add_hook(ANY, ANY, global_callback) + manager.add_hook(MyEvents.ACTION, ANY, specific_global_callback) + manager.add_hook(MyEvents.ACTION, MyHookable, class_callback) + manager.add_hook(MyEvents.ACTION, 'Source-A', name_callback) + manager.add_hook(MyEvents.ACTION, source, instance_callback) + callbacks = manager.get_callbacks(MyEvents.ACTION, source) + assert callbacks.count(global_callback) == 1 + assert callbacks.count(specific_global_callback) == 1 + assert callbacks.count(class_callback) == 1 + assert callbacks.count(name_callback) == 1 + assert callbacks.count(instance_callback) == 1 + assert manager.get_callbacks(MyEvents.DELETE, source) == [global_callback] + assert set(manager.get_callbacks(MyEvents.ACTION, object())) == {global_callback, specific_global_callback} + assert set(manager.get_callbacks(MyEvents.ACTION, [])) == {global_callback, specific_global_callback} -if __name__ == '__main__': - unittest.main() + manager.remove_hook(ANY, ANY, global_callback) + assert HookFlag.ANY_SOURCE in manager.flags + assert HookFlag.ANY_EVENT not in manager.flags + assert specific_global_callback in manager.get_callbacks(MyEvents.ACTION, source) + assert manager.get_callbacks(MyEvents.DELETE, source) == [] diff --git a/tests/test_logging.py b/tests/test_logging.py index e50c04c..0ac4830 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -3,8 +3,8 @@ # SPDX-License-Identifier: MIT # # PROGRAM/MODULE: firebird-base -# FILE: test/test_logging.py -# DESCRIPTION: Unit tests for firebird.base.logging +# FILE: tests/test_logging.py +# DESCRIPTION: Tests for firebird.base.logging # CREATED: 21.5.2020 # # The contents of this file are subject to the MIT License @@ -37,269 +37,597 @@ """ from __future__ import annotations -import unittest -import platform -from logging import getLogger, Formatter, lastResort, LogRecord -from firebird.base.types import * -from firebird.base.logging import logging_manager, get_logger, bind_logger, \ - LogLevel, BindFlag, install_null_logger + +import logging +from contextlib import contextmanager +from typing import Any # Added for type hints + +import pytest + +import firebird.base.logging as fblog +# Assuming types.py is importable for DEFAULT sentinel if used +from firebird.base.types import DEFAULT + +# --- Test Setup & Fixtures --- class Namespace: - "Simple Namespace" - -DECORATED = Namespace() -DECORATED.name = 'DECORATED' - -class BaseLoggingTest(unittest.TestCase): - "Base class for logging unit tests" - def __init__(self, methodName='runTest'): - super().__init__(methodName) - self.logger = getLogger() - self.logger.setLevel(LogLevel.NOTSET) - self.fmt: Formatter = Formatter("%(levelname)10s: [%(name)s] topic='%(topic)s' agent=%(agent)s context=%(context)s %(message)s") - lastResort.setLevel(LogLevel.NOTSET) - def setUp(self) -> None: - logging_manager.clear() - self.logger.handlers.clear() - #lastResort.setFormatter(self.fmt) - #self.logger.addHandler(lastResort) - def tearDown(self): - pass - def show(self, records, attrs=None): - while records: - item = records.pop(0) - try: - print({k: v for k, v in vars(item).items() if attrs is None or k in attrs}) - except: - print(item) - -class TestLogging(BaseLoggingTest): - """Unit tests for firebird.base.logging""" - def test_module(self): - self.assertIsNotNone(lastResort) - self.assertIsNotNone(lastResort.formatter) - def test_aaa(self): - if int(platform.python_version_tuple()[1]) < 11: - AGENT = 'test_aaa (test_logging.TestLogging)' - else: - AGENT = 'test_aaa (test_logging.TestLogging.test_aaa)' - # root - with self.assertLogs() as log: - get_logger(self).info('Message') - rec: LogRecord = log.records.pop(0) - self.assertEqual(rec.name, 'root') - self.assertEqual(rec.levelno, LogLevel.INFO) - self.assertEqual(rec.args, ()) - self.assertEqual(rec.module, 'test_logging') - self.assertEqual(rec.filename, 'test_logging.py') - self.assertEqual(rec.funcName, 'test_aaa') - self.assertEqual(rec.topic, '') - self.assertEqual(rec.agent, AGENT) - self.assertEqual(rec.context, UNDEFINED) - self.assertEqual(rec.message, 'Message') - # trace - with self.assertLogs() as log: - get_logger(self, topic='trace').info('Message') - rec = log.records.pop(0) - self.assertEqual(rec.name, 'trace') - self.assertEqual(rec.levelno, LogLevel.INFO) - self.assertEqual(rec.args, ()) - self.assertEqual(rec.module, 'test_logging') - self.assertEqual(rec.filename, 'test_logging.py') - self.assertEqual(rec.funcName, 'test_aaa') - self.assertEqual(rec.topic, 'trace') - self.assertEqual(rec.agent, AGENT) - self.assertEqual(rec.context, UNDEFINED) - self.assertEqual(rec.message, 'Message') - def test_interpolation(self): - data = ['interpolation', 'breakdown', 'overflow'] - # Using keyword arguments - with self.assertLogs() as log: - get_logger(self).info('Information {data}', data=data[0]) - rec: LogRecord = log.records.pop(0) - self.assertEqual(rec.message, 'Information interpolation') - # Using positional dictionary - with self.assertLogs() as log: - get_logger(self).info('Information {data}', {'data': data[1]}) - rec = log.records.pop(0) - self.assertEqual(rec.message, 'Information breakdown') - # Using positional args - with self.assertLogs() as log: - get_logger(self).info('Information {args[0][2]}', data) - rec = log.records.pop(0) - self.assertEqual(rec.message, 'Information overflow') - def test_bind(self): - LOG_AC = 'test.agent.ctx' - LOG_AX = 'test.agent.ANY' - LOG_XC = 'test.ANY.ctx' - LOG_XX = 'test.ANY.ANY' - ctx = Namespace() - ctx.logging_id = 'CONTEXT' - ctx_B = Namespace() - ctx_B.logging_id = 'B-CONTEXT' - agent = Namespace() - agent.logging_id = 'AGENT' - agent_B = Namespace() - agent_B.logging_id = 'B-AGENT' - # - logging_manager.bind_logger(agent, ctx, LOG_AC) - logging_manager.bind_logger(agent, ANY, LOG_AX) - logging_manager.bind_logger(ANY, ctx, LOG_XC) - # root logger unmasked - with self.assertLogs(level='DEBUG') as log: - get_logger(agent_B, ctx_B).debug('General:B-Agent:B-context') - rec: LogRecord = log.records.pop(0) - self.assertEqual(rec.name, 'root') - # this will mask the root logger, we also test the `bind_logger()` - bind_logger(ANY, ANY, LOG_XX) - # - self.assertTrue(BindFlag.ANY_AGENT in logging_manager.bindings) - self.assertTrue(BindFlag.ANY_CTX in logging_manager.bindings) - self.assertTrue(BindFlag.ANY_ANY in logging_manager.bindings) - self.assertTrue(BindFlag.DIRECT in logging_manager.bindings) - self.assertEqual(len(logging_manager.loggers), 4) - self.assertEqual(len(logging_manager.topics), 1) - self.assertEqual(logging_manager.topics[''], 4) - # - with self.assertLogs(level='DEBUG') as log: - get_logger(agent, ctx).debug('General:Agent:Context') - rec = log.records.pop(0) - self.assertEqual(rec.name, LOG_AC) - # - with self.assertLogs(level='DEBUG') as log: - get_logger(agent, ctx_B).debug('General:Agent:B-context') - rec = log.records.pop(0) - self.assertEqual(rec.name, LOG_AX) - # - with self.assertLogs(level='DEBUG') as log: - get_logger(agent, ANY).debug('General:Agent:ANY') - rec = log.records.pop(0) - self.assertEqual(rec.name, LOG_AX) - # - with self.assertLogs(level='DEBUG') as log: - get_logger(agent).debug('General:Agent:UNDEFINED') - rec = log.records.pop(0) - self.assertEqual(rec.name, LOG_AX) - # - with self.assertLogs(level='DEBUG') as log: - get_logger(agent_B, ctx).debug('General:B-Agent:Context') - rec = log.records.pop(0) - self.assertEqual(rec.name, LOG_XC) - # - with self.assertLogs(level='DEBUG') as log: - get_logger(context=ctx).debug('General:UNDEFINED:Context') - rec = log.records.pop(0) - self.assertEqual(rec.name, LOG_XC) - # - with self.assertLogs(level='DEBUG') as log: - get_logger(agent_B, ctx_B).debug('General:B-Agent:B-context') - rec = log.records.pop(0) - self.assertEqual(rec.name, LOG_XX) - # - with self.assertLogs(level='DEBUG') as log: - get_logger().debug('General:UNDEFINED:UNDEFINED') - rec = log.records.pop(0) - self.assertEqual(rec.name, LOG_XX) - def test_clear(self): - LOG_AC = 'test.agent.ctx' - LOG_AX = 'test.agent.ANY' - LOG_XC = 'test.ANY.ctx' - LOG_XX = 'test.ANY.ANY' - ctx = Namespace() - ctx.logging_id = 'CONTEXT' - agent = Namespace() - agent.logging_id = 'AGENT' - # - logging_manager.bind_logger(agent, ctx, LOG_AC) - logging_manager.bind_logger(agent, ANY, LOG_AX) - logging_manager.bind_logger(ANY, ctx, LOG_XC) - logging_manager.bind_logger(ANY, ANY, LOG_XX) - # - self.assertTrue(BindFlag.ANY_AGENT in logging_manager.bindings) - self.assertTrue(BindFlag.ANY_CTX in logging_manager.bindings) - self.assertTrue(BindFlag.ANY_ANY in logging_manager.bindings) - self.assertTrue(BindFlag.DIRECT in logging_manager.bindings) - self.assertEqual(len(logging_manager.loggers), 4) - self.assertEqual(len(logging_manager.topics), 1) - # Clear - logging_manager.clear() - self.assertFalse(BindFlag.ANY_AGENT in logging_manager.bindings) - self.assertFalse(BindFlag.ANY_CTX in logging_manager.bindings) - self.assertFalse(BindFlag.ANY_ANY in logging_manager.bindings) - self.assertFalse(BindFlag.DIRECT in logging_manager.bindings) - self.assertEqual(len(logging_manager.loggers), 0) - self.assertEqual(len(logging_manager.topics), 0) - def test_unbind(self): - LOG_AC = 'test.agent.ctx' - LOG_AX = 'test.agent.ANY' - LOG_XC = 'test.ANY.ctx' - LOG_XX = 'test.ANY.ANY' - ctx = Namespace() - ctx.logging_id = 'CONTEXT' - ctx_B = Namespace() - ctx_B.logging_id = 'B-CONTEXT' - agent = Namespace() - agent.logging_id = 'AGENT' - agent_B = Namespace() - agent_B.logging_id = 'B-AGENT' - # - logging_manager.bind_logger(agent, ctx, LOG_AC) - logging_manager.bind_logger(agent, ANY, LOG_AX) - logging_manager.bind_logger(ANY, ctx, LOG_XC) - logging_manager.bind_logger(ANY, ANY, LOG_XX) - # - self.assertTrue(BindFlag.ANY_AGENT in logging_manager.bindings) - self.assertTrue(BindFlag.ANY_CTX in logging_manager.bindings) - self.assertTrue(BindFlag.ANY_ANY in logging_manager.bindings) - self.assertTrue(BindFlag.DIRECT in logging_manager.bindings) - self.assertEqual(len(logging_manager.loggers), 4) - self.assertEqual(len(logging_manager.topics), 1) - self.assertEqual(logging_manager.topics[''], 4) - # Unbind - # nothing to remove - self.assertEqual(0, logging_manager.unbind(agent, ctx, 'trace')) - self.assertEqual(0, logging_manager.unbind(agent, ctx_B)) - self.assertEqual(0, logging_manager.unbind(agent_B, ctx)) - # targeted - self.assertEqual(1, logging_manager.unbind(ANY, ANY)) - self.assertTrue(BindFlag.ANY_AGENT in logging_manager.bindings) - self.assertTrue(BindFlag.ANY_CTX in logging_manager.bindings) - self.assertFalse(BindFlag.ANY_ANY in logging_manager.bindings) - self.assertTrue(BindFlag.DIRECT in logging_manager.bindings) - self.assertEqual(len(logging_manager.loggers), 3) - self.assertEqual(logging_manager.topics[''], 3) - # group (all agents for context) - self.assertEqual(2, logging_manager.unbind(ALL, ctx)) - self.assertFalse(BindFlag.ANY_AGENT in logging_manager.bindings) - self.assertTrue(BindFlag.ANY_CTX in logging_manager.bindings) - self.assertFalse(BindFlag.ANY_ANY in logging_manager.bindings) - self.assertFalse(BindFlag.DIRECT in logging_manager.bindings) - self.assertEqual(len(logging_manager.loggers), 1) - self.assertEqual(logging_manager.topics[''], 1) - # rebind - logging_manager.bind_logger(agent, ctx, LOG_AC) - logging_manager.bind_logger(agent, ANY, LOG_AX) - logging_manager.bind_logger(ANY, ctx, LOG_XC) - logging_manager.bind_logger(ANY, ANY, LOG_XX) - # group (all contexts for agent) - self.assertEqual(2, logging_manager.unbind(agent, ALL)) - self.assertTrue(BindFlag.ANY_AGENT in logging_manager.bindings) - self.assertTrue(BindFlag.ANY_CTX in logging_manager.bindings) - self.assertTrue(BindFlag.ANY_ANY in logging_manager.bindings) - self.assertFalse(BindFlag.DIRECT in logging_manager.bindings) - self.assertEqual(len(logging_manager.loggers), 2) - self.assertEqual(logging_manager.topics[''], 2) - def test_null_logger(self): - with self.assertLogs() as log: - get_logger(self).info('Message') - rec: LogRecord = log.records.pop(0) - self.assertEqual(rec.message, 'Message') - install_null_logger() - bind_logger(ANY, ANY, 'null') - with self.assertRaises(AssertionError) as cm: - with self.assertLogs() as log: - get_logger(self).info('Message') - self.assertEqual(cm.exception.args, ("no logs of level INFO or higher triggered on root",)) - -if __name__ == '__main__': - unittest.main() + """Simple class acting as a namespace for holding attributes.""" + pass + +class NaiveAgent: + """A test agent class without specific logging awareness.""" + @property + def name(self): + """Returns the agent name determined by the logging manager.""" + return fblog.get_agent_name(self) + +class AwareAgentAttr: + """A test agent class with a static _agent_name_ attribute.""" + _agent_name_: str = "_agent_name_attr" + log_context: Any = None # Add for testing context propagation + @property + def name(self): + """Returns the agent name determined by the logging manager.""" + return fblog.get_agent_name(self) + +class AwareAgentProperty: + """A test agent class with a dynamic _agent_name_ property.""" + def __init__(self, agent_name: Any): + self._int_agent_name = agent_name + @property + def _agent_name_(self) -> Any: + """Dynamically returns the configured agent name.""" + return self._int_agent_name + @property + def name(self): + """Returns the agent name determined by the logging manager.""" + return fblog.get_agent_name(self) + + +@contextmanager +def context_filter(target_logger: logging.Logger): + """Context manager to temporarily add the ContextFilter to a logger.""" + ctx_filter = fblog.ContextFilter() + target_logger.addFilter(ctx_filter) + try: + yield + finally: + target_logger.removeFilter(ctx_filter) + +# --- Test Functions --- + +def test_fstr_message(): + """Tests the FStrMessage formatter for f-string style log messages.""" + ns = Namespace() + ns.nested = Namespace() + ns.nested.item = "item!" + ns.attr = "attr" + ns.number = 5 + # Simple message + msg = fblog.FStrMessage("-> Message <-") + assert str(msg) == "-> Message <-" + # Message with nested attributes, expressions, repr, initialized via dict + msg = fblog.FStrMessage("Let's see {ns.number=} * 5 = {ns.number * 5}, [{ns.nested.item}] or {ns.attr!r}", {"ns": ns}) + assert str(msg) == "Let's see ns.number=5 * 5 = 25, [item!] or 'attr'" + # Same, initialized via kwargs + msg = fblog.FStrMessage("Let's see {ns.number=} * 5 = {ns.number * 5}, [{ns.nested.item}] or {ns.attr!r}", ns=ns) + assert str(msg) == "Let's see ns.number=5 * 5 = 25, [item!] or 'attr'" + # Message with positional args treated as 'args' list + msg = fblog.FStrMessage("Let's see {args[0]=} * 5 = {args[0] * 5}, {ns.attr!r}", 5, ns=ns) + assert str(msg) == "Let's see args[0]=5 * 5 = 25, 'attr'" + +def test_brace_message(): + """Tests the BraceMessage formatter for str.format() style log messages.""" + point = Namespace() + point.x = 0.5 + point.y = 0.5 + # Positional placeholders + msg = fblog.BraceMessage("Message with {0} {1}", 2, "placeholders") + assert str(msg) == "Message with 2 placeholders" + # Keyword placeholders with formatting + msg = fblog.BraceMessage("Message with coordinates: ({point.x:.2f}, {point.y:.2f})", point=point) + assert str(msg) == "Message with coordinates: (0.50, 0.50)" + +def test_dollar_message(): + """Tests the DollarMessage formatter for string.Template style log messages.""" + point = Namespace() + point.x = 0.5 + point.y = 0.5 + # Keyword substitution + msg = fblog.DollarMessage("Message with $num $what", num=2, what="placeholders") + assert str(msg) == "Message with 2 placeholders" + # Note: DollarMessage doesn't support attribute access like {point.x} directly + +def test_context_filter_alone(caplog): + """Tests the ContextFilter when applied directly to a standard logger. + + Ensures it adds the required attributes (domain, topic, agent, context) + with None values if they are not already present on the LogRecord. + """ + caplog.set_level(logging.INFO) + log = logging.getLogger("test_context_filter_logger") + log.propagate = False # Prevent interference from root logger if handlers exist + handler = caplog.handler # Use pytest's handler + log.addHandler(handler) + + # Log without the filter + log.info("Message 1") + assert len(caplog.records) == 1 + rec1 = caplog.records[0] + assert not hasattr(rec1, "domain") + assert not hasattr(rec1, "topic") + assert not hasattr(rec1, "agent") + assert not hasattr(rec1, "context") + caplog.clear() + + # Log with the filter applied + with context_filter(log): + log.info("Message 2") + assert len(caplog.records) == 1 + rec2 = caplog.records[0] + # Filter should have added attributes with None values + assert hasattr(rec2, "domain") and rec2.domain is None + assert hasattr(rec2, "topic") and rec2.topic is None + assert hasattr(rec2, "agent") and rec2.agent is None + assert hasattr(rec2, "context") and rec2.context is None + + log.removeHandler(handler) # Clean up handler + + +def test_context_adapter_basic(caplog): + """Tests the basic functionality of ContextLoggerAdapter. + + Verifies that it correctly adds domain, topic, agent name, and context (from agent) + to the LogRecord's dictionary via the 'extra' mechanism. + """ + caplog.set_level(logging.INFO) + agent_obj = AwareAgentAttr() + agent_obj.log_context = "Agent Context Data" + adapter = fblog.ContextLoggerAdapter(logging.getLogger("adapter_test"), + domain="domain1", topic="topic1", + agent=agent_obj, agent_name="agent_name1") + adapter.logger.propagate = False # Isolate logger + adapter.logger.addHandler(caplog.handler) + + adapter.info("Adapter message") + + assert len(caplog.records) == 1 + rec = caplog.records[0] + assert rec.domain == "domain1" + assert rec.topic == "topic1" + assert rec.agent == "agent_name1" + assert rec.context == "Agent Context Data" + + # Test overriding context via extra dict + caplog.clear() + adapter.info("Override context", extra={'context': 'OVERRIDE'}) + assert len(caplog.records) == 1 + rec_override = caplog.records[0] + assert rec_override.context == 'OVERRIDE' # Should use value from 'extra' + + adapter.logger.removeHandler(caplog.handler) + + +def test_context_adapter_with_filter(caplog): + """Tests ContextLoggerAdapter used in conjunction with ContextFilter. + + This combination is typical. The adapter adds the data, and the filter + ensures the attributes exist even if the adapter somehow didn't add them + (though that shouldn't happen with the adapter). Mainly verifies they don't conflict. + """ + caplog.set_level(logging.INFO) + adapter = fblog.ContextLoggerAdapter(logging.getLogger("adapter_filter_test"), + "domain", "topic", "agent", "agent_name") + adapter.logger.propagate = False + adapter.logger.addHandler(caplog.handler) + + with context_filter(adapter.logger): + adapter.info("Adapter+Filter message") + assert len(caplog.records) == 1 + rec = caplog.records[0] + assert rec.domain == "domain" + assert rec.topic == "topic" + assert rec.agent == "agent_name" + assert rec.context is None # Agent provided was string, no log_context + + adapter.logger.removeHandler(caplog.handler) + + +def test_mngr_default_domain(): + """Tests setting and getting the default_domain on the LoggingManager.""" + manager = fblog.LoggingManager() + assert manager.default_domain is None + manager.default_domain = "default_domain_test" + assert manager.default_domain == "default_domain_test" + manager.default_domain = None + assert manager.default_domain is None + manager.default_domain = 123 # Should convert to string + assert manager.default_domain == "123" + + +def test_mngr_logger_fmt(): + """Tests setting, getting, and validation of logger_fmt on the LoggingManager.""" + manager = fblog.LoggingManager() + assert manager.logger_fmt == [] # Default is empty list + + # Set valid format + value = ["app", fblog.DOMAIN, "module"] + manager.logger_fmt = value + assert manager.logger_fmt == value + # Ensure internal list is a copy + value[0] = "xxx_changed" + assert manager.logger_fmt == ["app", fblog.DOMAIN, "module"] # Should not have changed + + # Test empty string removal + manager.logger_fmt = ["app", "", fblog.TOPIC] + assert manager.logger_fmt == ["app", fblog.TOPIC] + + # Test invalid item types + with pytest.raises(ValueError, match="Unsupported item type"): + manager.logger_fmt = ["app", None, "module"] + with pytest.raises(ValueError, match="Unsupported item type"): + manager.logger_fmt = [1] + + # Test duplicate sentinels + with pytest.raises(ValueError, match="Only one occurence of sentinel TOPIC allowed"): + manager.logger_fmt = ["app", fblog.TOPIC, "x", fblog.TOPIC] + with pytest.raises(ValueError, match="Only one occurence of sentinel DOMAIN allowed"): + manager.logger_fmt = ["app", fblog.DOMAIN, "x", fblog.DOMAIN] + + # Test valid combination + value = ["app", fblog.DOMAIN, fblog.TOPIC, "suffix"] + manager.logger_fmt = value + assert manager.logger_fmt == value + + +def test_mngr_get_logger_name_generation(): + """Tests the internal _get_logger_name method for various formats and inputs.""" + manager = fblog.LoggingManager() + + # Default format (empty) + assert manager._get_logger_name("domain", "topic") == "" + + # Simple formats + manager.logger_fmt = ["app"] + assert manager._get_logger_name("domain", "topic") == "app" + manager.logger_fmt = ["app", "module"] + assert manager._get_logger_name("domain", "topic") == "app.module" + + # Format with DOMAIN + manager.logger_fmt = ["app", fblog.DOMAIN] + assert manager._get_logger_name("domain", "topic") == "app.domain" + assert manager._get_logger_name(None, "topic") == "app" # Domain None ignored + + # Format with TOPIC + manager.logger_fmt = ["app", fblog.TOPIC] + assert manager._get_logger_name("domain", "topic") == "app.topic" + assert manager._get_logger_name("domain", None) == "app" # Topic None ignored + + # Format with DOMAIN and TOPIC + manager.logger_fmt = ["app", fblog.DOMAIN, fblog.TOPIC] + assert manager._get_logger_name("domain", "topic") == "app.domain.topic" + assert manager._get_logger_name(None, "topic") == "app.topic" # Domain None ignored + assert manager._get_logger_name("domain", None) == "app.domain" # Topic None ignored + assert manager._get_logger_name(None, None) == "app" # Both None ignored + + # Format with empty strings and sentinels + manager.logger_fmt = ["prefix", "", fblog.TOPIC, "", fblog.DOMAIN, "suffix"] + assert manager._get_logger_name("domain", "topic") == "prefix.topic.domain.suffix" + + +def test_mngr_set_get_topic_mapping(): + """Tests setting, getting, and removing topic mappings.""" + topic_orig = "original_topic" + topic_mapped = "mapped_topic" + manager = fblog.LoggingManager() + + assert len(manager._topic_map) == 0 + assert manager.get_topic_mapping(topic_orig) is None + + # Set mapping + manager.set_topic_mapping(topic_orig, topic_mapped) + assert len(manager._topic_map) == 1 + assert manager.get_topic_mapping(topic_orig) == topic_mapped + assert manager.get_topic_mapping(topic_mapped) is None # Mapping is one-way + + # Remove mapping using None + manager.set_topic_mapping(topic_orig, None) + assert len(manager._topic_map) == 0 + assert manager.get_topic_mapping(topic_orig) is None + + # Remove mapping using empty string + manager.set_topic_mapping(topic_orig, topic_mapped) + manager.set_topic_mapping(topic_orig, "") + assert len(manager._topic_map) == 0 + assert manager.get_topic_mapping(topic_orig) is None + + # Test setting non-string (should be converted) + manager.set_topic_mapping(topic_orig, DEFAULT) + assert manager.get_topic_mapping(topic_orig) == str(DEFAULT) + assert len(manager._topic_map) == 1 + + +def test_mngr_get_agent_name_various(): + """Tests get_agent_name with different agent types and mappings.""" + manager = fblog.LoggingManager() + agent_str = "agent_string_id" + agent_naive = NaiveAgent() + agent_aware_attr = AwareAgentAttr() + agent_aware_prop = AwareAgentProperty("_agent_name_property") + agent_aware_nonstr = AwareAgentProperty(123) # Property returns int + + # String agent + assert manager.get_agent_name(agent_str) == agent_str + + # Naive object agent (uses class path) + expected_naive_name = "tests.test_logging.NaiveAgent" # Adjust if file location changes + assert manager.get_agent_name(agent_naive) == expected_naive_name + + # Aware object agent (attribute) + assert manager.get_agent_name(agent_aware_attr) == "_agent_name_attr" + + # Aware object agent (property) + assert manager.get_agent_name(agent_aware_prop) == "_agent_name_property" + + # Aware object agent (property returning non-string) + assert manager.get_agent_name(agent_aware_nonstr) == "123" # Should be converted to str + + # Test with agent mapping + mapped_name = "mapped_agent_id" + manager.set_agent_mapping(agent_str, mapped_name) + assert manager.get_agent_name(agent_str) == mapped_name # Should return mapped name + + manager.set_agent_mapping(expected_naive_name, mapped_name) + assert manager.get_agent_name(agent_naive) == mapped_name # Should map the derived name + + manager.set_agent_mapping("_agent_name_attr", mapped_name) + assert manager.get_agent_name(agent_aware_attr) == mapped_name + + +def test_mngr_set_get_agent_mapping(): + """Tests setting, getting, and removing agent name mappings.""" + agent_orig = "original_agent" + agent_mapped = "mapped_agent" + manager = fblog.LoggingManager() + + assert len(manager._agent_map) == 0 + assert manager.get_agent_mapping(agent_orig) is None + + # Set mapping + manager.set_agent_mapping(agent_orig, agent_mapped) + assert len(manager._agent_map) == 1 + assert manager.get_agent_mapping(agent_orig) == agent_mapped + + # Remove mapping using None + manager.set_agent_mapping(agent_orig, None) + assert len(manager._agent_map) == 0 + assert manager.get_agent_mapping(agent_orig) is None + + # Remove mapping using empty string + manager.set_agent_mapping(agent_orig, agent_mapped) + manager.set_agent_mapping(agent_orig, "") + assert len(manager._agent_map) == 0 + assert manager.get_agent_mapping(agent_orig) is None + + # Test setting non-string (should be converted) + manager.set_agent_mapping(agent_orig, DEFAULT) + assert manager.get_agent_mapping(agent_orig) == str(DEFAULT) + assert len(manager._agent_map) == 1 + + +def test_mngr_set_get_domain_mapping(): + """Tests setting, getting, updating, replacing, and removing domain mappings for agents.""" + domain1 = "domain1" + domain2 = "domain2" + agent1_name = "agent1" + agent2_name = "agent2" + agent3_name = "agent3" + manager = fblog.LoggingManager() + + # Initial state + assert len(manager._agent_domain_map) == 0 + assert len(manager._domain_agent_map) == 0 + assert manager.get_agent_domain(agent1_name) is None + assert manager.get_domain_mapping(domain1) is None + + # Set initial mapping (list) + manager.set_domain_mapping(domain1, [agent1_name, agent2_name]) + assert len(manager._agent_domain_map) == 2 + assert len(manager._domain_agent_map) == 1 + assert manager.get_domain_mapping(domain1) == {agent1_name, agent2_name} + assert manager.get_agent_domain(agent1_name) == domain1 + assert manager.get_agent_domain(agent2_name) == domain1 + assert manager.get_agent_domain(agent3_name) is None + + # Update mapping (add agent3, agent1 is duplicate but ok) + manager.set_domain_mapping(domain1, [agent1_name, agent3_name]) + assert len(manager._agent_domain_map) == 3 + assert len(manager._domain_agent_map) == 1 + assert manager.get_domain_mapping(domain1) == {agent1_name, agent2_name, agent3_name} + assert manager.get_agent_domain(agent1_name) == domain1 + assert manager.get_agent_domain(agent2_name) == domain1 + assert manager.get_agent_domain(agent3_name) == domain1 + + # Set mapping for a different domain (single agent name) + manager.set_domain_mapping(domain2, agent1_name) # Agent1 now maps to domain2 + assert len(manager._agent_domain_map) == 3 # Still 3 agents mapped + assert len(manager._domain_agent_map) == 2 # Now 2 domains + assert manager.get_domain_mapping(domain1) == {agent2_name, agent3_name} # agent1 removed from domain1 + assert manager.get_domain_mapping(domain2) == {agent1_name} + assert manager.get_agent_domain(agent1_name) == domain2 # agent1 updated + assert manager.get_agent_domain(agent2_name) == domain1 + assert manager.get_agent_domain(agent3_name) == domain1 + + # Replace mapping for domain1 + manager.set_domain_mapping(domain1, agent1_name, replace=True) # Should remove agent2, agent3 first + assert len(manager._agent_domain_map) == 1 # Only agent1 mapped now + assert len(manager._domain_agent_map) == 1 # Only domain1 remains + assert manager.get_domain_mapping(domain1) == {agent1_name} + assert manager.get_domain_mapping(domain2) is None # domain2 mapping removed + assert manager.get_agent_domain(agent1_name) == domain1 # agent1 updated again + assert manager.get_agent_domain(agent2_name) is None + assert manager.get_agent_domain(agent3_name) is None + + # Remove mapping for domain1 using None + manager.set_domain_mapping(domain1, None) + assert len(manager._agent_domain_map) == 0 + assert len(manager._domain_agent_map) == 0 + assert manager.get_agent_domain(agent1_name) is None + assert manager.get_domain_mapping(domain1) is None + + +def test_mngr_get_logger_scenarios(): + """Tests get_logger under various manager configurations.""" + manager = fblog.LoggingManager() + agent = "test_agent" + agent_mapped = "mapped_agent_name" + domain_specific = "specific_domain" + domain_default = "default_domain" + topic_orig = "original_topic" + topic_mapped = "mapped_topic" + app_name = "my_app" + + # Scenario 1: No mappings, no format, no defaults + logger = manager.get_logger(agent) + assert isinstance(logger, fblog.ContextLoggerAdapter) + assert logger.logger.name == "root" # Default logger name + assert logger.extra == {"domain": None, "topic": None, "agent": agent} + + # Setup for subsequent tests + manager.logger_fmt = [app_name, fblog.DOMAIN, fblog.TOPIC] + manager.default_domain = domain_default + manager.set_topic_mapping(topic_orig, topic_mapped) + manager.set_agent_mapping(agent, agent_mapped) + manager.set_domain_mapping(domain_specific, agent_mapped) # Map the *mapped* agent name + + # Scenario 2: All mappings active + logger = manager.get_logger(agent, topic_orig) + # Expected: Agent name mapped, domain from specific mapping, topic mapped, full logger name format + assert logger.logger.name == f"{app_name}.{domain_specific}.{topic_mapped}" + assert logger.extra['agent'] == agent_mapped + assert logger.extra['domain'] == domain_specific + assert logger.extra['topic'] == topic_mapped + + # Scenario 3: Agent mapped, but no specific domain mapping for mapped name, uses default domain + manager.set_domain_mapping(domain_specific, None) # Remove specific mapping + logger = manager.get_logger(agent, topic_orig) + assert logger.logger.name == f"{app_name}.{domain_default}.{topic_mapped}" + assert logger.extra['agent'] == agent_mapped + assert logger.extra['domain'] == domain_default # Falls back to default + assert logger.extra['topic'] == topic_mapped + manager.set_domain_mapping(domain_specific, agent_mapped) # Restore mapping for next test + + # Scenario 4: No topic provided + logger = manager.get_logger(agent) # Topic is None + assert logger.logger.name == f"{app_name}.{domain_specific}" # Topic omitted from name + assert logger.extra['agent'] == agent_mapped + assert logger.extra['domain'] == domain_specific + assert logger.extra['topic'] is None + + # Scenario 5: No domain mapping and no default domain + manager.set_domain_mapping(domain_specific, None) + manager.default_domain = None + logger = manager.get_logger(agent, topic_orig) + assert logger.logger.name == f"{app_name}.{topic_mapped}" # Domain omitted from name + assert logger.extra['agent'] == agent_mapped + assert logger.extra['domain'] is None + assert logger.extra['topic'] == topic_mapped + + +def test_logger_factory_integration(): + """Tests using a custom logger factory with the manager.""" + manager = fblog.LoggingManager() + custom_loggers_created = {} + + def my_logger_factory(name): + """Custom factory that tracks created loggers.""" + logger = logging.getLogger(name) # Still use standard mechanism internally + custom_loggers_created[name] = logger + return logger + + manager.set_logger_factory(my_logger_factory) + assert manager.get_logger_factory() is my_logger_factory + + # Get a logger via the manager + manager.logger_fmt = ["factory_test", fblog.DOMAIN] + manager.set_domain_mapping("domainX", "agentX") + logger_name = "factory_test.domainX" + + assert logger_name not in custom_loggers_created + adapter = manager.get_logger("agentX") + + # Check if factory was called and logger was retrieved + assert adapter.logger.name == logger_name + assert logger_name in custom_loggers_created + assert custom_loggers_created[logger_name] is adapter.logger + + # Restore default factory + manager.set_logger_factory(logging.getLogger) + assert manager.get_logger_factory() is logging.getLogger + + +def test_mngr_reset(): + """Tests that reset clears all manager configurations.""" + manager = fblog.LoggingManager() + # Setup some state + manager.set_agent_mapping("agent", "new_agent") + manager.set_domain_mapping("domain", "agent") + manager.set_topic_mapping("topic", "new_topic") + manager.logger_fmt = ["app"] + manager.default_domain = "app_default" + assert len(manager._agent_map) > 0 + assert len(manager._domain_agent_map) > 0 + assert len(manager._topic_map) > 0 + assert manager.logger_fmt != [] + assert manager.default_domain is not None + + # Reset + manager.reset() + + # Check state is cleared + assert len(manager._agent_map) == 0 + assert len(manager._domain_agent_map) == 0 + assert len(manager._agent_domain_map) == 0 # Check reverse map too + assert len(manager._topic_map) == 0 + assert manager.logger_fmt == [] + assert manager.default_domain is None + + +# Note: Tests for log record content (filename, funcName etc.) are kept from original +# as they verify standard logging behavior interaction. +def test_log_record_standard_attributes(caplog): + """Verifies standard LogRecord attributes like name, funcName, filename.""" + manager = fblog.LoggingManager() + agent_aware = AwareAgentAttr() + agent_aware.log_context = "Context data" + domain = "domain_rec_test" + topic = "topic_rec_test" + message = "Log message for record test" + manager.set_domain_mapping(domain, agent_aware.name) + manager.logger_fmt = ['record_test', fblog.DOMAIN] # Example format + + log = manager.get_logger(agent_aware, topic) + log.logger.propagate = False + log.logger.addHandler(caplog.handler) + caplog.set_level(logging.NOTSET) + + log.info(message) + assert len(caplog.records) == 1 + rec = caplog.records[0] + + # Check standard logging attributes + assert rec.name == "record_test.domain_rec_test" + assert rec.levelname == "INFO" + assert rec.levelno == logging.INFO + assert rec.getMessage() == message + # These depend on the exact location and execution context + assert rec.funcName.startswith("test_log_record_standard_attributes") # Check prefix + assert rec.filename == "test_logging.py" + assert rec.module == "test_logging" + # Check custom attributes + assert rec.domain == domain + assert rec.agent == agent_aware.name + assert rec.topic == topic + assert rec.context == "Context data" + + log.logger.removeHandler(caplog.handler) diff --git a/tests/test_protobuf.py b/tests/test_protobuf.py index 1cfe2ee..1b22970 100644 --- a/tests/test_protobuf.py +++ b/tests/test_protobuf.py @@ -3,8 +3,8 @@ # SPDX-License-Identifier: MIT # # PROGRAM/MODULE: firebird-base -# FILE: test/test_protobuf.py -# DESCRIPTION: Unit tests for firebird.base.protobuf +# FILE: tests/test_protobuf.py +# DESCRIPTION: Tests for firebird.base.protobuf # CREATED: 21.5.2020 # # The contents of this file are subject to the MIT License @@ -33,118 +33,127 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________ -"""firebird-base - Unit tests for firebird.base.protobuf - -""" - from __future__ import annotations -import unittest + from enum import IntEnum -from firebird.base.types import Error -from firebird.base.protobuf import register_decriptor, get_enum_type, get_enum_field_type, \ - get_enum_value_name, is_enum_registered, is_msg_registered, create_message, \ - ProtoEnumType, _enumreg, _msgreg -from base_test_pb2 import DESCRIPTOR -ENUM_TYPE_NAME: str = 'firebird.base.TestEnum' -STATE_MSG_TYPE_NAME: str = 'firebird.base.TestState' -COLLECTION_MSG_TYPE_NAME: str = 'firebird.base.TestCollection' +import pytest + +from firebird.base.protobuf import ( + ProtoEnumType, + _enumreg, + _msgreg, + create_message, + get_enum_field_type, + get_enum_type, + get_enum_value_name, + is_enum_registered, + is_msg_registered, + register_descriptor, +) + +from .base_test_pb2 import DESCRIPTOR + +## TODO: +# +# - struct2dict +# - dict2struct +# - create_message(serualized=True) +# - get_message_factory + +ENUM_TYPE_NAME: str = "firebird.base.TestEnum" +STATE_MSG_TYPE_NAME: str = "firebird.base.TestState" +COLLECTION_MSG_TYPE_NAME: str = "firebird.base.TestCollection" + +@pytest.fixture(autouse=True) +def clear(): + _enumreg.clear() + _msgreg.clear() +def test_aaa_register(): + assert len(_msgreg) == 0 + assert len(_enumreg) == 0 + # + assert not is_enum_registered(ENUM_TYPE_NAME) + assert not is_msg_registered(STATE_MSG_TYPE_NAME) + assert not is_msg_registered(COLLECTION_MSG_TYPE_NAME) + # + register_descriptor(DESCRIPTOR) + # + assert is_enum_registered(ENUM_TYPE_NAME) + assert is_msg_registered(STATE_MSG_TYPE_NAME) + assert is_msg_registered(COLLECTION_MSG_TYPE_NAME) + # + assert len(_msgreg) == 2 + assert len(_enumreg) == 1 + assert ENUM_TYPE_NAME in _enumreg + assert STATE_MSG_TYPE_NAME in _msgreg + assert COLLECTION_MSG_TYPE_NAME in _msgreg -class TestProtobuf(unittest.TestCase): - """Unit tests for firebird.base.types""" - def __init__(self, methodName='runTest'): - super().__init__(methodName) - def setUp(self) -> None: - _enumreg.clear() - _msgreg.clear() - def tearDown(self): - pass - def test_aaa_register(self): - self.assertEqual(len(_msgreg), 0) - self.assertEqual(len(_enumreg), 0) - # - self.assertFalse(is_enum_registered(ENUM_TYPE_NAME)) - self.assertFalse(is_msg_registered(STATE_MSG_TYPE_NAME)) - self.assertFalse(is_msg_registered(COLLECTION_MSG_TYPE_NAME)) - # - register_decriptor(DESCRIPTOR) - # - self.assertTrue(is_enum_registered(ENUM_TYPE_NAME)) - self.assertTrue(is_msg_registered(STATE_MSG_TYPE_NAME)) - self.assertTrue(is_msg_registered(COLLECTION_MSG_TYPE_NAME)) - # - self.assertEqual(len(_msgreg), 2) - self.assertEqual(len(_enumreg), 1) - self.assertIn(ENUM_TYPE_NAME, _enumreg) - self.assertIn(STATE_MSG_TYPE_NAME, _msgreg) - self.assertIn(COLLECTION_MSG_TYPE_NAME, _msgreg) - def test_enums(self): - class TestEnum(IntEnum): - UNKNOWN = 0 - READY = 1 - RUNNING = 2 - WAITING = 3 - SUSPENDED = 4 - FINISHED = 5 - ABORTED = 6 - # Aliases - CREATED = 1 - BLOCKED = 3 - STOPPED = 4 - TERMINATED = 6 +def test_enums(): + class TestEnum(IntEnum): + UNKNOWN = 0 + READY = 1 + RUNNING = 2 + WAITING = 3 + SUSPENDED = 4 + FINISHED = 5 + ABORTED = 6 + # Aliases + CREATED = 1 + BLOCKED = 3 + STOPPED = 4 + TERMINATED = 6 - enum_spec = [('TEST_UNKNOWN', 0), - ('TEST_READY', 1), - ('TEST_RUNNING', 2), - ('TEST_WAITING', 3), - ('TEST_SUSPENDED', 4), - ('TEST_FINISHED', 5), - ('TEST_ABORTED', 6), - ('TEST_CREATED', 1), - ('TEST_BLOCKED', 3), - ('TEST_STOPPED', 4), - ('TEST_TERMINATED', 6), - ] - register_decriptor(DESCRIPTOR) - # Value name - self.assertEqual(get_enum_value_name(ENUM_TYPE_NAME, TestEnum.SUSPENDED), - f'TEST_{TestEnum.SUSPENDED.name}') - # Errors - with self.assertRaises(KeyError) as cm: - get_enum_value_name('BAD.TYPE', TestEnum.SUSPENDED) - self.assertEqual(cm.exception.args, ("Unregistered protobuf enum type 'BAD.TYPE'",)) - with self.assertRaises(KeyError) as cm: - get_enum_value_name(ENUM_TYPE_NAME, 9999) - self.assertEqual(cm.exception.args, (f"Enum {ENUM_TYPE_NAME} has no name defined for value 9999",)) - # Type specification - enum: ProtoEnumType = get_enum_type(ENUM_TYPE_NAME) - self.assertEqual(enum.name, ENUM_TYPE_NAME) - self.assertEqual(enum.get_value_name(TestEnum.SUSPENDED), - f'TEST_{TestEnum.SUSPENDED.name}') - self.assertListEqual(enum.items(), enum_spec) - self.assertListEqual(enum.keys(), [k for k, v in enum_spec]) - self.assertListEqual(enum.values(), [v for k, v in enum_spec]) - # attribute access to enum values - for name, value in enum_spec: - self.assertEqual(getattr(enum, name), value) - with self.assertRaises(AttributeError) as cm: - enum.TEST_BAD_VALUE - self.assertEqual(cm.exception.args, (f"Enum {ENUM_TYPE_NAME} has no value with name 'TEST_BAD_VALUE'",)) - def test_messages(self): - register_decriptor(DESCRIPTOR) - # - msg = create_message(STATE_MSG_TYPE_NAME) - self.assertIsNotNone(msg) - self.assertEqual(get_enum_field_type(msg, 'test'), ENUM_TYPE_NAME) - # - msg.name = 'State.NAME' - msg.test = 1 - # Errors - with self.assertRaises(KeyError) as cm: - create_message('NOT_REGISTERED') - self.assertEqual(cm.exception.args, ("Unregistered protobuf message 'NOT_REGISTERED'",)) - with self.assertRaises(KeyError) as cm: - get_enum_field_type(msg, 'BAD_FIELD') - self.assertEqual(cm.exception.args, ("Message does not have field 'BAD_FIELD'",)) + enum_spec = [("TEST_UNKNOWN", 0), + ("TEST_READY", 1), + ("TEST_RUNNING", 2), + ("TEST_WAITING", 3), + ("TEST_SUSPENDED", 4), + ("TEST_FINISHED", 5), + ("TEST_ABORTED", 6), + ("TEST_CREATED", 1), + ("TEST_BLOCKED", 3), + ("TEST_STOPPED", 4), + ("TEST_TERMINATED", 6), + ] + register_descriptor(DESCRIPTOR) + # Value name + assert get_enum_value_name(ENUM_TYPE_NAME, TestEnum.SUSPENDED) == f"TEST_{TestEnum.SUSPENDED.name}" + # Errors + with pytest.raises(KeyError) as cm: + get_enum_value_name("BAD.TYPE", TestEnum.SUSPENDED) + assert cm.value.args == ("Unregistered protobuf enum type 'BAD.TYPE'",) + with pytest.raises(KeyError) as cm: + get_enum_value_name(ENUM_TYPE_NAME, 9999) + assert cm.value.args == (f"Enum {ENUM_TYPE_NAME} has no name defined for value 9999",) + # Type specification + enum: ProtoEnumType = get_enum_type(ENUM_TYPE_NAME) + assert enum.name == ENUM_TYPE_NAME + assert enum.get_value_name(TestEnum.SUSPENDED) == f"TEST_{TestEnum.SUSPENDED.name}" + assert enum.items() == enum_spec + assert enum.keys() == [k for k, v in enum_spec] + assert enum.values() == [v for k, v in enum_spec] + # attribute access to enum values + for name, value in enum_spec: + assert getattr(enum, name) == value + with pytest.raises(AttributeError) as cm: + enum.TEST_BAD_VALUE + assert cm.value.args == (f"Enum {ENUM_TYPE_NAME} has no value with name 'TEST_BAD_VALUE'",) +def test_messages(): + register_descriptor(DESCRIPTOR) + # + msg = create_message(STATE_MSG_TYPE_NAME) + assert msg is not None + assert get_enum_field_type(msg, "test") == ENUM_TYPE_NAME + # + msg.name = "State.NAME" + msg.test = 1 + # Errors + with pytest.raises(KeyError) as cm: + create_message("NOT_REGISTERED") + assert cm.value.args == ("Unregistered protobuf message 'NOT_REGISTERED'",) + with pytest.raises(KeyError) as cm: + get_enum_field_type(msg, "BAD_FIELD") + assert cm.value.args == ("Message does not have field 'BAD_FIELD'",) diff --git a/tests/test_signal.py b/tests/test_signal.py index 9a401b2..b90506e 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -33,505 +33,738 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________ -"""firebird-base - Tests for firebird.base.signal module - - -""" +"""Unit tests for the firebird.base.signal module (Signal and eventsocket).""" from __future__ import annotations -import typing as t -from firebird.base.signal import signal, Signal, eventsocket -from functools import partial + import inspect +import gc # For testing weak references +import weakref +from functools import partial +from typing import Any # Added for type hints -try: - import unittest2 as unittest -except ImportError: - import unittest +import pytest -def nopar_signal(): +from firebird.base.signal import Signal, _EventSocket, eventsocket, signal + +# --- Test Setup & Fixtures --- + +ns = {} # Global namespace for checking side effects of some test functions + +def nopar_signal_sig_func(): + """Function defining a signature with no parameters (except self implicitly).""" pass -nopar_signature = inspect.Signature.from_callable(nopar_signal) +nopar_signature = inspect.Signature.from_callable(nopar_signal_sig_func) +nopar_signature = nopar_signature.replace(parameters=[]) # Explicitly remove self if needed -def value_signal(value): +def value_signal_func(value) -> None: + """Function defining a signature with one 'value' parameter.""" pass -slot_signature = inspect.Signature.from_callable(value_signal) +slot_signature = inspect.Signature.from_callable(value_signal_func) +slot_signature = slot_signature.replace(parameters=[p for p in slot_signature.parameters.values() if p.name != 'self'], + return_annotation=inspect.Signature.empty) + + +# Functions to be used as slots/handlers +def _func(test_instance, value) -> None: + """A standalone function slot that modifies the test instance.""" + test_instance.checkval = value + test_instance.call_count += 1 -def _Func(test, value): - """A test standalone function for signals to attach onto""" - test.checkval = value - test.func_call_count += 1 +def _func_int(test_instance, value: int) -> None: + """A standalone function slot with type hints.""" + test_instance.checkval = value + test_instance.call_count += 1 -testFunc_signature = inspect.Signature.from_callable(_Func) +def _func_int_ret(value: int) -> int: + """A standalone function slot with type hints and return value.""" + return value -def _FuncWithKWDeafult(test, value, kiwi=None): - """A test standalone function with excess default keyword argument for signals to attach onto""" - test.checkval = value - test.func_call_count += 1 +def _func_with_kw_default(test_instance, value, kiwi=None) -> None: + """Slot with an extra keyword argument having a default value.""" + test_instance.checkval = value + test_instance.call_count += 1 -def _FuncWithKW(test, value, *, kiwi): - """A test standalone function with excess keyword argument for signals to attach onto""" - test.checkval = value - test.func_call_count += 1 +def _func_with_kw(test_instance, value, *, kiwi) -> None: + """Slot with an extra mandatory keyword argument.""" + test_instance.checkval = value + test_instance.call_count += 1 -def _LocalEmit(signal_instance): - """A test standalone function for signals to emit at local level""" - exec('signal_instance.emit()') +def _func_wrong_param_name(test_instance, val): + """Slot with a different parameter name.""" + test_instance.checkval = val + test_instance.call_count += 1 +def _func_wrong_param_type(test_instance, value: str): + """Slot with a different parameter type hint.""" + test_instance.checkval = value + test_instance.call_count += 1 -def _ModuleEmit(signal_instance): - """A test standalone function for signals to emit at module level""" - signal_instance.emit() +def _func_wrong_ret_type(test_instance, value: int) -> float: + """Slot with a different return type hint.""" + test_instance.checkval = value + test_instance.call_count += 1 + return float(value) class DummySignalClass: - """A dummy class to check for instance handling of signals""" + """A dummy class using the @signal decorator.""" @signal - def cSignal(self, value): - "cSignal" - pass + def c_signal(self, value: Any): + """Class Signal Docstring""" + pass # Signature definition only @signal - def cSignal2(self, value): - "cSignal2" + def c_signal2(self, value: Any): + """Class Signal 2 Docstring""" pass def __init__(self): - self.signal = Signal(slot_signature) - - def triggerSignal(self): - self.signal.emit() + self.instance_signal = Signal(slot_signature) # Manual signal instance - def triggerClassSignal(self): - self.cSignal.emit(1) + def trigger_instance_signal(self, value): + """Emits the manually created instance signal.""" + self.instance_signal.emit(value) + def trigger_class_signal(self, value): + """Emits the signal defined via the @signal decorator.""" + self.c_signal.emit(value) class DummyEventClass: - """A dummy class to check for eventsockets""" + """A dummy class using the @eventsocket decorator.""" @eventsocket def event(self, value: int) -> None: - "event" - pass + """Event Socket Docstring""" + pass # Signature definition only @eventsocket def event2(self, value: int) -> int: - "event2" + """Event Socket 2 Docstring (returns int)""" + pass + + @eventsocket + def event3(self, value): + """Event Socket 3 Docstring (no type hints)""" + pass + + @eventsocket + def event_nopar(self) -> None: + """Event Socket 4 Docstring (no parameters)""" pass class DummySlotClass: - """A dummy class to check for slot handling""" - checkval = None + """A dummy class providing methods to act as slots.""" + checkval: Any = None + call_count: int = 0 - def setVal(self, value): - """A method to test slot calls with""" - self.checkval = val + def set_val(self, value): + """Instance method slot.""" + self.__class__.checkval = value + self.__class__.call_count += 1 + + @classmethod + def cls_set_val(cls, value): + """Class method slot.""" + cls.checkval = value + cls.call_count += 1 class DummyEventSlotClass: - """A dummy class to check for eventsocket slot handling""" - checkval = None + """A dummy class providing methods to act as event handlers.""" + checkval: Any = None + call_count: int = 0 + + def set_val(self, value): + """Event handler instance method.""" + self.checkval = value + self.call_count += 1 + + def set_val_kw(self, value, extra=None): + """Handler with an extra keyword argument having a default.""" + self.checkval = value + self.call_count += 1 - def setVal(self, value): - """A method to test slot calls with""" + def set_val_extra(self, value, extra): + """Handler with an extra mandatory keyword argument.""" self.checkval = value + self.call_count += 1 - def setValInt(self, value: int) -> None: - """A method to test slot calls with""" + def set_val_int(self, value: int) -> None: + """Handler with specific type hints.""" self.checkval = value + self.call_count += 1 - def setValIntRetInt(self, value: int) -> int: - """A method to test slot calls with""" + def set_val_int_ret_int(self, value: int) -> int: + """Handler with type hints and a return value.""" self.checkval = value + self.call_count += 1 return value * 2 class SignalTestMixin: - """Mixin class with common helpers for signal tests""" - + """Mixin class with common setup, teardown, and helper methods for tests.""" def __init__(self): - self.checkval = None # A state check for the tests - self.checkval2 = None # A state check for the tests - self.setVal_call_count = 0 # A state check for the test method - self.setVal2_call_count = 0 # A state check for the test method - self.func_call_count = 0 # A state check for test function + self.checkval: Any = None + self.call_count: int = 0 + self.slot_call_count: int = 0 + self.reset() def reset(self): + """Resets state variables for a new test.""" self.checkval = None - self.checkval2 = None - self.setVal_call_count = 0 - self.setVal2_call_count = 0 - self.func_call_count = 0 - - # Helper methods - def setVal(self, value): - """A method to test instance settings with""" + self.call_count = 0 + self.slot_call_count = 0 + ns.clear() + ns["checkval"] = None + ns["call_count"] = 0 + # Reset class variables of dummy slot classes + DummySlotClass.checkval = None + DummySlotClass.call_count = 0 + DummyEventSlotClass.checkval = None + DummyEventSlotClass.call_count = 0 + + + # Helper methods acting as slots/handlers + def slot_method(self, value): + """Instance method slot.""" self.checkval = value - self.setVal_call_count += 1 + self.slot_call_count += 1 - def setVal2(self, value): - """Another method to test instance settings with""" - self.checkval2 = value - self.setVal2_call_count += 1 - - def setValInt(self, value: int) -> None: - """A method to test slot calls with""" + def slot_method_int(self, value: int) -> None: + """Instance method slot with int hint.""" self.checkval = value - self.setVal_call_count += 1 + self.slot_call_count += 1 - def setValIntRetInt(self, value: int) -> int: - """A method to test slot calls with""" + def slot_method_int_ret_int(self, value: int) -> int: + """Instance method slot with int hint and return.""" self.checkval = value - self.setVal_call_count += 1 + self.slot_call_count += 1 return value * 2 - def throwaway(self, value): - """A method to throw redundant data into""" - pass + @classmethod + def slot_cls_method(cls, value): + """Class method slot.""" + ns["checkval"] = value + ns["call_count"] += 1 - def throwawayInt(self, value: int) -> None: - """A method to throw redundant data into""" + def slot_method_ignore(self, value): + """Instance method slot used when testing disconnects/failures.""" + pass # Does nothing + + def slot_method_ignore_int(self, value: int) -> None: + """Typed instance method slot used for ignoring calls.""" pass - def throwawayIntRetInt(self, value: int) -> int: - """A method to throw redundant data into""" + def slot_method_ignore_int_ret_int(self, value: int) -> int: + """Typed instance method slot with return, used for ignoring calls.""" return value * 2 -# noinspection PyProtectedMember -class SignalTest(unittest.TestCase, SignalTestMixin): - """Unit tests for Signal class""" - - def setUp(self): - self.reset() - - def __init__(self, methodName='runTest'): - unittest.TestCase.__init__(self, methodName) - SignalTestMixin.__init__(self) - - def test_PartialConnect(self): - """Tests connecting signals to partials""" - partialSignal = Signal(nopar_signature) - partialSignal.connect(partial(_Func, self, 'Partial')) - self.assertEqual(len(partialSignal._slots), 1, "Expected single connected slot") - - def test_PartialConnectKWDifferOk(self): - """Tests connecting signals to partials""" - partialSignal = Signal(nopar_signature) - partialSignal.connect(partial(_FuncWithKWDeafult, self, 'Partial')) - self.assertEqual(len(partialSignal._slots), 1, "Expected single connected slot") - - def test_PartialConnectKWDifferBad(self): - """Tests connecting signals to partials""" - partialSignal = Signal(nopar_signature) - with self.assertRaises(ValueError): - partialSignal.connect(partial(_FuncWithKW, self, 'Partial')) - self.assertEqual(len(partialSignal._slots), 0, "Expected single connected slot") - - def test_PartialConnectDuplicate(self): - """Tests connecting signals to partials""" - partialSignal = Signal(nopar_signature) - func = partial(_Func, self, 'Partial') - partialSignal.connect(func) - partialSignal.connect(func) - self.assertEqual(len(partialSignal._slots), 1, "Expected single connected slot") - - def test_LambdaConnect(self): - """Tests connecting signals to lambdas""" - lambdaSignal = Signal(slot_signature) - lambdaSignal.connect(lambda value: _Func(self, value)) - self.assertEqual(len(lambdaSignal._slots), 1, "Expected single connected slot") - - def test_LambdaConnectDuplicate(self): - """Tests connecting signals to duplicate lambdas""" - lambdaSignal = Signal(slot_signature) - func = lambda value: _Func(self, value) - lambdaSignal.connect(func) - lambdaSignal.connect(func) - self.assertEqual(len(lambdaSignal._slots), 1, "Expected single connected slot") - - def test_MethodConnect(self): - """Test connecting signals to methods on class instances""" - methodSignal = Signal(slot_signature) - methodSignal.connect(self.setVal) - self.assertEqual(len(methodSignal._islots), 1, "Expected single connected slot") - self.assertEqual(len(methodSignal._slots), 0, "Expected single connected slot") - - def test_MethodConnectDuplicate(self): - """Test that each method connection is unique""" - methodSignal = Signal(slot_signature) - methodSignal.connect(self.setVal) - methodSignal.connect(self.setVal) - self.assertEqual(len(methodSignal._islots), 1, "Expected single connected slot") - self.assertEqual(len(methodSignal._slots), 0, "Expected single connected slot") - - def test_MethodConnectDifferentInstances(self): - """Test connecting the same method from different instances""" - methodSignal = Signal(slot_signature) - dummy1 = DummySlotClass() - dummy2 = DummySlotClass() - methodSignal.connect(dummy1.setVal) - methodSignal.connect(dummy2.setVal) - self.assertEqual(len(methodSignal._islots), 2, "Expected two connected slots") - self.assertEqual(len(methodSignal._slots), 0, "Expected single connected slot") - - def test_FunctionConnect(self): - """Test connecting signals to standalone functions""" - funcSignal = Signal(testFunc_signature) - funcSignal.connect(_Func) - self.assertEqual(len(funcSignal._slots), 1, "Expected single connected slot") - - def test_FunctionConnectDuplicate(self): - """Test that each function connection is unique""" - funcSignal = Signal(testFunc_signature) - funcSignal.connect(_Func) - funcSignal.connect(_Func) - self.assertEqual(len(funcSignal._slots), 1, "Expected single connected slot") - - def test_ConnectNonCallable(self): - """Test connecting non-callable object""" - nonCallableSignal = Signal(slot_signature) - with self.assertRaises(ValueError): - nonCallableSignal.connect(self.checkval) - - def test_EmitToPartial(self): - """Test emitting signals to partial""" - partialSignal = Signal(nopar_signature) - partialSignal.connect(partial(_Func, self, 'Partial')) - partialSignal.emit() - self.assertEqual(self.checkval, 'Partial') - self.assertEqual(self.func_call_count, 1, "Expected function to be called once") - - def test_EmitToLambda(self): - """Test emitting signal to lambda""" - lambdaSignal = Signal(slot_signature) - lambdaSignal.connect(lambda value: _Func(self, value)) - lambdaSignal.emit('Lambda') - self.assertEqual(self.checkval, 'Lambda') - self.assertEqual(self.func_call_count, 1, "Expected function to be called once") - - def test_EmitToMethod(self): - """Test emitting signal to method""" - toSucceed = DummySignalClass() - toSucceed.signal.connect(self.setVal) - toSucceed.signal.emit('Method') - self.assertEqual(self.checkval, 'Method') - self.assertEqual(self.setVal_call_count, 1, "Expected function to be called once") - - def test_EmitToMethodOnDeletedInstance(self): - """Test emitting signal to deleted instance method""" - toDelete = DummySlotClass() - toCall = Signal(slot_signature) - toCall.connect(toDelete.setVal) - toCall.connect(self.setVal) - del toDelete - toCall.emit(1) - self.assertEqual(self.checkval, 1) - - def test_EmitToFunction(self): - """Test emitting signal to standalone function""" - funcSignal = Signal(testFunc_signature) - funcSignal.connect(_Func) - funcSignal.emit(self, 'Function') - self.assertEqual(self.checkval, 'Function') - self.assertEqual(self.func_call_count, 1, "Expected function to be called once") - - def test_EmitToDeletedFunction(self): - """Test emitting signal to deleted instance method""" - def ToDelete(test, value): - test.checkVal = value - test.func_call_count += 1 - funcSignal = Signal(inspect.Signature.from_callable(ToDelete)) - funcSignal.connect(ToDelete) - del ToDelete - funcSignal.emit(self, 1) - self.assertEqual(self.checkval, None) - self.assertEqual(self.func_call_count, 0) - - def test_PartialDisconnect(self): - """Test disconnecting partial function""" - partialSignal = Signal(nopar_signature) - part = partial(_Func, self, 'Partial') - partialSignal.connect(part) - partialSignal.disconnect(part) - self.assertEqual(self.checkval, None, "Slot was not removed from signal") - - def test_PartialDisconnectUnconnected(self): - """Test disconnecting unconnected partial function""" - partialSignal = Signal(slot_signature) - part = partial(_Func, self, 'Partial') - try: - partialSignal.disconnect(part) - except: - self.fail("Disonnecting unconnected partial should not raise") - - def test_LambdaDisconnect(self): - """Test disconnecting lambda function""" - lambdaSignal = Signal(slot_signature) - func = lambda value: _Func(self, value) - lambdaSignal.connect(func) - lambdaSignal.disconnect(func) - self.assertEqual(len(lambdaSignal._slots), 0, "Slot was not removed from signal") - - def test_LambdaDisconnectUnconnected(self): - """Test disconnecting unconnected lambda function""" - lambdaSignal = Signal(slot_signature) - func = lambda value: _Func(self, value) - try: - lambdaSignal.disconnect(func) - except: - self.fail("Disconnecting unconnected lambda should not raise") - - def test_MethodDisconnect(self): - """Test disconnecting method""" - toCall = Signal(slot_signature) - toCall.connect(self.setVal) - toCall.disconnect(self.setVal) - toCall.emit(1) - self.assertEqual(len(toCall._islots), 0, "Expected 1 connected after disconnect, found %d" % len(toCall._slots)) - self.assertEqual(self.setVal_call_count, 0, "Expected function to be called once") - - def test_MethodDisconnectUnconnected(self): - """Test disconnecting unconnected method""" - toCall = Signal(slot_signature) - try: - toCall.disconnect(self.setVal) - except: - self.fail("Disconnecting unconnected method should not raise") - - def test_FunctionDisconnect(self): - """Test disconnecting function""" - funcSignal = Signal(testFunc_signature) - funcSignal.connect(_Func) - funcSignal.disconnect(_Func) - self.assertEqual(len(funcSignal._slots), 0, "Slot was not removed from signal") - - def test_FunctionDisconnectUnconnected(self): - """Test disconnecting unconnected function""" - funcSignal = Signal(slot_signature) - try: - funcSignal.disconnect(_Func) - except: - self.fail("Disconnecting unconnected function should not raise") - - def test_DisconnectNonCallable(self): - """Test disconnecting non-callable object""" - signal = Signal(slot_signature) - try: - signal.disconnect(self.checkval) - except: - self.fail("Disconnecting invalid object should not raise") - - def test_ClearSlots(self): - """Test clearing all slots""" - multiSignal = Signal(slot_signature) - func = lambda value: self.setVal(value) - multiSignal.connect(partial(_Func, self)) - multiSignal.connect(self.setVal) - multiSignal.clear() - self.assertEqual(len(multiSignal._slots), 0, "Not all slots were removed from signal") - - -class ClassSignalTest(unittest.TestCase, SignalTestMixin): - """Unit tests for ClassSignal class""" - - def setUp(self): - self.reset() - - def __init__(self, methodName='runTest'): - unittest.TestCase.__init__(self, methodName) - SignalTestMixin.__init__(self) - - def test_AssignToProperty(self): - """Test assigning to a ClassSignal property""" - dummy = DummySignalClass() - with self.assertRaises(AttributeError): - dummy.cSignal = None - - # noinspection PyUnresolvedReferences - def test_Emit(self): - """Test emitting signals from class signal and that instances of the class are unique""" - toSucceed = DummySignalClass() - toSucceed.name = 'toSucceed' - toSucceed.cSignal.connect(self.setVal) - toSucceed.cSignal2.connect(self.setVal) - toFail = DummySignalClass() - toFail.name = 'toFail' - toFail.cSignal.connect(self.throwaway) - toFail.cSignal2.connect(self.throwaway) - toSucceed.cSignal.emit(1) - self.assertEqual(self.checkval, 1) - toSucceed.cSignal2.emit(2) - self.assertEqual(self.checkval, 2) - toFail.cSignal.emit(3) - toFail.cSignal2.emit(3) - self.assertEqual(self.checkval, 2) - self.assertEqual(self.setVal_call_count, 2) - -class eventsocketTest(unittest.TestCase, SignalTestMixin): - """Unit tests for ClassSignal class""" - - def setUp(self): - self.reset() - - def __init__(self, methodName='runTest'): - unittest.TestCase.__init__(self, methodName) - SignalTestMixin.__init__(self) - - def test_01_assign(self): - """Test slot assignment to eventsocket.""" - obj = DummyEventClass() - # - self.assertFalse(obj.event.is_set()) - self.assertFalse(obj.event2.is_set()) - # - obj.event = self.setValInt - self.assertTrue(obj.event.is_set()) - # - obj.event2 = self.setValIntRetInt - - def test_02_clear(self): - """Test slot assignment to eventsocket.""" - obj = DummyEventClass() - # - obj.event = self.setValInt - self.assertTrue(obj.event.is_set()) - # - obj.event = None - self.assertFalse(obj.event.is_set()) - obj.event = None - - # noinspection PyUnresolvedReferences - def test_03_call(self): - """Test emitting events and that instances of the class are unique""" - toSucceed = DummyEventClass() - toSucceed.name = 'toSucceed' - toSucceed.event = self.setValInt - toSucceed.event2 = self.setValIntRetInt - # - toFail = DummyEventClass() - toFail.name = 'toFail' - toFail.event = self.throwawayInt - toFail.event2 = self.throwawayIntRetInt - # - result = toSucceed.event(1) - self.assertEqual(self.checkval, 1) - self.assertIsNone(result) - result = toSucceed.event2(2) - self.assertEqual(result, 2 * 2) - self.assertEqual(self.checkval, 2) - toFail.event(3) - result = toFail.event2(3) - self.assertEqual(result, 3 * 2) - self.assertEqual(self.checkval, 2) - self.assertEqual(self.setVal_call_count, 2) - - def test_04_instance_slot(self): - """Test that instance slots will automatically go away with instance.""" - obj = DummyEventClass() - slot = DummyEventSlotClass() - # - obj.event = slot.setValInt - self.assertTrue(obj.event.is_set()) - # - del slot - self.assertFalse(obj.event.is_set()) - +@pytest.fixture +def receiver() -> SignalTestMixin: + """Provides a fresh SignalTestMixin instance for each test.""" + return SignalTestMixin() + +# --- Signal Decorator Tests --- + +def test_signal_decorator_get(): + """Tests the @signal decorator's __get__ method and docstring propagation.""" + sig_instance = DummySignalClass() + # Get on instance returns Signal object + assert isinstance(sig_instance.c_signal, Signal) + # Get on class returns descriptor itself + assert isinstance(DummySignalClass.c_signal, signal) + # Check docstring + assert DummySignalClass.c_signal.__doc__ == "Class Signal Docstring" + # Accessing multiple times returns same Signal instance for that object + assert sig_instance.c_signal is sig_instance.c_signal + +def test_signal_decorator_set(): + """Tests that assigning to a @signal property raises AttributeError.""" + sig_instance = DummySignalClass() + with pytest.raises(AttributeError, match="Can't assign to signal"): + sig_instance.c_signal = _func # type: ignore + +def test_signal_decorator_del(): + """Tests that deleting a @signal property raises AttributeError.""" + sig_instance = DummySignalClass() + with pytest.raises(AttributeError, match="Can't delete signal"): + del sig_instance.c_signal + +# --- Signal Class Tests --- + +def test_signal_connect_signature_mismatch(receiver): + """Tests that Signal.connect raises ValueError for incompatible signatures.""" + sig = Signal(slot_signature) # Expects (value: Any) -> None + + # Wrong number of parameters + with pytest.raises(ValueError, match="Callable signature does not match"): + sig.connect(nopar_signal_sig_func) + # Wrong parameter name + with pytest.raises(ValueError, match="Callable signature does not match"): + sig.connect(receiver.slot_method_ignore_int) # Correct type, uses self implicitly + # Check against external func too + sig.connect(_func_wrong_param_name) # Correct number, wrong name ('val' vs 'value') + + +def test_signal_connect_various_types(receiver): + """Tests connecting various callable types to a Signal.""" + sig = Signal(slot_signature) # Expects (value: Any) -> None + + # Partial + part = partial(_func, receiver, "Partial Value") # Adapts _func's signature + sig_nopar = Signal(nopar_signature) + sig_nopar.connect(part) + assert len(sig_nopar._slots) == 1 + assert part in sig_nopar._slots + + # Lambda + lamb = lambda value: _func(receiver, value) + sig.connect(lamb) + assert len(sig._slots) == 1 + assert lamb in sig._slots + + # Instance Method + sig.connect(receiver.slot_method) + assert receiver.slot_method.__self__ in sig._islots # Check instance is key + assert sig._islots[receiver.slot_method.__self__] == receiver.slot_method.__func__ + + # Class Method + sig.connect(SignalTestMixin.slot_cls_method) + assert SignalTestMixin.slot_cls_method.__self__ in sig._islots # Class is key + assert sig._islots[SignalTestMixin.slot_cls_method.__self__] == SignalTestMixin.slot_cls_method.__func__ + + # Regular Function (stored as weakref) + sig_func = Signal(inspect.Signature.from_callable(_func).replace(parameters=[p for p in inspect.signature(_func).parameters.values() if p.name != 'self'], return_annotation=inspect.Signature.empty)) + sig_func.connect(_func) + assert len(sig_func._slots) == 1 + assert isinstance(sig_func._slots[0], weakref.ReferenceType) # Functions likely stored as weakref internally + +def test_signal_connect_duplicates(receiver): + """Tests that connecting the same slot multiple times only stores it once.""" + sig = Signal(slot_signature) + # Lambda + func = lambda value: _func(receiver, value) + sig.connect(func) + sig.connect(func) + assert len(sig._slots) == 1 + # Method + sig.connect(receiver.slot_method) + sig.connect(receiver.slot_method) + assert len(sig._islots) == 1 + # Function + sig_func = Signal(inspect.Signature.from_callable(_func).replace(parameters=[p for p in inspect.signature(_func).parameters.values() if p.name != 'self'], return_annotation=inspect.Signature.empty)) + sig_func.connect(_func) + sig_func.connect(_func) + assert len(sig_func._slots) == 1 + +def test_signal_connect_different_instances(): + """Tests connecting the same method from different instances.""" + method_sig = Signal(slot_signature) + dummy1 = DummySlotClass() + dummy2 = DummySlotClass() + method_sig.connect(dummy1.set_val) + method_sig.connect(dummy2.set_val) + assert len(method_sig._islots) == 2 # Should have entries for both instances + +def test_signal_connect_non_callable(receiver): + """Tests that connecting a non-callable raises ValueError.""" + sig = Signal(slot_signature) + with pytest.raises(ValueError, match="Connection to non-callable"): + sig.connect(receiver.checkval) # type: ignore + +def test_signal_connect_kwarg_signature_variants(receiver): + """Tests connecting slots with extra keyword arguments.""" + sig = Signal(slot_signature) # Expects (value) + + # Slot with extra kwarg having a default (OK) + sig.connect(partial(_func_with_kw_default, receiver)) # Must use partial for receiver + assert len(sig._slots) == 1 + + # Slot with extra mandatory kwarg (Fail) + with pytest.raises(ValueError, match="Callable signature does not match"): + sig.connect(_func_with_kw) + assert len(sig._slots) == 1 # Should not have added the invalid one + +def test_signal_emit_various_targets(receiver): + """Tests emitting signals to various connected slot types.""" + test_value = "Emitted Value" + + # To Partial + sig_nopar = Signal(nopar_signature) + sig_nopar.connect(partial(_func, receiver, test_value)) + sig_nopar.emit() + assert receiver.checkval == test_value + assert receiver.call_count == 1 + receiver.reset() + + # To Lambda + sig_lambda = Signal(slot_signature) + sig_lambda.connect(lambda value: _func(receiver, value)) + sig_lambda.emit(test_value) + assert receiver.checkval == test_value + assert receiver.call_count == 1 + receiver.reset() + + # To Instance Method + sig_method = Signal(slot_signature) + sig_method.connect(receiver.slot_method) + sig_method.emit(test_value) + assert receiver.checkval == test_value + assert receiver.slot_call_count == 1 + receiver.reset() + + # To Class Method + sig_cls_method = Signal(slot_signature) + sig_cls_method.connect(SignalTestMixin.slot_cls_method) + sig_cls_method.emit(test_value) + assert ns["checkval"] == test_value + assert ns["call_count"] == 1 + receiver.reset() # Also clears ns + + # To Regular Function + sig_func = Signal(inspect.Signature.from_callable(_func).replace(parameters=[p for p in inspect.signature(_func).parameters.values() if p.name != 'self'], return_annotation=inspect.Signature.empty)) + sig_func.connect(_func) + sig_func.emit(receiver, test_value) + assert receiver.checkval == test_value + assert receiver.call_count == 1 + +def test_signal_emit_to_method_on_deleted_instance(receiver): + """Tests that signals skip calls to methods of deleted instances.""" + sig = Signal(slot_signature) + to_delete = DummySlotClass() + sig.connect(to_delete.set_val) + sig.connect(receiver.slot_method) + assert len(sig._islots) == 2 + + # Emit once, both should receive + sig.emit(1) + assert DummySlotClass.checkval == 1 + assert DummySlotClass.call_count == 1 + assert receiver.checkval == 1 + assert receiver.slot_call_count == 1 + + # Delete one instance and collect garbage + del to_delete + gc.collect() + + # Emit again, only receiver should get it + sig.emit(2) + assert DummySlotClass.checkval == 1 # Unchanged + assert DummySlotClass.call_count == 1 # Unchanged + assert receiver.checkval == 2 + assert receiver.slot_call_count == 2 + # Internal slot count might decrease depending on WeakKeyDictionary timing + # assert len(sig._islots) == 1 # This might be flaky + +def test_signal_emit_to_deleted_function(receiver): + """Tests that signals skip calls to functions that have been deleted.""" + def func_to_delete(test, value): + """Temporary function to test deletion.""" + test.checkval = value + test.call_count += 1 + + func_signature = inspect.Signature.from_callable(func_to_delete) # Signature of func_to_delete + sig = Signal(func_signature) + sig.connect(func_to_delete) + assert len(sig._slots) == 1 + + # Emit once + sig.emit(receiver, "Before Delete") + assert receiver.checkval == "Before Delete" + assert receiver.call_count == 1 + receiver.reset() + + # Delete the function reference and collect garbage + del func_to_delete + gc.collect() + + # Emit again, should not call anything (weakref should be dead) + sig.emit(receiver, "After Delete") + assert receiver.checkval is None + assert receiver.call_count == 0 + # Internal slot count might decrease depending on weakref cleanup timing + +def test_signal_emit_block(receiver): + """Tests that setting signal.block = True prevents emissions.""" + sig = Signal(slot_signature) + sig.connect(receiver.slot_method) + sig.emit(1) + assert receiver.checkval == 1 + # Block emission + sig.block = True + sig.emit(2) + assert receiver.checkval == 1 # Value should not change + # Unblock emission + sig.block = False + sig.emit(3) + assert receiver.checkval == 3 + +def test_signal_emit_direct_call(receiver): + """Tests emitting by calling the Signal instance directly.""" + sig = Signal(slot_signature) + sig.connect(receiver.slot_method) + sig(1) # Emit using __call__ + assert receiver.checkval == 1 + +def test_signal_disconnect_various_types(receiver): + """Tests disconnecting various types of connected slots.""" + sig = Signal(slot_signature) + sig_nopar = Signal(nopar_signature) + sig_func = Signal(inspect.Signature.from_callable(_func).replace(parameters=[p for p in inspect.signature(_func).parameters.values() if p.name != 'self'], return_annotation=inspect.Signature.empty)) + + + # Partial + part = partial(_func, receiver, "Partial") + sig_nopar.connect(part) + assert part in sig_nopar._slots + sig_nopar.disconnect(part) + assert part not in sig_nopar._slots + + # Lambda + lamb = lambda value: _func(receiver, value) + sig.connect(lamb) + assert lamb in sig._slots + sig.disconnect(lamb) + assert lamb not in sig._slots + + # Instance Method + sig.connect(receiver.slot_method) + assert receiver.slot_method.__self__ in sig._islots + sig.disconnect(receiver.slot_method) + assert receiver.slot_method.__self__ not in sig._islots + + # Class Method + sig.connect(SignalTestMixin.slot_cls_method) + assert SignalTestMixin.slot_cls_method.__self__ in sig._islots + sig.disconnect(SignalTestMixin.slot_cls_method) + assert SignalTestMixin.slot_cls_method.__self__ not in sig._islots + + # Regular Function + sig_func.connect(_func) + assert len(sig_func._slots) > 0 # Weakref makes direct check hard + sig_func.disconnect(_func) + # Asserting weakref removal is tricky, check emit works + sig_func.emit(receiver, "After Disconnect") + assert receiver.checkval is None + +def test_signal_disconnect_unconnected(receiver): + """Tests that disconnecting an unconnected slot does not raise errors.""" + sig = Signal(slot_signature) + part = partial(_func, receiver, "Partial") + lamb = lambda value: _func(receiver, value) + try: + sig.disconnect(receiver.slot_method) + sig.disconnect(part) + sig.disconnect(lamb) + sig.disconnect(_func) + except Exception as e: + pytest.fail(f"Disconnecting unconnected slot raised: {e}") + +def test_signal_disconnect_non_callable(receiver): + """Tests that disconnecting a non-callable argument does not raise errors.""" + sig = Signal(slot_signature) + try: + sig.disconnect(receiver.checkval) # type: ignore + except Exception as e: + pytest.fail(f"Disconnecting non-callable raised: {e}") + +def test_signal_clear_slots(receiver): + """Tests the clear method removes all connected slots.""" + sig = Signal(slot_signature) + part = partial(_func, receiver) + lamb = lambda value: _func(receiver, value) + sig.connect(part) + sig.connect(lamb) + sig.connect(receiver.slot_method) + sig_func = Signal(inspect.Signature.from_callable(_func).replace(parameters=[p for p in inspect.signature(_func).parameters.values() if p.name != 'self'], return_annotation=inspect.Signature.empty)) + + sig_func.connect(_func) + + assert len(sig._slots) == 2 + assert len(sig._islots) == 1 + assert len(sig_func._slots) == 1 + + sig.clear() + sig_func.clear() + + assert len(sig._slots) == 0 + assert len(sig._islots) == 0 + assert len(sig_func._slots) == 0 + +# --- eventsocket Decorator Tests --- + +def test_event_decorator_get(): + """Tests the @eventsocket decorator's __get__ method and docstring propagation.""" + evt_instance = DummyEventClass() + # Get on instance returns _EventSocket object + socket = evt_instance.event + assert isinstance(socket, _EventSocket) + assert not socket.is_set() # Initially empty + # Get on class returns descriptor itself + assert isinstance(DummyEventClass.event, eventsocket) + # Check docstring + assert DummyEventClass.event.__doc__ == "Event Socket Docstring" + # Accessing multiple times returns same _EventSocket for that object (or default) + assert evt_instance.event is socket + +def test_event_decorator_del(): + """Tests that deleting an @eventsocket property raises AttributeError.""" + evt_instance = DummyEventClass() + with pytest.raises(AttributeError, match="Can't delete eventsocket"): + del evt_instance.event + +def test_event_assign_and_clear(receiver): + """Tests assignment of various handlers to an eventsocket and clearing.""" + obj = DummyEventClass() + slot_instance = DummyEventSlotClass() + + # Initially unset + assert not obj.event.is_set() + assert not obj.event2.is_set() + + # Assign instance method + obj.event = receiver.slot_method_int + assert obj.event.is_set() + obj.event = None # Clear + assert not obj.event.is_set() + + # Assign function + obj.event2 = _func_int_ret + assert obj.event2.is_set() + obj.event2 = None + assert not obj.event2.is_set() + + + # Assign lambda + obj.event3 = lambda value: _func(receiver, value) + assert obj.event3.is_set() + obj.event3 = None + assert not obj.event3.is_set() + + # Assign partial + obj.event = partial(slot_instance.set_val_extra, extra="Partial") + assert obj.event.is_set() + obj.event = None + assert not obj.event.is_set() + + # Assign method with extra default kwarg (OK) + obj.event = slot_instance.set_val_kw + assert obj.event.is_set() + obj.event = None + assert not obj.event.is_set() + + # Assign non-callable + with pytest.raises(ValueError, match="Connection to non-callable"): + obj.event = "non-callable" # type: ignore + +def test_event_assign_signature_mismatch(receiver): + """Tests that assigning handlers with incompatible signatures raises ValueError.""" + obj = DummyEventClass() # event expects (value: int) -> None + + # Wrong parameter name + with pytest.raises(ValueError, match="Callable signature does not match"): + obj.event = _func_wrong_param_name # Takes 'val' not 'value' + + # Wrong parameter type + with pytest.raises(ValueError, match="Callable signature does not match"): + obj.event = _func_wrong_param_type # Takes str, not int + + # Wrong return type (event expects None) + with pytest.raises(ValueError, match="Callable signature does not match"): + obj.event = _func_wrong_ret_type # Returns float, not None + + # Correct type, but different instance method signature (e.g., event2 expects int->int) + with pytest.raises(ValueError, match="Callable signature does not match"): + obj.event2 = receiver.slot_method_int # Returns None, not int + +def test_event_call(receiver): + """Tests calling eventsocket properties like functions.""" + evt_instance = DummyEventClass() + evt_instance.event = receiver.slot_method_int + evt_instance.event2 = receiver.slot_method_int_ret_int + + # Call event without return value + evt_instance.event(10) + assert receiver.checkval == 10 + assert receiver.slot_call_count == 1 + + # Call event with return value + result = evt_instance.event2(20) + assert result == 40 # 20 * 2 + assert receiver.checkval == 20 + assert receiver.slot_call_count == 2 + +def test_event_call_unset(receiver): + """Tests calling an eventsocket that has no handler assigned.""" + obj = DummyEventClass() + try: + # Call event without return expected + result1 = obj.event(123) + assert result1 is None # Should return None if unset and no return expected + + # Call event with return expected + result2 = obj.event2(456) + assert result2 is None # Should return None if unset, even if return expected + except Exception as e: + pytest.fail(f"Calling unset eventsocket raised: {e}") + +def test_event_handler_weakref(receiver): + """Tests that handlers referencing deleted objects are skipped.""" + obj = DummyEventClass() + slot_instance = DummyEventSlotClass() + + # Assign instance method + obj.event = slot_instance.set_val_int + assert obj.event.is_set() + + # Call it + obj.event(1) + assert slot_instance.checkval == 1 + assert slot_instance.call_count == 1 + + # Delete the instance holding the handler method + del slot_instance + gc.collect() + + # Check the handler is no longer set + assert not obj.event.is_set() + + # Call again, should do nothing + obj.event(2) + # Cannot check slot_instance.checkval as it's deleted + # Check that receiver (if used as alternative) wasn't called + assert receiver.checkval is None + +def test_event_handler_partial_kwarg_variants(receiver): + """Tests assigning and calling handlers with extra kwargs via partial.""" + obj = DummyEventClass() # event expects (value: int) -> None + slot_instance = DummyEventSlotClass() + + # Partial with extra default kwarg (OK to assign) + part_def = partial(_func_with_kw_default, slot_instance) # Binds test_instance + # Assign to event_nopar as it matches the remaining signature () -> None + # Needs adjustment in DummyEventClass if event_nopar removed + # Let's assign to event3 (value) -> None , binding value in partial + part_def_bound = partial(_func_with_kw_default, slot_instance, 99) + obj.event3 = part_def_bound # Signature now matches () -> None conceptually + assert obj.event3.is_set() + # Calling event3 (which takes value) will fail if partial expects no args left. + # Revisit: Assigning partials needs careful signature matching. + # Let's test assignment directly to a compatible event signature: + obj.event_nopar = part_def_bound # Assign partial expecting no args to event_nopar + assert obj.event_nopar.is_set() + obj.event_nopar() # Call event + assert slot_instance.checkval == 99 # Value bound in partial is used + slot_instance.checkval = None # Reset + + obj.event_nopar = None # Reset + # Partial with extra mandatory kwarg (assign should fail if check is strict) + part_man = partial(_func_with_kw, slot_instance, 100) + # Try assigning to event_nopar again + with pytest.raises(ValueError, match="Callable signature does not match"): + obj.event_nopar = part_man # Signature should mismatch due to extra 'kiwi' + assert not obj.event_nopar.is_set() # Should not be set + # Should pass if kwarg is bound + part_man_bound = partial(_func_with_kw, slot_instance, 100, kiwi="test") # Provide mandatory kwarg + obj.event_nopar = part_man_bound + assert obj.event_nopar.is_set() # Should be set diff --git a/tests/test_strconv.py b/tests/test_strconv.py new file mode 100644 index 0000000..6ffd500 --- /dev/null +++ b/tests/test_strconv.py @@ -0,0 +1,406 @@ +# SPDX-FileCopyrightText: 2019-present The Firebird Projects +# +# SPDX-License-Identifier: MIT +# +# PROGRAM/MODULE: firebird-base +# FILE: tests/test_strconv.py +# DESCRIPTION: Tests for firebird.base.strconv +# CREATED: 21.1.2025 +# +# The contents of this file are subject to the MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Copyright (c) 2025 Firebird Project (www.firebirdsql.org) +# All Rights Reserved. +# +# Contributor(s): Pavel Císař (original code) +# ______________________________________. + +from __future__ import annotations + +from decimal import Decimal # Import specific exception +from enum import Enum, IntEnum, IntFlag, auto # Added auto +from uuid import NAMESPACE_OID, UUID, uuid5 + +import pytest + +# Assuming strconv.py is importable as below +from firebird.base.strconv import * +# Assuming necessary types are available +from firebird.base.types import MIME, ByteOrder, Distinct, PyExpr, ZMQAddress, ZMQDomain # Added Distinct + +# --- Test Setup --- + +class MyCustomType: + """Dummy class for testing custom convertor registration.""" + def __init__(self, value): + self.value = value + def __eq__(self, other): + return isinstance(other, MyCustomType) and self.value == other.value + +class UnregisteredType: + """Dummy class guaranteed not to have a convertor.""" + pass + +class AnotherFlag(IntFlag): + """Another flag type for testing.""" + A = auto() + B = auto() + C = auto() + +# --- Test Functions --- + +def test_any2str(): + """Tests the default 'any to string' convertor function.""" + assert any2str(1) == "1" + assert any2str(True) == "True" # Note: Different from bool2str used by default + assert any2str(1.5) == "1.5" + assert any2str(None) == "None" + +def test_str2any(): + """Tests the default 'string to any' convertor function.""" + assert str2any(int, "1") == 1 + assert str2any(float, "1.5") == 1.5 + assert str2any(str, "hello") == "hello" + with pytest.raises(ValueError): + str2any(int, "not-a-number") + +def test_convertor_dataclass(): + """Tests the Convertor dataclass itself.""" + c1 = Convertor(int, any2str, str2any) + c2 = Convertor(int, lambda x: f"int:{x}", lambda c, v: int(v[4:])) + c3 = Convertor(str, any2str, str2any) + + # get_key + assert c1.get_key() is int + assert c3.get_key() is str + + # Equality (based on key/cls) + assert c1.get_key() == c2.get_key() # Same class key + assert c1.get_key() != c3.get_key() # Different class key + + # Check attributes + assert c1.cls is int + assert c1.to_str is any2str + assert c1.from_str is str2any + assert c1.name == "int" + assert c1.full_name == "builtins.int" # Check builtins module + + +def test_register_custom_convertor(): + """Tests registering, using, and getting a convertor for a new custom type.""" + custom_to_str = lambda x: f"CUSTOM<{x.value}>" + custom_from_str = lambda c, v: c(v[7:-1]) # Assumes MyCustomType("...") + + assert not has_convertor(MyCustomType) + with pytest.raises(TypeError, match="Type 'MyCustomType' has no Convertor"): + get_convertor(MyCustomType) + + # Register + register_convertor(MyCustomType, to_str=custom_to_str, from_str=custom_from_str) + + # Check presence and retrieval + assert has_convertor(MyCustomType) + conv = get_convertor(MyCustomType) + assert isinstance(conv, Convertor) + assert conv.cls is MyCustomType + assert conv.to_str is custom_to_str + assert conv.from_str is custom_from_str + assert conv == get_convertor("MyCustomType") + + # Test conversion + instance = MyCustomType("hello") + instance_str = "CUSTOM" + assert convert_to_str(instance) == instance_str + assert convert_from_str(MyCustomType, instance_str) == instance + + # Cleanup (optional, but good practice if tests interfere) + # This requires access to the internal registry, which might not be ideal. + # If cleanup is essential, strconv might need a 'unregister' function. + # For now, assume tests run sufficiently isolated or later tests overwrite. + + +def test_builtin_convertors_registered(): + """Checks that convertors for common built-in types are registered by default.""" + assert has_convertor(str) + assert has_convertor(int) + assert has_convertor(float) + assert has_convertor(complex) + assert has_convertor(bool) + assert has_convertor(Decimal) + assert has_convertor(UUID) + assert has_convertor(MIME) + assert has_convertor(ZMQAddress) + assert has_convertor(Enum) + assert has_convertor(IntEnum) + assert has_convertor(IntFlag) + assert has_convertor(ByteOrder) # Test a specific Enum subclass + assert has_convertor(AnotherFlag) # Test a specific IntFlag subclass + +def test_has_convertor_logic(): + """Tests various scenarios for has_convertor, including MRO and unregistered.""" + # Unregistered base class + assert not has_convertor(Distinct) + # Registered descendant (str is registered) + assert has_convertor(PyExpr) + # Explicitly unregistered type + assert not has_convertor(UnregisteredType) + # Unresolvable string name + assert not has_convertor("NoSuchClassAnywhere") + +def test_core_function_errors(): + """Tests TypeErrors when attempting operations on types without convertors.""" + # get_convertor + with pytest.raises(TypeError, match="Type 'UnregisteredType' has no Convertor"): + get_convertor(UnregisteredType) + # convert_to_str + with pytest.raises(TypeError, match="Type 'UnregisteredType' has no Convertor"): + convert_to_str(UnregisteredType()) + # convert_from_str + with pytest.raises(TypeError, match="Type 'UnregisteredType' has no Convertor"): + convert_from_str(UnregisteredType, "some string") + +def test_update_convertor_logic(): + """Tests the update_convertor function, including error cases.""" + # Test update works (reusing test_update_convertor from original) + conv = get_convertor(int) + original_to_str = conv.to_str + original_from_str = conv.from_str + try: + update_convertor(int, to_str=lambda x: "foo", from_str=lambda c, v: "baz") + assert convert_to_str(42) == "foo" + assert convert_from_str(int, "bar") == "baz" + + # Update only one function + update_convertor(int, to_str=lambda x: "updated_foo") + assert convert_to_str(42) == "updated_foo" + assert convert_from_str(int, "bar") == "baz" # from_str should be unchanged + + update_convertor(int, from_str=lambda c, v: "updated_baz") + assert convert_to_str(42) == "updated_foo" # to_str should be unchanged + assert convert_from_str(int, "bar") == "updated_baz" + + finally: + # Restore original convertors + update_convertor(int, to_str=original_to_str, from_str=original_from_str) + + # Test update on unregistered type + with pytest.raises(TypeError, match="Type 'UnregisteredType' has no Convertor"): + # Note: It raises TypeError because get_convertor fails first + update_convertor(UnregisteredType, to_str=lambda x: "") + +# --- Built-in Type Conversion Tests --- + +def test_builtin_str(): + """Tests string conversion (should be identity).""" + value = "test value" + assert convert_to_str(value) == value + assert convert_from_str(str, value) == value + +def test_builtin_int(): + """Tests integer conversion.""" + value = 42 + value_str = "42" + assert convert_to_str(value) == value_str + assert convert_from_str(int, value_str) == value + with pytest.raises(ValueError): + convert_from_str(int, "not-an-int") + +def test_builtin_bool(): + """Tests boolean conversion, including case-insensitivity and error handling.""" + # To string + assert convert_to_str(True) == "yes" + assert convert_to_str(False) == "no" + # From string (True cases) + for true_val in TRUE_STR + [s.upper() for s in TRUE_STR] + ["On", "YES", "True", "Y"]: + assert convert_from_str(bool, true_val) is True, f"Failed for '{true_val}'" + # From string (False cases) + for false_val in FALSE_STR + [s.upper() for s in FALSE_STR] + ["Off", "NO", "False", "N"]: + assert convert_from_str(bool, false_val) is False, f"Failed for '{false_val}'" + # From string (Error cases) + with pytest.raises(ValueError, match="Value is not a valid bool string constant"): + convert_from_str(bool, "maybe") + with pytest.raises(ValueError, match="Value is not a valid bool string constant"): + convert_from_str(bool, "") # Empty string + +def test_builtin_float(): + """Tests float conversion.""" + value = 42.5 + value_str = "42.5" + assert convert_to_str(value) == value_str + assert convert_from_str(float, value_str) == value + with pytest.raises(ValueError): + convert_from_str(float, "not-a-float") + +def test_builtin_complex(): + """Tests complex number conversion.""" + value = complex(42.5, -1.0) + value_str = "(42.5-1j)" # Default complex repr + assert convert_to_str(value) == value_str + assert convert_from_str(complex, value_str) == value + assert convert_from_str(complex, "42.5-1j") == value # Also handles without parens + with pytest.raises(ValueError): + convert_from_str(complex, "not-a-complex") + +def test_builtin_decimal(): + """Tests Decimal conversion, including error handling.""" + value = Decimal("42.123456789") + value_str = "42.123456789" + assert convert_to_str(value) == value_str + assert convert_from_str(Decimal, value_str) == value + # Test error case from str2decimal + with pytest.raises(ValueError, match="could not convert string to Decimal"): + convert_from_str(Decimal, "not-a-decimal") + +def test_builtin_uuid(): + """Tests UUID conversion.""" + value = uuid5(NAMESPACE_OID, "firebird.base.strconv") + value_str = str(value) #"2ff58c2e-5cfd-50f1-8767-c9e405d7d62e" + assert convert_to_str(value) == value_str + assert convert_from_str(UUID, value_str) == value + with pytest.raises(ValueError): # Invalid hex uuid + convert_from_str(UUID, "not-a-valid-uuid-string") + +def test_builtin_mime(): + """Tests MIME type conversion.""" + value = MIME("text/plain;charset=utf-8") + value_str = "text/plain;charset=utf-8" + assert convert_to_str(value) == value_str + assert convert_from_str(MIME, value_str) == value + with pytest.raises(ValueError): # Invalid MIME format + convert_from_str(MIME, "textplain") + +def test_builtin_zmqaddress(): + """Tests ZMQAddress conversion.""" + value = ZMQAddress("tcp://192.168.0.1:8080") + value_str = "tcp://192.168.0.1:8080" + assert convert_to_str(value) == value_str + assert convert_from_str(ZMQAddress, value_str) == value + with pytest.raises(ValueError): # Invalid ZMQ address format + convert_from_str(ZMQAddress, "192.168.0.1:8080") + +def test_builtin_enum(): + """Tests Enum conversion, including case-insensitivity and errors.""" + value = ByteOrder.BIG + value_str = "BIG" + assert convert_to_str(value) == value_str + assert convert_from_str(ByteOrder, value_str) == value + assert convert_from_str(ByteOrder, "little") == ByteOrder.LITTLE # Case test + assert convert_from_str(ByteOrder, "NeTwOrK") == ByteOrder.NETWORK # Case test + with pytest.raises(ValueError, match="'invalid_member'"): # Specific error + convert_from_str(ByteOrder, "invalid_member") + +def test_builtin_intenum(): + """Tests IntEnum conversion (should behave like Enum).""" + value = ZMQDomain.LOCAL + value_str = "LOCAL" + assert convert_to_str(value) == value_str + assert convert_from_str(ZMQDomain, value_str) == value + assert convert_from_str(ZMQDomain, "nOdE") == ZMQDomain.NODE # Case test + with pytest.raises(ValueError, match="'invalid_domain'"): + convert_from_str(ZMQDomain, "invalid_domain") + +def test_builtin_intflag(): + """Tests IntFlag conversion, including combinations, case, separators, and errors.""" + # Single flag + assert convert_to_str(AnotherFlag.A) == "A" + assert convert_from_str(AnotherFlag, "a") == AnotherFlag.A + assert convert_from_str(AnotherFlag, "B") == AnotherFlag.B + + # Combined flags + value_comb = AnotherFlag.A | AnotherFlag.C + value_str_comb = "A|C" + assert convert_to_str(value_comb) == value_str_comb + # From string (various separators and cases) + assert convert_from_str(AnotherFlag, "a|c") == value_comb + assert convert_from_str(AnotherFlag, "C | a") == value_comb # Spaces, order + + # Empty string + with pytest.raises(KeyError, match="''"): + assert convert_from_str(AnotherFlag, "") + + # Invalid flag name + with pytest.raises(KeyError, match="'invalid_flag'"): + convert_from_str(AnotherFlag, "a|invalid_flag") + with pytest.raises(KeyError, match="'d'"): + convert_from_str(AnotherFlag, "a|d") + +# --- Remaining Function Tests --- + +def test_get_convertor_lookup(): + """Tests get_convertor with different lookup methods (type, name, fullname, MRO).""" + # By type + assert get_convertor(int).cls is int + # By simple name (requires prior registration if not built-in/imported) + # tested in test_register_custom_convertor + # By full name + assert get_convertor("firebird.base.types.MIME").cls is MIME + # By MRO lookup + assert get_convertor(PyExpr).cls is str # PyExpr -> str, str has convertor + +def test_update_convertor_restoration(): + """Ensures update_convertor changes are correctly restored.""" + conv = get_convertor(int) + original_to_str = conv.to_str + original_from_str = conv.from_str + try: + update_convertor(int, to_str=lambda x: "foo", from_str=lambda c, v: "baz") + assert get_convertor(int).to_str is not original_to_str + finally: + # Restore original convertors + update_convertor(int, to_str=original_to_str, from_str=original_from_str) + # Verify restoration + assert get_convertor(int).to_str is original_to_str + assert get_convertor(int).from_str is original_from_str + + +def test_convertor_names_property(): + """Tests the .name and .full_name properties of a Convertor.""" + c = get_convertor(MIME) + assert c.name == "MIME" + assert c.full_name == "firebird.base.types.MIME" + + c_int = get_convertor(int) + assert c_int.name == "int" + assert c_int.full_name == "builtins.int" + + +def test_register_class_logic(): + """Tests register_class functionality and duplicate handling.""" + class TempClassForRegister: pass + + assert not has_convertor("TempClassForRegister") # Check lookup by name fails + register_class(TempClassForRegister) + assert not has_convertor("TempClassForRegister") # Still no *convertor*, just class known + assert not has_convertor(TempClassForRegister) + + # Register a convertor for it now + register_convertor(TempClassForRegister) + assert has_convertor("TempClassForRegister") # Now name lookup finds convertor + assert has_convertor(TempClassForRegister) + assert get_convertor("TempClassForRegister").cls is TempClassForRegister + + # Test duplicate registration + with pytest.raises(TypeError, match="Class 'TempClassForRegister' already registered"): + register_class(TempClassForRegister) + + # Cleanup (optional, requires internal access or unregister function) + # del _classes["TempClassForRegister"] + # del _convertors[TempClassForRegister] diff --git a/tests/test_trace.py b/tests/test_trace.py index 93ea47d..7e0429f 100644 --- a/tests/test_trace.py +++ b/tests/test_trace.py @@ -3,8 +3,8 @@ # SPDX-License-Identifier: MIT # # PROGRAM/MODULE: firebird-base -# FILE: test/test_trace.py -# DESCRIPTION: Unit tests for firebird.base.trace +# FILE: tests/test_trace.py +# DESCRIPTION: Tests for firebird.base.trace # CREATED: 21.5.2020 # # The contents of this file are subject to the MIT License @@ -33,465 +33,642 @@ # Contributor(s): Pavel Císař (original code) # ______________________________________ -"""firebird-base - Unit tests for firebird.base.logging -""" +"""Unit tests for the firebird.base.trace module.""" from __future__ import annotations -import unittest + import os -from logging import getLogger, Formatter, lastResort, LogRecord -from firebird.base.types import * -from firebird.base.logging import LoggingIdMixin, LogLevel -from firebird.base.trace import trace_manager, TraceFlag, add_trace, traced, TracedMixin +from logging import LogRecord, getLogger + +import pytest + +from firebird.base.logging import LogLevel, get_agent_name, logging_manager +from firebird.base.strconv import convert_from_str +# Assuming trace.py is importable as below +from firebird.base.trace import ( + TracedItem, TracedClass, TracedMixin, TraceFlag, add_trace, trace_manager, + traced, remove_trace, trace_object +) +# Assuming types.py is importable as below +from firebird.base.types import DEFAULT, Error + +# --- Test Setup & Fixtures --- class Namespace: - "Simple Namespace" - -DECORATED = Namespace() -DECORATED.name = 'DECORATED' - -class BaseLoggingTest(unittest.TestCase): - "Base class for logging unit tests" - def __init__(self, methodName='runTest'): - super().__init__(methodName) - self._saved_trace: TraceFlag = None - self.logger = getLogger() - self.logger.setLevel(LogLevel.NOTSET) - #self.mngr: LoggingManager = get_manager() - self.fmt: Formatter = Formatter("%(levelname)10s: [%(name)s] topic='%(topic)s' agent=%(agent)s context=%(context)s %(message)s") - lastResort.setLevel(LogLevel.NOTSET) - def setUp(self) -> None: - self._saved_flags = trace_manager.flags - trace_manager.clear() - self.logger.handlers.clear() - #lastResort.setFormatter(self.fmt) - #self.logger.addHandler(lastResort) - def tearDown(self): - trace_manager.flags = self._saved_flags - if 'FBASE_TRACE' in os.environ: - del os.environ['FBASE_TRACE'] - def show(self, records, attrs=None): - while records: - item = records.pop(0) - try: - print({k: v for k, v in vars(item).items() if attrs is None or k in attrs}) - except: - print(item) - -class Traced(LoggingIdMixin, TracedMixin): - "traceable callables" - def __init__(self, owner: BaseLoggingTest, logging_id: str=None): - self.owner = owner + """Simple namespace for holding test attributes.""" + name: str = "Namespace" + +class Traced(TracedMixin): + """A sample class using TracedMixin for automatic registration and instrumentation.""" + def __init__(self, logging_id: str | None = None): if logging_id is not None: - self._logging_id_ = logging_id + self._agent_name_: str = logging_id # type: ignore def traced_noparam_noresult(self) -> None: - getLogger().info('') + """Method with no parameters and no return value.""" + getLogger().info("") def traced_noparam_result(self) -> str: - getLogger().info('') - return 'OK' - def traced_param_noresult(self, pos_only, / , pos, kw='KW', *, kw_only='KW_ONLY') -> None: - getLogger().info('') - def traced_param_result(self, pos_only, / , pos, kw='KW', *, kw_only='KW_ONLY') -> str: - getLogger().info('') - return 'OK' + """Method with no parameters, returns a string.""" + getLogger().info("") + return "OK" + def traced_param_noresult(self, pos_only, / , pos, kw="KW", *, kw_only="KW_ONLY") -> None: + """Method with various parameters, no return value.""" + getLogger().info("") + def traced_param_result(self, pos_only, / , pos, kw="KW", *, kw_only="KW_ONLY") -> str: + """Method with various parameters, returns a string.""" + getLogger().info("") + return "OK" def traced_long_result(self) -> str: - getLogger().info('') - return '0123456789' * 10 + """Method returning a long string for truncation tests.""" + getLogger().info("") + return "0123456789" * 10 def traced_raises(self) -> None: - getLogger().info('') + """Method that raises an exception.""" + getLogger().info("") raise Error("No cookies left") -class DecoratedTraced(LoggingIdMixin): - "traceable callables" - def __init__(self, owner: BaseLoggingTest, logging_id: str=None): - self.owner = owner +class DecoratedTraced: + """A sample class using the @traced decorator directly.""" + def __init__(self, logging_id: str | None = None): if logging_id is not None: - self._logging_id_ = logging_id + self._agent_name_: str = logging_id # type: ignore @traced() def traced_noparam_noresult(self) -> None: - getLogger().info('') + """Decorated method with no parameters and no return value.""" + getLogger().info("") @traced() def traced_noparam_result(self) -> str: - getLogger().info('') - return 'OK' + """Decorated method with no parameters, returns a string.""" + getLogger().info("") + return "OK" @traced() - def traced_param_noresult(self, pos_only, / , pos, kw='KW', *, kw_only='KW_ONLY') -> None: - getLogger().info('') + def traced_param_noresult(self, pos_only, / , pos, kw="KW", *, kw_only="KW_ONLY") -> None: + """Decorated method with various parameters, no return value.""" + getLogger().info("") @traced() - def traced_param_result(self, pos_only, / , pos, kw='KW', *, kw_only='KW_ONLY') -> str: - getLogger().info('') - return 'OK' + def traced_param_result(self, pos_only, / , pos, kw="KW", *, kw_only="KW_ONLY") -> str: + """Decorated method with various parameters, returns a string.""" + getLogger().info("") + return "OK" @traced() def traced_raises(self) -> None: - getLogger().info('') + """Decorated method that raises an exception.""" + getLogger().info("") raise Error("No cookies left") +class UnregisteredTraced: + """A sample class *not* registered with TraceManager.""" + def method_a(self): + getLogger().info("") -class TestTraced(BaseLoggingTest): - """Unit tests for firebird.base.logging""" - def setUp(self) -> None: - super().setUp() - if not __debug__: - os.environ['FBASE_TRACE'] = 'on' - trace_manager.flags |= TraceFlag.ACTIVE - def verify_func(self, records, func_name: str, only: bool=False) -> None: - if only: - self.assertEqual(len(records), 1) + +@pytest.fixture(autouse=True) +def ensure_trace(monkeypatch): + """Fixture to ensure tracing is active and manager state is clean before each test.""" + # Ensure tracing is on for tests, even if __debug__ is False + if not __debug__: + monkeypatch.setenv("FBASE_TRACE", "on") + else: + # Make sure env var doesn't override __debug__ if it's set to off + monkeypatch.delenv("FBASE_TRACE", raising=False) + + # Configure logging manager format + logging_manager.logger_fmt = ["trace_test"] + + # Reset TraceManager state + trace_manager.clear() + trace_manager.decorator = traced # Reset to default decorator + # trace_manager._traced.clear() # Done by clear() + # trace_manager._flags = TraceFlag.NONE # Reset below + # Read flags from environment (consistent with manager init) + trace_manager.trace_active = convert_from_str(bool, os.getenv("FBASE_TRACE", str(__debug__))) + # Set flags based on env vars or defaults + flags = TraceFlag.NONE + if trace_manager.trace_active: + flags |= TraceFlag.ACTIVE + if convert_from_str(bool, os.getenv("FBASE_TRACE_BEFORE", "no")): # pragma: no cover + flags |= TraceFlag.BEFORE + if convert_from_str(bool, os.getenv("FBASE_TRACE_AFTER", "no")): # pragma: no cover + flags |= TraceFlag.AFTER + if convert_from_str(bool, os.getenv("FBASE_TRACE_FAIL", "yes")): + flags |= TraceFlag.FAIL + trace_manager.flags = flags + + # Register the Traced class (simulates TracedMixin effect) + trace_manager.register(Traced) + assert trace_manager.is_registered(Traced) # Verify registration + + +def verify_func(records: list[LogRecord], func_name: str, only: bool = False) -> None: + """Helper to verify that the actual (non-trace) log message from a function exists.""" + expected_msg = f"<{func_name}>" + if only: + assert len(records) == 1, f"Expected only 1 record, got {len(records)}" + assert records[0].getMessage() == expected_msg, f"Expected message '{expected_msg}'" + records.pop(0) # Consume the record + else: + found = False + initial_len = len(records) + for i, record in enumerate(records): + if record.getMessage() == expected_msg: + records.pop(i) + found = True + break + assert found, f"Did not find expected message '{expected_msg}' in records" + assert len(records) == initial_len - 1 + +# --- Test Functions --- + +def test_trace_dataclasses(): + """Tests the TracedItem and TracedClass dataclasses.""" + item = TracedItem(method="method_a", decorator=traced, args=[1], kwargs={'a': 1}) + assert item.method == "method_a" + assert item.decorator is traced + assert item.args == [1] + assert item.kwargs == {'a': 1} + assert item.get_key() == "method_a" + + cls_entry = TracedClass(cls=Traced) + assert cls_entry.cls is Traced + assert isinstance(cls_entry.traced, type(trace_manager._traced)) # Check registry type + assert len(cls_entry.traced) == 0 + assert cls_entry.get_key() is Traced + + cls_entry.traced.store(item) + assert len(cls_entry.traced) == 1 + + +def test_traced_defaults_fail_only(caplog): + """Tests the @traced decorator with default manager flags (ACTIVE | FAIL).""" + + def verify(records: list[LogRecord], func_name: str, result: str, + outcome: tuple[str, str] = ("log_failed", "<--")) -> None: + """Verify failure log record.""" + assert len(records) >= 2 # Expect func log + fail log + verify_func(records, func_name) # Consume the function's own log + assert len(records) == 1 # Only fail log should remain + rec = records.pop(0) + assert rec.name == "trace_test" # From logger_fmt + assert rec.levelno == LogLevel.DEBUG + assert rec.filename == "trace.py" + assert rec.funcName == outcome[0] + assert rec.topic == "trace" + assert rec.agent == get_agent_name(ctx) + assert rec.message.startswith(f"{outcome[1]} {func_name}") + assert rec.message.endswith(f"{result}") + + # Assuming fixture sets flags = ACTIVE | FAIL + assert trace_manager.flags == TraceFlag.ACTIVE | TraceFlag.FAIL + ctx = Traced() + + # Test methods that DON'T fail - should only log their own message + with caplog.at_level(level="DEBUG"): + traced()(ctx.traced_noparam_noresult)() + verify_func(caplog.records, "traced_noparam_noresult", only=True) + + with caplog.at_level(level="DEBUG"): + traced()(ctx.traced_noparam_result)() + verify_func(caplog.records, "traced_noparam_result", only=True) + + with caplog.at_level(level="DEBUG"): + traced()(ctx.traced_param_noresult)(1, 2, kw_only="NO-DEFAULT") + verify_func(caplog.records, "traced_param_noresult", only=True) + + with caplog.at_level(level="DEBUG"): + traced()(ctx.traced_param_result)(1, 2, kw_only="NO-DEFAULT") + verify_func(caplog.records, "traced_param_result", only=True) + + # Test method that fails - should log fail message + with caplog.at_level(level="DEBUG"): + with pytest.raises(Error): + traced()(ctx.traced_raises)() + verify(caplog.records, "traced_raises", result="Error: No cookies left") + + +def test_traced_all_events(caplog): + """Tests the @traced decorator when all event flags (BEFORE, AFTER, FAIL) are active.""" + def verify(records: list[LogRecord], func_name: str, params: str = "", result: str = "", + outcome: tuple[str, str] = ("log_after", "<<<"), expect_fail: bool = False) -> None: + """Verify log records for BEFORE, func, and AFTER/FAIL.""" + expected_records = 3 if __debug__ else 1 # Needs FBASE_TRACE=on if not debug + assert len(records) == expected_records, f"Expected {expected_records} records for {func_name}, got {len(records)}" + + if not __debug__: # Only func log expected + verify_func(records, func_name, only=True) + return + + # BEFORE log + rec_before = records.pop(0) + assert rec_before.name == "trace_test" + assert rec_before.levelno == LogLevel.DEBUG + assert rec_before.funcName == "log_before" + assert rec_before.topic == "trace" + assert rec_before.agent == get_agent_name(ctx) + expected_before = f">>> {func_name}({params})" if decorator.with_args else f">>> {func_name}" + assert rec_before.message == expected_before + + # Function's own log + verify_func(records, func_name) # Consumes the record + + # AFTER or FAIL log + rec_after_fail = records.pop(0) + assert rec_after_fail.name == "trace_test" + assert rec_after_fail.levelno == LogLevel.DEBUG + assert rec_after_fail.funcName == outcome[0] + assert rec_after_fail.topic == "trace" + assert rec_after_fail.agent == get_agent_name(ctx) + assert rec_after_fail.message.startswith(f"{outcome[1]} {func_name}") + # Result check needs refinement based on has_result + if expect_fail or decorator.has_result: + assert rec_after_fail.message.endswith(f"{result}") else: - self.assertGreaterEqual(len(records), 1) - self.assertEqual(records.pop(0).message, f'<{func_name}>') - def test_aaa(self): - "Default settings only, events: FAIL" - - def verify(records, func_name, params: str='', result: str=None, - outcome: str=('log_failed', '<--')) -> None: - self.assertGreaterEqual(len(records), 2) - self.verify_func(records, func_name) - rec = records.pop(0) - self.assertEqual(rec.name, 'trace') - self.assertEqual(rec.levelno, LogLevel.DEBUG) - self.assertEqual(rec.args, ()) - self.assertEqual(rec.filename, 'trace.py') - self.assertEqual(rec.module, 'trace') - self.assertEqual(rec.funcName, outcome[0]) - self.assertEqual(rec.topic, 'trace') - self.assertEqual(rec.agent, 'Traced') - self.assertEqual(rec.context, UNDEFINED) - self.assertTrue(rec.message.startswith(f'{outcome[1]} {func_name}')) - self.assertTrue(rec.message.endswith(f'{result}')) - - self.assertEqual(trace_manager.flags, TraceFlag.ACTIVE | TraceFlag.FAIL) - ctx = Traced(self) - # traced_noparam_noresult - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_noparam_noresult)() - self.verify_func(log.records, 'traced_noparam_noresult', True) - # traced_noparam_result - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_noparam_result)() - self.verify_func(log.records, 'traced_noparam_result', True) - # traced_param_noresult - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_param_noresult)(1, 2, kw_only='NO-DEFAULT') - self.verify_func(log.records, 'traced_param_noresult', True) - # traced_param_noresult - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_param_result)(1, 2, kw_only='NO-DEFAULT') - self.verify_func(log.records, 'traced_param_result', True) - # traced_raises - with self.assertLogs(level='DEBUG') as log: - with self.assertRaises(Error): - traced()(ctx.traced_raises)() - verify(log.records, 'traced_raises', result='Error: No cookies left') - def test_aab(self): - "Default decorator settings, all events" - def verify(records, func_name: str, params: str='', result: str='', - outcome: str=('log_after', '<<<')) -> None: - self.assertEqual(len(records), 3) - rec: LogRecord = records.pop(0) - self.assertEqual(rec.name, 'trace') - self.assertEqual(rec.levelno, LogLevel.DEBUG) - self.assertEqual(rec.args, ()) - self.assertEqual(rec.filename, 'trace.py') - self.assertEqual(rec.module, 'trace') - self.assertEqual(rec.funcName, 'log_before') - self.assertEqual(rec.topic, 'trace') - self.assertEqual(rec.agent, 'Traced') - self.assertEqual(rec.context, UNDEFINED) - self.assertEqual(rec.message, f'>>> {func_name}({params})') - # - self.verify_func(records, func_name) - # - rec = records.pop(0) - self.assertEqual(rec.name, 'trace') - self.assertEqual(rec.levelno, LogLevel.DEBUG) - self.assertEqual(rec.args, ()) - self.assertEqual(rec.filename, 'trace.py') - self.assertEqual(rec.module, 'trace') - self.assertEqual(rec.funcName, outcome[0]) - self.assertEqual(rec.topic, 'trace') - self.assertEqual(rec.agent, 'Traced') - self.assertEqual(rec.context, UNDEFINED) - self.assertTrue(rec.message.startswith(f'{outcome[1]} {func_name}')) - self.assertTrue(rec.message.endswith(f'{result}')) - - ctx = Traced(self) - trace_manager.flags |= (TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) - - # traced_noparam_noresult - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_noparam_noresult)() - verify(log.records, 'traced_noparam_noresult') - # traced_noparam_result - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_noparam_result)() - verify(log.records, 'traced_noparam_result', result='OK') - # traced_param_noresult - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_param_noresult)(1, 2, kw_only='NO-DEFAULT') - verify(log.records, 'traced_param_noresult', "pos_only=1, pos=2, kw='KW', kw_only='NO-DEFAULT'") - # traced_param_noresult - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_param_result)(1, 2, kw_only='NO-DEFAULT') - verify(log.records, 'traced_param_result', "pos_only=1, pos=2, kw='KW', kw_only='NO-DEFAULT'", 'OK') - # traced_raises - with self.assertLogs(level='DEBUG') as log: - with self.assertRaises(Error): - traced()(ctx.traced_raises)() - verify(log.records, 'traced_raises', result='Error: No cookies left', outcome=('log_failed', '<--')) - def test_custom_msg(self): - def verify(records, msg_before: str, msg_after_start: str, msg_after_end: str='') -> None: - self.assertEqual(len(records), 3) - rec = records.pop(0) - self.assertEqual(rec.message, msg_before) - records.pop(0) - rec = records.pop(0) - self.assertTrue(rec.message.startswith(msg_after_start)) - self.assertTrue(rec.message.endswith(msg_after_end)) - - ctx = Traced(self) - trace_manager.flags |= (TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) - # - with self.assertLogs(level='DEBUG') as log: - d = traced(msg_before='ENTER {_fname_} ({pos_only}, {pos}, {kw}, {kw_only})') - d(ctx.traced_param_noresult)(1, 2, kw_only='NO-DEFAULT') - verify(log.records, 'ENTER traced_param_noresult (1, 2, KW, NO-DEFAULT)', '<<< traced_param_noresult') - with self.assertLogs(level='DEBUG') as log: - d = traced(msg_after='EXIT {_fname_}: {_result_}') - d(ctx.traced_param_noresult)(1, 2, kw_only='NO-DEFAULT') - verify(log.records, ">>> traced_param_noresult(pos_only=1, pos=2, kw='KW', kw_only='NO-DEFAULT')", - 'EXIT traced_param_noresult: None', '') - d = traced(msg_before='ENTER {_fname_} ({pos_only}, {pos}, {kw}, {kw_only})', - msg_after='EXIT {_fname_}: {_result_}') - with self.assertLogs(level='DEBUG') as log: - d(ctx.traced_param_noresult)(1, 2, kw_only='NO-DEFAULT') - verify(log.records, 'ENTER traced_param_noresult (1, 2, KW, NO-DEFAULT)', - 'EXIT traced_param_noresult: None', '') - with self.assertLogs(level='DEBUG') as log: - d = traced(msg_before='ENTER {_fname_} ()', - msg_after='EXIT {_fname_}: {_result_}', - msg_failed='!!! {_fname_}: {_exc_}') - with self.assertRaises(Error): - d(ctx.traced_raises)() - verify(log.records, 'ENTER traced_raises ()', - '!!! traced_raises: Error: No cookies left', '') - def test_extra(self): - def foo(bar=''): - return f'Foo{bar}!' - - def verify(records, msg_before: str, msg_after: str) -> None: - self.assertEqual(len(records), 3) - self.assertEqual(records.pop(0).message, msg_before) - records.pop(0) - self.assertEqual(records.pop(0).message, msg_after) - - ctx = Traced(self) - trace_manager.flags |= (TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) - # - with self.assertLogs(level='DEBUG') as log: - d = traced(msg_before='>>> {_fname_} ({foo()}, {foo(kw)}, {foo(kw_only)})', - msg_after='<<< {_fname_}: {foo(_result_)}', extra={'foo': foo}) - d(ctx.traced_param_noresult)(1, 2, kw_only='bar') - verify(log.records, '>>> traced_param_noresult (Foo!, FooKW!, Foobar!)', - '<<< traced_param_noresult: FooNone!') - def test_topic(self): - def verify(records, topic: str) -> None: - self.assertEqual(len(records), 3) - self.assertEqual(records.pop(0).topic, topic) - records.pop(0) - self.assertEqual(records.pop(0).topic, topic) - - ctx = Traced(self) - trace_manager.flags |= (TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) - # - with self.assertLogs(level='DEBUG') as log: - traced(topic='fun')(ctx.traced_noparam_noresult)() - verify(log.records, 'fun') - def test_max_param_length(self): - def verify(records, message: str, result: str='Result: OK') -> None: - self.assertEqual(len(records), 3) - self.assertEqual(records.pop(0).message, message) - records.pop(0) - self.assertTrue(records.pop(0).message.endswith(result)) - - ctx = Traced(self) - trace_manager.flags |= (TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) - # - with self.assertLogs(level='DEBUG') as log: - traced(max_param_length=10)(ctx.traced_param_result)('123456789', '0123456789' * 10) - verify(log.records, ">>> traced_param_result(pos_only='123456789', pos='0123456789..[90]', kw='KW', kw_only='KW_ONLY')") - # - with self.assertLogs(level='DEBUG') as log: - traced(max_param_length=10)(ctx.traced_long_result)() - verify(log.records, '>>> traced_long_result()', 'Result: 0123456789..[90]') - def test_agent_ctx(self): - def verify(records, agent, ctx) -> None: - self.assertEqual(len(records), 3) - rec = records.pop(0) - self.assertEqual(rec.agent, agent) - self.assertEqual(rec.context, ctx) - records.pop(0) - rec = records.pop(0) - self.assertEqual(rec.agent, agent) - self.assertEqual(rec.context, ctx) - - ctx = Traced(self) - trace_manager.flags |= (TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) - # - with self.assertLogs(level='DEBUG') as log: - traced(agent=UNDEFINED, context=UNDEFINED)(ctx.traced_noparam_noresult)() - verify(log.records, UNDEFINED, UNDEFINED) - ctx.log_context = '' - ctx._logging_id_ = '' - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_noparam_noresult)() - verify(log.records, '', '') - def test_level(self): - def verify(records, level) -> None: - self.assertEqual(len(records), 3) - self.assertEqual(records.pop(0).levelno, level) - records.pop(0) - self.assertEqual(records.pop(0).levelno, level) - - ctx = Traced(self) - trace_manager.flags |= (TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) - # - with self.assertLogs(level='DEBUG') as log: - traced(level=LogLevel.INFO)(ctx.traced_noparam_noresult)() - verify(log.records, LogLevel.INFO) - def test_forced(self): - def verify(records, msg_before: str, msg_after_start: str, msg_after_end: str='') -> None: - self.assertEqual(len(records), 3) - rec = records.pop(0) - self.assertEqual(rec.message, msg_before) - records.pop(0) - rec = records.pop(0) - self.assertTrue(rec.message.startswith(msg_after_start)) - self.assertTrue(rec.message.endswith(msg_after_end)) - - ctx = Traced(self) - trace_manager.flags = (TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_noparam_noresult)() - self.verify_func(log.records, 'traced_noparam_noresult', True) - # - with self.assertLogs(level='DEBUG') as log: - traced(flags=TraceFlag.ACTIVE)(ctx.traced_noparam_noresult)() - verify(log.records, '>>> traced_noparam_noresult()', '<<< traced_noparam_noresult') - def test_env(self): - ctx = Traced(self) - trace_manager.flags = (TraceFlag.ACTIVE | TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_noparam_noresult)() - self.assertEqual(len(log.records), 3) - os.environ['FBASE_TRACE'] = 'off' - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_noparam_noresult)() - self.verify_func(log.records, 'traced_noparam_noresult', True) - del os.environ['FBASE_TRACE'] - def test_debug(self): - if __debug__: - self.skipTest("__debug__ is True") - if 'FBASE_TRACE' in os.environ: - del os.environ['FBASE_TRACE'] - ctx = Traced(self) - trace_manager.flags = (TraceFlag.ACTIVE | TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER) - with self.assertLogs(level='DEBUG') as log: - traced()(ctx.traced_noparam_noresult)() - self.verify_func(log.records, 'traced_noparam_noresult', True) - def test_decorated(self): - "Default settings only, events: FAIL" - - def verify(records, func_name, params: str='', result: str=None, - outcome: str=('log_failed', '<--')) -> None: - self.assertGreaterEqual(len(records), 2 if __debug__ else 1) - self.verify_func(records, func_name) - if __debug__: - rec = records.pop(0) - self.assertEqual(rec.name, 'trace') - self.assertEqual(rec.levelno, LogLevel.DEBUG) - self.assertEqual(rec.args, ()) - self.assertEqual(rec.filename, 'trace.py') - self.assertEqual(rec.module, 'trace') - self.assertEqual(rec.funcName, outcome[0]) - self.assertEqual(rec.topic, 'trace') - self.assertEqual(rec.agent, 'DecoratedTraced') - self.assertEqual(rec.context, UNDEFINED) - self.assertTrue(rec.message.startswith(f'{outcome[1]} {func_name}')) - self.assertTrue(rec.message.endswith(f'{result}')) - - self.assertEqual(trace_manager.flags, TraceFlag.ACTIVE | TraceFlag.FAIL) - ctx = DecoratedTraced(self) - # traced_noparam_noresult - with self.assertLogs(level='DEBUG') as log: - ctx.traced_noparam_noresult() - self.verify_func(log.records, 'traced_noparam_noresult', True) - # traced_noparam_result - with self.assertLogs(level='DEBUG') as log: - ctx.traced_noparam_result() - self.verify_func(log.records, 'traced_noparam_result', True) - # traced_param_noresult - with self.assertLogs(level='DEBUG') as log: - ctx.traced_param_noresult(1, 2, kw_only='NO-DEFAULT') - self.verify_func(log.records, 'traced_param_noresult', True) - # traced_param_noresult - with self.assertLogs(level='DEBUG') as log: - ctx.traced_param_result(1, 2, kw_only='NO-DEFAULT') - self.verify_func(log.records, 'traced_param_result', True) - # traced_raises - with self.assertLogs(level='DEBUG') as log: - with self.assertRaises(Error): - ctx.traced_raises() - verify(log.records, 'traced_raises', result='Error: No cookies left') - def test_add_traced(self): - "Default settings only, events: FAIL" - - def verify(records, func_name, params: str='', result: str=None, - outcome: str=('log_failed', '<--')) -> None: - self.assertGreaterEqual(len(records), 2) - self.verify_func(records, func_name) - rec = records.pop(0) - self.assertEqual(rec.name, 'trace') - self.assertEqual(rec.levelno, LogLevel.DEBUG) - self.assertEqual(rec.args, ()) - self.assertEqual(rec.filename, 'trace.py') - self.assertEqual(rec.module, 'trace') - self.assertEqual(rec.funcName, outcome[0]) - self.assertEqual(rec.topic, 'trace') - self.assertEqual(rec.agent, 'Traced') - self.assertEqual(rec.context, UNDEFINED) - self.assertTrue(rec.message.startswith(f'{outcome[1]} {func_name}')) - self.assertTrue(rec.message.endswith(f'{result}')) - - self.assertEqual(trace_manager.flags, TraceFlag.ACTIVE | TraceFlag.FAIL) - add_trace(Traced, 'traced_noparam_noresult') - add_trace(Traced, 'traced_noparam_result') - add_trace(Traced, 'traced_param_noresult') - add_trace(Traced, 'traced_param_result') - add_trace(Traced, 'traced_raises') - ctx = Traced(self) - # traced_noparam_noresult - with self.assertLogs(level='DEBUG') as log: - ctx.traced_noparam_noresult() - self.verify_func(log.records, 'traced_noparam_noresult', True) - # traced_noparam_result - with self.assertLogs(level='DEBUG') as log: - ctx.traced_noparam_result() - self.verify_func(log.records, 'traced_noparam_result', True) - # traced_param_noresult - with self.assertLogs(level='DEBUG') as log: - ctx.traced_param_noresult(1, 2, kw_only='NO-DEFAULT') - self.verify_func(log.records, 'traced_param_noresult', True) - # traced_param_result - with self.assertLogs(level='DEBUG') as log: - ctx.traced_param_result(1, 2, kw_only='NO-DEFAULT') - self.verify_func(log.records, 'traced_param_result', True) - # traced_raises - with self.assertLogs(level='DEBUG') as log: - with self.assertRaises(Error): - traced()(ctx.traced_raises)() - verify(log.records, 'traced_raises', result='Error: No cookies left') - - -if __name__ == '__main__': - unittest.main() + assert not rec_after_fail.message.endswith(f"{result}") # Check it doesn't include result + + ctx = Traced() + # Enable all flags in the manager for this test + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER + # traced_noparam_noresult (has_result=False implicitly) + decorator = traced() # As we refer to decorator in verify, we need fresh one + with caplog.at_level(level="DEBUG"): + decorator(ctx.traced_noparam_noresult)() + verify(caplog.records, "traced_noparam_noresult", result="None", expect_fail=False) + caplog.clear() + + # traced_noparam_result (has_result=True implicitly) + decorator = traced() # As we refer to decorator in verify, we need fresh one + with caplog.at_level(level="DEBUG"): + decorator(ctx.traced_noparam_result)() + verify(caplog.records, "traced_noparam_result", result="'OK'", expect_fail=False) + caplog.clear() + + # traced_param_noresult + decorator = traced() # As we refer to decorator in verify, we need fresh one + params_str = "pos_only=1, pos=2, kw='KW', kw_only='NO-DEFAULT'" + with caplog.at_level(level="DEBUG"): + decorator(ctx.traced_param_noresult)(1, 2, kw_only="NO-DEFAULT") + verify(caplog.records, "traced_param_noresult", params=params_str, result="None", expect_fail=False) + caplog.clear() + + # traced_param_result + decorator = traced() # As we refer to decorator in verify, we need fresh one + with caplog.at_level(level="DEBUG"): + decorator(ctx.traced_param_result)(1, 2, kw_only="NO-DEFAULT") + verify(caplog.records, "traced_param_result", params=params_str, result="'OK'", expect_fail=False) + caplog.clear() + + # traced_raises + decorator = traced() # As we refer to decorator in verify, we need fresh one + with caplog.at_level(level="DEBUG"): + with pytest.raises(Error): + decorator(ctx.traced_raises)() + verify(caplog.records, "traced_raises", params="", result="Error: No cookies left", outcome=("log_failed", "<--"), expect_fail=True) + +# --- Tests for specific `traced` arguments --- + +def test_traced_custom_msg(caplog): + """Tests customizing log messages via traced arguments.""" + def verify(records: list[LogRecord], msg_before: str, msg_after_fail_start: str, msg_after_fail_end: str = "") -> None: + if not __debug__: pytest.skip("Trace inactive") + assert len(records) == 3 # Before, Func, After/Fail + assert records[0].message == msg_before + # records[1] is the function's log + assert records[2].message.startswith(msg_after_fail_start) + assert records[2].message.endswith(msg_after_fail_end) + + ctx = Traced() + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER + + # Custom BEFORE message + with caplog.at_level(level="DEBUG"): + d = traced(msg_before="ENTER {_fname_} ({pos_only}, {pos}, {kw}, {kw_only})") + d(ctx.traced_param_noresult)(1, 2, kw_only="NO-DEFAULT") + verify(caplog.records, "ENTER traced_param_noresult (1, 2, KW, NO-DEFAULT)", "<<< traced_param_noresult") # Default after msg + caplog.clear() + + # Custom AFTER message + with caplog.at_level(level="DEBUG"): + d = traced(msg_after="EXIT {_fname_}: {_result_!r}") + d(ctx.traced_param_result)(1, 2, kw_only="NO-DEFAULT") + verify(caplog.records, ">>> traced_param_result(pos_only=1, pos=2, kw='KW', kw_only='NO-DEFAULT')", # Default before msg + "EXIT traced_param_result: 'OK'") + caplog.clear() + + # Custom FAIL message + with caplog.at_level(level="DEBUG"): + d = traced(msg_failed="!!! {_fname_}: {_exc_}") + with pytest.raises(Error): + d(ctx.traced_raises)() + verify(caplog.records, ">>> traced_raises()", # Default before msg + "!!! traced_raises: Error: No cookies left") + + +def test_traced_extra_arg(caplog): + """Tests passing and using 'extra' data in trace messages.""" + def foo(bar=""): + """Helper function available in extra.""" + return f"Foo{bar}!" + + def verify(records: list[LogRecord], msg_before: str, msg_after: str) -> None: + if not __debug__: pytest.skip("Trace inactive") + assert len(records) == 3 + assert records[0].message == msg_before + assert records[2].message == msg_after + + ctx = Traced() + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER + + with caplog.at_level(level="DEBUG"): + # Use 'foo' from extra in message formats + d = traced(msg_before=">>> {_fname_} ({foo(kw)}, {foo(kw_only)})", + msg_after="<<< {_fname_}: {foo(_result_)}", extra={"foo": foo}) + d(ctx.traced_param_result)(1, 2, kw_only="bar") + # Verify 'foo' was called correctly within the f-string interpolation + verify(caplog.records, ">>> traced_param_result (FooKW!, Foobar!)", + "<<< traced_param_result: FooOK!") + + +def test_traced_topic_arg(caplog): + """Tests setting a custom logging topic via the 'topic' argument.""" + def verify(records: list[LogRecord], topic: str) -> None: + if not __debug__: pytest.skip("Trace inactive") + assert len(records) == 3 + assert records[0].topic == topic + assert records[2].topic == topic + + ctx = Traced() + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER + + with caplog.at_level(level="DEBUG"): + traced(topic="custom_topic")(ctx.traced_noparam_noresult)() + verify(caplog.records, "custom_topic") + + +def test_traced_max_param_length_arg(caplog): + """Tests argument and result truncation using 'max_param_length'.""" + def verify(records: list[LogRecord], msg_before: str, msg_after_end: str) -> None: + if not __debug__: pytest.skip("Trace inactive") + assert len(records) == 3 + assert records[0].message == msg_before + assert records[2].message.endswith(msg_after_end) + + ctx = Traced() + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER + max_len = 10 + + # Test argument truncation + with caplog.at_level(level="DEBUG"): + traced(max_param_length=max_len)(ctx.traced_param_result)("1234567890ABC", "x" * 15, kw="LongKeyword") + # Expect truncation like 'LongKeywor..[1]' if length > 10 + # Exact format depends on internal logic, check if it's shorter than original + ellipsis + expected_before = ">>> traced_param_result(pos_only='1234567890..[3]', pos='xxxxxxxxxx..[5]', kw='LongKeywor..[1]', kw_only='KW_ONLY')" + verify(caplog.records, expected_before, "Result: 'OK'") # Result not truncated here + caplog.clear() + + # Test result truncation + with caplog.at_level(level="DEBUG"): + traced(max_param_length=max_len)(ctx.traced_long_result)() + expected_after_end = "Result: '0123456789..[90]'" + verify(caplog.records, ">>> traced_long_result()", expected_after_end) + + +def test_traced_agent_arg(caplog): + """Tests agent handling: default resolution vs. explicit 'agent' argument.""" + def verify(records: list[LogRecord], agent_id: Any, context: Any = None) -> None: + if not __debug__: pytest.skip("Trace inactive") + assert len(records) == 3 + assert records[0].agent == get_agent_name(agent_id) + assert records[0].context == context + assert records[2].agent == get_agent_name(agent_id) + assert records[2].context == context + + ctx = Traced("AgentID_1") + ctx.log_context = "Context_1" # type: ignore + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER + + # Default agent resolution (uses ctx._agent_name_) + with caplog.at_level(level="DEBUG"): + traced()(ctx.traced_noparam_noresult)() + verify(caplog.records, "AgentID_1", "Context_1") + caplog.clear() + + # Explicit agent argument + explicit_agent = Namespace() + explicit_agent.name = "ExplicitAgent" # type: ignore + with caplog.at_level(level="DEBUG"): + traced(agent=explicit_agent)(ctx.traced_noparam_noresult)() + verify(caplog.records, explicit_agent, None) # Context comes from agent, explicit_agent has no log_context + caplog.clear() + + # Agent argument as DEFAULT (should resolve to ctx like default case) + with caplog.at_level(level="DEBUG"): + traced(agent=DEFAULT)(ctx.traced_noparam_noresult)() + verify(caplog.records, "AgentID_1", "Context_1") + + +def test_traced_level_arg(caplog): + """Tests setting a custom logging level via the 'level' argument.""" + def verify(records: list[LogRecord], level: int) -> None: + if not __debug__: pytest.skip("Trace inactive") + assert len(records) == 3 + assert records[0].levelno == level + assert records[2].levelno == level + + ctx = Traced() + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.FAIL | TraceFlag.BEFORE | TraceFlag.AFTER + + # Log at INFO level + with caplog.at_level(level="INFO"): # Ensure caplog captures INFO + traced(level=LogLevel.INFO)(ctx.traced_noparam_noresult)() + verify(caplog.records, LogLevel.INFO) + + # Log at WARNING level (should still be captured if caplog level is INFO or lower) + caplog.clear() + with caplog.at_level(level="INFO"): + traced(level=LogLevel.WARNING)(ctx.traced_noparam_noresult)() + verify(caplog.records, LogLevel.WARNING) + + +def test_traced_flags_override(caplog): + """Tests overriding TraceManager flags using the decorator's 'flags' argument.""" + def verify_log_counts(records: list[LogRecord], before: bool, after: bool, fail: bool): + """Checks presence/absence of specific trace logs.""" + if not __debug__: pytest.skip("Trace inactive") + has_before = any(r.funcName == 'log_before' for r in records) + has_after = any(r.funcName == 'log_after' for r in records) + has_fail = any(r.funcName == 'log_failed' for r in records) + assert has_before == before + assert has_after == after + assert has_fail == fail + + ctx = Traced() + # Manager: FAIL only + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.FAIL + + # Decorator forces BEFORE | AFTER (FAIL comes from manager) + with caplog.at_level(level="DEBUG"): + traced(flags=TraceFlag.BEFORE | TraceFlag.AFTER)(ctx.traced_noparam_noresult)() + # Expect BEFORE, func, AFTER logs + verify_log_counts(caplog.records, before=True, after=True, fail=False) + + # Decorator forces FAIL only (manager adds ACTIVE) + caplog.clear() + with caplog.at_level(level="DEBUG"): + with pytest.raises(Error): + traced(flags=TraceFlag.FAIL)(ctx.traced_raises)() + # Expect func, FAIL logs (FAIL overrides manager's default FAIL?) -> No, flags are ORed. + # If manager has FAIL and decorator has FAIL, result still has FAIL. + # This test doesn't show override well. Let's try disabling. + verify_log_counts(caplog.records, before=False, after=False, fail=True) + + # Manager: BEFORE | AFTER | FAIL | ACTIVE + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.BEFORE | TraceFlag.AFTER | TraceFlag.FAIL + + # Decorator forces *only* ACTIVE (disables others for this call) + # The OR logic means we cannot *disable* flags this way. + # The test 'test_forced' seems misnamed or the logic misunderstood previously. + # Let's test adding a flag instead. + + # Manager: ACTIVE | FAIL + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.FAIL + # Decorator adds BEFORE + caplog.clear() + with caplog.at_level(level="DEBUG"): + traced(flags=TraceFlag.BEFORE)(ctx.traced_noparam_noresult)() + # Expect BEFORE, func logs (no fail, no after) + verify_log_counts(caplog.records, before=True, after=False, fail=False) + + +def test_traced_env_disable(caplog, monkeypatch): + """Tests disabling trace via FBASE_TRACE=off environment variable.""" + ctx = Traced() + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.BEFORE | TraceFlag.AFTER | TraceFlag.FAIL + + # Baseline: trace should be active + with caplog.at_level(level="DEBUG"): + traced()(ctx.traced_noparam_noresult)() + assert len(caplog.records) == 3 if __debug__ else 1 + caplog.clear() + + # Disable via environment variable + monkeypatch.setenv("FBASE_TRACE", "off") + # Re-create decorator instance *after* setting env var if it caches the check + decorator_instance = traced() + with caplog.at_level(level="DEBUG"): + decorator_instance(ctx.traced_noparam_noresult)() + # Only the function's own log should appear + verify_func(caplog.records, "traced_noparam_noresult", only=True) + + +@pytest.mark.skipif(__debug__, reason="Test requires __debug__ == False") +def test_traced_debug_disable(caplog, monkeypatch): + """Tests disabling trace when __debug__ is False and FBASE_TRACE is not set.""" + # Fixture ensures FBASE_TRACE is deleted if __debug__ is True, + # but we skip if __debug__ is True. So if we run, __debug__ is False. + # We need to ensure FBASE_TRACE is *not* set. + monkeypatch.delenv("FBASE_TRACE", raising=False) + + ctx = Traced() + trace_manager.flags = TraceFlag.ACTIVE | TraceFlag.BEFORE | TraceFlag.AFTER | TraceFlag.FAIL + + # Decorator should be disabled by __debug__ == False + decorator_instance = traced() + with caplog.at_level(level="DEBUG"): + decorator_instance(ctx.traced_noparam_noresult)() + # Only the function's own log should appear + verify_func(caplog.records, "traced_noparam_noresult", only=True) + +# --- Tests for direct @traced usage --- + +def test_decorated_class(caplog): + """Tests using @traced directly on methods of a class (no mixin/manager).""" + # Uses the same verify helper as test_traced_defaults_fail_only + def verify(records: list[LogRecord], func_name: str, result: str, + outcome: tuple[str, str] = ("log_failed", "<--")) -> None: + # Check if trace logs are expected based on __debug__ + if not __debug__: + verify_func(records, func_name, only=True) + return + + assert len(records) >= 2 # Expect func log + fail log + verify_func(records, func_name) # Consume the function's own log + assert len(records) == 1 # Only fail log should remain + rec = records.pop(0) + assert rec.name == "trace_test" # From logger_fmt + assert rec.levelno == LogLevel.DEBUG + assert rec.filename == "trace.py" # Decorator code location + assert rec.funcName == outcome[0] + assert rec.topic == "trace" # Decorator default + assert rec.agent == get_agent_name(ctx) + assert rec.message.startswith(f"{outcome[1]} {func_name}") + assert rec.message.endswith(f"{result}") + + # Manager flags only control implicit tracing via TracedMixin/trace_object + # Direct @traced decorator uses its own defaults + checks env/__debug__ + assert trace_manager.flags == TraceFlag.ACTIVE | TraceFlag.FAIL + ctx = DecoratedTraced("DecoratedAgent") + + # Test methods that DON'T fail - should only log their own message if trace inactive + with caplog.at_level(level="DEBUG"): + ctx.traced_noparam_noresult() + if not __debug__: verify_func(caplog.records, "traced_noparam_noresult", only=True) + else: assert len(caplog.records) == 1 # Only func log, FAIL flag doesn't trigger + caplog.clear() + + # Test method that fails - should log fail message IF trace active + with caplog.at_level(level="DEBUG"): + with pytest.raises(Error): + ctx.traced_raises() + # Verify output based on whether tracing was active + verify(caplog.records, "traced_raises", result="Error: No cookies left") + + +# --- Tests for TraceManager interaction --- + +def test_manager_add_remove_trace(caplog): + """Tests adding and removing trace specifications via TraceManager.""" + # Fixture registers Traced class. Add trace for one method. + add_trace(Traced, "traced_noparam_result", flags=TraceFlag.BEFORE | TraceFlag.AFTER) + ctx = Traced() # Instantiation applies the trace via TracedMeta/trace_object + + # Check that only the added method is traced with specified flags + trace_manager.flags = TraceFlag.ACTIVE # Ensure only ACTIVE is on manager + + # Call traced method + with caplog.at_level(level="DEBUG"): + ctx.traced_noparam_result() + # Expect BEFORE, func, AFTER logs because decorator flags were added + assert len(caplog.records) == 3 + assert caplog.records[0].funcName == "log_before" + assert caplog.records[2].funcName == "log_after" + caplog.clear() + + # Call another method (should not be traced by manager) + with caplog.at_level(level="DEBUG"): + ctx.traced_noparam_noresult() + verify_func(caplog.records, "traced_noparam_noresult", only=True) # Only func log + + # Remove the trace + remove_trace(Traced, "traced_noparam_result") + + # Re-instantiate to get clean object without the removed trace applied + ctx2 = Traced() + with caplog.at_level(level="DEBUG"): + ctx2.traced_noparam_result() + verify_func(caplog.records, "traced_noparam_result", only=True) # Should no longer trace + +def test_manager_trace_object_strict(caplog): + """Tests trace_object with strict=True for unregistered classes.""" + instance = UnregisteredTraced() + # Should work fine with strict=False (default) + traced_instance = trace_object(instance) + assert traced_instance is instance # No changes applied + with caplog.at_level(level="DEBUG"): + traced_instance.method_a() + verify_func(caplog.records, "UnregisteredTraced.method_a", only=True) # No trace logs + + # Should raise TypeError with strict=True + with pytest.raises(TypeError, match="Class 'UnregisteredTraced' not registered for trace!"): + trace_object(instance, strict=True) + + +# --- Config tests are missing --- +# TODO: Add tests for TraceConfig classes and TraceManager.load_config diff --git a/tests/test_types.py b/tests/test_types.py index a613421..0bcce22 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: MIT # # PROGRAM/MODULE: firebird-base -# FILE: test/test_types.py +# FILE: tests/test_types.py # DESCRIPTION: Unit tests for firebird.base.types # CREATED: 14.5.2020 # @@ -34,171 +34,593 @@ # ______________________________________. from __future__ import annotations -from typing import List -import unittest + +import io +import gc # For explicit GC in CachedDistinct test if needed +import sys # For load test from dataclasses import dataclass +from typing import Type # For metaclass annotations + +import pytest + from firebird.base.types import * -class TestTypes(unittest.TestCase): - """Unit tests for firebird.base.types""" - def __init__(self, methodName='runTest'): - super().__init__(methodName) - self.output: List = [] - def setUp(self) -> None: - self.output.clear() - def tearDown(self): +# --- Test Setup --- + +ns = {} + +class A(type): + """Test metaclass A.""" + attr_a: str = "A" # Changed type hint for clarity + def __call__(cls: Type, *args, **kwargs): + ns["A"] = cls.attr_a + return super().__call__(*args, **kwargs) + +class B(type): + """Test metaclass B.""" + attr_b: str = "B" # Changed type hint for clarity + def __call__(cls: Type, *args, **kwargs): + ns["B"] = cls.attr_b + return super().__call__(*args, **kwargs) + +class AA(metaclass=A): + """Test class using metaclass A.""" + pass + +class BB(metaclass=B): + """Test class using metaclass B.""" + pass + +class CC(AA, BB, metaclass=conjunctive): + """Test class combining AA and BB using the conjunctive metaclass.""" + pass + +class ValueHolder: + """Simple object for holding a value during tests.""" + value: Any = None # Added type hint + +def func(): + """Dummy function for testing.""" + pass + +# --- Test Functions --- + +def test_error_exception(): + """Tests the custom Error exception class. + + Verifies: + - Initialization with positional message and keyword arguments. + - Keyword arguments become instance attributes. + - Accessing non-existent attributes returns None (via __getattr__). + - Accessing the special '__notes__' attribute raises AttributeError. + """ + e = Error("Message", code=1, subject=ns) + assert e.args == ("Message",) + assert e.code == 1 + assert e.subject is ns + assert e.other_attr is None + # Explicitly test __notes__ attribute access + with pytest.raises(AttributeError): + _ = e.__notes__ + +def test_singleton_behavior(): + """Tests the Singleton base class and its metaclass. + + Verifies: + - Multiple instantiations of a Singleton subclass return the same instance. + - Subclasses of a Singleton are distinct singleton instances. + - Constructor arguments are only used during the *first* instantiation. + """ + class MySingleton(Singleton): + def __init__(self, arg=None): + # Store arg only if it's the first time __init__ is called + if not '_initialized_arg' in self.__class__.__dict__: + self.__class__._initialized_arg = arg + self.init_arg = arg + + class MyOtherSingleton(MySingleton): + pass + + # Basic singleton behavior + s1 = MySingleton("first") + s2 = MySingleton("second") + assert s1 is s2 + assert hasattr(s1, 'init_arg') + assert s1.init_arg == "first" # Argument from second call was ignored + + # Inheritance + os1 = MyOtherSingleton("other_first") + os2 = MyOtherSingleton("other_second") + assert os1 is os2 + assert s1 is not os1 + assert hasattr(os1, 'init_arg') + assert os1.init_arg == "other_first" + + # Cleanup class attribute for test isolation if needed + del MySingleton._initialized_arg + del MyOtherSingleton._initialized_arg + + +def test_sentinel_objects(): + """Tests the Sentinel base class, its metaclass, and predefined sentinel objects. + + Verifies: + - Predefined sentinels exist, have the correct type, name, str, and repr. + - Sentinels cannot be instantiated or subclassed. + - Dynamic creation of sentinels works, including custom repr. + """ + # 1. Test Predefined Sentinels + predefined = [DEFAULT, INFINITY, UNLIMITED, UNKNOWN, NOT_FOUND, UNDEFINED, ANY, ALL, SUSPEND, RESUME, STOP] + for sentinel_class in predefined: + class_name = sentinel_class.__name__ + assert isinstance(sentinel_class, Sentinel), f"{class_name} is not instance of Sentinel" + # Check if it's an instance of itself (due to metaclass cls.__class__ = cls) + assert isinstance(sentinel_class, sentinel_class), f"{class_name} is not instance of itself" + # Check properties and representations + assert sentinel_class.name == class_name, f"Incorrect name for {class_name}" + assert str(sentinel_class) == class_name, f"Incorrect str for {class_name}" + assert repr(sentinel_class) == class_name, f"Incorrect repr for {class_name}" + + # 2. Test Instantiation/Subclassing Prevention + for sentinel_class in predefined: + # Cannot instantiate predefined sentinels + with pytest.raises(TypeError, match=f"Cannot initialise or subclass sentinel '{sentinel_class.__name__}'"): + sentinel_class() # type: ignore + # Cannot subclass predefined sentinels + with pytest.raises(TypeError, match=f"Cannot initialise or subclass sentinel '{sentinel_class.__name__}'"): + class SubSentinel(sentinel_class): # type: ignore + pass + + # Cannot subclass Sentinel base class after its definition + # The class *definition* might succeed because it uses the metaclass, + # but instantiation of the subclass *should* fail. + class _SubSentinel(Sentinel): pass - def test_exceptions(self): - "Test exceptions" - e = Error("Message", code=1, subject=self) - self.assertTupleEqual(e.args, ("Message",)) - self.assertEqual(e.code, 1) - self.assertEqual(e.subject, self) - self.assertIsNone(e.other_attr) - def test_singletons(self): - "Test Singletons" - class MySingleton(Singleton): - pass - - class MyOtherSingleton(MySingleton): - pass - # - s = MySingleton() - self.assertIs(s, MySingleton()) - os = MyOtherSingleton() - self.assertIs(os, MyOtherSingleton()) - self.assertIsNot(s, os) - def test_sentinel(self): - "Test Sentinel" - self.assertEqual(UNKNOWN.name, 'UNKNOWN') - self.assertEqual(str(UNKNOWN), 'UNKNOWN') - self.assertEqual(repr(UNKNOWN), "Sentinel('UNKNOWN')") - self.assertDictEqual(UNKNOWN.instances, {'DEFAULT': DEFAULT, - 'INFINITY': INFINITY, - 'UNLIMITED': UNLIMITED, - 'UNKNOWN': UNKNOWN, - 'NOT_FOUND': NOT_FOUND, - 'UNDEFINED': UNDEFINED, - 'ANY': ANY, - 'ALL': ALL, - 'SUSPEND': SUSPEND, - 'RESUME': RESUME, - 'STOP': STOP, - }) - for name, sentinel in Sentinel.instances.items(): - self.assertEqual(sentinel, Sentinel(name)) - self.assertNotIn('TEST-SENTINEL', Sentinel.instances) - Sentinel('TEST-SENTINEL') - self.assertIn('TEST-SENTINEL', Sentinel.instances) - def test_distinct(self): - "Test Distinct" - @dataclass - class MyDistinct(Distinct): - key_1: int - key_2: str - payload: str - def get_key(self): - if not hasattr(self, '__key__'): - self.__key__ = (self.key_1, self.key_2) - return self.__key__ - - d = MyDistinct(1, 'A', '1A') - self.assertFalse(hasattr(d, '__key__')) - self.assertEqual(d.get_key(), (1, 'A')) - self.assertTrue(hasattr(d, '__key__')) - d.key_2 = 'B' - self.assertEqual(d.get_key(), (1, 'A')) - def test_cached_distinct(self): - "Test CachedDistinct" - class MyCachedDistinct(CachedDistinct): - def __init__(self, key_1, key_2, payload): - self.key_1 = key_1 - self.key_2 = key_2 - self.payload = payload - @classmethod - def extract_key(cls, *args, **kwargs) -> t.Hashable: - return (args[0], args[1]) - def get_key(self) -> t.Hashable: - return (self.key_1, self.key_2) - # - self.assertTrue(hasattr(MyCachedDistinct, '_instances_')) - cd_1 = MyCachedDistinct(1, ANY, 'type 1A') - self.assertIs(cd_1, MyCachedDistinct(1, ANY, 'type 1A')) - self.assertIsNot(cd_1, MyCachedDistinct(2, ANY, 'type 2A')) - self.assertTrue(hasattr(MyCachedDistinct, '_instances_')) - self.assertEqual(len(getattr(MyCachedDistinct, '_instances_')), 1) - cd_2 = MyCachedDistinct(2, ANY, 'type 2A') - self.assertEqual(len(getattr(MyCachedDistinct, '_instances_')), 2) - temp = MyCachedDistinct(2, ANY, 'type 2A') - self.assertEqual(len(getattr(MyCachedDistinct, '_instances_')), 2) - del cd_1, cd_2, temp - self.assertEqual(len(getattr(MyCachedDistinct, '_instances_')), 0) - def test_zmqaddress(self): - "Test ZMQAddress" - addr = ZMQAddress('ipc://@my-address') - self.assertEqual(addr.address, '@my-address') - self.assertEqual(addr.protocol, ZMQTransport.IPC) - self.assertEqual(addr.domain, ZMQDomain.NODE) - # - addr = ZMQAddress('inproc://my-address') - self.assertEqual(addr.address, 'my-address') - self.assertEqual(addr.protocol, ZMQTransport.INPROC) - self.assertEqual(addr.domain, ZMQDomain.LOCAL) - # - addr = ZMQAddress('tcp://127.0.0.1:*') - self.assertEqual(addr.address, '127.0.0.1:*') - self.assertEqual(addr.protocol, ZMQTransport.TCP) - self.assertEqual(addr.domain, ZMQDomain.NODE) - # - addr = ZMQAddress('tcp://192.168.0.1:8001') - self.assertEqual(addr.address, '192.168.0.1:8001') - self.assertEqual(addr.protocol, ZMQTransport.TCP) - self.assertEqual(addr.domain, ZMQDomain.NETWORK) - # - addr = ZMQAddress('pgm://192.168.0.1:8001') - self.assertEqual(addr.address, '192.168.0.1:8001') - self.assertEqual(addr.protocol, ZMQTransport.PGM) - self.assertEqual(addr.domain, ZMQDomain.NETWORK) - # Bytes - addr = ZMQAddress(b'ipc://@my-address') - self.assertEqual(addr.address, '@my-address') - self.assertEqual(addr.protocol, ZMQTransport.IPC) - self.assertEqual(addr.domain, ZMQDomain.NODE) - # Bad ZMQ address - with self.assertRaises(ValueError) as cm: - addr = ZMQAddress('onion://@my-address') - self.assertEqual(cm.exception.args, ("Unknown protocol 'onion'",)) - with self.assertRaises(ValueError) as cm: - addr = ZMQAddress('192.168.0.1:8001') - self.assertEqual(cm.exception.args, ("Protocol specification required",)) - with self.assertRaises(ValueError) as cm: - addr = ZMQAddress('unknown://192.168.0.1:8001') - self.assertEqual(cm.exception.args, ("Invalid protocol",)) - def test_MIME(self): - "Test MIME" - mime = MIME('text/plain;charset=utf-8') - self.assertEqual(mime.mime_type, 'text/plain') - self.assertEqual(mime.type, 'text') - self.assertEqual(mime.subtype, 'plain') - self.assertDictEqual(mime.params, {'charset': 'utf-8',}) - # - mime = MIME('text/plain') - self.assertEqual(mime.mime_type, 'text/plain') - self.assertEqual(mime.type, 'text') - self.assertEqual(mime.subtype, 'plain') - self.assertDictEqual(mime.params, {}) - # - # Bad MIME type - with self.assertRaises(ValueError) as cm: - mime = MIME('') - self.assertEqual(cm.exception.args, ("MIME type specification must be 'type/subtype[;param=value;...]'",)) - with self.assertRaises(ValueError) as cm: - mime = MIME('model/airplane') - self.assertEqual(cm.exception.args, ("MIME type 'model' not supported",)) - with self.assertRaises(ValueError) as cm: - mime = MIME('text/plain;charset:utf-8') - self.assertEqual(cm.exception.args, ("Wrong specification of MIME type parameters",)) - - -if __name__ == '__main__': - unittest.main() + with pytest.raises(TypeError, match="Cannot initialise or subclass sentinel '_SubSentinel'"): + _SubSentinel() + + # 3. Test Dynamic Sentinel Creation + # Simple dynamic creation + DynamicSent = Sentinel('DynamicSent') + assert DynamicSent.name == 'DynamicSent' + assert str(DynamicSent) == 'DynamicSent' + assert repr(DynamicSent) == 'DynamicSent' + assert isinstance(DynamicSent, Sentinel) + assert isinstance(DynamicSent, DynamicSent) # type: ignore + + # Dynamic creation with custom repr + ReprSent = Sentinel('ReprSent', repr='') + assert ReprSent.name == 'ReprSent' + assert str(ReprSent) == 'ReprSent' # __str__ uses __name__ via default metaclass __repr__ + assert repr(ReprSent) == '' + assert isinstance(ReprSent, Sentinel) + assert isinstance(ReprSent, ReprSent) # type: ignore + + # Cannot instantiate dynamically created sentinels + with pytest.raises(TypeError, match="Cannot initialise or subclass sentinel 'DynamicSent'"): + DynamicSent() # type: ignore + with pytest.raises(TypeError, match="Cannot initialise or subclass sentinel 'ReprSent'"): + ReprSent() # type: ignore + + # Dynamic creation attempt with bases (should fail due to metaclass __call__ logic redirecting + # to the neutered __new__) + with pytest.raises(TypeError, match="'Sentinel' must also be derived from when provided as a metaclass"): + Sentinel('BadDynamic', (object,), {}) + +def test_distinct_abc(): + """Tests the Distinct abstract base class using a concrete dataclass implementation. + + Verifies: + - A concrete subclass can be instantiated. + - get_key() method works as expected. + - __hash__() method works and uses the key. + """ + @dataclass(eq=False) + class MyDistinct(Distinct): + key_1: int + key_2: str + payload: str + # Cache the key after first calculation (implementation detail, but tested here) + __key__: tuple | None = None + + def get_key(self): + if self.__key__ is None: + self.__key__ = (self.key_1, self.key_2) + return self.__key__ + + # Explicitly define __hash__ to rely on Distinct's default or customize + # __hash__ = Distinct.__hash__ # Using the ABC's default hash + + d1 = MyDistinct(1, "A", "1A") + d2 = MyDistinct(1, "A", "Different Payload") + d3 = MyDistinct(2, "A", "2A") + + key1 = (1, "A") + key3 = (2, "A") + + assert d1.get_key() == key1 + assert d2.get_key() == key1 + assert d3.get_key() == key3 + + # Test hashing + assert hash(d1) == hash(key1) + assert hash(d2) == hash(key1) + assert hash(d3) == hash(key3) + assert hash(d1) == hash(d2) + assert hash(d1) != hash(d3) + + # Test use in a set/dict + s = {d1, d2, d3} + assert len(s) == 2 # d1 and d2 hash to the same value + assert d1 in s + assert d2 in s # d2 replaces d1 or vice-versa based on set implementation details + assert d3 in s + +def test_cached_distinct_abc(): + """Tests the CachedDistinct ABC and its instance caching mechanism. + + Verifies: + - Instances with the same key (extracted from init args) are cached and reused. + - Instances with different keys result in different objects. + - The cache uses weak references (tested implicitly by deleting references). + """ + class MyCachedDistinct(CachedDistinct): + def __init__(self, key_1: int, key_2: Any, payload: str): + self.key_1 = key_1 + self.key_2 = key_2 + self.payload = payload + + @classmethod + def extract_key(cls, *args, **kwargs) -> Hashable: + # Assumes key parts are the first two positional args + return (args[0], args[1]) + + def get_key(self) -> Hashable: + return (self.key_1, self.key_2) + + # Ensure cache is initially empty or clean for this type + if MyCachedDistinct in MyCachedDistinct._instances_: + MyCachedDistinct._instances_[MyCachedDistinct].clear() # type: ignore + + cd1_a = MyCachedDistinct(1, ANY, "payload A") + cd1_b = MyCachedDistinct(1, ANY, "payload B") # Payload differs, but key is the same + cd2 = MyCachedDistinct(2, ANY, "payload C") + + assert cd1_a is cd1_b # Should return the cached instance based on key (1, ANY) + assert cd1_a is not cd2 # Different key (2, ANY) + assert cd1_a.payload == "payload A" # Payload from the first creation is retained + + # Check cache size + assert len(MyCachedDistinct._instances_) == 2 # One for key (1, ANY), one for (2, ANY) + + # Test weak reference behavior (implicitly) + key1 = cd1_a.get_key() + key2 = cd2.get_key() + assert key1 in MyCachedDistinct._instances_ + assert key2 in MyCachedDistinct._instances_ + + del cd1_a + del cd1_b + # gc.collect() # Might be needed for immediate cleanup in some environments + # Check if weakref cleanup happened (might not be immediate) + # assert key1 not in MyCachedDistinct._instances_ # This assertion can be flaky + + del cd2 + # gc.collect() + # assert key2 not in MyCachedDistinct._instances_ # Flaky + + # Recreate to confirm cache was potentially cleared + cd1_new = MyCachedDistinct(1, ANY, "payload New") + assert cd1_new.payload == "payload New" # Confirms __init__ was likely called again + +def test_enums(): + """Tests the Enum definitions. + + Verifies basic member access and values. + """ + assert ByteOrder.LITTLE.value == 'little' + assert ByteOrder.BIG.value == 'big' + assert ByteOrder.NETWORK.value == 'big' # Alias check + + assert ZMQTransport.INPROC.value == 1 + assert ZMQTransport.TCP.value == 3 + assert ZMQTransport.UNKNOWN.value == 0 + + assert ZMQDomain.LOCAL.value == 1 + assert ZMQDomain.NODE.value == 2 + assert ZMQDomain.NETWORK.value == 3 + assert ZMQDomain.UNKNOWN.value == 0 + +def test_zmqaddress_type(): + """Tests the ZMQAddress enhanced string type. + + Verifies: + - Correct parsing of protocol, address, and domain for various ZMQ transports. + - Handling of bytes input. + - Correct __repr__ output. + - Error handling for invalid address formats. + """ + # IPC + addr_ipc = ZMQAddress("ipc://@my-address") + assert addr_ipc == "ipc://@my-address" + assert addr_ipc.address == "@my-address" + assert addr_ipc.protocol == ZMQTransport.IPC + assert addr_ipc.domain == ZMQDomain.NODE + assert repr(addr_ipc) == "ZMQAddress('ipc://@my-address')" + + # INPROC + addr_inproc = ZMQAddress("inproc://my-address") + assert addr_inproc.address == "my-address" + assert addr_inproc.protocol == ZMQTransport.INPROC + assert addr_inproc.domain == ZMQDomain.LOCAL + + # TCP - Node local + addr_tcp_node = ZMQAddress("tcp://127.0.0.1:*") + assert addr_tcp_node.address == "127.0.0.1:*" + assert addr_tcp_node.protocol == ZMQTransport.TCP + assert addr_tcp_node.domain == ZMQDomain.NODE + addr_tcp_localhost = ZMQAddress("tcp://localhost:5555") + assert addr_tcp_localhost.domain == ZMQDomain.NODE + + + # TCP - Network + addr_tcp_net = ZMQAddress("tcp://192.168.0.1:8001") + assert addr_tcp_net.address == "192.168.0.1:8001" + assert addr_tcp_net.protocol == ZMQTransport.TCP + assert addr_tcp_net.domain == ZMQDomain.NETWORK + + # PGM + addr_pgm = ZMQAddress("pgm://192.168.0.1:8001") + assert addr_pgm.address == "192.168.0.1:8001" + assert addr_pgm.protocol == ZMQTransport.PGM + assert addr_pgm.domain == ZMQDomain.NETWORK + + # EPGM and VMCI (assuming they follow network domain pattern) + addr_epgm = ZMQAddress("epgm://192.168.0.1:8002") + assert addr_epgm.protocol == ZMQTransport.EPGM + assert addr_epgm.domain == ZMQDomain.NETWORK + addr_vmci = ZMQAddress("vmci://100:101") + assert addr_vmci.protocol == ZMQTransport.VMCI + assert addr_vmci.domain == ZMQDomain.NETWORK + + + # Bytes input + addr_bytes = ZMQAddress(b"ipc://@my-bytes-address") + assert addr_bytes.address == "@my-bytes-address" + assert addr_bytes.protocol == ZMQTransport.IPC + assert addr_bytes.domain == ZMQDomain.NODE + addr_bytes = ZMQAddress(b"ipc://@my-bytes-address", encoding='ascii') + assert addr_bytes.address == "@my-bytes-address" + assert addr_bytes.protocol == ZMQTransport.IPC + assert addr_bytes.domain == ZMQDomain.NODE + + # Error Handling + with pytest.raises(ValueError, match="Unknown protocol 'onion'"): + ZMQAddress("onion://@my-address") + with pytest.raises(ValueError, match="Protocol specification required"): + ZMQAddress("192.168.0.1:8001") + with pytest.raises(ValueError, match="Invalid protocol"): + ZMQAddress("unknown://192.168.0.1:8001") + + +def test_mime_type(): + """Tests the MIME enhanced string type. + + Verifies: + - Correct parsing of type, subtype, and parameters. + - Handling of MIME types with and without parameters. + - Correct __repr__ output. + - Error handling for invalid MIME formats. + """ + # With parameters + mime_params = MIME("text/plain;charset=utf-8;format=flowed") + assert mime_params == "text/plain;charset=utf-8;format=flowed" + assert mime_params.mime_type == "text/plain" + assert mime_params.type == "text" + assert mime_params.subtype == "plain" + assert mime_params.params == {"charset": "utf-8", "format": "flowed"} + assert repr(mime_params) == "MIME('text/plain;charset=utf-8;format=flowed')" + + # Without parameters + mime_no_params = MIME("application/json") + assert mime_no_params.mime_type == "application/json" + assert mime_no_params.type == "application" + assert mime_no_params.subtype == "json" + assert mime_no_params.params == {} + assert repr(mime_no_params) == "MIME('application/json')" + + # Error Handling + with pytest.raises(ValueError, match="MIME type specification must be"): + MIME("textplain") + with pytest.raises(ValueError, match="MIME type 'model' not supported"): + MIME("model/vml") + with pytest.raises(ValueError, match="Wrong specification of MIME type parameters"): + MIME("text/plain;charset:utf-8") + with pytest.raises(ValueError, match="Wrong specification of MIME type parameters"): + MIME("text/plain;charset") # Missing '=' + + +def test_pyexpr_type(): + """Tests the PyExpr enhanced string type for Python expressions. + + Verifies: + - Valid expression compilation upon creation. + - Correct __repr__ output. + - Access to the compiled code via `.expr`. + - Creation of a callable function via `get_callable`. + - Error handling (SyntaxError) for invalid expressions. + """ + expr_str = "obj.value * 2 + offset" + expr = PyExpr(expr_str) + + assert expr == expr_str + assert repr(expr) == f"PyExpr('{expr_str}')" + assert hasattr(expr, '_expr_') # Check internal attribute exists + assert isinstance(expr.expr, type(compile("1", "", "eval"))) # Check type of compiled code + + # Test evaluation + obj = ValueHolder() + obj.value = 10 + namespace = {"obj": obj, "offset": 5} + assert eval(expr.expr, namespace) == 25 + + # Test get_callable + callable_func = expr.get_callable(arguments="obj, offset") + assert callable(callable_func) + assert callable_func(obj, 5) == 25 + assert callable_func(obj, offset=10) == 30 # Test kwarg + + # Test SyntaxError + with pytest.raises(SyntaxError): + PyExpr("invalid syntax-") + + +def test_pycode_type(): + """Tests the PyCode enhanced string type for Python code blocks. + + Verifies: + - Valid code compilation upon creation. + - Access to the compiled code via `.code`. + - Execution of the compiled code. + - Error handling (SyntaxError) for invalid code blocks. + """ + code_str = """ +results = [] +for i in range(start, end): + results.append(i * multiplier) +""" + code = PyCode(code_str) + assert code == code_str + assert hasattr(code, '_code_') + assert isinstance(code.code, type(compile("", "", "exec"))) # Check type + + # Test execution + namespace = {"start": 2, "end": 5, "multiplier": 3} + exec(code.code, namespace) + assert "results" in namespace + assert namespace["results"] == [6, 9, 12] + + # Test SyntaxError + with pytest.raises(SyntaxError): + PyCode("for i in range(5)\n print(i)") # Indentation error + + +def test_pycallable_type(): + """Tests the PyCallable enhanced string type for Python callables (functions/classes). + + Verifies: + - Valid callable compilation upon creation. + - Extraction of the callable's name. + - Ability to call the PyCallable instance directly. + - Error handling for invalid input (not function/class, SyntaxError). + """ + func_str = """ +def multiply(a: int, b: int = 2) -> int: + '''Docstring for multiply.''' + return a * b +""" + class_str = """ +class Adder: + '''Docstring for Adder.''' + def __init__(self, initial=0): + self.current = initial + def add(self, value): + self.current += value + return self.current +""" + # Test function callable + py_func = PyCallable(func_str) + assert py_func == func_str + assert py_func.name == "multiply" + assert callable(py_func) + assert py_func(5) == 10 # Uses default b=2 + assert py_func(5, 3) == 15 + assert py_func.__doc__ == "Docstring for multiply." # Check __doc__ passthrough + + # Test class callable + py_class = PyCallable(class_str) + assert py_class == class_str + assert py_class.name == "Adder" + assert callable(py_class) + instance = py_class(10) # Calls __init__ + assert isinstance(instance, py_class._callable_) # Check instance type + assert instance.current == 10 + assert instance.add(5) == 15 + assert py_class.__doc__ == "Docstring for Adder." + + # Error Handling + with pytest.raises(ValueError, match="Python function or class definition not found"): + PyCallable("a = 1 + 2") # Not a def or class + with pytest.raises(SyntaxError): + PyCallable("def invalid-func(a):\n pass") # Invalid function name + + +def test_conjunctive_metaclass(): + """Tests the conjunctive metaclass helper. + + Verifies that when a class inherits from multiple base classes, each with its own + distinct metaclass, the conjunctive metaclass ensures that the behaviors (like __call__) + of *all* parent metaclasses are executed upon instantiation of the final class. + """ + # Clear namespace used by metaclasses A and B + ns.clear() + + # Instantiate class AA (uses metaclass A) + _ = AA() + assert ns == {"A": "A"}, "Metaclass A should have been called" + ns.clear() + + # Instantiate class BB (uses metaclass B) + _ = BB() + assert ns == {"B": "B"}, "Metaclass B should have been called" + ns.clear() + + # Instantiate class CC (uses conjunctive metaclass combining A and B) + _ = CC() + assert ns == {"A": "A", "B": "B"}, "Both Metaclass A and B should have been called" + + +def test_load_function(): + """Tests the load function for importing objects dynamically. + + Verifies: + - Loading an object from a standard library module. + - Loading a nested object (class within a module). + - Loading an object from the current package. + - Error handling for non-existent modules and objects. + """ + # Load a function from stdlib + rgb_to_hsv = load("colorsys:rgb_to_hsv") + assert callable(rgb_to_hsv) + assert rgb_to_hsv(0.2, 0.4, 0.4) == (0.5, 0.5, 0.4) + + # Load a class from stdlib + deque_class = load("collections:deque") + assert isinstance(deque_class, type) + assert deque_class([1, 2]).pop() == 2 + + # Load an object from the current package (firebird.base) + conj_meta = load("firebird.base.types:conjunctive") + assert conj_meta is conjunctive + + # Load a nested object (enum member) + little_endian = load("firebird.base.types:ByteOrder.LITTLE") + assert little_endian is ByteOrder.LITTLE + + # Error Handling: Module not found + with pytest.raises(ModuleNotFoundError): + load("non_existent_module:some_object") + + # Error Handling: Object not found + with pytest.raises(AttributeError): + load("firebird.base.types:NonExistentClass") + + # Error Handling: Nested object not found + with pytest.raises(AttributeError): + load("firebird.base.types:ByteOrder.NONEXISTENT") + + # Error Handling: Malformed spec string + with pytest.raises(ValueError): + load("firebird.base.types") # Missing ':' + with pytest.raises(ValueError): + load(":ByteOrder") # Missing module