diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81fb0a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,86 @@ +# ignore test results +tests/test/* + +# mkdocs +site + +# development environment +.env +test_cli.sh + +# toy/experimental files +*.csv +*.tsv +*.pkl + +# ignore eggs +.eggs/ + + +# generic ignore list: +*.lst + +# Compiled source +*.com +*.class +*.dll +*.exe +*.o +*.so +*.pyc + +# Packages +# it's better to unpack these files and commit the raw source +# git has its own built in compression methods +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Logs and databases +*.log +*.sql +*.sqlite + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Gedit temporary files +*~ + +# libreoffice lock files: +.~lock* + +# Default-named test output +microtest/ +open_pipelines/ + +# IDE-specific items +.idea/ + +# pytest-related +.cache/ +.coverage +.pytest_cache/ + +# Reserved files for comparison +*RESERVE* + +# Build-related stuff +dist/ +*.egg-info/ + + +#ipynm checkpoints +*ipynb_checkpoints* +*.egg-info* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..76e1bf2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +repos: + # miscellaneous + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: check-yaml + - id: check-json + - id: end-of-file-fixer + - id: trailing-whitespace + # Python + # - repo: https://github.com/PyCQA/flake8 + # rev: 4.0.1 + # hooks: + # - id: flake8 + # args: [ + # "--ignore", + # "E203,W503,E741", # ignore these rules, to comply with black. See: https://black.readthedocs.io/en/stable/faq.html#why-are-flake8-s-e203-and-w503-violated + # "--max-line-length", + # "88", # 90-ish is a good choice. See: https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#line-length + # ] + - repo: https://github.com/PyCQA/isort + rev: 5.10.1 + hooks: + - id: isort + args: ["--profile", "black"] + - repo: https://github.com/psf/black + rev: 21.12b0 + hooks: + - id: black diff --git a/cloudwatcher/__init__.py b/cloudwatcher/__init__.py new file mode 100644 index 0000000..37c7430 --- /dev/null +++ b/cloudwatcher/__init__.py @@ -0,0 +1,28 @@ +from ._version import __version__ +from .cloudwatcher import CloudWatcher +from .logwatcher import LogWatcher +from .metric_handlers import ( + ResponseLogger, + ResponseSaver, + TimedMetricCsvSaver, + TimedMetricJsonSaver, + TimedMetricLogger, + TimedMetricPlotter, + TimedMetricSummarizer, +) +from .metricwatcher import MetricWatcher + +__classes__ = [ + "CloudWatcher", + "MetricWatcher", + "LogWatcher", + "ResponseLogger", + "ResponseSaver", + "TimedMetricCsvSaver", + "TimedMetricJsonSaver", + "TimedMetricLogger", + "TimedMetricPlotter", + "TimedMetricSummarizer", +] + +__all__ = __classes__ + ["__version__"] diff --git a/cloudwatcher/__main__.py b/cloudwatcher/__main__.py new file mode 100644 index 0000000..142a8e2 --- /dev/null +++ b/cloudwatcher/__main__.py @@ -0,0 +1,14 @@ +import logging +import sys + +from .cli import main + +if __name__ == "__main__": + + _LOGGER = logging.getLogger(__name__) + + try: + sys.exit(main()) + except KeyboardInterrupt: + _LOGGER.error("Program canceled by user!") + sys.exit(1) diff --git a/cloudwatcher/_version.py b/cloudwatcher/_version.py new file mode 100644 index 0000000..3b93d0b --- /dev/null +++ b/cloudwatcher/_version.py @@ -0,0 +1 @@ +__version__ = "0.0.2" diff --git a/cloudwatcher/argparser.py b/cloudwatcher/argparser.py new file mode 100644 index 0000000..13ffa2d --- /dev/null +++ b/cloudwatcher/argparser.py @@ -0,0 +1,199 @@ +""" Computing configuration representation """ + +import argparse + +from ._version import __version__ +from .const import LOG_CMD, METRIC_CMD, SUBPARSER_MESSAGES + + +class _VersionInHelpParser(argparse.ArgumentParser): + def format_help(self): + """Add version information to help text.""" + return ( + "version: {}\n".format(__version__) + + super(_VersionInHelpParser, self).format_help() + ) + + +def build_argparser(): + """Build argument parser""" + + # args defaults + metric_name = "mem_used" + id = "memory_usage" + days = 1 + hours = 0 + minutes = 0 + unit = "Bytes" + stat = "Maximum" + period = 60 + dir = "./" + region = "us-east-1" + + # add argument parser + parser = _VersionInHelpParser( + description="CloudWatch metrics explorer. In order to use the tool a CloudWatchAgent process must be running on the EC2 instance to be monitored." + ) + + subparsers = parser.add_subparsers(dest="command") + + def add_subparser(cmd, msg, subparsers): + return subparsers.add_parser( + cmd, + description=msg, + help=msg, + formatter_class=lambda prog: argparse.HelpFormatter( + prog, max_help_position=40, width=90 + ), + ) + + sps = {} + for cmd, desc in SUBPARSER_MESSAGES.items(): + sps[cmd] = add_subparser(cmd, desc, subparsers) + sps[cmd].add_argument( + "--version", + help="Print version and exit", + action="version", + version="%(prog)s {}".format(__version__), + ) + sps[cmd].add_argument( + "--debug", + help="Whether debug mode should be launched (default: %(default)s)", + action="store_true", + ) + sps[cmd].add_argument( + "--aws-region", + help="Region to monitor the metrics within. (default: %(default)s)", + type=str, + required=False, + default=region, + ) + sps[cmd].add_argument( + "--aws-access-key-id", + help="AWS Access Key ID to use for authentication", + type=str, + required=False, + ) + sps[cmd].add_argument( + "--aws-secret-access-key", + help="AWS Secret Access Key to use for authentication", + type=str, + required=False, + ) + sps[cmd].add_argument( + "--aws-session-token", + help="AWS Session Token to use for authentication", + type=str, + required=False, + ) + sps[cmd].add_argument( + "--save", + help="Whether to save the results to files in the selected directory (default: %(default)s)", + action="store_true", + ) + sps[cmd].add_argument( + "-d", + "--dir", + help="Directory to store the results in. Used with `--save` (default: %(default)s)", + default=dir, + ) + + sps[METRIC_CMD].add_argument( + "-q", + "--query-json", + help="Path to a query JSON file. This is not implemented yet.", + required=False, + default=None, + ) + sps[METRIC_CMD].add_argument( + "-i", + "--id", + help="The unique identifier to assign to the metric data. Must be of the form '^[a-z][a-zA-Z0-9_]*$'.", + default=id, + ) + sps[METRIC_CMD].add_argument( + "-m", + "--metric", + help="Name of the metric collected by CloudWatchAgent (default: %(default)s)", + default=metric_name, + ) + sps[METRIC_CMD].add_argument( + "-iid", + "--instance-id", + help="Instance ID, needs to follow 'i-' format", + required=True, + type=str, + ) + sps[METRIC_CMD].add_argument( + "--uptime", + help="Display the uptime of the instance in seconds. It's either calculated precisely if the instance is still running, or estimated based on the reported metrics.", + action="store_true", + ) + sps[METRIC_CMD].add_argument( + "--days", + help="How many days to subtract from the current date to determine the metric collection start time (default: %(default)s). Uptime will be estimated in the timespan starting at least 15 ago.", + default=days, + type=int, + ) + sps[METRIC_CMD].add_argument( + "-hr", + "--hours", + help="How many hours to subtract from the current time to determine the metric collection start time (default: %(default)s). Uptime will be estimated in the timespan starting at least 15 ago.", + default=hours, + type=int, + ) + sps[METRIC_CMD].add_argument( + "-mi", + "--minutes", + help="How many minutes to subtract from the current time to determine the metric collection start time (default: %(default)s). Uptime will be estimated in the timespan starting at least 15 ago.", + default=minutes, + type=int, + ) + sps[METRIC_CMD].add_argument( + "-u", + "--unit", + help=""" + If you omit Unit then all data that was collected with any unit is returned. + If you specify a unit, it acts as a filter and returns only data that was + collected with that unit specified. Use 'Bytes' for memory (default: %(default)s) + """, + default=unit, + ) + sps[METRIC_CMD].add_argument( + "-s", + "--stat", + help="The statistic to apply over the time intervals, e.g. 'Maximum' (default: %(default)s)", + default=stat, + ) + sps[METRIC_CMD].add_argument( + "-p", + "--period", + help="The granularity, in seconds, of the returned data points. Choices: 1, 5, 10, 30, 60, or any multiple of 60 (default: %(default)s)", + default=period, + type=int, + ) + sps[METRIC_CMD].add_argument( + "--plot", + help="Whether to plot the metric data (default: %(default)s)", + action="store_true", + ) + sps[METRIC_CMD].add_argument( + "--namespace", + help="Namespace to monitor the metrics within. This value must match the 'Namespace' value in the CloudWatchAgent config (default: %(default)s)", + type=str, + required=True, + ) + sps[LOG_CMD].add_argument( + "-g", + "--log-group-name", + help="The log group name to monitor", + required=True, + ) + sps[LOG_CMD].add_argument( + "-s", + "--log-stream-name", + help="The log stream name to monitor", + required=True, + ) + + return parser diff --git a/cloudwatcher/cli.py b/cloudwatcher/cli.py new file mode 100644 index 0000000..f099916 --- /dev/null +++ b/cloudwatcher/cli.py @@ -0,0 +1,128 @@ +import logging +import os +import sys + +from rich.logging import RichHandler + +from cloudwatcher.const import LOG_CMD, METRIC_CMD +from cloudwatcher.logwatcher import LogWatcher + +from .argparser import build_argparser +from .metricwatcher import MetricWatcher + + +def main(): + """ + Main entry point for the CLI. + """ + parser = build_argparser() + args = parser.parse_args() + + if not args.command: + parser.print_help() + sys.exit(1) + + logging.basicConfig( + level="DEBUG" if args.debug else "INFO", + format="%(message)s", + datefmt="[%X]", + handlers=[RichHandler()], + ) + _LOGGER = logging.getLogger(__name__) + + _LOGGER.debug(f"CLI arguments: {args}") + + if args.command == METRIC_CMD: + if args.query_json is not None: + raise NotImplementedError("Querying via JSON is not yet implemented") + + if not args.instance_id.startswith("i-"): + raise ValueError( + f"Instance id needs to start with 'i-'. Got: {args.instance_id}" + ) + + if not os.path.exists(args.dir): + _LOGGER.info(f"Creating directory: {args.dir}") + os.makedirs(args.dir, exist_ok=True) + + metric_watcher = MetricWatcher( + namespace=args.namespace, + metric_name=args.metric, + metric_id=args.id, + metric_unit=args.unit, + ec2_instance_id=args.instance_id, + aws_access_key_id=args.aws_access_key_id, + aws_secret_access_key=args.aws_secret_access_key, + aws_session_token=args.aws_session_token, + aws_region_name=args.aws_region, + ) + + response = metric_watcher.query_ec2_metrics( + days=args.days, + hours=args.hours, + minutes=args.minutes, + stat=args.stat, + period=args.period, + ) + + metric_watcher.log_response(response=response) + metric_watcher.log_metric(response=response) + + if args.save: + metric_watcher.save_metric_json( + file_path=os.path.join( + args.dir, f"{args.instance_id}_{args.metric}.json" + ), + response=response, + ) + metric_watcher.save_metric_csv( + file_path=os.path.join( + args.dir, f"{args.instance_id}_{args.metric}.csv" + ), + response=response, + ) + metric_watcher.save_response_json( + file_path=os.path.join(args.dir, f"{args.instance_id}_response.json"), + response=response, + ) + + if args.plot: + metric_watcher.save_metric_plot( + file_path=os.path.join( + args.dir, f"{args.instance_id}_{args.metric}.png" + ), + response=response, + ) + + if args.uptime: + try: + seconds_run = metric_watcher.get_ec2_uptime( + days=max( + 15, args.days + ), # metrics with a period of 60 seconds are available for 15 days + hours=args.hours, + minutes=args.minutes, + ) + if seconds_run is not None: + _LOGGER.info(f"Instance uptime is {int(seconds_run)} seconds") + except Exception as e: + _LOGGER.warning(f"Failed to get instance uptime ({e})") + + if args.command == LOG_CMD: + + log_watcher = LogWatcher( + log_group_name=args.log_group_name, + log_stream_name=args.log_stream_name, + aws_access_key_id=args.aws_access_key_id, + aws_secret_access_key=args.aws_secret_access_key, + aws_session_token=args.aws_session_token, + aws_region_name=args.aws_region, + ) + + print(log_watcher.return_formatted_logs()[0]) + if args.save: + log_watcher.save_log_file( + file_path=os.path.join( + args.dir, f"{args.log_grop_name}-{args.log_stream_name}.log" + ) + ) diff --git a/cloudwatcher/cloudwatcher.py b/cloudwatcher/cloudwatcher.py new file mode 100644 index 0000000..3963635 --- /dev/null +++ b/cloudwatcher/cloudwatcher.py @@ -0,0 +1,36 @@ +from typing import Optional + +import boto3 + + +class CloudWatcher: + """ + A base class for CloudWatch managers + """ + + def __init__( + self, + service_name: str, + aws_region_name: Optional[str] = None, + aws_access_key_id: Optional[str] = None, + aws_secret_access_key: Optional[str] = None, + aws_session_token: Optional[str] = None, + ) -> None: + """ + Initialize CloudWatcher + + :param str service_name: The name of the service + :param str region_name: The name of the region. Defaults to 'us-east-1' + :param Optional[str] aws_access_key_id: The AWS access key ID + :param Optional[str] aws_secret_access_key: The AWS secret access key + :param Optional[str] aws_session_token: The AWS session token + """ + self.aws_region_name = aws_region_name or "us-east-1" + self.service_name = service_name + self.client: boto3.Session.client = boto3.client( + service_name=self.service_name, + region_name=self.aws_region_name, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + ) diff --git a/cloudwatcher/const.py b/cloudwatcher/const.py new file mode 100644 index 0000000..2f85d2c --- /dev/null +++ b/cloudwatcher/const.py @@ -0,0 +1,7 @@ +METRIC_CMD = "metric" +LOG_CMD = "log" + +SUBPARSER_MESSAGES = { + METRIC_CMD: "Interact with AWS CloudWatch metrics.", + LOG_CMD: "Interact with AWS CloudWatch logs.", +} diff --git a/cloudwatcher/logwatcher.py b/cloudwatcher/logwatcher.py new file mode 100644 index 0000000..729b652 --- /dev/null +++ b/cloudwatcher/logwatcher.py @@ -0,0 +1,190 @@ +# TODO: query the AWS multiple times if query json provided + +import logging +import re +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from .cloudwatcher import CloudWatcher + +Event = Dict[str, str] + +_LOGGER = logging.getLogger(__name__) + + +class LogWatcher(CloudWatcher): + """ + A class for AWS CloudWatch log events retrieval and parsing + """ + + def __init__( + self, + log_group_name: str, + log_stream_name: str, + start_token: Optional[str] = None, + aws_access_key_id: Optional[str] = None, + aws_secret_access_key: Optional[str] = None, + aws_session_token: Optional[str] = None, + aws_region_name: Optional[str] = None, + ) -> None: + """ + Initialize LogWatcher + + :param str log_group_name: The name of the log group + :param str log_stream_name: The name of the log stream + :param Optional[str] region_name: The name of the region. Defaults to 'us-east-1' + :param Optional[str] start_token: The start token to use for the query + """ + super().__init__( + service_name="logs", + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + aws_region_name=aws_region_name, + ) + self.log_group_name = log_group_name + self.log_stream_name = log_stream_name + self.start_token = start_token + + def __repr__(self) -> str: + """ + Return a string representation of the object + + :return: A string representation of the object + """ + return f"LogWatcher('{self.log_group_name}/{self.log_stream_name}')" + + def check_log_exists(self) -> bool: + """ + Check if the log stream exists + + :return bool: True if the log stream exists, False otherwise + """ + try: + response = self.client.describe_log_streams( + logGroupName=self.log_group_name, + logStreamNamePrefix=self.log_stream_name, + ) + return True if response["logStreams"] else False + except Exception as e: + _LOGGER.error(f"Error checking if log stream exists: {e}") + return False + + def _get_events(self, query_kwargs: Dict[str, Any]) -> List[Event]: + """ + Get events from CloudWatch and update the arguments + for the next query with 'nextForwardToken' + + :param Dict[str, Any] query_kwargs: The query arguments + :return List[Event]: The list of events + """ + response = self.client.get_log_events(**query_kwargs) + query_kwargs.update({"nextToken": response["nextForwardToken"]}) + return response["events"], response["nextForwardToken"] + + def stream_cloudwatch_logs( + self, events_limit: int = 1000, max_retry_attempts: int = 5 + ) -> List[Event]: + """ + A generator that retrieves desired number of log events per iteration + + :param str log_group_name: The name of the log group + :param str log_stream_name: The name of the log stream + :param int events_limit: The number of events to retrieve per iteration + :return List[Event]: The list of log events + """ + query_kwargs = dict( + logGroupName=self.log_group_name, + logStreamName=self.log_stream_name, + limit=events_limit, + startFromHead=True, + ) + if self.start_token: + query_kwargs.update({"nextToken": self.start_token}) + _LOGGER.debug( + f"Retrieving log events from: {self.log_group_name}/{self.log_stream_name}" + ) + events, token = self._get_events(query_kwargs) + yield events, token + while events: + events, token = self._get_events(query_kwargs) + retry_attempts = 0 + while not events and max_retry_attempts > retry_attempts: + events, token = self._get_events(query_kwargs) + retry_attempts += 1 + _LOGGER.debug( + f"Received empty log events list. Retry attempt: {retry_attempts}" + ) + yield events, token + + def stream_formatted_logs( + self, + events_limit: int = 1000, + max_retry_attempts: int = 5, + sep: str = "
", + ) -> Tuple[List[str], str]: + """ + A generator that yields formatted log events + + :param Optional[int] events_limit: The number of events to retrieve per iteration. Defaults to 1000 + :param Optional[int] max_retry_attempts: The number of retry attempts. Defaults to 5 + :param Optional[str] sep: The format string to use for formatting the log event. Defaults to "
" + :return Tuple[List[str], str]: The list of formatted log events and the token to use for the next query + """ + for events, token in self.stream_cloudwatch_logs( + events_limit=events_limit, + max_retry_attempts=max_retry_attempts, + ): + yield sep.join(self.format_logs_events(log_events=events)), token + + def return_formatted_logs( + self, events_limit: int = 1000, max_retry_attempts: int = 5 + ) -> Tuple[str, str]: + """ + A generator that yields formatted log events + + :param Optional[int] events_limit: The number of events to retrieve per iteration. Defaults to 1000 + :param Optional[int] max_retry_attempts: The number of retry attempts. Defaults to 5 + :return Tuple[List[str], str]: formatted log events and the token to use for the next query + """ + formatted_events = "" + for events, token in self.stream_cloudwatch_logs( + events_limit=events_limit, max_retry_attempts=max_retry_attempts + ): + formatted_events += "\n".join(self.format_logs_events(log_events=events)) + return formatted_events, token + + def format_logs_events( + self, + log_events: List[Event], + regex: str = r"^\[\d+-\d+-\d+\s\d+:\d+:\d+(.|,)\d+(\]|\s-\s\w+\])", + fmt_str: str = "[{time} UTC] {message}", + ) -> List[str]: + """ + Format log events + + :param List[Event] log_events: The list of log events + :param str regex: The regex to use for extracting the timestamp + :param str fmt_str: The format string to use for formatting the log event + :return List[str]: The list of formatted log events + """ + + def _datestr(timestamp: int, fmt_str: str = "%d-%m-%Y %H:%M:%S") -> str: + """ + Convert milliseconds after Jan 1, 1970 UTC to a string date repr + Args: + timestamp (int): milliseconds after Jan 1, 1970 UTC + fmt_str (str): format string for the date + Returns: + str: date string + """ + return datetime.fromtimestamp(timestamp / 1000.0).strftime(fmt_str) + + formatted_log_list = [] + for e in log_events: + m = re.search(regex, e["message"]) + msg = e["message"][m.end() :] if m else e["message"] + formatted_log_list.append( + fmt_str.format(time=_datestr(e["timestamp"]), message=msg.strip()) + ) + return formatted_log_list diff --git a/cloudwatcher/metric_handlers.py b/cloudwatcher/metric_handlers.py new file mode 100644 index 0000000..c22070c --- /dev/null +++ b/cloudwatcher/metric_handlers.py @@ -0,0 +1,240 @@ +import csv +import json +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime +from typing import List, Tuple + +import matplotlib.pyplot as plt +import pytz +from rich.console import Console +from rich.table import Table + +_LOGGER = logging.getLogger(__name__) + + +def convert_mem(value: int, force_suffix: str = None) -> Tuple[float, str]: + """ + Convert memory in bytes to the highest possible, or desired memory unit + """ + suffixes = ["B", "KB", "MB", "GB", "TB"] + if force_suffix is not None: + try: + idx = suffixes.index(force_suffix) + except ValueError: + raise ValueError(f"Forced memory unit must me one of: {suffixes}") + else: + return value / float(pow(1024, idx)), force_suffix + suffixIndex = 0 + while value > 1024 and suffixIndex < len(suffixes) - 1: + suffixIndex += 1 + value = value / 1024.0 + return value, suffixes[suffixIndex] + + +@dataclass +class TimedMetric: + """ + Timed metric object + """ + + label: str + timestamps: List[datetime] + values: List[str] + + def __len__(self): + if len(self.timestamps) == len(self.values): + return len(self.values) + raise ValueError("The internal timed metric lengths are not equal") + + +class Handler(ABC): + """ + Abstract class to establish the interface for handling + """ + + @abstractmethod + def __init__(self, response: dict, logger: logging.Logger) -> None: + pass + + @abstractmethod + def __call__(self, target: str) -> None: + pass + + +class ResponseHandler(Handler): + """ + Abstract class to establish the interface for a response handling + """ + + def __init__(self, response: dict) -> None: + self.response = response + + +class TimedMetricHandler(Handler): + """ + Class to establish the interface for a timed metric handling + """ + + def __init__(self, timed_metric: TimedMetric) -> None: + self.timed_metric = timed_metric + + +class ResponseSaver(ResponseHandler): + """ + Save the response to a file + """ + + def __call__(self, target: str) -> None: + with open(target, "w") as f: + json.dump(self.response, f, indent=4, default=str) + _LOGGER.info(f"Saved response to: {target}") + + +class ResponseLogger(ResponseHandler): + """ + Log the response to the console + """ + + def __call__(self, target: str) -> None: + if target is not None: + raise NotImplementedError( + "Logging responses to a file is not yet implemented." + ) + _LOGGER.debug(json.dumps(self.response, indent=4, default=str)) + + +class TimedMetricPlotter(TimedMetricHandler): + def __call__(self, target: str, metric_unit: str) -> None: + """ + Plot the timed metric + """ + values = self.timed_metric.values + if self.timed_metric.label.startswith("mem") and metric_unit == "Bytes": + metric_unit = "GB" + values = [convert_mem(v, force_suffix=metric_unit)[0] for v in values] + plt.plot( + self.timed_metric.timestamps, + values, + linewidth=0.8, + ) + plt.title( + f"{self.timed_metric.label} over time", + loc="right", + fontstyle="italic", + ) + plt.ylabel(f"{self.timed_metric.label} ({metric_unit})") + plt.ticklabel_format(axis="y", style="plain", useOffset=False) + plt.tick_params(left=True, bottom=False, labelleft=True, labelbottom=False) + plt.savefig( + target, + bbox_inches="tight", + pad_inches=0.1, + dpi=300, + format="png", + ) + _LOGGER.info(f"Saved '{self.timed_metric.label}' plot to: {target}") + + +class TimedMetricSummarizer(TimedMetricHandler): + def __call__( + self, + target: str, + metric_unit: str, + summarizer: Tuple[str, callable], + ) -> None: + """ + Summarize the metric + """ + if target is not None: + raise NotImplementedError("Logging to a file is not yet implemented.") + timespan = self.timed_metric.timestamps[0] - self.timed_metric.timestamps[-1] + _LOGGER.info( + f"Retrieved '{self.timed_metric.label}' {len(self.timed_metric.values)} " + f"measurements over {timespan} timespan" + ) + summary = summarizer[1](self.timed_metric.values) + if self.timed_metric.label.startswith("mem") and metric_unit == "Bytes": + mem, metric_unit = convert_mem(summary) + _LOGGER.info( + f"{summarizer[0]} '{self.timed_metric.label}' is " + f"{mem:.2f} {metric_unit} over {timespan} timespan" + ) + else: + _LOGGER.info( + f"{summarizer[0]} '{self.timed_metric.label}' is " + f"{summary} over {timespan} timespan" + ) + + +class TimedMetricLogger(TimedMetricHandler): + def __call__(self, target: str) -> None: + """ + Log the timed metric as a table + """ + if target is not None: + raise NotImplementedError("Logging to a file is not yet implemented.") + table = Table(show_header=True, header_style="bold magenta") + table.add_column(f"Time ({str(pytz.utc)})", style="dim", justify="center") + table.add_column("Value") + values = [ + self.mem_to_str(v) if self.timed_metric.label.startswith("mem") else str(v) + for v in self.timed_metric.values + ] + for i in range(len(self.timed_metric.timestamps)): + table.add_row( + self.timed_metric.timestamps[i].strftime("%H:%M:%S"), values[i] + ) + console = Console() + console.print(table) + + @staticmethod + def mem_to_str(size: int, precision: int = 3) -> str: + """ + Convert bytes to human readable string + + :param int size: size in bytes + :param int precision: number of decimal places + + :return str: human readable string + """ + size, suffix = convert_mem(size) + return "%.*f %s" % (precision, size, suffix) + + +class TimedMetricJsonSaver(TimedMetricHandler): + def __call__(self, target: str) -> None: + """ + Write the object to a json file + """ + with open(target, "w") as f: + json.dump( + { + "Label": self.timed_metric.label, + "Timestamps": self.timed_metric.timestamps, + "Values": self.timed_metric.values, + }, + f, + indent=4, + default=str, + ) + _LOGGER.info(f"Saved '{self.timed_metric.label}' data to: {target}") + + +class TimedMetricCsvSaver(TimedMetricHandler): + def __call__(self, target: str) -> None: + """ + Write the object to a csv file + """ + with open(target, "w", encoding="UTF8", newline="") as f: + writer = csv.writer(f) + + # write the header + writer.writerow(["time", "value"]) + # write the data + for i in range(len(self.timed_metric)): + writer.writerow( + [self.timed_metric.timestamps[i], self.timed_metric.values[i]] + ) + _LOGGER.info(f"Saved '{self.timed_metric.label}' data to: {target}") diff --git a/cloudwatcher/metricwatcher.py b/cloudwatcher/metricwatcher.py new file mode 100644 index 0000000..63c944f --- /dev/null +++ b/cloudwatcher/metricwatcher.py @@ -0,0 +1,343 @@ +import datetime +import logging +from typing import Dict, List, Optional + +import boto3 +import pytz + +from .cloudwatcher import CloudWatcher +from .metric_handlers import ( + ResponseHandler, + ResponseLogger, + ResponseSaver, + TimedMetric, + TimedMetricCsvSaver, + TimedMetricHandler, + TimedMetricJsonSaver, + TimedMetricLogger, + TimedMetricPlotter, + TimedMetricSummarizer, +) + +_LOGGER = logging.getLogger(__name__) + + +class MetricWatcher(CloudWatcher): + """ + A class for AWS CloudWatch metric retrieval and parsing + """ + + def __init__( + self, + namespace: str, + ec2_instance_id: str, + metric_name: str, + metric_id: str, + metric_unit: str, + aws_access_key_id: Optional[str] = None, + aws_secret_access_key: Optional[str] = None, + aws_session_token: Optional[str] = None, + aws_region_name: Optional[str] = None, + ) -> None: + """ + Initialize MetricWatcher + + :param str namespace: The namespace of the metric + :param Optional[str] region_name: The name of the region. Defaults to 'us-east-1' + :param Optional[str] start_token: The start token to use for the query + """ + super().__init__( + service_name="cloudwatch", + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + aws_region_name=aws_region_name, + ) + self.namespace = namespace + self.ec2_instance_id = ec2_instance_id + self.metric_name = metric_name + self.metric_id = metric_id + self.metric_unit = metric_unit + self.ec2_resource = boto3.resource( + service_name="ec2", + region_name=self.aws_region_name, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + ) + + def query_ec2_metrics( + self, + days: int, + hours: int, + minutes: int, + stat: str, + period: int, + ) -> Dict: + """ + Query EC2 metrics + + :param str namespace: namespace to monitor the metrics within. This value must match the 'Nampespace' value in the config + :param int days: how many days to subtract from the current date to determine the metric collection start time + :param int hours: how many hours to subtract from the current time to determine the metric collection start time + :param int minute: how many minutes to subtract from the current time to determine the metric collection start time + :param str stat: stat to use, e.g. 'Maximum' + :param int period: the granularity, in seconds, of the returned data points + return dict: metric statistics response, check the structure of the response [here](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cloudwatch.html#CloudWatch.Client.get_metric_data) + """ + # Create CloudWatch client + now = datetime.datetime.now(pytz.utc) + start_time = now - datetime.timedelta(days=days, hours=hours, minutes=minutes) + + _LOGGER.info( + f"Querying '{self.metric_name}' for EC2 instance '{self.ec2_instance_id}'" + f" from {start_time.strftime('%H:%M:%S')} to {now.strftime('%H:%M:%S')}" + ) + + response = self.client.get_metric_data( + MetricDataQueries=[ + { + "Id": self.metric_id, + "MetricStat": { + "Metric": { + "Namespace": self.namespace, + "MetricName": self.metric_name, + "Dimensions": [ + {"Name": "InstanceId", "Value": self.ec2_instance_id} + ], + }, + "Stat": stat, + "Unit": self.metric_unit, + "Period": period, + }, + }, + ], + StartTime=start_time, + EndTime=now, + ) + resp_status = response["ResponseMetadata"]["HTTPStatusCode"] + if resp_status != 200: + _LOGGER.error(f"Invalid response status code: {resp_status}") + return + _LOGGER.debug(f"Response status code: {resp_status}") + return response + + def get_ec2_uptime( + self, + days: int, + hours: int, + minutes: int, + ) -> int: + """ + Get the runtime of an EC2 instance + + :param logging.logger logger: logger to use. Any object that has 'info', 'warning' and 'error' methods + :param int days: how many days to subtract from the current date to determine the metric collection start time + :param int hours: how many hours to subtract from the current time to determine the metric collection start time + :param int minute: how many minutes to subtract from the current time to determine the metric collection start time + :param str namespace: namespace of the metric, e.g. 'NepheleNamespace' + :param boto3.resource ec2_resource: boto3 resource object to use, optional + + Returns: + int: runtime of the instance in seconds + """ + if not self.is_ec2_running(): + _LOGGER.info( + f"Instance '{self.ec2_instance_id}' is not running anymore. " + f"Uptime will be estimated based on reported metrics in the last {days} days" + ) + instances = self.ec2_resource.instances.filter( + Filters=[{"Name": "instance-id", "Values": [self.ec2_instance_id]}] + ) + # get the latest reported metric + metrics_response = self.query_ec2_metrics( + days=days, + hours=hours, + minutes=minutes, + stat="Maximum", # any stat works + period=60, # most precise period that AWS stores for instances where start time is between 3 hours and 15 days ago + ) + # extract the latest metric report time + timed_metrics = self.timed_metric_factory(metrics_response) + try: + earliest_metric_report_time = timed_metrics[-1].timestamps[0] + latest_metric_report_time = timed_metrics[-1].timestamps[-1] + return ( + earliest_metric_report_time - latest_metric_report_time + ).total_seconds() + except IndexError: + _LOGGER.warning(f"No metric data found for EC2: {self.ec2_instance_id}") + return + instances = self.ec2_resource.instances.filter( + Filters=[{"Name": "instance-id", "Values": [self.ec2_instance_id]}] + ) + for instance in instances: + _LOGGER.info( + f"Instance '{self.ec2_instance_id}' is still running. " + f"Launch time: {instance.launch_time}" + ) + return (datetime.now(pytz.utc) - instance.launch_time).total_seconds() + + def is_ec2_running(self) -> bool: + """ + Check if EC2 instance is running + + :returns bool: True if instance is running, False otherwise + """ + instances = self.ec2_resource.instances.filter( + Filters=[{"Name": "instance-id", "Values": [self.ec2_instance_id]}] + ) + if len(list(instances)) == 0: + return None + if len(list(instances)) > 1: + raise Exception( + f"Multiple EC2 instances matched by ID: {self.ec2_instance_id}" + ) + for instance in instances: + # check the status codes and their meanings: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_InstanceState.html + if instance.state["Code"] <= 16: + return True + return False + + @staticmethod + def timed_metric_factory(response: dict) -> List[TimedMetric]: + """ + Create a collection of TimedMetrics from the CloudWatch client response. + + :param dict response: response from CloudWatch client + :return List[TimedMetric]: list of TimedMetric objects + """ + return [ + TimedMetric( + label=metric_data_result["Label"], + timestamps=metric_data_result["Timestamps"], + values=metric_data_result["Values"], + ) + for metric_data_result in response["MetricDataResults"] + ] + + def _exec_timed_metric_handler( + self, + handler_class: TimedMetricHandler, + response: Optional[Dict] = None, + **kwargs, + ) -> None: + """ + Internal method to execute a TimedMetricHandler + + :param TimedMetricHandler handler_class: TimedMetricHandler class to execute + :param kwargs: keyword arguments to pass to the handler + """ + _LOGGER.debug(f"Executing '{handler_class.__name__}'") + response = response or self.query_ec2_metrics() + timed_metrics = self.timed_metric_factory(response) + for timed_metric in timed_metrics: + if len(timed_metric.values) < 1: + continue + handler = handler_class(timed_metric=timed_metric) + handler(**kwargs) + + def _exec_response_handler( + self, + handler_class: ResponseHandler, + response: Optional[Dict] = None, + **kwargs, + ) -> None: + """ + Internal method to execute a ResponseHandler + + :param ResponseHandler handler_class: ResponseHandler class to execute + """ + _LOGGER.debug(f"Executing '{handler_class.__name__}'") + response = response or self.query_ec2_metrics() + handler = handler_class(response=response) + handler(**kwargs) + + def save_metric_json(self, file_path: str, response: Optional[Dict] = None): + """ + Query and save the metric data to a JSON file + + :param str file_path: path to the file to save the metric data to + :param dict response: response retrieved with `query_ec2_metrics`. + A query is performed if not provided. + """ + self._exec_timed_metric_handler( + TimedMetricJsonSaver, target=file_path, response=response + ) + + def save_metric_csv(self, file_path: str, response: Optional[Dict] = None): + """ + Query and save the metric data to a CSV file + + :param str file_path: path to the file to save the metric data to + :param dict response: response retrieved with `query_ec2_metrics`. + A query is performed if not provided. + """ + self._exec_timed_metric_handler( + TimedMetricCsvSaver, target=file_path, response=response + ) + + def log_metric(self, response: Optional[Dict] = None): + """ + Query and log the metric data + + :param kwargs: keyword arguments to pass to the handler + :param dict response: response retrieved with `query_ec2_metrics`. + A query is performed if not provided. + """ + self._exec_timed_metric_handler( + TimedMetricLogger, + target=None, # TODO: add support for saving to file + response=response, + ) + + def save_metric_plot(self, file_path: str, response: Optional[Dict] = None): + """ + Query and plot the metric data + + :param str file_path: path to the file to plot the metric data to + :param kwargs: keyword arguments to pass to the plotter + :param dict response: response retrieved with `query_ec2_metrics`. + A query is performed if not provided. + """ + self._exec_timed_metric_handler( + TimedMetricPlotter, + target=file_path, + metric_unit=self.metric_unit, + response=response, + ) + + def summarize_metric_json(self, response: Optional[Dict] = None): + """ + Query and summarize the metric data to a JSON file + + :param str file_path: path to the file to save the metric data to + :param dict response: response retrieved with `query_ec2_metrics`. + A query is performed if not provided. + """ + self._exec_timed_metric_handler( + TimedMetricSummarizer, + target=None, # TODO: add support for saving to file + metric_unit=self.metric_unit, + summarizer=("Max", max), + response=response, + ) + + def save_response_json(self, file_path: str, response: Optional[Dict] = None): + """ + Query and save the response data to a JSON file + + :param str file_path: path to the file to save the response data to + :param dict response: response retrieved with `query_ec2_metrics`. + A query is performed if not provided. + """ + self._exec_response_handler(ResponseSaver, target=file_path, response=response) + + def log_response(self, response: Optional[Dict] = None): + """ + Query and log the response + + :param dict response: response retrieved with `query_ec2_metrics`. + A query is performed if not provided. + """ + self._exec_response_handler(ResponseLogger, target=None, response=response) diff --git a/docs/API_documentation.md b/docs/API_documentation.md new file mode 100644 index 0000000..4651297 --- /dev/null +++ b/docs/API_documentation.md @@ -0,0 +1,375 @@ + + + + + +# Package `cloudwatcher` Documentation + +## Class `CloudWatcher` +A base class for CloudWatch managers + + +```python +def __init__(self, service_name: str, region_name: Union[str, NoneType]=None, aws_access_key_id: Union[str, NoneType]=None, aws_secret_access_key: Union[str, NoneType]=None, aws_session_token: Union[str, NoneType]=None, log_debug: Union[bool, NoneType]=False) -> None +``` + +Initialize CloudWatcher +#### Parameters: + +- `service_name` (`str`): The name of the service +- `region_name` (`str`): The name of the region. Defaults to 'us-east-1' +- `aws_access_key_id` (`Optional[str]`): The AWS access key ID +- `aws_secret_access_key` (`Optional[str]`): The AWS secret access key +- `aws_session_token` (`Optional[str]`): The AWS session token +- `log_debug` (`Optional[bool]`): Whether to log debug messages + + + + +## Class `MetricWatcher` +A class for AWS CloudWatch metric retrieval and parsing + + +```python +def __init__(self, namespace: str, ec2_instance_id: str, metric_name: str, metric_id: str, metric_unit: str, aws_access_key_id: Union[str, NoneType]=None, aws_secret_access_key: Union[str, NoneType]=None, aws_session_token: Union[str, NoneType]=None, region_name: Union[str, NoneType]=None) -> None +``` + +Initialize MetricWatcher +#### Parameters: + +- `namespace` (`str`): The namespace of the metric +- `region_name` (`Optional[str]`): The name of the region. Defaults to 'us-east-1' +- `start_token` (`Optional[str]`): The start token to use for the query + + + + +```python +def get_ec2_uptime(self, days: int, hours: int, minutes: int) -> int +``` + +Get the runtime of an EC2 instance +#### Parameters: + +- `logger` (`logging.logger`): logger to use. Any object that has 'info', 'warning' and 'error' methods +- `days` (`int`): how many days to subtract from the current date to determine the metric collection start time +- `hours` (`int`): how many hours to subtract from the current time to determine the metric collection start time +- `minute` (`int`): how many minutes to subtract from the current time to determine the metric collection start time +- `namespace` (`str`): namespace of the metric, e.g. 'NepheleNamespace' +- `ec2_resource` (`boto3.resource`): boto3 resource object to use, optional + + + + +```python +def is_ec2_running(self) -> bool +``` + +Check if EC2 instance is running +#### Returns: + +- `bool`: True if instance is running, False otherwise + + + + +```python +def log_metric(self, response: Union[Dict, NoneType]=None) +``` + +Query and log the metric data +#### Parameters: + +- `kwargs` (``): keyword arguments to pass to the handler +- `response` (`dict`): response retrieved with `query_ec2_metrics`.A query is performed if not provided. + + + + +```python +def log_response(self, response: Union[Dict, NoneType]=None) +``` + +Query and log the response +#### Parameters: + +- `response` (`dict`): response retrieved with `query_ec2_metrics`.A query is performed if not provided. + + + + +```python +def query_ec2_metrics(self, days: int, hours: int, minutes: int, stat: str, period: int) -> Dict +``` + +Query EC2 metrics +#### Parameters: + +- `namespace` (`str`): namespace to monitor the metrics within. This value must match the 'Nampespace' value in the config +- `days` (`int`): how many days to subtract from the current date to determine the metric collection start time +- `hours` (`int`): how many hours to subtract from the current time to determine the metric collection start time +- `minute` (`int`): how many minutes to subtract from the current time to determine the metric collection start time +- `stat` (`str`): stat to use, e.g. 'Maximum' +- `period` (`int`): the granularity, in seconds, of the returned data pointsreturn dict: metric statistics response, check the structure of the response [here](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cloudwatch.html#CloudWatch.Client.get_metric_data) + + + + +```python +def save_metric_csv(self, file_path: str, response: Union[Dict, NoneType]=None) +``` + +Query and save the metric data to a CSV file +#### Parameters: + +- `file_path` (`str`): path to the file to save the metric data to +- `response` (`dict`): response retrieved with `query_ec2_metrics`.A query is performed if not provided. + + + + +```python +def save_metric_json(self, file_path: str, response: Union[Dict, NoneType]=None) +``` + +Query and save the metric data to a JSON file +#### Parameters: + +- `file_path` (`str`): path to the file to save the metric data to +- `response` (`dict`): response retrieved with `query_ec2_metrics`.A query is performed if not provided. + + + + +```python +def save_metric_plot(self, file_path: str, response: Union[Dict, NoneType]=None) +``` + +Query and plot the metric data +#### Parameters: + +- `file_path` (`str`): path to the file to plot the metric data to +- `kwargs` (``): keyword arguments to pass to the plotter +- `response` (`dict`): response retrieved with `query_ec2_metrics`.A query is performed if not provided. + + + + +```python +def save_response_json(self, file_path: str, response: Union[Dict, NoneType]=None) +``` + +Query and save the response data to a JSON file +#### Parameters: + +- `file_path` (`str`): path to the file to save the response data to +- `response` (`dict`): response retrieved with `query_ec2_metrics`.A query is performed if not provided. + + + + +```python +def summarize_metric_json(self, response: Union[Dict, NoneType]=None) +``` + +Query and summarize the metric data to a JSON file +#### Parameters: + +- `file_path` (`str`): path to the file to save the metric data to +- `response` (`dict`): response retrieved with `query_ec2_metrics`.A query is performed if not provided. + + + + +```python +def timed_metric_factory(response: dict) -> List[cloudwatcher.metric_handlers.TimedMetric] +``` + +Create a collection of TimedMetrics from the CloudWatch client response. +#### Parameters: + +- `response` (`dict`): response from CloudWatch client + + +#### Returns: + +- `List[TimedMetric]`: list of TimedMetric objects + + + + +## Class `LogWatcher` +A class for AWS CloudWatch log events retrieval and parsing + + +```python +def __init__(self, log_group_name: str, log_stream_name: str, start_token: Union[str, NoneType]=None, aws_access_key_id: Union[str, NoneType]=None, aws_secret_access_key: Union[str, NoneType]=None, aws_session_token: Union[str, NoneType]=None, region_name: Union[str, NoneType]=None) -> None +``` + +Initialize LogWatcher +#### Parameters: + +- `log_group_name` (`str`): The name of the log group +- `log_stream_name` (`str`): The name of the log stream +- `region_name` (`Optional[str]`): The name of the region. Defaults to 'us-east-1' +- `start_token` (`Optional[str]`): The start token to use for the query + + + + +```python +def check_log_exists(self) -> bool +``` + +Check if the log stream exists +#### Returns: + +- `bool`: True if the log stream exists, False otherwise + + + + +```python +def format_logs_events(self, log_events: List[Dict[str, str]], regex: str='^\\[\\d+-\\d+-\\d+\\s\\d+:\\d+:\\d+(.|,)\\d+(\\]|\\s-\\s\\w+\\])', fmt_str: str='[{time} UTC] {message}') -> List[str] +``` + +Format log events +#### Parameters: + +- `log_events` (`List[Event]`): The list of log events +- `regex` (`str`): The regex to use for extracting the timestamp +- `fmt_str` (`str`): The format string to use for formatting the log event + + +#### Returns: + +- `List[str]`: The list of formatted log events + + + + +```python +def return_formatted_logs(self, events_limit: int=1000, max_retry_attempts: int=5) -> Tuple[str, str] +``` + +A generator that yields formatted log events +#### Parameters: + +- `events_limit` (`Optional[int]`): The number of events to retrieve per iteration. Defaults to 1000 +- `max_retry_attempts` (`Optional[int]`): The number of retry attempts. Defaults to 5 + + +#### Returns: + +- `Tuple[List[str], str]`: formatted log events and the token to use for the next query + + + + +```python +def stream_cloudwatch_logs(self, events_limit: int=1000, max_retry_attempts: int=5) -> List[Dict[str, str]] +``` + +A generator that retrieves desired number of log events per iteration +#### Parameters: + +- `log_group_name` (`str`): The name of the log group +- `log_stream_name` (`str`): The name of the log stream +- `events_limit` (`int`): The number of events to retrieve per iteration + + +#### Returns: + +- `List[Event]`: The list of log events + + + + +```python +def stream_formatted_logs(self, events_limit: int=1000, max_retry_attempts: int=5, sep: str='
') -> Tuple[List[str], str] +``` + +A generator that yields formatted log events +#### Parameters: + +- `events_limit` (`Optional[int]`): The number of events to retrieve per iteration. Defaults to 1000 +- `max_retry_attempts` (`Optional[int]`): The number of retry attempts. Defaults to 5 +- `sep` (`Optional[str]`): The format string to use for formatting the log event. Defaults to "
" + + +#### Returns: + +- `Tuple[List[str], str]`: The list of formatted log events and the token to use for the next query + + + + +## Class `ResponseLogger` +Log the response to the console + + +## Class `ResponseSaver` +Save the response to a file + + +## Class `TimedMetricCsvSaver` +Class to establish the interface for a timed metric handling + + +## Class `TimedMetricJsonSaver` +Class to establish the interface for a timed metric handling + + +## Class `TimedMetricLogger` +Class to establish the interface for a timed metric handling + + +```python +def mem_to_str(size: int, precision: int=3) -> str +``` + +Convert bytes to human readable string +#### Parameters: + +- `size` (`int`): size in bytes +- `precision` (`int`): number of decimal places + + + + +## Class `TimedMetricPlotter` +Class to establish the interface for a timed metric handling + + +## Class `TimedMetricSummarizer` +Class to establish the interface for a timed metric handling + + + + + +*Version Information: `cloudwatcher` v0.0.2, generated by `lucidoc` v0.4.3* diff --git a/docs/EC2_instance_setup.md b/docs/EC2_instance_setup.md new file mode 100644 index 0000000..a43f2fd --- /dev/null +++ b/docs/EC2_instance_setup.md @@ -0,0 +1,37 @@ +# EC2 instance setup + +In order to use the tool a `CloudWatchAgent` process must be running on the EC2 instance to be monitored. + +Please refer to [this page](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/install-CloudWatch-Agent-commandline-fleet.html) to learn how to install and start the `CloudWatchAgent` on an EC2 instance. + +## Configuration + +`CloudWatchAgent` is a powerful tool and can be [configured](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html) to report variety of metrics. The configuration file is located at `/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json` in each EC2 instance and is sourced from a file in the nephele2 repository: `resources/misc_files_for_worker/cloudwatch_agent_cfg.json`. + +Here is an example of the configuration file: + +```json linenums="1" title="cloudwatch_agent_cfg.json" +{ + "agent": { + "metrics_collection_interval": 10 + }, + "metrics": { + "namespace": "ExampleNamespace", + "metrics_collected": { + "mem": { + "measurement": ["mem_used", "mem_cached", "mem_total"], + "metrics_collection_interval": 1 + } + }, + "append_dimensions": { + "InstanceId": "${aws:InstanceId}" + } + } +} +``` + +The above configuration file is used to colect 3 memory metrics every second: + +- `mem_used` +- `mem_cached` +- `mem_total` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..9cba85b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,11 @@ +# cloudwatcher + +`cloudwatcher` is a tool for monitoring [AWS CloudWatch](https://aws.amazon.com/cloudwatch/) metrics and logs in EC2 instances. It can be used both as a command line tool and as a Python library. + +## Quick start + +Here are the steps to use `cloudwatcher` as a command line tool: + +1. [Install `cloudwatcher` with `pip`](installation.md) +2. [Configure target EC2 instance](EC2_instance_setup.md) +3. [Run `cloudwatcher`](usage.md) diff --git a/docs/docs_development.md b/docs/docs_development.md new file mode 100644 index 0000000..78a7678 --- /dev/null +++ b/docs/docs_development.md @@ -0,0 +1,74 @@ +# Documentation development + +The documentation is built from the makrdown files in the `/docs` directory with static site generator [MkDocs](https://mkdocs.org/). + +## With `make` + +!!! info "Makefile" + + The following commands are encoded in a Makefie in this repository: [`makefile`](./makefile). + + +### Serve + +To serve the documentation locally, you can the `serve_docs` target: + +```console +make serve_docs +``` + +The documentation is served on [http://localhost:8000/](http://localhost:8000/). + +### Build + +To build the documentation, you can the `build_docs` target: + +```console +make build_docs +``` + +The documentation is built in the `/site` directory. + +### Deploy + +To deploy the documentation to GitHub Pages, you can the `deploy_docs` target: + +```console +make deploy_docs +``` + +The documentation is deployed to GitHub Pages. + +## By hand + +In order to serve the documentation by hand follow the steps below. + +!!! info "Note" + + The commands need to be run from the root of the repository, unless stated otherwise. + +1. Install the documentation-related dependancies and the Python package itself with [Poetry](https://poetry.org/) + + ```console + poetry install + ``` + +2. Document the API of the package + + ```console + lucidoc cloudwatcher --parse rst --outfile docs/API_documentation.md + ``` + +3. Run the following command in the project root + + ``` + mkdocs serve + ``` + +## Deploying the documentation + +In order to deploy the documentation run the following command: + +``` +mkdocs gh-deploy +``` diff --git a/docs/example_plot.png b/docs/example_plot.png new file mode 100644 index 0000000..de58825 Binary files /dev/null and b/docs/example_plot.png differ diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..bbcf6fe --- /dev/null +++ b/docs/features.md @@ -0,0 +1,126 @@ +# Features + +The tool can generate multiple outputs, depending on the options specified. Generally, they can be classified as: [console output](#console-output) and [output files](#output-files). + +## Console output + +Always generated. + +### Table + +A table printed to the console, which can be used for visual inspection of the metrics collected. + +```console +┏━━━━━━━━━━━━┳━━━━━━━━━━━━┓ +┃ Time (UTC) ┃ Value ┃ +┡━━━━━━━━━━━━╇━━━━━━━━━━━━┩ +│ 19:17:30 │ 469.113 MB │ +│ 19:17:00 │ 1.721 GB │ +│ 19:16:30 │ 6.230 GB │ +│ 19:16:00 │ 7.428 GB │ +│ 19:15:30 │ 2.417 GB │ +│ 19:15:00 │ 2.752 GB │ +│ 19:14:30 │ 2.836 GB │ +│ 19:14:00 │ 1.348 GB │ +│ 19:13:30 │ 772.855 MB │ +└────────────┴────────────┘ +``` + +### Summary message + +A summary message is printed to the console: + +```console +Max 'memory_usage' is 6.23 GB over 1:03:00 timespan +``` + +### Uptime + +A summary message with the uptime of the instance in seconds. It's either calculated precisely if the instance is running, or estimated based on the reported metrics over at least 15 days. If a longer period of time is desired, please use the `--days` option. + +```console +Instance uptime is 72886 seconds +``` + +## Output files + +Generated when `--save` option is used. + +### JSON with reponse + +A JSON file with the response from the AWS API, useful for debugging. + +```json title="{instance_id}_response.json" +{ + "MetricDataResults": [ + { + "Id": "memory_usage", + "Label": "mem_used", + "Values": [ + 492003328.0, 492204032.0, 492040192.0, 450666496.0, 429965312.0 + ], + "Timestamps": [ + "2021-11-12 19:19:00+00:00", + "2021-11-12 19:18:30+00:00", + "2021-11-12 19:18:00+00:00", + "2021-11-12 19:12:00+00:00", + "2021-11-12 19:11:30+00:00" + ], + "StatusCode": "Complete" + } + ], + "Messages": [], + "ResponseMetadata": { + "RequestId": "f603ff23-a3d3-43a7-b3b3-65106445a9ed", + "HTTPStatusCode": 200, + "HTTPHeaders": { + "x-amzn-requestid": "f603ff23-a3d3-43a7-b3b3-65106445a9ed", + "content-type": "text/xml", + "content-length": "1954", + "date": "Fri, 12 Nov 2021 22:05:42 GMT" + }, + "RetryAttempts": 0 + } +} +``` + +### JSON with metric data + +A JSON file with the raw data, which can be used for further analysis. + +```json title="{instance_id}_{metric_label}.json" +{ + "Label": "mem_used", + "Values": [492003328.0, 492204032.0, 492040192.0, 450666496.0, 429965312.0], + "Timestamps": [ + "2021-11-12 19:19:00+00:00", + "2021-11-12 19:18:30+00:00", + "2021-11-12 19:18:00+00:00", + "2021-11-12 19:12:00+00:00", + "2021-11-12 19:11:30+00:00" + ] +} +``` + +### CSV with metric data + + +A CSV file with the raw data, which can be used for further analysis. + +``` title="{instance_id}_{metric_label}.csv" +time,value +2021-11-12 19:19:00+00:00,492003328.0 +2021-11-12 19:18:30+00:00,492204032.0 +2021-11-12 19:18:00+00:00,492040192.0 +2021-11-12 19:12:00+00:00,450666496.0 +2021-11-12 19:11:30+00:00,429965312.0 +``` + +### Plot with metric data + +Generated when `--plot` option used. + +
+ ![Memory usage over time](./example_plot.png) +
{instance_id}_{metric_label}.png
+
diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..acfa636 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,7 @@ +# Installation + +The package is distributed and available on PyPI: [cloudwatcher](https://pypi.org/project/cloudwatcher/). Therefore you can install it using pip: + +```console +pip install cloudwatcher +``` diff --git a/docs/login_credentials.md b/docs/login_credentials.md new file mode 100644 index 0000000..e5243e4 --- /dev/null +++ b/docs/login_credentials.md @@ -0,0 +1,24 @@ +# Login credentials + +The login credentials that determine the AWS account to be used are resolved by [`boto3`](https://boto3.amazonaws.com), the official Python SDK for AWS, during the `boto3.Session.client` initialization. + +## Resolution order + +In general, the credentials are resolved in the following order: + +1. The credentials are read from the environment variables: + - `AWS_ACCESS_KEY_ID` + - `AWS_SECRET_ACCESS_KEY` + + ```console + export AWS_ACCESS_KEY_ID= + export AWS_SECRET_ACCESS_KEY= + ``` + +2. The credentials are read from `[default]` section of the `~/.aws/credentials` file + + ```console + [default] + aws_access_key_id = + aws_secret_access_key = + ``` diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..82147bc --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,88 @@ +The tool is highly configurable and can be used in a variety of ways. Naturally, the [metrics available to be monitored](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/metrics-collected-by-CloudWatch-agent.html) depend on the configuration of the CloudWatchAgent process. + +By default the tool will report the `mem_used` metric starting 24 hours ago until present with granularity/period of 1 minute, expressed in Bytes. + +Please refer to the usage below for more options: + +``` +usage: cloudwatcher [-h] [-jid JOB_ID] [-q QUERY_JSON] [-i ID] [-m METRIC] -iid + INSTANCE_ID [--uptime] [--days DAYS] [-hr HOURS] [-mi MINUTES] + [-u UNIT] [-s STAT] [-p PERIOD] [--save] [--plot] [-d DIR] + [--debug] [--namespace NAMESPACE] + +CloudWatch metrics explorer. In order to use the tool a CloudWatchAgent process must +be running on the EC2 instance to be monitored. + +optional arguments: + -h, --help show this help message and exit + -jid JOB_ID, --job-id JOB_ID + Nephele job ID to use for the EC2 instance ID lookup. This + is not implemented yet. + -q QUERY_JSON, --query-json QUERY_JSON + Path to a query JSON file. This is not implemented yet. + -i ID, --id ID The unique identifier to assign to the metric data. Must be + of the form '^[a-z][a-zA-Z0-9_]*$'. + -iid INSTANCE_ID, --instance-id INSTANCE_ID + Instance ID, needs to follow 'i-' format + --uptime Display the uptime of the instance in seconds. It's either + calculated precisely if the instance is still running, or + estimated based on the reported metrics. + + Options for metric collection start time: + --days DAYS How many days to subtract from the current date to determine + the metric collection start time (default: 1). Uptime will + be estimated in the timespan starting at least 15 ago. + -hr HOURS, --hours HOURS + How many hours to subtract from the current time to + determine the metric collection start time (default: 0). + Uptime will be estimated in the timespan starting at least + 15 ago. + -mi MINUTES, --minutes MINUTES + How many minutes to subtract from the current time to + determine the metric collection start time (default: 0). + Uptime will be estimated in the timespan starting at least + 15 ago. + + Options for metric measurement: + --namespace NAMESPACE + Namespace to monitor the metrics within. This value must + match the 'Namespace' value in the config (default: + NepheleNamespace) + -m METRIC, --metric METRIC + Name of the metric collected by CloudWatchAgent (default: + mem_used) + -u UNIT, --unit UNIT If you omit Unit then all data that was collected with any + unit is returned. If you specify a unit, it acts as a filter + and returns only data that was collected with that unit + specified. Use 'Bytes' for memory (default: Bytes) + -s STAT, --stat STAT The statistic to apply over the time intervals, e.g. + 'Maximum' (default: Maximum) + -p PERIOD, --period PERIOD + The granularity, in seconds, of the returned data points. + Choices: 1, 5, 10, 30, 60, or any multiple of 60 (default: + 60) + Output options: + --save Whether to store the response and metric data in JSON and + CSV files (default: False) + --plot Whether to plot the metric data (default: False) + -d DIR, --dir DIR Directory to store the results in. Used with `--save` + (default: ./) + --debug Whether debug mode should be launched (default: False) +``` + +### Minimal command + +```console +python3.9 cloudwatch.py --instance-id i-024a73d6738255cbd +``` + +### Notes on metrics availabilty + +Amazon CloudWatch retains metric data as follows: + +- Data points with a period of less than 60 seconds are available for 3 hours. +- Data points with a period of 60 seconds (1-minute) are available for 15 days. +- Data points with a period of 300 seconds (5-minute) are available for 63 days. +- Data points with a period of 3600 seconds (1 hour) are available for 455 days (15 months). + +Select your period of interest accordingly. diff --git a/makefile b/makefile new file mode 100644 index 0000000..3f2723f --- /dev/null +++ b/makefile @@ -0,0 +1,14 @@ +# makefile to install the package, document the API with lucidoc and build the documentation with mkdocs + +doc_api: + poetry install -v + lucidoc cloudwatcher --parse rst --outfile docs/API_documentation.md + +serve_docs: doc_api + mkdocs serve + +build_docs: doc_api + mkdocs build + +deploy_docs: doc_api + mkdocs gh-deploy diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..fb9f5c4 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,39 @@ +site_name: cloudwatcher +theme: + name: material + palette: + - scheme: default + primary: black + toggle: + primary: black + icon: material/weather-night + name: Switch to dark mode + - scheme: slate + primary: black + toggle: + icon: material/weather-sunny + name: Switch to light mode + icon: + repo: material/github +repo_url: https://github.com/niaid/cloudwatcher +repo_name: niaid/cloudwatcher +edit_uri: edit/main/docs/ +markdown_extensions: + - admonition + - md_in_html + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences +nav: + - Installation & Setup: + - Installation: installation.md + - EC2 intance setup: EC2_instance_setup.md + - AWS credentials: login_credentials.md + - Usage & Features: + - Usage: usage.md + - Features: features.md + - API documentation: API_documentation.md + - Development: + - Docs development: docs_development.md diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..671f6e7 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1038 @@ +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "black" +version = "22.3.0" +description = "The uncompromising code formatter." +category = "main" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "boto3" +version = "1.21.46" +description = "The AWS SDK for Python" +category = "main" +optional = false +python-versions = ">= 3.6" + +[package.dependencies] +botocore = ">=1.24.46,<1.25.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.5.0,<0.6.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.24.46" +description = "Low-level, data-driven core of boto 3." +category = "main" +optional = false +python-versions = ">= 3.6" + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = ">=1.25.4,<1.27" + +[package.extras] +crt = ["awscrt (==0.13.8)"] + +[[package]] +name = "cfgv" +version = "3.3.1" +description = "Validate configuration and produce human readable error messages." +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[[package]] +name = "click" +version = "8.1.2" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "commonmark" +version = "0.9.1" +description = "Python parser for the CommonMark Markdown spec" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] + +[[package]] +name = "cycler" +version = "0.11.0" +description = "Composable style cycles" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "distlib" +version = "0.3.4" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "filelock" +version = "3.6.0" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] +testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] + +[[package]] +name = "flake8" +version = "4.0.1" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +importlib-metadata = {version = "<4.3", markers = "python_version < \"3.8\""} +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.8.0,<2.9.0" +pyflakes = ">=2.4.0,<2.5.0" + +[[package]] +name = "fonttools" +version = "4.33.2" +description = "Tools to manipulate font files" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +all = ["fs (>=2.2.0,<3)", "lxml (>=4.0,<5)", "zopfli (>=0.1.4)", "lz4 (>=1.7.4.2)", "matplotlib", "sympy", "skia-pathops (>=0.5.0)", "uharfbuzz (>=0.23.0)", "brotlicffi (>=0.8.0)", "scipy", "brotli (>=1.0.1)", "munkres", "unicodedata2 (>=14.0.0)", "xattr"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["scipy", "munkres"] +lxml = ["lxml (>=4.0,<5)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.23.0)"] +symfont = ["sympy"] +type1 = ["xattr"] +ufo = ["fs (>=2.2.0,<3)"] +unicode = ["unicodedata2 (>=14.0.0)"] +woff = ["zopfli (>=0.1.4)", "brotlicffi (>=0.8.0)", "brotli (>=1.0.1)"] + +[[package]] +name = "identify" +version = "2.4.12" +description = "File identification library for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "importlib-metadata" +version = "4.2.0" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "jmespath" +version = "1.0.0" +description = "JSON Matching Expressions" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "kiwisolver" +version = "1.4.2" +description = "A fast implementation of the Cassowary constraint solver" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + +[[package]] +name = "logmuse" +version = "0.2.7" +description = "Logging setup" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "lucidoc" +version = "0.4.3" +description = "API documentation in Markdown" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +logmuse = ">=0.2.0" +ubiquerg = ">=0.0.5" + +[[package]] +name = "matplotlib" +version = "3.5.1" +description = "Python plotting package" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +cycler = ">=0.10" +fonttools = ">=4.22.0" +kiwisolver = ">=1.0.1" +numpy = ">=1.17" +packaging = ">=20.0" +pillow = ">=6.2.0" +pyparsing = ">=2.2.1" +python-dateutil = ">=2.7" +setuptools_scm = ">=4" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "nodeenv" +version = "1.6.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "numpy" +version = "1.21.1" +description = "NumPy is the fundamental package for array computing with Python." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "pillow" +version = "9.1.0" +description = "Python Imaging Library (Fork)" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinx-rtd-theme (>=1.0)", "sphinxext-opengraph"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "platformdirs" +version = "2.5.2" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "2.18.1" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +toml = "*" +virtualenv = ">=20.0.8" + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pycodestyle" +version = "2.8.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyflakes" +version = "2.4.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pygments" +version = "2.12.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pyparsing" +version = "3.0.8" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "main" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytest" +version = "7.1.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2022.1" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "rich" +version = "12.2.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "main" +optional = false +python-versions = ">=3.6.3,<4.0.0" + +[package.dependencies] +commonmark = ">=0.9.0,<0.10.0" +pygments = ">=2.6.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] + +[[package]] +name = "s3transfer" +version = "0.5.2" +description = "An Amazon S3 Transfer Manager" +category = "main" +optional = false +python-versions = ">= 3.6" + +[package.dependencies] +botocore = ">=1.12.36,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] + +[[package]] +name = "setuptools-scm" +version = "6.4.2" +description = "the blessed package to manage your versions by scm tags" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +packaging = ">=20.0" +tomli = ">=1.0.0" + +[package.extras] +test = ["pytest (>=6.2)", "virtualenv (>20)"] +toml = ["setuptools (>=42)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "typed-ast" +version = "1.5.3" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "typing-extensions" +version = "4.2.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "ubiquerg" +version = "0.6.2" +description = "Various utility functions" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "urllib3" +version = "1.26.9" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "virtualenv" +version = "20.14.1" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[package.dependencies] +distlib = ">=0.3.1,<1" +filelock = ">=3.2,<4" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +platformdirs = ">=2,<3" +six = ">=1.9.0,<2" + +[package.extras] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] + +[[package]] +name = "zipp" +version = "3.8.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.7" +content-hash = "9d57a5f41e65d6aa23b1c0d02088fd9a9bf00b83034ae3ec7d7481424ffe2dd6" + +[metadata.files] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +] +black = [ + {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, + {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, + {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, + {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, + {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, + {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, + {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, + {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, + {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, + {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, + {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, + {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, + {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, + {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, + {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, + {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, + {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, + {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, + {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, + {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, + {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, + {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, + {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, +] +boto3 = [ + {file = "boto3-1.21.46-py3-none-any.whl", hash = "sha256:3b13d727854aba9dea900b6c7fa134c52396869d842460d14fab8b85b69645f7"}, + {file = "boto3-1.21.46.tar.gz", hash = "sha256:9ac902076eac82112f4536cc2606a1f597a387dbc56b250575ac2d2c64c75e20"}, +] +botocore = [ + {file = "botocore-1.24.46-py3-none-any.whl", hash = "sha256:663d8f02b98641846eb959c54c840cc33264d5f2dee5b8fc09ee8adbef0f8dcf"}, + {file = "botocore-1.24.46.tar.gz", hash = "sha256:89a203bba3c8f2299287e48a9e112e2dbe478cf67eaac26716f0e7f176446146"}, +] +cfgv = [ + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, +] +click = [ + {file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"}, + {file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +commonmark = [ + {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, + {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, +] +cycler = [ + {file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"}, + {file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"}, +] +distlib = [ + {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, + {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, +] +filelock = [ + {file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"}, + {file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"}, +] +flake8 = [ + {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, + {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, +] +fonttools = [ + {file = "fonttools-4.33.2-py3-none-any.whl", hash = "sha256:b4da40696829845ea8c1cb33ce51c552179754cbee7ab4e8b96a6bcf421f437a"}, + {file = "fonttools-4.33.2.zip", hash = "sha256:696fe922a271584c3ec8325ba31d4001a4fd6c4953b22900b767f1cb53ce1044"}, +] +identify = [ + {file = "identify-2.4.12-py2.py3-none-any.whl", hash = "sha256:5f06b14366bd1facb88b00540a1de05b69b310cbc2654db3c7e07fa3a4339323"}, + {file = "identify-2.4.12.tar.gz", hash = "sha256:3f3244a559290e7d3deb9e9adc7b33594c1bc85a9dd82e0f1be519bf12a1ec17"}, +] +importlib-metadata = [ + {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"}, + {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +jmespath = [ + {file = "jmespath-1.0.0-py3-none-any.whl", hash = "sha256:e8dcd576ed616f14ec02eed0005c85973b5890083313860136657e24784e4c04"}, + {file = "jmespath-1.0.0.tar.gz", hash = "sha256:a490e280edd1f57d6de88636992d05b71e97d69a26a19f058ecf7d304474bf5e"}, +] +kiwisolver = [ + {file = "kiwisolver-1.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6e395ece147f0692ca7cdb05a028d31b83b72c369f7b4a2c1798f4b96af1e3d8"}, + {file = "kiwisolver-1.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b7f50a1a25361da3440f07c58cd1d79957c2244209e4f166990e770256b6b0b"}, + {file = "kiwisolver-1.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c032c41ae4c3a321b43a3650e6ecc7406b99ff3e5279f24c9b310f41bc98479"}, + {file = "kiwisolver-1.4.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1dcade8f6fe12a2bb4efe2cbe22116556e3b6899728d3b2a0d3b367db323eacc"}, + {file = "kiwisolver-1.4.2-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0e45e780a74416ef2f173189ef4387e44b5494f45e290bcb1f03735faa6779bf"}, + {file = "kiwisolver-1.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d2bb56309fb75a811d81ed55fbe2208aa77a3a09ff5f546ca95e7bb5fac6eff"}, + {file = "kiwisolver-1.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b2d6c12f2ad5f55104a36a356192cfb680c049fe5e7c1f6620fc37f119cdc2"}, + {file = "kiwisolver-1.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:262c248c60f22c2b547683ad521e8a3db5909c71f679b93876921549107a0c24"}, + {file = "kiwisolver-1.4.2-cp310-cp310-win32.whl", hash = "sha256:1008346a7741620ab9cc6c96e8ad9b46f7a74ce839dbb8805ddf6b119d5fc6c2"}, + {file = "kiwisolver-1.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:6ece2e12e4b57bc5646b354f436416cd2a6f090c1dadcd92b0ca4542190d7190"}, + {file = "kiwisolver-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b978afdb913ca953cf128d57181da2e8798e8b6153be866ae2a9c446c6162f40"}, + {file = "kiwisolver-1.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f88c4b8e449908eeddb3bbd4242bd4dc2c7a15a7aa44bb33df893203f02dc2d"}, + {file = "kiwisolver-1.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e348f1904a4fab4153407f7ccc27e43b2a139752e8acf12e6640ba683093dd96"}, + {file = "kiwisolver-1.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c839bf28e45d7ddad4ae8f986928dbf5a6d42ff79760d54ec8ada8fb263e097c"}, + {file = "kiwisolver-1.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ae5a071185f1a93777c79a9a1e67ac46544d4607f18d07131eece08d415083a"}, + {file = "kiwisolver-1.4.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c222f91a45da9e01a9bc4f760727ae49050f8e8345c4ff6525495f7a164c8973"}, + {file = "kiwisolver-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:a4e8f072db1d6fb7a7cc05a6dbef8442c93001f4bb604f1081d8c2db3ca97159"}, + {file = "kiwisolver-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:be9a650890fb60393e60aacb65878c4a38bb334720aa5ecb1c13d0dac54dd73b"}, + {file = "kiwisolver-1.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ec2e55bf31b43aabe32089125dca3b46fdfe9f50afbf0756ae11e14c97b80ca"}, + {file = "kiwisolver-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d1078ba770d6165abed3d9a1be1f9e79b61515de1dd00d942fa53bba79f01ae"}, + {file = "kiwisolver-1.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cbb5eb4a2ea1ffec26268d49766cafa8f957fe5c1b41ad00733763fae77f9436"}, + {file = "kiwisolver-1.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e6cda72db409eefad6b021e8a4f964965a629f577812afc7860c69df7bdb84a"}, + {file = "kiwisolver-1.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1605c7c38cc6a85212dfd6a641f3905a33412e49f7c003f35f9ac6d71f67720"}, + {file = "kiwisolver-1.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81237957b15469ea9151ec8ca08ce05656090ffabc476a752ef5ad7e2644c526"}, + {file = "kiwisolver-1.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:240009fdf4fa87844f805e23f48995537a8cb8f8c361e35fda6b5ac97fcb906f"}, + {file = "kiwisolver-1.4.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:240c2d51d098395c012ddbcb9bd7b3ba5de412a1d11840698859f51d0e643c4f"}, + {file = "kiwisolver-1.4.2-cp38-cp38-win32.whl", hash = "sha256:8b6086aa6936865962b2cee0e7aaecf01ab6778ce099288354a7229b4d9f1408"}, + {file = "kiwisolver-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:0d98dca86f77b851350c250f0149aa5852b36572514d20feeadd3c6b1efe38d0"}, + {file = "kiwisolver-1.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:91eb4916271655dfe3a952249cb37a5c00b6ba68b4417ee15af9ba549b5ba61d"}, + {file = "kiwisolver-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa4d97d7d2b2c082e67907c0b8d9f31b85aa5d3ba0d33096b7116f03f8061261"}, + {file = "kiwisolver-1.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:71469b5845b9876b8d3d252e201bef6f47bf7456804d2fbe9a1d6e19e78a1e65"}, + {file = "kiwisolver-1.4.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8ff3033e43e7ca1389ee59fb7ecb8303abb8713c008a1da49b00869e92e3dd7c"}, + {file = "kiwisolver-1.4.2-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:89b57c2984f4464840e4b768affeff6b6809c6150d1166938ade3e22fbe22db8"}, + {file = "kiwisolver-1.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffbdb9a96c536f0405895b5e21ee39ec579cb0ed97bdbd169ae2b55f41d73219"}, + {file = "kiwisolver-1.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a830a03970c462d1a2311c90e05679da56d3bd8e78a4ba9985cb78ef7836c9f"}, + {file = "kiwisolver-1.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f74f2a13af201559e3d32b9ddfc303c94ae63d63d7f4326d06ce6fe67e7a8255"}, + {file = "kiwisolver-1.4.2-cp39-cp39-win32.whl", hash = "sha256:e677cc3626287f343de751e11b1e8a5b915a6ac897e8aecdbc996cd34de753a0"}, + {file = "kiwisolver-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b3e251e5c38ac623c5d786adb21477f018712f8c6fa54781bd38aa1c60b60fc2"}, + {file = "kiwisolver-1.4.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0c380bb5ae20d829c1a5473cfcae64267b73aaa4060adc091f6df1743784aae0"}, + {file = "kiwisolver-1.4.2-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:484f2a5f0307bc944bc79db235f41048bae4106ffa764168a068d88b644b305d"}, + {file = "kiwisolver-1.4.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e8afdf533b613122e4bbaf3c1e42c2a5e9e2d1dd3a0a017749a7658757cb377"}, + {file = "kiwisolver-1.4.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:42f6ef9b640deb6f7d438e0a371aedd8bef6ddfde30683491b2e6f568b4e884e"}, + {file = "kiwisolver-1.4.2.tar.gz", hash = "sha256:7f606d91b8a8816be476513a77fd30abe66227039bd6f8b406c348cb0247dcc9"}, +] +logmuse = [ + {file = "logmuse-0.2.7-py3-none-any.whl", hash = "sha256:691fc43118feddeaf41b57cd8b8ed5c8e6071948e57a2b824b9c690712e858a8"}, + {file = "logmuse-0.2.7.tar.gz", hash = "sha256:a4692c44ddfa912c3cb149ca4c7545f80119aa7485868fd1412e7c647e9a7e7e"}, +] +lucidoc = [ + {file = "lucidoc-0.4.3-py3-none-any.whl", hash = "sha256:6ddcd83f109566f9d67a9a1ff6c8eaac21df9c0f34b5d0f06c02d676b792d05c"}, + {file = "lucidoc-0.4.3.tar.gz", hash = "sha256:1c32b512dc097c2b6ece9507e2b7272b84c008d4cbca47e8890e6ede0ca28c54"}, +] +matplotlib = [ + {file = "matplotlib-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:456cc8334f6d1124e8ff856b42d2cc1c84335375a16448189999496549f7182b"}, + {file = "matplotlib-3.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8a77906dc2ef9b67407cec0bdbf08e3971141e535db888974a915be5e1e3efc6"}, + {file = "matplotlib-3.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e70ae6475cfd0fad3816dcbf6cac536dc6f100f7474be58d59fa306e6e768a4"}, + {file = "matplotlib-3.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53273c5487d1c19c3bc03b9eb82adaf8456f243b97ed79d09dded747abaf1235"}, + {file = "matplotlib-3.5.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3b6f3fd0d8ca37861c31e9a7cab71a0ef14c639b4c95654ea1dd153158bf0df"}, + {file = "matplotlib-3.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8c87cdaf06fd7b2477f68909838ff4176f105064a72ca9d24d3f2a29f73d393"}, + {file = "matplotlib-3.5.1-cp310-cp310-win32.whl", hash = "sha256:e2f28a07b4f82abb40267864ad7b3a4ed76f1b1663e81c7efc84a9b9248f672f"}, + {file = "matplotlib-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:d70a32ee1f8b55eed3fd4e892f0286df8cccc7e0475c11d33b5d0a148f5c7599"}, + {file = "matplotlib-3.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:68fa30cec89b6139dc559ed6ef226c53fd80396da1919a1b5ef672c911aaa767"}, + {file = "matplotlib-3.5.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e3484d8455af3fdb0424eae1789af61f6a79da0c80079125112fd5c1b604218"}, + {file = "matplotlib-3.5.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e293b16cf303fe82995e41700d172a58a15efc5331125d08246b520843ef21ee"}, + {file = "matplotlib-3.5.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e3520a274a0e054e919f5b3279ee5dbccf5311833819ccf3399dab7c83e90a25"}, + {file = "matplotlib-3.5.1-cp37-cp37m-win32.whl", hash = "sha256:2252bfac85cec7af4a67e494bfccf9080bcba8a0299701eab075f48847cca907"}, + {file = "matplotlib-3.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:abf67e05a1b7f86583f6ebd01f69b693b9c535276f4e943292e444855870a1b8"}, + {file = "matplotlib-3.5.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6c094e4bfecd2fa7f9adffd03d8abceed7157c928c2976899de282f3600f0a3d"}, + {file = "matplotlib-3.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:506b210cc6e66a0d1c2bb765d055f4f6bc2745070fb1129203b67e85bbfa5c18"}, + {file = "matplotlib-3.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b04fc29bcef04d4e2d626af28d9d892be6aba94856cb46ed52bcb219ceac8943"}, + {file = "matplotlib-3.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:577ed20ec9a18d6bdedb4616f5e9e957b4c08563a9f985563a31fd5b10564d2a"}, + {file = "matplotlib-3.5.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e486f60db0cd1c8d68464d9484fd2a94011c1ac8593d765d0211f9daba2bd535"}, + {file = "matplotlib-3.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b71f3a7ca935fc759f2aed7cec06cfe10bc3100fadb5dbd9c435b04e557971e1"}, + {file = "matplotlib-3.5.1-cp38-cp38-win32.whl", hash = "sha256:d24e5bb8028541ce25e59390122f5e48c8506b7e35587e5135efcb6471b4ac6c"}, + {file = "matplotlib-3.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:778d398c4866d8e36ee3bf833779c940b5f57192fa0a549b3ad67bc4c822771b"}, + {file = "matplotlib-3.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bb1c613908f11bac270bc7494d68b1ef6e7c224b7a4204d5dacf3522a41e2bc3"}, + {file = "matplotlib-3.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:edf5e4e1d5fb22c18820e8586fb867455de3b109c309cb4fce3aaed85d9468d1"}, + {file = "matplotlib-3.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:40e0d7df05e8efe60397c69b467fc8f87a2affeb4d562fe92b72ff8937a2b511"}, + {file = "matplotlib-3.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a350ca685d9f594123f652ba796ee37219bf72c8e0fc4b471473d87121d6d34"}, + {file = "matplotlib-3.5.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3e66497cd990b1a130e21919b004da2f1dc112132c01ac78011a90a0f9229778"}, + {file = "matplotlib-3.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:87900c67c0f1728e6db17c6809ec05c025c6624dcf96a8020326ea15378fe8e7"}, + {file = "matplotlib-3.5.1-cp39-cp39-win32.whl", hash = "sha256:b8a4fb2a0c5afbe9604f8a91d7d0f27b1832c3e0b5e365f95a13015822b4cd65"}, + {file = "matplotlib-3.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:fe8d40c434a8e2c68d64c6d6a04e77f21791a93ff6afe0dce169597c110d3079"}, + {file = "matplotlib-3.5.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:34a1fc29f8f96e78ec57a5eff5e8d8b53d3298c3be6df61e7aa9efba26929522"}, + {file = "matplotlib-3.5.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b19a761b948e939a9e20173aaae76070025f0024fc8f7ba08bef22a5c8573afc"}, + {file = "matplotlib-3.5.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6803299cbf4665eca14428d9e886de62e24f4223ac31ab9c5d6d5339a39782c7"}, + {file = "matplotlib-3.5.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:14334b9902ec776461c4b8c6516e26b450f7ebe0b3ef8703bf5cdfbbaecf774a"}, + {file = "matplotlib-3.5.1.tar.gz", hash = "sha256:b2e9810e09c3a47b73ce9cab5a72243a1258f61e7900969097a817232246ce1c"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +nodeenv = [ + {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, + {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, +] +numpy = [ + {file = "numpy-1.21.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:38e8648f9449a549a7dfe8d8755a5979b45b3538520d1e735637ef28e8c2dc50"}, + {file = "numpy-1.21.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fd7d7409fa643a91d0a05c7554dd68aa9c9bb16e186f6ccfe40d6e003156e33a"}, + {file = "numpy-1.21.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a75b4498b1e93d8b700282dc8e655b8bd559c0904b3910b144646dbbbc03e062"}, + {file = "numpy-1.21.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1412aa0aec3e00bc23fbb8664d76552b4efde98fb71f60737c83efbac24112f1"}, + {file = "numpy-1.21.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e46ceaff65609b5399163de5893d8f2a82d3c77d5e56d976c8b5fb01faa6b671"}, + {file = "numpy-1.21.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c6a2324085dd52f96498419ba95b5777e40b6bcbc20088fddb9e8cbb58885e8e"}, + {file = "numpy-1.21.1-cp37-cp37m-win32.whl", hash = "sha256:73101b2a1fef16602696d133db402a7e7586654682244344b8329cdcbbb82172"}, + {file = "numpy-1.21.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7a708a79c9a9d26904d1cca8d383bf869edf6f8e7650d85dbc77b041e8c5a0f8"}, + {file = "numpy-1.21.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95b995d0c413f5d0428b3f880e8fe1660ff9396dcd1f9eedbc311f37b5652e16"}, + {file = "numpy-1.21.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:635e6bd31c9fb3d475c8f44a089569070d10a9ef18ed13738b03049280281267"}, + {file = "numpy-1.21.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a3d5fb89bfe21be2ef47c0614b9c9c707b7362386c9a3ff1feae63e0267ccb6"}, + {file = "numpy-1.21.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8a326af80e86d0e9ce92bcc1e65c8ff88297de4fa14ee936cb2293d414c9ec63"}, + {file = "numpy-1.21.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:791492091744b0fe390a6ce85cc1bf5149968ac7d5f0477288f78c89b385d9af"}, + {file = "numpy-1.21.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0318c465786c1f63ac05d7c4dbcecd4d2d7e13f0959b01b534ea1e92202235c5"}, + {file = "numpy-1.21.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a513bd9c1551894ee3d31369f9b07460ef223694098cf27d399513415855b68"}, + {file = "numpy-1.21.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:91c6f5fc58df1e0a3cc0c3a717bb3308ff850abdaa6d2d802573ee2b11f674a8"}, + {file = "numpy-1.21.1-cp38-cp38-win32.whl", hash = "sha256:978010b68e17150db8765355d1ccdd450f9fc916824e8c4e35ee620590e234cd"}, + {file = "numpy-1.21.1-cp38-cp38-win_amd64.whl", hash = "sha256:9749a40a5b22333467f02fe11edc98f022133ee1bfa8ab99bda5e5437b831214"}, + {file = "numpy-1.21.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d7a4aeac3b94af92a9373d6e77b37691b86411f9745190d2c351f410ab3a791f"}, + {file = "numpy-1.21.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9e7912a56108aba9b31df688a4c4f5cb0d9d3787386b87d504762b6754fbb1b"}, + {file = "numpy-1.21.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:25b40b98ebdd272bc3020935427a4530b7d60dfbe1ab9381a39147834e985eac"}, + {file = "numpy-1.21.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8a92c5aea763d14ba9d6475803fc7904bda7decc2a0a68153f587ad82941fec1"}, + {file = "numpy-1.21.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:05a0f648eb28bae4bcb204e6fd14603de2908de982e761a2fc78efe0f19e96e1"}, + {file = "numpy-1.21.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f01f28075a92eede918b965e86e8f0ba7b7797a95aa8d35e1cc8821f5fc3ad6a"}, + {file = "numpy-1.21.1-cp39-cp39-win32.whl", hash = "sha256:88c0b89ad1cc24a5efbb99ff9ab5db0f9a86e9cc50240177a571fbe9c2860ac2"}, + {file = "numpy-1.21.1-cp39-cp39-win_amd64.whl", hash = "sha256:01721eefe70544d548425a07c80be8377096a54118070b8a62476866d5208e33"}, + {file = "numpy-1.21.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2d4d1de6e6fb3d28781c73fbde702ac97f03d79e4ffd6598b880b2d95d62ead4"}, + {file = "numpy-1.21.1.zip", hash = "sha256:dff4af63638afcc57a3dfb9e4b26d434a7a602d225b42d746ea7fe2edf1342fd"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pathspec = [ + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, +] +pillow = [ + {file = "Pillow-9.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:af79d3fde1fc2e33561166d62e3b63f0cc3e47b5a3a2e5fea40d4917754734ea"}, + {file = "Pillow-9.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:55dd1cf09a1fd7c7b78425967aacae9b0d70125f7d3ab973fadc7b5abc3de652"}, + {file = "Pillow-9.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66822d01e82506a19407d1afc104c3fcea3b81d5eb11485e593ad6b8492f995a"}, + {file = "Pillow-9.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5eaf3b42df2bcda61c53a742ee2c6e63f777d0e085bbc6b2ab7ed57deb13db7"}, + {file = "Pillow-9.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01ce45deec9df310cbbee11104bae1a2a43308dd9c317f99235b6d3080ddd66e"}, + {file = "Pillow-9.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:aea7ce61328e15943d7b9eaca87e81f7c62ff90f669116f857262e9da4057ba3"}, + {file = "Pillow-9.1.0-cp310-cp310-win32.whl", hash = "sha256:7a053bd4d65a3294b153bdd7724dce864a1d548416a5ef61f6d03bf149205160"}, + {file = "Pillow-9.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:97bda660702a856c2c9e12ec26fc6d187631ddfd896ff685814ab21ef0597033"}, + {file = "Pillow-9.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21dee8466b42912335151d24c1665fcf44dc2ee47e021d233a40c3ca5adae59c"}, + {file = "Pillow-9.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b6d4050b208c8ff886fd3db6690bf04f9a48749d78b41b7a5bf24c236ab0165"}, + {file = "Pillow-9.1.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5cfca31ab4c13552a0f354c87fbd7f162a4fafd25e6b521bba93a57fe6a3700a"}, + {file = "Pillow-9.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed742214068efa95e9844c2d9129e209ed63f61baa4d54dbf4cf8b5e2d30ccf2"}, + {file = "Pillow-9.1.0-cp37-cp37m-win32.whl", hash = "sha256:c9efef876c21788366ea1f50ecb39d5d6f65febe25ad1d4c0b8dff98843ac244"}, + {file = "Pillow-9.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:de344bcf6e2463bb25179d74d6e7989e375f906bcec8cb86edb8b12acbc7dfef"}, + {file = "Pillow-9.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:17869489de2fce6c36690a0c721bd3db176194af5f39249c1ac56d0bb0fcc512"}, + {file = "Pillow-9.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:25023a6209a4d7c42154073144608c9a71d3512b648a2f5d4465182cb93d3477"}, + {file = "Pillow-9.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8782189c796eff29dbb37dd87afa4ad4d40fc90b2742704f94812851b725964b"}, + {file = "Pillow-9.1.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:463acf531f5d0925ca55904fa668bb3461c3ef6bc779e1d6d8a488092bdee378"}, + {file = "Pillow-9.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f42364485bfdab19c1373b5cd62f7c5ab7cc052e19644862ec8f15bb8af289e"}, + {file = "Pillow-9.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3fddcdb619ba04491e8f771636583a7cc5a5051cd193ff1aa1ee8616d2a692c5"}, + {file = "Pillow-9.1.0-cp38-cp38-win32.whl", hash = "sha256:4fe29a070de394e449fd88ebe1624d1e2d7ddeed4c12e0b31624561b58948d9a"}, + {file = "Pillow-9.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:c24f718f9dd73bb2b31a6201e6db5ea4a61fdd1d1c200f43ee585fc6dcd21b34"}, + {file = "Pillow-9.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fb89397013cf302f282f0fc998bb7abf11d49dcff72c8ecb320f76ea6e2c5717"}, + {file = "Pillow-9.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c870193cce4b76713a2b29be5d8327c8ccbe0d4a49bc22968aa1e680930f5581"}, + {file = "Pillow-9.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69e5ddc609230d4408277af135c5b5c8fe7a54b2bdb8ad7c5100b86b3aab04c6"}, + {file = "Pillow-9.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35be4a9f65441d9982240e6966c1eaa1c654c4e5e931eaf580130409e31804d4"}, + {file = "Pillow-9.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82283af99c1c3a5ba1da44c67296d5aad19f11c535b551a5ae55328a317ce331"}, + {file = "Pillow-9.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a325ac71914c5c043fa50441b36606e64a10cd262de12f7a179620f579752ff8"}, + {file = "Pillow-9.1.0-cp39-cp39-win32.whl", hash = "sha256:a598d8830f6ef5501002ae85c7dbfcd9c27cc4efc02a1989369303ba85573e58"}, + {file = "Pillow-9.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0c51cb9edac8a5abd069fd0758ac0a8bfe52c261ee0e330f363548aca6893595"}, + {file = "Pillow-9.1.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a336a4f74baf67e26f3acc4d61c913e378e931817cd1e2ef4dfb79d3e051b481"}, + {file = "Pillow-9.1.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb1b89b11256b5b6cad5e7593f9061ac4624f7651f7a8eb4dfa37caa1dfaa4d0"}, + {file = "Pillow-9.1.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:255c9d69754a4c90b0ee484967fc8818c7ff8311c6dddcc43a4340e10cd1636a"}, + {file = "Pillow-9.1.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5a3ecc026ea0e14d0ad7cd990ea7f48bfcb3eb4271034657dc9d06933c6629a7"}, + {file = "Pillow-9.1.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5b0ff59785d93b3437c3703e3c64c178aabada51dea2a7f2c5eccf1bcf565a3"}, + {file = "Pillow-9.1.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7110ec1701b0bf8df569a7592a196c9d07c764a0a74f65471ea56816f10e2c8"}, + {file = "Pillow-9.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:8d79c6f468215d1a8415aa53d9868a6b40c4682165b8cb62a221b1baa47db458"}, + {file = "Pillow-9.1.0.tar.gz", hash = "sha256:f401ed2bbb155e1ade150ccc63db1a4f6c1909d3d378f7d1235a44e90d75fb97"}, +] +platformdirs = [ + {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, + {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +pre-commit = [ + {file = "pre_commit-2.18.1-py2.py3-none-any.whl", hash = "sha256:02226e69564ebca1a070bd1f046af866aa1c318dbc430027c50ab832ed2b73f2"}, + {file = "pre_commit-2.18.1.tar.gz", hash = "sha256:5d445ee1fa8738d506881c5d84f83c62bb5be6b2838e32207433647e8e5ebe10"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pycodestyle = [ + {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, + {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, +] +pyflakes = [ + {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, + {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, +] +pygments = [ + {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, + {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, +] +pyparsing = [ + {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, + {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, +] +pytest = [ + {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, + {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] +pytz = [ + {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, + {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, +] +pyyaml = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] +rich = [ + {file = "rich-12.2.0-py3-none-any.whl", hash = "sha256:c50f3d253bc6a9bb9c79d61a26d510d74abdf1b16881260fab5edfc3edfb082f"}, + {file = "rich-12.2.0.tar.gz", hash = "sha256:ea74bc9dad9589d8eea3e3fd0b136d8bf6e428888955f215824c2894f0da8b47"}, +] +s3transfer = [ + {file = "s3transfer-0.5.2-py3-none-any.whl", hash = "sha256:7a6f4c4d1fdb9a2b640244008e142cbc2cd3ae34b386584ef044dd0f27101971"}, + {file = "s3transfer-0.5.2.tar.gz", hash = "sha256:95c58c194ce657a5f4fb0b9e60a84968c808888aed628cd98ab8771fe1db98ed"}, +] +setuptools-scm = [ + {file = "setuptools_scm-6.4.2-py3-none-any.whl", hash = "sha256:acea13255093849de7ccb11af9e1fb8bde7067783450cee9ef7a93139bddf6d4"}, + {file = "setuptools_scm-6.4.2.tar.gz", hash = "sha256:6833ac65c6ed9711a4d5d2266f8024cfa07c533a0e55f4c12f6eff280a5a9e30"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +typed-ast = [ + {file = "typed_ast-1.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ad3b48cf2b487be140072fb86feff36801487d4abb7382bb1929aaac80638ea"}, + {file = "typed_ast-1.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:542cd732351ba8235f20faa0fc7398946fe1a57f2cdb289e5497e1e7f48cfedb"}, + {file = "typed_ast-1.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc2c11ae59003d4a26dda637222d9ae924387f96acae9492df663843aefad55"}, + {file = "typed_ast-1.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd5df1313915dbd70eaaa88c19030b441742e8b05e6103c631c83b75e0435ccc"}, + {file = "typed_ast-1.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:e34f9b9e61333ecb0f7d79c21c28aa5cd63bec15cb7e1310d7d3da6ce886bc9b"}, + {file = "typed_ast-1.5.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f818c5b81966d4728fec14caa338e30a70dfc3da577984d38f97816c4b3071ec"}, + {file = "typed_ast-1.5.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3042bfc9ca118712c9809201f55355479cfcdc17449f9f8db5e744e9625c6805"}, + {file = "typed_ast-1.5.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4fff9fdcce59dc61ec1b317bdb319f8f4e6b69ebbe61193ae0a60c5f9333dc49"}, + {file = "typed_ast-1.5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8e0b8528838ffd426fea8d18bde4c73bcb4167218998cc8b9ee0a0f2bfe678a6"}, + {file = "typed_ast-1.5.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ef1d96ad05a291f5c36895d86d1375c0ee70595b90f6bb5f5fdbee749b146db"}, + {file = "typed_ast-1.5.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed44e81517364cb5ba367e4f68fca01fba42a7a4690d40c07886586ac267d9b9"}, + {file = "typed_ast-1.5.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f60d9de0d087454c91b3999a296d0c4558c1666771e3460621875021bf899af9"}, + {file = "typed_ast-1.5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9e237e74fd321a55c90eee9bc5d44be976979ad38a29bbd734148295c1ce7617"}, + {file = "typed_ast-1.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ee852185964744987609b40aee1d2eb81502ae63ee8eef614558f96a56c1902d"}, + {file = "typed_ast-1.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:27e46cdd01d6c3a0dd8f728b6a938a6751f7bd324817501c15fb056307f918c6"}, + {file = "typed_ast-1.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d64dabc6336ddc10373922a146fa2256043b3b43e61f28961caec2a5207c56d5"}, + {file = "typed_ast-1.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8cdf91b0c466a6c43f36c1964772918a2c04cfa83df8001ff32a89e357f8eb06"}, + {file = "typed_ast-1.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:9cc9e1457e1feb06b075c8ef8aeb046a28ec351b1958b42c7c31c989c841403a"}, + {file = "typed_ast-1.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e20d196815eeffb3d76b75223e8ffed124e65ee62097e4e73afb5fec6b993e7a"}, + {file = "typed_ast-1.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:37e5349d1d5de2f4763d534ccb26809d1c24b180a477659a12c4bde9dd677d74"}, + {file = "typed_ast-1.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9f1a27592fac87daa4e3f16538713d705599b0a27dfe25518b80b6b017f0a6d"}, + {file = "typed_ast-1.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8831479695eadc8b5ffed06fdfb3e424adc37962a75925668deeb503f446c0a3"}, + {file = "typed_ast-1.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:20d5118e494478ef2d3a2702d964dae830aedd7b4d3b626d003eea526be18718"}, + {file = "typed_ast-1.5.3.tar.gz", hash = "sha256:27f25232e2dd0edfe1f019d6bfaaf11e86e657d9bdb7b0956db95f560cceb2b3"}, +] +typing-extensions = [ + {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"}, + {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, +] +ubiquerg = [ + {file = "ubiquerg-0.6.2-py2.py3-none-any.whl", hash = "sha256:6336c4dc2c64fd759585265ad0a307eb48944368de531fb686447d2a93a5779d"}, + {file = "ubiquerg-0.6.2.tar.gz", hash = "sha256:a9b1388799d4c366f956e0c912819099ad8f6cd0e5d890923cdde197f80d14cf"}, +] +urllib3 = [ + {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, + {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, +] +virtualenv = [ + {file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"}, + {file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"}, +] +zipp = [ + {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, + {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c2f977b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[tool.poetry] +name = "cloudwatcher" +version = "0.0.2" +description = "A tool for monitoring AWS CloudWatch metrics" +authors = ["Michal Stolarczyk "] + +[tool.poetry.dependencies] +python = "^3.7" +rich = "^12.2.0" +black = "^22.3.0" +matplotlib = "^3.5.1" +pytz = "^2022.1" +boto3 = "^1.21.46" + +[tool.poetry.dev-dependencies] +pytest = "^7.1.1" +lucidoc = "^0.4.3" +pre-commit = "^2.18.1" +flake8 = "^4.0.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +cloudwatcher = 'cloudwatcher.__main__:main' diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..9ab5390 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,8 @@ +import pytest + +# verify that the version is correct +from cloudwatcher._version import __version__ + + +def test_version(): + assert __version__ == "0.0.2"