Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2.0.0 #22

Merged
merged 45 commits into from
May 27, 2017
Merged
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
78ad44b
added support for optional args for parity with newest C library
dmalan Apr 9, 2017
ea74fd7
deploying on master and simplified makefile
Apr 14, 2017
406ee20
Merge pull request #13 from cs50/deployment
Apr 14, 2017
a6a43ed
disabled tag builds
Apr 18, 2017
b83c6a5
Merge pull request #14 from cs50/tag-builds
Apr 18, 2017
d8a74a3
returning None for EOF, updated comments
dmalan Apr 29, 2017
9f7dca1
added eprint
dmalan Apr 29, 2017
bc1483a
clarified eprint's comments
dmalan Apr 29, 2017
bfbe74d
configured slack notifications [skip ci]
May 12, 2017
a94b031
deploying using pypi integration [skip ci]
May 12, 2017
d6ed4b4
Merge pull request #18 from cs50/pypi/slack
May 12, 2017
93fa599
removed makefile and updated readme
May 12, 2017
19b33c0
added dependency
dmalan May 20, 2017
508937a
trying support for all paramstyles
dmalan May 20, 2017
ab5d836
added support for expandable parameters
dmalan May 21, 2017
64c1a04
removed sqlparse
dmalan May 21, 2017
7b69487
Merge pull request #17 from cs50/optional-args
dmalan May 21, 2017
d626967
Merge pull request #19 from cs50/expandable-parameters
dmalan May 21, 2017
71dea16
fixed support for PostgreSQL
dmalan May 21, 2017
b63f521
added logging
dmalan May 21, 2017
f2732cd
Merge pull request #21 from cs50/logging
dmalan May 21, 2017
bf2b82a
upped version
dmalan May 21, 2017
be16156
fixed eprint for Python 2
dmalan May 21, 2017
d9c9aba
Merge branch 'develop' into eprint
dmalan May 21, 2017
5455ee4
Merge pull request #16 from cs50/eprint
dmalan May 21, 2017
f98662b
reorganized directories
dmalan May 21, 2017
cc1f840
fixed naming conventions, fixed exception handling
dmalan May 25, 2017
a115e9a
no longer reraising RuntimeErrors
dmalan May 25, 2017
0032a54
upped version
dmalan May 25, 2017
7ef9d6d
no longer reraising exceptions
dmalan May 25, 2017
2230609
added comments
dmalan May 25, 2017
2955b7b
Merge pull request #24 from cs50/exception-changes
dmalan May 25, 2017
41f1027
ignored *.pyc and *.db
May 27, 2017
a1bbaab
exiting with error code if sql test failed
May 27, 2017
f91a93e
suppressed unknown table in mysql's tearDownClass
May 27, 2017
d3c25fc
dropped postgres password for travis
May 27, 2017
c3253f9
running sql tests against python 2 and 3 on travis
May 27, 2017
2a07bc8
removed __pycache__
May 27, 2017
77dbb65
fixed package path
May 27, 2017
4bc77ab
fixed sqltests.py path
May 27, 2017
a45a3ec
fixed unknown table suppression in sql tests
May 27, 2017
f7cf369
simplified sqltests
May 27, 2017
5e2b03e
added test for multi-insert statements
May 27, 2017
1586f70
Merge pull request #25 from cs50/sqltests
May 27, 2017
da85752
decreased version
May 27, 2017
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -2,4 +2,6 @@
!.gitignore
!.travis.yml
dist/
*.db
*.egg-info/
*.pyc
72 changes: 30 additions & 42 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,44 +1,32 @@
language: python

python: "3.4"

# build sdist
script: make build

# install twine for uploading to PyPI
before_deploy: pip install twine

python:
- '2.7'
- '3.4'
branches:
except: "/^v\\d/"
services:
- mysql
- postgresql
install:
- python setup.py install
- pip install mysqlclient
- pip install psycopg2
before_script:
- mysql -e 'CREATE DATABASE IF NOT EXISTS test;'
- psql -c 'create database test;' -U postgres
script: python tests/sqltests.py
deploy:

# create github release
- provider: releases

# GitHub access token
api_key:
secure: "pwBn1lky58GHp1qR4i0oSZyOJkGMTvRzt0EfESVtl2ZbRTVUE7UFQbk/cL1002zMOUkJ4c5IjRCg95NJZWUvOSHwt4cOyqDVzi/S6AqACOlOhfnkw3S6oLGgRT3IlXK2ng0Viiqc1+BlVdwwXURKyobirqFgr4MAlb5kh75WmV9Xs4GwIS+qPq9luv38Bls2US/mNt5KRV1DiePr2ZSCqFESFfoIz+QKhZtVdynEF4jJwevEwP4HrCoT3guIJlXcWhOG37n+e8S4YLwg+k3yYeQTmR/QMgjmQLwEBZ6v9bNjqXM3CMtn3KUryDzcp5Z5+Vv1p1uoDbmuK+Ll5nQttAp/gARk+IWZ/xWc8MuQpFvjRzafbtPiF7ZlqaYh1wCREuZTWDAk/UJgQxb81v0jo0iAPyk9HMfgK2CJuU8wDwraKZ5dKk4y45Zww1gSSzpJJ8xSrylKPn7Wnft617Lnc+O0X6DnIAFtHDAPu/lPFaaokn1TN9AOPXoxb2cEeh+oDcUQD4zZG6Ukvh9+Hw8XiFBG+jEm6ekCvawTjnlZmBIw8YPJKEjrZv8LWfKhnVebRbmehawmnrZxUALCp39EjrcsIltYw4gefbd/Z9kIr8r3yVZfuq7U6vd8PBuCiDZHlKM1Lz4Ns24WK96nYe6V9Lt3WUERh6xt8JtuFrNHBiQ="

# enable wildcards in filenames
file_glob: true

# upload sdist
file: dist/*

# avoid stashing sdist
skip_cleanup: true

# create releases on tags only
on:
tags: true

# deploy to PyPI
- provider: script

# upload sdist to PyPI
script: twine upload -u $PYPI_USERNAME -p $PYPI_PASSWORD dist/*

# avoid stashing sdist
skip_cleanup: true

# deploy on tags only
on:
tags: true
- provider: script
script: 'curl --fail --data "{ \"tag_name\": \"v$(python setup.py --version)\",
\"target_commitish\": \"$TRAVIS_COMMIT\", \"name\": \"v$(python setup.py --version)\"
}" --user bot50:$GITHUB_TOKEN https://api.github.com/repos/$TRAVIS_REPO_SLUG/releases'
on:
branch: master
- provider: pypi
user: "$PYPI_USERNAME"
password: "$PYPI_PASSWORD"
on:
branch: master
notifications:
slack:
secure: lJklhcBVjDT6KzUNa3RFHXdXSeH7ytuuGrkZ5ZcR72CXMoTf2pMJTzPwRLWOp6lCSdDC9Y8MWLrcg/e33dJga4Jlp9alOmWqeqesaFjfee4st8vAsgNbv8/RajPH1gD2bnkt8oIwUzdHItdb5AucKFYjbH2g0d8ndoqYqUeBLrnsT1AP5G/Vi9OHC9OWNpR0FKaZIJE0Wt52vkPMH3sV2mFeIskByPB+56U5y547mualKxn61IVR/dhYBEtZQJuSvnwKHPOn9Pkk7cCa+SSSeTJ4w5LboY8T17otaYNauXo46i1bKIoGiBcCcrJyQHHiPQmcq/YU540MC5Wzt9YXUycmJzRi347oyQeDee27wV3XJlWMXuuhbtJiKCFny7BTQ160VATlj/dbwIzN99Ra6/BtTumv/6LyTdKIuVjdAkcN8dtdDW1nlrQ29zuPNCcXXzJ7zX7kQaOCUV1c2OrsbiH/0fE9nknUORn97txqhlYVi0QMS7764wFo6kg0vpmFQRkkQySsJl+TmgcZ01AlsJc2EMMWVuaj9Af9JU4/4yalqDiXIh1fOYYUZnLfOfWS+MsnI+/oLfqJFyMbrsQQTIjs+kTzbiEdhd2R4EZgusU/xRFWokS2NAvahexrRhRQ6tpAI+LezPrkNOR3aHiykBf+P9BkUa0wPp6V2Ayc6q0=
22 changes: 0 additions & 22 deletions Makefile

This file was deleted.

16 changes: 3 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -4,19 +4,11 @@

Supports Python 2 and Python 3.

## Development

Requires [Docker Engine](https://docs.docker.com/engine/installation/).

make bash
make deb # builds .deb

## Installation

1. Download the latest release per https://github.com/cs50/python-cs50/releases
1. Extract `python-cs50-*`
1. `cd python-cs50-*`
1. `make install`
```
pip install cs50
```

## Usage

@@ -31,8 +23,6 @@ Requires [Docker Engine](https://docs.docker.com/engine/installation/).
s = cs50.get_string();

## TODO

* Add install target to Makefile.
* Conditionally install for Python 2 and/or Python 3.
* Add targets for `pacman`, `rpm`.
* Add tests.
81 changes: 0 additions & 81 deletions cs50/cs50.py

This file was deleted.

52 changes: 0 additions & 52 deletions cs50/sql.py

This file was deleted.

3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
@@ -13,7 +13,8 @@
install_requires=["SQLAlchemy"],
keywords="cs50",
name="cs50",
package_dir={"": "src"},
packages=["cs50"],
url="https://github.com/cs50/python-cs50",
version="1.3.0"
version="2.0.0"
)
File renamed without changes.
130 changes: 130 additions & 0 deletions src/cs50/cs50.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
from __future__ import print_function
import inspect
import re
import sys

class flushfile():
"""
Disable buffering for standard output and standard error.
http://stackoverflow.com/a/231216
"""
def __init__(self, f):
self.f = f

def __getattr__(self, name):
return object.__getattribute__(self.f, name)

def write(self, x):
self.f.write(x)
self.f.flush()
sys.stderr = flushfile(sys.stderr)
sys.stdout = flushfile(sys.stdout)

def eprint(*args, **kwargs):
"""
Print an error message to standard error, prefixing it with
file name and line number from which method was called.
"""
end = kwargs.get("end", "\n")
sep = kwargs.get("sep", " ")
(filename, lineno) = inspect.stack()[1][1:3]
print("{}:{}: ".format(filename, lineno), end="")
print(*args, end=end, file=sys.stderr, sep=sep)

def get_char(prompt=None):
"""
Read a line of text from standard input and return the equivalent char;
if text is not a single char, user is prompted to retry. If line can't
be read, return None.
"""
while True:
s = get_string(prompt)
if s is None:
return None
if len(s) == 1:
return s[0]

# temporarily here for backwards compatibility
if prompt is None:
print("Retry: ", end="")

def get_float(prompt=None):
"""
Read a line of text from standard input and return the equivalent float
as precisely as possible; if text does not represent a double, user is
prompted to retry. If line can't be read, return None.
"""
while True:
s = get_string(prompt)
if s is None:
return None
if len(s) > 0 and re.search(r"^[+-]?\d*(?:\.\d*)?$", s):
try:
return float(s)
except ValueError:
pass

# temporarily here for backwards compatibility
if prompt is None:
print("Retry: ", end="")

def get_int(prompt=None):
"""
Read a line of text from standard input and return the equivalent int;
if text does not represent an int, user is prompted to retry. If line
can't be read, return None.
"""
while True:
s = get_string(prompt);
if s is None:
return None
if re.search(r"^[+-]?\d+$", s):
try:
i = int(s, 10)
if type(i) is int: # could become long in Python 2
return i
except ValueError:
pass

# temporarily here for backwards compatibility
if prompt is None:
print("Retry: ", end="")

if sys.version_info.major != 3:
def get_long(prompt=None):
"""
Read a line of text from standard input and return the equivalent long;
if text does not represent a long, user is prompted to retry. If line
can't be read, return None.
"""
while True:
s = get_string(prompt)
if s is None:
return None
if re.search(r"^[+-]?\d+$", s):
try:
return long(s, 10)
except ValueError:
pass

# temporarily here for backwards compatibility
if prompt is None:
print("Retry: ", end="")

def get_string(prompt=None):
"""
Read a line of text from standard input and return it as a string,
sans trailing line ending. Supports CR (\r), LF (\n), and CRLF (\r\n)
as line endings. If user inputs only a line ending, returns "", not None.
Returns None upon error or no input whatsoever (i.e., just EOF).
"""
try:
if prompt is not None:
print(prompt, end="")
s = sys.stdin.readline()
if not s:
return None
return re.sub(r"(?:\r|\r\n|\n)$", "", s)
except ValueError:
return None
141 changes: 141 additions & 0 deletions src/cs50/sql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import datetime
import importlib
import logging
import re
import sqlalchemy
import sys
import warnings

class SQL(object):
"""Wrap SQLAlchemy to provide a simple SQL API."""

def __init__(self, url, **kwargs):
"""
Create instance of sqlalchemy.engine.Engine.
URL should be a string that indicates database dialect and connection arguments.
http://docs.sqlalchemy.org/en/latest/core/engines.html#sqlalchemy.create_engine
http://docs.sqlalchemy.org/en/latest/dialects/index.html
"""

# log statements to standard error
logging.basicConfig(level=logging.DEBUG)
self.logger = logging.getLogger(__name__)

# create engine, raising exception if back end's module not installed
self.engine = sqlalchemy.create_engine(url, **kwargs)

def execute(self, text, **params):
"""
Execute a SQL statement.
"""

class UserDefinedType(sqlalchemy.TypeDecorator):
"""
Add support for expandable values, a la https://bitbucket.org/zzzeek/sqlalchemy/issues/3953/expanding-parameter.
"""
impl = sqlalchemy.types.UserDefinedType
def process_literal_param(self, value, dialect):
"""Receive a literal parameter value to be rendered inline within a statement."""
def process(value):
"""Render a literal value, escaping as needed."""

# bool
if isinstance(value, bool):
return sqlalchemy.types.Boolean().literal_processor(dialect)(value)

# datetime.date
elif isinstance(value, datetime.date):
return sqlalchemy.types.String().literal_processor(dialect)(value.strftime("%Y-%m-%d"))

# datetime.datetime
elif isinstance(value, datetime.datetime):
return sqlalchemy.types.String().literal_processor(dialect)(value.strftime("%Y-%m-%d %H:%M:%S"))

# datetime.time
elif isinstance(value, datetime.time):
return sqlalchemy.types.String().literal_processor(dialect)(value.strftime("%H:%M:%S"))

# float
elif isinstance(value, float):
return sqlalchemy.types.Float().literal_processor(dialect)(value)

# int
elif isinstance(value, int):
return sqlalchemy.types.Integer().literal_processor(dialect)(value)

# long
elif sys.version_info.major != 3 and isinstance(value, long):
return sqlalchemy.types.Integer().literal_processor(dialect)(value)

# str
elif isinstance(value, str):
return sqlalchemy.types.String().literal_processor(dialect)(value)

# None
elif isinstance(value, sqlalchemy.sql.elements.Null):
return sqlalchemy.types.NullType().literal_processor(dialect)(value)

# unsupported value
raise RuntimeError("unsupported value")

# process value(s), separating with commas as needed
if type(value) is list:
return ", ".join([process(v) for v in value])
else:
return process(value)

# raise exceptions for warnings
warnings.filterwarnings("error")

# prepare, execute statement
try:

# construct a new TextClause clause
statement = sqlalchemy.text(text)

# iterate over parameters
for key, value in params.items():

# translate None to NULL
if value is None:
value = sqlalchemy.sql.null()

# bind parameters before statement reaches database, so that bound parameters appear in exceptions
# http://docs.sqlalchemy.org/en/latest/core/sqlelement.html#sqlalchemy.sql.expression.text
statement = statement.bindparams(sqlalchemy.bindparam(key, value=value, type_=UserDefinedType()))

# stringify bound parameters
# http://docs.sqlalchemy.org/en/latest/faq/sqlexpressions.html#how-do-i-render-sql-expressions-as-strings-possibly-with-bound-parameters-inlined
statement = str(statement.compile(compile_kwargs={"literal_binds": True}))

# execute statement
result = self.engine.execute(statement)

# log statement
self.logger.debug(statement)

# if SELECT (or INSERT with RETURNING), return result set as list of dict objects
if re.search(r"^\s*SELECT\s+", statement, re.I):
rows = result.fetchall()
return [dict(row) for row in rows]

# if INSERT, return primary key value for a newly inserted row
elif re.search(r"^\s*INSERT\s+", statement, re.I):
if self.engine.url.get_backend_name() == "postgresql":
result = self.engine.execute(sqlalchemy.text("SELECT LASTVAL()"))
return result.first()[0]
else:
return result.lastrowid

# if DELETE or UPDATE, return number of rows matched
elif re.search(r"^\s*(?:DELETE|UPDATE)\s+", statement, re.I):
return result.rowcount

# if some other statement, return True unless exception
return True

# if constraint violated, return None
except sqlalchemy.exc.IntegrityError:
return None
2 changes: 0 additions & 2 deletions test/python2.py → tests/python2.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import cs50

from cs50 import SQL

l = cs50.get_long()
print(l)
2 changes: 0 additions & 2 deletions test/python3.py → tests/python3.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import cs50
from cs50 import SQL

i = cs50.get_int()
print(i)

126 changes: 126 additions & 0 deletions tests/sqltests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from cs50.sql import SQL
import sys
import unittest
import warnings

class SQLTests(unittest.TestCase):
def multi_inserts_enabled(self):
return True

def test_delete_returns_affected_rows(self):
rows = [
{"id": 1, "val": "foo"},
{"id": 2, "val": "bar"},
{"id": 3, "val": "baz"}
]
for row in rows:
self.db.execute("INSERT INTO cs50(val) VALUES(:val);", val=row["val"])

print(self.db.execute("DELETE FROM cs50 WHERE id = :id", id=rows[0]["id"]))
print(self.db.execute("SELECT * FROM cs50"))
return

self.assertEqual(self.db.execute("DELETE FROM cs50 WHERE id = :id", id=rows[0]["id"]), 1)
self.assertEqual(self.db.execute("DELETE FROM cs50 WHERE id = :a or id = :b", a=rows[1]["id"], b=rows[2]["id"]), 2)
self.assertEqual(self.db.execute("DELETE FROM cs50 WHERE id = -50"), 0)

def test_insert_returns_last_row_id(self):
self.assertEqual(self.db.execute("INSERT INTO cs50(val) VALUES('foo')"), 1)
self.assertEqual(self.db.execute("INSERT INTO cs50(val) VALUES('bar')"), 2)
if self.multi_inserts_enabled():
self.assertEqual(self.db.execute("INSERT INTO cs50(val) VALUES('baz'); INSERT INTO cs50(val) VALUES('qux')"), 4)

def test_select_all(self):
self.assertEqual(self.db.execute("SELECT * FROM cs50"), [])

rows = [
{"id": 1, "val": "foo"},
{"id": 2, "val": "bar"},
{"id": 3, "val": "baz"}
]
for row in rows:
self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])

self.assertEqual(self.db.execute("SELECT * FROM cs50"), rows)

def test_select_cols(self):
rows = [
{"val": "foo"},
{"val": "bar"},
{"val": "baz"}
]
for row in rows:
self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])

self.assertEqual(self.db.execute("SELECT val FROM cs50"), rows)

def test_select_where(self):
rows = [
{"id": 1, "val": "foo"},
{"id": 2, "val": "bar"},
{"id": 3, "val": "baz"}
]
for row in rows:
self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])

self.assertEqual(self.db.execute("SELECT * FROM cs50 WHERE id = :id OR val = :val", id=rows[1]["id"], val=rows[2]["val"]), rows[1:3])

def test_update_returns_affected_rows(self):
rows = [
{"id": 1, "val": "foo"},
{"id": 2, "val": "bar"},
{"id": 3, "val": "baz"}
]
for row in rows:
self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])

self.assertEqual(self.db.execute("UPDATE cs50 SET val = 'foo' WHERE id > 1"), 2)
self.assertEqual(self.db.execute("UPDATE cs50 SET val = 'foo' WHERE id = -50"), 0)

def tearDown(self):
self.db.execute("DROP TABLE cs50")

@classmethod
def tearDownClass(self):
try:
self.db.execute("DROP TABLE IF EXISTS cs50")
except Warning as e:
# suppress "unknown table"
if not str(e).startswith("(1051"):
raise e

class MySQLTests(SQLTests):
@classmethod
def setUpClass(self):
self.db = SQL("mysql://root@localhost/test")

def setUp(self):
self.db.execute("CREATE TABLE cs50 (id INTEGER NOT NULL AUTO_INCREMENT, val VARCHAR(16), PRIMARY KEY (id))")

class PostgresTests(SQLTests):
@classmethod
def setUpClass(self):
self.db = SQL("postgresql://postgres@localhost/test")

def setUp(self):
self.db.execute("CREATE TABLE cs50 (id SERIAL PRIMARY KEY, val VARCHAR(16))")

class SQLiteTests(SQLTests):
@classmethod
def setUpClass(self):
self.db = SQL("sqlite:///test.db")

def setUp(self):
self.db.execute("CREATE TABLE cs50(id INTEGER PRIMARY KEY, val TEXT)")

def multi_inserts_enabled(self):
return False

if __name__ == "__main__":
suite = unittest.TestSuite([
unittest.TestLoader().loadTestsFromTestCase(SQLiteTests),
unittest.TestLoader().loadTestsFromTestCase(MySQLTests),
unittest.TestLoader().loadTestsFromTestCase(PostgresTests)
])

sys.exit(not unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful())