Repository :
http://git.fedorahosted.org/cgit/copr.git
On branch : python_copr_apiv2
---------------------------------------------------------------
commit 70ff5b4bcc520df1a9106dbdbf631417df67d143
Author: Valentin Gologuzov <vgologuz(a)redhat.com>
Date: Fri Sep 18 11:57:40 2015 +0200
[python][WIP] support APIv2
---------------------------------------------------------------
python/copr/client/__init__.py | 3 +
python/copr/client/client.py | 4 +-
python/copr/client/responses.py | 2 +-
.../src => python/copr/client_v2}/__init__.py | 0
python/copr/client_v2/client.py | 155 +++++++++++
python/copr/client_v2/common.py | 8 +
python/copr/client_v2/entities.py | 290 ++++++++++++++++++++
python/copr/client_v2/net_client.py | 128 +++++++++
python/copr/{client => }/exceptions.py | 0
python/copr/test/client_v2/test_client.py | 33 +++
python/copr/{client => }/util.py | 0
python/run_tmp.py | 33 +++
12 files changed, 653 insertions(+), 3 deletions(-)
diff --git a/python/copr/client/__init__.py b/python/copr/client/__init__.py
index 045574f..52f7c6c 100644
--- a/python/copr/client/__init__.py
+++ b/python/copr/client/__init__.py
@@ -7,3 +7,6 @@ __version__ = "0.0.1"
__description__ = "Python client for copr service."
from .client import CoprClient
+
+# for cli backward compatibility
+import copr.exceptions as exceptions
diff --git a/python/copr/client/client.py b/python/copr/client/client.py
index aa20647..40071f7 100644
--- a/python/copr/client/client.py
+++ b/python/copr/client/client.py
@@ -33,7 +33,7 @@ else:
log = logging.getLogger(__name__)
log.addHandler(NullHandler())
-from .exceptions import CoprConfigException, CoprNoConfException, \
+from ..exceptions import CoprConfigException, CoprNoConfException, \
CoprRequestException, \
CoprUnknownResponseException
@@ -44,7 +44,7 @@ from .parsers import fabric_simple_fields_parser, ProjectListParser, \
CommonMsgErrorOutParser, NewBuildListParser, ProjectChrootsParser, \
ProjectDetailsFieldsParser
-from .util import UnicodeMixin
+from ..util import UnicodeMixin
# TODO: add deco to check that login/token are provided
# and raise correct error
diff --git a/python/copr/client/responses.py b/python/copr/client/responses.py
index af46f58..23f6016 100644
--- a/python/copr/client/responses.py
+++ b/python/copr/client/responses.py
@@ -15,7 +15,7 @@ import weakref
# TODO: Add Response for collections?
# TODO: what about copr_project-> chroot_list, build_list, repos_list
#
-from .util import UnicodeMixin
+from ..util import UnicodeMixin
class CoprResponse(UnicodeMixin):
diff --git a/builder_image/src/__init__.py b/python/copr/client_v2/__init__.py
similarity index 100%
copy from builder_image/src/__init__.py
copy to python/copr/client_v2/__init__.py
diff --git a/python/copr/client_v2/client.py b/python/copr/client_v2/client.py
new file mode 100644
index 0000000..e57496b
--- /dev/null
+++ b/python/copr/client_v2/client.py
@@ -0,0 +1,155 @@
+# coding: utf-8
+
+from __future__ import print_function
+from __future__ import unicode_literals
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import sys
+import os
+import logging
+from marshmallow import pprint
+
+import requests
+import six
+
+from six.moves import configparser
+# from requests_toolbelt.multipart.encoder import MultipartEncoder
+
+from .entities import parse_root, ProjectHandle, RootEntity
+from client_v2.common import EntityTypes
+from .net_client import NetClient
+
+if sys.version_info < (2, 7):
+ class NullHandler(logging.Handler):
+ def emit(self, record):
+ pass
+else:
+ from logging import NullHandler
+
+from ..exceptions import CoprConfigException, CoprNoConfException, \
+ CoprRequestException, \
+ CoprUnknownResponseException
+
+
+
+# from .responses import ProjectHandle, \
+# CoprResponse, BuildHandle, BaseHandle, ProjectChrootHandle
+#
+# from .parsers import fabric_simple_fields_parser, ProjectListParser, \
+# CommonMsgErrorOutParser, NewBuildListParser, ProjectChrootsParser, \
+# ProjectDetailsFieldsParser
+
+from ..util import UnicodeMixin
+
+log = logging.getLogger(__name__)
+log.addHandler(NullHandler())
+
+
+class CoprClient(UnicodeMixin):
+ """ Main interface to the copr service
+
+ :param NetClient net_client: wrapper for http requests
+ :param unicode root_url: used as copr projects root
+ :param bool no_config: helper flag to indicate that no config was provided
+
+ Could be created:
+ - using static method :py:meth:`CoprClient.create_from_file_config`
+ - using static method :py:meth:`CoprClient.create_from_params`
+
+ If you create Client directly call post_init() method after the creation.
+ """
+
+ def __init__(self, net_client, root_url=None, no_config=False):
+ """
+
+ """
+ self.nc = net_client
+ self.root_url = root_url or u"http://copr.fedoraproject.org"
+
+ self.no_config = no_config
+
+ self._main_resources = None
+
+ self.root = None
+ """:type : RootEntity or None """
+
+ self.projects = None
+ """:type : ProjectHandle or None """
+
+ def __unicode__(self):
+ return (
+ u"<Copr client. api root url: {}, config provided: {}, net client:
{}>"
+ .format(self.root_url, not self.no_config, self.nc)
+ )
+
+ @property
+ def api_root(self):
+ """
+ Url to API endpoint
+ """
+ return "{0}/api_2".format(self.root_url)
+
+ @classmethod
+ def create_from_params(cls, root_url=None, login=None, token=None):
+ nc = NetClient(login, token)
+ client = cls(nc, root_url, no_config=True)
+ client.post_init()
+ return client
+
+
+ @classmethod
+ def create_from_file_config(cls, filepath=None, ignore_error=False):
+ """
+ Creates Copr client using the information from the config file.
+
+ :param filepath: specifies config location,
+ default: "~/.config/copr"
+ :type filepath: `str`
+ :param bool ignore_error: When true and config is missing,
+ creates default Client without credentionals
+
+ :rtype: :py:class:`~.client.CoprClient`
+ """
+ raw_config = configparser.ConfigParser()
+ if not filepath:
+ filepath = os.path.join(os.path.expanduser("~"),
".config", "copr")
+ config = {}
+ if not raw_config.read(filepath):
+ log.warning(
+ "No configuration file '~/.config/copr' found. "
+ "See man copr-cli for more information")
+ config["no_config"] = True
+ if not ignore_error:
+ raise CoprNoConfException()
+ else:
+ client = cls.create_from_params()
+ else:
+ try:
+ for field in ["login", "token",
"copr_url"]:
+ if six.PY3:
+ config[field] = raw_config["copr-cli"].get(field,
None)
+ else:
+ config[field] = raw_config.get("copr-cli", field,
None)
+ nc = NetClient(config["login"], config["token"])
+ client = cls(nc, root_url=config["copr_url"], )
+
+ except configparser.Error as err:
+ if not ignore_error:
+ raise CoprConfigException(
+ "Bad configuration file: {0}".format(err))
+ else:
+ client = cls.create_from_params()
+
+ client.post_init()
+ return client
+
+ def post_init(self):
+ log.debug("Getting root resources")
+ response = self.nc.request(self.api_root)
+
+ self.root = parse_root(response, self.root_url)
+ self.projects = ProjectHandle(self.nc,
self.root.get_resource_base_url(u"projects"))
+
+ # import ipdb; ipdb.set_trace()
diff --git a/python/copr/client_v2/common.py b/python/copr/client_v2/common.py
new file mode 100644
index 0000000..4e96bf5
--- /dev/null
+++ b/python/copr/client_v2/common.py
@@ -0,0 +1,8 @@
+# coding: utf-8
+class EntityTypes(object):
+ ROOT = "root"
+ PROJECT = "project"
+ PROJECT_CHROOT = "project_chroot"
+ BUILD = "build"
+ BUILD_TASK = "build_task"
+ MOCK_CHROOT = "mock_chroot"
diff --git a/python/copr/client_v2/entities.py b/python/copr/client_v2/entities.py
new file mode 100644
index 0000000..b9ef8e7
--- /dev/null
+++ b/python/copr/client_v2/entities.py
@@ -0,0 +1,290 @@
+# coding: utf-8
+
+from collections import Iterable
+import json
+
+from client_v2.common import EntityTypes
+from .net_client import NetClient, ResponseWrapper
+
+
+class Link(object):
+
+ def __init__(self, role, href, target_type):
+ self.role = role
+ self.href = href
+ self.target_type = target_type
+
+ def __unicode__(self):
+ return u"<Link: role: {}, target type: {}, href: {}".format(
+ self.role, self.target_type, self.href)
+
+ def __str__(self):
+ return self.__unicode__()
+
+ @classmethod
+ def parse_from_dict(cls, data_dict, map_name_to_role):
+ """
+ {
+ "self": {
+ "href":
"/api_2/projects/2482?show_builds=True&show_chroots=True"
+ },
+ "chroots": {
+ "href": "/api_2/projects/2482/chroots"
+ },
+ "builds": {
+ "href": "/api_2/builds?project_id=2482"
+ }
+ }
+
+ return
+ {
+ <entity type> -> Link()
+ }
+ """
+ return {
+ role_name:
+ cls(role_name, definition["href"],
map_name_to_role[role_name])
+ for role_name, definition in data_dict.items()
+ if role_name in map_name_to_role
+ }
+
+
+class Individual(object):
+ """
+ :type handle: AbstractHandle or None
+ :type response: ResponseWrapper
+ :type links: (dict of (str, Link)) or None
+ """
+
+ def __init__(self, handle=None, response=None, links=None, data=None, embedded=None,
options=None):
+ self._handle = handle
+ self._response = response
+ self._links = links
+ self._data = data
+ self._embedded = embedded or dict()
+ self._options = options or dict()
+
+ def get_href_by_name(self, name):
+ """
+ :type name: str
+ """
+ return self._links[name].href
+
+ def to_json(self):
+ return "null"
+
+
+class Collection(Iterable):
+ """
+ :type handle: AbstractHandle or None
+ :type response: ResponseWrapper
+ :type links: (dict of (str, Link)) or None
+ """
+
+ def __init__(self, handle=None, response=None, links=None, individuals=None,
options=None):
+ self._handle = handle
+ self._response = response
+ self._links = links
+ self._options = options or dict()
+ self._individuals = individuals
+
+ def get_href_by_name(self, name):
+ """
+ :type name: str
+ """
+ return self._links[name].href
+
+ def __iter__(self):
+ """
+ :rtype: Iterable[Individual]
+ """
+ return iter(self._individuals)
+
+
+class ProjectsList(Collection):
+ """
+ :type handle: ProjectHandle
+ """
+
+ # def __iter__(self):
+ # """
+ # :rtype: Iterable[ProjectEntity]
+ # """
+ # return super(ProjectsList, self).__iter__()
+
+ def __init__(self, handle, **kwargs):
+ super(ProjectsList, self).__init__(**kwargs)
+ self._handle = handle
+
+ @property
+ def projects(self):
+ return self._individuals
+
+ def next_page(self):
+ limit = self._options.get("limit", 100)
+ offset = self._options.get("offset", 0)
+
+ offset += limit
+ params = {}
+ params.update(self._options)
+ params["limit"] = limit
+ params["offset"] = offset
+
+ return self._handle.get_list(self, **params)
+
+
+class OperationResult(Individual):
+
+ def __init__(self, handle, response=None, data=None, new_location=None,
options=None):
+ super(OperationResult, self).__init__(handle, response=response, data=data,
options=options)
+ self.new_location = new_location
+
+
+class RootEntity(Individual):
+
+ def __init__(self, response, links, root_url):
+ super(RootEntity, self).__init__(response=response, links=links)
+ self.root_url = root_url
+
+ def get_resource_base_url(self, resource_name):
+ """
+ :type entity_type: client_v2.common.EntityTypes
+ """
+ return "{}{}".format(self.root_url,
self.get_href_by_name(resource_name))
+
+
+# TODO: replace with marshmallow
+class ProjectEntity(Individual):
+ """
+ :type handle: ProjectHandle
+ """
+ def __init__(self, handle, **kwargs):
+ super(ProjectEntity, self).__init__( **kwargs)
+ self._handle = handle
+
+ self.id = self._data.get("id")
+ self.name = self._data.get("name")
+ self.owner = self._data.get("owner")
+ self.description = self._data.get("description")
+ self.instructions = self._data.get("instructions")
+ self.homepage = self._data.get("homepage")
+ self.contact = self._data.get("contact")
+ self.disable_createrepo = self._data.get("disable_creatrepo")
+ self.build_enable_net = self._data.get("build_enable_net")
+ self.repos = self._data.get("repos")
+
+ def to_json(self):
+ # todo: replace with marshmallow
+ to_dump = {}
+ for key in self._data.keys():
+ to_dump[key] = getattr(self, key)
+ return json.dumps(to_dump)
+
+ def update(self):
+ self._handle.update(self)
+
+ def delete(self):
+ self._handle.delete(self.id)
+
+ def __unicode__(self):
+ return "<Project #{}: {}/{}>".format(self.id, self.owner,
self.name)
+
+ def __str__(self):
+ return self.__unicode__()
+
+
+def parse_root(response, root_url):
+ """
+ :type response: ResponseWrapper
+ :type root_url: unicode
+ """
+ data_dict = response.json
+ links = Link.parse_from_dict(data_dict["_links"], {
+ "self": EntityTypes.ROOT,
+ "projects": EntityTypes.PROJECT,
+ "builds": EntityTypes.BUILD,
+ "build_tasks": EntityTypes.BUILD_TASK,
+ "mock_chroots": EntityTypes.MOCK_CHROOT,
+ })
+ return RootEntity(response=response, links=links, root_url=root_url)
+
+
+def parse_project(handle, response, data_dict, options=None):
+ links = Link.parse_from_dict(data_dict["_links"], {
+ "self": EntityTypes.PROJECT,
+ "builds": EntityTypes.BUILD,
+ })
+ return ProjectEntity(handle, response=response, links=links,
data=data_dict["project"], options=options)
+
+
+class AbstractHandle(object):
+ pass
+
+
+class ProjectHandle(AbstractHandle):
+ """
+ :type nc: NetClient
+ """
+ def __init__(self, nc, base_url):
+
+ self.nc = nc
+ self.base_url = base_url
+
+ def get_list(self, search_query=None, owner=None, name=None, limit=None,
offset=None):
+ """
+ :param search_query:
+ :param owner:
+ :param name:
+ :param limit:
+ :param offset:
+ :rtype: ProjectsList
+ """
+ options = {}
+ if limit:
+ options["limit"] = limit
+ # todo: add other
+
+ response = self.nc.request(self.base_url, query_params=options)
+ data_dict = response.json
+ result = ProjectsList(
+ self,
+ response=response,
+ links=Link.parse_from_dict(data_dict["_links"], {
+ "self": EntityTypes.PROJECT,
+ "builds": EntityTypes.BUILD,
+ }),
+ individuals=[
+ parse_project(self, response, data_dict=dict_part)
+ for dict_part in data_dict["projects"]
+ ],
+ options=None
+ )
+ return result
+
+ def get_one(self, project_id, show_builds=False, show_chroots=False):
+ """
+ :type project_id: int
+ """
+ query_params = {
+ "show_builds": show_builds,
+ "show_chroots": show_chroots
+ }
+
+ url = "{}/{}".format(self.base_url, project_id)
+ response = self.nc.request(url, query_params=query_params)
+ return parse_project(self, response, data_dict=response.json,
options=query_params)
+
+ def update(self, project):
+ """
+ :type project: ProjectEntity
+ """
+ url = "{}/{}".format(self.base_url, project.id)
+ data = project.to_json()
+
+ response = self.nc.request(url, method="put", data=data, do_auth=True)
+ return OperationResult(self, response)
+
+ def delete(self, project_id):
+ url = "{}/{}".format(self.base_url, project_id)
+ response = self.nc.request(url, method="delete", do_auth=True)
+ return OperationResult(self, response)
diff --git a/python/copr/client_v2/net_client.py b/python/copr/client_v2/net_client.py
new file mode 100644
index 0000000..26aeccd
--- /dev/null
+++ b/python/copr/client_v2/net_client.py
@@ -0,0 +1,128 @@
+# coding: utf-8
+import json
+
+from logging import getLogger
+
+from requests import request, ConnectionError
+
+
+log = getLogger(__name__)
+
+
+class RequestError(Exception):
+ def __init__(self, msg, url, request_kwargs, response=None):
+ self.msg = msg
+ self.url = url
+ self.request_kwargs = request_kwargs
+ self.response = response
+
+ @property
+ def response_json(self):
+ if self.response is None:
+ raise ValueError("No response")
+ try:
+ result = json.loads(self.response.text)
+ except (ValueError, AttributeError):
+ raise ValueError("Malformed response, couldn't get json
content")
+
+ return result
+
+ def __unicode__(self):
+ res = "Error occurred while accessing {}: {}\n".format(
+ self.url, self.msg)
+ if self.response is not None:
+ res += "code {}: {}\n".format(self.response.status_code,
self.response_json["message"])
+ return res
+
+ def __str__(self):
+ return self.__unicode__()
+
+
+class NetworkError(RequestError):
+ def __init__(self, url, request_kwargs, requests_error):
+ self.requests_error = requests_error
+ super(NetworkError, self).__init__(
+ u"Connection error", url, request_kwargs, response=None)
+
+ def __unicode__(self):
+ res = super(NetworkError, self).__unicode__()
+ res += u"Original error: {}\n".format(self.requests_error)
+
+
+class AuthError(RequestError):
+ def __init__(self, url, request_kwargs, response):
+ super(AuthError, self).__init__("Authorization failed",
+ url, request_kwargs, response)
+
+
+class ResponseWrapper(object):
+
+ def __init__(self, response):
+ """
+ :raises ValueError: when fails to deserialize json content
+ """
+ self.response = response
+ if response.status_code != 204:
+ self.json = response.json()
+ else:
+ self.json = None
+
+ @property
+ def status_code(self):
+ return self.response.status_code
+
+ @property
+ def headers(self):
+ return self.response.headers
+
+
+class NetClient(object):
+ """
+ Abstraction around python-requests
+
+ :param unicode login: login for BasicAuth
+ :param unicode password: password for BasicAuth
+ """
+
+ def __init__(self, login=None, password=None):
+ self.login = login
+ self.token = password
+
+ def request(self, url, method=None, query_params=None, data=None, do_auth=False):
+
+ if method is None:
+ method = "get"
+ elif method not in ["get", "post", "delete",
"put"]:
+ raise Exception("Method {0} not allowed".format(method))
+
+ kwargs = {}
+ if do_auth:
+ if self.login is None or self.token is None:
+ raise RequestError("Credentionals for BasicAuth not set, request
aborted",
+ url, kwargs)
+ kwargs["auth"] = (self.login, self.token)
+ if query_params:
+ kwargs["params"] = query_params
+ if data:
+ kwargs["data"] = data
+
+ try:
+ response = request(
+ method=method.upper(),
+ url=url,
+ **kwargs
+ )
+ log.debug("raw response: {0}".format(response.text))
+ except ConnectionError as e:
+ raise NetworkError(url, kwargs, e)
+
+ if response.status_code == 403:
+ raise AuthError(url, kwargs, response)
+
+ if response.status_code > 399:
+ raise RequestError("", url, kwargs, response)
+
+ try:
+ return ResponseWrapper(response)
+ except ValueError:
+ raise RequestError("Failed to parse server response", url, kwargs,
response)
diff --git a/python/copr/client/exceptions.py b/python/copr/exceptions.py
similarity index 100%
rename from python/copr/client/exceptions.py
rename to python/copr/exceptions.py
diff --git a/python/copr/test/client_v2/test_client.py
b/python/copr/test/client_v2/test_client.py
new file mode 100644
index 0000000..34bc49e
--- /dev/null
+++ b/python/copr/test/client_v2/test_client.py
@@ -0,0 +1,33 @@
+# coding: utf-8
+import os
+import copy
+import tarfile
+import tempfile
+import shutil
+import time
+
+import six
+
+
+if six.PY3:
+ from unittest import mock
+ from unittest.mock import MagicMock
+else:
+ import mock
+ from mock import MagicMock
+
+import pytest
+
+(a)pytest.yield_fixture
+def mc_requests():
+ with mock.patch('backend.createrepo.Popen') as handle:
+ yield handle
+
+
+
+class TestClient(object):
+
+ def setup_method(self, method):
+
+ self.requests_patcher = mock.patch('')
+
diff --git a/python/copr/client/util.py b/python/copr/util.py
similarity index 100%
rename from python/copr/client/util.py
rename to python/copr/util.py
diff --git a/python/run_tmp.py b/python/run_tmp.py
new file mode 100644
index 0000000..04b6771
--- /dev/null
+++ b/python/run_tmp.py
@@ -0,0 +1,33 @@
+# coding: utf-8
+
+from copr.client_v2.client import CoprClient
+from copr.client_v2.entities import ProjectEntity
+
+def main():
+ copr_url = "http://copr-fe-dev.cloud.fedoraproject.org"
+ client = CoprClient.create_from_params(
+ root_url=copr_url,
+ login="wczictdaerhmwfxolham",
+ token="kxcnwfmleulpnkckzspxxgwhxjolhc"
+
+ )
+ client.post_init()
+
+ project = client.projects.get_one(3260)
+
+ print("Project: {} id {}, {}\n {}".format(
+ project.name,
+ project.id,
+ project.description,
+ [(x, y.href) for x, y in project._links.items()]
+ ))
+ # res = project._handle.update(project)
+
+ plist = client.projects.get_list(limit=7)
+ for p in plist:
+ print(p)
+ print(p.get_href_by_name("builds"))
+
+
+if __name__ == "__main__":
+ main()