diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000000..f6da14e67f15 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: python +python: + - "2.6" + - "2.7" +# command to install dependencies +install: "pip install . unittest2" +# command to run tests +script: nosetests diff --git a/README.rst b/README.rst index 1961c399df94..85ef1dcd4d97 100644 --- a/README.rst +++ b/README.rst @@ -1,28 +1,36 @@ -Google Cloud -============ +Google Cloud Python Client +========================== -Official documentation ----------------------- +The goal of this project is to make it really simple and Pythonic +to use Google Cloud Platform services. -If you just want to **use** the library -(not contribute to it), -check out the official documentation: -http://GoogleCloudPlatform.github.io/gcloud-python/ +.. image:: https://travis-ci.org/GoogleCloudPlatform/gcloud-python.svg?branch=master + :target: https://travis-ci.org/GoogleCloudPlatform/gcloud-python -Incredibly quick demo ---------------------- +Quickstart +---------- -Start by cloning the repository:: +The library is ``pip``-installable:: - $ git clone git://github.com/GoogleCloudPlatform/gcloud-python.git - $ cd gcloud - $ python setup.py develop + $ pip install gcloud + $ python -m gcloud.storage.demo # Runs the storage demo! + +Documentation +------------- + +- `gcloud docs (browse all services, quick-starts, walk-throughs) `_ +- `gcloud.datastore API docs `_ +- `gcloud.storage API docs `_ +- gcloud.bigquery API docs (*coming soon)* +- gcloud.compute API docs *(coming soon)* +- gcloud.dns API docs *(coming soon)* +- gcloud.sql API docs *(coming soon)* I'm getting weird errors... Can you help? ----------------------------------------- -Chances are you have some dependency problems, -if you're on Ubuntu, +Chances are you have some dependency problems... +If you're on Ubuntu, try installing the pre-compiled packages:: $ sudo apt-get install python-crypto python-openssl libffi-dev @@ -32,6 +40,7 @@ or try installing the development packages and then ``pip install`` the dependencies again:: $ sudo apt-get install python-dev libssl-dev libffi-dev + $ pip install gcloud How do I build the docs? ------------------------ @@ -50,4 +59,23 @@ Make sure you have ``nose`` installed and:: $ git clone git://github.com/GoogleCloudPlatform/gcloud-python.git $ pip install unittest2 nose + $ cd gcloud-python $ nosetests + +How can I contribute? +--------------------- + +Before we can accept any pull requests +we have to jump through a couple of legal hurdles, +primarily a Contributor License Agreement (CLA): + +- **If you are an individual writing original source code** + and you're sure you own the intellectual property, + then you'll need to sign an `individual CLA + `_. +- **If you work for a company that wants to allow you to contribute your work**, + then you'll need to sign a `corporate CLA + `_. + +You can sign these electronically (just scroll to the bottom). +After that, we'll be able to accept your pull requests. diff --git a/docs/index.rst b/docs/index.rst index 1cb740ee1122..aa382ee8abab 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,7 +12,7 @@ Google Cloud Python API .. warning:: This library is **still under construction** - and is **not** the official Google Python API client library. + and is **not** the official Google Cloud Python API client library. Getting started --------------- diff --git a/docs/storage-getting-started.rst b/docs/storage-getting-started.rst index 63e1266b5a2f..3cca86d0d5cd 100644 --- a/docs/storage-getting-started.rst +++ b/docs/storage-getting-started.rst @@ -217,10 +217,7 @@ otherwise you'll get an error. If you have a full bucket, you can delete it this way:: - >>> bucket = connection.get_bucket('my-bucket') - >>> for key in bucket: - ... key.delete() - >>> bucket.delete() + >>> bucket = connection.get_bucket('my-bucket', force=True) Listing available buckets ------------------------- diff --git a/gcloud/connection.py b/gcloud/connection.py index 35855e89b445..fb9773441f0f 100644 --- a/gcloud/connection.py +++ b/gcloud/connection.py @@ -1,4 +1,8 @@ import httplib2 +import json +import urllib + +from gcloud import exceptions class Connection(object): @@ -42,3 +46,83 @@ def http(self): self._http = self._credentials.authorize(self._http) return self._http + +class JsonConnection(Connection): + + API_BASE_URL = 'https://www.googleapis.com' + """The base of the API call URL.""" + + _EMPTY = object() + """A pointer to represent an empty value for default arguments.""" + + def __init__(self, project=None, *args, **kwargs): + + super(JsonConnection, self).__init__(*args, **kwargs) + + self.project = project + + def build_api_url(self, path, query_params=None, api_base_url=None, + api_version=None): + + url = self.API_URL_TEMPLATE.format( + api_base_url=(api_base_url or self.API_BASE_URL), + api_version=(api_version or self.API_VERSION), + path=path) + + query_params = query_params or {} + query_params.update({'project': self.project}) + url += '?' + urllib.urlencode(query_params) + + return url + + def make_request(self, method, url, data=None, content_type=None, + headers=None): + + headers = headers or {} + headers['Accept-Encoding'] = 'gzip' + + if data: + content_length = len(str(data)) + else: + content_length = 0 + + headers['Content-Length'] = content_length + + if content_type: + headers['Content-Type'] = content_type + + return self.http.request(uri=url, method=method, headers=headers, + body=data) + + def api_request(self, method, path=None, query_params=None, + data=None, content_type=None, + api_base_url=None, api_version=None, + expect_json=True): + + url = self.build_api_url(path=path, query_params=query_params, + api_base_url=api_base_url, + api_version=api_version) + + # Making the executive decision that any dictionary + # data will be sent properly as JSON. + if data and isinstance(data, dict): + data = json.dumps(data) + content_type = 'application/json' + + response, content = self.make_request( + method=method, url=url, data=data, content_type=content_type) + + # TODO: Add better error handling. + if response.status == 404: + raise exceptions.NotFoundError(response, content) + elif not 200 <= response.status < 300: + raise exceptions.ConnectionError(response, content) + + if content and expect_json: + # TODO: Better checking on this header for JSON. + content_type = response.get('content-type', '') + if not content_type.startswith('application/json'): + raise TypeError('Expected JSON, got %s' % content_type) + return json.loads(content) + + return content diff --git a/gcloud/datastore/query.py b/gcloud/datastore/query.py index 46c34c1e0933..97b68a644b91 100644 --- a/gcloud/datastore/query.py +++ b/gcloud/datastore/query.py @@ -3,6 +3,7 @@ from gcloud.datastore import datastore_v1_pb2 as datastore_pb from gcloud.datastore import helpers from gcloud.datastore.entity import Entity +from gcloud.datastore.key import Key # TODO: Figure out how to properly handle namespaces. @@ -132,6 +133,72 @@ def filter(self, expression, value): setattr(property_filter.value, attr_name, pb_value) return clone + def ancestor(self, ancestor): + """Filter the query based on an ancestor. + + This will return a clone of the current :class:`Query` + filtered by the ancestor provided. + + For example:: + + >>> parent_key = Key.from_path('Person', '1') + >>> query = dataset.query('Person') + >>> filtered_query = query.ancestor(parent_key) + + If you don't have a :class:`gcloud.datastore.key.Key` but just + know the path, you can provide that as well:: + + >>> query = dataset.query('Person') + >>> filtered_query = query.ancestor(['Person', '1']) + + Each call to ``.ancestor()`` returns a cloned :class:`Query:, + however a query may only have one ancestor at a time. + + :type ancestor: :class:`gcloud.datastore.key.Key` or list + :param ancestor: Either a Key or a path of the form + ``['Kind', 'id or name', 'Kind', 'id or name', ...]``. + + :rtype: :class:`Query` + :returns: A Query filtered by the ancestor provided. + """ + + clone = self._clone() + + # If an ancestor filter already exists, remove it. + for i, filter in enumerate(clone._pb.filter.composite_filter.filter): + property_filter = filter.property_filter + if property_filter.operator == datastore_pb.PropertyFilter.HAS_ANCESTOR: + del clone._pb.filter.composite_filter.filter[i] + + # If we just deleted the last item, make sure to clear out the filter + # property all together. + if not clone._pb.filter.composite_filter.filter: + clone._pb.ClearField('filter') + + # If the ancestor is None, just return (we already removed the filter). + if not ancestor: + return clone + + # If a list was provided, turn it into a Key. + if isinstance(ancestor, list): + ancestor = Key.from_path(*ancestor) + + # If we don't have a Key value by now, something is wrong. + if not isinstance(ancestor, Key): + raise TypeError('Expected list or Key, got %s.' % type(ancestor)) + + # Get the composite filter and add a new property filter. + composite_filter = clone._pb.filter.composite_filter + composite_filter.operator = datastore_pb.CompositeFilter.AND + + # Filter on __key__ HAS_ANCESTOR == ancestor. + ancestor_filter = composite_filter.filter.add().property_filter + ancestor_filter.property.name = '__key__' + ancestor_filter.operator = datastore_pb.PropertyFilter.HAS_ANCESTOR + ancestor_filter.value.key_value.CopyFrom(ancestor.to_protobuf()) + + return clone + def kind(self, *kinds): """Get or set the Kind of the Query. @@ -244,4 +311,5 @@ def fetch(self, limit=None): entity_pbs = self.dataset().connection().run_query( query_pb=clone.to_protobuf(), dataset_id=self.dataset().id()) - return [Entity.from_protobuf(entity) for entity in entity_pbs] + return [Entity.from_protobuf(entity, dataset=self.dataset()) + for entity in entity_pbs] diff --git a/gcloud/dns/__init__.py b/gcloud/dns/__init__.py new file mode 100644 index 000000000000..83b11cfd4022 --- /dev/null +++ b/gcloud/dns/__init__.py @@ -0,0 +1,92 @@ +"""Shortcut methods for getting set up with Google Cloud DNS. + +You'll typically use these to get started with the API: + +>>> import gcloud.dns +>>> zone = gcloud.dns.get_zone('zone-name-here', + 'long-email@googleapis.com', + '/path/to/private.key') + +The main concepts with this API are: + +- :class:`gcloud.dns.connection.Connection` + which represents a connection between your machine + and the Cloud DNS API. + +- :class:`gcloud.dns.zone.Zone` + which represents a particular zone. +""" + + +__version__ = '0.1' + +# TODO: Allow specific scopes and authorization levels. +SCOPE = ('https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/ndev.clouddns.readonly', + 'https://www.googleapis.com/auth/ndev.clouddns.readwrite') +"""The scope required for authenticating as a Cloud DNS consumer.""" + + +def get_connection(project, client_email, private_key_path): + """Shortcut method to establish a connection to Cloud DNS. + + Use this if you are going to access several zones + with the same set of credentials: + + >>> from gcloud import dns + >>> connection = dns.get_connection(project, email, key_path) + >>> zone1 = connection.get_zone('zone1') + >>> zone2 = connection.get_zone('zone2') + + :type project: string + :param project: The name of the project to connect to. + + :type client_email: string + :param client_email: The e-mail attached to the service account. + + :type private_key_path: string + :param private_key_path: The path to a private key file (this file was + given to you when you created the service + account). + + :rtype: :class:`gcloud.dns.connection.Connection` + :returns: A connection defined with the proper credentials. + """ + + from gcloud.credentials import Credentials + from gcloud.dns.connection import Connection + + credentials = Credentials.get_for_service_account( + client_email, private_key_path, scope=SCOPE) + return Connection(project=project, credentials=credentials) + + +def get_zone(zone, project, client_email, private_key_path): + """Shortcut method to establish a connection to a particular zone. + + You'll generally use this as the first call to working with the API: + + >>> from gcloud import dns + >>> zone = dns.get_zone(zone, project, email, key_path) + + :type zone: string + :param zone: The id of the zone you want to use. + This is akin to a disk name on a file system. + + :type project: string + :param project: The name of the project to connect to. + + :type client_email: string + :param client_email: The e-mail attached to the service account. + + :type private_key_path: string + :param private_key_path: The path to a private key file (this file was + given to you when you created the service + account). + + :rtype: :class:`gcloud.dns.zone.Zone` + :returns: A zone with a connection using the provided credentials. + """ + + connection = get_connection(project, client_email, private_key_path) + return connection.get_zone(zone) diff --git a/gcloud/dns/change.py b/gcloud/dns/change.py new file mode 100644 index 000000000000..243ce3b965a1 --- /dev/null +++ b/gcloud/dns/change.py @@ -0,0 +1,21 @@ +class Change(object): + """A class representing a Change on Cloud DNS. + + :type additions: list + :param name: A list of records slated to be added to a zone. + + :type deletions: list + :param data: A list of records slated to be deleted to a zone. + """ + + def __init__(self, additions=None, deletions=None): + self.additions = additions + self.deletions = deletions + + def to_dict(self): + """Format the change into a dict compatible with Cloud DNS. + + :rtype: dict + :returns: A Cloud DNS dict representation of a change. + """ + return {'additions': self.additions, 'deletions': self.deletions} diff --git a/gcloud/dns/connection.py b/gcloud/dns/connection.py new file mode 100644 index 000000000000..11bc608262f5 --- /dev/null +++ b/gcloud/dns/connection.py @@ -0,0 +1,145 @@ +from gcloud import connection +from gcloud.dns.record import Record +from gcloud.dns.zone import Zone + + +class Connection(connection.JsonConnection): + """A connection to Google Cloud DNS via the JSON REST API. + + See :class:`gcloud.connection.JsonConnection` for a full list of parameters. + :class:`Connection` differs only in needing a project name + (which you specify when creating a project in the Cloud Console). + """ + + API_VERSION = 'v1beta1' + """The version of the API, used in building the API call's URL.""" + + API_URL_TEMPLATE = ('{api_base_url}/dns/{api_version}/projects/{path}') + """A template used to craft the URL pointing toward a particular API call.""" + + _EMPTY = object() + """A pointer to represent an empty value for default arguments.""" + + def __init__(self, project=None, *args, **kwargs): + """ + :type project: string + :param project: The project name to connect to. + """ + + super(Connection, self).__init__(*args, **kwargs) + + self.project = project + + def new_zone(self, zone): + """Factory method for creating a new (unsaved) zone object. + + :type zone: string or :class:`gcloud.dns.zone.Zone` + :param zone: A name of a zone or an existing Zone object. + """ + + if isinstance(zone, Zone): + return zone + + # Support Python 2 and 3. + try: + string_type = basestring + except NameError: + string_type = str + + if isinstance(zone, string_type): + return Zone(connection=self, name=zone) + + def create_zone(self, zone, dns_name, description): + """Create a new zone. + + :type zone: string or :class:`gcloud.dns.zone.Zone` + :param zone: The zone name (or zone object) to create. + + :rtype: :class:`gcloud.dns.zone.Zone` + :returns: The newly created zone. + """ + + zone = self.new_zone(zone) + response = self.api_request(method='POST', path=zone.path, + data={'name': zone.name, 'dnsName': dns_name, + 'description': description}) + return Zone.from_dict(response, connection=self) + + def delete_zone(self, zone, force=False): + """Delete a zone. + + You can use this method to delete a zone by name, + or to delete a zone object:: + + >>> from gcloud import dns + >>> connection = dns.get_connection(project, email, key_path) + >>> connection.delete_zone('my-zone') + True + + You can also delete pass in the zone object:: + + >>> zone = connection.get_zone('other-zone') + >>> connection.delete_zone(zone) + True + + :type zone: string or :class:`gcloud.dns.zone.Zone` + :param zone: The zone name (or zone object) to create. + + :type force: bool + :param full: If True, deletes the zones's recordss then deletes it. + + :rtype: bool + :returns: True if the zone was deleted. + """ + + zone = self.new_zone(zone) + + if force: + rrsets = self.get_records(zone) + for rrset in rrsets['rrsets']: + record = Record.from_dict(rrset) + if record.type != 'NS' and record.type != 'SOA': + zone.remove_record(record) + zone.save() + + self.api_request(method='DELETE', path=zone.path + zone.name) + return True + + def get_zone(self, zone): + """Get a zone by name. + + :type zone: string + :param zone: The name of the zone to get. + + :rtype: :class:`gcloud.dns.zone.Zone` + :returns: The zone matching the name provided. + """ + + zone = self.new_zone(zone) + response = self.api_request(method='GET', path=zone.path) + return Zone.from_dict(response['managedZones'][0], connection=self) + + def get_records(self, zone): + """Get a list of resource records on a zone. + + :type zone: string or :class:`gcloud.dns.zone.Zone` + :param zone: The zone name (or zone object) to get records from. + """ + + zone = self.new_zone(zone) + return self.api_request(method='GET', path=zone.path + zone.name + + '/rrsets') + + def save_change(self, zone, change): + """Save a set of changes to a zone. + + :type zone: string or :class:`gcloud.dns.zone.Zone` + :param zone: The zone name (or zone object) to save to. + + :type change: dict + :param dict: A dict with the addition and deletion lists of records. + """ + + zone = self.new_zone(zone) + return self.api_request(method='POST', path=zone.path + zone.name + + '/changes', data=change) diff --git a/gcloud/dns/demo/__init__.py b/gcloud/dns/demo/__init__.py new file mode 100644 index 000000000000..6b050674353b --- /dev/null +++ b/gcloud/dns/demo/__init__.py @@ -0,0 +1,19 @@ +import os +from gcloud import dns + + +__all__ = ['get_connection', 'get_zone' 'CLIENT_EMAIL', 'PRIVATE_KEY_PATH', + 'PROJECT'] + + +CLIENT_EMAIL = '524635209885-rda26ks46309o10e0nc8rb7d33rn0hlm@developer.gserviceaccount.com' +PRIVATE_KEY_PATH = os.path.join(os.path.dirname(__file__), 'demo.p12') +PROJECT = 'gceremote' + + +def get_connection(): + return dns.get_connection(PROJECT, CLIENT_EMAIL, PRIVATE_KEY_PATH) + + +def get_zone(zone): + return dns.get_zone(zone, PROJECT, CLIENT_EMAIL, PRIVATE_KEY_PATH) diff --git a/gcloud/dns/demo/__main__.py b/gcloud/dns/demo/__main__.py new file mode 100644 index 000000000000..8074eae216b9 --- /dev/null +++ b/gcloud/dns/demo/__main__.py @@ -0,0 +1,5 @@ +from gcloud import demo +from gcloud import dns + + +demo.DemoRunner.from_module(dns).run() diff --git a/gcloud/dns/demo/demo.p12 b/gcloud/dns/demo/demo.p12 new file mode 100644 index 000000000000..0f5966647b38 Binary files /dev/null and b/gcloud/dns/demo/demo.p12 differ diff --git a/gcloud/dns/demo/demo.py b/gcloud/dns/demo/demo.py new file mode 100644 index 000000000000..e6eca9d89621 --- /dev/null +++ b/gcloud/dns/demo/demo.py @@ -0,0 +1,26 @@ +# Welcome to the gCloud DNS Demo! (hit enter) + +# We're going to walk through some of the basics..., +# Don't worry though. You don't need to do anything, just keep hitting enter... + +# Let's start by importing the demo module and getting a connection: +from gcloud.dns import demo +connection = demo.get_connection() + +# Lets create a zone. +zone = connection.create_zone('zone', 'zone.com.', 'My zone.') + +# Lets see what records the zone has... +print connection.get_records('zone') + +# Lets add a A record to the zone. +zone.add_a('zone.com.', ['1.1.1.1'], 9000) + +# Lets commit the changes of the zone with... +zone.save() + +# Lets see what records the zone has... +print connection.get_records('zone') + +# Finally lets clean up and delete our test zone. +zone.delete(force=True) diff --git a/gcloud/dns/exceptions.py b/gcloud/dns/exceptions.py new file mode 100644 index 000000000000..dff61f3cf740 --- /dev/null +++ b/gcloud/dns/exceptions.py @@ -0,0 +1,6 @@ +from gcloud.exceptions import Error +# TODO: Make these super useful. + + +class DNSError(Error): + pass diff --git a/gcloud/dns/record.py b/gcloud/dns/record.py new file mode 100644 index 000000000000..857bcb62f5aa --- /dev/null +++ b/gcloud/dns/record.py @@ -0,0 +1,65 @@ +class Record(object): + """A class representing a Resource Record Set on Cloud DNS. + + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + + :type type: string + :param string: The type of DNS record. + """ + + def __init__(self, name=None, data=[], ttl=None, type=None): + self.name = name + self.data = data + self.ttl = ttl + self.type = type + + @classmethod + def from_dict(cls, record_dict): + """Construct a new record from a dictionary of data from Cloud DNS. + + :type record_dict: dict + :param record_dict: The dictionary of data to construct a record from. + + :rtype: :class:`Record` + :returns: A record constructed from the data provided. + """ + + return cls(name=record_dict['name'], data=record_dict['rrdatas'], + ttl=record_dict['ttl'], type=record_dict['type']) + + def __str__(self): + """Format the record when printed. + + :rtype: string + :returns: A formated record string. + """ + + record = ('{name} {ttl} IN {type} {data}') + return record.format(name=self.name, ttl=self.ttl, type=self.type, + data=self.data) + + def add_data(self, data): + """Add to the list of resource record data for the record. + + :type data: string + :param data: The textual representation of a resourse record. + """ + + self.data.append(data) + + def to_dict(self): + """Format the record into a dict compatible with Cloud DNS. + + :rtype: dict + :returns: A Cloud DNS dict representation of a record. + """ + + return {'name': self.name, 'rrdatas': self.data, 'ttl': self.ttl, + 'type': self.type} diff --git a/gcloud/dns/zone.py b/gcloud/dns/zone.py new file mode 100644 index 000000000000..bb9924da81a8 --- /dev/null +++ b/gcloud/dns/zone.py @@ -0,0 +1,260 @@ +from gcloud.dns.change import Change +from gcloud.dns.record import Record + + +class Zone(object): + """A class representing a Managed Zone on Cloud DNS. + + :type connection: :class:`gcloud.dns.connection.Connection` + :param connection: The connection to use when sending requests. + + :type creation_time: string + :param connection_time: Time that this zone was created on the server. + + :type description: string + :param data: A description of the zone. + + :type dns_name: string + :param data: The DNS name of the zone. + + :type id: unsigned long + :param data: Unique identifier defined by the server. + + :type kind: string + :param data: Identifies what kind of resource. + + :type name_servers: list + :param name_servers: List of virtual name servers of the zone. + """ + + def __init__(self, connection=None, creation_time=None, + description=None, dns_name=None, id=None, kind=None, name=None, + name_servers=None): + self.additions = [] + self.connection = connection + self.creation_time = creation_time + self.deletions = [] + self.description = description + self.dns_name = dns_name + self.id = id + self.kind = kind + self.name = name + self.name_servers = name_servers + + @classmethod + def from_dict(cls, zone_dict, connection=None): + """Construct a new zone from a dictionary of data from Cloud DNS. + + :type zone_dict: dict + :param zone_dict: The dictionary of data to construct a record from. + + :rtype: :class:`Zone` + :returns: A zone constructed from the data provided. + """ + + return cls(connection=connection, + creation_time=zone_dict['creationTime'], + description=zone_dict['description'], + dns_name=zone_dict['dnsName'], id=zone_dict['id'], + kind=zone_dict['kind'], name=zone_dict['name'], + name_servers=zone_dict['nameServers']) + + @property + def path(self): + """The URL path to this zone.""" + + if not self.connection.project: + raise ValueError('Cannot determine path without project name.') + + return self.connection.project + '/managedZones/' + + def delete(self, force=False): + """Delete this zone. + + The zone **must** be empty in order to delete it. + + If you want to delete a non-empty zone you can pass + in a force parameter set to true. + This will iterate through the zones's records and delete the related + records, before deleting the zone. + + :type force: bool + :param full: If True, deletes the zones's records then deletes it. + """ + + return self.connection.delete_zone(self.name, force=force) + + def save(self): + """Commit all the additions and deletions of records on this zone. + """ + + change = Change(additions=self.additions, deletions=self.deletions) + self.connection.save_change(self.name, change.to_dict()) + self.additions = [] + self.deletions = [] + return True + + def add_record(self, record): + """Add a record to the dict of records to be added to the zone. + + :type record: dict or :class:`Record` + :param record: A dict representation of a record to be added. + """ + + if isinstance(record, Record): + record = record.to_dict() + + if isinstance(record, dict): + self.additions.append(record) + + # Throw type error here. + + def remove_record(self, record): + """Add a record to the dict of records to be deleted to the zone. + + :type record: dict or :class:`Record` + :param record: A dict representation of a record to be deleted. + """ + + if isinstance(record, Record): + record = record.to_dict() + + if isinstance(record, dict): + self.deletions.append(record) + + # Throw type error here. + + def add_a(self, name, data, ttl): + """ Shortcut method to add a A record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'A') + self.add_record(record) + + def add_aaaa(self, name, data, ttl): + """ Shortcut method to add a AAAA record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'AAAA') + self.add_record(record) + + def add_cname(self, name, data, ttl): + """ Shortcut method to add a CNAME record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'CNAME') + self.add_record(record) + + def add_mx(self, name, data, ttl): + """ Shortcut method to add a MX record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'MX') + self.add_record(record) + + def add_ns(self, name, data, ttl): + """ Shortcut method to add a NS record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'NS') + self.add_record(record) + + def add_ptr(self, name, data, ttl): + """ Shortcut method to add a PTR record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'PTR') + self.add_record(record) + + def add_soa(self, name, data, ttl): + """ Shortcut method to add a SOA record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'SOA') + self.add_record(record) + + def add_spf(self, name, data, ttl): + """ Shortcut method to add a SRV record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'SRV') + self.add_record(record) + + def add_txt(self, name, data, ttl): + """ Shortcut method to add a TXT record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'TXT') + self.add_record(record) diff --git a/gcloud/exceptions.py b/gcloud/exceptions.py new file mode 100644 index 000000000000..b05d5586307b --- /dev/null +++ b/gcloud/exceptions.py @@ -0,0 +1,18 @@ +# TODO: Make these super useful. + + +class Error(Exception): + pass + + +class ConnectionError(Error): + + def __init__(self, response, content): + message = str(response) + content + super(ConnectionError, self).__init__(message) + + +class NotFoundError(Error): + + def __init__(self, response, content): + self.message = 'GET %s returned a 404.' % (response.url) diff --git a/gcloud/storage/__init__.py b/gcloud/storage/__init__.py index e53c3df71981..f2e0b51f252b 100644 --- a/gcloud/storage/__init__.py +++ b/gcloud/storage/__init__.py @@ -37,19 +37,19 @@ 'https://www.googleapis.com/auth/devstorage.read_write') -def get_connection(project_name, client_email, private_key_path): +def get_connection(project, client_email, private_key_path): """Shortcut method to establish a connection to Cloud Storage. Use this if you are going to access several buckets with the same set of credentials: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> bucket1 = connection.get_bucket('bucket1') >>> bucket2 = connection.get_bucket('bucket2') - :type project_name: string - :param project_name: The name of the project to connect to. + :type project: string + :param project: The name of the project to connect to. :type client_email: string :param client_email: The e-mail attached to the service account. @@ -68,15 +68,15 @@ def get_connection(project_name, client_email, private_key_path): credentials = Credentials.get_for_service_account( client_email, private_key_path, scope=SCOPE) - return Connection(project_name=project_name, credentials=credentials) + return Connection(project=project, credentials=credentials) -def get_bucket(bucket_name, project_name, client_email, private_key_path): +def get_bucket(bucket_name, project, client_email, private_key_path): """Shortcut method to establish a connection to a particular bucket. You'll generally use this as the first call to working with the API: >>> from gcloud import storage - >>> bucket = storage.get_bucket(project_name, bucket_name, email, key_path) + >>> bucket = storage.get_bucket(project, bucket_name, email, key_path) >>> # Now you can do things with the bucket. >>> bucket.exists('/path/to/file.txt') False @@ -85,8 +85,8 @@ def get_bucket(bucket_name, project_name, client_email, private_key_path): :param bucket_name: The id of the bucket you want to use. This is akin to a disk name on a file system. - :type project_name: string - :param project_name: The name of the project to connect to. + :type project: string + :param project: The name of the project to connect to. :type client_email: string :param client_email: The e-mail attached to the service account. @@ -100,5 +100,5 @@ def get_bucket(bucket_name, project_name, client_email, private_key_path): :returns: A bucket with a connection using the provided credentials. """ - connection = get_connection(project_name, client_email, private_key_path) + connection = get_connection(project, client_email, private_key_path) return connection.get_bucket(bucket_name) diff --git a/gcloud/storage/acl.py b/gcloud/storage/acl.py index 714beffc4a06..9ce9612f661d 100644 --- a/gcloud/storage/acl.py +++ b/gcloud/storage/acl.py @@ -8,7 +8,7 @@ :func:`gcloud.storage.bucket.Bucket.get_acl`:: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> bucket = connection.get_bucket(bucket_name) >>> acl = bucket.get_acl() diff --git a/gcloud/storage/bucket.py b/gcloud/storage/bucket.py index 6c3cfe07ff00..13b5b91bcf83 100644 --- a/gcloud/storage/bucket.py +++ b/gcloud/storage/bucket.py @@ -62,7 +62,7 @@ def get_key(self, key): This will return None if the key doesn't exist:: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> bucket = connection.get_bucket('my-bucket') >>> print bucket.get_key('/path/to/key.txt') @@ -130,7 +130,7 @@ def new_key(self, key): raise TypeError('Invalid key: %s' % key) - def delete(self): + def delete(self, force=False): """Delete this bucket. The bucket **must** be empty in order to delete it. @@ -139,12 +139,20 @@ def delete(self): If the bucket is not empty, this will raise an Exception. + If you want to delete a non-empty bucket you can pass + in a force parameter set to true. + This will iterate through the bucket's keys and delete the related objects, + before deleting the bucket. + + :type force: bool + :param full: If True, empties the bucket's objects then deletes it. + :raises: :class:`gcloud.storage.exceptions.NotFoundError` """ # TODO: Make sure the proper exceptions are raised. - return self.connection.delete_bucket(self.name) + return self.connection.delete_bucket(self.name, force=force) def delete_key(self, key): # TODO: Should we accept a 'silent' param here to not raise an exception? @@ -157,7 +165,7 @@ def delete_key(self, key): >>> from gcloud import storage >>> from gcloud.storage import exceptions - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> bucket = connection.get_bucket('my-bucket') >>> print bucket.get_all_keys() [] @@ -198,7 +206,7 @@ def upload_file(self, filename, key=None): For example:: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> bucket = connection.get_bucket('my-bucket') >>> bucket.upload_file('~/my-file.txt', 'remote-text-file.txt') >>> print bucket.get_all_keys() @@ -210,7 +218,7 @@ def upload_file(self, filename, key=None): (**not** the complete path):: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> bucket = connection.get_bucket('my-bucket') >>> bucket.upload_file('~/my-file.txt') >>> print bucket.get_all_keys() @@ -329,7 +337,7 @@ def configure_website(self, main_page_suffix=None, not_found_page=None): and a page to use when a key isn't found:: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, private_key_path) + >>> connection = storage.get_connection(project, email, private_key_path) >>> bucket = connection.get_bucket(bucket_name) >>> bucket.configure_website('index.html', '404.html') @@ -460,7 +468,7 @@ def clear_acl(self): to a bunch of coworkers:: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, private_key_path) + >>> connection = storage.get_connection(project, email, private_key_path) >>> bucket = connection.get_bucket(bucket_name) >>> acl = bucket.get_acl() >>> acl.user('coworker1@example.org').grant_read() diff --git a/gcloud/storage/connection.py b/gcloud/storage/connection.py index ec25a843f181..214d15824a3d 100644 --- a/gcloud/storage/connection.py +++ b/gcloud/storage/connection.py @@ -31,7 +31,7 @@ class Connection(connection.Connection): :class:`gcloud.storage.bucket.Bucket` objects:: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> bucket = connection.create_bucket('my-bucket-name') You can then delete this bucket:: @@ -67,15 +67,15 @@ class Connection(connection.Connection): API_ACCESS_ENDPOINT = 'https://storage.googleapis.com' - def __init__(self, project_name, *args, **kwargs): + def __init__(self, project, *args, **kwargs): """ - :type project_name: string - :param project_name: The project name to connect to. + :type project: string + :param project: The project name to connect to. """ super(Connection, self).__init__(*args, **kwargs) - self.project_name = project_name + self.project = project def __iter__(self): return iter(BucketIterator(connection=self)) @@ -115,7 +115,7 @@ def build_api_url(self, path, query_params=None, api_base_url=None, path=path) query_params = query_params or {} - query_params.update({'project': self.project_name}) + query_params.update({'project': self.project}) url += '?' + urllib.urlencode(query_params) return url @@ -242,7 +242,7 @@ def get_all_buckets(self, *args, **kwargs): so these two operations are identical:: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> for bucket in connection.get_all_buckets(): >>> print bucket >>> # ... is the same as ... @@ -269,7 +269,7 @@ def get_bucket(self, bucket_name, *args, **kwargs): >>> from gcloud import storage >>> from gcloud.storage import exceptions - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> try: >>> bucket = connection.get_bucket('my-bucket') >>> except exceptions.NotFoundError: @@ -296,7 +296,7 @@ def lookup(self, bucket_name): than catching an exception:: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> bucket = connection.get_bucket('doesnt-exist') >>> print bucket None @@ -323,7 +323,7 @@ def create_bucket(self, bucket, *args, **kwargs): For example:: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, client, key_path) + >>> connection = storage.get_connection(project, client, key_path) >>> bucket = connection.create_bucket('my-bucket') >>> print bucket @@ -340,14 +340,14 @@ def create_bucket(self, bucket, *args, **kwargs): data={'name': bucket.name}) return Bucket.from_dict(response, connection=self) - def delete_bucket(self, bucket, *args, **kwargs): + def delete_bucket(self, bucket, force=False, *args, **kwargs): """Delete a bucket. You can use this method to delete a bucket by name, or to delete a bucket object:: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> connection.delete_bucket('my-bucket') True @@ -369,12 +369,21 @@ def delete_bucket(self, bucket, *args, **kwargs): :type bucket: string or :class:`gcloud.storage.bucket.Bucket` :param bucket: The bucket name (or bucket object) to create. + :type force: bool + :param full: If True, empties the bucket's objects then deletes it. + :rtype: bool :returns: True if the bucket was deleted. :raises: :class:`gcloud.storage.exceptions.NotFoundError` """ bucket = self.new_bucket(bucket) + + # This force delete operation is slow. + if force: + for key in bucket: + key.delete() + response = self.api_request(method='DELETE', path=bucket.path) return True diff --git a/gcloud/storage/demo/__init__.py b/gcloud/storage/demo/__init__.py index 13d862564fc9..7aaa2dca6697 100644 --- a/gcloud/storage/demo/__init__.py +++ b/gcloud/storage/demo/__init__.py @@ -2,13 +2,13 @@ from gcloud import storage -__all__ = ['get_connection', 'CLIENT_EMAIL', 'PRIVATE_KEY_PATH', 'PROJECT_NAME'] +__all__ = ['get_connection', 'CLIENT_EMAIL', 'PRIVATE_KEY_PATH', 'PROJECT'] CLIENT_EMAIL = '606734090113-6ink7iugcv89da9sru7lii8bs3i0obqg@developer.gserviceaccount.com' PRIVATE_KEY_PATH = os.path.join(os.path.dirname(__file__), 'demo.key') -PROJECT_NAME = 'gcloud-storage-demo' +PROJECT = 'gcloud-storage-demo' def get_connection(): - return storage.get_connection(PROJECT_NAME, CLIENT_EMAIL, PRIVATE_KEY_PATH) + return storage.get_connection(PROJECT, CLIENT_EMAIL, PRIVATE_KEY_PATH) diff --git a/gcloud/storage/exceptions.py b/gcloud/storage/exceptions.py index 1d23a96cfdb8..dff01204bc95 100644 --- a/gcloud/storage/exceptions.py +++ b/gcloud/storage/exceptions.py @@ -14,7 +14,7 @@ def __init__(self, response, content): class NotFoundError(ConnectionError): def __init__(self, response, content): - self.message = 'GET %s returned a 404.' % (response.url) + self.message = 'Request returned a 404. Headers: %s' % (response) class StorageDataError(StorageError): diff --git a/gcloud/storage/key.py b/gcloud/storage/key.py index 87348c261666..9b970bd12f1d 100644 --- a/gcloud/storage/key.py +++ b/gcloud/storage/key.py @@ -264,7 +264,7 @@ def set_contents_from_string(self, data, content_type='text/plain'): You can use this method to quickly set the value of a key:: >>> from gcloud import storage - >>> connection = storage.get_connection(project_name, email, key_path) + >>> connection = storage.get_connection(project, email, key_path) >>> bucket = connection.get_bucket(bucket_name) >>> key = bucket.new_key('my_text_file.txt') >>> key.set_contents_from_string('This is the contents of my file!') diff --git a/gcloud/storage/test_connection.py b/gcloud/storage/test_connection.py index d15c0e11484e..cfb4ff60a6a9 100644 --- a/gcloud/storage/test_connection.py +++ b/gcloud/storage/test_connection.py @@ -1,10 +1,19 @@ import unittest2 from gcloud.storage.connection import Connection - +from gcloud.storage.exceptions import NotFoundError class TestConnection(unittest2.TestCase): def test_init(self): connection = Connection('project-name') - self.assertEqual('project-name', connection.project_name) + self.assertEqual('project-name', connection.project) + + +class TestExceptions(unittest2.TestCase): + + def test_not_found_always_prints(self): + e = NotFoundError({}, None) + self.assertEqual('', str(e)) + +