diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c8d967e6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,212 @@ + +# v3.1.0 + +* fix `MapUser` inheritence (now properly inherits from `ReplayContainer`) +* fix incorrect `__add__` method for `Check` +* rename `Keys` to `Key` (`Keys` left available as deprecated) +* don't double load `ReplayInfo` when using `Map` +* fix mod subtraction not being commutative (eg `Mod.HDHR - Mod.HR` has a different meaning from `Mod.HR - Mod.HDHR`) +* store beatmap hash in `ReplayPath` +* properly check response for `map_id`, `user_id`, and `username` functions +* enforce ratelimit to all Loader functions +* don't reraise `InvalidKeyException` as a `CircleguardException` +* add CHANGELOG file to both track unreleased changes and past changes +* various documentation links and wording fixes + +# v3.0.0 + +## Important + +* add aim correction detection +* new forward-facing documentation built with sphinx, including a comprehensive introduction on how to use circlecore () +* new `User` class which represents a user's top plays +* new `MapUser` class which represents all of a user's plays on a map +* new `Mod` and `ModCombination` class which represent mods +* restructure `replay.py` and inheritance of `loadables`. `Container` is (roughly) replaced with `InfoLoadable`, and `Check` is the only entry point for `Detect`. +* major `Detect` restructure; split into subclasses per cheat type. A `Detect` is instantiated with its respective thresholds(steal, ur, etc) instead of thresholds being global settings +* `Map` and `User` are now iterable and indexable, referencing `Replay`s in the `Map` or `User` +* rewrite and update all internal documentation +* `Circleguard` now takes a `cache` argument, which can make the database effectively read-only + +## Not so Important + +* switch documentation style from google to numpy () +* new method `cg.load_info` that loads the info for `ReplayContainer`s +* global settings almost entirely removed, save for `loglevel` +* instance settings almost entirely removed, save for `cache` +* rename `replay.py` to `loadable.py` +* rename `UserInfo` to `ReplayInfo` +* `Options` class removed +* add a `loader#username` function which retrieves a username from a user id. See also +* add an `lru_cache` to `loader#map_id`, `#user_id`, and `#username` functions +* `loader#get_user_best` now returns a list of `UserInfo`, and accepts a `mods` argument. +* `cg.run` now only accepts a `Check` +* replace int mods with `ModCombination` in most places +* add ScoreV2 mod. Fixes not being able to process ScoreV2 replays. +* create a slider `Library` every time `#run` is called if `slider_dir` is not passed. Fixes `PermissionError`s on windows. +* remove convenience methods (`user_check`, `map_check`, etc). These have been replaced by `Map` and `User` (new) +* update STYLE document +* all Circleguard instances now use the same logger +* `filter` argument removed throughout the codebase +* `RatelimitWeight` and `ResultType` enum string values capitalized +* `ResultType.AIM_CORRECTION` renamed to `CORRECTION` +* `#ur` is now a staticmethod in `Investigator` + +# v2.4.0 + +* new Map class for conveniently specifying a range of replays on a map that can be ran directly with cg.run() +* new span argument to loadables and map_check and user_check which specify exactly which of the top replays to check +* restructure of Replays and Checks. Both now inherit from Loadable, and Check and Map inherit from Container. Containers can hold other Containers, to an arbitrary depth. cg.run() now accepts any Container. +* use Slider to download beatmaps for relax detection +* fix user_check not using the same args as create_user_check +* check.filter() now requires a Loader +* add test cases for different replay types +* REPLAY_STEALING and REMODDING ResultType renamed to STEAL and REMOD respectively +* optimize ur calculation +* fix RelaxResult returning timestamped data in `result.replay` instead of the replay +* keys enum is now an IntFlag instead of an Enum +* update test cases for cookiezi's new name (chocomint) +* comparer decides mode on its own and does not need a mode in Comparer#compare +* clean up ColoredFormatter code + +# v2.3.1 + +* fix error when running local check with both u and map id +* throw NoInfoAvailableException on empty api response + +# v2.3.0 + +* cg.load now accepts either a Check object or a Replay object. Passing a check will result in all replays stored in the check being loaded. +* cg.load no longer requires a Check to load a replay. +* settings overhaul - settings now cascade properly and at different times than before. +* test suite added (not covering everything, yet) +* fix error when setting an Option class value (infinite recursion) + +# v2.2.0 + +* add relax cheat detection (and consequently UR calculation) +* allow circleguard to be used without a database +* add mods argument to map_check +* add Detect settings to global/cg/check/replay +* retry requests if JSONDecodeError response is returned by ossapi + * avoids fatal error while replay loading if api returns invalid response +* fix `pip install circleguard` failing if requirements were not installed +* minor readme example updates +* remove load progress tracking from Loader + +## Now Using ossapi 1.2.2 + +* return custom response for JSONDecodeError when api returns invalid json + +## Now using circleparse 6.1.0 + +* replay_id now parses to an int instead of a tuple +* add files for more complicated parsing +* switch license to GPL3 to comply with osu-parser license + +# v2.1.0 + +* fix convenience options not having effect when passing falsy values +* load map id and user id for local osrs +* fix fatal error when ratelimit is barely hit and proceeded by light api calls +* require map_id, user_id, and timestamp in Replay +* provide earlier_replay and later_replay in Result class that reference either replay1 or replay2 depending on timestamp order (and remove later_name) (#78) + +# v2.0.2 + +* fix false positive when the user being checked was on the map leaderboard being checked with map check +* fix false positive with user screen when user was on leaderboard of their top plays +* fix error when trying to load only a single replay from a map + +# v2.0.1 + +* differentiate loggers between circleguard instances +* add missing cache option to convenience methods +* add map and user options to create_local_check + +# v2.0.0 + +This release splits `Circleguard` into `circlecore` (pip module) and `circleguard` (gui with `pyqt` as the frontend, using `circlecore` as the backend). + +Changes: + +* replays can be loaded from arbitrary locations (db, osu website, mirror website, osr file) +* convenience methods for common use cases added (checking a map or user) +* support for comparing two arbitrary replays (from two different maps, if you so choose) +* logging overhaul, any important action is logged +* major code cleanup +* removal of command line interface +* code standardized for pip upload (`setup.py`, `__init__.py`, etc) + +# v1.3.3 + +* fix non-osr files being loaded as osr + +# v1.3.2 + +* fix replay being compared against itself with -m -u + +# v1.3.1 + +* fix fatal error when using both -l and -m flags + +# v1.3.0 + +* new profile screener that checks a user's top n plays for replay stealing and remodding when -u flag only is passed +* ability to restrict what replays are downloaded (and compared) with the --mods flag +* major internal cleanup with the addition of the user_info class +* fix names starting with an underscore not being displayed on graphs + +## Now using circleparse v4.0.1 + +* don't fatal error when rng seed is not present when we expect it to be +* fix wrong name to int mappings +* add scorev2 (fixes fatal error on attempting to parse a replay with the scorev2 mod) + +## Now using ossapi v1.1.1 + +* filter out None values, not None keys, in kwargs parameters + +# v1.2.0 + +* now detect steals which either have hr added or hr removed from the replay it was stolen from +* print progress every 10% when comparing replays +* print loading progress every time there is a pause for ratelimits while loading beatmaps +* move api wrapper to separate repo; formalize api calls +* catch and retry Request related exceptions +* potentially fix matplotlib printing "invalid command name" (#43) +* fix error when redownloading outdated replays + +## Now using circleparse v3.2.1 + +* parse rng seed value from last frame of lzma (previously, nonsensical values such as -12345|0|0|10186099 were stored in the replay data) + +## Now using ossapi v1.0.0 + +* move api wrapper to separate repo + +# v1.1.0 + +* new --verify flag designed for staff use that checks if replays by two users on a given map are copies +* add --version flag that prints program version +* program renamed to circleguard (thanks to InvisibleSymbol for the name) +* print usernames instead of user ids for OnlineReplay comparisons +* use a single replay folder for local comparisons instead of two +* change default threshold to 18 +* highlight the later replay instead of the first replay in printout +* remove --single flag (this is now default behavior when -l is set) +* load local replays per circleguard instance (fixes incosistent gui behavior) +* handle "Replay retrieval failed." api response +* fix None replays being compared after handling api error response +* force gui comparisons to not visualize replays (avoid multithreading crashes) +* raise properly sublclassed exceptions instead of base Exception +* only revalidate users that are actually stored in local cache +* properly compress replays that use smoke key (see v1.1.1 wtc-lzma-compression) + +## Now using wtc-lzma-compression v1.1.1 + +* treat z stream as a signed byte instead of unsigned + +# v1.0.0 + +* original release diff --git a/README.md b/README.md index ed008088..db27c89b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Circlecore can be installed from pip: pip install circleguard ``` -This documentation refers to the project as `circlecore` to differentiate it from our organization [Circleguard](https://github.com/circleguard) and the gui application [Circleguard](https://github.com/circleguard/circlegaurd). However, `circlecore` is installed from pypi with the name `circleguard`, and is imported as such in python (`import circleguard`). +This documentation refers to the project as `circlecore` to differentiate it from our organization [Circleguard](https://github.com/circleguard) and the gui application [Circleguard](https://github.com/circleguard/circleguard). However, `circlecore` is installed from pypi with the name `circleguard`, and is imported as such in python (`import circleguard`). ## Links diff --git a/appveyor.yml b/appveyor.yml index 4a10365e..65d14d23 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,5 +1,4 @@ image: Visual Studio 2017 -build: off environment: python_37: C:/Python37/python.exe @@ -8,7 +7,7 @@ install: - "%python_37% -V" - "%python_37% -m pip install -e ." -test_script: +build_script: - "%python_37% -m unittest" on_failure: diff --git a/circleguard/__init__.py b/circleguard/__init__.py index aea549b8..568bc901 100644 --- a/circleguard/__init__.py +++ b/circleguard/__init__.py @@ -2,7 +2,7 @@ from circleguard.circleguard import Circleguard, set_options from circleguard.loadable import Check, Replay, ReplayMap, ReplayPath, Map, User, MapUser -from circleguard.enums import Detect, RatelimitWeight, Keys, StealDetect, RelaxDetect, Mod, CorrectionDetect +from circleguard.enums import Detect, RatelimitWeight, Keys, Key, StealDetect, RelaxDetect, Mod, CorrectionDetect from circleguard.utils import TRACE, ColoredFormatter from circleguard.loader import Loader from circleguard.replay_info import ReplayInfo @@ -25,5 +25,5 @@ "ComparisonResult", "RelaxResult", "ReplayStealingResult", "ResultType", "CircleguardException", "InvalidArgumentsException", "Map", "User", "APIException", "NoInfoAvailableException", "UnknownAPIException", "InternalAPIException", - "InvalidKeyException", "RatelimitException", "InvalidJSONException", "ReplayUnavailableException", "Keys", + "InvalidKeyException", "RatelimitException", "InvalidJSONException", "ReplayUnavailableException", "Keys", "Key", "Mod", "CorrectionResult", "MapUser"] diff --git a/circleguard/cacher.py b/circleguard/cacher.py index 5334e7e4..044549bd 100644 --- a/circleguard/cacher.py +++ b/circleguard/cacher.py @@ -36,7 +36,7 @@ def __init__(self, cache, path): def cache(self, lzma_bytes, replay_info): """ Caches a replay in the form of a (compressed) lzma stream to the - database, linking it to the given user info. + database, linking it to the given replay info. Parameters ---------- @@ -49,11 +49,11 @@ def cache(self, lzma_bytes, replay_info): Notes ----- - If an entry with the given user info already exists, it is overwritten + If an entry with the given replay info already exists, it is overwritten by the passed lzma. The lzma string is compressed with wtc compression. See - :func:`~circleguard.Cacher.compress` and :func:`~wtc.compress` for more. + :func:`~Cacher._compress` and :func:`wtc.compress` for more. A call to this method has no effect if the Cacher's ``should_cache`` is ``False``. @@ -84,7 +84,7 @@ def revalidate(self, loader, replay_info): Checks entries in ``replay_info`` against their entries in the database (if any) to look for score id mismatches, indicating an outdated replay. If there are mismatches, the replay is redownloaded and cached from the - given user info. + given replay info. Parameters ---------- @@ -150,7 +150,7 @@ def check_cache(self, map_id, user_id, mods): The id of the map the replay was played on. user_id: int The id of the user that played the replay. - mods: :class:`circleguard.enums.ModCombination` + mods: :class:`~circleguard.enums.ModCombination` The mods this replay was played with. Returns diff --git a/circleguard/circleguard.py b/circleguard/circleguard.py index 73b8f459..a6f3ced7 100644 --- a/circleguard/circleguard.py +++ b/circleguard/circleguard.py @@ -29,13 +29,13 @@ class Circleguard: given path does not exist, a fresh database will be created there. If `None`, no replays will be cached or loaded from cache. slider_dir: str or :class:`os.PathLike` - The path to the directory used by :mod:`slider` to store beatmaps. - If `None`, a temporary directory will be created for :mod:`slider`, - and subdsequently destroyed when this :class:`~.circleguard` - object is garbage collected. - loader: :class:`~.Loader` - A :class:`~.Loader` class or subclass, which will be used in place of - instantiating a new :class:`~.Loader` if passed. This must be the + The path to the directory used by :class:`slider.library.Library` to + store beatmaps. If `None`, a temporary directory will be created for + :class:`slider.library.Library` and subsequently destroyed when this + :class:`~Circleguard` object is garbage collected. + loader: :class:`~circleguard.loader.Loader` + A loader class or subclass, which will be used in place of + instantiating a new loader if passed. This must be the class itself, *not* an instantiation of it. It will be instantiated upon circleguard instantiation, with two args - a key and a cacher. """ @@ -127,7 +127,7 @@ def load(self, loadable): Parameters ---------- - loadable: :class:`~.replay.Loadable` + loadable: :class:`~circleguard.loadable.Loadable` The loadable to load. Notes @@ -142,7 +142,7 @@ def load_info(self, info_loadable): Parameters ---------- - loadable: :class:`~.replay.InfoLoadable` + loadable: :class:`~circleguard.loadable.InfoLoadable` The info loadable to load. Notes diff --git a/circleguard/comparer.py b/circleguard/comparer.py index e9b00160..22826d2f 100644 --- a/circleguard/comparer.py +++ b/circleguard/comparer.py @@ -20,10 +20,10 @@ class Comparer: threshold: int If a comparison scores below this value, one of the :class:`~.replay.Replay` in the comparison is considered cheated. - replays1: list[:class:`~.replay.Replay`] + replays1: list[:class:`~circleguard.loadable.Replay`] The replays to compare against either ``replays2`` if ``replays`` is not ``None``, or against other replays in ``replays1``. - replays2: list[:class:`~.replay.Replay`] + replays2: list[:class:`~circleguard.loadable.Replay`] The replays to compare against ``replays1``. Notes @@ -38,14 +38,15 @@ class Comparer: See Also -------- - :class:`~investigator.Investigator`, for investigating single replays. + :class:`~circleguard.investigator.Investigator`, for investigating single + replays. """ def __init__(self, threshold, replays1, replays2=None): self.log = logging.getLogger(__name__) self.threshold = threshold - # filter beatmaps we had no data for - see Loader.replay_data and OnlineReplay.from_map + # filter beatmaps we had no data for self.replays1 = [replay for replay in replays1 if replay.replay_data is not None] self.replays2 = [replay for replay in replays2 if replay.replay_data is not None] if replays2 else None self.mode = "double" if self.replays2 else "single" diff --git a/circleguard/enums.py b/circleguard/enums.py index d3e68f5e..970add79 100644 --- a/circleguard/enums.py +++ b/circleguard/enums.py @@ -147,7 +147,7 @@ def __add__(self, other): return ModCombination(self.value | other.value) def __sub__(self, other): - return ModCombination(self.value ^ other.value) + return ModCombination(self.value & ~other.value) def __hash__(self): return self.value @@ -404,9 +404,13 @@ class ResultType(Enum): CORRECTION = "Aim Correction" TIMEWARP = "Timewarp" -class Keys(IntFlag): +class Key(IntFlag): M1 = 1 M2 = 2 K1 = 4 K2 = 8 SMOKE = 16 + +# TODO remove in 4.x +# @deprecated +Keys = Key diff --git a/circleguard/loadable.py b/circleguard/loadable.py index f1c2e056..ba9de917 100644 --- a/circleguard/loadable.py +++ b/circleguard/loadable.py @@ -74,10 +74,10 @@ class ReplayContainer(InfoLoadable): Holds a list of Replays, in addition to being a :class:`~Loadable`. ReplayContainer's start unloaded and become info loaded when - :meth:`~ReplayContainer.load_info` is called. They become fully - loaded when :meth:`~ReplayContainer.load` + :meth:`~InfoLoadable.load_info` is called. They become fully + loaded when :meth:`~Loadable.load` is called (and if this is called when the ReplayContainer is in the - unloaded state, :meth:`~ReplayContainer.load` will load info first, + unloaded state, :meth:`~Loadable.load` will load info first, then load the replays.) In the unloaded state, the container has no actual Replay objects. It may @@ -107,13 +107,13 @@ class Check(InfoLoadable): detect: :class:`~.Detect` What cheats to investigate for. loadables2: :class:`~.Loadable` - A second set of loadables. Only used when :class:`.~StealDetect` is + A second set of loadables. Only used when :class:`~StealDetect` is passed in ``detect``. If passed, the loadables in ``loadables`` will not be compared to each other, but instead to each replay in ``loadables2``, for replay stealing. cache: bool Whether to cache the loadables once they are loaded. This will be - overriden by a ``cache`` option set by a :class:`.~Loadable` in + overriden by a ``cache`` option set by a :class:`~Loadable` in ``loadables``. It only affects children loadables when they do not have a ``cache`` option set. """ @@ -133,13 +133,12 @@ def all_loadables(self): Returns ------- - list[:class:`~circleguard.loadable.Loadable`] + list[:class:`~Loadable`] All loadables in this class. See Also -------- - :func:`~circleguard.loadable.Loadable.all_replays` and - :func:`~circleguard.loadable.Loadable.all_replays2` + :func:`~Check.all_replays` and :func:`~Check.all_replays2`. Notes ----- @@ -163,8 +162,8 @@ def load(self, loader, cache=None): return cascade_cache = cache if self.cache is None else self.cache self.load_info(loader) - for replay in self.all_loadables(): - replay.load(loader, cascade_cache) + for loadable in self.all_loadables(): + loadable.load(loader, cascade_cache) self.loaded = True def load_info(self, loader): @@ -183,17 +182,18 @@ def num_replays(self): def all_replays(self): """ - Returns all the :class:`~.replay`\s in this check. Contrast with - :func:`~.all_loadables`, which returns all the :class:`.~Loadable`\s - in this check. + Returns all the :class:`~.Replay`\s in this check. Contrast with + :func:`~Check.all_loadables`, which returns all the + :class:`~.Loadable`\s in this check. Warnings -------- - If you want an accurate list of :class:`~.replay`\s in this check, you - must call :func:`.~circleguard.load` on this :class:`~.Check` before - :func:`~.all_replays`. :class:`~.InfoLoadable`\s contained in this - :class:`~.Check` may not be info loaded otherwise, and thus do not have - a complete list of the replays they represent. + If you want an accurate list of :class:`~.Replay`\s in this check, you + must call :func:`~circleguard.circleguard.Circleguard.load` on this + :class:`~Check` before :func:`~Check.all_replays`. + :class:`~.InfoLoadable`\s contained in this :class:`~Check` may not be + info loaded otherwise, and thus do not have a complete list of the + replays they represent. """ replays = [] for loadable in self.loadables: @@ -201,6 +201,10 @@ def all_replays(self): return replays def all_replays2(self): + """ + Returns all the :class:`~.Replay`\s contained by ``replays2`` of this + check. + """ replays2 = [] for loadable in self.loadables2: replays2 += loadable.all_replays() @@ -208,7 +212,8 @@ def all_replays2(self): def __add__(self, other): self.loadables.append(other) - return Check(self.loadables, self.loadables2, self.cache, self.detect) + # TODO why not just return ``self``? + return Check(self.loadables, self.detect, self.loadables2, self.cache) def __repr__(self): return (f"Check(loadables={self.loadables},loadables2={self.loadables2},cache={self.cache}," @@ -255,7 +260,7 @@ def load_info(self, loader): if self.info_loaded: return for info in loader.replay_info(self.map_id, num=self.num, mods=self.mods, span=self.span): - self.replays.append(ReplayMap(info.map_id, info.user_id, info.mods, cache=self.cache)) + self.replays.append(ReplayMap(info.map_id, info.user_id, info.mods, cache=self.cache, info=info)) self.info_loaded = True def load(self, loader, cache=None): @@ -278,14 +283,15 @@ def num_replays(self): def all_replays(self): """ - Returns all the :class:`~.replay`\s in this map. + Returns all the :class:`~.Replay`\s in this map. Warnings -------- - If you want an accurate list of :class:`~.replay`\s in this map, you - must call :func:`.~circleguard.load` on this map before - :func:`~.all_replays`. Otherwise, this class is not info loaded, and - does not have a complete list of replays it represents. + If you want an accurate list of :class:`~.Replay`\s in this map, you + must call :func:`~circleguard.circleguard.Circleguard.load` on this map + before :func:`~Map.all_replays`. Otherwise, this + class is not info loaded, and does not have a complete list of replays + it represents. """ return self.replays @@ -375,14 +381,15 @@ def num_replays(self): def all_replays(self): """ - Returns all the :class:`~.replay`\s in this map. + Returns all the :class:`~.Replay`\s in this user. Warnings -------- - If you want an accurate list of :class:`~.replay`\s in this user, you - must call :func:`.~circleguard.load` on this user before - :func:`~.all_replays`. Otherwise, this class is not info loaded, and - does not have a complete list of replays it represents. + If you want an accurate list of :class:`~.Replay`\s in this user, you + must call :func:`~circleguard.circleguard.Circleguard.load` on this + user before :func:`~User.all_replays`. Otherwise, this class is not + info loaded, and does not have a complete list of replays it + represents. """ replays = [] for loadable in self.replays: @@ -399,7 +406,7 @@ def __iter__(self): return iter(self.replays) -class MapUser(InfoLoadable): +class MapUser(ReplayContainer): """ All replays on a map by a user, not just the top replay. @@ -463,14 +470,14 @@ def num_replays(self): def all_replays(self): """ - Returns all the :class:`~.replay`\s in this MapUser. + Returns all the :class:`~.Replay`\s in this MapUser. Warnings -------- - If you want an accurate list of :class:`~.replay`\s in this MapUser, - you must call :func:`.~circleguard.load` on this MapUser before - :func:`~.all_replays`. Otherwise, this class is not info loaded, and - does not have a complete list of replays it represents. + If you want an accurate list of :class:`~.Replay`\s in this MapUser, + you must call :func:`~circleguard.circleguard.Circleguard.load` on this + MapUser before :func:`~.all_replays`. Otherwise, this class is not info + loaded, and does not have a complete list of replays it represents. """ replays = [] for loadable in self.replays: @@ -548,7 +555,7 @@ def as_list_with_timestamps(self): Returns ------- - list[tuple(int, float, float, something)] + list[tuple(int, float, float, int)] A list of tuples of (t, x, y, keys) for each event in the replay data. """ @@ -576,7 +583,7 @@ class ReplayMap(Replay): user_id: int The id of the player who played the replay. mods: ModCombination - The mods the replay was played with. If `None`, the + The mods the replay was played with. If ``None``, the highest scoring replay of ``user_id`` on ``map_id`` will be loaded, regardless of mod combination. Otherwise, the replay with ``mods`` will be loaded. @@ -656,6 +663,7 @@ class ReplayPath(Replay): def __init__(self, path, cache=None): self.log = logging.getLogger(__name__ + ".ReplayPath") self.path = path + self.hash = None self.cache = cache self.weight = RatelimitWeight.LIGHT self.loaded = False @@ -701,6 +709,7 @@ def load(self, loader, cache): loaded = circleparse.parse_replay_file(self.path) map_id = loader.map_id(loaded.beatmap_hash) user_id = loader.user_id(loaded.player_name) + self.hash = loaded.beatmap_hash Replay.__init__(self, loaded.timestamp, map_id, loaded.player_name, user_id, ModCombination(loaded.mod_combination), loaded.replay_id, loaded.play_data, self.weight) diff --git a/circleguard/loader.py b/circleguard/loader.py index 8d6513a9..f94e4be0 100644 --- a/circleguard/loader.py +++ b/circleguard/loader.py @@ -17,6 +17,7 @@ InvalidJSONException, NoInfoAvailableException) from circleguard.utils import TRACE, span_to_list + def request(function): """ A decorator that handles :mod:`requests` and api related exceptions, as @@ -54,8 +55,6 @@ def wrapper(*args, **kwargs): self.log.warning("Invalid json exception: {}. API likely having issues; sleeping for 3 seconds then retrying".format(e)) time.sleep(3) ret = request(function)(*args, **kwargs) - except InvalidKeyException as e: - raise CircleguardException("The given key is invalid") except RequestException as e: self.log.warning("Request exception: {}. Likely a network issue; sleeping for 5 seconds then retrying".format(e)) time.sleep(5) @@ -106,6 +105,7 @@ def wrapper(*args, **kwargs): return function(*args, **kwargs) return wrapper + class Loader(): """ Manages interactions with the osu api, using the :mod:`ossapi` wrapper. @@ -141,25 +141,25 @@ def __init__(self, key, cacher=None): @request def replay_info(self, map_id, num=None, user_id=None, mods=None, limit=True, span=None): """ - Retrieves user infos from ``map_id``. + Retrieves replay infos from ``map_id``. Parameters ---------- map_id: int - The map id to retrieve user info for. + The map id to retrieve replay info for. num: int - The number of user infos on the map to retrieve. + The number of replay infos on the map to retrieve. user_id: int - If passed, only retrieve user info on ``map_id`` for this user. + If passed, only retrieve replay info on ``map_id`` for this user. Note that this is not necessarily limited to just the user's top score on the map. See ``limit``. mods: :class:`~.ModCombination` - The mods to limit user infos to. ie only return user infos with + The mods to limit replay infos to. ie only return replay infos with mods that match ``mods``. limit: bool Whether to limit to only one response. Only has an effect if ``user_id`` is passed. If ``limit`` is ``True``, will only return - the top scoring user info by ``user_id``. If ``False``, will return + the top scoring replay info by ``user_id``. If ``False``, will return all scores by ``user_id``. span: str A comma separated list of ranges of top replays on the map to @@ -169,7 +169,7 @@ def replay_info(self, map_id, num=None, user_id=None, mods=None, limit=True, spa Returns ------- list[:class:`~.ReplayInfo`] - The user infos as specified by the arguments. + The replay infos as specified by the arguments. :class:`~.ReplayInfo` If ``limit`` is ``True`` and ``user_id`` is passed. @@ -190,7 +190,7 @@ def replay_info(self, map_id, num=None, user_id=None, mods=None, limit=True, spa # I spent many-a-hour figuring this out, # and if anyone has a more elegant solution I'm all ears. locals_ = locals() - self.log.log(TRACE, "Loading user info on map %d with options %s", + self.log.log(TRACE, "Loading replay info on map %d with options %s", map_id, {k: locals_[k] for k in locals_ if k != 'self'}) if num and (num > 100 or num < 1): @@ -234,9 +234,10 @@ def get_user_best(self, user_id, num=None, span=None, mods=None): A comma separated list of ranges of top plays to retrieve. ``span="1-3,6,2-4"`` -> replays in the range ``[1,2,3,4,6]``. - Returns: - A list of Integer map_ids for the given number of the user's top - plays. + Returns + ------- + list[int] + A list of map_ids for the given number of the user's top plays. Raises ------ @@ -300,7 +301,6 @@ def load_replay_data(self, map_id, user_id, mods=None): self.log.log(TRACE, "Requesting replay data by user %d on map %d with mods %s", user_id, map_id, mods) response = self.api.get_replay({"m": "0", "b": map_id, "u": user_id, "mods": mods if mods is None else mods.value}) Loader.check_response(response) - return base64.b64decode(response["content"]) @check_cache @@ -312,7 +312,7 @@ def replay_data(self, replay_info, cache=None): Parameters ---------- replay_info: :class:`~.ReplayInfo` - The user info representing the replay to retrieve. + The replay info representing the replay to retrieve. Returns ------- @@ -353,6 +353,7 @@ def replay_data(self, replay_info, cache=None): return replay_data @lru_cache() + @request def map_id(self, map_hash): """ Retrieves a map id from a corresponding map hash through the api. @@ -375,12 +376,14 @@ def map_id(self, map_hash): """ response = self.api.get_beatmaps({"h": map_hash}) - if response == []: + try: + Loader.check_response(response) + except NoInfoAvailableException: return 0 - else: - return int(response[0]["beatmap_id"]) + return int(response[0]["beatmap_id"]) @lru_cache() + @request def user_id(self, username): """ Retrieves a user id from a corresponding username through the api. @@ -411,12 +414,14 @@ def user_id(self, username): """ response = self.api.get_user({"u": username, "type": "string"}) - if response == []: + try: + Loader.check_response(response) + except NoInfoAvailableException: return 0 - else: - return int(response[0]["user_id"]) + return int(response[0]["user_id"]) @lru_cache() + @request def username(self, user_id): """ Retrieves the username from a corresponding user id through the api. @@ -441,10 +446,11 @@ def username(self, user_id): duplicate api calls. """ response = self.api.get_user({"u": user_id, "type": "id"}) - if response == []: + try: + Loader.check_response(response) + except NoInfoAvailableException: return "" - else: - return response[0]["username"] + return response[0]["username"] @staticmethod def check_response(response): @@ -473,8 +479,6 @@ def check_response(response): if not response: # response is empty, list or dict case raise NoInfoAvailableException("No info was available from the api for the given arguments.") - - def _enforce_ratelimit(self): """ Sleeps the thread until we have refreshed our ratelimits. diff --git a/circleguard/replay_info.py b/circleguard/replay_info.py index 7065c8a8..11f18ba2 100644 --- a/circleguard/replay_info.py +++ b/circleguard/replay_info.py @@ -8,7 +8,7 @@ class ReplayInfo(): Parameters ---------- - timestamp: :class:`~datetime.datetime` + timestamp: :class:`datetime.datetime` When this replay was set. map_id: int The id of the map the replay was played on. @@ -16,7 +16,7 @@ class ReplayInfo(): The id of the player who played the replay. username: str The username of the player who played the replay. - replay_id: str + replay_id: int The id of the replay. mods: :class:`~circleguard.enums.ModCombination` The mods the replay was played with. diff --git a/circleguard/result.py b/circleguard/result.py index c1ff002a..1fcd9762 100644 --- a/circleguard/result.py +++ b/circleguard/result.py @@ -119,7 +119,7 @@ class CorrectionResult(InvestigationResult): ---------- replay: :class:`~circleguard.loadable.Replay` The replay investigated. - snaps: list[:class:`.~Snap`] + snaps: list[:class:`~circleguard.investigator.Snap`] A list of suspicious hits in the replay. ischeat: bool Whether the replay is cheated or not. diff --git a/circleguard/version.py b/circleguard/version.py index 528787cf..f5f41e56 100644 --- a/circleguard/version.py +++ b/circleguard/version.py @@ -1 +1 @@ -__version__ = "3.0.0" +__version__ = "3.1.0" diff --git a/setup.py b/setup.py index e6d8d6bb..3240b61b 100644 --- a/setup.py +++ b/setup.py @@ -20,8 +20,9 @@ name="circleguard", version=VERSION, description="A player made and maintained cheat detection tool for osu!. " - "Provides support for detecting replay stealing and remodding " - "from a profile, map, or set of osr files.", + "Provides support for detecting replay stealing, remodding, " + "relax, and aim correction from a profile, map, or set of osr " + "files.", long_description=long_description, long_description_content_type="text/markdown", classifiers=[ diff --git a/source/appendix.rst b/source/appendix.rst index 1a6a856b..a68a9666 100644 --- a/source/appendix.rst +++ b/source/appendix.rst @@ -44,7 +44,7 @@ Loader :members: Loadable ------- +-------- .. automodule:: circleguard.loadable :members: @@ -54,7 +54,7 @@ Result :members: ReplayInfo --------- +---------- .. automodule:: circleguard.replay_info :members: diff --git a/source/caching.rst b/source/caching.rst index d413e781..eb48ee21 100644 --- a/source/caching.rst +++ b/source/caching.rst @@ -91,10 +91,10 @@ For a |ReplayContainer|, ``cache`` cascades to its |Replay|\s. cg = Circleguard("key", db_path="./db.db") m = Map(221777, num=2, cache=False) - cg.load(m) # both replays in Map cached + cg.load(m) # neither replay in Map cached |Check| can also get passed ``cache``. If it contains a |ReplayContainer|, -the cache set by |ReplayContainer| takes precedence: +the cache set by the |ReplayContainer| takes precedence: .. code-block:: python diff --git a/source/conf.py b/source/conf.py index 45edf9de..b3d6c6ac 100644 --- a/source/conf.py +++ b/source/conf.py @@ -4,13 +4,23 @@ # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html +# This will fail if circlecore's dependencies aren't installed. +# Which shouldn't be an issue because the only people running ``make html`` +# (building the docs) are people with circlecore properly installed, hopefully. +from circleguard import __version__ + project = "Circleguard" copyright = "2019, Liam DeVoe, samuelhklumpers, InvisibleSymbol" author = "Liam DeVoe, samuelhklumpers, InvisibleSymbol" -release = "2.4.0" -version = "2.4.0" +release = "v" + __version__ +version = "v" + __version__ master_doc = 'index' +# https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_show_copyright +html_show_copyright = False +# https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_show_sphinx +html_show_sphinx = False + extensions = [ "sphinx.ext.autodoc", "sphinx.ext.napoleon", diff --git a/source/creating-checks.rst b/source/creating-checks.rst index 83c3ea81..8092d303 100644 --- a/source/creating-checks.rst +++ b/source/creating-checks.rst @@ -32,8 +32,9 @@ Detect ~~~~~~ |Detect| lets you control exactly what circlecore is investigating the -|Loadable|\s in a |Check| for. You may only care about finding relax cheaters. -Additionally, some cheats are quicker to investigate for than others. +|Loadable|\s in a |Check| for. You may only care about finding relax cheaters, +for instance. Additionally, some cheats are quicker to investigate for than +others. Each |Detect| subclass (|RelaxDetect|, |StealDetect|) corresponds to a cheat. Each of these classes can be passed values on instantiation to be more or diff --git a/source/loading.rst b/source/loading.rst index f685c9c5..4f7498ac 100644 --- a/source/loading.rst +++ b/source/loading.rst @@ -35,18 +35,17 @@ before you call |cg.run|. .. code-block:: python cg = Circleguard("key") - m = Map(221777, num=2) - print(m.loaded) + m = Map(221777, num=1) + print(m.info_loaded, m.loaded) + cg.load_info(m) + print(m.info_loaded, m.loaded) cg.load(m) - print(m.loaded) + print(m.info_loaded, m.loaded) Stages ------ -This is not an idea truly formalized in the codebase (yet), but is a useful -crutch of an explanation. - Different |Loadable|\s have different stages, where varying amounts of information is available to you. Each stage requires loading more information from the api, which can be an expensive operation. This is why we defer loading @@ -78,8 +77,7 @@ issue. link to advanced section where we do talk about ratelimitweight/etc - have replay data be empty by default, maybe truly formalize the unloaded - /loaded relationship as well + have replay data be empty by default Replay Containers ~~~~~~~~~~~~~~~~~ diff --git a/source/representing-replays.rst b/source/representing-replays.rst index 871231a0..fe65fb1e 100644 --- a/source/representing-replays.rst +++ b/source/representing-replays.rst @@ -5,7 +5,7 @@ Circlecore needs a replay to investigate before it can tell you anything about if that replay is cheated. There are several ways to create a replay through circlecore. -All four of the following classes are subclasses of |Loadable|. +All of the following classes are subclasses of |Loadable|. .. note:: diff --git a/tests/test.py b/tests/test.py index 435c212b..728f97f9 100644 --- a/tests/test.py +++ b/tests/test.py @@ -3,10 +3,10 @@ from pathlib import Path import warnings from circleguard import (Circleguard, Check, ReplayMap, ReplayPath, RelaxDetect, StealDetect, - RatelimitWeight, set_options, Map, User, MapUser, Mod) + RatelimitWeight, set_options, Map, User, MapUser, Mod, Loader, InvalidKeyException) KEY = os.environ.get('OSU_API_KEY') -if KEY is None: +if not KEY: KEY = input("Enter your api key: ") RES = Path(__file__).parent / "resources" @@ -18,6 +18,7 @@ # light can run locally, heavy can run on prs. HEAVY_CALL_COUNT = 9 + class CGTestCase(TestCase): @classmethod def setUpClass(cls): @@ -34,8 +35,8 @@ def setUp(self): message="unclosed", category=ResourceWarning) -class TestReplays(CGTestCase): +class TestReplays(CGTestCase): def test_cheated_replaypath(self): # taken from http://redd.it/bvfv8j, remodded replay by same user (CielXDLP) from HDHR to FLHDHR replays = [ReplayPath(RES / "stolen_replay1.osr"), ReplayPath(RES / "stolen_replay2.osr")] @@ -107,7 +108,6 @@ def test_loading_replaymap(self): self.assertTrue(r.loaded, "Loaded status was not correct") - class TestMap(CGTestCase): @classmethod def setUpClass(cls): @@ -130,7 +130,7 @@ def test_map_load(self): self.assertTrue(self.map.loaded) self.assertTrue(self.map.info_loaded) - def test_user_slice(self): + def test_map_slice(self): # sanity check (map id better be what we put in) self.assertEqual(self.map[0].map_id, 221777) # 2nd (rohulk) @@ -172,6 +172,7 @@ def test_user_slice(self): # 1st and 3rd (FDFD and Remote Control) self.assertListEqual([r.map_id for r in self.user[0:3:2]], [129891, 774965]) + class TestMapUser(CGTestCase): @classmethod def setUpClass(cls): @@ -203,6 +204,43 @@ def test_map_user_slice(self): # test slicing self.assertListEqual([r.map_id for r in self.mu[0:2]], [795627, 795627]) + +class TestLoader(CGTestCase): + @classmethod + def setUpClass(cls): + cls.loader = Loader(KEY) + + def test_loading_map_id(self): + result = self.loader.map_id("E") + self.assertEqual(result, 0) + + result = self.loader.map_id("9d0a8fec2fe3f778334df6bdc60b113c") + self.assertEqual(result, 221777) + + def test_loading_user_id(self): + result = self.loader.user_id("E") + self.assertEqual(result, 0) + + result = self.loader.user_id("] [") + self.assertEqual(result, 13506780) + + result = self.loader.user_id("727") + self.assertEqual(result, 10750899) + + def test_loading_username(self): + result = self.loader.username(0) + self.assertEqual(result, "") + + result = self.loader.username(13506780) + self.assertEqual(result, "] [") + + def test_incorrect_key(self): + loader = Loader("incorrect key") + self.assertRaises(InvalidKeyException, loader.username, 13506780) + self.assertRaises(InvalidKeyException, loader.user_id, "] [") + self.assertRaises(InvalidKeyException, loader.map_id, "9d0a8fec2fe3f778334df6bdc60b113c") + + if __name__ == '__main__': suite = TestSuite() suite.addTest(TestMap("test_map_with_replays"))