Skip to content

Configuration

Dataclasses and loading logic for the INI configuration file.

SyncConfig.load() is the main entry point — it reads a config file from disk, validates permissions, and returns a fully populated SyncConfig instance.

devpi_gitea_sync.config

ConfigError

Bases: RuntimeError

Raised when configuration loading or validation fails.

Source code in devpi_gitea_sync/config.py
class ConfigError(RuntimeError):
    """Raised when configuration loading or validation fails."""

GiteaConfig dataclass

Configuration for talking to a Gitea instance.

Parameters:

Name Type Description Default
url str

The URL of the Gitea instance.

required
verify_ssl bool | str

SSL verification: True to verify with default CAs, False to disable, or a path string to a custom CA bundle file.

True
timeout float

The timeout for requests to Gitea.

10.0
default_token str | None

The default Gitea API token.

None
default_token_env str | None

The name of the environment variable containing the default Gitea API token.

None
per_org_tokens dict[str, str]

A dictionary of Gitea API tokens per organization.

dict()
per_org_token_env dict[str, str]

A dictionary of environment variable names for Gitea API tokens per organization.

dict()
Source code in devpi_gitea_sync/config.py
@dataclass(frozen=True)
class GiteaConfig:
    """Configuration for talking to a Gitea instance.

    :param url: The URL of the Gitea instance.
    :param verify_ssl: SSL verification: True to verify with default CAs, False to disable,
        or a path string to a custom CA bundle file.
    :param timeout: The timeout for requests to Gitea.
    :param default_token: The default Gitea API token.
    :param default_token_env: The name of the environment variable containing the default Gitea API token.
    :param per_org_tokens: A dictionary of Gitea API tokens per organization.
    :param per_org_token_env: A dictionary of environment variable names for Gitea API tokens per organization.
    """

    url: str
    verify_ssl: bool | str = True
    timeout: float = 10.0
    default_token: str | None = None
    default_token_env: str | None = None
    per_org_tokens: dict[str, str] = field(default_factory=dict)
    per_org_token_env: dict[str, str] = field(default_factory=dict)

    def token_for(self, organization: str) -> str | None:
        """Return the token configured for a given organisation, if any.

        :param organization: The name of the organization.
        :return: The Gitea API token for the organization, or the default token if not found.
        """
        return self.per_org_tokens.get(organization) or self.default_token

    def token_env_for(self, organization: str) -> str | None:
        """Return the environment variable backing the organisation token, if known.

        :param organization: The name of the organization.
        :return: The name of the environment variable for the organization's token, or the default if not found.
        """
        return self.per_org_token_env.get(organization) or self.default_token_env

token_for(organization)

Return the token configured for a given organisation, if any.

Parameters:

Name Type Description Default
organization str

The name of the organization.

required

Returns:

Type Description
str | None

The Gitea API token for the organization, or the default token if not found.

Source code in devpi_gitea_sync/config.py
def token_for(self, organization: str) -> str | None:
    """Return the token configured for a given organisation, if any.

    :param organization: The name of the organization.
    :return: The Gitea API token for the organization, or the default token if not found.
    """
    return self.per_org_tokens.get(organization) or self.default_token

token_env_for(organization)

Return the environment variable backing the organisation token, if known.

Parameters:

Name Type Description Default
organization str

The name of the organization.

required

Returns:

Type Description
str | None

The name of the environment variable for the organization's token, or the default if not found.

Source code in devpi_gitea_sync/config.py
def token_env_for(self, organization: str) -> str | None:
    """Return the environment variable backing the organisation token, if known.

    :param organization: The name of the organization.
    :return: The name of the environment variable for the organization's token, or the default if not found.
    """
    return self.per_org_token_env.get(organization) or self.default_token_env

DevpiServerConfig dataclass

Configuration for talking to a Devpi server.

Parameters:

Name Type Description Default
name str

The name of the Devpi server.

required
url str

The URL of the Devpi server.

required
username str

The username for authentication with the Devpi server.

required
password str | None

The password for authentication with the Devpi server.

None
password_env str | None

The name of the environment variable containing the password.

None
token str | None

The authentication token for the Devpi server.

None
token_env str | None

The name of the environment variable containing the authentication token.

None
verify_ssl bool | str

SSL verification: True to verify with default CAs, False to disable, or a path string to a custom CA bundle file.

True
timeout float

The timeout for requests to the Devpi server.

30.0
Source code in devpi_gitea_sync/config.py
@dataclass(frozen=True)
class DevpiServerConfig:
    """Configuration for talking to a Devpi server.

    :param name: The name of the Devpi server.
    :param url: The URL of the Devpi server.
    :param username: The username for authentication with the Devpi server.
    :param password: The password for authentication with the Devpi server.
    :param password_env: The name of the environment variable containing the password.
    :param token: The authentication token for the Devpi server.
    :param token_env: The name of the environment variable containing the authentication token.
    :param verify_ssl: SSL verification: True to verify with default CAs, False to disable,
        or a path string to a custom CA bundle file.
    :param timeout: The timeout for requests to the Devpi server.
    """

    name: str
    url: str
    username: str
    password: str | None = None
    password_env: str | None = None
    token: str | None = None
    token_env: str | None = None
    verify_ssl: bool | str = True
    timeout: float = 30.0

MappingConfig dataclass

Represents how a single Gitea org should sync into a Devpi index.

Parameters:

Name Type Description Default
name str

The name of the mapping.

required
organization str

The name of the Gitea organization.

required
index str

The name of the Devpi index.

required
devpi_server str

The name of the Devpi server to use.

'default'
repositories list[str] | None

A list of repositories to include in the sync.

None
include_archived bool

Whether to include archived repositories.

False
gitea_token str | None

The Gitea API token for this mapping.

None
gitea_token_env str | None

The name of the environment variable containing the Gitea API token.

None
Source code in devpi_gitea_sync/config.py
@dataclass(frozen=True)
class MappingConfig:
    """Represents how a single Gitea org should sync into a Devpi index.

    :param name: The name of the mapping.
    :param organization: The name of the Gitea organization.
    :param index: The name of the Devpi index.
    :param devpi_server: The name of the Devpi server to use.
    :param repositories: A list of repositories to include in the sync.
    :param include_archived: Whether to include archived repositories.
    :param gitea_token: The Gitea API token for this mapping.
    :param gitea_token_env: The name of the environment variable containing the Gitea API token.
    """

    name: str
    organization: str
    index: str
    devpi_server: str = "default"
    repositories: list[str] | None = None
    include_archived: bool = False
    gitea_token: str | None = None
    gitea_token_env: str | None = None

    def normalized_repositories(self) -> list[str] | None:
        """Return a sorted, de-duplicated repository allowlist.

        :return: A sorted list of repository names, or None if no repositories are specified.
        """
        if self.repositories is None:
            return None
        return sorted({name.strip() for name in self.repositories if name.strip()})

normalized_repositories()

Return a sorted, de-duplicated repository allowlist.

Returns:

Type Description
list[str] | None

A sorted list of repository names, or None if no repositories are specified.

Source code in devpi_gitea_sync/config.py
def normalized_repositories(self) -> list[str] | None:
    """Return a sorted, de-duplicated repository allowlist.

    :return: A sorted list of repository names, or None if no repositories are specified.
    """
    if self.repositories is None:
        return None
    return sorted({name.strip() for name in self.repositories if name.strip()})

SyncConfig dataclass

Top-level configuration for a sync run.

Parameters:

Name Type Description Default
gitea GiteaConfig

The Gitea configuration.

required
devpi_servers dict[str, DevpiServerConfig]

A dictionary of Devpi server configurations.

required
mappings list[MappingConfig]

A list of mapping configurations.

list()
poll_interval_seconds int

The interval in seconds to poll for changes in server mode.

300
download_dir Path

The directory to download release assets to.

(lambda: Path(gettempdir()) / 'devpi-gitea-sync')()
server_host str

The host address the web server binds to.

'0.0.0.0'
server_port int

The port the web server listens on.

8080
Source code in devpi_gitea_sync/config.py
@dataclass(frozen=True)
class SyncConfig:
    """Top-level configuration for a sync run.

    :param gitea: The Gitea configuration.
    :param devpi_servers: A dictionary of Devpi server configurations.
    :param mappings: A list of mapping configurations.
    :param poll_interval_seconds: The interval in seconds to poll for changes in server mode.
    :param download_dir: The directory to download release assets to.
    :param server_host: The host address the web server binds to.
    :param server_port: The port the web server listens on.
    """

    gitea: GiteaConfig
    devpi_servers: dict[str, DevpiServerConfig]
    mappings: list[MappingConfig] = field(default_factory=list)
    poll_interval_seconds: int = 300
    download_dir: Path = field(default_factory=lambda: Path(tempfile.gettempdir()) / "devpi-gitea-sync")
    server_host: str = "0.0.0.0"
    server_port: int = 8080

    @classmethod
    def load(cls, path: Path) -> "SyncConfig":
        """Read configuration from an INI/CONF file and instantiate a SyncConfig.

        :param path: The path to the configuration file.
        :return: A SyncConfig instance.
        """
        parser = _read_conf(path)
        return cls.from_config_parser(parser)

    @classmethod
    def from_config_parser(cls, parser: configparser.ConfigParser) -> "SyncConfig":
        """Build configuration objects from a ConfigParser instance.

        :param parser: A ConfigParser instance.
        :return: A SyncConfig instance.
        :raises ConfigError: If the configuration is invalid.
        """
        if not parser.has_section("gitea"):
            raise ConfigError("Missing required [gitea] section.")
        if not parser.has_section("devpi"):
            raise ConfigError("Missing required [devpi] section.")

        gitea_section = parser["gitea"]
        devpi_section = parser["devpi"]
        runtime_section = parser["runtime"] if parser.has_section("runtime") else None

        default_token, default_token_env = _get_secret(
            gitea_section, "token", "token_env", "default gitea token"
        )

        per_org_tokens: dict[str, str] = {}
        per_org_token_env: dict[str, str] = {}
        for section_name in parser.sections():
            if section_name.lower().startswith("gitea:"):
                alias = section_name.split(":", 1)[1].strip()
                if not alias:
                    raise ConfigError(
                        f"Section '{section_name}' must specify a Gitea organization or user name."
                    )
                secret, env_name = _get_secret(
                    parser[section_name],
                    "token",
                    "token_env",
                    f"gitea token for '{alias}'",
                    required=True,
                )
                per_org_tokens[alias] = secret
                if env_name:
                    per_org_token_env[alias] = env_name

        gitea = GiteaConfig(
            url=_require_str_option(gitea_section, "url"),
            verify_ssl=_get_verify_ssl(gitea_section, "verify_ssl"),
            timeout=_get_float(gitea_section, "timeout", default=10.0),
            default_token=default_token,
            default_token_env=default_token_env,
            per_org_tokens=per_org_tokens,
            per_org_token_env=per_org_token_env,
        )

        devpi_servers: dict[str, DevpiServerConfig] = {}
        devpi_servers["default"] = _build_devpi_server("default", devpi_section)
        for section_name in parser.sections():
            if section_name.lower().startswith("devpi:"):
                server_name = section_name.split(":", 1)[1].strip()
                if not server_name:
                    raise ConfigError(
                        f"Section '{section_name}' must specify a Devpi server identifier."
                    )
                devpi_servers[server_name] = _build_devpi_server(
                    server_name, parser[section_name]
                )

        mappings: list[MappingConfig] = []
        for section_name in parser.sections():
            if not section_name.lower().startswith("mapping:"):
                continue
            mapping_section = parser[section_name]
            label = section_name.split(":", 1)[1].strip() if ":" in section_name else ""
            mapping_name = label or section_name
            organization = mapping_section.get("organization", fallback=label).strip()
            if not organization:
                raise ConfigError(
                    f"Mapping section '{section_name}' must specify an organization name."
                )
            devpi_name = mapping_section.get("devpi", fallback="default").strip() or "default"
            if devpi_name not in devpi_servers:
                raise ConfigError(
                    f"Mapping section '{section_name}' references unknown Devpi server '{devpi_name}'."
                )
            index = _require_str_option(mapping_section, "index")
            repos_value = mapping_section.get("repositories", fallback="")
            repositories = _parse_list(repos_value)
            include_archived = _get_bool(mapping_section, "include_archived", default=False)
            mapping_token, mapping_token_env = _get_secret(
                mapping_section,
                "token",
                "token_env",
                f"gitea token for mapping '{section_name}'",
            )
            effective_token = mapping_token or gitea.token_for(organization)
            effective_token_env = mapping_token_env or gitea.token_env_for(organization)
            if not effective_token:
                raise ConfigError(
                    f"No Gitea token configured for organization '{organization}'. "
                    "Set a default token via [gitea] token or token_env, define [gitea:<org>] token/token_env, "
                    "or specify token/token_env in the mapping."
                )
            mappings.append(
                MappingConfig(
                    name=mapping_name,
                    organization=organization,
                    devpi_server=devpi_name,
                    index=index,
                    repositories=repositories,
                    include_archived=include_archived,
                    gitea_token=effective_token,
                    gitea_token_env=effective_token_env,
                )
            )

        if not mappings:
            raise ConfigError("At least one [mapping:<name>] section is required.")

        default_download_dir = Path(tempfile.gettempdir()) / "devpi-gitea-sync"
        poll_interval = _get_int(runtime_section, "poll_interval_seconds", default=300, minimum=60)
        download_dir = _get_path(runtime_section, "download_dir", default_download_dir)
        server_host = _get_str(runtime_section, "server_host", default="0.0.0.0")
        server_port = _get_int(runtime_section, "server_port", default=8080, minimum=1)

        return cls(
            gitea=gitea,
            devpi_servers=devpi_servers,
            mappings=mappings,
            poll_interval_seconds=poll_interval,
            download_dir=download_dir,
            server_host=server_host,
            server_port=server_port,
        )

load(path) classmethod

Read configuration from an INI/CONF file and instantiate a SyncConfig.

Parameters:

Name Type Description Default
path Path

The path to the configuration file.

required

Returns:

Type Description
'SyncConfig'

A SyncConfig instance.

Source code in devpi_gitea_sync/config.py
@classmethod
def load(cls, path: Path) -> "SyncConfig":
    """Read configuration from an INI/CONF file and instantiate a SyncConfig.

    :param path: The path to the configuration file.
    :return: A SyncConfig instance.
    """
    parser = _read_conf(path)
    return cls.from_config_parser(parser)

from_config_parser(parser) classmethod

Build configuration objects from a ConfigParser instance.

Parameters:

Name Type Description Default
parser ConfigParser

A ConfigParser instance.

required

Returns:

Type Description
'SyncConfig'

A SyncConfig instance.

Raises:

Type Description
ConfigError

If the configuration is invalid.

Source code in devpi_gitea_sync/config.py
@classmethod
def from_config_parser(cls, parser: configparser.ConfigParser) -> "SyncConfig":
    """Build configuration objects from a ConfigParser instance.

    :param parser: A ConfigParser instance.
    :return: A SyncConfig instance.
    :raises ConfigError: If the configuration is invalid.
    """
    if not parser.has_section("gitea"):
        raise ConfigError("Missing required [gitea] section.")
    if not parser.has_section("devpi"):
        raise ConfigError("Missing required [devpi] section.")

    gitea_section = parser["gitea"]
    devpi_section = parser["devpi"]
    runtime_section = parser["runtime"] if parser.has_section("runtime") else None

    default_token, default_token_env = _get_secret(
        gitea_section, "token", "token_env", "default gitea token"
    )

    per_org_tokens: dict[str, str] = {}
    per_org_token_env: dict[str, str] = {}
    for section_name in parser.sections():
        if section_name.lower().startswith("gitea:"):
            alias = section_name.split(":", 1)[1].strip()
            if not alias:
                raise ConfigError(
                    f"Section '{section_name}' must specify a Gitea organization or user name."
                )
            secret, env_name = _get_secret(
                parser[section_name],
                "token",
                "token_env",
                f"gitea token for '{alias}'",
                required=True,
            )
            per_org_tokens[alias] = secret
            if env_name:
                per_org_token_env[alias] = env_name

    gitea = GiteaConfig(
        url=_require_str_option(gitea_section, "url"),
        verify_ssl=_get_verify_ssl(gitea_section, "verify_ssl"),
        timeout=_get_float(gitea_section, "timeout", default=10.0),
        default_token=default_token,
        default_token_env=default_token_env,
        per_org_tokens=per_org_tokens,
        per_org_token_env=per_org_token_env,
    )

    devpi_servers: dict[str, DevpiServerConfig] = {}
    devpi_servers["default"] = _build_devpi_server("default", devpi_section)
    for section_name in parser.sections():
        if section_name.lower().startswith("devpi:"):
            server_name = section_name.split(":", 1)[1].strip()
            if not server_name:
                raise ConfigError(
                    f"Section '{section_name}' must specify a Devpi server identifier."
                )
            devpi_servers[server_name] = _build_devpi_server(
                server_name, parser[section_name]
            )

    mappings: list[MappingConfig] = []
    for section_name in parser.sections():
        if not section_name.lower().startswith("mapping:"):
            continue
        mapping_section = parser[section_name]
        label = section_name.split(":", 1)[1].strip() if ":" in section_name else ""
        mapping_name = label or section_name
        organization = mapping_section.get("organization", fallback=label).strip()
        if not organization:
            raise ConfigError(
                f"Mapping section '{section_name}' must specify an organization name."
            )
        devpi_name = mapping_section.get("devpi", fallback="default").strip() or "default"
        if devpi_name not in devpi_servers:
            raise ConfigError(
                f"Mapping section '{section_name}' references unknown Devpi server '{devpi_name}'."
            )
        index = _require_str_option(mapping_section, "index")
        repos_value = mapping_section.get("repositories", fallback="")
        repositories = _parse_list(repos_value)
        include_archived = _get_bool(mapping_section, "include_archived", default=False)
        mapping_token, mapping_token_env = _get_secret(
            mapping_section,
            "token",
            "token_env",
            f"gitea token for mapping '{section_name}'",
        )
        effective_token = mapping_token or gitea.token_for(organization)
        effective_token_env = mapping_token_env or gitea.token_env_for(organization)
        if not effective_token:
            raise ConfigError(
                f"No Gitea token configured for organization '{organization}'. "
                "Set a default token via [gitea] token or token_env, define [gitea:<org>] token/token_env, "
                "or specify token/token_env in the mapping."
            )
        mappings.append(
            MappingConfig(
                name=mapping_name,
                organization=organization,
                devpi_server=devpi_name,
                index=index,
                repositories=repositories,
                include_archived=include_archived,
                gitea_token=effective_token,
                gitea_token_env=effective_token_env,
            )
        )

    if not mappings:
        raise ConfigError("At least one [mapping:<name>] section is required.")

    default_download_dir = Path(tempfile.gettempdir()) / "devpi-gitea-sync"
    poll_interval = _get_int(runtime_section, "poll_interval_seconds", default=300, minimum=60)
    download_dir = _get_path(runtime_section, "download_dir", default_download_dir)
    server_host = _get_str(runtime_section, "server_host", default="0.0.0.0")
    server_port = _get_int(runtime_section, "server_port", default=8080, minimum=1)

    return cls(
        gitea=gitea,
        devpi_servers=devpi_servers,
        mappings=mappings,
        poll_interval_seconds=poll_interval,
        download_dir=download_dir,
        server_host=server_host,
        server_port=server_port,
    )

locate_config_file(explicit_path, search_from=None)

Locate the configuration file using an explicit path or default filenames.

Parameters:

Name Type Description Default
explicit_path Path | None

An explicit path to the configuration file.

required
search_from Path | None

The directory to search from.

None

Returns:

Type Description
Path

The path to the configuration file.

Raises:

Type Description
ConfigError

If the configuration file cannot be found.

Source code in devpi_gitea_sync/config.py
def locate_config_file(
    explicit_path: Path | None, search_from: Path | None = None
) -> Path:
    """Locate the configuration file using an explicit path or default filenames.

    :param explicit_path: An explicit path to the configuration file.
    :param search_from: The directory to search from.
    :return: The path to the configuration file.
    :raises ConfigError: If the configuration file cannot be found.
    """
    if explicit_path:
        candidate = explicit_path.expanduser()
        if not candidate.exists():
            raise ConfigError(f"Config file '{candidate}' does not exist.")
        return candidate

    base_dir = (search_from or Path.cwd()).resolve()
    for filename in DEFAULT_CONFIG_FILENAMES:
        candidate = base_dir / filename
        if candidate.exists():
            return candidate

    raise ConfigError(
        f"Unable to find configuration file; looked for {', '.join(DEFAULT_CONFIG_FILENAMES)} in {base_dir}"
    )