Repository: fgmacedo/python-statemachine

Issue:
Supporting abstract classes
### Discussed in https://github.com/fgmacedo/python-statemachine/discussions/344

<div type='discussions-op-text'>

<sup>Originally posted by **galhyt** January 30, 2023</sup>
I have a class that inherites statemachine and has no states and no transitions, all are defined in the children classes. The problem is that from version 1.0 it's mandatory to have transitions and states. Can you reverse it?</div>

Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=fgmacedo_python-statemachine&pullRequest=353)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=CODE_SMELL) [1 Code Smell](https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&pullRequest=353&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=353) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=353&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=fgmacedo_python-statemachine&pullRequest=353&metric=new_duplicated_lines_density&view=list)


codecov[bot]: # [Codecov](https://codecov.io/gh/fgmacedo/python-statemachine/pull/353?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) Report
Base: **100.00**% // Head: **100.00**% // No change to project coverage :thumbsup:
> Coverage data is based on head [(`f304aa2`)](https://codecov.io/gh/fgmacedo/python-statemachine/pull/353?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) compared to base [(`0a54b24`)](https://codecov.io/gh/fgmacedo/python-statemachine/commit/0a54b24fd195522c98db42d43ea6a75138750a2f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo).
> Patch coverage: 100.00% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##           develop      #353   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           18        18           
  Lines          862       866    +4     
  Branches       140       141    +1     
=========================================
+ Hits           862       866    +4     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `100.00% <100.00%> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/fgmacedo/python-statemachine/pull/353?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) | Coverage Δ | |
|---|---|---|
| [statemachine/factory.py](https://codecov.io/gh/fgmacedo/python-statemachine/pull/353?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo#diff-c3RhdGVtYWNoaW5lL2ZhY3RvcnkucHk=) | `100.00% <100.00%> (ø)` | |
| [statemachine/statemachine.py](https://codecov.io/gh/fgmacedo/python-statemachine/pull/353?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo#diff-c3RhdGVtYWNoaW5lL3N0YXRlbWFjaGluZS5weQ==) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/fgmacedo/python-statemachine/pull/353?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo).


Patch:
diff --git a/docs/releases/2.0.0.md b/docs/releases/2.0.0.md
index e5cd38b..7d3367e 100644
--- a/docs/releases/2.0.0.md
+++ b/docs/releases/2.0.0.md
@@ -47,6 +47,7 @@ See {ref}`internal transition` for more details.
 - [#342](https://github.com/fgmacedo/python-statemachine/pull/342): Assignment of `Transition`
   guards using decorators is now possible.
 - [#331](https://github.com/fgmacedo/python-statemachine/pull/331): Added a way to generate diagrams using [QuickChart.io](https://quickchart.io) instead of GraphViz. See {ref}`diagrams` for more details.
+- [#353](https://github.com/fgmacedo/python-statemachine/pull/353): Support for abstract state machine classes, so you can subclass `StateMachine` to add behavior on your own base class. Abstract `StateMachine` cannot be instantiated.
 
 ## Bugfixes in 2.0
 
diff --git a/statemachine/factory.py b/statemachine/factory.py
index 186f28c..720196b 100644
--- a/statemachine/factory.py
+++ b/statemachine/factory.py
@@ -8,7 +8,6 @@ from .graph import visit_connected_states
 from .state import State
 from .transition import Transition
 from .transition_list import TransitionList
-from .utils import qualname
 from .utils import ugettext as _
 
 
@@ -57,18 +56,19 @@ class StateMachineMetaclass(type):
             )
 
     def _check(cls):
+        has_states = bool(cls.states)
+        has_events = bool(cls._events)
 
-        # do not validate the base class
-        name = qualname(cls)
-        if name == "statemachine.statemachine.StateMachine":
-            return
+        cls._abstract = not has_states and not has_events
 
-        cls._abstract = False
+        # do not validate the base abstract classes
+        if cls._abstract:
+            return
 
-        if not cls.states:
+        if not has_states:
             raise InvalidDefinition(_("There are no states."))
 
-        if not cls._events:
+        if not has_events:
             raise InvalidDefinition(_("There are no events."))
 
         cls._check_disconnected_state()
diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py
index f9e3831..fa9f983 100644
--- a/statemachine/statemachine.py
+++ b/statemachine/statemachine.py
@@ -5,10 +5,12 @@ from .dispatcher import resolver_factory
 from .event import Event
 from .event_data import EventData
 from .exceptions import InvalidStateValue
+from .exceptions import InvalidDefinition
 from .exceptions import TransitionNotAllowed
 from .factory import StateMachineMetaclass
 from .model import Model
 from .transition import Transition
+from .utils import ugettext as _
 
 
 if TYPE_CHECKING:
@@ -28,6 +30,9 @@ class StateMachine(metaclass=StateMachineMetaclass):
         self.state_field = state_field
         self.start_value = start_value
 
+        if self._abstract:
+            raise InvalidDefinition(_("There are no states or transitions."))
+
         initial_transition = Transition(
             None, self._get_initial_state(), event="__initial__"
         )


Test patch:
diff --git a/tests/test_statemachine.py b/tests/test_statemachine.py
index 095b44d..2d6d068 100644
--- a/tests/test_statemachine.py
+++ b/tests/test_statemachine.py
@@ -296,13 +296,21 @@ def test_state_machine_with_a_invalid_start_value(
         machine_cls(model, start_value=start_value)
 
 
-def test_should_not_create_instance_of_machine_without_states():
+def test_should_not_create_instance_of_abstract_machine():
+    class EmptyMachine(StateMachine):
+        "An empty machine"
+        pass
+
+    with pytest.raises(exceptions.InvalidDefinition):
+        EmptyMachine()
 
+
+def test_should_not_create_instance_of_machine_without_states():
+    s1 = State("X")
     with pytest.raises(exceptions.InvalidDefinition):
 
-        class EmptyMachine(StateMachine):
-            "An empty machine"
-            pass
+        class OnlyTransitionMachine(StateMachine):
+            t1 = s1.to.itself()
 
 
 def test_should_not_create_instance_of_machine_without_transitions():