If the sections have a non-overlapping structure, then strongly consider encoding the state machine via ordinary control flow. Regardless of whether that is possible, you can likely separate low-level scanning of lines from their higher-level grouping.
What I mean by non-overlapping structure
Some data description formats like XML require proper nesting – annotated regions can be consecutive or nested, but they cannot be overlapping.
For example, <a> <b>...</b></a>
is allowed,
as is <a>...</a> <b>...</b>
,
but not <a> <b> ... </a> </b>
.
Such non-overlapping structures are much simpler to handle programmatically, for computer science reasons involving terms such as "Chomsky hierarchy", "stack automata", and "context-free languages".
Problem statement
I'm assuming you have log file lines that look like this:
foo
#begin SECTION
x
y
#end SECTION
bar
It is possible to maintain a stack or a variable that indicates whether we are currently within that section, e.g.:
section = None
for line in input:
# handle state transitions
if section is not None and line == "#end SECTION":
print(f"completed section: {section}")
section = None
continue
if section is None and line == "#begin SECTION":
section = []
continue
# process line according to current state
if section is not None:
section.append(line)
continue
# default action
print(f"ordinary line: {line}")
This would produce output like:
ordinary line: foo
completed section: ['x', 'y']
ordinary line: bar
Possible approach: one function (or class) per state
One potential refactoring is to identify the state you're in, and to then call a function that handles the input in that state. Here, we would select the state on the section is None
condition. However, modifying persistent variables is more tricky with such separate function, so that you'd likely want to extract them into an object. Incidentally, this also makes it possible to simply represent the current state via that object's class. Here:
@dataclass
class OrdinaryState:
def handle_line(self, line: str) -> State:
if line == "#begin SECTION":
return InSectionState(contents=[])
# default action
print(f"ordinary line: {line}")
return self
@dataclass
class InSectionState:
contents: list[str]
def handle_line(self, line: str) -> State:
if line == "#end SECTION":
print(f"completed section: {self.contents}")
return OrdinaryState()
# default action
self.contents.append(line)
return self
State: TypeAlias = OrdinaryState | InSectionState
state = OrdinaryState()
for line in input:
state = state.handle_line(line)
Possible approach: recursive functions
Instead of maintaining an explicit state machine,
you could call one function per state – which works here because we are assuming at most a nested structure. So there might be a function like parse_section(lines)
that will consume the contents of the section, and then return.
This is where iterators become relevant.
As long as we don't have to "look ahead" to the next line, then passing an iterator to these functions is an elegant way to handle progress through the file. Each function can consume zero or more lines from that iterator, and the next function will continue where another left off.
Here, a solution might look as follows:
def parse_logfile(lines: Iterator[str]):
for line in lines:
if line == "#begin SECTION":
parse_section(lines)
continue
print(f"ordinary line: {line}")
def parse_section(lines: Iterator[str]):
contents = []
for line in lines:
if line == "#end SECTION":
break
contents.append(line)
print(f"completed section: {contents}")
parse_logfile(iter(input))
Whenever possible, this kind of design would be my preferred approach. It is highly debuggable, because you're just writing code, not trying to encode a state machine. The code tends to be simpler, shorter, and less susceptible to tricky bugs.
Drawbacks of extracting state machine parts
Both of these suggested refactorings have the issue that information about the state machine is now spread across multiple places. For example, the code that starts a section is part of one state, the code that ends it is in another. There are techniques to avoid this, but they require lookahead – being able to inspect the next line without consuming it.
General idea: separate low-level parsing form high-level structure/grouping
If the syntax of these lines is more involved, especially if complex regexes are needed, it can make sense to extract some of those low-level tasks from the main state machine.
In my above example, I might extract functions is_section_start(line)
and is_section_end(line)
functions – though it's probably not worth it here since I'm just comparing constant strings.
Another alternative is to first parse each line individually, and to then run your state machine. This is similar to a "lexer", "scanner", or "tokenizer".
I don't think it would be worth in this example, but we might define:
@dataclass
class BeginSection: pass
@dataclass
class EndSection: pass
@dataclass
class OrdinaryLine:
contents: str
Line: TypeAlias = BeginSection | EndSection | OrdinaryLine
def tokenize(lines: Iterable[str]) -> Iterable[Line]:
for line in lines:
if line == "#begin SECTION":
yield BeginSection()
continue
if line == "#end SECTION":
yield EndSection()
continue
yield OrdinaryLine(contents=line)
This can be an especially good strategy if the syntax includes irrelevant parts that should be skipped, like comments or empty lines.
In this particular instance the state machine wouldn't be simplified a lot, but this might be an opportunity to use Python 3.10's shiny new pattern matching feature:
section = None
for line in tokenize(input):
match line:
case EndSection():
print(f"completed section: {section}")
section = None
case BeginSection():
section = []
case OrdinaryLine(contents=contents) if section is not None:
section.append(contents)
case OrdinaryLine(contents=contents):
print(f"ordinary line: {contents}")
Whether this is better is mainly a matter of taste, but if we introduce some intermediate representation then we can separate different concerns (like recognizing lines vs grouping them). This may or may not make the code more maintainable.
Caveat: this strategy only works if each line has the same meaning in all contexts. If the interpretation of a line depends on the current state, this separation is going to be much more difficult, and the appropriate response would be to extract the low-level details into separate functions instead, as discussed above.
Conclusion
State machines are complex, but there are different strategies for simplifying them. In particular:
- extracting low-level concerns so that the state machine only consists of high-level operations
- using different functions or classes for handling each state
- encoding states in the control flow of (recursive) functions
Depending on context, one or another technique may be preferable.
It's also worth noting that it is difficult to write code that would behave exactly the same in all variants. Even my code examples here have notable differences in how they handle "incorrect" inputs, for example:
#end SECTION
foo
#begin SECTION
x
#begin SECTION
y
interpreter pattern
is your friend :-).