Skip to content
This repository has been archived by the owner on Nov 5, 2019. It is now read-only.

[Discussion] post 4.0.0 refactor #597

Closed
theacodes opened this issue Aug 9, 2016 · 31 comments
Closed

[Discussion] post 4.0.0 refactor #597

theacodes opened this issue Aug 9, 2016 · 31 comments

Comments

@theacodes
Copy link
Contributor

With 3.0.0 out and 4.0.0 on the way, we've finally addressed most of the "cruft" in this library without making huge, sweeping, breaking changes. However, some huge obstacles remain - such as the twisted class hierarchy and the strange naming of some of the credentials classes.

I'd like to start a discussion on refactoring this library post-4.0.0. I'm not one to throw out well-tested and proven code, but the code that is here needs to be better organized to suit our downstream clients and our users.

I want to put forth the idea of this package slowly migrating to two new packages: google.auth and google.oauth2. During the initial phases we'll retain code inside of and continue to publish the oauth2client package, but once complete the oauth2client package will be permanently deprecated.

The google.auth package will focus solely on server-to-server authentication. A rough idea of what this package will look like is:

  • google.auth.default() returns the application default credentials.
  • google.auth.service_account.Credentials credentials using a service account key to obtain an access token.
  • google.auth.jwt.Credentials credentials using a service account key to directly assert credentials.
  • google.auth.gae.Credentials credentials using App Engine identity credentials.
  • google.auth.gce.Credentials credentials using the Compute Engine metadata server.
  • google.auth.access_token.Credentials credential using a bare access token.

This package won't contain anything related to storage or end-user based credentials.

The google.oauth2 package will contain the remainder of the current library and focus solely on oauth2 user-specific stuff, better organized:

  • google.oauth2.flow - generic flow that can be easily used with web frameworks.
  • google.oauth2.credentials - user credentials with access and refresh tokens.
  • google.oauth2.storage - a wrapper that allows credentials to be stored.
    etc.

What are you initial thoughts @nathanielmanistaatgoogle @dhermes @waprin @thobrla @anthmgoogle @elibixby @pferate & any others.

@waprin
Copy link
Contributor

waprin commented Aug 9, 2016

First pass sounds reasonable to me. I assume the work you're doing on GAE SDK regarding the google package being properly namespaces is a prereq to this. Is there a need to protect this library from any other google packages that dont do it correctly? I worry that even if you do things "right" and others do it wrong, it's poor consolation if your library won't install correctly.

I also would like people to consider the topic of the Storage classes, and how get does not take a key as a parameter. The "construct with a key then re-use" always seemed awkward to me and forced awkward code. Wondering if it's just me.

@theacodes
Copy link
Contributor Author

Is there a need to protect this library from any other google packages that dont do it correctly?

We've mostly snuffed that out. gcloud-python should also be moving to google.cloud iirc. The google namespace should be 'safe' now. protobuf and google.appengine were the worst offenders, and I believe those have been fixed.

I also would like people to consider the topic of the Storage classes

Absolutely. The first phase would be google.auth which wouldn't concern itself with storage at all. I would personally like to see the credentials themselves be completely obvious of storage. We can have a wrapper that handles storage, for example:

import google.oauth2.storage.redis

credentials = do_something_to_obtain_credentials()
storage = google.oauth2.storage.redis.Storage(...)
stored_credentials = storage.put(credentials)

# credentials = <google.oauth2.credentials.Credentials>
# stored_credentials = <google.oauth2.storage.StoredCredentials>
# `stored_credentials` will update the store on refresh, `credentials` will not.

@nathanielmanistaatgoogle
Copy link
Contributor

On naming: oauth2client has the name oauth2client because it was sufficiently widely known and used that it didn't get renamed to be google-auth-library-<language> as happened to Java, NodeJS, Ruby, and PHP. So... that may be a thing that happens in the name of larger Google developer brand uniformity compliance, if we turn around and decide that we want to be the ones to deprecate oauth2client as a name.

@theacodes
Copy link
Contributor Author

Interesting, yeah, I think it's time for us to align with the rest of the libraries. the repository name could be google-auth-library-python, the pypi package could be google-auth, and the import path would be google.auth.

@nathanielmanistaatgoogle
Copy link
Contributor

On code: no implementation inheritance in the public API. Not just no "twisted class hierarchy"; none whatsoever in the API. And probably none in the implementation; it tends to evaporate when the constraint of a legacy API is removed.

@nathanielmanistaatgoogle
Copy link
Contributor

Outside of those subtopics I think it sounds like "forward", which is the right direction for the library. 👍

@theacodes
Copy link
Contributor Author

no implementation inheritance in the public API.

I'm with you on this, and you'll be the primary reviewer so you can make sure this sits right with you. The only inheritance I envisioned was possibly an ABC for Credentials.

I think it sounds like "forward", which is the right direction for the library.

Woohoo. I think for the actual work of doing we should follow the example set by gcloud-python and write the usage guide first in rst then write the implementation.

We do need to discuss the logistics of creating the new package (should it live in this repo on the master branch side-by-side with oauth2client? Should we shove it in a sub-folder? Should we start another branch?)

@thobrla
Copy link
Contributor

thobrla commented Aug 9, 2016

General direction sounds reasonable to me. Do you still intend to depend on httplib2?

@theacodes
Copy link
Contributor Author

@dhermes is addressing that before 4.0.0

On Tue, Aug 9, 2016, 3:00 PM thobrla [email protected] wrote:

General direction sounds reasonable to me. Do you still intend to depend
on httplib2?


You are receiving this because you authored the thread.
Reply to this email directly, view it on GitHub
#597 (comment),
or mute the thread
https://github.com/notifications/unsubscribe-auth/AAPUcwC9_1eUfpiUFhPa9RHkN47RInGhks5qePiBgaJpZM4Jggc_
.

@pferate
Copy link
Contributor

pferate commented Aug 9, 2016

Initially looks reasonable to me also. +1 to the plan of writing the usage guide first. I'll have more comments as things start shaping up.

@bjmc
Copy link
Contributor

bjmc commented Aug 10, 2016

the repository name could be google-auth-library-python, the pypi package could be google-auth, and the import path would be google.auth.

Does that imply that v. >4.0 will be more specifically a Google-focused library for authenticating against Google resources, and less of a general-purpose OAuth2 client?

@nathanielmanistaatgoogle
Copy link
Contributor

@jonparrott: Agreed - I said no implementation inheritance. Interfaces, defined with the abc module, are the foundation of healthy code. :-)

@pferate
Copy link
Contributor

pferate commented Aug 10, 2016

Does that imply that v. >4.0 will be more specifically a Google-focused library for authenticating against Google resources, and less of a general-purpose OAuth2 client?

@bjmc, I think it may mean the opposite. Like @jonparrott said, "The google.auth package will focus solely on server-to-server authentication.". That means that the remaining, google.oauth2 or whatever name is decided on, will be more of a generic OAuth2 client.

For the transition, maybe we could do something similar to google-api-python-client, in moving the code into the new package format, but having oauth2client keep the same public surface, and import from the new code as needed, until the move is complete.

@theacodes
Copy link
Contributor Author

will be more specifically a Google-focused library for authenticating against Google resources, and less of a general-purpose OAuth2 client?

@bjmc honestly this library has never done a good job of being a general-purpose oauth2 client. It just hasn't. I don't think this transition will hurt that, but this transition alone isn't intended to fix that.

having oauth2client keep the same public surface, and import from the new code as needed, until the move is complete.

That's probably untenable. google-api-python-client was just a rename. This is far more significant.

@bjmc
Copy link
Contributor

bjmc commented Aug 10, 2016

honestly this library has never done a good job of being a general-purpose oauth2 client.

I don't think it's that bad. It has a lot of Google stuff hardcoded into it as defaults, but it's reasonably easy to override, and the step1..., step2... interface is very straightforward and approachable for new users who don't know much about OAuth2/OIDC.

We've been recommending it to our users for authenticating against our Authorization Server, and I'm trying to get a sense of how much commitment there is to maintaining it as a general-purpose client, or whether we should be exploring alternatives for the future.

@theacodes
Copy link
Contributor Author

I don't think it's that bad.

I don't either, but it would be good for us to stop advertising as such. :D

We've been recommending it to our users for authenticating against our Authorization Server, and I'm trying to get a sense of how much commitment there is to maintaining it as a general-purpose client, or whether we should be exploring alternatives for the future.

There's a lot of great general-purpose oauth2 clients out there, and some of them work with Google's servers as well. In general, oauth2 is a mess.

@elibixby
Copy link
Contributor

@jonparrott I like splitting into google.auth and google.oauth2 thoughts on splitting the cloud stuff out even more clearly. Like google.cloud.auth or google.auth.cloud, and putting GAE, GCE stuff in that package. I think it'd be nice to explicitly call out in the packaging which parts are not relevant for non-cloud APIs.

@theacodes
Copy link
Contributor Author

@elibixby I don't think we need to go that far. ADC doesn't make a distinction, and I think just drawing the line at server-to-server vs user-to-server is enough.

@theacodes
Copy link
Contributor Author

@dhermes any thoughts?

@dhermes
Copy link
Contributor

dhermes commented Aug 11, 2016

None in particular

@theacodes
Copy link
Contributor Author

Hmm.. that's someone concerning. Considering gcloud-python is a huge user of this library - how would this change impact you? Would it have a positive effect? Negative?

@dhermes
Copy link
Contributor

dhermes commented Aug 11, 2016

We're mostly worried about server-to-server and using ADC without users having to worry about anything but an environment variable (GOOGLE_APPLICATION_CREDENTIALS). So a re-structuring won't be such a big deal. (#128 is a much bigger deal)

@theacodes
Copy link
Contributor Author

I figured as much, so it is logical for us to separate the server-to-server stuff in google.auth.

@theacodes
Copy link
Contributor Author

theacodes commented Sep 26, 2016

For interested parties: I put together a proof-of-concept for this refactor over at https://github.com/jonparrott/goth. Check it out if you're curious.

Some highlights:

  • jwt, service account, and gce credentials implemented.
  • 100% coverage.
  • Passes pylint.
  • Flat class hierarchy using interfaces. (old, new).
  • Tests written in pytest style.
  • Almost all public API surfaces of implemented modules revised.
  • Documentation.

I believe this proves this is worthwhile and will land us in a much better state long-term. The interfaces are working quite well in practice, but they aren't entirely "pure" (they do provide some attributes and methods to subclasses, but I could be easily convinced to make them pure - I just don't want to repeat code). Would love your thoughts on that @nathanielmanistaatgoogle.

If course, I still want to get #128 done and 3.0.0 released before I start trying to merge this in; I just had some free time to play around with this idea. I think the rough idea is that I will merge in one module at a time in a separate, orphan branch once 3.0.0 is out.

@nathanielmanistaatgoogle
Copy link
Contributor

Thank you for the hierarchy graphics; they definitely make clear how dramatic is the clean-up.

I'm still negative on the mixture of concrete and abstract elements in the the Credentials superclass (the only one at which I looked). Notice the way you say "they do provide some attributes and methods to subclasses" - that may be your intent, but they're actually provided to everyone, not just subclasses, and isn't that part of the story of how oauth2client got in trouble?

It looks like the only abstract elements of Credentials are token, expiry, and refresh. Could these be expressed as a smaller "MinimalCredentials" or "CredentialsKernel"? If so, what would it look like? A tiny interface? A single function with a tuple return type?

Part of my enthusiasm for separating the concrete elements from the abstract elements is that it would more clearly structure the API boundary between that which our library provides and that which developer-users provide to our library - do you see that? Do you see that as valuable?

@theacodes
Copy link
Contributor Author

theacodes commented Oct 4, 2016

that may be your intent, but they're actually provided to everyone, not just subclasses, and isn't that part of the story of how oauth2client got in trouble?

Yes, that's fine IMO because the hierarchy is known before-hand (unlike oauth2client) and we're using mixins (ScopedCredentials, SigningCredentials) to indicate optional parts. oauth2client got in trouble because almost everything was shoved into the the base OAuth2Credentials class and everything tried to be a subclass of that.

It looks like the only abstract elements of Credentials are token, expiry, and refresh. Could these be expressed as a smaller "MinimalCredentials" or "CredentialsKernel"? If so, what would it look like? A tiny interface? A single function with a tuple return type?

I don't think it's useful to decompose this further. I could be convinced otherwise, but I'd have to see an example of what it would look like to be convinced.

Part of my enthusiasm for separating the concrete elements from the abstract elements is that it would more clearly structure the API boundary between that which our library provides and that which developer-users provide to our library - do you see that? Do you see that as valuable?

I'm not sure I completely understand what you mean here.

@nathanielmanistaatgoogle
Copy link
Contributor

There are at least three ways in an API to do what providing a public partially-abstract partially-concrete class does. Of course the first is to specify a partially-abstract partially-concrete class:

@six.add_metaclass(abc.ABCMeta)
class MixedSuperclass(object):

  @abc.abstractmethod
  def implemented_by_application_method(self):
    raise NotImplementedError

  def concrete_method(self):
    <some statements that make use of implemented_by_application_method>

(let’s call this MixedSuperclass). A second is to define an interface and specify a fully-concrete class:

@six.add_metaclass(abc.ABCMeta)
class ImplementedByApplication(object):

  @abc.abstractmethod
  def some_behavior(self):
    raise NotImplementedError()


class ConcreteSuperclass(object):

  def __init__(self, implemented_by_application):
    """Constructor.

    Args:
      implemented_by_application: An ImplementedByApplication.
    """
    self._implemented_by_application = implemented_by_application

  def concrete_method(self):
    <some statements that make use of self._implemented_by_application>

(let’s call this InterfaceAndConcreteSuperclass). A third is to define two interfaces and a function:

@six.add_metaclass(abc.ABCMeta)
class ImplementedByApplication(object):

  @abc.abstractmethod
  def some_behavior(self):
    raise NotImplementedError()


@six.add_metaclass(abc.ABCMeta)
class ImplementedByLibrary(object):

  @abc.abstractmethod
  def some_other_behavior(self):
    raise NotImplementedError()


class _ImplementedByLibrary(ImplementedByLibrary):

  def __init__(self, implemented_by_application):
    self._implemented_by_application = implemented_by_application

  def some_other_behavior(self):
    <some statements that make use of self._implemented_by_application>


def construct_implemented_by_library_from_implemented_by_application(
    implemented_by_application):
  return _ImplementedByLibrary(implemented_by_application)

(let’s call this TwoInterfacesAndConstructionFunction). These are all three behaviorally equivalent so it might be tempting to believe that the choice between them is completely arbitrary (or that MixedSuperclass is best since it affords the code’s utility in one single API-exposed code element rather than two or three). What disrupts the equivalence between them is taking into account the projected scope and lifetime of the code and its API. For private behavior in a private module that is only ever used internally within a system and for which all use sites are under one’s own control and no support is ever committed, MixedSuperclass is fine (and the other two forms are probably overkill). For library-public code elements I strongly advocate for TwoInterfacesAndConstructionFunction.

Concrete classes, be they partially or completely concrete, do at least three things: (1) they define a type, (2) they define a (partial or complete) implementation of that type, and (3) they define at least one means of constructing that implementation. I have nothing against these three things; it is expected that a library will have to do all three (perhaps several times over) on its way to affording value to its developer-users. It is the coupling of all three into a single code element that is often inadequately considered, that is without bearing on the utility afforded by the library, and that leads to maintenance problems, and the flawed assumption that leads to these problems is "our object will only ever make sense in terms of the one application-supplied object of which we are currently aware". Let’s look at how each implementation strategy handles, two years down the road, the revelation that there’s a slightly different input with which we want to allow applications to construct our object. MixedSuperclass keels over:

@six.add_metaclass(abc.ABCMeta)
class MixedSuperclass(object):

  @abc.abstractmethod
  def implemented_by_application_method(self):
    raise NotImplementedError

  # Can't have @abc.abstractmethod on this without breaking existing code
  def implemented_by_application_prime_method(self):
    """Does the a_prime behavior (or not).

    Raises:
      NotImplementedError: if this MixedSuperclass instance is implemented
        in terms of a_method rather than a_prime_method.
    """
    raise NotImplementedError()

  def concrete_method(self):
    try:
      <some statements that make use of implemented_by_application_prime_method>
    except NotImplementedError:
      <some statements that make use of implemented_by_application_method>

. InterfaceAndConcreteSuperclass isn't great either:

@six.add_metaclass(abc.ABCMeta)
class ImplementedByApplication(object):

  @abc.abstractmethod
  def some_behavior(self):
    raise NotImplementedError()


@six.add_metaclass(abc.ABCMeta)
class ImplementedByApplicationPrime(object):

  @abc.abstractmethod
  def some_behavior_prime(self):
    raise NotImplementedError()


class ConcreteSuperclass(object):

  def __init__(self, implemented_by_application, implemented_by_application_prime=None):
    """Constructor.

    Args:
      implemented_by_application: An ImplementedByApplication. Ignored
        if a_prime is not None.
      implemented_by_application_prime: An optional
        ImplementedByApplicationPrime.
    """
    self._implemented_by_application = implemented_by_application
    self._implemented_by_application_prime = implemented_by_application_prime

  def concrete_method(self):
    if self._implemented_by_application_prime is None:
      <some statements that make use of self._implemented_by_application>
    else:
      <some statements that make use of self._implemented_by_application_prime>

. TwoInterfacesAndConstructionFunction, at its API, changes little and with grace:

@six.add_metaclass(abc.ABCMeta)
class ImplementedByApplication(object):

  @abc.abstractmethod
  def some_behavior(self):
    raise NotImplementedError()


@six.add_metaclass(abc.ABCMeta)
class ImplementedByApplicationPrime(object):

  @abc.abstractmethod
  def some_behavior_prime(self):
    raise NotImplementedError()


@six.add_metaclass(abc.ABCMeta)
class ImplementedByLibrary(object):

  @abc.abstractmethod
  def some_other_behavior(self):
    raise NotImplementedError()


class _ImplementedByLibrary(ImplementedByLibrary):

  def __init__(self, implemented_by_application):
    self._implemented_by_application = implemented_by_application

  def some_other_behavior(self):
    <some statements that make use of self._implemented_by_application>


class _ImplementedByLibraryPrime(ImplementedByLibrary):

  def __init__(self, implemented_by_application_prime):
    self._implemented_by_application_prime = implemented_by_application_prime

  def some_other_behavior(self):
    <some statements that make use of self._implemented_by_application_prime>


def construct_implemented_by_library_from_implemented_by_application(
    implemented_by_application):
  return _ImplementedByLibrary(implemented_by_application)


def construct_implemented_by_library_from_implemented_by_application_prime(
    implemented_by_application_prime):
  return _ImplementedByLibraryPrime(implemented_by_application_prime)

. From a developer-user perspective, it’s a problem that MixedSuperclass and InterfaceAndConcreteSuperclass made a commitment to always be concrete and always support the non-prime-centered construction semantics with which they débuted. TwoInterfacesAndConstructionFunction never made such a commitment. From a developer-maintainer perspective, it’s a problem that MixedSuperclass and InterfaceAndConcreteSuperclass are now squeezing into one class two different ways of implementing one type. TwoInterfacesAndConstructionFunction never committed to putting any executable statements inside one particular class or any other code element or scope.

Moving from the theoretical, contrived, and short to the real and sizeable (since you asked for an example): take a look at ServiceAccountCredentials. Because it’s trying to be a concrete class with multiple backing implementations, it’s a mess! Note the _private_key_pkcs12 attribute - will it ever exist on the instance? Well, maybe, if the instance was constructed a certain way. If the instance was constructed a different way, the attribute is dead code. Would the class be cleaner and shorter if it were broken into two separate implementations, with the common behavior that they share implemented as helper functions located outside the body of either class? Probably! But oauth2client doesn’t have the freedom to do this, because it promised its developer-users that ServiceAccountCredentials would be the one code element that both defines what a ServiceAccountCredentials is and affords access to every possible implementation of that definition.

So that’s consideration that I find most fearsome about exposing concrete classes in public APIs. There are also a few other considerations that to varying degrees push me in the same direction, and I’m not aware of anything other than "what most programmers are used to" and "no one likes writing forwarding methods" pushing back.

I really like that TwoInterfacesAndConstructionFunction retains implementation freedom. In libraries and in open source, “no” is temporary and “yes” is forever (am I remembering correctly that you quoted this aphorism in some oauth2client support traffic?). google-auth-library-python is to be an open source library so the principle applies doubly, right?

I like and find useful the separation that comes from defining an interface for just the part that the developer-user of the library must implement (InterfaceAndConcreteSuperclass and TwoInterfacesAndConstructionFunction both have this property). I think the developer-user experience is better for the way they can see everything that they have to do and nothing else in a single code element rather than having to scan a partially-abstract, partially-concrete large single class for the methods that they must implement before they can construct the class.

Moving away from classes designed for inheritance clarifies layers of abstraction and makes structurally impossible this-thing-isn’t-allowed-to-call-that-thing-but-oops-it-does bugs.

I’ve totally bought into the favor-composition-over-inheritance movement and think everyone else should too.

Mixins are generally intended to help, but the help that they offer can’t be accepted without becoming a new kind of thing. For the classes you’ve drafted as mixins, how certain are you that the only legitimate way for application code to make use of the behavior that you’ve implemented is to implement the type you’ve defined? Why couple the type and the behavior rather than offer them a la carte?

I hope this is persuasive - while I feel very strongly, I recognize that I’m not the one authoring the new code. And I don’t just feel strongly in abstract theoretical principle; I think that the general practice of "unintentionally (and often without awareness) making implementation promises in the API and restricting future choices in the library" is one of the morals of the oauth2client story and I’d like to see google-auth-library-python avoid not just what specifically went wrong but also the patterns and practices that led oauth2client where it went.

@theacodes
Copy link
Contributor Author

theacodes commented Oct 10, 2016

I hope this is persuasive

I found this more overwhelming than persuasive. I know that wasn't your intent, but I presently have no idea what I would change over at googleapis/google-auth-library-python#8 to satisfy your concerns. Do you have any concrete recommendations for those 3 interfaces that you can show with code? Showing what the real interfaces would look like along with one or more concrete credentials could really help clarify what you want here. Feel free to send a rough PR to add the same module but in the way that you want- I can take it from there.

In particular, I found these sections difficult to apply to the situation at hand:

our object will only ever make sense in terms of the one application-supplied object of which we are currently aware

There's no part of the credentials that I expect to be implemented by the consuming application - implementations are all first party but the interfaces are for the benefit of the users.

Moving from the theoretical, contrived, and short to the real and sizeable (since you asked for an example): take a look at ServiceAccountCredentials

No one here is proposing we repeat what happened with ServiceAccountCredentials. I've shared the hierarchy and prototype code for the new implementation and all things considered, it's pretty clean.

I think the developer-user experience is better for the way they can see everything that they have to do and nothing else in a single code element rather than having to scan a partially-abstract, partially-concrete large single class for the methods that they must implement before they can construct the class.

Everything in credentials is abstract and is intended to tell the developer what can be done with a specific credential instance (see below).

Mixins are generally intended to help, but the help that they offer can’t be accepted without becoming a new kind of thing. For the classes you’ve drafted as mixins, how certain are you that the only legitimate way for application code to make use of the behavior that you’ve implemented is to implement the type you’ve defined? Why couple the type and the behavior rather than offer them a la carte?

Your argument against mixins is that they force the implementing class to become an instance of the mixin. That is exactly what I want here. Application default credentials can return one of 5 classes, so it's important to be able to ascertain what can be done without without relying on checking for concrete classes:

credentials = google.auth.default()

if isinstance(credentials, ScopedCredentials) and credentials.requires_scopes:
    credentials = credentials.with_scopes(scopes)

Similarly:

if isinstance(credentials, SingingCredentials):
    signature = credentials.sign_blob(blob)
else:
   raise ValueError('Credentials can not sign blobs')

These are use cases lifted directly from google-cloud-python.

@nathanielmanistaatgoogle
Copy link
Contributor

@jonparrott: Will try to craft a response over in the relevant code review thread.

@theacodes
Copy link
Contributor Author

SG

On Tue, Oct 11, 2016 at 1:00 PM Nathaniel Manista [email protected]
wrote:

@jonparrott https://github.com/jonparrott: Will try to craft a response
over in the relevant code review thread
googleapis/google-auth-library-python#8
.


You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
#597 (comment),
or mute the thread
https://github.com/notifications/unsubscribe-auth/AAPUc6BWKdHx2hc1RphEroReABWszoZAks5qy-r0gaJpZM4Jggc_
.

@theacodes
Copy link
Contributor Author

Thank you for creating this issue, however, this project is deprecatedand we will only be addressing critical security issues. You can read moreabout this deprecation here.

If you need support or help using this library, we recommend that you ask yourquestion on StackOverflow.

If you still think this issue is relevant and should be addressed, pleasecomment and let us know!

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants