Commit 17583c8b authored by Mark Beierl's avatar Mark Beierl
Browse files

Merge branch 'master' into 'master'

Update squid charm

See merge request !105
parents e2197769 9c012576
Pipeline #143 passed with stage
in 1 minute and 34 seconds
#!/bin/bash
URL=`action-get url`
if ! grep -Fxq "http_access allow allowedurls" /etc/squid/squid.conf
then
sed -i '/^# And finally deny all .*/i http_access allow allowedurls\n' /etc/squid/squid.conf
fi
sed -i "/^http_access allow allowedurls.*/i acl allowedurls dstdomain \.$URL" /etc/squid/squid.conf
kill -HUP `cat /var/run/squid.pid`
options:
image:
type: string
description: 'Docker image for squid'
default: 'domfleischmann/squid-python'
port:
type: int
description: 'Port'
description: "Port"
default: 3128
#!/bin/sh
JUJU_DISPATCH_PATH="${JUJU_DISPATCH_PATH:-$0}" PYTHONPATH=lib:venv ./src/charm.py
../src/charm.py
\ No newline at end of file
../dispatch
\ No newline at end of file
../dispatch
\ No newline at end of file
../dispatch
\ No newline at end of file
# Copyright 2020 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The Operator Framework."""
# Import here the bare minimum to break the circular import between modules
from . import charm # NOQA
# Copyright 2019-2020 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pathlib
import typing
import yaml
from ops.framework import Object, EventSource, EventBase, Framework, ObjectEvents
from ops import model
class HookEvent(EventBase):
"""A base class for events that trigger because of a Juju hook firing."""
class ActionEvent(EventBase):
"""A base class for events that trigger when a user asks for an Action to be run.
To read the parameters for the action, see the instance variable `params`.
To respond with the result of the action, call `set_results`. To add progress
messages that are visible as the action is progressing use `log`.
:ivar params: The parameters passed to the action (read by action-get)
"""
def defer(self):
"""Action events are not deferable like other events.
This is because an action runs synchronously and the user is waiting for the result.
"""
raise RuntimeError('cannot defer action events')
def restore(self, snapshot: dict) -> None:
"""Used by the operator framework to record the action.
Not meant to be called directly by Charm code.
"""
env_action_name = os.environ.get('JUJU_ACTION_NAME')
event_action_name = self.handle.kind[:-len('_action')].replace('_', '-')
if event_action_name != env_action_name:
# This could only happen if the dev manually emits the action, or from a bug.
raise RuntimeError('action event kind does not match current action')
# Params are loaded at restore rather than __init__ because
# the model is not available in __init__.
self.params = self.framework.model._backend.action_get()
def set_results(self, results: typing.Mapping) -> None:
"""Report the result of the action.
Args:
results: The result of the action as a Dict
"""
self.framework.model._backend.action_set(results)
def log(self, message: str) -> None:
"""Send a message that a user will see while the action is running.
Args:
message: The message for the user.
"""
self.framework.model._backend.action_log(message)
def fail(self, message: str = '') -> None:
"""Report that this action has failed.
Args:
message: Optional message to record why it has failed.
"""
self.framework.model._backend.action_fail(message)
class InstallEvent(HookEvent):
"""Represents the `install` hook from Juju."""
class StartEvent(HookEvent):
"""Represents the `start` hook from Juju."""
class StopEvent(HookEvent):
"""Represents the `stop` hook from Juju."""
class RemoveEvent(HookEvent):
"""Represents the `remove` hook from Juju. """
class ConfigChangedEvent(HookEvent):
"""Represents the `config-changed` hook from Juju."""
class UpdateStatusEvent(HookEvent):
"""Represents the `update-status` hook from Juju."""
class UpgradeCharmEvent(HookEvent):
"""Represents the `upgrade-charm` hook from Juju.
This will be triggered when a user has run `juju upgrade-charm`. It is run after Juju
has unpacked the upgraded charm code, and so this event will be handled with new code.
"""
class PreSeriesUpgradeEvent(HookEvent):
"""Represents the `pre-series-upgrade` hook from Juju.
This happens when a user has run `juju upgrade-series MACHINE prepare` and
will fire for each unit that is running on the machine, telling them that
the user is preparing to upgrade the Machine's series (eg trusty->bionic).
The charm should take actions to prepare for the upgrade (a database charm
would want to write out a version-independent dump of the database, so that
when a new version of the database is available in a new series, it can be
used.)
Once all units on a machine have run `pre-series-upgrade`, the user will
initiate the steps to actually upgrade the machine (eg `do-release-upgrade`).
When the upgrade has been completed, the :class:`PostSeriesUpgradeEvent` will fire.
"""
class PostSeriesUpgradeEvent(HookEvent):
"""Represents the `post-series-upgrade` hook from Juju.
This is run after the user has done a distribution upgrade (or rolled back
and kept the same series). It is called in response to
`juju upgrade-series MACHINE complete`. Charms are expected to do whatever
steps are necessary to reconfigure their applications for the new series.
"""
class LeaderElectedEvent(HookEvent):
"""Represents the `leader-elected` hook from Juju.
Juju will trigger this when a new lead unit is chosen for a given application.
This represents the leader of the charm information (not necessarily the primary
of a running application). The main utility is that charm authors can know
that only one unit will be a leader at any given time, so they can do
configuration, etc, that would otherwise require coordination between units.
(eg, selecting a password for a new relation)
"""
class LeaderSettingsChangedEvent(HookEvent):
"""Represents the `leader-settings-changed` hook from Juju.
Deprecated. This represents when a lead unit would call `leader-set` to inform
the other units of an application that they have new information to handle.
This has been deprecated in favor of using a Peer relation, and having the
leader set a value in the Application data bag for that peer relation.
(see :class:`RelationChangedEvent`).
"""
class CollectMetricsEvent(HookEvent):
"""Represents the `collect-metrics` hook from Juju.
Note that events firing during a CollectMetricsEvent are currently
sandboxed in how they can interact with Juju. To report metrics
use :meth:`.add_metrics`.
"""
def add_metrics(self, metrics: typing.Mapping, labels: typing.Mapping = None) -> None:
"""Record metrics that have been gathered by the charm for this unit.
Args:
metrics: A collection of {key: float} pairs that contains the
metrics that have been gathered
labels: {key:value} strings that can be applied to the
metrics that are being gathered
"""
self.framework.model._backend.add_metrics(metrics, labels)
class RelationEvent(HookEvent):
"""A base class representing the various relation lifecycle events.
Charmers should not be creating RelationEvents directly. The events will be
generated by the framework from Juju related events. Users can observe them
from the various `CharmBase.on[relation_name].relation_*` events.
Attributes:
relation: The Relation involved in this event
app: The remote application that has triggered this event
unit: The remote unit that has triggered this event. This may be None
if the relation event was triggered as an Application level event
"""
def __init__(self, handle, relation, app=None, unit=None):
super().__init__(handle)
if unit is not None and unit.app != app:
raise RuntimeError(
'cannot create RelationEvent with application {} and unit {}'.format(app, unit))
self.relation = relation
self.app = app
self.unit = unit
def snapshot(self) -> dict:
"""Used by the framework to serialize the event to disk.
Not meant to be called by Charm code.
"""
snapshot = {
'relation_name': self.relation.name,
'relation_id': self.relation.id,
}
if self.app:
snapshot['app_name'] = self.app.name
if self.unit:
snapshot['unit_name'] = self.unit.name
return snapshot
def restore(self, snapshot: dict) -> None:
"""Used by the framework to deserialize the event from disk.
Not meant to be called by Charm code.
"""
self.relation = self.framework.model.get_relation(
snapshot['relation_name'], snapshot['relation_id'])
app_name = snapshot.get('app_name')
if app_name:
self.app = self.framework.model.get_app(app_name)
else:
self.app = None
unit_name = snapshot.get('unit_name')
if unit_name:
self.unit = self.framework.model.get_unit(unit_name)
else:
self.unit = None
class RelationCreatedEvent(RelationEvent):
"""Represents the `relation-created` hook from Juju.
This is triggered when a new relation to another app is added in Juju. This
can occur before units for those applications have started. All existing
relations should be established before start.
"""
class RelationJoinedEvent(RelationEvent):
"""Represents the `relation-joined` hook from Juju.
This is triggered whenever a new unit of a related application joins the relation.
(eg, a unit was added to an existing related app, or a new relation was established
with an application that already had units.)
"""
class RelationChangedEvent(RelationEvent):
"""Represents the `relation-changed` hook from Juju.
This is triggered whenever there is a change to the data bucket for a related
application or unit. Look at `event.relation.data[event.unit/app]` to see the
new information.
"""
class RelationDepartedEvent(RelationEvent):
"""Represents the `relation-departed` hook from Juju.
This is the inverse of the RelationJoinedEvent, representing when a unit
is leaving the relation (the unit is being removed, the app is being removed,
the relation is being removed). It is fired once for each unit that is
going away.
"""
class RelationBrokenEvent(RelationEvent):
"""Represents the `relation-broken` hook from Juju.
If a relation is being removed (`juju remove-relation` or `juju remove-application`),
once all the units have been removed, RelationBrokenEvent will fire to signal
that the relationship has been fully terminated.
"""
class StorageEvent(HookEvent):
"""Base class representing Storage related events."""
class StorageAttachedEvent(StorageEvent):
"""Represents the `storage-attached` hook from Juju.
Called when new storage is available for the charm to use.
"""
class StorageDetachingEvent(StorageEvent):
"""Represents the `storage-detaching` hook from Juju.
Called when storage a charm has been using is going away.
"""
class CharmEvents(ObjectEvents):
"""The events that are generated by Juju in response to the lifecycle of an application."""
install = EventSource(InstallEvent)
start = EventSource(StartEvent)
stop = EventSource(StopEvent)
remove = EventSource(RemoveEvent)
update_status = EventSource(UpdateStatusEvent)
config_changed = EventSource(ConfigChangedEvent)
upgrade_charm = EventSource(UpgradeCharmEvent)
pre_series_upgrade = EventSource(PreSeriesUpgradeEvent)
post_series_upgrade = EventSource(PostSeriesUpgradeEvent)
leader_elected = EventSource(LeaderElectedEvent)
leader_settings_changed = EventSource(LeaderSettingsChangedEvent)
collect_metrics = EventSource(CollectMetricsEvent)
class CharmBase(Object):
"""Base class that represents the Charm overall.
Usually this initialization is done by ops.main.main() rather than Charm authors
directly instantiating a Charm.
Args:
framework: The framework responsible for managing the Model and events for this
Charm.
key: Arbitrary key to distinguish this instance of CharmBase from another.
Generally is None when initialized by the framework. For charms instantiated by
main.main(), this is currenly None.
Attributes:
on: Defines all events that the Charm will fire.
"""
on = CharmEvents()
def __init__(self, framework: Framework, key: typing.Optional[str]):
"""Initialize the Charm with its framework and application name.
"""
super().__init__(framework, key)
for relation_name in self.framework.meta.relations:
relation_name = relation_name.replace('-', '_')
self.on.define_event(relation_name + '_relation_created', RelationCreatedEvent)
self.on.define_event(relation_name + '_relation_joined', RelationJoinedEvent)
self.on.define_event(relation_name + '_relation_changed', RelationChangedEvent)
self.on.define_event(relation_name + '_relation_departed', RelationDepartedEvent)
self.on.define_event(relation_name + '_relation_broken', RelationBrokenEvent)
for storage_name in self.framework.meta.storages:
storage_name = storage_name.replace('-', '_')
self.on.define_event(storage_name + '_storage_attached', StorageAttachedEvent)
self.on.define_event(storage_name + '_storage_detaching', StorageDetachingEvent)
for action_name in self.framework.meta.actions:
action_name = action_name.replace('-', '_')
self.on.define_event(action_name + '_action', ActionEvent)
@property
def app(self) -> model.Application:
"""Application that this unit is part of."""
return self.framework.model.app
@property
def unit(self) -> model.Unit:
"""Unit that this execution is responsible for."""
return self.framework.model.unit
@property
def meta(self) -> 'CharmMeta':
"""CharmMeta of this charm.
"""
return self.framework.meta
@property
def charm_dir(self) -> pathlib.Path:
"""Root directory of the Charm as it is running.
"""
return self.framework.charm_dir
class CharmMeta:
"""Object containing the metadata for the charm.
This is read from metadata.yaml and/or actions.yaml. Generally charms will
define this information, rather than reading it at runtime. This class is
mostly for the framework to understand what the charm has defined.
The maintainers, tags, terms, series, and extra_bindings attributes are all
lists of strings. The requires, provides, peers, relations, storage,
resources, and payloads attributes are all mappings of names to instances
of the respective RelationMeta, StorageMeta, ResourceMeta, or PayloadMeta.
The relations attribute is a convenience accessor which includes all of the
requires, provides, and peers RelationMeta items. If needed, the role of
the relation definition can be obtained from its role attribute.
Attributes:
name: The name of this charm
summary: Short description of what this charm does
description: Long description for this charm
maintainers: A list of strings of the email addresses of the maintainers
of this charm.
tags: Charm store tag metadata for categories associated with this charm.
terms: Charm store terms that should be agreed to before this charm can
be deployed. (Used for things like licensing issues.)
series: The list of supported OS series that this charm can support.
The first entry in the list is the default series that will be
used by deploy if no other series is requested by the user.
subordinate: True/False whether this charm is intended to be used as a
subordinate charm.
min_juju_version: If supplied, indicates this charm needs features that
are not available in older versions of Juju.
requires: A dict of {name: :class:`RelationMeta` } for each 'requires' relation.
provides: A dict of {name: :class:`RelationMeta` } for each 'provides' relation.
peers: A dict of {name: :class:`RelationMeta` } for each 'peer' relation.
relations: A dict containing all :class:`RelationMeta` attributes (merged from other
sections)
storages: A dict of {name: :class:`StorageMeta`} for each defined storage.
resources: A dict of {name: :class:`ResourceMeta`} for each defined resource.
payloads: A dict of {name: :class:`PayloadMeta`} for each defined payload.
extra_bindings: A dict of additional named bindings that a charm can use
for network configuration.
actions: A dict of {name: :class:`ActionMeta`} for actions that the charm has defined.
Args:
raw: a mapping containing the contents of metadata.yaml
actions_raw: a mapping containing the contents of actions.yaml
"""
def __init__(self, raw: dict = {}, actions_raw: dict = {}):
self.name = raw.get('name', '')
self.summary = raw.get('summary', '')
self.description = raw.get('description', '')
self.maintainers = []
if 'maintainer' in raw:
self.maintainers.append(raw['maintainer'])
if 'maintainers' in raw:
self.maintainers.extend(raw['maintainers'])
self.tags = raw.get('tags', [])
self.terms = raw.get('terms', [])
self.series = raw.get('series', [])
self.subordinate = raw.get('subordinate', False)
self.min_juju_version = raw.get('min-juju-version')
self.requires = {name: RelationMeta('requires', name, rel)
for name, rel in raw.get('requires', {}).items()}
self.provides = {name: RelationMeta('provides', name, rel)
for name, rel in raw.get('provides', {}).items()}
# TODO: (jam 2020-05-11) The *role* should be 'peer' even though it comes from the
# 'peers' section.
self.peers = {name: RelationMeta('peers', name, rel)
for name, rel in raw.get('peers', {}).items()}
self.relations = {}
self.relations.update(self.requires)
self.relations.update(self.provides)
self.relations.update(self.peers)
self.storages = {name: StorageMeta(name, storage)
for name, storage in raw.get('storage', {}).items()}
self.resources = {name: ResourceMeta(name, res)
for name, res in raw.get('resources', {}).items()}
self.payloads = {name: PayloadMeta(name, payload)
for name, payload in raw.get('payloads', {}).items()}
self.extra_bindings = raw.get('extra-bindings', {})
self.actions = {name: ActionMeta(name, action) for name, action in actions_raw.items()}
@classmethod
def from_yaml(
cls, metadata: typing.Union[str, typing.TextIO],
actions: typing.Optional[typing.Union[str, typing.TextIO]] = None):
"""Instantiate a CharmMeta from a YAML description of metadata.yaml.
Args:
metadata: A YAML description of charm metadata (name, relations, etc.)
This can be a simple string, or a file-like object. (passed to `yaml.safe_load`).
actions: YAML description of Actions for this charm (eg actions.yaml)
"""
meta = yaml.safe_load(metadata)
raw_actions = {}
if actions is not None:
raw_actions = yaml.safe_load(actions)
return cls(meta, raw_actions)
class RelationMeta:
"""Object containing metadata about a relation definition.
Should not be constructed directly by Charm code. Is gotten from one of
:attr:`CharmMeta.peers`, :attr:`CharmMeta.requires`, :attr:`CharmMeta.provides`,
:attr:`CharmMeta.relations`.
Attributes:
role: This is one of requires/provides/peers
relation_name: Name of this relation from metadata.yaml
interface_name: Optional definition of the interface protocol.
scope: "global" or "container" scope based on how the relation should be used.
"""
def __init__(self, role, relation_name, raw):
self.role = role
self.relation_name = relation_name
self.interface_name = raw['interface']
self.scope = raw.get('scope')
class StorageMeta:
"""Object containing metadata about a storage definition."""
def __init__(self, name, raw):
self.storage_name = name
self.type = raw['type']
self.description = raw.get('description', '')
self.shared = raw.get('shared', False)
self.read_only = raw.get('read-only', False)
self.minimum_size = raw.get('minimum-size')
self.location = raw.get('location')
self.multiple_range = None
if 'multiple' in raw:
range = raw['multiple']['range']
if '-' not in range:
self.multiple_range = (int(range), int(range))
else:
range = range.split('-')
self.multiple_range = (int(range[0]), int(range[1]) if range[1] else None)
class ResourceMeta:
"""Object containing metadata about a resource definition."""
def __init__(self, name, raw):
self.resource_name = name
self.type = raw['type']
self.filename = raw.get('filename', None)
self.description = raw.get('description', '')
class PayloadMeta:
"""Object containing metadata about a payload definition."""
def __init__(self, name, raw):
self.payload_name = name
self.type = raw['type']
class ActionMeta:
"""Object containing metadata about an action's definition."""
def __init__(self, name, raw=None):
raw = raw or {}
self.name = name
self.title = raw.get('title', '')
self.description = raw.get('description', '')
self.parameters = raw.get('params', {}) # {<parameter name>: <JSON Schema definition>}
self.required = raw.get('required', []) # [<parameter name>, ...]
# Copyright 2020 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
class JujuLogHandler(logging.Handler):
"""A handler for sending logs to Juju via juju-log."""
def __init__(self, model_backend, level=logging.DEBUG):
super().__init__(level)
self.model_backend = model_backend
def emit(self, record):
self.model_backend.juju_log(record.levelname, self.format(record))
def setup_root_logging(model_backend, debug=False):
"""Setup python logging to forward messages to juju-log.
By default, logging is set to DEBUG level, and messages will be filtered by Juju.
Charmers can also set their own default log level with::
logging.getLogger().setLevel(logging.INFO)
model_backend -- a ModelBackend to use for juju-log
debug -- if True, write logs to stderr as well as to juju-log.
"""
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
logger.addHandler(JujuLogHandler(model_backend))
if debug:
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
# Copyright 2019 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import weakref
import os
import shutil
import tempfile
import time
import datetime
import re
import ipaddress
import decimal
from abc import ABC, abstractmethod
from collections.abc import Mapping, MutableMapping
from pathlib import Path
from subprocess import run, PIPE, CalledProcessError
class Model:
def __init__(self, unit_name, meta, backend):
self._cache = _ModelCache(backend)
self._backend = backend
self.unit = self.get_unit(unit_name)
self.app = self.unit.app
self.relations = RelationMapping(meta.relations, self.unit, self._backend, self._cache)
self.config = ConfigData(self._backend)
self.resources = Resources(list(meta.resources), self._backend)
self.pod = Pod(self._backend)
self.storages = StorageMapping(list(meta.storages), self._backend)
self._bindings = BindingMapping(self._backend)
def get_unit(self, unit_name):
return self._cache.get(Unit, unit_name)
def get_app(self, app_name):
return self._cache.get(Application, app_name)
def get_relation(self, relation_name, relation_id=None):
"""Get a specific Relation instance.
If relation_id is given, this will return that Relation instance.
If relation_id is not given, this will return the Relation instance if the
relation is established only once or None if it is not established. If this
same relation is established multiple times the error TooManyRelatedAppsError is raised.
"""
return self.relations._get_unique(relation_name, relation_id)
def get_binding(self, binding_key):
"""Get a network space binding.
binding_key -- The relation name or instance to obtain bindings for.
If binding_key is a relation name, the method returns the default binding for that
relation. If a relation instance is provided, the method first looks up a more specific
binding for that specific relation ID, and if none is found falls back to the default
binding for the relation name.
"""
return self._bindings.get(binding_key)
class _ModelCache:
def __init__(self, backend):
self._backend = backend
self._weakrefs = weakref.WeakValueDictionary()
def get(self, entity_type, *args):
key = (entity_type,) + args
entity = self._weakrefs.get(key)
if entity is None:
entity = entity_type(*args, backend=self._backend, cache=self)
self._weakrefs[key] = entity
return entity
class Application:
def __init__(self, name, backend, cache):
self.name = name
self._backend = backend
self._cache = cache
self._is_our_app = self.name == self._backend.app_name
self._status = None
@property
def status(self):
if not self._is_our_app:
return UnknownStatus()
if not self._backend.is_leader():
raise RuntimeError('cannot get application status as a non-leader unit')
if self._status:
return self._status
s = self._backend.status_get(is_app=True)
self._status = StatusBase.from_name(s['status'], s['message'])
return self._status
@status.setter
def status(self, value):
if not isinstance(value, StatusBase):
raise InvalidStatusError(
'invalid value provided for application {} status: {}'.format(self, value)
)
if not self._is_our_app:
raise RuntimeError('cannot to set status for a remote application {}'.format(self))
if not self._backend.is_leader():
raise RuntimeError('cannot set application status as a non-leader unit')
self._backend.status_set(value.name, value.message, is_app=True)
self._status = value
def __repr__(self):
return '<{}.{} {}>'.format(type(self).__module__, type(self).__name__, self.name)
class Unit:
def __init__(self, name, backend, cache):
self.name = name
app_name = name.split('/')[0]
self.app = cache.get(Application, app_name)
self._backend = backend
self._cache = cache
self._is_our_unit = self.name == self._backend.unit_name
self._status = None
@property
def status(self):
if not self._is_our_unit:
return UnknownStatus()
if self._status:
return self._status
s = self._backend.status_get(is_app=False)
self._status = StatusBase.from_name(s['status'], s['message'])
return self._status
@status.setter
def status(self, value):
if not isinstance(value, StatusBase):
raise InvalidStatusError(
'invalid value provided for unit {} status: {}'.format(self, value)
)
if not self._is_our_unit:
raise RuntimeError('cannot set status for a remote unit {}'.format(self))
self._backend.status_set(value.name, value.message, is_app=False)
self._status = value
def __repr__(self):
return '<{}.{} {}>'.format(type(self).__module__, type(self).__name__, self.name)
def is_leader(self):
if self._is_our_unit:
# This value is not cached as it is not guaranteed to persist for the whole duration
# of a hook execution.
return self._backend.is_leader()
else:
raise RuntimeError(
'cannot determine leadership status for remote applications: {}'.format(self)
)
def set_workload_version(self, version):
"""Record the version of the software running as the workload.
This shouldn't be confused with the revision of the charm. This is informative only;
shown in the output of 'juju status'.
"""
if not isinstance(version, str):
raise TypeError("workload version must be a str, not {}: {!r}".format(
type(version).__name__, version))
self._backend.application_version_set(version)
class LazyMapping(Mapping, ABC):
_lazy_data = None
@abstractmethod
def _load(self):
raise NotImplementedError()
@property
def _data(self):
data = self._lazy_data
if data is None:
data = self._lazy_data = self._load()
return data
def _invalidate(self):
self._lazy_data = None
def __contains__(self, key):
return key in self._data
def __len__(self):
return len(self._data)
def __iter__(self):
return iter(self._data)
def __getitem__(self, key):
return self._data[key]
class RelationMapping(Mapping):
"""Map of relation names to lists of Relation instances."""
def __init__(self, relations_meta, our_unit, backend, cache):
self._peers = set()
for name, relation_meta in relations_meta.items():
if relation_meta.role == 'peers':
self._peers.add(name)
self._our_unit = our_unit
self._backend = backend
self._cache = cache
self._data = {relation_name: None for relation_name in relations_meta}
def __contains__(self, key):
return key in self._data
def __len__(self):
return len(self._data)
def __iter__(self):
return iter(self._data)
def __getitem__(self, relation_name):
is_peer = relation_name in self._peers
relation_list = self._data[relation_name]
if relation_list is None:
relation_list = self._data[relation_name] = []
for rid in self._backend.relation_ids(relation_name):
relation = Relation(relation_name, rid, is_peer,
self._our_unit, self._backend, self._cache)
relation_list.append(relation)
return relation_list
def _invalidate(self, relation_name):
self._data[relation_name] = None
def _get_unique(self, relation_name, relation_id=None):
if relation_id is not None:
if not isinstance(relation_id, int):
raise ModelError('relation id {} must be int or None not {}'.format(
relation_id,
type(relation_id).__name__))
for relation in self[relation_name]:
if relation.id == relation_id:
return relation
else:
# The relation may be dead, but it is not forgotten.
is_peer = relation_name in self._peers
return Relation(relation_name, relation_id, is_peer,
self._our_unit, self._backend, self._cache)
num_related = len(self[relation_name])
if num_related == 0:
return None
elif num_related == 1:
return self[relation_name][0]
else:
# TODO: We need something in the framework to catch and gracefully handle
# errors, ideally integrating the error catching with Juju's mechanisms.
raise TooManyRelatedAppsError(relation_name, num_related, 1)
class BindingMapping:
def __init__(self, backend):
self._backend = backend
self._data = {}
def get(self, binding_key):
if isinstance(binding_key, Relation):
binding_name = binding_key.name
relation_id = binding_key.id
elif isinstance(binding_key, str):
binding_name = binding_key
relation_id = None
else:
raise ModelError('binding key must be str or relation instance, not {}'
''.format(type(binding_key).__name__))
binding = self._data.get(binding_key)
if binding is None:
binding = Binding(binding_name, relation_id, self._backend)
self._data[binding_key] = binding
return binding
class Binding:
"""Binding to a network space."""
def __init__(self, name, relation_id, backend):
self.name = name
self._relation_id = relation_id
self._backend = backend
self._network = None
@property
def network(self):
if self._network is None:
try:
self._network = Network(self._backend.network_get(self.name, self._relation_id))
except RelationNotFoundError:
if self._relation_id is None:
raise
# If a relation is dead, we can still get network info associated with an
# endpoint itself
self._network = Network(self._backend.network_get(self.name))
return self._network
class Network:
"""Network space details."""
def __init__(self, network_info):
self.interfaces = []
# Treat multiple addresses on an interface as multiple logical
# interfaces with the same name.
for interface_info in network_info['bind-addresses']:
interface_name = interface_info['interface-name']
for address_info in interface_info['addresses']:
self.interfaces.append(NetworkInterface(interface_name, address_info))
self.ingress_addresses = []
for address in network_info['ingress-addresses']:
self.ingress_addresses.append(ipaddress.ip_address(address))
self.egress_subnets = []
for subnet in network_info['egress-subnets']:
self.egress_subnets.append(ipaddress.ip_network(subnet))
@property
def bind_address(self):
return self.interfaces[0].address
@property
def ingress_address(self):
return self.ingress_addresses[0]
class NetworkInterface:
def __init__(self, name, address_info):
self.name = name
# TODO: expose a hardware address here, see LP: #1864070.
self.address = ipaddress.ip_address(address_info['value'])
cidr = address_info['cidr']
if not cidr:
# The cidr field may be empty, see LP: #1864102.
# In this case, make it a /32 or /128 IP network.
self.subnet = ipaddress.ip_network(address_info['value'])
else:
self.subnet = ipaddress.ip_network(cidr)
# TODO: expose a hostname/canonical name for the address here, see LP: #1864086.
class Relation:
def __init__(self, relation_name, relation_id, is_peer, our_unit, backend, cache):
self.name = relation_name
self.id = relation_id
self.app = None
self.units = set()
# For peer relations, both the remote and the local app are the same.
if is_peer:
self.app = our_unit.app
try:
for unit_name in backend.relation_list(self.id):
unit = cache.get(Unit, unit_name)
self.units.add(unit)
if self.app is None:
self.app = unit.app
except RelationNotFoundError:
# If the relation is dead, just treat it as if it has no remote units.
pass
self.data = RelationData(self, our_unit, backend)
def __repr__(self):
return '<{}.{} {}:{}>'.format(type(self).__module__,
type(self).__name__,
self.name,
self.id)
class RelationData(Mapping):
def __init__(self, relation, our_unit, backend):
self.relation = weakref.proxy(relation)
self._data = {
our_unit: RelationDataContent(self.relation, our_unit, backend),
our_unit.app: RelationDataContent(self.relation, our_unit.app, backend),
}
self._data.update({
unit: RelationDataContent(self.relation, unit, backend)
for unit in self.relation.units})
# The relation might be dead so avoid a None key here.
if self.relation.app is not None:
self._data.update({
self.relation.app: RelationDataContent(self.relation, self.relation.app, backend),
})
def __contains__(self, key):
return key in self._data
def __len__(self):
return len(self._data)
def __iter__(self):
return iter(self._data)
def __getitem__(self, key):
return self._data[key]
# We mix in MutableMapping here to get some convenience implementations, but whether it's actually
# mutable or not is controlled by the flag.
class RelationDataContent(LazyMapping, MutableMapping):
def __init__(self, relation, entity, backend):
self.relation = relation
self._entity = entity
self._backend = backend
self._is_app = isinstance(entity, Application)
def _load(self):
try:
return self._backend.relation_get(self.relation.id, self._entity.name, self._is_app)
except RelationNotFoundError:
# Dead relations tell no tales (and have no data).
return {}
def _is_mutable(self):
if self._is_app:
is_our_app = self._backend.app_name == self._entity.name
if not is_our_app:
return False
# Whether the application data bag is mutable or not depends on
# whether this unit is a leader or not, but this is not guaranteed
# to be always true during the same hook execution.
return self._backend.is_leader()
else:
is_our_unit = self._backend.unit_name == self._entity.name
if is_our_unit:
return True
return False
def __setitem__(self, key, value):
if not self._is_mutable():
raise RelationDataError('cannot set relation data for {}'.format(self._entity.name))
if not isinstance(value, str):
raise RelationDataError('relation data values must be strings')
self._backend.relation_set(self.relation.id, key, value, self._is_app)
# Don't load data unnecessarily if we're only updating.
if self._lazy_data is not None:
if value == '':
# Match the behavior of Juju, which is that setting the value to an
# empty string will remove the key entirely from the relation data.
del self._data[key]
else:
self._data[key] = value
def __delitem__(self, key):
# Match the behavior of Juju, which is that setting the value to an empty
# string will remove the key entirely from the relation data.
self.__setitem__(key, '')
class ConfigData(LazyMapping):
def __init__(self, backend):
self._backend = backend
def _load(self):
return self._backend.config_get()
class StatusBase:
"""Status values specific to applications and units."""
_statuses = {}
def __init__(self, message):
self.message = message
def __new__(cls, *args, **kwargs):
if cls is StatusBase:
raise TypeError("cannot instantiate a base class")
cls._statuses[cls.name] = cls
return super().__new__(cls)
@classmethod
def from_name(cls, name, message):
return cls._statuses[name](message)
class ActiveStatus(StatusBase):
"""The unit is ready.
The unit believes it is correctly offering all the services it has been asked to offer.
"""
name = 'active'
def __init__(self, message=None):
super().__init__(message or '')
class BlockedStatus(StatusBase):
"""The unit requires manual intervention.
An operator has to manually intervene to unblock the unit and let it proceed.
"""
name = 'blocked'
class MaintenanceStatus(StatusBase):
"""The unit is performing maintenance tasks.
The unit is not yet providing services, but is actively doing work in preparation
for providing those services. This is a "spinning" state, not an error state. It
reflects activity on the unit itself, not on peers or related units.
"""
name = 'maintenance'
class UnknownStatus(StatusBase):
"""The unit status is unknown.
A unit-agent has finished calling install, config-changed and start, but the
charm has not called status-set yet.
"""
name = 'unknown'
def __init__(self):
# Unknown status cannot be set and does not have a message associated with it.
super().__init__('')
class WaitingStatus(StatusBase):
"""A unit is unable to progress.
The unit is unable to progress to an active state because an application to which
it is related is not running.
"""
name = 'waiting'
class Resources:
"""Object representing resources for the charm.
"""
def __init__(self, names, backend):
self._backend = backend
self._paths = {name: None for name in names}
def fetch(self, name):
"""Fetch the resource from the controller or store.
If successfully fetched, this returns a Path object to where the resource is stored
on disk, otherwise it raises a ModelError.
"""
if name not in self._paths:
raise RuntimeError('invalid resource name: {}'.format(name))
if self._paths[name] is None:
self._paths[name] = Path(self._backend.resource_get(name))
return self._paths[name]
class Pod:
def __init__(self, backend):
self._backend = backend
def set_spec(self, spec, k8s_resources=None):
if not self._backend.is_leader():
raise ModelError('cannot set a pod spec as this unit is not a leader')
self._backend.pod_spec_set(spec, k8s_resources)
class StorageMapping(Mapping):
"""Map of storage names to lists of Storage instances."""
def __init__(self, storage_names, backend):
self._backend = backend
self._storage_map = {storage_name: None for storage_name in storage_names}
def __contains__(self, key):
return key in self._storage_map
def __len__(self):
return len(self._storage_map)
def __iter__(self):
return iter(self._storage_map)
def __getitem__(self, storage_name):
storage_list = self._storage_map[storage_name]
if storage_list is None:
storage_list = self._storage_map[storage_name] = []
for storage_id in self._backend.storage_list(storage_name):
storage_list.append(Storage(storage_name, storage_id, self._backend))
return storage_list
def request(self, storage_name, count=1):
"""Requests new storage instances of a given name.
Uses storage-add tool to request additional storage. Juju will notify the unit
via <storage-name>-storage-attached events when it becomes available.
"""
if storage_name not in self._storage_map:
raise ModelError(('cannot add storage {!r}:'
' it is not present in the charm metadata').format(storage_name))
self._backend.storage_add(storage_name, count)
class Storage:
def __init__(self, storage_name, storage_id, backend):
self.name = storage_name
self.id = storage_id
self._backend = backend
self._location = None
@property
def location(self):
if self._location is None:
raw = self._backend.storage_get('{}/{}'.format(self.name, self.id), "location")
self._location = Path(raw)
return self._location
class ModelError(Exception):
pass
class TooManyRelatedAppsError(ModelError):
def __init__(self, relation_name, num_related, max_supported):
super().__init__('Too many remote applications on {} ({} > {})'.format(
relation_name, num_related, max_supported))
self.relation_name = relation_name
self.num_related = num_related
self.max_supported = max_supported
class RelationDataError(ModelError):
pass
class RelationNotFoundError(ModelError):
pass
class InvalidStatusError(ModelError):
pass
class ModelBackend:
LEASE_RENEWAL_PERIOD = datetime.timedelta(seconds=30)
def __init__(self):
self.unit_name = os.environ['JUJU_UNIT_NAME']
self.app_name = self.unit_name.split('/')[0]
self._is_leader = None
self._leader_check_time = None
def _run(self, *args, return_output=False, use_json=False):
kwargs = dict(stdout=PIPE, stderr=PIPE)
if use_json:
args += ('--format=json',)
try:
result = run(args, check=True, **kwargs)
except CalledProcessError as e:
raise ModelError(e.stderr)
if return_output:
if result.stdout is None:
return ''
else:
text = result.stdout.decode('utf8')
if use_json:
return json.loads(text)
else:
return text
def relation_ids(self, relation_name):
relation_ids = self._run('relation-ids', relation_name, return_output=True, use_json=True)
return [int(relation_id.split(':')[-1]) for relation_id in relation_ids]
def relation_list(self, relation_id):
try:
return self._run('relation-list', '-r', str(relation_id),
return_output=True, use_json=True)
except ModelError as e:
if 'relation not found' in str(e):
raise RelationNotFoundError() from e
raise
def relation_get(self, relation_id, member_name, is_app):
if not isinstance(is_app, bool):
raise TypeError('is_app parameter to relation_get must be a boolean')
try:
return self._run('relation-get', '-r', str(relation_id),
'-', member_name, '--app={}'.format(is_app),
return_output=True, use_json=True)
except ModelError as e:
if 'relation not found' in str(e):
raise RelationNotFoundError() from e
raise
def relation_set(self, relation_id, key, value, is_app):
if not isinstance(is_app, bool):
raise TypeError('is_app parameter to relation_set must be a boolean')
try:
return self._run('relation-set', '-r', str(relation_id),
'{}={}'.format(key, value), '--app={}'.format(is_app))
except ModelError as e:
if 'relation not found' in str(e):
raise RelationNotFoundError() from e
raise
def config_get(self):
return self._run('config-get', return_output=True, use_json=True)
def is_leader(self):
"""Obtain the current leadership status for the unit the charm code is executing on.
The value is cached for the duration of a lease which is 30s in Juju.
"""
now = time.monotonic()
if self._leader_check_time is None:
check = True
else:
time_since_check = datetime.timedelta(seconds=now - self._leader_check_time)
check = (time_since_check > self.LEASE_RENEWAL_PERIOD or self._is_leader is None)
if check:
# Current time MUST be saved before running is-leader to ensure the cache
# is only used inside the window that is-leader itself asserts.
self._leader_check_time = now
self._is_leader = self._run('is-leader', return_output=True, use_json=True)
return self._is_leader
def resource_get(self, resource_name):
return self._run('resource-get', resource_name, return_output=True).strip()
def pod_spec_set(self, spec, k8s_resources):
tmpdir = Path(tempfile.mkdtemp('-pod-spec-set'))
try:
spec_path = tmpdir / 'spec.json'
spec_path.write_text(json.dumps(spec))
args = ['--file', str(spec_path)]
if k8s_resources:
k8s_res_path = tmpdir / 'k8s-resources.json'
k8s_res_path.write_text(json.dumps(k8s_resources))
args.extend(['--k8s-resources', str(k8s_res_path)])
self._run('pod-spec-set', *args)
finally:
shutil.rmtree(str(tmpdir))
def status_get(self, *, is_app=False):
"""Get a status of a unit or an application.
app -- A boolean indicating whether the status should be retrieved for a unit
or an application.
"""
return self._run('status-get', '--include-data', '--application={}'.format(is_app))
def status_set(self, status, message='', *, is_app=False):
"""Set a status of a unit or an application.
app -- A boolean indicating whether the status should be set for a unit or an
application.
"""
if not isinstance(is_app, bool):
raise TypeError('is_app parameter must be boolean')
return self._run('status-set', '--application={}'.format(is_app), status, message)
def storage_list(self, name):
return [int(s.split('/')[1]) for s in self._run('storage-list', name,
return_output=True, use_json=True)]
def storage_get(self, storage_name_id, attribute):
return self._run('storage-get', '-s', storage_name_id, attribute,
return_output=True, use_json=True)
def storage_add(self, name, count=1):
if not isinstance(count, int) or isinstance(count, bool):
raise TypeError('storage count must be integer, got: {} ({})'.format(count,
type(count)))
self._run('storage-add', '{}={}'.format(name, count))
def action_get(self):
return self._run('action-get', return_output=True, use_json=True)
def action_set(self, results):
self._run('action-set', *["{}={}".format(k, v) for k, v in results.items()])
def action_log(self, message):
self._run('action-log', message)
def action_fail(self, message=''):
self._run('action-fail', message)
def application_version_set(self, version):
self._run('application-version-set', '--', version)
def juju_log(self, level, message):
self._run('juju-log', '--log-level', level, message)
def network_get(self, binding_name, relation_id=None):
"""Return network info provided by network-get for a given binding.
binding_name -- A name of a binding (relation name or extra-binding name).
relation_id -- An optional relation id to get network info for.
"""
cmd = ['network-get', binding_name]
if relation_id is not None:
cmd.extend(['-r', str(relation_id)])
try:
return self._run(*cmd, return_output=True, use_json=True)
except ModelError as e:
if 'relation not found' in str(e):
raise RelationNotFoundError() from e
raise
def add_metrics(self, metrics, labels=None):
cmd = ['add-metric']
if labels:
label_args = []
for k, v in labels.items():
_ModelBackendValidator.validate_metric_label(k)
_ModelBackendValidator.validate_label_value(k, v)
label_args.append('{}={}'.format(k, v))
cmd.extend(['--labels', ','.join(label_args)])
metric_args = []
for k, v in metrics.items():
_ModelBackendValidator.validate_metric_key(k)
metric_value = _ModelBackendValidator.format_metric_value(v)
metric_args.append('{}={}'.format(k, metric_value))
cmd.extend(metric_args)
self._run(*cmd)
class _ModelBackendValidator:
"""Provides facilities for validating inputs and formatting them for model backends."""
METRIC_KEY_REGEX = re.compile(r'^[a-zA-Z](?:[a-zA-Z0-9-_]*[a-zA-Z0-9])?$')
@classmethod
def validate_metric_key(cls, key):
if cls.METRIC_KEY_REGEX.match(key) is None:
raise ModelError(
'invalid metric key {!r}: must match {}'.format(
key, cls.METRIC_KEY_REGEX.pattern))
@classmethod
def validate_metric_label(cls, label_name):
if cls.METRIC_KEY_REGEX.match(label_name) is None:
raise ModelError(
'invalid metric label name {!r}: must match {}'.format(
label_name, cls.METRIC_KEY_REGEX.pattern))
@classmethod
def format_metric_value(cls, value):
try:
decimal_value = decimal.Decimal.from_float(value)
except TypeError as e:
e2 = ModelError('invalid metric value {!r} provided:'
' must be a positive finite float'.format(value))
raise e2 from e
if decimal_value.is_nan() or decimal_value.is_infinite() or decimal_value < 0:
raise ModelError('invalid metric value {!r} provided:'
' must be a positive finite float'.format(value))
return str(decimal_value)
@classmethod
def validate_label_value(cls, label, value):
# Label values cannot be empty, contain commas or equal signs as those are
# used by add-metric as separators.
if not value:
raise ModelError(
'metric label {} has an empty value, which is not allowed'.format(label))
v = str(value)
if re.search('[,=]', v) is not None:
raise ModelError(
'metric label values must not contain "," or "=": {}={!r}'.format(label, value))
......@@ -14,10 +14,11 @@ deployment:
type: stateful
service: loadbalancer
storage:
docker:
type: filesystem
location: /srv/docker/squid
spool:
type: filesystem
location: /var/spool/squid
resources:
image:
type: oci-image
description: OSM docker image for LCM
upstream-source: "domfleischmann/squid-python"
\ No newline at end of file
version: 2 # required
formats: [] # i.e. no extra formats (for now)
python:
version: "3.5"
install:
- requirements: docs/requirements.txt
- requirements: requirements.txt
dist: bionic
language: python
arch:
- amd64
- arm64
python:
- "3.5"
- "3.6"
- "3.7"
- "3.8"
matrix:
include:
- os: osx
language: generic
install:
- pip3 install pyyaml autopep8 flake8
script:
- ./run_tests
# The Operator Framework
The Operator Framework provides a simple, lightweight, and powerful way of encapsulating operational experience in code.
The framework will help you to:
* model the integration of your services
* manage the lifecycle of your application
* create reusable and scalable components
* keep your code simple and readable
## Getting Started
The following overall structure for your charm directory is recommended:
```
.
├── config.yaml
├── metadata.yaml
├── mod/
├── lib/
│ └── ops -> ../mod/operator/ops
├── src/
│ └── charm.py
└── hooks/
├── install -> ../src/charm.py
└── start -> ../src/charm.py # for k8s charms per below
```
The `mod/` directory should contain the operator framework dependency as a git
submodule:
```
git submodule add https://github.com/canonical/operator mod/operator
```
Then symlink from the git submodule for the operator framework into the `lib/`
directory of your charm so it can be imported at run time:
```
ln -s ../mod/operator/ops lib/ops
```
Other dependencies included as git submodules can be added in the `mod/`
directory and symlinked into `lib/` as well.
You can sync subsequent changes from the framework and other submodule
dependencies by running:
```
git submodule update
```
Those cloning and checking out the source for your charm for the first time
will need to run:
```
git submodule update --init
```
Your `src/charm.py` is the entry point for your charm logic. It should be set
to executable and use Python 3.6 or greater. At a minimum, it needs to define
a subclass of `CharmBase` and pass that into the framework's `main` function:
```python
import sys
sys.path.append('lib') # noqa: E402
from ops.charm import CharmBase
from ops.main import main
class MyCharm(CharmBase):
pass
if __name__ == "__main__":
main(MyCharm)
```
This charm does nothing, because the `MyCharm` class passed to the operator
framework's `main` function is empty. Functionality can be added to the charm
by instructing it to observe particular Juju events when the `MyCharm` object
is initialized. For example,
```python
class MyCharm(CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.start, self.on_start)
def on_start(self, event):
# Handle the start event here.
```
Every standard event in Juju may be observed that way, and you can also easily
define your own events in your custom types.
> The second argument to `observe` can be either the handler as a bound
> method, or the observer itself if the handler is a method of the observer
> that follows the conventional naming pattern. That is, in this case, we
> could have called just `self.framework.observe(self.on.start, self)`.
The `hooks/` directory must contain a symlink to your `src/charm.py` entry
point so that Juju can call it. You only need to set up the `hooks/install` link
(`hooks/start` for K8s charms, until [lp#1854635](https://bugs.launchpad.net/juju/+bug/1854635)
is resolved), and the framework will create all others at runtime.
Once your charm is ready, upload it to the charm store and deploy it as
normal with:
```
# Replace ${CHARM} with the name of the charm.
charm push . cs:~${USER}/${CHARM}
# Replace ${VERSION} with the version created by `charm push`.
charm release cs:~${USER}/${CHARM}-${VERSION}
charm grant cs:~${USER}/${CHARM}-${VERSION} everyone
# And now deploy your charm.
juju deploy cs:~${USER}/$CHARM
```
Alternatively, to deploy directly from local disk, run:
```
juju deploy .
```
# Operator Framework development
If you want to work in the framework *itself* you will need the following depenencies installed in your system:
- Python >= 3.5
- PyYAML
- autopep8
- flake8
Then you can try `./run_tests`, it should all go green.
#!/bin/sh
set -e
flavour=html
if [ "$1" ]; then
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
flavour=help
else
flavour="$1"
fi
shift
fi
cd docs
sphinx-build -M "$flavour" . _build "$@"
# Configuration file for the Sphinx documentation builder.
#
# For a full list of options see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
# -- Project information -----------------------------------------------------
project = 'The Operator Framework'
copyright = '2019-2020, Canonical Ltd.'
author = 'Canonical Ltd'
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.napoleon',
'sphinx.ext.todo',
'sphinx.ext.viewcode',
]
# The document name of the “master” document, that is, the document
# that contains the root toctree directive.
master_doc = 'index'
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
# html_theme = 'nature' # 'alabaster'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# -- Options for sphinx.ext.todo ---------------------------------------------
# If this is True, todo and todolist produce output, else they
# produce nothing. The default is False.
todo_include_todos = False
# -- Options for sphinx.ext.autodoc ------------------------------------------
# This value controls how to represents typehints. The setting takes the
# following values:
# 'signature' – Show typehints as its signature (default)
# 'description' – Show typehints as content of function or method
# 'none' – Do not show typehints
autodoc_typehints = 'description'
# This value selects what content will be inserted into the main body of an
# autoclass directive. The possible values are:
# 'class' - Only the class’ docstring is inserted. This is the
# default. You can still document __init__ as a separate method
# using automethod or the members option to autoclass.
# 'both' - Both the class’ and the __init__ method’s docstring are
# concatenated and inserted.
# 'init' - Only the __init__ method’s docstring is inserted.
autoclass_content = 'both'
autodoc_default_options = {
'members': None, # None here means "yes"
'undoc-members': None,
'show-inheritance': None,
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment