cloudcat 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudcat/__init__.py +5 -0
- cloudcat/cli.py +345 -0
- cloudcat-0.1.1.dist-info/METADATA +60 -0
- cloudcat-0.1.1.dist-info/RECORD +8 -0
- cloudcat-0.1.1.dist-info/WHEEL +5 -0
- cloudcat-0.1.1.dist-info/entry_points.txt +2 -0
- cloudcat-0.1.1.dist-info/licenses/LICENSE +21 -0
- cloudcat-0.1.1.dist-info/top_level.txt +1 -0
cloudcat/__init__.py
ADDED
cloudcat/cli.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
import click
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import sys
|
|
5
|
+
import io
|
|
6
|
+
from tabulate import tabulate
|
|
7
|
+
from colorama import init, Fore, Style
|
|
8
|
+
import json
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
# Initialize colorama
|
|
13
|
+
init()
|
|
14
|
+
|
|
15
|
+
# For GCS
|
|
16
|
+
try:
|
|
17
|
+
from google.cloud import storage as gcs
|
|
18
|
+
HAS_GCS = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
HAS_GCS = False
|
|
21
|
+
|
|
22
|
+
# For S3
|
|
23
|
+
try:
|
|
24
|
+
import boto3
|
|
25
|
+
import botocore
|
|
26
|
+
HAS_S3 = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
HAS_S3 = False
|
|
29
|
+
|
|
30
|
+
# For Parquet
|
|
31
|
+
try:
|
|
32
|
+
import pyarrow.parquet as pq
|
|
33
|
+
import pyarrow as pa
|
|
34
|
+
HAS_PARQUET = True
|
|
35
|
+
except ImportError:
|
|
36
|
+
HAS_PARQUET = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_cloud_path(path):
|
|
40
|
+
"""Parse a cloud storage path into service, bucket, and object components."""
|
|
41
|
+
parsed = urlparse(path)
|
|
42
|
+
|
|
43
|
+
if parsed.scheme == 'gs' or parsed.scheme == 'gcs':
|
|
44
|
+
service = 'gcs'
|
|
45
|
+
bucket = parsed.netloc
|
|
46
|
+
object_path = parsed.path.lstrip('/')
|
|
47
|
+
elif parsed.scheme == 's3':
|
|
48
|
+
service = 's3'
|
|
49
|
+
bucket = parsed.netloc
|
|
50
|
+
object_path = parsed.path.lstrip('/')
|
|
51
|
+
else:
|
|
52
|
+
raise ValueError(f"Unsupported scheme: {parsed.scheme}. Use gcs:// or s3://")
|
|
53
|
+
|
|
54
|
+
return service, bucket, object_path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def detect_format_from_path(path):
|
|
58
|
+
"""Detect file format from file extension."""
|
|
59
|
+
if path.lower().endswith('.json'):
|
|
60
|
+
return 'json'
|
|
61
|
+
elif path.lower().endswith('.csv'):
|
|
62
|
+
return 'csv'
|
|
63
|
+
elif path.lower().endswith('.parquet'):
|
|
64
|
+
return 'parquet'
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError(f"Could not infer format from path: {path}. Please specify --input-format.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_gcs_stream(bucket_name, object_name):
|
|
70
|
+
"""Get a file stream from GCS with minimal downloading."""
|
|
71
|
+
if not HAS_GCS:
|
|
72
|
+
sys.stderr.write(Fore.RED + "Error: google-cloud-storage package is required for GCS access.\n" +
|
|
73
|
+
"Install it with: pip install google-cloud-storage\n" + Style.RESET_ALL)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
|
|
76
|
+
client = gcs.Client()
|
|
77
|
+
bucket = client.bucket(bucket_name)
|
|
78
|
+
blob = bucket.blob(object_name)
|
|
79
|
+
|
|
80
|
+
# Create a streaming buffer
|
|
81
|
+
buffer = io.BytesIO()
|
|
82
|
+
blob.download_to_file(buffer)
|
|
83
|
+
buffer.seek(0)
|
|
84
|
+
|
|
85
|
+
return buffer
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_s3_stream(bucket_name, object_name):
|
|
89
|
+
"""Get a file stream from S3."""
|
|
90
|
+
if not HAS_S3:
|
|
91
|
+
sys.stderr.write(Fore.RED + "Error: boto3 package is required for S3 access.\n" +
|
|
92
|
+
"Install it with: pip install boto3\n" + Style.RESET_ALL)
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
s3 = boto3.client('s3')
|
|
96
|
+
response = s3.get_object(Bucket=bucket_name, Key=object_name)
|
|
97
|
+
return response['Body']
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_csv_data(stream, num_rows, columns=None):
|
|
101
|
+
"""Read CSV data from a stream."""
|
|
102
|
+
if num_rows > 0:
|
|
103
|
+
df = pd.read_csv(stream, nrows=num_rows)
|
|
104
|
+
else:
|
|
105
|
+
df = pd.read_csv(stream)
|
|
106
|
+
|
|
107
|
+
if columns:
|
|
108
|
+
cols = [c.strip() for c in columns.split(',')]
|
|
109
|
+
valid_cols = [c for c in cols if c in df.columns]
|
|
110
|
+
if len(valid_cols) != len(cols):
|
|
111
|
+
missing = set(cols) - set(valid_cols)
|
|
112
|
+
click.echo(Fore.YELLOW + f"Warning: Columns not found: {', '.join(missing)}" + Style.RESET_ALL)
|
|
113
|
+
df = df[valid_cols]
|
|
114
|
+
|
|
115
|
+
return df
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_json_data(stream, num_rows, columns=None):
|
|
119
|
+
"""Read JSON data from a stream."""
|
|
120
|
+
if num_rows > 0:
|
|
121
|
+
df = pd.read_json(stream, lines=True, nrows=num_rows)
|
|
122
|
+
else:
|
|
123
|
+
df = pd.read_json(stream, lines=True)
|
|
124
|
+
|
|
125
|
+
if columns:
|
|
126
|
+
cols = [c.strip() for c in columns.split(',')]
|
|
127
|
+
valid_cols = [c for c in cols if c in df.columns]
|
|
128
|
+
if len(valid_cols) != len(cols):
|
|
129
|
+
missing = set(cols) - set(valid_cols)
|
|
130
|
+
click.echo(Fore.YELLOW + f"Warning: Columns not found: {', '.join(missing)}" + Style.RESET_ALL)
|
|
131
|
+
df = df[valid_cols]
|
|
132
|
+
|
|
133
|
+
return df
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read_parquet_data(stream, num_rows, columns=None):
|
|
137
|
+
"""Read Parquet data from a stream."""
|
|
138
|
+
if not HAS_PARQUET:
|
|
139
|
+
sys.stderr.write(Fore.RED + "Error: pyarrow package is required for Parquet support.\n" +
|
|
140
|
+
"Install it with: pip install pyarrow\n" + Style.RESET_ALL)
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
# For Parquet, we need a temporary file to properly read the metadata
|
|
144
|
+
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
|
145
|
+
temp_path = temp_file.name
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
# If stream is a file-like object, copy to temp file
|
|
149
|
+
if hasattr(stream, 'read'):
|
|
150
|
+
with open(temp_path, 'wb') as f:
|
|
151
|
+
f.write(stream.read())
|
|
152
|
+
else:
|
|
153
|
+
# Assume it's already a path
|
|
154
|
+
temp_path = stream
|
|
155
|
+
|
|
156
|
+
parquet_file = pq.ParquetFile(temp_path)
|
|
157
|
+
|
|
158
|
+
# Select columns if specified
|
|
159
|
+
col_names = columns.split(',') if columns else None
|
|
160
|
+
|
|
161
|
+
# Read the data efficiently
|
|
162
|
+
if num_rows > 0:
|
|
163
|
+
tables = []
|
|
164
|
+
rows_read = 0
|
|
165
|
+
|
|
166
|
+
for i in range(parquet_file.num_row_groups):
|
|
167
|
+
if rows_read >= num_rows:
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
table = parquet_file.read_row_group(i, columns=col_names)
|
|
171
|
+
|
|
172
|
+
# Limit rows if needed for the last batch
|
|
173
|
+
if rows_read + table.num_rows > num_rows:
|
|
174
|
+
table = table.slice(0, num_rows - rows_read)
|
|
175
|
+
|
|
176
|
+
tables.append(table)
|
|
177
|
+
rows_read += min(table.num_rows, num_rows - rows_read)
|
|
178
|
+
|
|
179
|
+
if tables:
|
|
180
|
+
result_table = pa.concat_tables(tables)
|
|
181
|
+
return result_table.to_pandas()
|
|
182
|
+
else:
|
|
183
|
+
return pd.DataFrame()
|
|
184
|
+
else:
|
|
185
|
+
# Read all data (potentially with column filtering)
|
|
186
|
+
table = parquet_file.read(columns=col_names)
|
|
187
|
+
return table.to_pandas()
|
|
188
|
+
|
|
189
|
+
finally:
|
|
190
|
+
# Clean up the temporary file
|
|
191
|
+
import os
|
|
192
|
+
try:
|
|
193
|
+
if hasattr(stream, 'read'): # Only delete if we created temp file
|
|
194
|
+
os.unlink(temp_path)
|
|
195
|
+
except:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def read_data(service, bucket, object_path, input_format, num_rows, columns=None):
|
|
200
|
+
"""Read data from cloud storage."""
|
|
201
|
+
# Get appropriate stream based on service
|
|
202
|
+
if service == 'gcs':
|
|
203
|
+
stream = get_gcs_stream(bucket, object_path)
|
|
204
|
+
elif service == 's3':
|
|
205
|
+
stream = get_s3_stream(bucket, object_path)
|
|
206
|
+
else:
|
|
207
|
+
raise ValueError(f"Unsupported service: {service}")
|
|
208
|
+
|
|
209
|
+
# Read based on format
|
|
210
|
+
if input_format == 'csv':
|
|
211
|
+
return read_csv_data(stream, num_rows, columns)
|
|
212
|
+
elif input_format == 'json':
|
|
213
|
+
return read_json_data(stream, num_rows, columns)
|
|
214
|
+
elif input_format == 'parquet':
|
|
215
|
+
return read_parquet_data(stream, num_rows, columns)
|
|
216
|
+
else:
|
|
217
|
+
raise ValueError(f"Unsupported format: {input_format}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_record_count(service, bucket, object_path, input_format):
|
|
221
|
+
"""Get record count from a file."""
|
|
222
|
+
if input_format == 'parquet' and HAS_PARQUET:
|
|
223
|
+
# For Parquet, we can get count from metadata
|
|
224
|
+
if service == 'gcs':
|
|
225
|
+
stream = get_gcs_stream(bucket, object_path)
|
|
226
|
+
elif service == 's3':
|
|
227
|
+
stream = get_s3_stream(bucket, object_path)
|
|
228
|
+
|
|
229
|
+
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
|
230
|
+
temp_path = temp_file.name
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
if hasattr(stream, 'read'):
|
|
234
|
+
with open(temp_path, 'wb') as f:
|
|
235
|
+
f.write(stream.read())
|
|
236
|
+
else:
|
|
237
|
+
temp_path = stream
|
|
238
|
+
|
|
239
|
+
parquet_file = pq.ParquetFile(temp_path)
|
|
240
|
+
return parquet_file.metadata.num_rows
|
|
241
|
+
finally:
|
|
242
|
+
import os
|
|
243
|
+
try:
|
|
244
|
+
if hasattr(stream, 'read'):
|
|
245
|
+
os.unlink(temp_path)
|
|
246
|
+
except:
|
|
247
|
+
pass
|
|
248
|
+
else:
|
|
249
|
+
# For CSV and JSON, we need to count the rows
|
|
250
|
+
click.echo(Fore.YELLOW + "Counting records (this might take a while for large files)..." + Style.RESET_ALL)
|
|
251
|
+
|
|
252
|
+
# Use pandas to count rows in chunks
|
|
253
|
+
if service == 'gcs':
|
|
254
|
+
stream = get_gcs_stream(bucket, object_path)
|
|
255
|
+
elif service == 's3':
|
|
256
|
+
stream = get_s3_stream(bucket, object_path)
|
|
257
|
+
|
|
258
|
+
if input_format == 'csv':
|
|
259
|
+
chunk_count = 0
|
|
260
|
+
for chunk in pd.read_csv(stream, chunksize=10000):
|
|
261
|
+
chunk_count += len(chunk)
|
|
262
|
+
return chunk_count
|
|
263
|
+
elif input_format == 'json':
|
|
264
|
+
chunk_count = 0
|
|
265
|
+
for chunk in pd.read_json(stream, lines=True, chunksize=10000):
|
|
266
|
+
chunk_count += len(chunk)
|
|
267
|
+
return chunk_count
|
|
268
|
+
|
|
269
|
+
return "Unknown"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@click.command()
|
|
273
|
+
@click.option('--path', required=True, help='Path to the file (gcs://... or s3://...)')
|
|
274
|
+
@click.option('--output-format', type=click.Choice(['json', 'csv', 'table']), default='table',
|
|
275
|
+
help='Output format (default: table)')
|
|
276
|
+
@click.option('--input-format', type=click.Choice(['json', 'csv', 'parquet']),
|
|
277
|
+
help='Input format (default: inferred from path)')
|
|
278
|
+
@click.option('--columns', help='Comma-separated list of columns to display (default: all)')
|
|
279
|
+
@click.option('--num-rows', '-n', default=10, type=int, help='Number of rows to display (default: 10)')
|
|
280
|
+
@click.option('--schema', type=click.Choice(['show', 'dont_show', 'schema_only']), default='show',
|
|
281
|
+
help='Schema display option (default: show)')
|
|
282
|
+
@click.option('--count', is_flag=True, help='Show record count at the end')
|
|
283
|
+
def main(path, output_format, input_format, columns, num_rows, schema, count):
|
|
284
|
+
"""Display data from files in Google Cloud Storage or AWS S3.
|
|
285
|
+
|
|
286
|
+
Example usage:
|
|
287
|
+
|
|
288
|
+
\b
|
|
289
|
+
# Read from GCS
|
|
290
|
+
cloudcat --path gcs://my-bucket/data.csv --output-format table
|
|
291
|
+
|
|
292
|
+
\b
|
|
293
|
+
# Read from S3 with column selection
|
|
294
|
+
cloudcat --path s3://my-bucket/data.parquet --columns id,name,value
|
|
295
|
+
|
|
296
|
+
\b
|
|
297
|
+
# Show record count and limit to 20 rows
|
|
298
|
+
cloudcat --path gcs://bucket/events.json --num-rows 20 --count
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
# Parse the path
|
|
302
|
+
service, bucket, object_path = parse_cloud_path(path)
|
|
303
|
+
|
|
304
|
+
# Determine input format if not specified
|
|
305
|
+
if not input_format:
|
|
306
|
+
input_format = detect_format_from_path(object_path)
|
|
307
|
+
click.echo(Fore.BLUE + f"Inferred input format: {input_format}" + Style.RESET_ALL)
|
|
308
|
+
|
|
309
|
+
# Read the data
|
|
310
|
+
df = read_data(service, bucket, object_path, input_format, num_rows, columns)
|
|
311
|
+
|
|
312
|
+
# Display schema if requested
|
|
313
|
+
if schema in ['show', 'schema_only']:
|
|
314
|
+
click.echo(Fore.GREEN + "Schema:" + Style.RESET_ALL)
|
|
315
|
+
for col, dtype in df.dtypes.items():
|
|
316
|
+
click.echo(f" {col}: {dtype}")
|
|
317
|
+
click.echo("")
|
|
318
|
+
|
|
319
|
+
# Exit if only schema was requested
|
|
320
|
+
if schema == 'schema_only':
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
# Display the data
|
|
324
|
+
if output_format == 'table':
|
|
325
|
+
click.echo(tabulate(df, headers='keys', tablefmt='psql', showindex=False))
|
|
326
|
+
elif output_format == 'json':
|
|
327
|
+
click.echo(df.to_json(orient='records', lines=True))
|
|
328
|
+
elif output_format == 'csv':
|
|
329
|
+
click.echo(df.to_csv(index=False))
|
|
330
|
+
|
|
331
|
+
# Count records if requested
|
|
332
|
+
if count:
|
|
333
|
+
try:
|
|
334
|
+
total_count = get_record_count(service, bucket, object_path, input_format)
|
|
335
|
+
click.echo(Fore.CYAN + f"\nTotal records: {total_count}" + Style.RESET_ALL)
|
|
336
|
+
except Exception as e:
|
|
337
|
+
click.echo(Fore.YELLOW + f"\nCould not count records: {str(e)}" + Style.RESET_ALL)
|
|
338
|
+
|
|
339
|
+
except Exception as e:
|
|
340
|
+
click.echo(Fore.RED + f"Error: {str(e)}" + Style.RESET_ALL, err=True)
|
|
341
|
+
sys.exit(1)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == '__main__':
|
|
345
|
+
main()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cloudcat
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A CLI utility to read and display files from cloud storage
|
|
5
|
+
Home-page: https://github.com/yourusername/cloudcat
|
|
6
|
+
Author: Your Name
|
|
7
|
+
Author-email: your.email@example.com
|
|
8
|
+
Keywords: cloud,gcs,s3,cli,storage,data
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Topic :: Utilities
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Requires-Python: >=3.7
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: click>=8.0.0
|
|
21
|
+
Requires-Dist: pandas>=1.3.0
|
|
22
|
+
Requires-Dist: tabulate>=0.8.9
|
|
23
|
+
Requires-Dist: colorama>=0.4.4
|
|
24
|
+
Provides-Extra: gcs
|
|
25
|
+
Requires-Dist: google-cloud-storage>=2.0.0; extra == "gcs"
|
|
26
|
+
Provides-Extra: s3
|
|
27
|
+
Requires-Dist: boto3>=1.18.0; extra == "s3"
|
|
28
|
+
Provides-Extra: parquet
|
|
29
|
+
Requires-Dist: pyarrow>=5.0.0; extra == "parquet"
|
|
30
|
+
Provides-Extra: all
|
|
31
|
+
Requires-Dist: google-cloud-storage>=2.0.0; extra == "all"
|
|
32
|
+
Requires-Dist: boto3>=1.18.0; extra == "all"
|
|
33
|
+
Requires-Dist: pyarrow>=5.0.0; extra == "all"
|
|
34
|
+
Dynamic: author
|
|
35
|
+
Dynamic: author-email
|
|
36
|
+
Dynamic: classifier
|
|
37
|
+
Dynamic: description
|
|
38
|
+
Dynamic: description-content-type
|
|
39
|
+
Dynamic: home-page
|
|
40
|
+
Dynamic: keywords
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
Dynamic: provides-extra
|
|
43
|
+
Dynamic: requires-dist
|
|
44
|
+
Dynamic: requires-python
|
|
45
|
+
Dynamic: summary
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# CloudCat
|
|
49
|
+
|
|
50
|
+
A command-line utility to read and display files from Google Cloud Storage and AWS S3 buckets.
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
- Read files from GCS (gcs://) or S3 (s3://)
|
|
55
|
+
- Stream data (avoid downloading entire files when possible)
|
|
56
|
+
- Support for CSV, JSON, and Parquet formats
|
|
57
|
+
- Column selection and row limiting
|
|
58
|
+
- Schema display
|
|
59
|
+
- Record counting
|
|
60
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
cloudcat/__init__.py,sha256=ZntHrMzdkU3hB1jG5FaBVk25PFOl-JZaGTX2sYhm62w,122
|
|
2
|
+
cloudcat/cli.py,sha256=nKfjOHEVx3Qszj9cXouMsZhlmgAHY6rdmNvM5-zlOyQ,11793
|
|
3
|
+
cloudcat-0.1.1.dist-info/licenses/LICENSE,sha256=_yLa5HTj2C4OoQQR4xIdE6JQd9tfm2Uk-1Ir0dM5iQM,1074
|
|
4
|
+
cloudcat-0.1.1.dist-info/METADATA,sha256=I1KjSACgc2j-slzTc1M29xv-rLejMkjs-nhAhGiYogA,1880
|
|
5
|
+
cloudcat-0.1.1.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
|
|
6
|
+
cloudcat-0.1.1.dist-info/entry_points.txt,sha256=SREU3NzaQasfEMWKR70wteKtLNhSmOGdNB3LCL8b4QI,47
|
|
7
|
+
cloudcat-0.1.1.dist-info/top_level.txt,sha256=ivkPFw9-wEfCEGSzaIpoM89ohFkPslHlce8RN04Cfck,9
|
|
8
|
+
cloudcat-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jonathan Sudhakar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cloudcat
|