Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ ignore = [
"F403", # ignore * imports
"I001", # ignore unsorted imports
]
"tools/*" = [
"T201", # standalone developer scripts report their results on stdout
]

[lint.pydocstyle]
convention = "pep257"
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ Before you can run this tool, make sure the below requirements are set on your s
source install requirements
- [Other Odoo dependencies](https://www.odoo.com/documentation/19.0/administration/on_premise/source.html#dependencies)

Odoo builds part of its Python dependencies (`gevent`, `lxml`, `python-ldap`, …) from source, which needs a C compiler
and the matching development packages: the headers of the Python version Odoo runs on, the PostgreSQL client library
and the OpenLDAP and SASL headers. On Debian and Ubuntu, install them all from the Odoo sources Odev has cloned:

```sh
sudo ~/odoo/repositories/odoo/odoo/setup/debinstall.sh
```

On Fedora, Arch, openSUSE, Alpine or macOS, install the equivalents with your own package manager. Odev checks
whenever it creates a virtual environment for a version of Odoo and, whatever the system, tells you what is missing
along with the command that installs it. It never installs anything itself.

Make sure `git` is properly setup with SSH key authentication before using commands, as Odev will try to connect to
the Odoo [Community](https://github.com/odoo/odoo) and [Enterprise](https://github.com/odoo/enterprise) repositories
to pull sources when required.
Expand Down
27 changes: 27 additions & 0 deletions docs/tutorials/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ In this basic tutorial, we'll see how we can define a brand new command to print
- [Going further...](#going-further)
- [Extended command classes](#extended-command-classes)
- [Mutually exclusive arguments](#mutually-exclusive-arguments)
- [Flags that can be turned off](#flags-that-can-be-turned-off)
- [Unknown arguments](#unknown-arguments)
- [Raising errors](#raising-errors)

Expand Down Expand Up @@ -265,6 +266,32 @@ class SampleCommand(Command):

![odev sample](img/command-sample-08.png)

### Flags that can be turned off

A flag defined with `args.Flag` is either present or absent, so a command cannot tell "the user asked for the value to
be turned off" apart from "the user did not mention it". When that distinction matters, typically for a command editing
a value that already exists, use `args.FlagOptional` instead: it registers a `--no-` counterpart for each of its
aliases.

```python
class SampleCommand(Command):
"""Example command used for tutorials purposes."""

_name = "sample"
_aliases = ["example"]

flag = args.FlagOptional(aliases=["--flag"], description="Sample three-state flag argument")

def run(self):
if self.args.flag is None:
self.console.print("The flag was not mentioned, leaving the value as it is")
else:
self.console.print(f"The flag was set to {self.args.flag}")
```

`--flag` sets the value to `True`, `--no-flag` sets it to `False`, and omitting both leaves it at its default, `None`
unless another one is given to the argument.

### Unknown arguments

By default, Odev will treat any unknown argument received as invalid and raise an error.
Expand Down
65 changes: 65 additions & 0 deletions docs/tutorials/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ To enable a plugin, run `odev plugin --enable <organization>/<repository>`.

- [Plugins](#plugins)
- [Table of contents](#table-of-contents)
- [Finding and managing plugins](#finding-and-managing-plugins)
- [Searching for plugins](#searching-for-plugins)
- [Listing local plugins](#listing-local-plugins)
- [Inspecting a plugin](#inspecting-a-plugin)
- [Creating a new plugin](#creating-a-new-plugin)
- [Plugin structure](#plugin-structure)
- [The manifest](#the-manifest)
Expand All @@ -18,6 +22,64 @@ To enable a plugin, run `odev plugin --enable <organization>/<repository>`.
- [Adding a new command](#adding-a-new-command)
- [Extending a command](#extending-a-command)

## Finding and managing plugins

### Searching for plugins

Run `odev plugin --search` to look for plugins published on GitHub. Odev queries the GitHub search API for the `odev`
and `plugin` keywords, then keeps only the repositories exposing a valid [manifest](#the-manifest) at their root, so
unrelated repositories never show up in the results.

Add a term to narrow the search down, quoting it if it contains several words:

```sh
odev plugin --search editor
odev plugin --search "upgrade code"
```

The results are displayed in a table showing, for each plugin, the version declared on its default branch, its number
of stars and whether it is already available locally. Archived repositories and the
[template repository](https://github.com/odoo-odev/odev-plugin-template), which cannot be installed, are left out.
`--limit` caps how many repositories are inspected (20 by default) to stay within the GitHub API rate limits.

> [!NOTE]
>
> Searching never installs anything. Copy the name of a plugin from the results and run
> `odev plugin --enable <organization>/<repository>` to install it.

### Listing local plugins

Run `odev plugin --list` to display every plugin available on your machine, in one of the following states:

| State | Meaning |
| ---------- | --------------------------------------------------------------------------------------------- |
| `enabled` | The plugin is loaded by odev. |
| `shadowed` | The plugin is enabled but another plugin already uses its module name, so it cannot be loaded. |
| `missing` | The plugin is enabled but its link under `~/.config/odev/plugins` is gone. |
| `disabled` | The plugin was downloaded previously but is not enabled; re-enabling it will not clone it again. |

### Inspecting a plugin

Use `odev plugin --show <organization>/<repository>` to get the details of a single plugin: its state, version,
branch, path, dependencies and description. Without an argument, `--show` details every plugin available locally, in
the same order as `--list`.

A plugin that is not on your machine is looked up on GitHub, so `--show` also describes plugins you have not installed
yet, or that you uninstalled and whose clone you deleted:

```sh
odev plugin --show odoo-odev/odev-plugin-editor-vscode
```

Plugins already available locally are read from disk and never trigger a request to GitHub. The full
`<organization>/<repository>` name is required to look a plugin up remotely: a repository name on its own is only
matched against the plugins present on your machine, as long as it is not ambiguous.

> [!NOTE]
>
> When GitHub cannot be reached — no token configured, no network, rate limit exceeded — `--show` silently falls back
> to the information available locally instead of failing.

## Creating a new plugin

To create a new odev plugin, start by creating a new repository. You can copy a
Expand Down Expand Up @@ -52,6 +114,9 @@ Create a new file `__manifest__.py` at the root of your plugin with the followin
repository). Replace the docstring by a summary of your module's features. This will be read by Odev and displayed when
required by the `odev plugin` command.

The `__version__` assignment is also what makes a repository recognizable as a plugin: a repository without a root
`__manifest__.py` declaring it is ignored by `odev plugin --search`.

If any, add the dependencies (other plugins) of your own plugin. For example, `odoo-odev/odev-plugin-editor-vscode`
depends on the abstract plugin `odoo-odev/odev-plugin-editor-base` which is therefore required for the plugin to work:
[VScode Editor plugin's depends](https://github.com/odoo-odev/odev-plugin-editor-vscode/blob/main/__manifest__.py#L38).
Expand Down
2 changes: 1 addition & 1 deletion odev/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@
# or merged change.
# ------------------------------------------------------------------------------

__version__ = "4.29.9"
__version__ = "4.31.3"

Check failure on line 25 in odev/_version.py

View workflow job for this annotation

GitHub Actions / version-bump

Version Not Updated

The odev version has not been updated. Please update incrementally the __version__ value on odev/_version.py

Check failure on line 25 in odev/_version.py

View workflow job for this annotation

GitHub Actions / version-bump

Version Update Not Incremental

The new version value does not follow the incremental pattern (e.g: 1.2.3 -> 1.2.4 or 1.3.0 or 2.0.0). Please update incrementally the __version__ value on odev/_version.py
187 changes: 187 additions & 0 deletions odev/commands/database/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""Edit the parameters of a local database without starting it."""

from odev.common import args, string
from odev.common.commands import GitCommand, LocalDatabaseCommand
from odev.common.connectors import GitConnector
from odev.common.errors import ConnectorError
from odev.common.logging import logging
from odev.common.python import PythonEnv


logger = logging.getLogger(__name__)


class DatabaseSetCommand(LocalDatabaseCommand, GitCommand):
"""Display and edit the parameters of a local database without starting it.
Called without any argument other than the database, the current parameters are displayed.
"""

_name = "database"
_aliases = ["db"]

set_repository = args.String(
aliases=["--set-repo"],
description="""Change the repository linked to the database. Accepts a repository name in
the format <organization>/<repository>, a git URL or the path to a local clone.
""",
metavar="REPOSITORY",
)
remove_repository = args.Flag(
aliases=["--remove-repo"],
description="Remove the repository linked to the database.",
)

set_venv = args.String(
aliases=["--set-venv"],
description="Change the virtualenv linked to the database.",
metavar="VENV",
)
remove_venv = args.Flag(
aliases=["--remove-venv"],
description="Remove the virtualenv linked to the database.",
)

set_worktree = args.String(
aliases=["--set-worktree"],
description="Change the worktree linked to the database.",
metavar="WORKTREE",
)
remove_worktree = args.Flag(
aliases=["--remove-worktree"],
description="Remove the worktree linked to the database.",
)

whitelist = args.FlagOptional(
aliases=["--whitelist"],
description="Whitelist or unwhitelist the database, preventing or allowing its automatic removal.",
)

_exclusive_pairs = (
("set_repository", "remove_repository"),
("set_venv", "remove_venv"),
("set_worktree", "remove_worktree"),
)
"""Pairs of arguments that set and remove the same value, and cannot be used together."""

@classmethod
def prepare_command(cls, *args, **kwargs) -> None:
super().prepare_command(*args, **kwargs)
cls.remove_argument("version")

def run(self):
self._check_exclusive_arguments()

if self._has_changes:
self._set_values()
self._remove_values()

self._print_values()

@property
def _has_changes(self) -> bool:
"""Whether the command was called with an argument changing a parameter."""
return bool(
self.args.set_repository
or self.args.remove_repository
or self.args.set_venv
or self.args.remove_venv
or self.args.set_worktree
or self.args.remove_worktree
or self.args.whitelist is not None
)

def _check_exclusive_arguments(self) -> None:
"""Ensure no parameter is both set and removed in the same call."""
for set_argument, remove_argument in self._exclusive_pairs:
if getattr(self.args, set_argument) and getattr(self.args, remove_argument):
raise self.error(
f"Arguments {self.argument_name(set_argument)!r} and "
f"{self.argument_name(remove_argument)!r} cannot be used together"
)

def argument_name(self, argument: str) -> str:
"""Return the CLI alias of an argument, for use in error messages."""
aliases = self._arguments.get(argument, {}).get("aliases", [])
return next((alias for alias in aliases if alias.startswith("--")), argument)

def _set_values(self) -> None:
if self.args.set_repository:
self._set_repository(self.args.set_repository)

if self.args.set_venv:
venv = PythonEnv(self.args.set_venv)

if not venv.exists:
raise self.error(f"Virtualenv {self.args.set_venv!r} not found, please create it and retry")

self._database.venv = venv
logger.info(f"Virtualenv set to {venv.name!r}")

if self.args.set_worktree:
if self.args.set_worktree not in self.grouped_worktrees:
raise self.error(f"Worktree {self.args.set_worktree!r} not found, please create it and retry")

self._database.worktree = self.args.set_worktree
logger.info(f"Worktree set to {self.args.set_worktree!r}")

if self.args.whitelist is True:
self._database.whitelisted = True
logger.info("Database whitelisted")

def _set_repository(self, repository: str) -> None:
"""Link a repository to the database, cloning it first if it is missing locally.

:param repository: The repository name, git URL or path to a local clone.
"""
try:
connector = GitConnector(repository)
except ConnectorError as error:
raise self.error(str(error)) from error

if not connector.exists and self.console.confirm(
f"Repository {connector.name!r} not found locally, clone now?"
):
self.odev.run_command("clone", connector.name)

linked = self._database.link_repository(connector)
logger.info(f"Repository set to {linked.full_name!r}")

def _remove_values(self) -> None:
# Values are cleared through the data store rather than through the database properties:
# those fall back to reading the store when their cached value is empty, so assigning None
# to them would write the value that was just cleared straight back.
if self.args.remove_repository:
self.store.databases.set_value(self._database, "repository", None)
self.store.databases.set_value(self._database, "branch", None)
self._database._repository = None
self._database._branch = None
logger.info("Repository removed")

if self.args.remove_venv:
self.store.databases.set_value(self._database, "virtualenv", None)
self._database._venv = None
logger.info("Virtualenv removed")

if self.args.remove_worktree:
self.store.databases.set_value(self._database, "worktree", None)
self._database._worktree = None
logger.info("Worktree removed")

if self.args.whitelist is False:
self._database.whitelisted = False
logger.info("Database unwhitelisted")

def _print_values(self) -> None:
"""Print the current parameters of the database."""
info = self.store.databases.get(self._database)
values = {
"Repository": info and info.repository,
"Branch": info and info.branch,
"Virtualenv": info and info.virtualenv,
"Worktree": info and info.worktree,
"Whitelisted": "yes" if info and info.whitelisted else "no",
}
logger.info(
f"Parameters of database {self._database.name!r}:\n"
+ string.join_bullet([f"{key}: {value or 'not set'}" for key, value in values.items()])
)
29 changes: 22 additions & 7 deletions odev/commands/database/quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,31 @@ def run(self):
raise self.error(f"Database {self._database.name!r} could not be restored")

new_database = LocalDatabase(self.args.name or self._database.name)

# Link the repository before restoring: `restore` neutralizes the database, and
# neutralization looks for `data/neutralize.sql` scripts in the custom modules found
# under the addons paths derived from the repository linked to the database.
self.link_repository(new_database)
self.odev.run_command("restore", dump_file.as_posix(), database=new_database)

if self._database.repository:
if isinstance(self._database.repository, Repository):
repo_org = self._database.repository.organization
repo_name = self._database.repository.name
else:
repo_org, repo_name = self._database.repository.name.split("/")
# `restore` drops and recreates the database, clearing its entry in the data store.
self.link_repository(new_database)

def link_repository(self, database: LocalDatabase) -> None:
"""Link the repository of the source database to the target local database.

:param database: The database to link the repository to.
"""
if not self._database.repository:
return

if isinstance(self._database.repository, Repository):
repo_org = self._database.repository.organization
repo_name = self._database.repository.name
else:
repo_org, repo_name = self._database.repository.name.split("/")

new_database.repository = Repository(repo_name, repo_org)
database.repository = Repository(repo_name, repo_org)

def get_dump_filename_kwargs(self) -> MutableMapping[str, Any]:
"""Return the keyword arguments to pass to Database.get_dump_filename()."""
Expand Down
Loading
Loading