From d94d179df7949f17e3c13e8fef271e3b3f70d4fe Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Tue, 10 Mar 2020 01:01:20 +0100 Subject: [PATCH 1/6] Move callbacks from reactor to structs --- kopf/engines/probing.py | 2 +- kopf/on.py | 2 +- kopf/reactor/activities.py | 2 +- kopf/reactor/handlers.py | 2 +- kopf/reactor/handling.py | 2 +- kopf/reactor/invocation.py | 2 +- kopf/reactor/registries.py | 2 +- kopf/reactor/states.py | 2 +- kopf/{reactor => structs}/callbacks.py | 0 tests/persistence/test_outcomes.py | 2 +- 10 files changed, 9 insertions(+), 9 deletions(-) rename kopf/{reactor => structs}/callbacks.py (100%) diff --git a/kopf/engines/probing.py b/kopf/engines/probing.py index 65ebfd2c..bf9afd4f 100644 --- a/kopf/engines/probing.py +++ b/kopf/engines/probing.py @@ -7,11 +7,11 @@ import aiohttp.web from kopf.reactor import activities -from kopf.reactor import callbacks from kopf.reactor import causation from kopf.reactor import handlers from kopf.reactor import lifecycles from kopf.reactor import registries +from kopf.structs import callbacks logger = logging.getLogger(__name__) diff --git a/kopf/on.py b/kopf/on.py index 8449e6bd..61f817cd 100644 --- a/kopf/on.py +++ b/kopf/on.py @@ -15,12 +15,12 @@ def creation_handler(**kwargs): from typing import Optional, Callable -from kopf.reactor import callbacks from kopf.reactor import causation from kopf.reactor import errors as errors_ from kopf.reactor import handlers from kopf.reactor import handling from kopf.reactor import registries +from kopf.structs import callbacks from kopf.structs import dicts from kopf.structs import filters from kopf.structs import resources diff --git a/kopf/reactor/activities.py b/kopf/reactor/activities.py index 3922a591..c817b8b0 100644 --- a/kopf/reactor/activities.py +++ b/kopf/reactor/activities.py @@ -19,13 +19,13 @@ import logging from typing import NoReturn, Mapping -from kopf.reactor import callbacks from kopf.reactor import causation from kopf.reactor import handlers from kopf.reactor import handling from kopf.reactor import lifecycles from kopf.reactor import registries from kopf.reactor import states +from kopf.structs import callbacks from kopf.structs import credentials logger = logging.getLogger(__name__) diff --git a/kopf/reactor/handlers.py b/kopf/reactor/handlers.py index 119db512..888ffefb 100644 --- a/kopf/reactor/handlers.py +++ b/kopf/reactor/handlers.py @@ -2,9 +2,9 @@ import warnings from typing import NewType, Callable, Optional, Any -from kopf.reactor import callbacks from kopf.reactor import causation from kopf.reactor import errors as errors_ +from kopf.structs import callbacks from kopf.structs import dicts from kopf.structs import filters diff --git a/kopf/reactor/handling.py b/kopf/reactor/handling.py index c567650f..dd335fd9 100644 --- a/kopf/reactor/handling.py +++ b/kopf/reactor/handling.py @@ -15,7 +15,6 @@ from kopf.engines import logging as logging_engine from kopf.engines import sleeping -from kopf.reactor import callbacks from kopf.reactor import causation from kopf.reactor import errors from kopf.reactor import handlers as handlers_ @@ -23,6 +22,7 @@ from kopf.reactor import lifecycles from kopf.reactor import registries from kopf.reactor import states +from kopf.structs import callbacks from kopf.structs import dicts from kopf.structs import diffs diff --git a/kopf/reactor/invocation.py b/kopf/reactor/invocation.py index 9bac5bd5..06e24c5d 100644 --- a/kopf/reactor/invocation.py +++ b/kopf/reactor/invocation.py @@ -12,8 +12,8 @@ from typing import Optional, Any, Union, List, Iterable, Iterator, Tuple, Dict, cast, TYPE_CHECKING from kopf import config -from kopf.reactor import callbacks from kopf.reactor import causation +from kopf.structs import callbacks if TYPE_CHECKING: asyncio_Future = asyncio.Future[Any] diff --git a/kopf/reactor/registries.py b/kopf/reactor/registries.py index ca3af0e3..c0c1f159 100644 --- a/kopf/reactor/registries.py +++ b/kopf/reactor/registries.py @@ -19,12 +19,12 @@ from typing import (Any, MutableMapping, Optional, Sequence, Collection, Iterable, Iterator, List, Set, FrozenSet, Mapping, Callable, cast, Generic, TypeVar) -from kopf.reactor import callbacks from kopf.reactor import causation from kopf.reactor import errors as errors_ from kopf.reactor import handlers from kopf.reactor import invocation from kopf.structs import bodies +from kopf.structs import callbacks from kopf.structs import dicts from kopf.structs import filters from kopf.structs import resources as resources_ diff --git a/kopf/reactor/states.py b/kopf/reactor/states.py index e3dbd184..1c329fd1 100644 --- a/kopf/reactor/states.py +++ b/kopf/reactor/states.py @@ -55,9 +55,9 @@ import datetime from typing import Any, Optional, Mapping, Dict, Collection, Iterator, cast, overload -from kopf.reactor import callbacks from kopf.reactor import handlers as handlers_ from kopf.structs import bodies +from kopf.structs import callbacks from kopf.structs import patches diff --git a/kopf/reactor/callbacks.py b/kopf/structs/callbacks.py similarity index 100% rename from kopf/reactor/callbacks.py rename to kopf/structs/callbacks.py diff --git a/tests/persistence/test_outcomes.py b/tests/persistence/test_outcomes.py index 77567312..566e87f8 100644 --- a/tests/persistence/test_outcomes.py +++ b/tests/persistence/test_outcomes.py @@ -1,5 +1,5 @@ -from kopf.reactor.callbacks import HandlerResult from kopf.reactor.states import HandlerOutcome +from kopf.structs.callbacks import HandlerResult def test_creation_for_ignored_handlers(): From 519685ec03abc13f23edbe7212988f469090b945 Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Tue, 10 Mar 2020 01:03:35 +0100 Subject: [PATCH 2/6] Rename callbacks.HandlerResult -> callbacks.Result It is applied not only to handlers now, but also for boolean callbacks, such as when= filters, and labels/annotations per-key filters. So, it is just a result of a callback: `callbacks.Result`. --- kopf/engines/probing.py | 2 +- kopf/reactor/activities.py | 2 +- kopf/reactor/handlers.py | 2 +- kopf/reactor/handling.py | 4 ++-- kopf/reactor/states.py | 2 +- kopf/structs/callbacks.py | 6 +++--- tests/persistence/test_outcomes.py | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/kopf/engines/probing.py b/kopf/engines/probing.py index bf9afd4f..aa60ebc5 100644 --- a/kopf/engines/probing.py +++ b/kopf/engines/probing.py @@ -34,7 +34,7 @@ async def health_reporter( is cancelled or failed). Once it will stop responding for any reason, Kubernetes will assume the pod is not alive anymore, and will restart it. """ - probing_container: MutableMapping[handlers.HandlerId, callbacks.HandlerResult] = {} + probing_container: MutableMapping[handlers.HandlerId, callbacks.Result] = {} probing_timestamp: Optional[datetime.datetime] = None probing_max_age = datetime.timedelta(seconds=10.0) probing_lock = asyncio.Lock() diff --git a/kopf/reactor/activities.py b/kopf/reactor/activities.py index c817b8b0..110a9fe6 100644 --- a/kopf/reactor/activities.py +++ b/kopf/reactor/activities.py @@ -95,7 +95,7 @@ async def run_activity( lifecycle: lifecycles.LifeCycleFn, registry: registries.OperatorRegistry, activity: causation.Activity, -) -> Mapping[handlers.HandlerId, callbacks.HandlerResult]: +) -> Mapping[handlers.HandlerId, callbacks.Result]: logger = logging.getLogger(f'kopf.activities.{activity.value}') # For the activity handlers, we have neither bodies, nor patches, just the state. diff --git a/kopf/reactor/handlers.py b/kopf/reactor/handlers.py index 888ffefb..e9a8f811 100644 --- a/kopf/reactor/handlers.py +++ b/kopf/reactor/handlers.py @@ -20,7 +20,7 @@ @dataclasses.dataclass class BaseHandler: id: HandlerId - fn: Callable[..., Optional[callbacks.HandlerResult]] + fn: Callable[..., Optional[callbacks.Result]] errors: Optional[errors_.ErrorsMode] timeout: Optional[float] retries: Optional[int] diff --git a/kopf/reactor/handling.py b/kopf/reactor/handling.py index dd335fd9..79401ae2 100644 --- a/kopf/reactor/handling.py +++ b/kopf/reactor/handling.py @@ -340,7 +340,7 @@ async def invoke_handler( cause: causation.BaseCause, lifecycle: lifecycles.LifeCycleFn, **kwargs: Any, -) -> Optional[callbacks.HandlerResult]: +) -> Optional[callbacks.Result]: """ Invoke one handler only, according to the calling conventions. @@ -384,4 +384,4 @@ async def invoke_handler( await execute() # Since we know that we invoked the handler, we cast "any" result to a handler result. - return callbacks.HandlerResult(result) + return callbacks.Result(result) diff --git a/kopf/reactor/states.py b/kopf/reactor/states.py index 1c329fd1..438045f5 100644 --- a/kopf/reactor/states.py +++ b/kopf/reactor/states.py @@ -76,7 +76,7 @@ class HandlerOutcome: """ final: bool delay: Optional[float] = None - result: Optional[callbacks.HandlerResult] = None + result: Optional[callbacks.Result] = None exception: Optional[Exception] = None diff --git a/kopf/structs/callbacks.py b/kopf/structs/callbacks.py index 6d78dd58..b7056915 100644 --- a/kopf/structs/callbacks.py +++ b/kopf/structs/callbacks.py @@ -15,7 +15,7 @@ # A specialised type to highlight the purpose or origin of the data of type Any, # to not be mixed with other arbitrary Any values, where it is indeed "any". -HandlerResult = NewType('HandlerResult', object) +Result = NewType('Result', object) class ActivityHandlerFn(Protocol): @@ -24,7 +24,7 @@ def __call__( # lgtm[py/similar-function] *args: Any, logger: Union[logging.Logger, logging.LoggerAdapter], **kwargs: Any, - ) -> Optional[HandlerResult]: ... + ) -> Optional[Result]: ... class ResourceHandlerFn(Protocol): @@ -46,7 +46,7 @@ def __call__( # lgtm[py/similar-function] old: Optional[Union[bodies.BodyEssence, Any]], # "Any" is for field-handlers. new: Optional[Union[bodies.BodyEssence, Any]], # "Any" is for field-handlers. **kwargs: Any, - ) -> Optional[HandlerResult]: ... + ) -> Optional[Result]: ... class WhenHandlerFn(Protocol): diff --git a/tests/persistence/test_outcomes.py b/tests/persistence/test_outcomes.py index 566e87f8..c2b5e5c1 100644 --- a/tests/persistence/test_outcomes.py +++ b/tests/persistence/test_outcomes.py @@ -1,5 +1,5 @@ from kopf.reactor.states import HandlerOutcome -from kopf.structs.callbacks import HandlerResult +from kopf.structs.callbacks import Result def test_creation_for_ignored_handlers(): @@ -11,7 +11,7 @@ def test_creation_for_ignored_handlers(): def test_creation_for_results(): - result = HandlerResult(object()) + result = Result(object()) outcome = HandlerOutcome(final=True, result=result) assert outcome.final assert outcome.delay is None From aa189e8b16bff1f0634a5b2ec517d7c23edf1ae8 Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Tue, 10 Mar 2020 01:05:34 +0100 Subject: [PATCH 3/6] Rename WhenHandlerFn -> WhenFilterFn (it is not a handler-fn) --- kopf/on.py | 16 ++++++++-------- kopf/reactor/handlers.py | 2 +- kopf/reactor/registries.py | 6 +++--- kopf/structs/callbacks.py | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/kopf/on.py b/kopf/on.py index 61f817cd..d9b0b81c 100644 --- a/kopf/on.py +++ b/kopf/on.py @@ -136,7 +136,7 @@ def resume( # lgtm[py/similar-function] deleted: Optional[bool] = None, labels: Optional[filters.MetaFilter] = None, annotations: Optional[filters.MetaFilter] = None, - when: Optional[callbacks.WhenHandlerFn] = None, + when: Optional[callbacks.WhenFilterFn] = None, ) -> ResourceHandlerDecorator: """ ``@kopf.on.resume()`` handler for the object resuming on operator (re)start. """ def decorator(fn: callbacks.ResourceHandlerFn) -> callbacks.ResourceHandlerFn: @@ -168,7 +168,7 @@ def create( # lgtm[py/similar-function] registry: Optional[registries.OperatorRegistry] = None, labels: Optional[filters.MetaFilter] = None, annotations: Optional[filters.MetaFilter] = None, - when: Optional[callbacks.WhenHandlerFn] = None, + when: Optional[callbacks.WhenFilterFn] = None, ) -> ResourceHandlerDecorator: """ ``@kopf.on.create()`` handler for the object creation. """ def decorator(fn: callbacks.ResourceHandlerFn) -> callbacks.ResourceHandlerFn: @@ -200,7 +200,7 @@ def update( # lgtm[py/similar-function] registry: Optional[registries.OperatorRegistry] = None, labels: Optional[filters.MetaFilter] = None, annotations: Optional[filters.MetaFilter] = None, - when: Optional[callbacks.WhenHandlerFn] = None, + when: Optional[callbacks.WhenFilterFn] = None, ) -> ResourceHandlerDecorator: """ ``@kopf.on.update()`` handler for the object update or change. """ def decorator(fn: callbacks.ResourceHandlerFn) -> callbacks.ResourceHandlerFn: @@ -233,7 +233,7 @@ def delete( # lgtm[py/similar-function] optional: Optional[bool] = None, labels: Optional[filters.MetaFilter] = None, annotations: Optional[filters.MetaFilter] = None, - when: Optional[callbacks.WhenHandlerFn] = None, + when: Optional[callbacks.WhenFilterFn] = None, ) -> ResourceHandlerDecorator: """ ``@kopf.on.delete()`` handler for the object deletion. """ def decorator(fn: callbacks.ResourceHandlerFn) -> callbacks.ResourceHandlerFn: @@ -266,7 +266,7 @@ def field( # lgtm[py/similar-function] registry: Optional[registries.OperatorRegistry] = None, labels: Optional[filters.MetaFilter] = None, annotations: Optional[filters.MetaFilter] = None, - when: Optional[callbacks.WhenHandlerFn] = None, + when: Optional[callbacks.WhenFilterFn] = None, ) -> ResourceHandlerDecorator: """ ``@kopf.on.field()`` handler for the individual field changes. """ def decorator(fn: callbacks.ResourceHandlerFn) -> callbacks.ResourceHandlerFn: @@ -294,7 +294,7 @@ def event( # lgtm[py/similar-function] registry: Optional[registries.OperatorRegistry] = None, labels: Optional[filters.MetaFilter] = None, annotations: Optional[filters.MetaFilter] = None, - when: Optional[callbacks.WhenHandlerFn] = None, + when: Optional[callbacks.WhenFilterFn] = None, ) -> ResourceHandlerDecorator: """ ``@kopf.on.event()`` handler for the silent spies on the events. """ def decorator(fn: callbacks.ResourceHandlerFn) -> callbacks.ResourceHandlerFn: @@ -327,7 +327,7 @@ def this( # lgtm[py/similar-function] registry: Optional[registries.ResourceChangingRegistry] = None, labels: Optional[filters.MetaFilter] = None, annotations: Optional[filters.MetaFilter] = None, - when: Optional[callbacks.WhenHandlerFn] = None, + when: Optional[callbacks.WhenFilterFn] = None, ) -> ResourceHandlerDecorator: """ ``@kopf.on.this()`` decorator for the dynamically generated sub-handlers. @@ -388,7 +388,7 @@ def register( # lgtm[py/similar-function] registry: Optional[registries.ResourceChangingRegistry] = None, labels: Optional[filters.MetaFilter] = None, annotations: Optional[filters.MetaFilter] = None, - when: Optional[callbacks.WhenHandlerFn] = None, + when: Optional[callbacks.WhenFilterFn] = None, ) -> callbacks.ResourceHandlerFn: """ Register a function as a sub-handler of the currently executed handler. diff --git a/kopf/reactor/handlers.py b/kopf/reactor/handlers.py index e9a8f811..5fc90c96 100644 --- a/kopf/reactor/handlers.py +++ b/kopf/reactor/handlers.py @@ -59,7 +59,7 @@ class ResourceHandler(BaseHandler): deleted: Optional[bool] # used for mixed-in (initial==True) @on.resume handlers only. labels: Optional[filters.MetaFilter] annotations: Optional[filters.MetaFilter] - when: Optional[callbacks.WhenHandlerFn] + when: Optional[callbacks.WhenFilterFn] requires_finalizer: Optional[bool] @property diff --git a/kopf/reactor/registries.py b/kopf/reactor/registries.py index c0c1f159..52f1d3cf 100644 --- a/kopf/reactor/registries.py +++ b/kopf/reactor/registries.py @@ -125,7 +125,7 @@ def register( requires_finalizer: bool = False, labels: Optional[filters.MetaFilter] = None, annotations: Optional[filters.MetaFilter] = None, - when: Optional[callbacks.WhenHandlerFn] = None, + when: Optional[callbacks.WhenFilterFn] = None, ) -> callbacks.ResourceHandlerFn: warnings.warn("registry.register() is deprecated; " "use @kopf.on... decorators with registry= kwarg.", @@ -272,7 +272,7 @@ def register_resource_watching_handler( id: Optional[str] = None, labels: Optional[filters.MetaFilter] = None, annotations: Optional[filters.MetaFilter] = None, - when: Optional[callbacks.WhenHandlerFn] = None, + when: Optional[callbacks.WhenFilterFn] = None, ) -> callbacks.ResourceHandlerFn: """ Register an additional handler function for low-level events. @@ -306,7 +306,7 @@ def register_resource_changing_handler( requires_finalizer: bool = False, labels: Optional[filters.MetaFilter] = None, annotations: Optional[filters.MetaFilter] = None, - when: Optional[callbacks.WhenHandlerFn] = None, + when: Optional[callbacks.WhenFilterFn] = None, ) -> callbacks.ResourceHandlerFn: """ Register an additional handler function for the specific resource and specific reason. diff --git a/kopf/structs/callbacks.py b/kopf/structs/callbacks.py index b7056915..7a87a346 100644 --- a/kopf/structs/callbacks.py +++ b/kopf/structs/callbacks.py @@ -49,7 +49,7 @@ def __call__( # lgtm[py/similar-function] ) -> Optional[Result]: ... -class WhenHandlerFn(Protocol): +class WhenFilterFn(Protocol): def __call__( # lgtm[py/similar-function] self, *args: Any, From ebd4d30f779ac198b09c5dd87944a429408cc78b Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Tue, 10 Mar 2020 00:23:26 +0100 Subject: [PATCH 4/6] Filter labels/annotations by arbitrary callbacks similar to `when=` --- docs/filters.rst | 12 ++++++ examples/11-filtering-handlers/example.py | 18 +++++++++ .../11-filtering-handlers/test_example_11.py | 2 + kopf/reactor/registries.py | 37 ++++++++++++++----- kopf/structs/callbacks.py | 18 +++++++++ kopf/structs/filters.py | 4 +- 6 files changed, 80 insertions(+), 11 deletions(-) diff --git a/docs/filters.rst b/docs/filters.rst index 8104c936..e0d80871 100644 --- a/docs/filters.rst +++ b/docs/filters.rst @@ -71,3 +71,15 @@ By arbitrary callbacks when=lambda spec, **_: spec.get('my-field') == 'somevalue') def my_handler(spec, **_): pass + +* Check on labels/annotations with an arbitrary callback for individual values + (the value comes as the first positional argument, plus usual :doc:`kwargs`):: + + def check_value(value, spec, **_): + return value == 'some-value' and spec.get('field') is not None + + @kopf.on.create('zalando.org', 'v1', 'kopfexamples', + labels={'some-label': check_value}, + annotations={'some-annotation': check_value}) + def my_handler(spec, **_): + pass diff --git a/examples/11-filtering-handlers/example.py b/examples/11-filtering-handlers/example.py index 99e18360..01d065b3 100644 --- a/examples/11-filtering-handlers/example.py +++ b/examples/11-filtering-handlers/example.py @@ -1,6 +1,14 @@ import kopf +def say_yes(value, spec, **_): + return value == 'somevalue' and spec.get('field') is not None + + +def say_no(value, spec, **_): + return value == 'somevalue' and spec.get('field') == 'not-this-value-for-sure' + + @kopf.on.create('zalando.org', 'v1', 'kopfexamples', labels={'somelabel': 'somevalue'}) def create_with_labels_matching(logger, **kwargs): logger.info("Label is matching.") @@ -16,6 +24,11 @@ def create_with_labels_absent(logger, **kwargs): logger.info("Label is absent.") +@kopf.on.create('zalando.org', 'v1', 'kopfexamples', labels={'somelabel': say_yes}) +def create_with_labels_callback_matching(logger, **kwargs): + logger.info("Label callback matching.") + + @kopf.on.create('zalando.org', 'v1', 'kopfexamples', annotations={'someannotation': 'somevalue'}) def create_with_annotations_matching(logger, **kwargs): logger.info("Annotation is matching.") @@ -31,6 +44,11 @@ def create_with_annotations_absent(logger, **kwargs): logger.info("Annotation is absent.") +@kopf.on.create('zalando.org', 'v1', 'kopfexamples', annotations={'someannotation': say_no}) +def create_with_annotations_callback_matching(logger, **kwargs): + logger.info("Annotation callback mismatch.") + + @kopf.on.create('zalando.org', 'v1', 'kopfexamples', when=lambda body, **_: True) def create_with_filter_satisfied(logger, **kwargs): logger.info("Filter satisfied.") diff --git a/examples/11-filtering-handlers/test_example_11.py b/examples/11-filtering-handlers/test_example_11.py index 28952600..a8eb1521 100644 --- a/examples/11-filtering-handlers/test_example_11.py +++ b/examples/11-filtering-handlers/test_example_11.py @@ -50,8 +50,10 @@ def test_handler_filtering(mocker): assert '[default/kopf-example-1] Label is matching.' in runner.stdout assert '[default/kopf-example-1] Label is present.' in runner.stdout assert '[default/kopf-example-1] Label is absent.' in runner.stdout + assert '[default/kopf-example-1] Label callback matching.' in runner.stdout assert '[default/kopf-example-1] Annotation is matching.' in runner.stdout assert '[default/kopf-example-1] Annotation is present.' in runner.stdout assert '[default/kopf-example-1] Annotation is absent.' in runner.stdout + assert '[default/kopf-example-1] Annotation callback mismatch.' not in runner.stdout assert '[default/kopf-example-1] Filter satisfied.' in runner.stdout assert '[default/kopf-example-1] Filter not satisfied.' not in runner.stdout diff --git a/kopf/reactor/registries.py b/kopf/reactor/registries.py index 52f1d3cf..853ede66 100644 --- a/kopf/reactor/registries.py +++ b/kopf/reactor/registries.py @@ -23,7 +23,6 @@ from kopf.reactor import errors as errors_ from kopf.reactor import handlers from kopf.reactor import invocation -from kopf.structs import bodies from kopf.structs import callbacks from kopf.structs import dicts from kopf.structs import filters @@ -546,11 +545,13 @@ def match( changed_fields: Collection[dicts.FieldPath] = frozenset(), ignore_fields: bool = False, ) -> bool: + # Kwargs are lazily evaluated on the first _actual_ use, and shared for all filters since then. + kwargs: MutableMapping[str, Any] = {} return all([ _matches_field(handler, changed_fields or {}, ignore_fields), - _matches_labels(handler, cause.body), - _matches_annotations(handler, cause.body), - _matches_filter_callback(handler, cause), + _matches_labels(handler, cause, kwargs), + _matches_annotations(handler, cause, kwargs), + _matches_filter_callback(handler, cause, kwargs), ]) @@ -566,26 +567,32 @@ def _matches_field( def _matches_labels( handler: handlers.ResourceHandler, - body: bodies.Body, + cause: causation.ResourceCause, + kwargs: MutableMapping[str, Any], ) -> bool: return (not handler.labels or _matches_metadata(pattern=handler.labels, - content=body.get('metadata', {}).get('labels', {}))) + content=cause.body.get('metadata', {}).get('labels', {}), + kwargs=kwargs, cause=cause)) def _matches_annotations( handler: handlers.ResourceHandler, - body: bodies.Body, + cause: causation.ResourceCause, + kwargs: MutableMapping[str, Any], ) -> bool: return (not handler.annotations or _matches_metadata(pattern=handler.annotations, - content=body.get('metadata', {}).get('annotations', {}))) + content=cause.body.get('metadata', {}).get('annotations', {}), + kwargs=kwargs, cause=cause)) def _matches_metadata( *, pattern: filters.MetaFilter, # from the handler content: Mapping[str, str], # from the body + kwargs: MutableMapping[str, Any], + cause: causation.ResourceCause, ) -> bool: for key, value in pattern.items(): if value is filters.MetaFilterToken.ABSENT and key not in content: @@ -594,6 +601,13 @@ def _matches_metadata( continue elif value is None and key in content: # deprecated; warned in @kopf.on continue + elif callable(value) and key in content: + if not kwargs: + kwargs.update(invocation.build_kwargs(cause=cause)) + if value(content[key], **kwargs): + continue + else: + return False elif key not in content: return False elif value != content[key]: @@ -606,10 +620,13 @@ def _matches_metadata( def _matches_filter_callback( handler: handlers.ResourceHandler, cause: causation.ResourceCause, + kwargs: MutableMapping[str, Any], ) -> bool: - if not handler.when: + if handler.when is None: return True - return handler.when(**invocation.build_kwargs(cause=cause)) + if not kwargs: + kwargs.update(invocation.build_kwargs(cause=cause)) + return handler.when(**kwargs) _default_registry: Optional[OperatorRegistry] = None diff --git a/kopf/structs/callbacks.py b/kopf/structs/callbacks.py index 7a87a346..9f766cf5 100644 --- a/kopf/structs/callbacks.py +++ b/kopf/structs/callbacks.py @@ -69,3 +69,21 @@ def __call__( # lgtm[py/similar-function] new: Optional[Union[bodies.BodyEssence, Any]], # "Any" is for field-handlers. **kwargs: Any, ) -> bool: ... + + +class MetaFilterFn(Protocol): + def __call__( # lgtm[py/similar-function] + self, + value: str, # because it is either labels or annotations, nothing else. + *args: Any, + body: bodies.Body, + meta: bodies.Meta, + spec: bodies.Spec, + status: bodies.Status, + uid: str, + name: str, + namespace: Optional[str], + patch: patches.Patch, + logger: Union[logging.Logger, logging.LoggerAdapter], + **kwargs: Any, + ) -> bool: ... diff --git a/kopf/structs/filters.py b/kopf/structs/filters.py index 747a444c..38282407 100644 --- a/kopf/structs/filters.py +++ b/kopf/structs/filters.py @@ -1,6 +1,8 @@ import enum from typing import Mapping, Union +from kopf.structs import callbacks + class MetaFilterToken(enum.Enum): """ Tokens for filtering by annotations/labels. """ @@ -13,4 +15,4 @@ class MetaFilterToken(enum.Enum): PRESENT = MetaFilterToken.PRESENT # Filters for handler specifications (not the same as the object's values). -MetaFilter = Mapping[str, Union[None, str, MetaFilterToken]] +MetaFilter = Mapping[str, Union[None, str, MetaFilterToken, callbacks.MetaFilterFn]] From 9f8a79aafad7743097bec63fb683e2a7684ad2d7 Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Tue, 10 Mar 2020 21:14:45 +0100 Subject: [PATCH 5/6] Call labels/annotations callbacks with `None` for absent keys Can be implemented for criteria like "not value X", which includes the situation when the value is just absent (it is also "not X"). With the previous logic, the key was expected to exist to match. --- kopf/reactor/registries.py | 4 ++-- kopf/structs/callbacks.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kopf/reactor/registries.py b/kopf/reactor/registries.py index 853ede66..41a6419d 100644 --- a/kopf/reactor/registries.py +++ b/kopf/reactor/registries.py @@ -601,10 +601,10 @@ def _matches_metadata( continue elif value is None and key in content: # deprecated; warned in @kopf.on continue - elif callable(value) and key in content: + elif callable(value): if not kwargs: kwargs.update(invocation.build_kwargs(cause=cause)) - if value(content[key], **kwargs): + if value(content.get(key, None), **kwargs): continue else: return False diff --git a/kopf/structs/callbacks.py b/kopf/structs/callbacks.py index 9f766cf5..e92a5031 100644 --- a/kopf/structs/callbacks.py +++ b/kopf/structs/callbacks.py @@ -74,7 +74,7 @@ def __call__( # lgtm[py/similar-function] class MetaFilterFn(Protocol): def __call__( # lgtm[py/similar-function] self, - value: str, # because it is either labels or annotations, nothing else. + value: Optional[str], # because it is either labels or annotations, nothing else. *args: Any, body: bodies.Body, meta: bodies.Meta, From 9799132232ffda40025067ca295bf00cb0eb44cf Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Tue, 10 Mar 2020 21:15:10 +0100 Subject: [PATCH 6/6] Test the labels/annotations filtering callbacks --- tests/registries/test_handler_matching.py | 64 ++++++++++++++++++++--- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/tests/registries/test_handler_matching.py b/tests/registries/test_handler_matching.py index f1990c83..bf0879bb 100644 --- a/tests/registries/test_handler_matching.py +++ b/tests/registries/test_handler_matching.py @@ -16,11 +16,11 @@ def some_fn(x=None): pass -def _never(**_): +def _never(*_, **__): return False -def _always(**_): +def _always(*_, **__): return True @@ -195,6 +195,32 @@ def test_catchall_handlers_with_undesired_labels_absent( assert handlers +@pytest.mark.parametrize('labels', [ + pytest.param({}, id='without-label'), + pytest.param({'somelabel': 'somevalue'}, id='with-label'), + pytest.param({'somelabel': 'othervalue'}, id='with-other-value'), +]) +def test_catchall_handlers_with_labels_callback_says_true( + registry, handler_factory, resource, labels): + cause = Mock(resource=resource, reason='some-reason', diff=None, body={'metadata': {'labels': labels}}) + handler_factory(reason=None, labels={'somelabel': _always}) + handlers = registry.resource_changing_handlers[cause.resource].get_handlers(cause) + assert handlers + + +@pytest.mark.parametrize('labels', [ + pytest.param({}, id='without-label'), + pytest.param({'somelabel': 'somevalue'}, id='with-label'), + pytest.param({'somelabel': 'othervalue'}, id='with-other-value'), +]) +def test_catchall_handlers_with_labels_callback_says_false( + registry, handler_factory, resource, labels): + cause = Mock(resource=resource, reason='some-reason', diff=None, body={'metadata': {'labels': labels}}) + handler_factory(reason=None, labels={'somelabel': _never}) + handlers = registry.resource_changing_handlers[cause.resource].get_handlers(cause) + assert not handlers + + @pytest.mark.parametrize('labels', [ pytest.param({}, id='without-label'), pytest.param({'somelabel': 'somevalue'}, id='with-label'), @@ -283,6 +309,32 @@ def test_catchall_handlers_with_undesired_annotations_absent( assert handlers +@pytest.mark.parametrize('annotations', [ + pytest.param({}, id='without-annotation'), + pytest.param({'someannotation': 'somevalue'}, id='with-annotation'), + pytest.param({'someannotation': 'othervalue'}, id='with-other-value'), +]) +def test_catchall_handlers_with_annotations_callback_says_true( + registry, handler_factory, resource, annotations): + cause = Mock(resource=resource, reason='some-reason', diff=None, body={'metadata': {'annotations': annotations}}) + handler_factory(reason=None, annotations={'someannotation': _always}) + handlers = registry.resource_changing_handlers[cause.resource].get_handlers(cause) + assert handlers + + +@pytest.mark.parametrize('annotations', [ + pytest.param({}, id='without-annotation'), + pytest.param({'someannotation': 'somevalue'}, id='with-annotation'), + pytest.param({'someannotation': 'othervalue'}, id='with-other-value'), +]) +def test_catchall_handlers_with_annotations_callback_says_false( + registry, handler_factory, resource, annotations): + cause = Mock(resource=resource, reason='some-reason', diff=None, body={'metadata': {'annotations': annotations}}) + handler_factory(reason=None, annotations={'someannotation': _never}) + handlers = registry.resource_changing_handlers[cause.resource].get_handlers(cause) + assert not handlers + + @pytest.mark.parametrize('annotations', [ pytest.param({}, id='without-annotation'), pytest.param({'someannotation': 'somevalue'}, id='with-annotation'), @@ -333,7 +385,7 @@ def test_catchall_handlers_with_labels_and_annotations_not_satisfied( pytest.param(lambda body=None, **_: body['spec']['name'] == 'test', id='with-when'), pytest.param(lambda **_: True, id='with-other-when'), ]) -def test_catchall_handlers_with_when_match( +def test_catchall_handlers_with_when_callback_matching( registry, handler_factory, resource, reason, when): cause = ResourceChangingCause( resource=resource, @@ -354,7 +406,7 @@ def test_catchall_handlers_with_when_match( pytest.param(lambda body=None, **_: body['spec']['name'] != "test", id='with-when'), pytest.param(lambda **_: False, id='with-other-when'), ]) -def test_catchall_handlers_with_when_not_match( +def test_catchall_handlers_with_when_callback_mismatching( registry, handler_factory, resource, when): cause = ResourceChangingCause( resource=resource, @@ -589,7 +641,7 @@ def some_fn(**_): ... @mismatching_reason_and_decorator -def test_irrelevant_handlers_with_when_satisfied( +def test_irrelevant_handlers_with_when_callback_satisfied( cause_any_diff, registry, resource, reason, decorator): @decorator(resource.group, resource.version, resource.plural, registry=registry, @@ -603,7 +655,7 @@ def some_fn(**_): ... @mismatching_reason_and_decorator -def test_irrelevant_handlers_with_when_not_satisfied( +def test_irrelevant_handlers_with_when_callback_not_satisfied( cause_any_diff, registry, resource, reason, decorator): @decorator(resource.group, resource.version, resource.plural, registry=registry,