Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature Request] Pseudo-callable (i.e., non-function object implementing the __call__() protocol) support #211

Closed
patrick-kidger opened this issue Mar 6, 2023 · 10 comments

Comments

@patrick-kidger
Copy link
Contributor

patrick-kidger commented Mar 6, 2023

With beartype version 0.12.0:

from beartype import beartype
from functools import update_wrapper

def f(x: int):
    pass

class A:
    def __call__(self, a):
        pass

a = A()
a = update_wrapper(a, f)
beartype(a)
# beartype.roar.BeartypeDecorWrappeeException: <__main__.A object at 0x7d03b3c19d90> not pure-Python callable backed by code object (i.e., either C-based callable or pure-Python callable backed by __call__() dunder method).

:(

@leycec
Copy link
Member

leycec commented Mar 8, 2023

ohnoes. A bug, everybody! An actual bug! It's been months since I had one of these. I almost don't know what to do, anymore. Paaaaani–

Wait. Panic never solved anything except causing me to bald yet further. It's time to roll up the unwashed sleeves and get greasy with the @beartype codebase again. 🐒

@leycec
Copy link
Member

leycec commented Mar 8, 2023

...heh. So, it comes to this. @beartype doesn't appear to explicitly support pseudo-callables (i.e., objects masquerading as functions by defining the __call__() dunder method) yet. That is a fairly serious omission, which is bad.

Let's rectify that, stat! And... there goes the beartype 0.12.1 patch release I was gonna push out tonight. This is why @leycec exhales with exhaustion. See also this surprisingly accurate emoji: 😮‍💨

@leycec leycec changed the title Exception with beartype + ft.wraps + class instances [Feature Request] Pseudo-callable (i.e., non-function object implementing the __call__() protocol) support Mar 8, 2023
leycec added a commit that referenced this issue Mar 8, 2023
This commit is the first in a commit chain adding support for
**pseudo-callables** (i.e., objects masquerading as functions by
defining the `__call__()` dunder method), en-route to resolving feature
request #211 kindly submitted by Google X typing guru @patrick-kidger
(Patrick Kidger). Specifically, this commit internally details a sane
path forward for implementing this in about a microsecond. We're on the
clock here, people! ...okay, we're not. But let's time ourselves anyway.
Gotta break records like a speed runner here. (*Extravagant extravaganza!*)
leycec added a commit that referenced this issue Mar 10, 2023
This commit is the next in a commit chain adding support for
**pseudo-callables** (i.e., objects masquerading as functions by
defining the `__call__()` dunder method), en-route to resolving feature
request #211 kindly submitted by Google X typing guru @patrick-kidger
(Patrick Kidger). Specifically, this commit defines a new private
`beartype._decor.decorcore._beartype_method_bound()` decorator
decorating bound method descriptors with `@beartype`. (*Dalliance, man! Mandala!*)
leycec added a commit that referenced this issue Mar 10, 2023
This commit is the next in a commit chain adding support for
**pseudo-callables** (i.e., objects masquerading as functions by
defining the `__call__()` dunder method), en-route to resolving feature
request #211 kindly submitted by Google X typing guru @patrick-kidger
(Patrick Kidger). Specifically, this commit resolves `pyright`
complaints raised by recent versions of `pyright` on our new
`beartype._decor.decorcore._beartype_method_bound()` decorator
decorating bound method descriptors with `@beartype`. (*Indiscriminate incrimination!*)
leycec added a commit that referenced this issue Mar 11, 2023
This commit is the next in a commit chain adding support for
**pseudo-callables** (i.e., objects masquerading as functions by
defining the `__call__()` dunder method), en-route to resolving feature
request #211 kindly submitted by Google X typing guru @patrick-kidger
(Patrick Kidger). Specifically, this commit finalizes our draft
implementation of this functionality. Superficially, everything behaves
as expected. But let's actually test this, shall we? (*Importune apportion!*)
leycec added a commit that referenced this issue Mar 13, 2023
This commit is the last in a commit chain adding support for
**pseudo-callables** (i.e., objects masquerading as functions by
defining the `__call__()` dunder method), resolving feature request #211
kindly submitted by Google X typing guru @patrick-kidger (Patrick
Kidger). Specifically, this commit exhaustively tests our implementation
of this functionality. This commit's for you, Dr. Kidger! (*Illustrious lustrous illusions!*)
leycec added a commit that referenced this issue Mar 15, 2023
This commit is the *real* last in a commit chain adding support for
**pseudo-callables** (i.e., objects masquerading as functions by
defining the `__call__()` dunder method), resolving feature request #211
kindly submitted by Google X typing guru @patrick-kidger (Patrick
Kidger). Specifically, this commit cleans up this functionality a bit in
preparation for generalizing @beartype to monkey-patch runtime
type-checking into arbitrary objects. Mere anarchy is loosed upon the
typing scene! (*Ostentatious ostriches ostensibly tense in riches!*)
@leycec
Copy link
Member

leycec commented Mar 15, 2023

Resolved by cab50aa. Bwa-ha! Against all odds, we did something. Interestingly, resolving this pushes @beartype considerably closer to a full-blown runtime type-checking monkey-patcher. After all, @beartype is now only one small step for mankind away from being able to:

  • (A) Accept any arbitrary Python object.
  • (B) Monkey-patch (i.e., replace) all methods of that object with comparable methods augmented by @beartype-based runtime type-checking.

Oh – and I think your example's a bit busted, maybe? The low-level functools.update_wrapper() function doesn't work quite the way everyone wants it to. If your code can be refactored to leverage the higher-level @functools.wraps() decorator instead, that would be just noice: e.g.,

from beartype import beartype
from functools import wraps

def f(self, x: int):
    return x

class A:
    @wraps(f)
    def __call__(self, x):
        return f(self, x)

a = A()
beartype(a)

# This prints:
#     0xFEEDFACE
print(a(0xFEEDFACE))

# This raises the expected type-checking exception:
#     beartype.roar.BeartypeCallHintParamViolation: @beartyped __main__.f()
#     parameter x='What horror hath @beartype wraught this time!?' violates
#     type hint <class 'int'>, as str 'What horror hath @beartype wraught
#     this time!?' not instance of int.
print(a('What horror hath @beartype wraught this time!?'))

Thanks so much for flying Air Beartype. We hope you enjoy your flight. To your left, you can see a giant balloon shaped like a bear currently being filled with Sidewinder missiles by an F-35A Lightning II. Meals consisting exclusively of honey and maple syrup will begin shortly. 🎈

@leycec leycec closed this as completed Mar 15, 2023
@patrick-kidger
Copy link
Contributor Author

It's worth bearing (ahem) in mind that your example does something different to my original -- I was wrapping the class, not the __call__ method.

What do you mean? wraps is just a functools.partial + update_wrapper.

@leycec
Copy link
Member

leycec commented Mar 16, 2023

Ahh... I'm afraid your example doesn't work, though. It is sad. I tried it, too; functools.update_wrapper fails to transfer the type hint annotating the f() function onto the A.__call__() method, which then causes beartype(a) to just silently reduce to a noop, because A.__call__() remains unannotated.

You can readily verify this yourself at a REPL: e.g.,

$ ipython3.11
>>> from beartype import beartype
>>> from functools import update_wrapper
>>> def f(x: int): pass
>>> class A:
...     def __call__(self, a): pass
>>> a = A()
>>> a = update_wrapper(a, f)
>>> a.__call__.__annotations__
{}

A.__call__() remains unannotated. So, update_wrapper() did nothing. Or mostly nothing, anyway. Or... am I missing something here? I'm probably missing something. That often happens when you in a live in a cabin down by the river. </sigh>

@patrick-kidger
Copy link
Contributor Author

patrick-kidger commented Mar 16, 2023

It ends up being placed on the class instance as a.__annotations__ instead.

Perhaps beartype could check for __annotations__ dictionaries on both the class instance and the __call__ method?

Alternatively I could probably also just call both update_wrapper(a, f) and update_wrapper(a.__call__, f). Actually, that might take some more thought, as a.__call__ is an ephemeral bound method. And adding annotations to A.__call__ would be wrong as different instances of A may wrap different functions. Probably this could be fixed by wrapping __call__ in a custom descriptor? Just update_wrapper(a, f) would still be the usual approach here though.

@leycec
Copy link
Member

leycec commented Mar 17, 2023

It ends up being placed on the class instance as a.__annotations__ instead.

nods furiously

Perhaps beartype could check for __annotations__ dictionaries on both the class instance and the __call__ method?

That... sounds pretty non-standard, magical, and implicit. I mean, sure. Technically, @beartype can do anything. Nobody in CPython's typing community acknowledges the existence of runtime type-checking. They've left everything up to our perverse discretion. Always a mistake, Guido! Now, we're making up the rules as we go along.

But you should probably just propagate type hints onto the __annotations__ dictionary of the __call__() dunder method like everybody else. You can do that. So, you should just do that. That's the standard, right? Just pass A.__call__ rather than a to the update_wrapper() function. I can confirm that behaves as expected: e.g.,

from beartype import beartype
from functools import update_wrapper

def f(self, x: int):
    return x

class A:
    def __call__(self, x):
        return f(self, x)

a = A()
update_wrapper(A.__call__, f)
beartype(a)

# Works! Totally works! I am shocked face as well.
print(a(0xFEEDFACE))
print(a('What horror hath @beartype wraught this time!?'))

Do not ask what @beartype can do for Google X. Ask what Google X can do for @beartype. 😄

Kidding! Just kidding! Still, the sentiment stands. Typing is an anarchic battle royale and you're personally invited. Or... does even the above not work for your specific use case? Does Google really need @beartype to start making up its own normative pseudo-standards? We could do that, if Google is asking. What Google wants, Google gets. Even cray-cray stuff is on the table. </sigh>

@patrick-kidger
Copy link
Contributor Author

patrick-kidger commented Mar 17, 2023

Being placed on A.__call__ would be wrong. That would result in all instances of A sharing the same annotations.

Intended behaviour here is that each instance of A wrap a different function.

I think doing so is canonical / not sketchy!

(Sadly, I'm only using beartype in hobby projects right now. FWIW the complexity of beartype's codebase probably makes it a tough security review before it could be used anywhere internally.)

@leycec
Copy link
Member

leycec commented Mar 18, 2023

Being placed on A.__call__ would be wrong. That would result in all instances of A sharing the same annotations.

Exactly. That's exactly it. Oh... I see. @patrick-kidger doesn't know yet. Gods. Should I be the one to break it to him? I hate being that guy. 😶‍🌫️

Sadly, you've just hit a deep Python constraint without knowing it. Unlike almost everything else, __call__() only exists on classes. Technically, you can try to forcefully set a __call__ attribute on the dictionary of a non-class object. Pragmatically, nobody does that, because Python silently ignores any attempts to do so.

Thus, A.__call__. a.__call__ basically doesn't exist; it's just a thin bound method descriptor around A.__call__. In particular, a.__call__ has no unique annotations of its own, because:

>>> assert a.__call__.__func__ is A.__call__
True

When you call beartype(a) from your code, you might think you're just monkey-patching the object a without touching its class A. That's what it looks, right? In actuality, it's the opposite; beartype(a) monkey-patches the class A without touching the object a. Why? Because @beartype has no choice.

Again, this is a low-level Python constraint. Although most methods don't work like that, __call__() does. Python probably does this for space and/or time efficiency reasons. I can only shrug politely and refer you to official documentation on the subject.

FWIW the complexity of beartype's codebase probably makes it a tough security review before it could be used anywhere internally.

Hah! The jokes on you, Google. Against all odds, @beartype has already insinuated itself into over 100 upstream open-source packages – some of which I guarantee Google is already using, because they are literally Google projects. You and I are equally shocked.

AudioLM-Pytorch is my personal favourite. It's Google. It's @beartype. As an obsessive-compulsive RyMer (i.e., RateYourMusic user), I proudly look forward to the day when Google AI will synthesize a realtime feed of @beartype-checked afro-samba música popular brasileira synthwave that I can jack directly into my neocortex. 🎧 🎶

@patrick-kidger
Copy link
Contributor Author

Sadly, you've just hit a deep Python constraint without knowing it. Unlike almost everything else, call() only exists on classes. Technically, you can try to forcefully set a call attribute on the dictionary of a non-class object. Pragmatically, nobody does that, because Python silently ignores any attempts to do so.

Thus, A.call. a.call basically doesn't exist; it's just a thin bound method descriptor around A.call. In particular, a.call has no unique annotations of its own, because

This isn't quite right. You are correct that a.__call__ is just an ephemeral descriptor. But that's not the reason for your statement "Python silently ignores any attempts to do so".

The reason is that built-in magic methods are looked up on the type: foo() resolves to type(foo).__call__(foo). This was chosen as a performance optimisation within Python, to avoid looking through potentially large instance dictionaries.

Incidentally, this has an exception. If the class is a types.ModuleType, then __getattr__ and __dir__ are looked up on the instance rather than the class. This is to allow writing def __getattr__(...): ... as top-level functions in Python files (=instances of ModuleType), as for example you do in beartype/__init__.py.

Ephemerality of bound methods is unrelated.


This shouldn't affect beartype's ability to check class instances as callables. For example, here's a wrapper that fixes beartype's behaviour here (just by wrapping the instance into a function, which has the desired behaviour):

import beartype
import functools as ft

# Library code

def fixed_beartype(fn):
  @ft.wraps(fn)
  def wrapped(*args, **kwargs):
    return fn(*args, **kwargs)
  return beartype.beartype(wrapped)

# User code

class Kls:
  def __call__(self, x):
    return x * 2

a = Kls()
b = Kls()

def f(x: int):
  ...

def g(x: str):
  ...

ft.update_wrapper(a, f)
ft.update_wrapper(b, g)
assert a.__annotations__ == {"x": int}
assert b.__annotations__ == {"x": str}
a = fixed_beartype(a)
b = fixed_beartype(b)

a(1)  # okay
b("hi")  # okay

try:
  a("hi")
except beartype.roar.BeartypeCallHintParamViolation:
  pass  # okay
else:
  raise Exception

try:
  b(1)
except beartype.roar.BeartypeCallHintParamViolation:
  pass  # okay
else:
  raise Exception

This works because f.__annotations__ gets transferred on to a.__annotations__ which gets transferred on to fixed_beartype.<locals>.wrapped.__annotations__, and beartype correctly looks up annotations on functions.

My expectation is that beartype(foo)(*args) will check *args against foo.__annotations__, regardless of whether foo is an instance or a function.

AudioLM-Pytorch is my personal favourite. It's Google. It's https://github.com/beartype. As an obsessive-compulsive RyMer (i.e., RateYourMusic user), I proudly look forward to the day when Google AI will synthesize a realtime feed of @beartype-checked afro-samba música popular brasileira synthwave that I can jack directly into my neocortex. 🎧 🎶

I don't think that's a Google project.

Lucidrains does reimplementations of the work of various research groups; Google amongst them.

Beartype isn't currently used internally.

leycec added a commit that referenced this issue Apr 8, 2023
This minor release delivers pulse-quickening support for **pandera
(pandas) type hints,** **PEP 484,** **PEP 585**, **PEP 591**, **PEP
647**, **PEP 3119**, and **pseudo-callables.**

This minor release resolves **12 issues** and merges **2 pull
requests.** But first, a quiet word from our wondrous sponsors. They are
monocled QA wizards who serve justice while crushing bugs for humanity.
High fives, please!

## Beartype Sponsors

* [**ZeroGuard:** The Modern Threat Hunting
  Platform](https://zeroguard.com). *All the signals, All the time.*

Thunderous applause echoes through the cavernous confines of the Bear
Den. 👏 🐻‍❄️ 👏

And now... the moment we've waited for. A heinous display of plaintext
that assaults all five senses simultaneously.

## Compatibility Added

* **Pandera (pandas) type hints** (i.e., ad-hoc PEP-noncompliant type
  hints validating pandas `DataFrame` objects, produced by subscripting
  factories published by the `pandera.typing` subpackage and validated
  *only* by user-defined callables decorated by the ad-hoc
  PEP-noncompliant `@pandera.check_types` runtime type-checking
  decorator), resolving feature request #227 kindly submitted by
  @ulfaslakprecis (Ulf Aslak) the Big Boss Typer. @beartype now:
  * Transparently supports pandera's PEP-noncompliant
    `@pandera.check_types` decorator for deeply runtime type-checking
    arbitrary pandas objects.
  * *Always* performs a rudimentary `O(1)` `isinstance()`-based
    type-check for each Pandera type hint. Doing so substantially
    improves usability in common use cases, including:
    * Callables annotated by one or more pandera type hints that are
      correctly decorated by @beartype but incorrectly *not* decorated
      by the pandera-specific `@pandera.check_types` decorator.
    * (Data)classes annotated by one or more pandera type hints.
    * Pandera type hints passed as the second argument to
      statement-level @beartype type-checkers – including:
      * `beartype.door.is_bearable()`.
      * `beartype.door.die_if_unbearable()`.
  * Implements a non-trivial trie data structure to efficiently
    detect all type hints produced by subscriptable factories in the
    `pandera.typing` submodule. Let us pretend this never happened,
    @ulfaslakprecis.
* **PEP 484- and 585-compliant generator constraints.** This release
  relaxes prior constraints erroneously imposed by @beartype
  prohibiting both asynchronous and synchronous generator callables from
  being annotated as returning unsubscripted standard abstract base
  classes (ABCs) defined by the `collections.abc` module. Now, @beartype
  permits:
  * Asynchronous generator callables to be annotated as returning the
    unsubscripted `collections.abc.AsyncGenerator` type.
  * Synchronous generator callables to be annotated as returning the
    unsubscripted `collections.abc.Generator` type.
* **PEP 591** (i.e., `typing.Final[...]` type hints), partially
  resolving issue #223 kindly submitted by the acronym known only as
  @JWCS (Jude). @beartype now trivially reduces *all*
  `typing.Final[{hint}]` type hints to merely `{hint}` (e.g.,
  `typing.Final[int]` to `int`). In other words, @beartype no longer
  raises exceptions when confronted with final type hints and instead at
  least tries to do the right thing. This still isn't *quite* what
  everyone wants @beartype to do here; ideally, @beartype should also
  raise exceptions on detecting attempts to redefine instance and class
  variables annotated as `Final[...]`. Doing so is *definitely* feasible
  and exactly what @beartype should *eventually* do – but also
  non-trivial, because whatever @beartype *eventually* does needs to
  preserve compatibility with all implementations of the `@dataclass`
  decorator across all versions of Python now and forever. Cue that
  head-throbbing migraine. It's comin'! Oh, I can feel it!
* **PEP 647** (i.e., `typing.TypeGuard[...] type hints`), resolving
  feature request #221 kindly submitted by Google X researcher
  extraordinaire @patrick-kidger. @beartype now trivially reduces *all*
  `typing.TypeGuard[...]` type hints to the builtin `bool` type.

## Compatibility Improved

* **PEP 3119.** @beartype now detects both
  **non-isinstanceable classes** (i.e., classes whose metaclasses define
  PEP 3119-compliant `__instancecheck__()` dunder methods
  unconditionally raising `TypeError` exceptions) and
  **non-issubclassable classes** (i.e., classes whose metaclasses define
  PEP 3119-compliant `__subclasscheck__()` dunder methods
  unconditionally raising `TypeError` exceptions) more narrowly for
  safety, resolving issue #220 kindly submitted by *ex*traordinary
  Google X researcher @patrick-kidger (Patrick Kidger). Notably,
  @beartype now *only* accepts `TypeError` exceptions as connoting
  non-isinstanceability and non-issubclassability. Previously, @beartype
  broadly treated any class raising any exception whatsoever when passed
  as the second parameter to `isinstance()` and `issubclass()` as
  non-isinstanceable and non-issubclassable. Sadly, doing so erroneously
  raises false positives for isinstanceable and issubclassable
  metaclasses that have yet to be fully "initialized" at the early time
  the `@beartype` decorator performs this detection.

## Features Added

* **Pseudo-callable monkey-patching support.** `@beartype` now supports
  **pseudo-callables** (i.e., otherwise uncallable objects masquerading
  as callable by defining the `__call__()` dunder method), resolving
  feature request #211 kindly submitted by Google X typing guru
  @patrick-kidger (Patrick Kidger). When passed a pseudo-callable whose
  `__call__()` method is annotated by one or more type hints,
  `@beartype` runtime type-checks that method in the standard way.

## Documentation Revised

* **Literally everything,** also known as the release that migrated
  `README.rst` -> [Read the Docs
  (RtD)](https://beartype.readthedocs.io), resolving both issue #203
  kindly submitted by @LittleBigGene (AKA the dynamo of the cell) and
  ancient issue #8 kindly submitted by @felix-hilden (AKA the Finnish
  computer vision art genius that really made all of this possible).
  Readable documentation slowly emerges from the primordial soup of
  @beartype's shameless past for which we cannot be blamed. @leycec was
  young and "spirited" back then. Specifically, this release:
  * Coerces our prior monolithic slab of unreadable `README.rst`
    documentation into a website graciously hosted by Read the Docs
    (RtD) subdividing that prior documentation into well-structured
    pages, resolving issue #203 kindly submitted by @LittleBigGene (AKA
    the dynamo of the cell).
  * Documents *most* previously undocumented public APIs in the
    @beartype codebase. Although a handful of public APIs remain
    undocumented (notably, the `beartype.peps` submodule), these
    undocumented APIs are assumed to either be sufficiently unpopular or
    non-useful to warrant investing additional scarce resources here.
  * Updates our installation instructions to note @beartype's recent
    availability as official packages in the official package
    repositories of various Linux distributions. Truly, this can only be
    the final mark of pride. These include:
    * Gentoo Linux's Portage tree.
    * Arch Linux's Arch User Repository (AUR).
  * Improves the Python code sample embedded in the ["Are We on the
    Worst Timeline?" subsection of our **Beartype Errors**
    chapter](https://beartype.readthedocs.io/en/latest/api_roar/#are-we-on-the-worst-timeline).
    Thanks to @JWCS for their related pull request (PR) #210, which
    strongly inspired this bald-faced improvement to the usability of
    our `beartype.typing` API.
  * Circumvents multiple long-standing upstream issues in the PyData
    Sphinx theme regarding empty left sidebars via the requisite
    `_templates/sidebar-nav-bs.html` template hack shamelessly
    copy-pasted into literally *every* project requiring this theme.
    This includes @beartype, because why not spew boilerplate that
    nobody understands everywhere? Sadly, doing so requires pinning to a
    maximum obsolete version of this theme that will surely die soon.
    And this is why I facepalm. These issues include:
    * pydata/pydata-sphinx-theme#90.
    * pydata/pydata-sphinx-theme#221.
    * pydata/pydata-sphinx-theme#1181.
  * Truncates our `README.rst` documentation to a placeholder stub that
    just directs everyone to RtD instead.
  * Improves `linecache` integration commentary. Specifically, a pull
    request by @faangbait (AKA the little-known third member of Daft
    Punk) improves internal commentary in our private
    `beartype._util.func.utilfuncmake.make_func()` factory function
    responsible for dynamically synthesizing new in-memory functions
    on-the-fly. Our suspicious usage of `None` as the second item of
    tuples added as values to the standard `linecache.cache` global
    dictionary has now been documented. Thanks so much for this
    stupendous contribution, @faangbait!

## Tests Improved

* **Mypy integration.** This release improves our `test_pep561_mypy()`
  integration test to intentionally ignore unhelpful non-fatal warnings
  improperly emitted by mypy (which encourage usage of
  `typing_extensions`, oddly enough).
* **Sphinx integration.** This release resolves multiple intersecting
  issues involving integration testing of Sphinx + @beartype, including:
  * `test_beartype_in_sphinx()` h0tfix is h0t. This release generalizes
    our test-specific `test_beartype_in_sphinx()` integration test to
    support arbitrary versions of Sphinx, resolving issue #209 kindly
    submitted by @danigm the sun-loving Málaga resident who frolics in
    the sea that Canadians everywhere are openly jealous of.
    Specifically, this release fundamentally refactors this integration
    test to fork a new Python interpreter as a subprocess of the current
    `pytest` process running the `sphinx-build` command.
  * A Python 3.7-specific failure in our continuous integration (CI)
    workflow caused by Sphinx attempting to call deprecated
    functionality of the third-party `pkg_resources` package. This
    release simply avoids installing Sphinx entirely under Python 3.7;
    although admittedly crude, it's unclear how else @beartype could
    possibly resolve this. Since Python 3.7 has almost hit its official
    End-Of-Life (EOL) and thus increasingly poses a security concern,
    this is hardly the worst resolution ever. Really! Believe what we're
    saying.

Break nothing! It's the @beartype way. This is why @leycec cries like a
mewling cat with no milk. (*Thrilling chills spill towards an untoward ontology!*)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants