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

Observer for http requests #154

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft

Observer for http requests #154

wants to merge 3 commits into from

Conversation

mnezerka
Copy link
Contributor

This change allows caller to observe technical details (e.g. headers, body, etc.) of http requests that are sent in the background of odata calls.

@CLAassistant
Copy link

CLAassistant commented May 14, 2021

CLA assistant check
All committers have signed the CLA.

@codecov-commenter
Copy link

codecov-commenter commented May 14, 2021

Codecov Report

Merging #154 (cc2386b) into master (7846065) will decrease coverage by 0.02%.
The diff coverage is 89.47%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #154      +/-   ##
==========================================
- Coverage   92.57%   92.54%   -0.03%     
==========================================
  Files           6        8       +2     
  Lines        2666     2684      +18     
==========================================
+ Hits         2468     2484      +16     
- Misses        198      200       +2     
Impacted Files Coverage Δ
pyodata/v2/model.py 93.24% <0.00%> (-0.12%) ⬇️
pyodata/utils/__init__.py 100.00% <100.00%> (ø)
pyodata/utils/request_observer.py 100.00% <100.00%> (ø)
pyodata/v2/service.py 90.50% <100.00%> (+0.03%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 7846065...cc2386b. Read the comment docs.

@phanak-sap
Copy link
Contributor

@mnezerka PLS sign CLA licence for your user.

@@ -20,7 +20,7 @@
# -- Project information -----------------------------------------------------

project = 'PyOData'
copyright = '2019 SAP SE or an SAP affiliate company'
copyright = '2021 SAP SE or an SAP affiliate company'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be nice, separate PR, immediately merged.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah, you update copyright, when you touch the code. This is change is OK.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to separate commit to make it easier to cherry pick this change.

@@ -1048,6 +1050,8 @@ def association_set(self, set_name, namespace=None):
except KeyError:
pass

raise KeyError('Association set does not exist')

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be nice, separate PR, immediately merged, unrelated to the new observer class.

There are cases where you need access to the transport protocol (http). For
example: you need to read value of specific http header. Pyodata provides
simple mechanism to observe all http requests and access low lever properties
directly from underlying engine (**python requests**).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not true. Pyodata does not have underlying engine (python requests) - it is networking library agnostic, dependency to requests is not direct in requirements.txt, by readme https://github.com/SAP/python-pyodata#usage it only expect Requests library Session-like object. Pyodata should work by design with other http networking libraries.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only issue with the paragraph is that it mentions "python requests", otherwise I see no untrue parts :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that was the only line this comment was referring to :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I slightly rephrased this last sentence. Reference to python requests is sill there, but just as an example.

@phanak-sap
Copy link
Contributor

phanak-sap commented Jun 15, 2021

I think that this PR - apart from lines marked for another PR - should be closed and not merged.

Rationale - unnecessary code and workaround without PR exists.

Point 1 - It bring unnecessary code, which may be even tied to requests library only, while pyodata does not strictly use
requests library
only. Imagine aiohttp with internal event loop and the RequestObserverLastCall - may work, may not (not sure) but we would need to start write networking library compatibility tests. Anyway, new code to maintain forever.

Point 2 - for the problems at hand - get to the response HTTP code, headers etc at runtime, when used with request Session instance, existing mechanism of Requests Hooks can be used, see requests documentation

Sample code:

import requests
import pyodata

def print_url(r, *args, **kwargs):
    print(str(r.status_code) + ' - ' + r.url)
    print(r.headers)
    print('-------')

SERVICE_URL = 'http://services.odata.org/V2/Northwind/Northwind.svc/'

s = requests.Session()
s.hooks['response'].append(print_url)

northwind = pyodata.Client(SERVICE_URL, s)

employee1 = northwind.entity_sets.Employees.get_entity(1).execute()
print(employee1.FirstName)
employee2 = northwind.entity_sets.Employees.get_entity(2).execute()
print(employee2.FirstName)

output:

(v) c:\github\pyodata-hook>python hooks.py
301 - http://services.odata.org/V2/Northwind/Northwind.svc/$metadata
{'Content-Type': 'text/html; charset=UTF-8', 'Location': 'https://services.odata.org/V2/Northwind/Northwind.svc/$metadata', 'Date': 'Tue, 15 Jun 2021 09:49:25 GMT', 'Content-Length': '0'}
-------
200 - https://services.odata.org/V2/Northwind/Northwind.svc/$metadata
{'Cache-Control': 'private', 'Content-Length': '39035', 'Content-Type': 'application/xml;charset=utf-8', 'Expires': 'Tue, 15 Jun 2021 09:50:26 GMT', 'Vary': '*', 'Server': 'Microsoft-IIS/10.0', 'DataServiceVersion': '1.0;', 'X-AspNet-Version': '4.0.30319', 'X-Powered-By': 'ASP.NET', 'Date': 'Tue, 15 Jun 2021 09:49:25 GMT'}
-------
301 - http://services.odata.org/V2/Northwind/Northwind.svc/Employees(1)
{'Content-Type': 'text/html; charset=UTF-8', 'Location': 'https://services.odata.org/V2/Northwind/Northwind.svc/Employees(1)', 'Date': 'Tue, 15 Jun 2021 09:49:26 GMT', 'Content-Length': '0'}
-------
200 - https://services.odata.org/V2/Northwind/Northwind.svc/Employees(1)
{'Cache-Control': 'private', 'Content-Length': '18369', 'Content-Type': 'application/json;charset=utf-8', 'Content-Encoding': 'gzip', 'Expires': 'Tue, 15 Jun 2021 09:50:27 GMT', 'Vary': '*', 'Server': 'Microsoft-IIS/10.0', 'DataServiceVersion': '1.0;', 'X-AspNet-Version': '4.0.30319', 'X-Powered-By': 'ASP.NET', 'Date': 'Tue, 15 Jun 2021 09:49:26 GMT'}
-------
Nancy
301 - http://services.odata.org/V2/Northwind/Northwind.svc/Employees(2)
{'Content-Type': 'text/html; charset=UTF-8', 'Location': 'https://services.odata.org/V2/Northwind/Northwind.svc/Employees(2)', 'Date': 'Tue, 15 Jun 2021 09:49:27 GMT', 'Content-Length': '0'}
-------
200 - https://services.odata.org/V2/Northwind/Northwind.svc/Employees(2)
{'Cache-Control': 'private', 'Content-Length': '16074', 'Content-Type': 'application/json;charset=utf-8', 'Content-Encoding': 'gzip', 'Expires': 'Tue, 15 Jun 2021 09:50:27 GMT', 'Vary': '*', 'Server': 'Microsoft-IIS/10.0', 'DataServiceVersion': '1.0;', 'X-AspNet-Version': '4.0.30319', 'X-Powered-By': 'ASP.NET', 'Date': 'Tue, 15 Jun 2021 09:49:26 GMT'}
-------
Andrew

Copy link
Contributor

@phanak-sap phanak-sap left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request change - isolate the 3 lines outside of scope of Observer to new PR.

@mnezerka
Copy link
Contributor Author

@phanak-sap Regarding your sample:

employee1 = northwind.entity_sets.Employees.get_entity(1).execute()
print(employee1.FirstName)
employee2 = northwind.entity_sets.Employees.get_entity(2).execute()
print(employee2.FirstName)

Are you sure you will be able to pair individual requests with calls of print_url hook without putting some glue inside each request? Your example is very simple synchronous scenario. Imagine you are sharing instance of pyodata client (and request session) by more threads or other asynchronous constructions/pools. Your callback will be called, but it could be hard to find originating request for it.

@jfilak
Copy link
Contributor

jfilak commented Jun 15, 2021

@phanak-sap I asked @mnezerka to implement this PR. I need to be able to get Headers for some requests. What's your proposal?

@phanak-sap
Copy link
Contributor

phanak-sap commented Jun 15, 2021

@phanak-sap Regarding your sample:

employee1 = northwind.entity_sets.Employees.get_entity(1).execute()
print(employee1.FirstName)
employee2 = northwind.entity_sets.Employees.get_entity(2).execute()
print(employee2.FirstName)

Are you sure you will be able to pair individual requests with calls of print_url hook without putting some glue inside each request? Your example is very simple synchronous scenario. Imagine you are sharing instance of pyodata client (and request session) by more threads or other asynchronous constructions/pools. Your callback will be called, but it could be hard to find originating request for it.

Each response instance is calling the hooked function on itself in the Requests lib. I don't understand what kind of gluing you are talking about, but the s.hooks['response'].append(print_url) is only registering the hook on every request the sesssion object made, but the hook function have the 'r' argument to referring the response instance, so the particular response data would be accessible. The originating request is the request URL, printed in the example. Or you need for some purpose the original request variable, in the hook itself you have aceess the response.request instance.

Source: https://github.com/psf/requests/blob/c45a4dfe6bfc6017d4ea7e9f051d6cc30972b310/requests/sessions.py#L662

I don't see problem for threads or asynchronous usage.

@phanak-sap
Copy link
Contributor

phanak-sap commented Jun 15, 2021

@phanak-sap I asked @mnezerka to implement this PR. I need to be able to get Headers for some requests. What's your proposal?

Well, Point 2 in my comment is the proposed workaround for this use case. Is there anything that is accessible only by this new change and not by the Requests own hook system? If yes, I withdraw my case. If not, I don't see the necessity for the PR.

@jfilak
Copy link
Contributor

jfilak commented Jun 15, 2021

Well, Point 2 in my comment is the proposed workaround for this use case. Is there anything that is accessible only by this new change and not by the Requests own hook system? If yes, I withdraw my case. If not, I don't see the necessity for the PR.

The point 2 is good, I didn't know about Requests Hooks. However, I though I will change Michal's observer to a decorator, so consumer can do automatic post-processing of retrieved data.

SAP BusinessGateway returns logs in HTTP headers and thought we could imbue the logs to return values of the method execute().

@mnezerka
Copy link
Contributor Author

mnezerka commented Jun 15, 2021

@phanak-sap I asked @mnezerka to implement this PR. I need to be able to get Headers for some requests. What's your proposal?

Well, Point 2 in my comment is the proposed workaround for this use case. Is there anything that is accessible only by this new change and not by the Requests own hook system? If yes, I withdraw my case. If not, I don't see the necessity for the PR.

If you call:

thread1: employee1 = northwind.entity_sets.Employees.get_entity(1).execute()
thread2: employee2 = northwind.entity_sets.Employees.get_entity(2).execute()

so you don't know which call was executed first. Request hooks will call your callback twice. How do you know the originating reuqest? I understand that print_url gets instance of request or response, but this instance is from network layer (requests) and not pyodata. And this was my point - will you be able to match it to pyodata requests? So will you be able to say which one was for employee1 and which for employee2?

@phanak-sap
Copy link
Contributor

phanak-sap commented Jun 15, 2021

@mnezerka OK, I seems to understand now. Since you are always talking about "hard to find originating request for it" or " pair individual requests with calls of print_url hook" and the Description of the PR is "This change allows caller to observe technical details (e.g. headers, body, etc.) of http requests that are sent in the background of odata calls." it gave me a wrong impression.
I thought you were always talking ONLY about the actual networking layer, that you want to be able to somehow to work differently with the Requests library response instance, based on HTTP status or headers.

It is true that in the hook workaround, you are outside of scope of the ODataHttpResponse.execute() and you see only the URL for the response, not the protected values in the pyodata scope above it (what Entitiset etc.). So far it never seemed it was a problem, use case seemed to be logging of responses based on status code/error in a header.

But you can have anything in the hook. For the purpose of control flow, there could be for example an exception, so you know if the problem is in the employee1 or employe2 in the runtime. Example modified script, for use case of some say batch read/creation of some entities, which sometimes fails and we want to log what entity was having problem. Observer is another way, but with the necessity of pyodata code change (which I argue is not needed).

import requests
import pyodata

def print_url(r, *args, **kwargs):
    print(str(r.status_code) + ' - ' + r.url)
    print(r.headers)
    print('-------')

    if r.status_code >= 400:
        raise RuntimeError(r) #most probably some own Exception class

SERVICE_URL = 'http://services.odata.org/V2/Northwind/Northwind.svc/'

s = requests.Session()
s.hooks['response'].append(print_url)

northwind = pyodata.Client(SERVICE_URL, s)

employees = [1,-123, 2]

for id in employees:

    try:
        employee = northwind.entity_sets.Employees.get_entity(id).execute()
        print(employee.FirstName)
    except RuntimeError as e:
        print("id: "+ str(id) + ",error: {0}".format(e))

Output:

301 - http://services.odata.org/V2/Northwind/Northwind.svc/$metadata
{'Content-Type': 'text/html; charset=UTF-8', 'Location': 'https://services.odata.org/V2/Northwind/Northwind.svc/$metadata', 'Date': 'Tue, 15 Jun 2021 14:58:48 GMT', 'Content-Length': '0'}
-------
200 - https://services.odata.org/V2/Northwind/Northwind.svc/$metadata
{'Cache-Control': 'private', 'Content-Length': '39035', 'Content-Type': 'application/xml;charset=utf-8', 'Expires': 'Tue, 15 Jun 2021 14:58:50 GMT', 'Vary': '*', 'Server': 'Microsoft-IIS/10.0', 'DataServiceVersion': '1.0;', 'X-AspNet-Version': '4.0.30319', 'X-Powered-By': 'ASP.NET', 'Date': 'Tue, 15 Jun 2021 14:58:48 GMT'}
-------
301 - http://services.odata.org/V2/Northwind/Northwind.svc/Employees(1)
{'Content-Type': 'text/html; charset=UTF-8', 'Location': 'https://services.odata.org/V2/Northwind/Northwind.svc/Employees(1)', 'Date': 'Tue, 15 Jun 2021 14:58:49 GMT', 'Content-Length': '0'}
-------
200 - https://services.odata.org/V2/Northwind/Northwind.svc/Employees(1)
{'Cache-Control': 'private', 'Content-Length': '18369', 'Content-Type': 'application/json;charset=utf-8', 'Content-Encoding': 'gzip', 'Expires': 'Tue, 15 Jun 2021 14:58:50 GMT', 'Vary': '*', 'Server': 'Microsoft-IIS/10.0', 'DataServiceVersion': '1.0;', 'X-AspNet-Version': '4.0.30319', 'X-Powered-By': 'ASP.NET', 'Date': 'Tue, 15 Jun 2021 14:58:49 GMT'}
-------
Nancy
301 - http://services.odata.org/V2/Northwind/Northwind.svc/Employees(-123)
{'Content-Type': 'text/html; charset=UTF-8', 'Location': 'https://services.odata.org/V2/Northwind/Northwind.svc/Employees(-123)', 'Date': 'Tue, 15 Jun 2021 14:58:49 GMT', 'Content-Length': '0'}
-------
404 - https://services.odata.org/V2/Northwind/Northwind.svc/Employees(-123)
{'Cache-Control': 'private', 'Content-Length': '125', 'Content-Type': 'application/json', 'Expires': 'Tue, 15 Jun 2021 14:59:50 GMT', 'Vary': '*', 'Server': 'Microsoft-IIS/10.0', 'DataServiceVersion': '1.0;', 'X-AspNet-Version': '4.0.30319', 'X-Powered-By': 'ASP.NET', 'Date': 'Tue, 15 Jun 2021 14:58:50 GMT'}
-------
id: -123,error: <Response [404]>

Any other use case which the Observer class must exists? I still think for the practical usage, hooks do just fine. Async/Threading should work IMHO just fine, each response instance and its hook call is isolated to another, if you think otherwise pls show me sample which fails.

@phanak-sap
Copy link
Contributor

phanak-sap commented Jun 15, 2021

SAP BusinessGateway returns logs in HTTP headers and thought we could imbue the logs to return values of the method execute().

@jfilak OK, this one looks like is unsolvable by the hook, since it operates above and below the execute() method. Is this still needed or the proposed workaround with throwing the exception solve the same use case?

In relevance to changing to decorator.. they look a bit nicer, but are a bit harder to debug. If the observer will be introduced, I would prefer current Michal's solution.

@mnezerka
Copy link
Contributor Author

mnezerka commented Jun 15, 2021

@mnezerka OK, I seems to understand now. Since you are always talking about "hard to find originating request for it" or " pair individual requests with calls of print_url hook" and the Description of the PR is "This change allows caller to observe technical details (e.g. headers, body, etc.) of http requests that are sent in the background of odata calls." it gave me a wrong impression.
I thought you were always talking ONLY about the actual networking layer, that you want to be able to somehow to work differently with the Requests library response instance, based on HTTP status or headers.

It is true that in the hook workaround, you are outside of scope of the ODataHttpResponse.execute() and you see only the URL for the response, not the protected values in the pyodata scope above it (what Entitiset etc.). So far it never seemed it was a problem, use case seemed to be logging of responses based on status code/error in a header.

But you can have anything in the hook. For the purpose of control flow, there could be for example an exception, so you know if the problem is in the employee1 or employe2 in the runtime. Example modified script, for use case of some say batch read/creation of some entities, which sometimes fails and we want to log what entity was having problem. Observer is another way, but with the necessity of pyodata code change (which I argue is not needed).

import requests
import pyodata

def print_url(r, *args, **kwargs):
    print(str(r.status_code) + ' - ' + r.url)
    print(r.headers)
    print('-------')

    if r.status_code >= 400:
        raise RuntimeError(r) #most probably some own Exception class

SERVICE_URL = 'http://services.odata.org/V2/Northwind/Northwind.svc/'

s = requests.Session()
s.hooks['response'].append(print_url)

northwind = pyodata.Client(SERVICE_URL, s)

employees = [1,-123, 2]

for id in employees:

    try:
        employee = northwind.entity_sets.Employees.get_entity(id).execute()
        print(employee.FirstName)
    except RuntimeError as e:
        print("id: "+ str(id) + ",error: {0}".format(e))

Output:

301 - http://services.odata.org/V2/Northwind/Northwind.svc/$metadata
{'Content-Type': 'text/html; charset=UTF-8', 'Location': 'https://services.odata.org/V2/Northwind/Northwind.svc/$metadata', 'Date': 'Tue, 15 Jun 2021 14:58:48 GMT', 'Content-Length': '0'}
-------
200 - https://services.odata.org/V2/Northwind/Northwind.svc/$metadata
{'Cache-Control': 'private', 'Content-Length': '39035', 'Content-Type': 'application/xml;charset=utf-8', 'Expires': 'Tue, 15 Jun 2021 14:58:50 GMT', 'Vary': '*', 'Server': 'Microsoft-IIS/10.0', 'DataServiceVersion': '1.0;', 'X-AspNet-Version': '4.0.30319', 'X-Powered-By': 'ASP.NET', 'Date': 'Tue, 15 Jun 2021 14:58:48 GMT'}
-------
301 - http://services.odata.org/V2/Northwind/Northwind.svc/Employees(1)
{'Content-Type': 'text/html; charset=UTF-8', 'Location': 'https://services.odata.org/V2/Northwind/Northwind.svc/Employees(1)', 'Date': 'Tue, 15 Jun 2021 14:58:49 GMT', 'Content-Length': '0'}
-------
200 - https://services.odata.org/V2/Northwind/Northwind.svc/Employees(1)
{'Cache-Control': 'private', 'Content-Length': '18369', 'Content-Type': 'application/json;charset=utf-8', 'Content-Encoding': 'gzip', 'Expires': 'Tue, 15 Jun 2021 14:58:50 GMT', 'Vary': '*', 'Server': 'Microsoft-IIS/10.0', 'DataServiceVersion': '1.0;', 'X-AspNet-Version': '4.0.30319', 'X-Powered-By': 'ASP.NET', 'Date': 'Tue, 15 Jun 2021 14:58:49 GMT'}
-------
Nancy
301 - http://services.odata.org/V2/Northwind/Northwind.svc/Employees(-123)
{'Content-Type': 'text/html; charset=UTF-8', 'Location': 'https://services.odata.org/V2/Northwind/Northwind.svc/Employees(-123)', 'Date': 'Tue, 15 Jun 2021 14:58:49 GMT', 'Content-Length': '0'}
-------
404 - https://services.odata.org/V2/Northwind/Northwind.svc/Employees(-123)
{'Cache-Control': 'private', 'Content-Length': '125', 'Content-Type': 'application/json', 'Expires': 'Tue, 15 Jun 2021 14:59:50 GMT', 'Vary': '*', 'Server': 'Microsoft-IIS/10.0', 'DataServiceVersion': '1.0;', 'X-AspNet-Version': '4.0.30319', 'X-Powered-By': 'ASP.NET', 'Date': 'Tue, 15 Jun 2021 14:58:50 GMT'}
-------
id: -123,error: <Response [404]>

Any other use case which the Observer class must exists? I still think for the practical usage, hooks do just fine. Async/Threading should work IMHO just fine, each response instance and its hook call is isolated to another, if you think otherwise pls show me sample which fails.

Real use case and also motivation for this pull request:
There are odata services that communicate errors not via HTTP status code, but in content of the one specific header. There is no way how to process such errors smoothly. This PR brings one solution to solve this problem, is backward compatible and stays "user friendly". All you have to do is to check this special header in response catched by observer. The important is that your code is sequential, so you can continue or raise error based on the observer.response.headers. I don't see an easy way how to achieve same with hooks - I'm sure, it is possible. I decided to implement it this way. That's all.

@phanak-sap
Copy link
Contributor

phanak-sap commented Jun 17, 2021

After some investigation, it seems to me that the whole "hooks will not work in async way / Imagine you are sharing instance of pyodata client (and request session) by more threads or other asynchronous constructions/pools" is just mental exercise "what if", while nobody so far really tried it.

Because it seems it will not work anyway and with Pyodata at the moment you are stuck in sequential requests whether you like it or not.

  1. Requests library is blocking in its core. See documentation
  2. Alternatives are not working:
    • grequests lib is python 2 only. But you can try gevent and its monkey_patching and wrap the pyodata calls, it is working here - but if the requests are blocking the IO, the better performance in my link is gained from asynchronous network calls switching with mongoDB + direct filesystem usage. AFAIK, gevent will not bring anything better to just pyodata + requests stack.
    • requsts-futures will not work with pyodata, fails in client.py._fetch_metadata because method do not expect Futures instance.
    • requests-thread is just experiment, not real working library
    • only httpx looks promising, but I did not tried how well is compatible with requests and pyodata. It will be nice integration test if it works, for sure.
  3. Requests session is one per thread anyway, recommended by the lib authors.
  4. aiohttp is the most used non-blocking http lib in python at the moment, but the compatibility with Pyodata is unknown to me and in first glance I guess it is no, It would be nice integration test - and if the pyodata would not only work with requests, but with aiohttp (and if aiohttp would not have similar hook mechanism, did not check), generic Observer pattern mechanism would definitely made sense in such case - with the integration tests of usage with both libraries.

It would be nice to see the actual working async/multithreading example of pyodata usage - to really prove the hook workaround is somehow not feasible.

This change allows caller to observe technical details (e.g. headers,
body, etc.) of http requests that are sent in the background of odata
calls.

ver 2: Incorporated review comments:
       * changed documentation - section where python requests are
         mentioned
       * removed copyright year increment - will be added as separate
         commit
@phanak-sap
Copy link
Contributor

phanak-sap commented Jan 14, 2022

@jfilak you stated that you would like to re-write it differently to decorator (in this comment ](#154 (comment))).

Should it be merged as as? Change to draft and commit changes? Dropped (the need for the observer seems to be now lower in priority)?

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

Successfully merging this pull request may close these issues.

None yet

5 participants