From 168e8a04020d01c5b8af58a3c9bc5a975ea9eaad Mon Sep 17 00:00:00 2001 From: Eduardo Aguad Date: Sun, 7 Jun 2026 00:03:18 -0400 Subject: [PATCH 1/3] feat: migrate the CLI to cleo 2 cleo 0.8.1 (2020) is vulnerable to CVE-2022-42966 (ReDoS) and three majors behind. Commands keep declaring their signature in the docstring block format: the base Command now parses it into a cleo 2 definition (arguments/options helpers) instead of relying on cleo 0.8's removed docstring parsing. - CanOverrideConfig and CanOverrideOptionsDefault are folded into the base Command: the global --config/-C option is added to the definition, and option defaults can still be overridden per instance (SomeCommand(directory=...)) - cleo 0.8 semantics preserved for bare optional-value options (--show reads as truthy) - Entry/CommandTester imports updated to cleo 2 paths - cleo>=2.1,<3; version bumped to 3.1.0 Full suite: 1029 passed (the one failing postgres test requires the CI database service and fails identically on cleo 0.8). --- setup.py | 4 +- src/masoniteorm/commands/CanOverrideConfig.py | 16 -- .../commands/CanOverrideOptionsDefault.py | 22 --- src/masoniteorm/commands/Command.py | 175 +++++++++++++++++- src/masoniteorm/commands/Entry.py | 4 +- tests/commands/test_shell.py | 2 +- 6 files changed, 176 insertions(+), 47 deletions(-) delete mode 100644 src/masoniteorm/commands/CanOverrideConfig.py delete mode 100644 src/masoniteorm/commands/CanOverrideOptionsDefault.py diff --git a/setup.py b/setup.py index f7b3c590..876aabe9 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version="3.0.2", + version="3.1.0", package_dir={"": "src"}, description="The Official Masonite ORM", long_description=long_description, @@ -33,7 +33,7 @@ install_requires=[ "inflection>=0.3,<0.6", "pendulum>=3.0,<4.0", - "cleo>=0.8.0,<2.0", + "cleo>=2.1,<3", ], # See https://pypi.python.org/pypi?%3Aaction=list_classifiers classifiers=[ diff --git a/src/masoniteorm/commands/CanOverrideConfig.py b/src/masoniteorm/commands/CanOverrideConfig.py deleted file mode 100644 index 5b23e97c..00000000 --- a/src/masoniteorm/commands/CanOverrideConfig.py +++ /dev/null @@ -1,16 +0,0 @@ -from cleo import Command - - -class CanOverrideConfig(Command): - def __init__(self): - super().__init__() - self.add_option() - - def add_option(self): - # 8 is the required flag constant in cleo - self._config.add_option( - "config", - "C", - 8, - description="The path to the ORM configuration file. If not given DB_CONFIG_PATH env variable will be used and finally 'config.database'.", - ) diff --git a/src/masoniteorm/commands/CanOverrideOptionsDefault.py b/src/masoniteorm/commands/CanOverrideOptionsDefault.py deleted file mode 100644 index 6726a523..00000000 --- a/src/masoniteorm/commands/CanOverrideOptionsDefault.py +++ /dev/null @@ -1,22 +0,0 @@ -from inflection import underscore - - -class CanOverrideOptionsDefault: - """Command mixin to allow to override optional argument default values when instantiating the - command. - Example: SomeCommand(app, option1="other/default"). - - If an argument long name is using - then use _ in keyword argument: - Example: SomeCommand(app, option_1="other/default") for an option named in option-1 - """ - - def __init__(self, **kwargs): - super().__init__() - self.overriden_default = kwargs - for option_name, option in self.config.options.items(): - # Cleo does not authorize _ in option name but - are authorized and unfortunately - - # cannot be used in Python variables. So underscore() is called to make sure that - # an option like 'option-a' will be accessible with 'option_a' in kwargs - default = self.overriden_default.get(underscore(option_name)) - if default: - option.set_default(default) diff --git a/src/masoniteorm/commands/Command.py b/src/masoniteorm/commands/Command.py index a4d13a10..36a126b7 100644 --- a/src/masoniteorm/commands/Command.py +++ b/src/masoniteorm/commands/Command.py @@ -1,6 +1,173 @@ -from .CanOverrideConfig import CanOverrideConfig -from .CanOverrideOptionsDefault import CanOverrideOptionsDefault +import re +from cleo.commands.command import Command as BaseCommand +from cleo.helpers import argument, option +from inflection import underscore -class Command(CanOverrideOptionsDefault, CanOverrideConfig): - pass +_TOKEN_RE = re.compile(r"\{([^}]+)\}") +_NAME_RE = re.compile(r"[A-Za-z0-9:_\-]+") + + +class Command(BaseCommand): + """Masonite ORM's base command. + + Commands declare their signature in the class docstring using the + historical cleo 0.8 block format, which this class translates into a + cleo 2 definition: + + Description of the command + + command:name + {argument : Argument description} + {optional_argument? : Optional argument} + {--flag : Boolean option} + {--s|--long : Option with a shortcut} + {--option=? : Option expecting a value} + {--option=default : Option with a default value} + + Optional option defaults can be overridden when instantiating the + command: SomeCommand(directory="other/default"). If an option long name + uses - then use _ in the keyword argument. + + Every command also gets a global --config/-C option pointing to the ORM + configuration file. + """ + + def __init__(self, **kwargs): + self._configure_from_docstring() + super().__init__() + + # global --config option available on every ORM command + self._definition.add_option( + option( + "config", + "C", + "The path to the ORM configuration file. If not given DB_CONFIG_PATH " + "env variable will be used and finally 'config.database'.", + flag=False, + value_required=False, + ) + ) + + # allow overriding option defaults per instance + self.overriden_default = kwargs + for definition_option in self._definition.options: + default = self.overriden_default.get(underscore(definition_option.name)) + if default: + definition_option.set_default(default) + + def option(self, key=None): + value = super().option(key) + if value is None and key is not None: + # cleo 0.8 compatibility: an optional-value option passed without + # a value (e.g. a bare --show) reads as present, hence truthy. + try: + given_options = self.io.input._options + except AttributeError: + return value + if key in given_options: + return True + return value + + @classmethod + def _configure_from_docstring(cls): + # configure each concrete command class once + if "_docstring_parsed" in cls.__dict__: + return + doc = cls.__doc__ or "" + + name, description = cls._parse_name_and_description(doc) + arguments, options = cls._parse_tokens(doc) + + if name: + cls.name = name + if description: + cls.description = description + cls.arguments = arguments + cls.options = options + cls._docstring_parsed = True + + @staticmethod + def _parse_name_and_description(doc): + """The command name is the last text line that looks like a command + slug; every text line before it forms the description.""" + text_lines = [ + line.strip() + for line in _TOKEN_RE.sub("", doc).splitlines() + if line.strip() + ] + + name = None + name_index = None + for index, line in enumerate(text_lines): + if _NAME_RE.fullmatch(line): + name = line + name_index = index + + description_lines = ( + text_lines[:name_index] if name_index is not None else text_lines + ) + return name, " ".join(description_lines) + + @classmethod + def _parse_tokens(cls, doc): + arguments = [] + options = [] + for token in _TOKEN_RE.findall(doc): + spec, _, token_description = token.strip().partition(":") + spec = spec.strip() + token_description = token_description.strip() or None + + if spec.startswith("--"): + options.append(cls._parse_option(spec, token_description)) + else: + arguments.append(cls._parse_argument(spec, token_description)) + return arguments, options + + @staticmethod + def _parse_option(spec, description): + parts = [part.strip().lstrip("-") for part in spec.split("|")] + short_name = parts[0] if len(parts) > 1 else None + long_name = parts[-1] + + flag = True + multiple = False + default = None + if "=" in long_name: + long_name, _, default = long_name.partition("=") + flag = False + if default == "*": + multiple = True + default = None + elif default in ("?", ""): + default = None + + return option( + long_name, + short_name, + description, + flag=flag, + value_required=False, + multiple=multiple, + default=default, + ) + + @staticmethod + def _parse_argument(spec, description): + optional = False + multiple = False + default = None + if spec.endswith("?"): + spec = spec[:-1] + optional = True + elif spec.endswith("*"): + spec = spec[:-1] + optional = True + multiple = True + elif "=" in spec: + spec, _, default = spec.partition("=") + optional = True + + return argument( + spec, description, optional=optional, multiple=multiple, default=default + ) diff --git a/src/masoniteorm/commands/Entry.py b/src/masoniteorm/commands/Entry.py index 15df69c2..10f5c174 100644 --- a/src/masoniteorm/commands/Entry.py +++ b/src/masoniteorm/commands/Entry.py @@ -5,7 +5,7 @@ successfully import commands for you. """ -from cleo import Application +from cleo.application import Application from . import ( MakeMigrationCommand, @@ -23,7 +23,7 @@ ShellCommand, ) -application = Application("ORM Version:", 0.1) +application = Application("ORM Version:", "0.1") application.add(MigrateCommand()) application.add(MigrateRollbackCommand()) diff --git a/tests/commands/test_shell.py b/tests/commands/test_shell.py index 8d432121..2f02e508 100644 --- a/tests/commands/test_shell.py +++ b/tests/commands/test_shell.py @@ -1,5 +1,5 @@ import unittest -from cleo import CommandTester +from cleo.testers.command_tester import CommandTester from src.masoniteorm.commands import ShellCommand From 0167547db0cc0e880c7b48e31f1f16966f609c9c Mon Sep 17 00:00:00 2001 From: Eduardo Aguad Date: Sun, 7 Jun 2026 00:09:22 -0400 Subject: [PATCH 2/3] fix: bump cleo pin in requirements.txt too (used by CI) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ca245376..eea7817f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ psycopg2-binary python-dotenv>=0.14 pyodbc pendulum>=3.0,<4.0 -cleo>=0.8.0,<2.0 +cleo>=2.1,<3 From 50d9204ba9f11d540131616dd1cc48ef4d009ddb Mon Sep 17 00:00:00 2001 From: Eduardo Aguad Date: Sun, 7 Jun 2026 00:14:33 -0400 Subject: [PATCH 3/3] fix: update root orm script to cleo 2 import path --- orm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orm b/orm index 73c273fd..8fafe078 100644 --- a/orm +++ b/orm @@ -5,7 +5,7 @@ This can be used by running "python craft". This module is not ran when the CLI successfully import commands for you. """ -from cleo import Application +from cleo.application import Application from src.masoniteorm.commands import ( MigrateCommand, MigrateRollbackCommand, @@ -21,7 +21,7 @@ from src.masoniteorm.commands import ( SeedRunCommand, ) -application = Application("ORM Version:", 0.1) +application = Application("ORM Version:", "0.1") application.add(MigrateCommand()) application.add(MigrateRollbackCommand())