diff --git a/.gitignore b/.gitignore index f75ca9b..5a13495 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ !.gitignore !.travis.yml dist/ +*.db *.egg-info/ +*.pyc diff --git a/.travis.yml b/.travis.yml index b766e01..a20df42 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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= diff --git a/Makefile b/Makefile deleted file mode 100644 index 415b084..0000000 --- a/Makefile +++ /dev/null @@ -1,22 +0,0 @@ -.PHONY: build -build: clean - python setup.py sdist - -.PHONY: clean -clean: - rm -rf *.egg-info dist - -.PHONY: install -install: build - pip install dist/*.tar.gz - -.PHONY: push -push: - git push origin "v$$(python setup.py --version)" - -.PHONY: release -release: tag push - -.PHONY: tag -tag: - git tag "v$$(python setup.py --version)" diff --git a/README.md b/README.md index 3f51734..fa498f1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cs50/cs50.py b/cs50/cs50.py deleted file mode 100644 index 657f405..0000000 --- a/cs50/cs50.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import print_function -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 get_char(): - """Read a line of text from standard input and return the equivalent char.""" - while True: - s = get_string() - if s is None: - return None - if len(s) == 1: - return s[0] - print("Retry: ", end="") - -def get_float(): - """Read a line of text from standard input and return the equivalent float.""" - while True: - s = get_string() - if s is None: - return None - if len(s) > 0 and re.search(r"^[+-]?\d*(?:\.\d*)?$", s): - try: - return float(s) - except ValueError: - pass - print("Retry: ", end="") - -def get_int(): - """Read a line of text from standard input and return the equivalent int.""" - while True: - s = get_string(); - 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 - print("Retry: ", end="") - -if sys.version_info.major != 3: - def get_long(): - """Read a line of text from standard input and return the equivalent long.""" - while True: - s = get_string(); - if s is None: - return None - if re.search(r"^[+-]?\d+$", s): - try: - return long(s, 10) - except ValueError: - pass - print("Retry: ", end="") - -def get_string(): - """Read a line of text from standard input and return it as a string.""" - try: - s = sys.stdin.readline() - return re.sub(r"(?:\r|\r\n|\n)$", "", s) - except ValueError: - return None diff --git a/cs50/sql.py b/cs50/sql.py deleted file mode 100644 index 2064133..0000000 --- a/cs50/sql.py +++ /dev/null @@ -1,52 +0,0 @@ -import sqlalchemy - -class SQL(object): - """Wrap SQLAlchemy to provide a simple SQL API.""" - - def __init__(self, url): - """ - 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 - """ - try: - self.engine = sqlalchemy.create_engine(url) - except Exception as e: - raise RuntimeError(e) - - def execute(self, text, *multiparams, **params): - """ - Execute a SQL statement. - """ - try: - - # 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 - # https://groups.google.com/forum/#!topic/sqlalchemy/FfLwKT1yQlg - # http://docs.sqlalchemy.org/en/latest/core/connections.html#sqlalchemy.engine.Engine.execute - # http://docs.sqlalchemy.org/en/latest/faq/sqlexpressions.html#how-do-i-render-sql-expressions-as-strings-possibly-with-bound-parameters-inlined - statement = sqlalchemy.text(text).bindparams(*multiparams, **params) - result = self.engine.execute(str(statement.compile(compile_kwargs={"literal_binds": True}))) - - # if SELECT (or INSERT with RETURNING), return result set as list of dict objects - if result.returns_rows: - rows = result.fetchall() - return [dict(row) for row in rows] - - # if INSERT, return primary key value for a newly inserted row - elif result.lastrowid is not None: - return result.lastrowid - - # if DELETE or UPDATE (or INSERT without RETURNING), return number of rows matched - else: - return result.rowcount - - # if constraint violated, return None - except sqlalchemy.exc.IntegrityError: - return None - - # else raise error - except Exception as e: - raise RuntimeError(e) diff --git a/setup.py b/setup.py index cee2266..8440a86 100644 --- a/setup.py +++ b/setup.py @@ -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" ) diff --git a/cs50/__init__.py b/src/cs50/__init__.py similarity index 100% rename from cs50/__init__.py rename to src/cs50/__init__.py diff --git a/src/cs50/cs50.py b/src/cs50/cs50.py new file mode 100644 index 0000000..5661aa9 --- /dev/null +++ b/src/cs50/cs50.py @@ -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 diff --git a/src/cs50/sql.py b/src/cs50/sql.py new file mode 100644 index 0000000..bfa79f0 --- /dev/null +++ b/src/cs50/sql.py @@ -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 diff --git a/test/python2.py b/tests/python2.py similarity index 65% rename from test/python2.py rename to tests/python2.py index 10f19e4..69de822 100644 --- a/test/python2.py +++ b/tests/python2.py @@ -1,6 +1,4 @@ import cs50 -from cs50 import SQL - l = cs50.get_long() print(l) diff --git a/test/python3.py b/tests/python3.py similarity index 65% rename from test/python3.py rename to tests/python3.py index efef779..b545b98 100644 --- a/test/python3.py +++ b/tests/python3.py @@ -1,6 +1,4 @@ import cs50 -from cs50 import SQL i = cs50.get_int() print(i) - diff --git a/tests/sqltests.py b/tests/sqltests.py new file mode 100644 index 0000000..8e518b4 --- /dev/null +++ b/tests/sqltests.py @@ -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())