Skip to content

Conversation

@benthorner
Copy link
Contributor

This introduces a couple of new abstractions - Readings and
Streams - to expose more reader.py code for use by libraries.
The new abstractions separate how to get readings from the
CLI concerns of how many to get and how often.

Example of Stream / Reading API:

stream = SensorStream(
    sensor="PMSx003", 
    port="/dev/ttyUSB0"
)

stream.open()
reading = stream.read()
reading.obs_data  # => ObsData
reading.raw_data  # => RawData
stream.close()

The last three commits are more optional: introducing Streams
isn't necessary for library usage but I think it's clearer / simpler
- the final commit showcases this by DRYing-up the sampling
behaviour across both of the Reader classes.

Reading DRYs-up knowledge of how to construct "RawData". Since Reader
classes don't know how to construct "ObsData", it makes sense that the
knowledge of how to construct "RawData" is also abstracted away.

The new type will also be heavily used in the following commits, as
we introduce a new interface to read from sensors. In future, the new
type could replace "RawData" and "ObsData" as a standard interface.
This is part of adapting the reader module to support its use as a
library. Previously it was only possible to read *all* the data in
one go, which meant that error handling was hard to customise.

SensorReader.read_one uses StopIteration to indicate there are no
more readings. The choice of exception is to be consistent with the
MessageReader, but a custom one could potentially be used.
This will give us more confidence when we refactor these exception
handlers in the next commits.
This clarifies that both exceptions relate to a temporary condition
where it makes sense to wait before trying again.
This makes it easier for a library to implement its own polling
scheme. Streams are lower level than Readers, hence the granular
Stream.open and Stream.close API instead of a context manager.

Implementation notes:

- Reinstating a type hint for __enter__ of "-> Self" will become
possible in Python 3.11.

- SensorReader.sensor property is necessary as it's used outside
the class by the CLI [^1].

[^1]: https://github.com/benthorner/PyPMS/blob/f8d6d59a514aca757c0f8dd7d0f5b4b9b84fa2d5/src/pms/cli.py#L60
This takes advantage of the new Stream classes to move towards a
single Reader class: SensorReader and MessageReader are now largely
the same except for the stream they use. Using a common definition
of Reader has the advantage of keeping their behaviour consistent.

Implementation notes:

- The sample counting code looks different between the two readers
but actually behaves the same way except that MessageReader would
only count once - at the class level.

- The "interval" default of "None" means MessageReader will continue
to behave as it did before, although in future it could of course
make use of this feature if needed.

- The two new "Stream" exceptions represent a layer of abstraction
that makes it possible for Reader.__call__ to make generic decisions
about Streams, rather than just sensors.
Copy link
Owner

@avaldebe avaldebe left a comment

Choose a reason for hiding this comment

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

I do not see where is this PR going.
I'm quite happy with the context manager interface as it is. I would not mind extending it, but I would like to know why the extension is needed.
What does the stream interface achieve that was not possible before?
Maybe this is a stepping stone towards a new use case, but I can not tel from this PR alone.

Comment on lines +37 to +42
class StreamNotReady(Exception):
pass


class TemporaryFailure(Exception):
pass
Copy link
Owner

Choose a reason for hiding this comment

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

maybe these and the "old" exceptions should be collected on a separate module

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, can do. I'll come back to this when we decide what should happen with the PR overall.

@benthorner
Copy link
Contributor Author

Hey @avaldebe. Sure, I can give you the background here. The usecase I have is to be able to plug this library into a system I'm using, as a "data source". Data sources look a bit like the following:

class DataSource:
    def start(self) -> None:
        # prepare to poll for data

    def poll(self) -> Data:
        # return a single data sample

    def stop(self) -> None:
        # close down the source

At the moment there's no good way to plug the library in. "start" and "stop" are called separately so don't match up with the context manager API. And "poll" expects a single sample, not an array.

Here are two approaches I've tried to integrate the library in the system:

# Option 1: New reader for every poll
#
# Resets the sensor every time, which could damage it and
# isn't compatible with features like pre-heat.

class DataSource:
    reader: SensorReader  # "samples" set to 1

    def poll(self) -> Data:
        with reader:
            data = reader()[0]
        return data
# Option 2: Manual context manager
#
# More efficient but still clunky to manually work with 
# array return value and context manager methods.

class DataSource:
    reader: SensorReader  # "samples" set to 1

    def start(self) -> None:
        reader.__enter__()

    def poll(self) -> Data:
        return reader()[0]

    def stop(self) -> None:
        reader.__exit__(
            exception_type=None, 
            exception_value=None, 
            traceback=None,
        )

My first thought was: I will need to write my own version of SensorReader. Not a big deal, but also not ideal. However, as I looked at the reader code I saw some an opportunity, in the form of Stream*.

* "Stream" is an arbitrary name, by the way. I also thought of e.g. "Source".

My aim in this PR is to try and pull out / separate the code to get data (my usecase, the "Stream") from the code that governs how much data to get and how often (CLI usecase, the "Reader"). At the same time, I also wanted to preserve the existing Reader API, hence adding all the tests to avoid any breaking changes.

Does that help?

@avaldebe
Copy link
Owner

I understand the usage now. Thanks for the clarification.
I would like to keep the additional classes to a minimum, as the maintenance burden will be mine after your PRs are merged.

What do you think about expanding the Reader class to something like the following?

class Reader(AbstractContextManager):
    @abstractmethod
    def __call__(self, *, raw: Optional[bool] = None) -> Iterator[Union[RawData, ObsData]]:
        ...

    def start(self) -> None:
        self.__enter__()

    def poll(self,  *, raw: Optional[bool] = None) -> Union[RawData, ObsData]:
        return next(self(raw))

    def stop(self) -> None:
        self.__exit__(
            exception_type=None, 
            exception_value=None, 
            traceback=None,
        )        

@benthorner
Copy link
Contributor Author

That works for me. A few comments / questions:

  1. read_one would be a clearer method name than poll.

  2. How about e.g. calling stop from __exit__ instead?

  3. Would you still be up for the new exception in 7cca8f9?

I would like to keep the additional classes to a minimum

Sure, if that's a problem then something like the above approach is the way to go. I'm curious though: what are the issues you anticipate with more classes? I get that loads of classes can be hard to follow, but we're only talking about 2 / 3 new classes here. I get that adding all this stuff is overkill for the features I'm interested in. My thinking with the PR was: rather than just make the Reader classes more complicated for my own benefit, first try to make them simpler.

Anyway, hopefully that's helpful context. Let me know what you think. If you prefer to keep everything inside the Reader classes, then I can make another PR to do that instead - depending on those comments / questions ☝️.

@avaldebe
Copy link
Owner

avaldebe commented Nov 23, 2022

  1. read_one would be a clearer method name than poll.
  2. How about e.g. calling stop from __exit__ instead?
  3. Would you still be up for the new exception in 7cca8f9?

Yes to all. For consistency with 2, please call __enter__ from start.

I would like to keep the additional classes to a minimum

Sure, if that's a problem then something like the above approach is the way to go. I'm curious though: what are the issues you anticipate with more classes? I get that loads of classes can be hard to follow, but we're only talking about 2 / 3 new classes here. I get that adding all this stuff is overkill for the features I'm interested in. My thinking with the PR was: rather than just make the Reader classes more complicated for my own benefit, first try to make them simpler.

The sensors implementation uses inheritance to avoid repetition between vendor variants, and when something breaks down it takes me some time to find out where the behaviours are defined. Therefore, I would like to keep things simple and inheritance shallow.

Keep in mind that, for my uses, this project is quite close to feature completion. Therefore, I only engage with it when something breaks down, and I do not want to spend time looking for the right class and...

Anyway, hopefully that's helpful context. Let me know what you think. If you prefer to keep everything inside the Reader classes, then I can make another PR to do that instead - depending on those comments / questions point_up.

You can keep this PR (and edit the PR's name and description), or start a new one. It is up to you.

benthorner added a commit to benthorner/PyPMS that referenced this pull request Nov 26, 2022
In response to [^1].

Previously it was hard to operate the reader classes from calling
code that separates resource management ("start" / "stop") and the
actual polling of data ("read_one"). This exposes new APIs to make
it easier for to reader classes in such a granular way.

[^1]: avaldebe#32 (comment)
benthorner added a commit to benthorner/PyPMS that referenced this pull request Nov 26, 2022
In response to [^1].

Previously it was hard to operate the reader classes from calling
code that separates resource management ("open" / "close") and the
actual polling of data ("read_one"). This exposes new APIs to make
it easier for to reader classes in such a granular way.

Note: I've used "open" and "close" instead of the "start" / "stop"
discussed, as those terms are a better match of the behaviour of
the reader e.g. saying "open a message reader to read" makes sense,
whereas "start a message reader ???" doesn't make sense.

[^1]: avaldebe#32 (comment)
benthorner added a commit to benthorner/PyPMS that referenced this pull request Nov 26, 2022
In response to [^1].

Previously it was hard to operate the reader classes from calling
code that separates resource management ("open" / "close") and the
actual polling of data ("read_one"). This exposes new APIs to make
it easier for to reader classes in such a granular way.

Note: I've used "open" and "close" instead of the "start" / "stop"
discussed, as those terms are a better match of the behaviour of
the reader e.g. saying "open a message reader to read" makes sense,
whereas "start a message reader ???" doesn't make sense.

[^1]: avaldebe#32 (comment)
benthorner added a commit to benthorner/PyPMS that referenced this pull request Nov 26, 2022
In response to [^1].

Previously it was hard to operate the reader classes from calling
code that separates resource management ("open" / "close") and the
actual polling of data ("read_one"). This exposes new APIs to make
it easier for to reader classes in such a granular way.

Note: I've used "open" and "close" instead of the "start" / "stop"
discussed, as those terms are a better match of the behaviour of
the reader e.g. saying "open a message reader to read" makes sense,
whereas "start a message reader ???" doesn't make sense.

[^1]: avaldebe#32 (comment)
benthorner added a commit to benthorner/PyPMS that referenced this pull request Nov 26, 2022
In response to [^1].

Previously it was hard to operate the reader classes from calling
code that separates resource management ("open" / "close") and the
actual polling of data ("read_one"). This exposes new APIs to make
it easier to use reader classes in such a granular way.

Note: I've used "open" and "close" instead of the "start" / "stop"
discussed, as those terms are a better match of the behaviour of
the reader e.g. saying "open a message reader to read" makes sense,
whereas "start a message reader ???" doesn't make sense.

Implementation notes:

- Reinstating a type hint for __enter__ of "-> Self" will become
possible in Python 3.11.

- "start" / "stop" were discussed in relation to the Sensor being
operated by a SensorReader, but these concepts don't make much sense
for the MessageReader class, so I've used "open" / "close" instead.

[^1]: avaldebe#32 (comment)
benthorner added a commit to benthorner/PyPMS that referenced this pull request Nov 26, 2022
In response to [^1].

Previously it was hard to operate the reader classes from calling
code that separates resource management ("open" / "close") and the
actual polling of data ("read_one"). This exposes new APIs to make
it easier to use reader classes in such a granular way.

Implementation notes:

- Reinstating a type hint for __enter__ of "-> Self" will become
possible in Python 3.11.

- "start" / "stop" were discussed in relation to the Sensor being
operated by a SensorReader, but these concepts don't make much sense
for the MessageReader class, so I've used "open" / "close" instead.

[^1]: avaldebe#32 (comment)
benthorner added a commit to benthorner/PyPMS that referenced this pull request Nov 26, 2022
In response to [^1].

Previously it was hard to operate the reader classes from calling
code that separates resource management ("open" / "close") and the
actual polling of data ("read_one"). This exposes new APIs to make
it easier to use reader classes in such a granular way.

Implementation notes:

- Reinstating a type hint for __enter__ of "-> Self" will become
possible in Python 3.11.

- "start" / "stop" were discussed in relation to the Sensor being
operated by a SensorReader, but these concepts don't make much sense
for the MessageReader class, so I've used "open" / "close" instead.

[^1]: avaldebe#32 (comment)
benthorner added a commit to benthorner/PyPMS that referenced this pull request Nov 26, 2022
In response to [^1].

Previously it was hard to operate the reader classes from calling
code that separates resource management ("open" / "close") and the
actual polling of data ("read_one"). This exposes new APIs to make
it easier to use reader classes in such a granular way.

Implementation notes:

- Reinstating a type hint for __enter__ of "-> Self" will become
possible in Python 3.11.

- "start" / "stop" were discussed in relation to the Sensor being
operated by a SensorReader, but these concepts don't make much sense
for the MessageReader class, so I've used "open" / "close" instead.

[^1]: avaldebe#32 (comment)
@benthorner
Copy link
Contributor Author

The sensors implementation uses inheritance to avoid repetition between vendor variants, and when something breaks down it takes me some time to find out where the behaviours are defined.

I had a similar experience when I tried to understand how the decoding works.


@avaldebe I've made a new PR that follows the approach we discussed: #33.

However, implementing read_one turned out to be harder than just doing a return next(self(raw)) because __call__ is still a bit too non-customisable / CLI-oriented e.g. with this line.

I went back to some of the ideas in this PR to make progress, but also tried to stay true to the principles and signatures we agreed on here. Please take a look and let me know what you think.

@avaldebe
Copy link
Owner

Thanks for this and the new PR. I'll close this PR, and continue the discussion #33.

Thanks again,
Á.

@avaldebe avaldebe closed this Nov 28, 2022
benthorner added a commit to benthorner/PyPMS that referenced this pull request Dec 2, 2022
In response to [^1].

Previously it was hard to operate the reader classes from calling
code that separates resource management ("open" / "close") and the
actual polling of data ("read_one"). This exposes new APIs to make
it easier to use reader classes in such a granular way.

Implementation notes:

- Reinstating a type hint for __enter__ of "-> Self" will become
possible in Python 3.11.

- "start" / "stop" were discussed in relation to the Sensor being
operated by a SensorReader, but these concepts don't make much sense
for the MessageReader class, so I've used "open" / "close" instead.

[^1]: avaldebe#32 (comment)
avaldebe pushed a commit that referenced this pull request Dec 6, 2022
In response to [^1].

Previously it was hard to operate the reader classes from calling
code that separates resource management ("open" / "close") and the
actual polling of data ("read_one"). This exposes new APIs to make
it easier to use reader classes in such a granular way.

Implementation notes:

- Reinstating a type hint for __enter__ of "-> Self" will become
possible in Python 3.11.

- "start" / "stop" were discussed in relation to the Sensor being
operated by a SensorReader, but these concepts don't make much sense
for the MessageReader class, so I've used "open" / "close" instead.

[^1]: #32 (comment)
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.

2 participants