Skip to content
Closed
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
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.31.1"
__version__ = "4.31.3"

Check notice on line 25 in odev/_version.py

View workflow job for this annotation

GitHub Actions / version-bump

Patch Update

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
26 changes: 24 additions & 2 deletions odev/common/commands/odoobin.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,36 @@ def _guess_addons_paths(self) -> list[Path]:
"Some additional addons paths are invalid, they will be ignored:\n"
+ string.join_bullet([path.as_posix() for path in invalid_paths])
)
elif self._database.repository:
addons_paths = [GitConnector(self._database.repository.full_name).path.resolve()]
elif (repository_path := self._database_addons_path) is not None:
addons_paths = [repository_path]
else:
current_path = Path().resolve()
addons_paths = [current_path] if self.odoobin.check_addons_path(current_path) else []

return addons_paths

@property
def _database_addons_path(self) -> Path | None:
"""Path to the repository linked to the database, if it exists on the filesystem.
Databases linked to a repository that cannot be found locally fall back to detecting
the repository from the current directory, so that a wrong link can be fixed by running
the command again from within the repository.
"""
if not self._database.repository:
return None

path = GitConnector(self._database.repository.full_name).path.resolve()

if not path.is_dir():
logger.warning(
f"Repository {self._database.repository.full_name!r} linked to database "
f"{self._database.name!r} was not found at {path.as_posix()}, "
"falling back to the current directory"
)
return None

return path

def _set_addons_paths(self) -> None:
"""Find additional addons paths from the database repository if any."""
self.odoobin.additional_addons_paths = self.odoobin.expand_addons_paths(self._guess_addons_paths())
Expand Down
104 changes: 75 additions & 29 deletions odev/common/connectors/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,38 +418,84 @@ def __init__(self, repo: str, path: Path | None = None):
path = Path(repo)

self._path: Path | None = path
name: tuple[str, str] | None = None

if path and path.joinpath(".git").exists():
repo_url = Repo(path).remote().url
self._organization, self._repository = repo_url.removesuffix(".git").split("/")[-2:]
if path is not None and path.joinpath(".git").exists():
name = self._name_from_remote(path)

if ":" in self._organization:
self._organization = self._organization.split(":")[-1]
else:
if "@" in repo and ":" in repo:
# Assume the repo is in the format git@github.com:organization/repository.git
repo = repo.split(":")[-1]

repo = urlparse(repo).path.removeprefix("/").removesuffix(".git")
repo_values = repo.split("/")

if len(repo_values) != GIT_EXPECTED_REPO_PARTS:
raise ConnectorError(
"Invalid repository format: expected a valid git URL or repository name in one of the formats:\n"
+ string.join_bullet(
[
string.stylize(url, "color.purple")
for url in (
"organization/repository",
"https://github.com/organization/repository",
"git@github.com:organization/repository.git",
)
],
),
self,
)
self._organization, self._repository = name or self._name_from_string(self._fallback_name(repo, path))
Comment thread
brinkflew marked this conversation as resolved.

@staticmethod
def _fallback_name(repo: str, path: Path | None) -> str:
"""Best guess of the repository name when it cannot be read from a git remote.
:param repo: The repository as passed to the connector.
:param path: The path to the repository, if any.
:return: The repository name in the format `organization/repository`.
"""
if path is not None and (not repo or Path(repo).is_absolute()):
return f"{path.parent.name}/{path.name}"

return repo

@classmethod
def _name_from_remote(cls, path: Path) -> tuple[str, str] | None:
"""Read the organization and repository names from the remote of a local git repository.
The remote is the only reliable source of truth: the directory a repository is cloned to
may not follow the `<repositories>/<organization>/<repository>` convention.
:param path: The path to the local repository.
:return: The organization and repository names, or None if they cannot be determined.
"""
try:
remotes = list(Repo(path).remotes)
except (GitCommandError, InvalidGitRepositoryError, NoSuchPathError, ValueError) as error:
logger.debug(f"Could not read the git repository at {path.as_posix()}: {error}")
return None

remote = next((remote for remote in remotes if remote.name == "origin"), None) or next(iter(remotes), None)

if remote is None:
logger.debug(f"No git remote configured for the repository at {path.as_posix()}")
return None

parts = remote.url.removesuffix(".git").rstrip("/").split("/")

if len(parts) < GIT_EXPECTED_REPO_PARTS:
logger.debug(f"Unexpected git remote URL {remote.url!r} for the repository at {path.as_posix()}")
return None

organization, repository = parts[-2:]
return organization.split(":")[-1], repository

def _name_from_string(self, repo: str) -> tuple[str, str]:
"""Parse the organization and repository names out of a repository name or git URL.
:param repo: The repository in the format `organization/repository` or a valid git URL.
:return: The organization and repository names.
"""
if "@" in repo and ":" in repo:
# Assume the repo is in the format git@github.com:organization/repository.git
repo = repo.split(":")[-1]

repo = urlparse(repo).path.removeprefix("/").removesuffix(".git")
repo_values = repo.split("/")

if len(repo_values) != GIT_EXPECTED_REPO_PARTS:
raise ConnectorError(
"Invalid repository format: expected a valid git URL or repository name in one of the formats:\n"
+ string.join_bullet(
[
string.stylize(url, "color.purple")
for url in (
"organization/repository",
"https://github.com/organization/repository",
"git@github.com:organization/repository.git",
)
],
),
self,
)

self._organization, self._repository = repo_values
organization, repository = repo_values
return organization, repository

def __repr__(self) -> str:
return f"GitConnector({self.name!r})"
Expand Down
18 changes: 11 additions & 7 deletions odev/common/connectors/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,27 +314,31 @@ def create_table(self, table: str, columns: Mapping[str, str]) -> bool:
return bool(self.query(f"CREATE TABLE IF NOT EXISTS {table} ({sql_columns})"))

def columns_exist(self, table: str, columns: list[str]) -> list[str]:
"""Check whether a column exists in a table.
"""List the columns missing from a table, among the ones requested.

:param table: The name of the table to check.
:param columns: The name of the column to check.
:return: Whether the column exists.
:param columns: The names of the columns to look for.
:return: The requested columns that do not exist in the table, in the order they were requested.
:rtype: list[str]
"""
if not isinstance(columns, list):
raise TypeError("Columns should be a list of strings")

if not columns:
return []

results = self.query(
f"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = '{table}' AND column_name IN ({",".join([f"'{c}'" for c in columns])})
WHERE table_name = '{table}' AND column_name IN ({",".join([f"'{column}'" for column in columns])})
""",
)

if results and isinstance(results, list) and columns:
return [c for c in columns if c not in [r[0] for r in results]]
return []
# An empty result means none of the requested columns exist, not that none are missing.
existing = {result[0] for result in results} if isinstance(results, list) else set()

return [column for column in columns if column not in existing]

def create_column(self, table: str, column: str, attributes: str) -> bool:
"""Create a column in a table.
Expand Down
28 changes: 20 additions & 8 deletions odev/common/databases/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from zipfile import ZipFile

from packaging.version import Version
from psycopg2 import OperationalError

from odev.common import bash, progress, string
from odev.common.connectors import GitConnector, GitWorktree, PostgresConnector
Expand Down Expand Up @@ -60,9 +61,6 @@
class LocalDatabase(PostgresConnectorMixin, Database):
"""Class for manipulating PostgreSQL (local) databases."""

connector: PostgresConnector | None = None
"""The PostgreSQL connector of the database."""

_whitelisted: bool = False
"""Whether the database is whitelisted and should not be removed automatically."""

Expand Down Expand Up @@ -101,11 +99,11 @@ def __init__(self, name: str):
self.whitelisted = info is not None and info.whitelisted

def __enter__(self):
self.connector = self.psql(self.name).__enter__() # type: ignore [assignment]
self._enter_connector(self.name)
return self

def __exit__(self, *args):
self.psql(self.name).__exit__(*args)
self._exit_connector(*args)

@property
def rpc_port(self):
Expand All @@ -116,8 +114,20 @@ def is_odoo(self) -> bool:
if not self.exists:
return False

with self:
return self.table_exists("ir_module_module")
try:
with self:
return self.table_exists("ir_module_module")

except OperationalError:
# Any process can drop a database at any time, including between the check above and this
# connection. Listing databases inspects each of them in turn and must not fail because one
# went away in the meantime; anything else is a genuine connection error. The check has to
# reach the server rather than the cache, which is what said the database was still there.
with self.psql() as psql, psql.nocache():
if psql.database_exists(self.name):
raise

return False

@property
def venv(self) -> PythonEnv:
Expand Down Expand Up @@ -637,7 +647,9 @@ def signal_handler_progress(

tracker.stop()

if self.connector is not None:
# The attribute holds the connector class until a block connects for the first time, so the check
# is on the type rather than on `None`, as in `drop`.
if isinstance(self.connector, PostgresConnector):
self.connector.invalidate_cache()

def _replace_filestore(self, source_dir: Path) -> None:
Expand Down
32 changes: 32 additions & 0 deletions odev/common/mixins/connectors/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,38 @@ class PostgresConnectorMixin(ConnectorMixin):
_connector_class: type[PostgresConnector] = PostgresConnector # type: ignore [assignment]
connector: PostgresConnector # type: ignore [assignment]

_connection_depth: int = 0
"""Number of nested blocks currently sharing the connector."""

def psql(self, name: str = "postgres") -> PostgresConnector:
"""Return a PostgreSQL connector to the selected database."""
return self._connector_class(name)

def _enter_connector(self, name: str) -> PostgresConnector:
"""Connect to a database, or join the connection an enclosing block already opened.

`ensure_connected` runs every database method inside its own block, and those blocks nest, so
opening a connection per block would mean a new PostgreSQL backend for each call.

:param name: The name of the database to connect to.
:return: The connector the caller should use.
:rtype: PostgresConnector
"""
# A held connection that was closed in the meantime is replaced rather than reused: `drop`
# disconnects explicitly, and the datastore holds its own connection across such a call. At depth
# zero the check is skipped, `connector` still being the connector class rather than an instance.
if not self._connection_depth or not self.connector.connected:
# Narrows what the base mixin types as a `Connector`, deliberately: everything reached through
# this mixin talks to PostgreSQL, and its callers query rather than use the base interface.
self.connector = self.psql(name).__enter__() # pyright: ignore[reportIncompatibleVariableOverride]

self._connection_depth += 1

return self.connector

def _exit_connector(self, *args) -> None:
"""Close the connection once the outermost block sharing it is done with it."""
self._connection_depth -= 1

if not self._connection_depth:
self.connector.__exit__(*args)
18 changes: 14 additions & 4 deletions odev/common/odev.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
Any,
ClassVar,
Generic,
Literal,
NamedTuple,
TypedDict,
cast,
Expand Down Expand Up @@ -197,17 +196,21 @@ class Odev(Generic[CommandType]):
_command_stack: list[CommandType] = []
"""Stack of current commands being executed. Last command in list is the one currently running."""

def __init__(self, test: bool = False):
def __init__(self, test: bool = False, name: str | None = None):
"""Initialize the framework.

:param test: Whether the framework is being initialized for testing purposes
:param name: Namespace of the framework, overriding the one inferred from the test mode
"""
self.start_time = monotonic()
"""Time when the framework was started."""

self.in_test_mode = test
"""Whether the framework is in testing mode."""

self._name = name
"""Namespace explicitly assigned to this instance, if any."""

self.commands = CommandRegistry(self)
"""Collection of existing commands, imported on demand."""

Expand All @@ -225,8 +228,15 @@ def git(self) -> GitConnector:
return GitConnector(f"{self.path.parent.name}/{self.path.name}", self.path)

@property
def name(self) -> Literal["odev", "odev-test"]:
"""Name of the framework."""
def name(self) -> str:
"""Name of the framework, and the namespace of everything it owns.

The configuration file and the datastore database are both named after it, so an instance given an
explicit name works on its own resources rather than on the ones of the user.
"""
if self._name is not None:
return self._name

return "odev" if not self.in_test_mode else "odev-test"

@property
Expand Down
5 changes: 4 additions & 1 deletion odev/common/odoobin.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,10 @@ def additional_repositories(self) -> Generator[GitConnector, None, None]:
"""Return the list of additional repositories linked to this database."""
for path in self.additional_addons_paths:
if (path / ".git").exists() and self.check_addons_path(path):
yield GitConnector(f"{path.parent.name}/{path.name}")
# Pass the path so the name is read from the git remote: a repository cloned outside of
# the `<repositories>/<organization>/<repository>` convention would otherwise be named
# after the directories it lives in, and resolved back to a path that does not exist.
yield GitConnector(f"{path.parent.name}/{path.name}", path)

@property
def odoo_worktrees(self) -> Generator[GitWorktree, None, None]:
Expand Down
Loading
Loading