diff --git a/.gitignore b/.gitignore index e53bc19..81b69de 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -.vscode __pycache__ .pytest_cache .ipynb_checkpoints diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..1238b2c --- /dev/null +++ b/.pylintrc @@ -0,0 +1,633 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold to be exceeded before program exits with error. +fail-under=10.0 + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the ignore-list. The +# regex matches against paths. +ignore-paths= + +# Files or directories matching the regex patterns are skipped. The regex +# matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'error', 'warning', 'refactor', and 'convention' +# which contain the number of messages in each category, as well as 'statement' +# which is the total number of statements analyzed. This score is used by the +# global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _, + db, + x, + y, + S, + log_S, + sr + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +#notes-rgx= + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=no + +# Signatures are removed from the similarity computation +ignore-signatures=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it work, +# install the 'python-enchant' package. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear and the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# List of qualified class names to ignore when countint class parents (see +# R0901) +ignored-parents= + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a230115 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to Chrome", + "port": 9222, + "request": "attach", + "type": "pwa-chrome", + "webRoot": "${workspaceFolder}" + }, + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + }, + { + "name": "Python: FastAPI", + "type": "python", + "request": "launch", + "env": { + "APP_URL": "http://localhost:3000", + "DOCS_URL": "/docs", + "REDOC_URL": "/redoc" + }, + "module": "uvicorn", + "args": [ + "backend.main:app", + "--reload", + "--reload-dir", + "backend", + "--port=8001" + ], + "jinja": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3904844 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "python.testing.pytestArgs": [ + "backend" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.provider": "yapf" +} \ No newline at end of file diff --git a/Pipfile b/Pipfile index 9e1c83c..ce2fdda 100644 --- a/Pipfile +++ b/Pipfile @@ -17,6 +17,7 @@ sqlalchemy-utils = "*" [dev-packages] yapf = "*" pytest = "*" +pylint = "*" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 4a9a7ff..92ffebe 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f6c46c813ac200b0088cfb0017774e712daacd86334b6c8191abea9ebd95d99a" + "sha256": "83952e3de9bfd4c975f9d45609b98df9415d39e16c4881765dbce8f87dd3bc43" }, "pipfile-spec": 6, "requires": { @@ -214,6 +214,14 @@ "markers": "python_version >= '3.6'", "version": "==8.0.1" }, + "colorama": { + "hashes": [ + "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", + "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + ], + "markers": "platform_system == 'Windows'", + "version": "==0.4.4" + }, "decorator": { "hashes": [ "sha256:6e5c199c16f7a9f0e3a61a4a54b3d27e7dad0dbdde92b944426cb20914376323", @@ -255,11 +263,11 @@ }, "google-auth-oauthlib": { "hashes": [ - "sha256:4ab58e6c3dc6ccf112f921fcced40e5426fba266768986ea502228488276eaba", - "sha256:b5a1ce7c617d247ccb2dfbba9d4bfc734b41096803d854a2c52592ae80150a67" + "sha256:3f2a6e802eebbb6fb736a370fbf3b055edcb6b52878bf2f26330b5e041316c73", + "sha256:a90a072f6993f2c327067bf65270046384cda5a8ecb20b94ea9a687f1f233a7a" ], "markers": "python_version >= '3.6'", - "version": "==0.4.5" + "version": "==0.4.6" }, "google-pasta": { "hashes": [ @@ -327,59 +335,52 @@ }, "grpcio": { "hashes": [ - "sha256:02e8a8b41db8e13df53078355b439363e4ac46d0ac9a8a461a39e42829e2bcf8", - "sha256:050901a5baa6c4ca445e1781ef4c32d864f965ccec70c46cd5ad92d15e282c6a", - "sha256:1ab44dde4e1b225d3fc873535ca6e642444433131dd2891a601b75fb46c87c11", - "sha256:2068a2b896ac67103c4a5453d5435fafcbb1a2f41eaf25148d08780096935cee", - "sha256:20f57c5d09a36e0d0c8fe16ee1905f4307edb1d04f6034b56320f7fbc1a1071a", - "sha256:25731b2c20a4ed51bea7e3952d5e83d408a5df32d03c7553457b2e6eb8bcb16c", - "sha256:27e2c6213fc04e71a862bacccb51f3c8e722255933f01736ace183e92d860ee6", - "sha256:2a4308875b9b986000513c6b04c2e7424f436a127f15547036c42d3cf8289374", - "sha256:2a958ad794292e12d8738a06754ebaf71662e635a89098916c18715b27ca2b5b", - "sha256:2bc7eebb405aac2d7eecfaa881fd73b489f99c01470d7193b4431a6ce199b9c3", - "sha256:366b6b35b3719c5570588e21d866460c5666ae74e3509c2a5a73ca79997abdaf", - "sha256:3c14e2087f809973d5ee8ca64f772a089ead0167286f3f21fdda8b6029b50abb", - "sha256:3c57fa7fec932767bc553bfb956759f45026890255bd232b2f797c3bc4dfeba2", - "sha256:3cccf470fcaab65a1b0a826ff34bd7c0861eb82ed957a83c6647a983459e4ecd", - "sha256:4039645b8b5d19064766f3a6fa535f1db52a61c4d4de97a6a8945331a354d527", - "sha256:4163e022f365406be2da78db890035463371effea172300ce5af8a768142baf3", - "sha256:4258b778ce09ffa3b7c9a26971c216a34369e786771afbf4f98afe223f27d248", - "sha256:43c57987e526d1b893b85099424387b22de6e3eee4ea7188443de8d657d11cc0", - "sha256:43e0f5c49f985c94332794aa6c4f15f3a1ced336f0c6a6c8946c67b5ab111ae9", - "sha256:46cfb0f2b757673bfd36ab4b0e3d61988cc1a0d47e0597e91462dcbef7528f35", - "sha256:46d510a7af777d2f38ef4c1d25491add37cad24143012f3eebe72dc5c6d0fc4c", - "sha256:476fa94ba8efb09213baabd757f6f93e839794d8ae0eaa371347d6899e8f57a0", - "sha256:4b3fcc1878a1a5b71e1ecdfe82c74f7cd9eadaa43e25be0d67676dcec0c9d39f", - "sha256:5091b4a5ee8454a8f0c8ac45946ca25d6142c3be4b1fba141f1d62a6e0b5c696", - "sha256:5127f4ba1f52fda28037ae465cf4b0e5fabe89d5ac1d64d15b073b46b7db5e16", - "sha256:52100d800390d58492ed1093de6faccd957de6fc29b1a0e5948c84f275d9228f", - "sha256:544e1c1a133b43893e03e828c8325be5b82e20d3b0ef0ee3942d32553052a1b5", - "sha256:5628e7cc69079159f9465388ff21fde1e1a780139f76dd99d319119d45156f45", - "sha256:57974361a459d6fe04c9ae0af1845974606612249f467bbd2062d963cb90f407", - "sha256:691f5b3a75f072dfb7b093f46303f493b885b7a42f25a831868ffaa22ee85f9d", - "sha256:6ba6ad60009da2258cf15a72c51b7e0c2f58c8da517e97550881e488839e56c6", - "sha256:6d51be522b573cec14798d4742efaa69d234bedabce122fec2d5489abb3724d4", - "sha256:7b95b3329446408e2fe6db9b310d263303fa1a94649d08ec1e1cc12506743d26", - "sha256:88dbef504b491b96e3238a6d5360b04508c34c62286080060c85fddd3caf7137", - "sha256:8ed1e52ad507a54d20e6aaedf4b3edcab18cc12031eafe6de898f97513d8997b", - "sha256:a1fb9936b86b5efdea417fe159934bcad82a6f8c6ab7d1beec4bf3a78324d975", - "sha256:a2733994b05ee5382da1d0378f6312b72c5cb202930c7fa20c794a24e96a1a34", - "sha256:a6211150765cc2343e69879dfb856718b0f7477a4618b5f9a8f6c3ee84c047c0", - "sha256:a659f7c634cacfcf14657687a9fa3265b0a1844b1c19d140f3b66aebfba1a66b", - "sha256:b0ff14dd872030e6b2fce8a6811642bd30d93833f794d3782c7e9eb2f01234cc", - "sha256:b236eb4b50d83754184b248b8b1041bb1546287fff7618c4b7001b9f257bb903", - "sha256:c44958a24559f875d902d5c1acb0ae43faa5a84f6120d1d0d800acb52f96516e", - "sha256:c8fe430add656b92419f6cd0680b64fbe6347c831d89a7788324f5037dfb3359", - "sha256:cd2e39a199bcbefb3f4b9fa6677c72b0e67332915550fed3bd7c28b454bf917d", - "sha256:cffdccc94e63710dd6ead01849443390632c8e0fec52dc26e4fddf9f28ac9280", - "sha256:d5a105f5a595b89a0e394e5b147430b115333d07c55efb0c0eddc96055f0d951", - "sha256:dc3a24022a90c1754e54315009da6f949b48862c1d06daa54f9a28f89a5efacb", - "sha256:de83a045005703e7b9e67b61c38bb72cd49f68d9d2780d2c675353a3a3f2816f", - "sha256:e98aca5cfe05ca29950b3d99006b9ddb54fde6451cd12cb2db1443ae3b9fa076", - "sha256:ed845ba6253c4032d5a01b7fb9db8fe80299e9a437e695a698751b0b191174be", - "sha256:f2621c82fbbff1496993aa5fbf60e235583c7f970506e818671ad52000b6f310" - ], - "version": "==1.39.0" + "sha256:005fe14e67291498989da67d454d805be31d57a988af28ed3a2a0a7cabb05c53", + "sha256:1708a0ba90c798b4313f541ffbcc25ed47e790adaafb02111204362723dabef0", + "sha256:17ed13d43450ef9d1f9b78cc932bcf42844ca302235b93026dfd07fb5208d146", + "sha256:1d9eabe2eb2f78208f9ae67a591f73b024488449d4e0a5b27c7fca2d6901a2d4", + "sha256:1f9ccc9f5c0d5084d1cd917a0b5ff0142a8d269d0755592d751f8ce9e7d3d7f1", + "sha256:24277aab99c346ca36a1aa8589a0624e19a8e6f2b74c83f538f7bb1cc5ee8dbc", + "sha256:27dee6dcd1c04c4e9ceea49f6143003569292209d2c24ca100166660805e2440", + "sha256:33dc4259fecb96e6eac20f760656b911bcb1616aa3e58b3a1d2f125714a2f5d3", + "sha256:3d172158fe886a2604db1b6e17c2de2ab465fe0fe36aba2ec810ca8441cefe3a", + "sha256:41e250ec7cd7523bf49c815b5509d5821728c26fac33681d4b0d1f5f34f59f06", + "sha256:45704b9b5b85f9bcb027f90f2563d11d995c1b870a9ee4b3766f6c7ff6fc3505", + "sha256:49155dfdf725c0862c428039123066b25ce61bd38ce50a21ce325f1735aac1bd", + "sha256:4967949071c9e435f9565ec2f49700cebeda54836a04710fe21f7be028c0125a", + "sha256:4c2baa438f51152c9b7d0835ff711add0b4bc5056c0f5df581a6112153010696", + "sha256:5729ca9540049f52c2e608ca110048cfabab3aeaa0d9f425361d9f8ba8506cac", + "sha256:5f6d6b638698fa6decf7f040819aade677b583eaa21b43366232cb254a2bbac8", + "sha256:5ff0dcf66315f3f00e1a8eb7244c6a49bdb0cc59bef4fb65b9db8adbd78e6acb", + "sha256:6b9b432f5665dfc802187384693b6338f05c7fc3707ebf003a89bd5132074e27", + "sha256:6f8f581787e739945e6cda101f312ea8a7e7082bdbb4993901eb828da6a49092", + "sha256:72b7b8075ee822dad4b39c150d73674c1398503d389e38981e9e35a894c476de", + "sha256:886d056f5101ac513f4aefe4d21a816d98ee3f9a8e77fc3bcb4ae1a3a24efe26", + "sha256:8a35b5f87247c893b01abf2f4f7493a18c2c5bf8eb3923b8dd1654d8377aa1a7", + "sha256:913916823efa2e487b2ee9735b7759801d97fd1974bacdb1900e3bbd17f7d508", + "sha256:a4389e26a8f9338ca91effdc5436dfec67d6ecd296368dba115799ae8f8e5bdb", + "sha256:a66a30513d2e080790244a7ac3d7a3f45001f936c5c2c9613e41e2a5d7a11794", + "sha256:a812164ceb48cb62c3217bd6245274e693c624cc2ac0c1b11b4cea96dab054dd", + "sha256:a93490e6eff5fce3748fb2757cb4273dc21eb1b56732b8c9640fd82c1997b215", + "sha256:b1b34e5a6f1285d1576099c663dae28c07b474015ed21e35a243aff66a0c2aed", + "sha256:ba9dd97ea1738be3e81d34e6bab8ff91a0b80668a4ec81454b283d3c828cebde", + "sha256:bf114be0023b145f7101f392a344692c1efd6de38a610c54a65ed3cba035e669", + "sha256:c26de909cfd54bacdb7e68532a1591a128486af47ee3a5f828df9aa2165ae457", + "sha256:d271e52038dec0db7c39ad9303442d6087c55e09b900e2931b86e837cf0cbc2e", + "sha256:d3b4b41eb0148fca3e6e6fc61d1332a7e8e7c4074fb0d1543f0b255d7f5f1588", + "sha256:d487b4daf84a14741ca1dc1c061ffb11df49d13702cd169b5837fafb5e84d9c0", + "sha256:d760a66c9773780837915be85a39d2cd4ab42ef32657c5f1d28475e23ab709fc", + "sha256:e12d776a240fee3ebd002519c02d165d94ec636d3fe3d6185b361bfc9a2d3106", + "sha256:e19de138199502d575fcec5cf68ae48815a6efe7e5c0d0b8c97eba8c77ae9f0e", + "sha256:e2367f2b18dd4ba64cdcd9f626a920f9ec2e8228630839dc8f4a424d461137ea", + "sha256:ecfd80e8ea03c46b3ea7ed37d2040fcbfe739004b9e4329b8b602d06ac6fb113", + "sha256:edddc849bed3c5dfe215a9f9532a9bd9f670b57d7b8af603be80148b4c69e9a8", + "sha256:eedc8c3514c10b6f11c6f406877e424ca29610883b97bb97e33b1dd2a9077f6c", + "sha256:f06e07161c21391682bfcac93a181a037a8aa3d561546690e9d0501189729aac", + "sha256:fb06708e3d173e387326abcd5182d52beb60e049db5c3d317bd85509e938afdc", + "sha256:fbe3b66bfa2c2f94535f6063f6db62b5b150d55a120f2f9e1175d3087429c4d9" + ], + "version": "==1.40.0" }, "h11": { "hashes": [ @@ -525,6 +526,7 @@ "hashes": [ "sha256:06b8a3d0896daad13bcdfe7c3d3431480aa4c12b3c4478b1c0c583394f3f63bc", "sha256:0801755ac3e05f7ba30c5404556cedc437f58baa3b1b4886c40c16f33c5d684c", + "sha256:10f4e76dbf0a8f01a6fe9dd7d20e8f9c842c8a86c5ea9046fbf4c44c76c7451a", "sha256:13ee9e82b7ce3d147e0ec49d07034ffcc2e956df1cda9cbfd9ab855f2d50543b", "sha256:253f0989c1e1a0f4e6409eefeea59cddb11f181b44d75ac5a3a287cccff8c9f0", "sha256:27fdb46ea27c891b70f476f643af86d0af8c08a4d9d19a0ae28f80ce68efc550", @@ -532,6 +534,7 @@ "sha256:466099da7ab3d9b18b6e7cee420994f80acb6e3330a741e03030ac8eef4b3b78", "sha256:5963c6e39a25f8dfb85abd47451947b8a32e86b6ffcad091a01af37b27dcb11b", "sha256:61c037065c3762263d27bd08d87b7a00b18025016a54df836b096b689d1b8b91", + "sha256:95718e256d2551e62d329a7d76193327c1974e9439cdba5929d383d86e664bb9", "sha256:97f996262a168d30b8d1d56b55a5a957229ff89ca2e436bd5c2d69afa441727d", "sha256:a33902c1861ec97cd92c7ded7ba4d6182bf5f91ed1228557f02ce5d3e5636301", "sha256:aaf44e8d3b0adaccaab7c19a6987850e8ad4130f8f5c99e203e8fb376d3f5abb", @@ -539,6 +542,7 @@ "sha256:bad6bd98ab2e41c34aa9c80b8d9737e07d92a53df4f74d3ada1458b0b516ccff", "sha256:c27d7396e53a4daab0fdd2cd7181693797b5cedac1996855841be97fbf72d1de", "sha256:d6b30809ac4da6d25c51d0411ce034aa1c9b1f2eb20892bccede25a2ed70a70d", + "sha256:ddf031ba9ddf0f1aeab0850efd7783121763600ca9f85dd3ee11ae97684c9ed6", "sha256:edf6744a906a107b49d601d7134fa318fcce9c3f468b5e4ac1fbe26ef3a3c03c", "sha256:f72aca38d8746adfb5018c58d41f683378e7d136e674281456ecc3e98ce09fbe", "sha256:fd3f98d3add16fce2a2792f6cf42eff97908324c0ae5038b0aa4c198dc7a7ab5" @@ -735,7 +739,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "requests": { @@ -837,7 +841,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "soundfile": { @@ -1053,6 +1057,22 @@ } }, "develop": { + "astroid": { + "hashes": [ + "sha256:3b680ce0419b8a771aba6190139a3998d14b413852506d99aff8dc2bf65ee67c", + "sha256:dc1e8b28427d6bbef6b8842b18765ab58f558c42bb80540bd7648c98412af25e" + ], + "markers": "python_version ~= '3.6'", + "version": "==2.7.3" + }, + "atomicwrites": { + "hashes": [ + "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197", + "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a" + ], + "markers": "sys_platform == 'win32'", + "version": "==1.4.0" + }, "attrs": { "hashes": [ "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", @@ -1061,6 +1081,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==21.2.0" }, + "colorama": { + "hashes": [ + "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", + "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + ], + "markers": "platform_system == 'Windows'", + "version": "==0.4.4" + }, "iniconfig": { "hashes": [ "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", @@ -1068,6 +1096,49 @@ ], "version": "==1.1.1" }, + "isort": { + "hashes": [ + "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899", + "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2" + ], + "markers": "python_version < '4.0' and python_full_version >= '3.6.1'", + "version": "==5.9.3" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653", + "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61", + "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2", + "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837", + "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3", + "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43", + "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726", + "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3", + "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587", + "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8", + "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a", + "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd", + "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f", + "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad", + "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4", + "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b", + "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf", + "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981", + "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741", + "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e", + "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93", + "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==1.6.0" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, "packaging": { "hashes": [ "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", @@ -1076,13 +1147,21 @@ "markers": "python_version >= '3.6'", "version": "==21.0" }, + "platformdirs": { + "hashes": [ + "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f", + "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648" + ], + "markers": "python_version >= '3.6'", + "version": "==2.3.0" + }, "pluggy": { "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", + "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.13.1" + "markers": "python_version >= '3.6'", + "version": "==1.0.0" }, "py": { "hashes": [ @@ -1092,30 +1171,44 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.10.0" }, + "pylint": { + "hashes": [ + "sha256:6758cce3ddbab60c52b57dcc07f0c5d779e5daf0cf50f6faacbef1d3ea62d2a1", + "sha256:e178e96b6ba171f8ef51fbce9ca30931e6acbea4a155074d80cc081596c9e852" + ], + "index": "pypi", + "version": "==2.10.2" + }, "pyparsing": { "hashes": [ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { "hashes": [ - "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b", - "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890" + "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89", + "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134" ], "index": "pypi", - "version": "==6.2.4" + "version": "==6.2.5" }, "toml": { "hashes": [ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, + "wrapt": { + "hashes": [ + "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" + ], + "version": "==1.12.1" + }, "yapf": { "hashes": [ "sha256:408fb9a2b254c302f49db83c59f9aa0b4b0fd0ec25be3a5c51181327922ff63d", diff --git a/README.md b/README.md index a58eb4b..de87fa6 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ This is the source code for my webpage which is hosted at https://deej-ai.online In order to run this, you wil need to create a `credentials.py` file in the `backend` directory with your Spotify Developer API credentials, which can be obtained from https://developer.spotify.com/dashboard/login. ``` -client_id = '' -client_secret = '' -redirect_uri = '/api/v1/callback' +CLIENT_ID = '' +CLIENT_SECRET = '' +REDIRECT_URI = '/api/v1/callback' ``` You will also need to download the following files to the root directory: diff --git a/backend/database.py b/backend/database.py index eceb20c..5e5db10 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,4 +1,7 @@ +"""Connect to database. +""" import os + from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.declarative import declarative_base diff --git a/backend/deejai.py b/backend/deejai.py index 6abd64b..89af744 100644 --- a/backend/deejai.py +++ b/backend/deejai.py @@ -1,82 +1,118 @@ +"""Playlist generation using Deep Learning models. +""" import os import re import uuid import pickle import random import shutil -import librosa import logging +from io import BytesIO + +import librosa import requests import numpy as np -from io import BytesIO +from starlette.concurrency import run_in_threadpool + import tensorflow as tf from keras.models import load_model -from starlette.concurrency import run_in_threadpool -from tensorflow.compat.v1.keras.losses import cosine_proximity class DeejAI: + """Playlist generation class. + """ + N_FFT = 2048 + HOP_LENGTH = 512 + def __init__(self): - mp3tovecs = pickle.load(open('spotifytovec.p', 'rb')) + with open('spotifytovec.p', 'rb') as file: + mp3tovecs = pickle.load(file) mp3tovecs = dict( zip(mp3tovecs.keys(), [ mp3tovecs[_] / np.linalg.norm(mp3tovecs[_]) for _ in mp3tovecs ])) - tracktovecs = pickle.load(open('tracktovec.p', 'rb')) + with open('tracktovec.p', 'rb') as file: + tracktovecs = pickle.load(file) tracktovecs = dict( zip(tracktovecs.keys(), [ tracktovecs[_] / np.linalg.norm(tracktovecs[_]) for _ in tracktovecs ])) - self.tracks = pickle.load(open('spotify_tracks.p', 'rb')) - self.track_ids = [_ for _ in mp3tovecs] + with open('spotify_tracks.p', 'rb') as file: + self.tracks = pickle.load(file) + self.track_ids = list(mp3tovecs) self.track_indices = dict( map(lambda x: (x[1], x[0]), enumerate(mp3tovecs))) self.mp3tovecs = np.array([[mp3tovecs[_], tracktovecs[_]] for _ in mp3tovecs]) del mp3tovecs, tracktovecs - self.model = load_model( - 'speccy_model', - custom_objects={'cosine_proximity': cosine_proximity}) + self.model = load_model('speccy_model', + custom_objects={ + 'cosine_proximity': + tf.compat.v1.keras.losses.cosine_proximity + }) def get_tracks(self): + """Get tracks. + + Returns: + dict: Tracks. + """ return self.tracks async def search(self, string, max_items=100): + """Find all tracks with artist or title containing all words in string. + + Args: + string (str): Search string. + max_items (int, optional): Maximum number of tracks to return. Defaults to 100. + """ def _search(): tracks = self.tracks search_string = re.sub(r'([^\s\w]|_)+', '', string.lower()).split() ids = sorted([ - track for track in tracks - if all(word in re.sub(r'([^\s\w]|_)+', '', tracks[track].lower()) - for word in search_string) + track for track in tracks if all( + word in re.sub(r'([^\s\w]|_)+', '', tracks[track].lower()) + for word in search_string) ], - key=lambda x: tracks[x])[:max_items] + key=lambda x: tracks[x])[:max_items] return ids return await run_in_threadpool(_search) async def playlist(self, track_ids, size, creativity, noise): + """Generate playlist. + + Args: + track_ids (list): Waypoints to include. + size (int): Number of tracks to add between waypoints. + creativity (float): Creativity (between 0 and 1 inclusive). + noise (float): Noise (between 0 and 1 inclusive). + + Returns: + list: Track IDs. + """ if len(track_ids) == 0: track_ids = [random.choice(self.track_ids)] - if len(track_ids) > 1: + elif len(track_ids) > 1: return await self.join_the_dots([creativity, 1 - creativity], - track_ids, - n=size, - noise=noise) - else: - return await self.make_playlist([creativity, 1 - creativity], track_ids, size=size, noise=noise) + return await self.make_playlist([creativity, 1 - creativity], + track_ids, + size=size, + noise=noise) - async def most_similar(self, - mp3tovecs, - weights, - positive=[], - negative=[], - noise=0, - vecs=None): + async def most_similar(self, # pylint: disable=too-many-arguments + mp3tovecs, + weights, + positive=iter(()), + negative=iter(()), + noise=0, + vecs=None): + """Most similar IDs. + """ mp3_vecs_i = np.array([ weights[j] * np.sum([mp3tovecs[i, j] @@ -99,12 +135,14 @@ async def most_similar(self, del result[result.index(i)] return result - async def most_similar_by_vec(self, - mp3tovecs, - weights, - positives=[], - negatives=[], - noise=0): + async def most_similar_by_vec(self, # pylint: disable=too-many-arguments + mp3tovecs, + weights, + positives=iter(()), + negatives=iter(()), + noise=0): + """Most similar IDs by vector. + """ mp3_vecs_i = np.array([ weights[j] * np.sum(positives[j] if positives else [] + -negatives[j] if negatives else [], @@ -119,7 +157,9 @@ async def most_similar_by_vec(self, -np.tensordot(mp3tovecs, mp3_vecs_i, axes=((1, 2), (0, 1))))) return result - async def join_the_dots(self, weights, ids, n=5, noise=0): + async def join_the_dots(self, weights, ids, size=5, noise=0): + """Generate playlist that joins the dots between given waypoints. + """ playlist = [] playlist_tracks = [self.tracks[_] for _ in ids] end = start = ids[0] @@ -127,11 +167,11 @@ async def join_the_dots(self, weights, ids, n=5, noise=0): for end in ids[1:]: end_vec = self.mp3tovecs[self.track_indices[end]] playlist.append(start) - for i in range(n): + for i in range(size): candidates = await self.most_similar_by_vec( self.mp3tovecs, - weights, [[(n - i) / (n + 1) * start_vec[k] + - (i + 1) / (n + 1) * end_vec[k]] + weights, [[(size - i) / (size + 1) * start_vec[k] + + (i + 1) / (size + 1) * end_vec[k]] for k in range(len(weights))], noise=noise) for candidate in candidates: @@ -149,15 +189,17 @@ async def join_the_dots(self, weights, ids, n=5, noise=0): playlist.append(end) return playlist - async def make_playlist(self, + async def make_playlist(self, # pylint: disable=too-many-arguments weights, playlist, size=10, lookback=3, noise=0): + """Generate playlist starting from seed track(s). + """ playlist_tracks = [self.tracks[_] for _ in playlist] playlist_indices = [self.track_indices[_] for _ in playlist] - for i in range(len(playlist), size): + for _ in range(len(playlist), size): candidates = await self.most_similar( self.mp3tovecs, weights, @@ -174,31 +216,33 @@ async def make_playlist(self, break playlist.append(track_id) playlist_tracks.append(self.tracks[track_id]) - playlist_indices.append(candidate) + playlist_indices.append(candidate) # pylint: disable=undefined-loop-variable return playlist async def get_similar_vec(self, track_url, max_items=10): + """Most similar to MP3 given by URL. + """ def _get_similar_vec(): y, sr = librosa.load(f'{playlist_id}.{extension}', mono=True) os.remove(f'{playlist_id}.{extension}') S = librosa.feature.melspectrogram(y=y, sr=sr, - n_fft=n_fft, - hop_length=hop_length, + n_fft=self.N_FFT, + hop_length=self.HOP_LENGTH, n_mels=n_mels, fmax=sr / 2) # hack because Spotify samples are a shade under 30s x = np.ndarray(shape=(S.shape[1] // slice_size + 1, n_mels, slice_size, 1), dtype=float) - for slice in range(S.shape[1] // slice_size): + for slice_ in range(S.shape[1] // slice_size): log_S = librosa.power_to_db( - S[:, slice * slice_size:(slice + 1) * slice_size], + S[:, slice_ * slice_size:(slice_ + 1) * slice_size], ref=np.max) if np.max(log_S) - np.min(log_S) != 0: log_S = (log_S - np.min(log_S)) / (np.max(log_S) - np.min(log_S)) - x[slice, :, :, 0] = log_S + x[slice_, :, :, 0] = log_S # hack because Spotify samples are a shade under 30s log_S = librosa.power_to_db(S[:, -slice_size:], ref=np.max) if np.max(log_S) - np.min(log_S) != 0: @@ -208,19 +252,20 @@ def _get_similar_vec(): return self.model.predict(x) playlist_id = str(uuid.uuid4()) - n_fft = 2048 - hop_length = 512 n_mels = self.model.layers[0].input_shape[0][1] slice_size = self.model.layers[0].input_shape[0][2] try: - r = requests.get(track_url, allow_redirects=True) - if r.status_code != 200: + response = requests.get(track_url, allow_redirects=True) + if response.status_code != 200: return [] - extension = 'wav' if 'wav' in r.headers['Content-Type'] else 'mp3' + extension = 'wav' if 'wav' in response.headers[ + 'Content-Type'] else 'mp3' with open(f'{playlist_id}.{extension}', 'wb') as file: # this is really annoying! - shutil.copyfileobj(BytesIO(r.content), file, length=131072) + shutil.copyfileobj(BytesIO(response.content), + file, + length=131072) vecs = await run_in_threadpool(_get_similar_vec) candidates = await self.most_similar_by_vec( self.mp3tovecs[:, np.newaxis, 0, :], [1], [vecs]) @@ -229,8 +274,8 @@ def _get_similar_vec(): for candidate in candidates[0:max_items] ] return ids - except Exception as e: - logging.error(e) + except Exception as error: # pylint: disable=broad-except + logging.error(error) if os.path.exists(f'./{playlist_id}.mp3'): os.remove(f'./{playlist_id}.mp3') return [] diff --git a/backend/main.py b/backend/main.py index 379caeb..86828ed 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,31 +1,38 @@ +"""Backend to handle calls to DeejAI, Spotify authentication and database functionality. +""" + import os import re import urllib -import aiohttp import logging -from . import models -from . import schemas -from . import credentials -from .deejai import DeejAI from typing import Optional from base64 import b64encode + from starlette.types import Scope -from sqlalchemy.orm import Session -from sqlalchemy import desc, event -from starlette.responses import Response -from sqlalchemy.exc import IntegrityError -from .database import SessionLocal, engine +from starlette.responses import Response, RedirectResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from fastapi import Depends, FastAPI, HTTPException from fastapi.staticfiles import StaticFiles -from starlette.responses import RedirectResponse from fastapi.middleware.cors import CORSMiddleware -from fastapi import Depends, FastAPI, HTTPException from fastapi.exception_handlers import http_exception_handler -from starlette.exceptions import HTTPException as StarletteHTTPException + +from sqlalchemy import desc, event +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError, SQLAlchemyError + +import aiohttp + +from . import models +from . import schemas +from . import credentials +from .deejai import DeejAI +from .database import SessionLocal, engine credentials.redirect_uri = os.environ.get('SPOTIFY_REDIRECT_URI', - credentials.redirect_uri) + credentials.REDIRECT_URL) -# create tables if necessary +# Create tables if necessary models.Base.metadata.create_all(bind=engine) deejai = DeejAI() @@ -35,6 +42,11 @@ # Dependency def get_db(): + """Connect to database. + + Yields: + Session: SQLAlchemy session. + """ db = SessionLocal() try: yield db @@ -43,12 +55,16 @@ def get_db(): @event.listens_for(models.Playlist, "before_update") -def receive_before_update(mapper, connection, target): +def receive_before_update(mapper, connection, target): # pylint: disable=unused-argument + """Ensure hash is calculated before an update. + """ target.hash = models.Playlist.hash_it(target) @event.listens_for(models.Playlist, "before_insert") -def receive_before_update(mapper, connection, target): +def receive_before_insert(mapper, connection, target): # pylint: disable=unused-argument + """Ensure hash is calculated before an insert. + """ target.hash = models.Playlist.hash_it(target) @@ -70,7 +86,9 @@ def receive_before_update(mapper, connection, target): ) -class EndpointFilter(logging.Filter): +class EndpointFilter(logging.Filter): # pylint: disable=too-few-public-methods + """Filter out endpoints (e.g. /healthz) from logging + """ def filter(self, record: logging.LogRecord) -> bool: return record.getMessage().find("/healthz") == -1 @@ -81,17 +99,30 @@ def filter(self, record: logging.LogRecord) -> bool: @app.get("/healthz") async def health_check(): - return {'status': 'pass'} + """Am I alive? + + Returns: + dict: {"status": "pass"} + """ + return {"status": "pass"} @app.get("/api/v1/login") async def spotify_login(state: Optional[str] = None): + """Initiate Spotify authentication. + + Args: + state (Optional[str], optional): State that is passed to callback. Defaults to None. + + Returns: + RedirectResponse: Redirection to Spotify authentication URL. + """ scope = "playlist-modify-public user-read-currently-playing" body = { 'response_type': 'code', - 'client_id': credentials.client_id, + 'client_id': credentials.CLIENT_ID, 'scope': scope, - 'redirect_uri': credentials.redirect_uri, + 'redirect_uri': credentials.REDIRECT_URL, } if state: body['state'] = state @@ -102,15 +133,24 @@ async def spotify_login(state: Optional[str] = None): @app.get("/api/v1/callback") async def spotify_callback(code: str, state: Optional[str] = '/'): + """Handle callback from Spotify. This endpoint must correspond to credentials.redirect_uri. + + Args: + code (str): Authorization code. + state (Optional[str], optional): State passed in from login. Defaults to '/'. + + Returns: + RedirectResponse: Redirection to application root with hash parameters. + """ data = { 'code': code, - 'redirect_uri': credentials.redirect_uri, + 'redirect_uri': credentials.REDIRECT_URL, 'grant_type': 'authorization_code' } headers = { 'Authorization': 'Basic ' + - b64encode(f'{credentials.client_id}:{credentials.client_secret}'. + b64encode(f'{credentials.CLIENT_ID}:{credentials.CLIENT_SECRET}'. encode('utf-8')).decode('utf-8') } async with aiohttp.ClientSession() as session: @@ -123,7 +163,7 @@ async def spotify_callback(code: str, state: Optional[str] = '/'): detail=response.reason) json = await response.json() except aiohttp.ClientError as error: - raise HTTPException(status_code=400, detail=str(error)) + raise HTTPException(status_code=400, detail=str(error)) from error body = { 'access_token': json['access_token'], 'refresh_token': json['refresh_token'], @@ -135,11 +175,19 @@ async def spotify_callback(code: str, state: Optional[str] = '/'): @app.get("/api/v1/refresh_token") async def spotify_refresh_token(refresh_token: str): + """Get a new access token using the refresh token. + + Args: + refresh_token (str): Spotify refresh token. + + Returns: + str: Access token in JSON format. + """ data = {'refresh_token': refresh_token, 'grant_type': 'refresh_token'} headers = { 'Authorization': 'Basic ' + - b64encode(f'{credentials.client_id}:{credentials.client_secret}'. + b64encode(f'{credentials.CLIENT_ID}:{credentials.CLIENT_SECRET}'. encode('utf-8')).decode('utf-8') } async with aiohttp.ClientSession() as session: @@ -152,15 +200,24 @@ async def spotify_refresh_token(refresh_token: str): detail=response.reason) json = await response.json() except aiohttp.ClientError as error: - raise HTTPException(status_code=400, detail=str(error)) + raise HTTPException(status_code=400, detail=str(error)) from error return json @app.get("/api/v1/widget") async def widget(track_id: str): + """Get Spotify track widget. + + Args: + track_id (str): Spotify track ID. + + Returns: + str: Base 64 encoded HTML which can be embedded in an iframe. + """ headers = { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36' + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " \ + "Chrome/92.0.4515.159 Safari/537.36" } async with aiohttp.ClientSession() as session: try: @@ -172,24 +229,50 @@ async def widget(track_id: str): detail=response.reason) text = await response.text() except aiohttp.ClientError as error: - raise HTTPException(status_code=400, detail=str(error)) + raise HTTPException(status_code=400, detail=str(error)) from error return b64encode(text.encode('ascii')) @app.get("/api/v1/search") async def search_tracks(string: str, max_items: int): + """Search database of tracks for artists and titles including words in string. + + Args: + string (str): String of case insensitive words to search for. + max_items (int): Maximum number of items to return. + + Returns: + list: List of track IDs and tracks. + """ ids = await deejai.search(string, max_items) return [{'track_id': id, 'track': deejai.get_tracks()[id]} for id in ids] @app.get("/api/v1/search_similar") async def search_similar_tracks(url: str, max_items: int): + """Search for tracks that sound similar using Deep Learning model. + + Args: + url (str): URL of MP3 (for example, Spotify preview URL). + max_items (int): Maximum number of items to return. + + Returns: + list: List of track IDs and tracks. + """ ids = await deejai.get_similar_vec(url, max_items) return [{'track_id': id, 'track': deejai.get_tracks()[id]} for id in ids] @app.post("/api/v1/playlist") -async def playlist(playlist: schemas.NewPlaylist): +async def generate_playlist(playlist: schemas.NewPlaylist): + """Generate a playlist that joins the dots between the waypoints. + + Args: + playlist (schemas.NewPlaylist): List of track IDs, size, "creativity" and "noise". + + Returns: + dict: Separate list of track IDs and tracks. + """ ids = await deejai.playlist(playlist.track_ids, playlist.size, playlist.creativity, playlist.noise) return { @@ -200,6 +283,15 @@ async def playlist(playlist: schemas.NewPlaylist): @app.post("/api/v1/create_playlist") def create_playlist(playlist: schemas.Playlist, db: Session = Depends(get_db)): + """Store new playlist in database + + Args: + playlist (schemas.Playlist): Playlist fields. + db (Session, optional): SQLAlchemy session. Defaults to Depends(get_db). + + Returns: + models.Playlist: Newly created playlist from database. + """ db_item = models.Playlist(**playlist.dict()) try: db.add(db_item) @@ -212,72 +304,116 @@ def create_playlist(playlist: schemas.Playlist, db: Session = Depends(get_db)): db_item.update({'created': playlist.created}) db.commit() # doesn't call hook but that's ok db_item = db_item.first() - except: - logging.error(f"Unable to create playlist {playlist}") - db.rollback() return db_item @app.get("/api/v1/read_playlist") -def get_playlist(id: int, db: Session = Depends(get_db)): - db_item = db.query(models.Playlist).get(id) +def get_playlist(playlist_id: int, db: Session = Depends(get_db)): + """Get playlist by ID from the database. + + Args: + playlist_id (int): Playlist ID. + db (Session, optional): SQLAlchemy session. Defaults to Depends(get_db). + + Returns: + models.Playlist: Playlist from database. + """ + db_item = db.query(models.Playlist).get(playlist_id) return db_item @app.post("/api/v1/update_playlist_name") def update_playlist_name(playlist: schemas.PlaylistName, db: Session = Depends(get_db)): + """Update name of playlist with given ID in the database. + + Args: + playlist (schemas.PlaylistName): playlist.name should be specified. + db (Session, optional): SQLAlchemy session. Defaults to Depends(get_db). + """ db.query(models.Playlist).get(playlist.id).name = playlist.name try: db.commit() - except: - logging.error(f"Unable to update playlist name {playlist}") + except SQLAlchemyError: + logging.error("Unable to update playlist name %s", playlist) db.rollback() @app.post("/api/v1/update_playlist_rating") def update_playlist_rating(playlist: schemas.PlaylistRating, db: Session = Depends(get_db)): + """Update rating of playlist with given ID in the database. + + Args: + playlist (schemas.PlaylistName): playlist.av_rating and playlist.num_ratings + should be specified. + db (Session, optional): SQLAlchemy session. Defaults to Depends(get_db). + """ db_item = db.query(models.Playlist).get(playlist.id) db_item.av_rating = playlist.av_rating db_item.num_ratings = playlist.num_ratings try: db.commit() - except: - logging.error(f"Unable to update playlist rating {playlist}") + except SQLAlchemyError: + logging.error("Unable to update playlist rating %s", playlist) db.rollback() @app.post("/api/v1/update_playlist_id") def update_playlist_id(playlist: schemas.PlaylistId, db: Session = Depends(get_db)): + """Update Spotrify playlist and user IDs of playlist with given ID in the database. + + Args: + playlist (schemas.PlaylistName): playlist.playlist_id and playlist.user_id + should be specified. + db (Session, optional): SQLAlchemy session. Defaults to Depends(get_db). + """ db_item = db.query(models.Playlist).get(playlist.id) db_item.user_id = playlist.user_id db_item.playlist_id = playlist.playlist_id try: db.commit() - except: - logging.error(f"Unable to update playlist id {playlist}") + except SQLAlchemyError: + logging.error("Unable to update playlist id %s", playlist) db.rollback() @app.get("/api/v1/latest_playlists") def get_latest_playlists(top_n: int, db: Session = Depends(get_db)): + """Get the most recently added playlists from the database. + + Args: + top_n (int): Maximum number of playlists to return. + db (Session, optional): SQLAlchemy session. Defaults to Depends(get_db). + + Returns: + list: List of models.Playlist items. + """ try: - db_items = db.query(models.Playlist).order_by(desc( - models.Playlist.created)).limit(top_n).all() - except: - return [] + db_items = db.query(models.Playlist).order_by( + desc(models.Playlist.created)).limit(top_n).all() + except SQLAlchemyError: + return [] return db_items @app.get("/api/v1/top_playlists") def get_top_playlists(top_n: int, db: Session = Depends(get_db)): + """Get the most highly rated playlists from the database. + + Args: + top_n (int): Maximum number of playlists to return. + db (Session, optional): SQLAlchemy session. Defaults to Depends(get_db). + + Returns: + list: List of models.Playlist items. + """ try: - db_items = db.query(models.Playlist).order_by( - desc(models.Playlist.av_rating)).limit(top_n).all() - except: - return [] + db_items = db.query(models.Playlist).order_by( + desc(models.Playlist.av_rating)).limit(top_n).all() + except SQLAlchemyError: + return [] return db_items @@ -285,6 +421,16 @@ def get_top_playlists(top_n: int, db: Session = Depends(get_db)): def search_playlists(string: str, max_items: int, db: Session = Depends(get_db)): + """Search for playlists in the database with a name or tracks with words in string. + + Args: + string (str): String of case insensitive words to search for. + max_items (int): Maximum number of playlists to return. + db (Session, optional): SQLAlchemy session. Defaults to Depends(get_db). + + Returns: + list: List of models.Playlist items. + """ db_items = [] search_string = re.sub(r'([^\s\w]|_)+', '', string.lower()).split() for db_item in db.query(models.Playlist).all(): @@ -301,22 +447,24 @@ def search_playlists(string: str, # let front end handle 404 @app.exception_handler(StarletteHTTPException) async def custom_http_exception_handler(request, exc): + """Itercept 404 not found and redirect to application 404 page. + """ if exc.status_code == 404: url = os.environ.get('APP_URL', '') + "/#" + urllib.parse.urlencode( {'route': request.url.path}) return RedirectResponse(url=url) - else: - return await http_exception_handler(request, exc) + return await http_exception_handler(request, exc) -# hack because starlette StaticFiles returns PlainTextReponse instead of Exception in case of 404 class _StaticFiles(StaticFiles): + """Hack because starlette StaticFiles returns PlainTextReponse instead of Exception + in case of 404 + """ async def get_response(self, path: str, scope: Scope) -> Response: response = await super().get_response(path, scope) if response.status_code == 404: raise StarletteHTTPException(404, "Not found") - else: - return response + return response # must be last diff --git a/backend/models.py b/backend/models.py index 7f7615a..2674ad5 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,10 +1,15 @@ +"""Define database models. +""" import pickle from hashlib import sha256 -from .database import Base from sqlalchemy import Column, Float, Integer, String, DateTime +from .database import Base + class Playlist(Base): + """Model for playlist table in the database. + """ __tablename__ = "playlists" id = Column(Integer, primary_key=True, index=True) name = Column(String(30), default="Deej-A.I.") @@ -20,12 +25,24 @@ class Playlist(Base): noise = Column(Float, default=0) hash = Column(String(64), default=0, unique=True) + @staticmethod def hash_it(target): + """Generate unique hash based on playlist name, user_id, playlist_id, av_rating, + num_ratings, track_ids and waypoints + + Args: + target (models.Playlist): [description] + + Returns: + str: SHA256 digest. + """ blob = (target.name, target.user_id, target.playlist_id, target.av_rating, target.num_ratings, target.track_ids, target.waypoints) return sha256(pickle.dumps(blob)).hexdigest() def __repr__(self): + """Pretty print playlist object. + """ return "" % ( self.id, self.name, self.hash) diff --git a/backend/schemas.py b/backend/schemas.py index d932b90..c524429 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,33 +1,45 @@ +"""Schemas for pydantic API calls. +""" from typing import Optional from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel # pylint: disable=no-name-in-module -class NewPlaylist(BaseModel): +class NewPlaylist(BaseModel): # pylint: disable=too-few-public-methods + """Schema for generating a new playlist. + """ track_ids: list size: Optional[int] = 10 creativity: Optional[float] = 0.5 noise: Optional[float] = 0 -class PlaylistName(BaseModel): +class PlaylistName(BaseModel): # pylint: disable=too-few-public-methods + """Schema for updating playlist name. + """ id: int name: Optional[str] = 'Deej-A.I.' -class PlaylistRating(BaseModel): +class PlaylistRating(BaseModel): # pylint: disable=too-few-public-methods + """Schema for updating playlist rating. + """ id: int av_rating: float num_ratings: int -class PlaylistId(BaseModel): +class PlaylistId(BaseModel): # pylint: disable=too-few-public-methods + """Schema for updating playlist Spotif playlist an2d user IDs. + """ id: int user_id: Optional[str] = None playlist_id: Optional[str] = None -class Playlist(BaseModel): +class Playlist(BaseModel): # pylint: disable=too-few-public-methods + """Schema for storing a new playlist in the database. + """ name: Optional[str] = "Deej-A.I." created: datetime user_id: Optional[str] = "" diff --git a/backend/test_deejai.py b/backend/test_deejai.py index 638c869..31fd7d7 100644 --- a/backend/test_deejai.py +++ b/backend/test_deejai.py @@ -1,23 +1,28 @@ +"""Unit tests using pytest. +""" import os +import json +import asyncio +from datetime import datetime + +import pytest # pylint: disable=unused-import + +from . import main +from . import schemas os.environ["SQLALCHEMY_DATABASE_URL"] = "sqlite:///./deejai-test.db" if os.path.exists('deejai-test.db'): os.remove('deejai-test.db') -import json -import pytest -import asyncio -from .main import * -from . import schemas -from datetime import datetime - -db = SessionLocal() +db = main.SessionLocal() def test_playlist_1(): + """Test joining the dots between two waypoints. + """ new_playlist = schemas.NewPlaylist( track_ids=["1O0xeZrBDbq7HPREdmYUYK", "1b7LMtXCXGc2EwOIplI35z"]) - assert asyncio.run(playlist(new_playlist))['track_ids'] == [ + assert asyncio.run(main.generate_playlist(new_playlist))['track_ids'] == [ "1O0xeZrBDbq7HPREdmYUYK", "6Y0ed41KYLRnJJyYGGaDgY", "5yrsBzgHkfu2idkl2ILQis", "6yXcmVKGjFofPWvW9ustQX", "1DKyFVzIh1oa1fFnEmTkIl", "6b8hjwuGl1H9o5ZbrHJcpJ", @@ -28,10 +33,12 @@ def test_playlist_1(): def test_playlist_2(): + """Test varying creativity. + """ new_playlist = schemas.NewPlaylist(track_ids=["7dEYcnW1YSBpiKofefCFCf"], size=20, creativity=0.1) - assert asyncio.run(playlist(new_playlist))['track_ids'] == [ + assert asyncio.run(main.generate_playlist(new_playlist))['track_ids'] == [ "7dEYcnW1YSBpiKofefCFCf", "7u9szLn7CWcWtiYcRLy0Ab", "34QkdRnLmpTp3GemmSXPkz", "0sQ9MCD0ichtBCSi8Khn3h", "0hwEeMnAgwEvClAXOl3Sgh", "63Iv8NhccFc2qXgIsrDo4Q", @@ -46,7 +53,9 @@ def test_playlist_2(): def test_search(): - assert asyncio.run(search_tracks(string='hello', max_items=3)) == [{ + """Test searching for tracks. + """ + assert asyncio.run(main.search_tracks(string='hello', max_items=3)) == [{ 'track_id': '6BbTfV6NXacNelIcVLXu9t', 'track': @@ -65,15 +74,17 @@ def test_search(): def test_add_playlist(): + """Test adding playlist to the databsse. + """ new_playlist = schemas.NewPlaylist(track_ids=["7dEYcnW1YSBpiKofefCFCf"], size=10, creativity=0.) - new_playlist = asyncio.run(playlist(new_playlist)) + new_playlist = asyncio.run(main.generate_playlist(new_playlist)) new_playlist = schemas.Playlist(created=datetime.now(), track_ids=json.dumps( new_playlist['track_ids'])) - create_playlist(new_playlist, db) - assert (get_latest_playlists(1, db)[0].track_ids == json.dumps([ + main.create_playlist(new_playlist, db) + assert (main.get_latest_playlists(1, db)[0].track_ids == json.dumps([ "7dEYcnW1YSBpiKofefCFCf", "66LPSGwq2MKuFLSjAnclmg", "1Ulk1RYwszH5PliccyN5pF", "3ayr466SicYLcMRSCuiOSL", "6ijkogEt87TOoFEUdTpYxD", "2hq28hLmCPFxg2FamW6KA3", @@ -82,25 +93,29 @@ def test_add_playlist(): ])) -# assumes test_add_playlist has already been run def test_update_playlist(): - id = get_latest_playlists(1, db)[0].id - playlist_name = schemas.PlaylistName(id=id, name="Test") - update_playlist_name(playlist_name, db) - playlist_rating = schemas.PlaylistRating(id=id, + """Test updating an existing playlist. Assumes test_add_playlist has already been run. + """ + playlist_id = main.get_latest_playlists(1, db)[0].id + playlist_name = schemas.PlaylistName(id=playlist_id, name="Test") + main.update_playlist_name(playlist_name, db) + playlist_rating = schemas.PlaylistRating(id=playlist_id, av_rating=4.5, num_ratings=1) - update_playlist_rating(playlist_rating, db) - playlist = get_playlist(id, db) + main.update_playlist_rating(playlist_rating, db) + playlist = main.get_playlist(playlist_id, db) assert (playlist.name == "Test" and playlist.av_rating == 4.5 and playlist.num_ratings == 1) def test_search_similar(): + """Test searching for a similar sounding track. + """ assert asyncio.run( - search_similar_tracks( + main.search_similar_tracks( url= - 'https://p.scdn.co/mp3-preview/04b28b12174a4c4448486070962dae74494c0f70?cid=194086cb37be48ebb45b9ba4ce4c5936', + "https://p.scdn.co/mp3-preview/04b28b12174a4c4448486070962dae74494c0f70?" \ + "cid=194086cb37be48ebb45b9ba4ce4c5936", max_items=10)) == [{ "track_id": "1a9SiOELQS7YsBQwdEPMuq", "track": "Luis Fonsi - Despacito" diff --git a/migrate_db.py b/migrate_db.py index ae9ff9e..b1cf322 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -1,16 +1,27 @@ +"""Migrate from one database to another. + + Usage: python migrate.py +""" import logging import argparse -from backend import models -from sqlalchemy import event + from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, class_mapper -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy_utils import database_exists, create_database +from backend import models + logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO) def copy_objects(db_from, db_to, cls): + """Copy all items in a table from one databse to another. + + Args: + db_from (Session): SQLAlchemy session of databsse to copy from. + db_to (Session): SQLAlchemy session of databsse to copy to. + cls (type): Class of table schema. + """ db_to_items = [] db_items = db_from.query(cls) mapper = class_mapper(cls) @@ -21,7 +32,7 @@ def copy_objects(db_from, db_to, cls): db_to_items.append(db_to_item) db_to.bulk_save_objects(db_to_items) db_to.commit() - logging.info(f"Migrated {len(db_to_items)} {cls}") + logging.info("Migrated %d %s", len(db_to_items), cls) if __name__ == "__main__": @@ -53,7 +64,5 @@ def copy_objects(db_from, db_to, cls): models.Base.metadata.drop_all(bind=to_engine, tables=[models.Playlist.__table__]) models.Base.metadata.create_all(bind=to_engine) - db_from = FromSessionLocal() - db_to = ToSessionLocal() - copy_objects(db_from, db_to, models.Playlist) + copy_objects(FromSessionLocal(), ToSessionLocal(), models.Playlist) diff --git a/pre-commit b/pre-commit new file mode 100644 index 0000000..b062085 --- /dev/null +++ b/pre-commit @@ -0,0 +1,8 @@ +#!/bin/sh +# cp pre-commit .git/hooks/ +git stash -q --keep-index +./run_tests.sh +RESULT=$? +git stash pop -q +[ $RESULT -ne 0 ] && exit 1 +exit 0