From e8ef759a6b7809aa04f2d1884eea4167828bc79e Mon Sep 17 00:00:00 2001 From: snipe <72265661+notsniped@users.noreply.github.com> Date: Tue, 2 May 2023 11:02:50 +0530 Subject: [PATCH 1/5] Port osu! commands from isobot v1 to a cog --- cogs/osu.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 cogs/osu.py diff --git a/cogs/osu.py b/cogs/osu.py new file mode 100644 index 00000000..a5e6b490 --- /dev/null +++ b/cogs/osu.py @@ -0,0 +1,60 @@ +"""The isobot cog file for osu! commands.""" + +# Imports +import discord +from ossapi import * +from discord import option, ApplicationContext +from discord.ext import commands + +# Commands +class Osu(commands.Cog): + def __init__(self, bot): + self.bot = bot + api = OssapiV2(13110, 'UDGR1XA2e406y163lRzzJgs4tQCvu94ehbkXU8w2') + + @commands.slash_command( + name="osu_user", + description="View information on an osu! player." + ) + @option(name="user", description="The name of the user", type=str) + async def osu_user(ctx, *, user:str): + try: + compact_user = api.search(query=user).users.data[0] + e = discord.Embed(title=f'osu! stats for {user}', color=0xff66aa) + e.set_thumbnail(url='https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Osu%21_Logo_2016.svg/2048px-Osu%21_Logo_2016.svg.png') + e.add_field(name='Rank (Global)', value=f'#{compact_user.expand().statistics.global_rank}') + e.add_field(name='Rank (Country)', value=f'#{compact_user.expand().statistics.country_rank}') + e.add_field(name='Ranked Score', value=f'{compact_user.expand().statistics.ranked_score}') + e.add_field(name='Level', value=f'Level {compact_user.expand().statistics.level.current} ({compact_user.expand().statistics.level.progress}% progress)') + e.add_field(name='pp', value=round(compact_user.expand().statistics.pp)) + e.add_field(name='Max Combo', value=f'{compact_user.expand().statistics.maximum_combo}x') + e.add_field(name='Total Hits', value=f'{compact_user.expand().statistics.total_hits}') + e.add_field(name='Play Count', value=compact_user.expand().statistics.play_count) + e.add_field(name='Accuracy', value=f'{round(compact_user.expand().statistics.hit_accuracy, 2)}%') + e.add_field(name='Replays Watched by Others', value=f'{compact_user.expand().statistics.replays_watched_by_others}') + await ctx.respond(embed=e) + except: await ctx.respond(f':warning: {user} was not found in osu!.', ephemeral=True) + + @commands.slash_command( + name="osu_beatmap", + description="View information on an osu! beatmap." + ) + @option(name="query", description="The beatmap's id", type=int) + async def osu_beatmap(ctx, *, query:int): + try: + beatmap = api.beatmap(beatmap_id=query) + e = discord.Embed(title=f'osu! beatmap info for {beatmap.expand()._beatmapset.title} ({beatmap.expand()._beatmapset.title_unicode})', color=0xff66aa) + e.set_thumbnail(url='https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Osu%21_Logo_2016.svg/2048px-Osu%21_Logo_2016.svg.png') + #.beatmap.data[0] + e.add_field(name='Artist', value=f'{beatmap.expand()._beatmapset.artist} ({beatmap.expand()._beatmapset.artist_unicode})') + e.add_field(name='Mapper', value=beatmap.expand()._beatmapset.creator) + e.add_field(name='Difficulty', value=f'{beatmap.expand().difficulty_rating} stars') + e.add_field(name='BPM', value=beatmap.expand().bpm) + e.add_field(name='Circles', value=beatmap.expand().count_circles) + e.add_field(name='Sliders', value=beatmap.expand().count_sliders) + e.add_field(name='HP Drain', value=beatmap.expand().drain) + await ctx.respond(embed=e) + except Exception as f: await ctx.respond(f"An error occured when trying to execute this command.\n```{f.__type__()}: {f}```", ephemeral=True) + +# Cog Initialization +def setup(bot): bot.add_cog(Osu(bot)) From 4004a3bbd1822576f63913db4631ab2bfdf3c8b6 Mon Sep 17 00:00:00 2001 From: snipe <72265661+notsniped@users.noreply.github.com> Date: Tue, 2 May 2023 11:08:23 +0530 Subject: [PATCH 2/5] Add ossapi library to project folder and add ossapi LICENSE --- oss-licenses/ossapi_LICENSE | 674 ++++++++++++++++++ ossapi/__init__.py | 57 ++ ossapi/encoder.py | 32 + ossapi/enums.py | 538 ++++++++++++++ ossapi/mod.py | 329 +++++++++ ossapi/models.py | 1048 ++++++++++++++++++++++++++++ ossapi/ossapi.py | 404 +++++++++++ ossapi/ossapiv2.py | 1317 +++++++++++++++++++++++++++++++++++ ossapi/replay.py | 78 +++ ossapi/utils.py | 215 ++++++ ossapi/version.py | 1 + 11 files changed, 4693 insertions(+) create mode 100644 oss-licenses/ossapi_LICENSE create mode 100644 ossapi/__init__.py create mode 100644 ossapi/encoder.py create mode 100644 ossapi/enums.py create mode 100644 ossapi/mod.py create mode 100644 ossapi/models.py create mode 100644 ossapi/ossapi.py create mode 100644 ossapi/ossapiv2.py create mode 100644 ossapi/replay.py create mode 100644 ossapi/utils.py create mode 100644 ossapi/version.py diff --git a/oss-licenses/ossapi_LICENSE b/oss-licenses/ossapi_LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/oss-licenses/ossapi_LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/ossapi/__init__.py b/ossapi/__init__.py new file mode 100644 index 00000000..5b12481c --- /dev/null +++ b/ossapi/__init__.py @@ -0,0 +1,57 @@ +import logging +# we need to explicitly set a handler for the logging module to be happy +handler = logging.StreamHandler() +logging.getLogger("ossapi").addHandler(handler) + +from ossapi.ossapi import (Ossapi, ReplayUnavailableException, + InvalidKeyException, APIException) +from ossapi.ossapiv2 import OssapiV2, Grant, Scope +from ossapi.models import (Beatmap, BeatmapCompact, BeatmapUserScore, + ForumTopicAndPosts, Search, CommentBundle, Cursor, Score, + BeatmapsetSearchResult, ModdingHistoryEventsBundle, User, Rankings, + BeatmapScores, KudosuHistory, Beatmapset, BeatmapPlaycount, Spotlight, + Spotlights, WikiPage, _Event, Event, BeatmapsetDiscussionPosts, Build, + ChangelogListing, MultiplayerScores, MultiplayerScoresCursor, + BeatmapsetDiscussionVotes, CreatePMResponse, BeatmapsetDiscussions, + UserCompact, BeatmapsetCompact) +from ossapi.enums import (GameMode, ScoreType, RankingFilter, RankingType, + UserBeatmapType, BeatmapDiscussionPostSort, UserLookupKey, + BeatmapsetEventType, CommentableType, CommentSort, ForumTopicSort, + SearchMode, MultiplayerScoresSort, BeatmapsetDiscussionVote, + BeatmapsetDiscussionVoteSort, BeatmapsetStatus, MessageType) +from ossapi.mod import Mod +from ossapi.replay import Replay +from ossapi.version import __version__ +from ossapi.encoder import ModelEncoder, serialize_model + +from oauthlib.oauth2 import AccessDeniedError, TokenExpiredError +from oauthlib.oauth2.rfc6749.errors import InsufficientScopeError + + +__all__ = [ + # OssapiV1 + "Ossapi", "ReplayUnavailableException", "InvalidKeyException", + "APIException", + # OssapiV2 core + "OssapiV2", "Grant", "Scope", + # OssapiV2 models + "Beatmap", "BeatmapCompact", "BeatmapUserScore", "ForumTopicAndPosts", + "Search", "CommentBundle", "Cursor", "Score", "BeatmapsetSearchResult", + "ModdingHistoryEventsBundle", "User", "Rankings", "BeatmapScores", + "KudosuHistory", "Beatmapset", "BeatmapPlaycount", "Spotlight", + "Spotlights", "WikiPage", "_Event", "Event", "BeatmapsetDiscussionPosts", + "Build", "ChangelogListing", "MultiplayerScores", "MultiplayerScoresCursor", + "BeatmapsetDiscussionVotes", "CreatePMResponse", + "BeatmapsetDiscussions", "UserCompact", "BeatmapsetCompact", + # OssapiV2 enums + "GameMode", "ScoreType", "RankingFilter", "RankingType", + "UserBeatmapType", "BeatmapDiscussionPostSort", "UserLookupKey", + "BeatmapsetEventType", "CommentableType", "CommentSort", "ForumTopicSort", + "SearchMode", "MultiplayerScoresSort", "BeatmapsetDiscussionVote", + "BeatmapsetDiscussionVoteSort", "BeatmapsetStatus", "MessageType", + # OssapiV2 exceptions + "AccessDeniedError", "TokenExpiredError", "InsufficientScopeError", + # misc + "Mod", "Replay", "__version__", "ModelEncoder", + "serialize_model" +] diff --git a/ossapi/encoder.py b/ossapi/encoder.py new file mode 100644 index 00000000..0327c4a4 --- /dev/null +++ b/ossapi/encoder.py @@ -0,0 +1,32 @@ +import json +from json import JSONEncoder +from datetime import datetime +from enum import Enum + +from ossapi.models import Model +from ossapi.mod import Mod + +class ModelEncoder(JSONEncoder): + def default(self, o): + if isinstance(o, datetime): + return 1000 * int(o.timestamp()) + if isinstance(o, Enum): + return o.value + if isinstance(o, Mod): + return o.value + + to_serialize = {} + if isinstance(o, Model): + for name, value in o.__dict__.items(): + # don't seriailize private attributes, like ``_api``. + if name.startswith("_"): + continue + to_serialize[name] = value + return to_serialize + + return super().default(o) + + +def serialize_model(model, ensure_ascii=False, **kwargs): + return json.dumps(model, cls=ModelEncoder, ensure_ascii=ensure_ascii, + **kwargs) diff --git a/ossapi/enums.py b/ossapi/enums.py new file mode 100644 index 00000000..0fe866f0 --- /dev/null +++ b/ossapi/enums.py @@ -0,0 +1,538 @@ +from typing import Optional, List, Any + +from ossapi.utils import (EnumModel, Datetime, Model, Field, IntFlagModel) + +# ================ +# Documented Enums +# ================ + +class ProfilePage(EnumModel): + ME = "me" + RECENT_ACTIVITY = "recent_activity" + BEATMAPS = "beatmaps" + HISTORICAL = "historical" + KUDOSU = "kudosu" + TOP_RANKS = "top_ranks" + MEDALS = "medals" + +class GameMode(EnumModel): + STD = "osu" + TAIKO = "taiko" + CTB = "fruits" + MANIA = "mania" + +class PlayStyles(IntFlagModel): + MOUSE = 1 + KEYBOARD = 2 + TABLET = 4 + TOUCH = 8 + + @classmethod + def _missing_(cls, value): + """ + Allow instantiation via either strings or lists of ints / strings. The + api returns a list of strings for User.playstyle. + """ + if isinstance(value, list): + value = iter(value) + new_val = cls(next(value)) + for val in value: + new_val |= cls(val) + return new_val + + if value == "mouse": + return PlayStyles.MOUSE + if value == "keyboard": + return PlayStyles.KEYBOARD + if value == "tablet": + return PlayStyles.TABLET + if value == "touch": + return PlayStyles.TOUCH + return super()._missing_(value) + +class RankStatus(EnumModel): + GRAVEYARD = -2 + WIP = -1 + PENDING = 0 + RANKED = 1 + APPROVED = 2 + QUALIFIED = 3 + LOVED = 4 + + @classmethod + def _missing_(cls, value): + """ + The api can return ``RankStatus`` values as either an int or a string, + so if we try to instantiate with a string, return the corresponding + enum attribute. + """ + if value == "graveyard": + return cls(-2) + if value == "wip": + return cls(-1) + if value == "pending": + return cls(0) + if value == "ranked": + return cls(1) + if value == "approved": + return cls(2) + if value == "qualified": + return cls(3) + if value == "loved": + return cls(4) + return super()._missing_(value) + +class UserAccountHistoryType(EnumModel): + NOTE = "note" + RESTRICTION = "restriction" + SILENCE = "silence" + +class MessageType(EnumModel): + DISQUALIFY = "disqualify" + HYPE = "hype" + MAPPER_NOTE = "mapper_note" + NOMINATION_RESET = "nomination_reset" + PRAISE = "praise" + PROBLEM = "problem" + REVIEW = "review" + SUGGESTION = "suggestion" + +class BeatmapsetEventType(EnumModel): + APPROVE = "approve" + BEATMAP_OWNER_CHANGE = "beatmap_owner_change" + DISCUSSION_DELETE = "discussion_delete" + DISCUSSION_LOCK = "discussion_lock" + DISCUSSION_POST_DELETE = "discussion_post_delete" + DISCUSSION_POST_RESTORE = "discussion_post_restore" + DISCUSSION_RESTORE = "discussion_restore" + DISCUSSION_UNLOCK = "discussion_unlock" + DISQUALIFY = "disqualify" + DISQUALIFY_LEGACY = "disqualify_legacy" + GENRE_EDIT = "genre_edit" + ISSUE_REOPEN = "issue_reopen" + ISSUE_RESOLVE = "issue_resolve" + KUDOSU_ALLOW = "kudosu_allow" + KUDOSU_DENY = "kudosu_deny" + KUDOSU_GAIN = "kudosu_gain" + KUDOSU_LOST = "kudosu_lost" + KUDOSU_RECALCULATE = "kudosu_recalculate" + LANGUAGE_EDIT = "language_edit" + LOVE = "love" + NOMINATE = "nominate" + NOMINATE_MODES = "nominate_modes" + NOMINATION_RESET = "nomination_reset" + NOMINATION_RESET_RECEIVED = "nomination_reset_received" + QUALIFY = "qualify" + RANK = "rank" + REMOVE_FROM_LOVED = "remove_from_loved" + NSFW_TOGGLE = "nsfw_toggle" + +class BeatmapsetDownload(EnumModel): + ALL = "all" + NO_VIDEO = "no_video" + DIRECT = "direct" + +class UserListFilters(EnumModel): + ALL = "all" + ONLINE = "online" + OFFLINE = "offline" + +class UserListSorts(EnumModel): + LAST_VISIT = "last_visit" + RANK = "rank" + USERNAME = "username" + +class UserListViews(EnumModel): + CARD = "card" + LIST = "list" + BRICK = "brick" + +class KudosuAction(EnumModel): + GIVE = "vote.give" + RESET = "vote.reset" + REVOKE = "vote.revoke" + +class EventType(EnumModel): + ACHIEVEMENT = "achievement" + BEATMAP_PLAYCOUNT = "beatmapPlaycount" + BEATMAPSET_APPROVE = "beatmapsetApprove" + BEATMAPSET_DELETE = "beatmapsetDelete" + BEATMAPSET_REVIVE = "beatmapsetRevive" + BEATMAPSET_UPDATE = "beatmapsetUpdate" + BEATMAPSET_UPLOAD = "beatmapsetUpload" + RANK = "rank" + RANK_LOST = "rankLost" + USER_SUPPORT_FIRST = "userSupportFirst" + USER_SUPPORT_AGAIN = "userSupportAgain" + USER_SUPPORT_GIFT = "userSupportGift" + USERNAME_CHANGE = "usernameChange" + +# TODO this is just a subset of ``RankStatus``, and is only (currently) used for +# ``EventType.BEATMAPSET_APPROVE``. Find some way to de-duplicate? Could move to +# ``RankStatus``, but then how to enforce taking on only a subset of values? +class BeatmapsetApproval(EnumModel): + RANKED = "ranked" + APPROVED = "approved" + QUALIFIED = "qualified" + LOVED = "loved" + +class ForumTopicType(EnumModel): + NORMAL = "normal" + STICKY = "sticky" + ANNOUNCEMENT = "announcement" + + +# ================== +# Undocumented Enums +# ================== + +class UserRelationType(EnumModel): + # undocumented + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserRelationTransformer.php#L20 + FRIEND = "friend" + BLOCK = "block" + +class Grade(EnumModel): + SSH = "XH" + SS = "X" + SH = "SH" + S = "S" + A = "A" + B = "B" + C = "C" + D = "D" + F = "F" + + +# =============== +# Parameter Enums +# =============== + +class ScoreType(EnumModel): + BEST = "best" + FIRSTS = "firsts" + RECENT = "recent" + +class RankingFilter(EnumModel): + ALL = "all" + FRIENDS = "friends" + +class RankingType(EnumModel): + CHARTS = "spotlight" + COUNTRY = "country" + PERFORMANCE = "performance" + SCORE = "score" + +class UserLookupKey(EnumModel): + ID = "id" + USERNAME = "username" + +class UserBeatmapType(EnumModel): + FAVOURITE = "favourite" + GRAVEYARD = "graveyard" + LOVED = "loved" + MOST_PLAYED = "most_played" + RANKED = "ranked" + PENDING = "pending" + +class BeatmapDiscussionPostSort(EnumModel): + NEW = "id_desc" + OLD = "id_asc" + +class BeatmapsetStatus(EnumModel): + ALL = "all" + RANKED = "ranked" + QUALIFIED = "qualified" + DISQUALIFIED = "disqualified" + NEVER_QUALIFIED = "never_qualified" + +class ChannelType(EnumModel): + PUBLIC = "PUBLIC" + PRIVATE = "PRIVATE" + MULTIPLAYER = "MULTIPLAYER" + SPECTATOR = "SPECTATOR" + TEMPORARY = "TEMPORARY" + PM = "PM" + GROUP = "GROUP" + +class CommentableType(EnumModel): + NEWS_POST = "news_post" + CHANGELOG = "build" + BEATMAPSET = "beatmapset" + +class CommentSort(EnumModel): + NEW = "new" + OLD = "old" + TOP = "top" + +class ForumTopicSort(EnumModel): + NEW = "id_desc" + OLD = "id_asc" + +class SearchMode(EnumModel): + ALL = "all" + USERS = "user" + WIKI = "wiki_page" + +class MultiplayerScoresSort(EnumModel): + NEW = "score_desc" + OLD = "score_asc" + +class BeatmapsetDiscussionVote(EnumModel): + UPVOTE = 1 + DOWNVOTE = -1 + +class BeatmapsetDiscussionVoteSort(EnumModel): + NEW = "id_desc" + OLD = "id_asc" + +# ================= +# Documented Models +# ================= + +class Failtimes(Model): + exit: Optional[List[int]] + fail: Optional[List[int]] + +class Ranking(Model): + # https://github.com/ppy/osu-web/blob/master/app/Transformers/CountryTransformer.php#L30 + active_users: int + play_count: int + ranked_score: int + performance: int + +class Country(Model): + # https://github.com/ppy/osu-web/blob/master/app/Transformers/CountryTransformer.php#L10 + code: str + name: str + + # optional fields + # --------------- + display: Optional[int] + ranking: Optional[Ranking] + +class Cover(Model): + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserCompactTransformer.php#L158 + custom_url: Optional[str] + url: str + # api should really return an int here instead...open an issue? + id: Optional[str] + + +class ProfileBanner(Model): + id: int + tournament_id: int + image: str + +class UserAccountHistory(Model): + description: Optional[str] + type: UserAccountHistoryType + timestamp: Datetime + length: int + + +class UserBadge(Model): + awarded_at: Datetime + description: str + image_url: str + url: str + +class GroupDescription(Model): + html: str + markdown: str + +class UserGroup(Model): + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserGroupTransformer.php#L10 + id: int + identifier: str + name: str + short_name: str + colour: Optional[str] + description: Optional[GroupDescription] + playmodes: Optional[List[GameMode]] + is_probationary: bool + has_listing: bool + has_playmodes: bool + +class Covers(Model): + """ + https://osu.ppy.sh/docs/index.html#beatmapsetcompact-covers + """ + cover: str + cover_2x: str = Field(name="cover@2x") + card: str + card_2x: str = Field(name="card@2x") + list: str + list_2x: str = Field(name="list@2x") + slimcover: str + slimcover_2x: str = Field(name="slimcover@2x") + +class Statistics(Model): + count_50: int + count_100: int + count_300: int + count_geki: int + count_katu: int + count_miss: int + +class Availability(Model): + download_disabled: bool + more_information: Optional[str] + +class Hype(Model): + current: int + required: int + +class Nominations(Model): + current: int + required: int + +class Kudosu(Model): + total: int + available: int + +class KudosuGiver(Model): + url: str + username: str + +class KudosuPost(Model): + url: Optional[str] + # will be "[deleted beatmap]" for deleted beatmaps. TODO codify this + # somehow? another enum perhaps? see + # https://osu.ppy.sh/docs/index.html#kudosuhistory + title: str + +class KudosuVote(Model): + user_id: int + score: int + + def user(self): + return self._fk_user(self.user_id) + +class EventUser(Model): + username: str + url: str + previousUsername: Optional[str] + +class EventBeatmap(Model): + title: str + url: str + +class EventBeatmapset(Model): + title: str + url: str + +class EventAchivement(Model): + icon_url: str + id: int + name: str + # TODO ``grouping`` can probably be enumified (example value: "Dedication"), + # need to find full list first though + grouping: str + ordering: int + slug: str + description: str + mode: Optional[GameMode] + instructions: Optional[Any] + +class GithubUser(Model): + display_name: str + github_url: Optional[str] + id: Optional[int] + osu_username: Optional[str] + user_id: Optional[int] + user_url: Optional[str] + + def user(self): + return self._fk_user(self.user_id) + +class ChangelogSearch(Model): + from_: Optional[str] = Field(name="from") + limit: int + max_id: Optional[int] + stream: Optional[str] + to: Optional[str] + +class NewsSearch(Model): + limit: int + sort: str + # undocumented + year: Optional[int] + +class ForumPostBody(Model): + html: str + raw: str + +class ReviewsConfig(Model): + max_blocks: int + + +# =================== +# Undocumented Models +# =================== + +class UserMonthlyPlaycount(Model): + # undocumented + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserMonthlyPlaycountTransformer.php + start_date: Datetime + count: int + +class UserPage(Model): + # undocumented (and not a class on osu-web) + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserCompactTransformer.php#L270 + html: str + raw: str + +class UserLevel(Model): + # undocumented (and not a class on osu-web) + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserStatisticsTransformer.php#L27 + current: int + progress: int + +class UserGradeCounts(Model): + # undocumented (and not a class on osu-web) + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserStatisticsTransformer.php#L43 + ss: int + ssh: int + s: int + sh: int + a: int + +class UserReplaysWatchedCount(Model): + # undocumented + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserReplaysWatchedCountTransformer.php + start_date: Datetime + count: int + +class UserAchievement(Model): + # undocumented + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserAchievementTransformer.php#L10 + achieved_at: Datetime + achievement_id: int + +class UserProfileCustomization(Model): + # undocumented + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserCompactTransformer.php#L363 + # https://github.com/ppy/osu-web/blob/master/app/Models/UserProfileCustomization.php + audio_autoplay: Optional[bool] + audio_muted: Optional[bool] + audio_volume: Optional[int] + beatmapset_download: Optional[BeatmapsetDownload] + beatmapset_show_nsfw: Optional[bool] + beatmapset_title_show_original: Optional[bool] + comments_show_deleted: Optional[bool] + forum_posts_show_deleted: bool + ranking_expanded: bool + user_list_filter: Optional[UserListFilters] + user_list_sort: Optional[UserListSorts] + user_list_view: Optional[UserListViews] + +class RankHistory(Model): + # undocumented + # https://github.com/ppy/osu-web/blob/master/app/Transformers/RankHistoryTransformer.php + mode: GameMode + data: List[int] + +class Weight(Model): + percentage: float + pp: float diff --git a/ossapi/mod.py b/ossapi/mod.py new file mode 100644 index 00000000..6e9f8c96 --- /dev/null +++ b/ossapi/mod.py @@ -0,0 +1,329 @@ +from ossapi.utils import BaseModel + +int_to_mod = { + 0 : ["NM", "NoMod"], + 1 << 0 : ["NF", "NoFail"], + 1 << 1 : ["EZ", "Easy"], + 1 << 2 : ["TD", "TouchDevice"], + 1 << 3 : ["HD", "Hidden"], + 1 << 4 : ["HR", "HardRock"], + 1 << 5 : ["SD", "SuddenDeath"], + 1 << 6 : ["DT", "DoubleTime"], + 1 << 7 : ["RX", "Relax"], + 1 << 8 : ["HT", "HalfTime"], + 1 << 9 : ["NC", "Nightcore"], + 1 << 10 : ["FL", "Flashlight"], + 1 << 11 : ["AT", "Autoplay"], + 1 << 12 : ["SO", "SpunOut"], + 1 << 13 : ["AP", "Autopilot"], + 1 << 14 : ["PF", "Perfect"], + 1 << 15 : ["K4", "Key4"], + 1 << 16 : ["K5", "Key5"], + 1 << 17 : ["K6", "Key6"], + 1 << 18 : ["K7", "Key7"], + 1 << 19 : ["K8", "Key8"], + 1 << 20 : ["FI", "FadeIn"], + 1 << 21 : ["RD", "Random"], + 1 << 22 : ["CN", "Cinema"], + 1 << 23 : ["TP", "Target"], + 1 << 24 : ["K9", "Key9"], + 1 << 25 : ["CO", "KeyCoop"], + 1 << 26 : ["K1", "Key1"], + 1 << 27 : ["K3", "Key3"], + 1 << 28 : ["K2", "Key2"], + 1 << 29 : ["V2", "ScoreV2"], + 1 << 30 : ["MR", "Mirror"] +} + + +class ModCombination(BaseModel): + """ + An osu! mod combination. + + Notes + ----- + This class only exists to allow ``Mod`` to have ``ModCombination`` objects + as class attributes, as you can't instantiate instances of your own class in + a class definition. + """ + + def __init__(self, value): + self.value = value + + @staticmethod + def _parse_mod_string(mod_string): + """ + Creates an integer representation of a mod string made up of two letter + mod names ("HDHR", for example). + + Parameters + ---------- + mod_string: str + The mod string to represent as an int. + + Returns + ------- + int + The integer representation of the mod string. + + Raises + ------ + ValueError + If mod_string is empty, not of even length, or any of its 2-length + substrings do not correspond to a Mod in Mod.ORDER. + """ + if mod_string == "": + raise ValueError("Invalid mod string (cannot be empty)") + if len(mod_string) % 2 != 0: + raise ValueError(f"Invalid mod string {mod_string} (not of even " + "length)") + mod = Mod.NM + for i in range(0, len(mod_string) - 1, 2): + single_mod = mod_string[i: i + 2] + # there better only be one Mod that has an acronym matching ours, + # but a comp + 0 index works too + matching_mods = [mod for mod in Mod.ORDER if \ + mod.short_name() == single_mod] + # ``mod.ORDER`` uses ``_NC`` and ``_PF``, and we want to parse + # eg "NC" as "DTNC" + if Mod._NC in matching_mods: + matching_mods.remove(Mod._NC) + matching_mods.append(Mod.NC) + if Mod._PF in matching_mods: + matching_mods.remove(Mod._PF) + matching_mods.append(Mod.PF) + if not matching_mods: + raise ValueError("Invalid mod string (no matching mod found " + f"for {single_mod})") + mod += matching_mods[0] + return mod.value + + def short_name(self): + """ + The acronym-ized names of the component mods. + + Returns + ------- + str + The short name of this ModCombination. + + Examples + -------- + >>> ModCombination(576).short_name() + "NC" + >>> ModCombination(24).short_name() + "HDHR" + + Notes + ----- + This is a function instead of an attribute set at initialization time + because otherwise we couldn't refer to a :class:`~.Mod`\s as its class + body isn't loaded while it's instantiating :class:`~.Mod`\s. + + Although technically mods such as NC are represented with two bits - + DT and NC - being set, short_name removes DT and so returns "NC" + rather than "DTNC". + """ + if self.value in int_to_mod: + # avoid infinite recursion with every mod decomposing into itself + # ad infinitum + return int_to_mod[self.value][0] + + component_mods = self.decompose(clean=True) + return "".join(mod.short_name() for mod in component_mods) + + def long_name(self): + """ + The spelled out names of the component mods. + + Returns + ------- + str + The long name of this ModCombination. + + Examples + -------- + >>> ModCombination(576).long_name() + "Nightcore" + >>> ModCombination(24).long_name() + "Hidden HardRock" + + Notes + ----- + This is a function instead of an attribute set at initialization time + because otherwise we couldn't refer to :class:`~.Mod`\s as its class + body isn't loaded while it's instantiating :class:`~.Mod`\s. + + Although technically mods such as NC are represented with two bits - + DT and NC - being set, long_name removes DT and so returns "Nightcore" + rather than "DoubleTime Nightcore". + """ + if self.value in int_to_mod: + return int_to_mod[self.value][1] + + component_mods = self.decompose(clean=True) + return " ".join(mod.long_name() for mod in component_mods) + + def __eq__(self, other): + """Compares the ``value`` of each object""" + if not isinstance(other, ModCombination): + return False + return self.value == other.value + + def __add__(self, other): + """Returns a Mod representing the bitwise OR of the two Mods""" + return ModCombination(self.value | other.value) + + def __sub__(self, other): + return ModCombination(self.value & ~other.value) + + def __hash__(self): + return hash(self.value) + + def __repr__(self): + return f"ModCombination(value={self.value})" + + def __str__(self): + return self.short_name() + + def __contains__(self, other): + return bool(self.value & other.value) + + def decompose(self, clean=False): + """ + Decomposes this mod into its base component mods, which are + :class:`~.ModCombination`\s with a ``value`` of a power of two. + + Parameters + ---------- + clean: bool + If true, removes mods that we would think of as duplicate - if both + NC and DT are component mods, remove DT. If both PF and SD are + component mods, remove SD. + + Returns + ------- + list[:class:`~.ModCombination`] + A list of the component :class:`~.ModCombination`\s of this mod, + ordered according to :const:`~circleguard.mod.ModCombination.ORDER`. + """ + + mods = [ModCombination(mod_int) for mod_int in int_to_mod if + self.value & mod_int] + # order the mods by Mod.ORDER + mods = [mod for mod in Mod.ORDER if mod in mods] + if not clean: + return mods + + if Mod._NC in mods and Mod.DT in mods: + mods.remove(Mod.DT) + if Mod._PF in mods and Mod.SD in mods: + mods.remove(Mod.SD) + return mods + + +class Mod(ModCombination): + """ + An ingame osu! mod. + + Common combinations are available as ``HDDT``, ``HDHR``, and ``HDDTHR``. + + Parameters + ---------- + value: int or str or list + A representation of the desired mod. This can either be its integer + representation such as ``64`` for ``DT`` and ``72`` (``64`` + ``8``) for + ``HDDT``, or a string such as ``"DT"`` for ``DT`` and ``"HDDT"`` (or + ``DTHD``) for ``HDDT``, or a list of strings such as ``["HD", "DT"]`` + for ``HDDT``. + |br| + If used, the string must be composed of two-letter acronyms for mods, + in any order. + + Notes + ----- + The nightcore mod is never set by itself. When we see plays set with ``NC``, + we are really seeing a ``DT + NC`` play. ``NC`` by itself is ``512``, but + what we expect to see is ``576`` (``512 + 64``; ``DT`` is ``64``). As such + ``Mod.NC`` is defined to be the more intuitive version—``DT + NC``. We + provide the true, technical version of the ``NC`` mod (``512``) as + ``Mod._NC``. + + This same treatment and reasoning applies to ``Mod.PF``, which we define + as ``PF + SD``. The technical version of PF is available as ``Mod._PF``. + + A full list of mods and their specification can be found at + https://osu.ppy.sh/help/wiki/Game_Modifiers, or a more technical list at + https://github.com/ppy/osu-api/wiki#mods. + + Warnings + -------- + The fact that this class subclasses ModCombination is slightly misleading. + This is only done so that this class can be instantiated directly, backed + by an internal ModCombination, instead of exposing ModCombination to users. + """ + + NM = NoMod = ModCombination(0) + NF = NoFail = ModCombination(1 << 0) + EZ = Easy = ModCombination(1 << 1) + TD = TouchDevice = ModCombination(1 << 2) + HD = Hidden = ModCombination(1 << 3) + HR = HardRock = ModCombination(1 << 4) + SD = SuddenDeath = ModCombination(1 << 5) + DT = DoubleTime = ModCombination(1 << 6) + RX = Relax = ModCombination(1 << 7) + HT = HalfTime = ModCombination(1 << 8) + _NC = _Nightcore = ModCombination(1 << 9) + # most people will find it more useful for NC to be defined as it is ingame + NC = Nightcore = _NC + DT + FL = Flashlight = ModCombination(1 << 10) + AT = Autoplay = ModCombination(1 << 11) + SO = SpunOut = ModCombination(1 << 12) + AP = Autopilot = ModCombination(1 << 13) + _PF = _Perfect = ModCombination(1 << 14) + PF = Perfect = _PF + SD + K4 = Key4 = ModCombination(1 << 15) + K5 = Key5 = ModCombination(1 << 16) + K6 = Key6 = ModCombination(1 << 17) + K7 = Key7 = ModCombination(1 << 18) + K8 = Key8 = ModCombination(1 << 19) + FI = FadeIn = ModCombination(1 << 20) + RD = Random = ModCombination(1 << 21) + CN = Cinema = ModCombination(1 << 22) + TP = Target = ModCombination(1 << 23) + K9 = Key9 = ModCombination(1 << 24) + CO = KeyCoop = ModCombination(1 << 25) + K1 = Key1 = ModCombination(1 << 26) + K3 = Key3 = ModCombination(1 << 27) + K2 = Key2 = ModCombination(1 << 28) + V2 = ScoreV2 = ModCombination(1 << 29) + MR = Mirror = ModCombination(1 << 30) + + KM = KeyMod = K1 + K2 + K3 + K4 + K5 + K6 + K7 + K8 + K9 + KeyCoop + + # common mod combinations + HDDT = HD + DT + HDHR = HD + HR + HDDTHR = HD + DT + HR + + # how people naturally sort mods in combinations (HDDTHR, not DTHRHD) + # sphinx uses repr() here + # (see https://github.com/sphinx-doc/sphinx/issues/3857), so provide + # our own, more human readable docstrings. #: denotes sphinx docstrings. + #: [NM, EZ, HD, HT, DT, _NC, HR, FL, NF, SD, _PF, RX, AP, SO, AT, V2, TD, + #: FI, RD, CN, TP, K1, K2, K3, K4, K5, K6, K7, K8, K9, CO, MR] + ORDER = [NM, EZ, HD, HT, DT, _NC, HR, FL, NF, SD, _PF, RX, AP, SO, AT, + V2, TD, # we stop caring about order after this point + FI, RD, CN, TP, K1, K2, K3, K4, K5, K6, K7, K8, K9, CO, MR] + + def __init__(self, value): + if isinstance(value, str): + value = ModCombination._parse_mod_string(value) + if isinstance(value, list): + mod = Mod.NM + for mod_str in value: + mod += Mod(mod_str) + value = mod.value + if isinstance(value, ModCombination): + value = value.value + super().__init__(value) diff --git a/ossapi/models.py b/ossapi/models.py new file mode 100644 index 00000000..313b8175 --- /dev/null +++ b/ossapi/models.py @@ -0,0 +1,1048 @@ +# opt-in to forward type annotations +# https://docs.python.org/3.7/whatsnew/3.7.html#pep-563-postponed-evaluation-of-annotations +from __future__ import annotations +from typing import Optional, TypeVar, Generic, Any, List, Union + +from ossapi.mod import Mod +from ossapi.enums import (UserAccountHistory, ProfileBanner, UserBadge, Country, + Cover, UserGroup, UserMonthlyPlaycount, UserPage, UserReplaysWatchedCount, + UserAchievement, UserProfileCustomization, RankHistory, Kudosu, PlayStyles, + ProfilePage, GameMode, RankStatus, Failtimes, Covers, Hype, Availability, + Nominations, Statistics, Grade, Weight, MessageType, KudosuAction, + KudosuGiver, KudosuPost, EventType, EventAchivement, EventUser, + EventBeatmap, BeatmapsetApproval, EventBeatmapset, KudosuVote, + BeatmapsetEventType, UserRelationType, UserLevel, UserGradeCounts, + GithubUser, ChangelogSearch, ForumTopicType, ForumPostBody, ForumTopicSort, + ChannelType, ReviewsConfig, NewsSearch) +from ossapi.utils import Datetime, Model, BaseModel, Field + +T = TypeVar("T") +S = TypeVar("S") + +""" +a type hint of ``Optional[Any]`` or ``Any`` means that I don't know what type it +is, not that the api actually lets any type be returned there. +""" + +# ================= +# Documented Models +# ================= + +# the weird location of the cursor class and `CursorT` definition is to remove +# the need for forward type annotations, which breaks typing_utils when they +# try to evaluate the forwardref (as the `Cursor` class is not in scope at that +# moment). We would be able to fix this by manually passing forward refs to the +# lib instead, but I don't want to have to keep track of which forward refs need +# passing and which don't, or which classes I need to import in various files +# (it's not as simple as just sticking a `global()` call in and calling it a +# day). So I'm just going to ban forward refs in the codebase for now, until we +# want to drop typing_utils (and thus support for python 3.8 and lower). +# It's also possible I'm missing an obvious fix for this, but I suspect this is +# a limitation of older python versions. + +# Cursors are an interesting case. As I understand it, they don't have a +# predefined set of attributes across all endpoints, but instead differ per +# endpoint. I don't want to have dozens of different cursor classes (although +# that would perhaps be the proper way to go about this), so just allow +# any attribute. +# This is essentially a reimplementation of SimpleNamespace to deal with +# BaseModels being passed the data as a single dict (`_data`) instead of as +# **kwargs, plus some other weird stuff we're doing like handling cursor +# objects being passed as data +# We want cursors to also be instantiatable manually (eg `Cursor(page=199)`), +# so make `_data` optional and also allow arbitrary `kwargs`. + +class Cursor(BaseModel): + def __init__(self, _data=None, **kwargs): + super().__init__() + # allow Cursor to be instantiated with another cursor as a no-op + if isinstance(_data, Cursor): + _data = _data.__dict__ + _data = _data or kwargs + self.__dict__.update(_data) + + def __repr__(self): + keys = sorted(self.__dict__) + items = (f"{k}={self.__dict__[k]!r}" for k in keys) + return f"{type(self).__name__}({', '.join(items)})" + + def __eq__(self, other): + return self.__dict__ == other.__dict__ + +# if there are no more results, a null cursor is returned instead. +# So always let the cursor be nullable to catch this. It's the user's +# responsibility to check for a null cursor to see if there are any more +# results. +CursorT = Optional[Cursor] + +class UserCompact(Model): + """ + https://osu.ppy.sh/docs/index.html#usercompact + """ + # required fields + # --------------- + avatar_url: str + country_code: str + default_group: str + id: int + is_active: bool + is_bot: bool + is_deleted: bool + is_online: bool + is_supporter: bool + last_visit: Optional[Datetime] + pm_friends_only: bool + profile_colour: Optional[str] + username: str + + # optional fields + # --------------- + account_history: Optional[List[UserAccountHistory]] + active_tournament_banner: Optional[ProfileBanner] + badges: Optional[List[UserBadge]] + beatmap_playcounts_count: Optional[int] + blocks: Optional[UserRelation] + country: Optional[Country] + cover: Optional[Cover] + favourite_beatmapset_count: Optional[int] + # undocumented + follow_user_mapping: Optional[List[int]] + follower_count: Optional[int] + friends: Optional[List[UserRelation]] + graveyard_beatmapset_count: Optional[int] + groups: Optional[List[UserGroup]] + # undocumented + guest_beatmapset_count: Optional[int] + is_admin: Optional[bool] + is_bng: Optional[bool] + is_full_bn: Optional[bool] + is_gmt: Optional[bool] + is_limited_bn: Optional[bool] + is_moderator: Optional[bool] + is_nat: Optional[bool] + is_restricted: Optional[bool] + is_silenced: Optional[bool] + loved_beatmapset_count: Optional[int] + # undocumented + mapping_follower_count: Optional[int] + monthly_playcounts: Optional[List[UserMonthlyPlaycount]] + page: Optional[UserPage] + previous_usernames: Optional[List[str]] + # deprecated, replaced by ranked_beatmapset_count + ranked_and_approved_beatmapset_count: Optional[int] + ranked_beatmapset_count: Optional[int] + replays_watched_counts: Optional[List[UserReplaysWatchedCount]] + scores_best_count: Optional[int] + scores_first_count: Optional[int] + scores_recent_count: Optional[int] + statistics: Optional[UserStatistics] + statistics_rulesets: Optional[UserStatisticsRulesets] + support_level: Optional[int] + # deprecated, replaced by pending_beatmapset_count + unranked_beatmapset_count: Optional[int] + pending_beatmapset_count: Optional[int] + unread_pm_count: Optional[int] + user_achievements: Optional[List[UserAchievement]] + user_preferences: Optional[UserProfileCustomization] + rank_history: Optional[RankHistory] + # deprecated, replaced by rank_history + rankHistory: Optional[RankHistory] + + def expand(self) -> User: + return self._fk_user(self.id) + +class User(UserCompact): + comments_count: int + cover_url: str + discord: Optional[str] + has_supported: bool + interests: Optional[str] + join_date: Datetime + kudosu: Kudosu + location: Optional[str] + max_blocks: int + max_friends: int + occupation: Optional[str] + playmode: str + playstyle: Optional[PlayStyles] + post_count: int + profile_order: List[ProfilePage] + title: Optional[str] + title_url: Optional[str] + twitter: Optional[str] + website: Optional[str] + scores_pinned_count: int + + def expand(self) -> User: + # we're already expanded, no need to waste an api call + return self + + +class BeatmapCompact(Model): + # required fields + # --------------- + difficulty_rating: float + id: int + mode: GameMode + status: RankStatus + total_length: int + version: str + user_id: int + beatmapset_id: int + + # optional fields + # --------------- + _beatmapset: Optional[BeatmapsetCompact] = Field(name="beatmapset") + checksum: Optional[str] + failtimes: Optional[Failtimes] + max_combo: Optional[int] + + def expand(self) -> Beatmap: + return self._fk_beatmap(self.id) + + def user(self) -> User: + return self._fk_user(self.user_id) + + def beatmapset(self) -> Union[Beatmapset, BeatmapsetCompact]: + return self._fk_beatmapset(self.beatmapset_id, + existing=self._beatmapset) + +class Beatmap(BeatmapCompact): + total_length: int + version: str + accuracy: float + ar: float + bpm: Optional[float] + convert: bool + count_circles: int + count_sliders: int + count_spinners: int + cs: float + deleted_at: Optional[Datetime] + drain: float + hit_length: int + is_scoreable: bool + last_updated: Datetime + mode_int: int + passcount: int + playcount: int + ranked: RankStatus + url: str + + # overridden fields + # ----------------- + _beatmapset: Optional[Beatmapset] = Field(name="beatmapset") + + def expand(self) -> Beatmap: + return self + + def beatmapset(self) -> Beatmapset: + return self._fk_beatmapset(self.beatmapset_id, + existing=self._beatmapset) + + +class BeatmapsetCompact(Model): + """ + https://osu.ppy.sh/docs/index.html#beatmapsetcompact + """ + # required fields + # --------------- + artist: str + artist_unicode: str + covers: Covers + creator: str + favourite_count: int + id: int + play_count: int + preview_url: str + source: str + status: RankStatus + title: str + title_unicode: str + user_id: int + video: bool + nsfw: bool + offset: int + spotlight: bool + # documented as being in ``Beatmapset`` only, but returned by + # ``api.beatmapset_events`` which uses a ``BeatmapsetCompact``. + hype: Optional[Hype] + + # optional fields + # --------------- + beatmaps: Optional[List[Beatmap]] + converts: Optional[Any] + current_user_attributes: Optional[Any] + description: Optional[Any] + discussions: Optional[Any] + events: Optional[Any] + genre: Optional[Any] + has_favourited: Optional[bool] + language: Optional[Any] + nominations: Optional[Any] + ratings: Optional[Any] + recent_favourites: Optional[Any] + related_users: Optional[Any] + _user: Optional[UserCompact] = Field(name="user") + # undocumented + track_id: Optional[int] + + def expand(self) -> Beatmapset: + return self._fk_beatmapset(self.id) + + def user(self) -> Union[UserCompact, User]: + return self._fk_user(self.user_id, existing=self._user) + +class Beatmapset(BeatmapsetCompact): + availability: Availability + bpm: float + can_be_hyped: bool + discussion_enabled: bool + discussion_locked: bool + is_scoreable: bool + last_updated: Datetime + legacy_thread_url: Optional[str] + nominations_summary: Nominations + ranked: RankStatus + ranked_date: Optional[Datetime] + storyboard: bool + submitted_date: Optional[Datetime] + tags: str + + def expand(self) -> Beatmapset: + return self + + +class Match(Model): + pass + +class Score(Model): + """ + https://osu.ppy.sh/docs/index.html#score + """ + id: int + best_id: Optional[int] + user_id: int + accuracy: float + mods: Mod + score: int + max_combo: int + perfect: bool + statistics: Statistics + pp: Optional[float] + rank: Grade + created_at: Datetime + mode: GameMode + mode_int: int + replay: bool + passed: bool + current_user_attributes: Any + + beatmap: Optional[Beatmap] + beatmapset: Optional[BeatmapsetCompact] + rank_country: Optional[int] + rank_global: Optional[int] + weight: Optional[Weight] + _user: Optional[UserCompact] = Field(name="user") + match: Optional[Match] + + def user(self) -> Union[UserCompact, User]: + return self._fk_user(self.user_id, existing=self._user) + +class BeatmapUserScore(Model): + position: int + score: Score + +class BeatmapUserScores(Model): + scores: List[Score] + +class BeatmapScores(Model): + scores: List[Score] + userScore: Optional[BeatmapUserScore] + + +class CommentableMeta(Model): + # this class is currently not following the documentation in order to work + # around https://github.com/ppy/osu-web/issues/7317. Will be updated when + # that issue is resolved (one way or the other). + id: Optional[int] + title: str + type: Optional[str] + url: Optional[str] + # both undocumented + owner_id: Optional[int] + owner_title: Optional[str] + current_user_attributes: Any + +class Comment(Model): + commentable_id: int + commentable_type: str + created_at: Datetime + deleted_at: Optional[Datetime] + edited_at: Optional[Datetime] + edited_by_id: Optional[int] + id: int + legacy_name: Optional[str] + message: Optional[str] + message_html: Optional[str] + parent_id: Optional[int] + pinned: bool + replies_count: int + updated_at: Datetime + user_id: int + votes_count: int + + def user(self) -> User: + return self._fk_user(self.user_id) + + def edited_by(self) -> Optional[User]: + return self._fk_user(self.edited_by_id) + +class CommentBundle(Model): + commentable_meta: List[CommentableMeta] + comments: List[Comment] + has_more: bool + has_more_id: Optional[int] + included_comments: List[Comment] + pinned_comments: Optional[List[Comment]] + sort: str + top_level_count: Optional[int] + total: Optional[int] + user_follow: bool + user_votes: List[int] + users: List[UserCompact] + # undocumented + cursor: CursorT + +class ForumPost(Model): + created_at: Datetime + deleted_at: Optional[Datetime] + edited_at: Optional[Datetime] + edited_by_id: Optional[int] + forum_id: int + id: int + topic_id: int + user_id: int + body: ForumPostBody + + def user(self) -> User: + return self._fk_user(self.user_id) + + def edited_by(self) -> Optional[User]: + return self._fk_user(self.edited_by_id) + +class ForumTopic(Model): + created_at: Datetime + deleted_at: Optional[Datetime] + first_post_id: int + forum_id: int + id: int + is_locked: bool + last_post_id: int + post_count: int + title: str + type: ForumTopicType + updated_at: Datetime + user_id: int + poll: Any + + def user(self) -> User: + return self._fk_user(self.user_id) + +class ForumTopicAndPosts(Model): + cursor: CursorT + search: ForumTopicSearch + posts: List[ForumPost] + topic: ForumTopic + cursor_string: Optional[str] + +class ForumTopicSearch(Model): + sort: Optional[ForumTopicSort] + limit: Optional[int] + start: Optional[int] + end: Optional[int] + +class SearchResult(Generic[T], Model): + data: List[T] + total: int + +class WikiPage(Model): + layout: str + locale: str + markdown: str + path: str + subtitle: Optional[str] + tags: List[str] + title: str + available_locales: List[str] + +class Search(Model): + users: Optional[SearchResult[UserCompact]] = Field(name="user") + wiki_pages: Optional[SearchResult[WikiPage]] = Field(name="wiki_page") + +class Spotlight(Model): + end_date: Datetime + id: int + mode_specific: bool + participant_count: Optional[int] + name: str + start_date: Datetime + type: str + +class Spotlights(Model): + spotlights: List[Spotlight] + +class Rankings(Model): + beatmapsets: Optional[List[Beatmapset]] + cursor: CursorT + ranking: List[UserStatistics] + spotlight: Optional[Spotlight] + total: int + +class BeatmapsetDiscussionPost(Model): + id: int + beatmapset_discussion_id: int + user_id: int + last_editor_id: Optional[int] + deleted_by_id: Optional[int] + system: bool + message: str + created_at: Datetime + updated_at: Datetime + deleted_at: Optional[Datetime] + + def user(self) -> user: + return self._fk_user(self.user_id) + + def last_editor(self) -> Optional[User]: + return self._fk_user(self.last_editor_id) + + def deleted_by(self) -> Optional[User]: + return self._fk_user(self.deleted_by_id) + +class BeatmapsetDiscussion(Model): + id: int + beatmapset_id: int + beatmap_id: Optional[int] + user_id: int + deleted_by_id: Optional[int] + message_type: MessageType + parent_id: Optional[int] + # a point of time which is ``timestamp`` milliseconds into the map + timestamp: Optional[int] + resolved: bool + can_be_resolved: bool + can_grant_kudosu: bool + created_at: Datetime + # documented as non-optional, api.beatmapset_events() might give a null + # response for this? but very rarely. need to find a repro case + current_user_attributes: Any + updated_at: Datetime + deleted_at: Optional[Datetime] + # similarly as for current_user_attributes, in the past this has been null + # but can't find a repro case + last_post_at: Datetime + kudosu_denied: bool + starting_post: Optional[BeatmapsetDiscussionPost] + posts: Optional[List[BeatmapsetDiscussionPost]] + _beatmap: Optional[BeatmapCompact] = Field(name="beatmap") + _beatmapset: Optional[BeatmapsetCompact] = Field(name="beatmapset") + + def user(self) -> User: + return self._fk_user(self.user_id) + + def deleted_by(self) -> Optional[User]: + return self._fk_user(self.deleted_by_id) + + def beatmapset(self) -> Union[Beatmapset, BeatmapsetCompact]: + return self._fk_beatmapset(self.beatmapset_id, + existing=self._beatmapset) + + def beatmap(self) -> Union[Optional[Beatmap], BeatmapCompact]: + return self._fk_beatmap(self.beatmap_id, existing=self._beatmap) + +class BeatmapsetDiscussionVote(Model): + id: int + score: int + user_id: int + beatmapset_discussion_id: int + created_at: Datetime + updated_at: Datetime + cursor_string: Optional[str] + + def user(self): + return self._fk_user(self.user_id) + +class KudosuHistory(Model): + id: int + action: KudosuAction + amount: int + # TODO enumify this. Described as "Object type which the exchange happened + # on (forum_post, etc)." in https://osu.ppy.sh/docs/index.html#kudosuhistory + model: str + created_at: Datetime + giver: Optional[KudosuGiver] + post: KudosuPost + # see https://github.com/ppy/osu-web/issues/7549 + details: Any + +class BeatmapPlaycount(Model): + beatmap_id: int + _beatmap: Optional[BeatmapCompact] = Field(name="beatmap") + beatmapset: Optional[BeatmapsetCompact] + count: int + + def beatmap(self) -> Union[Beatmap, BeatmapCompact]: + return self._fk_beatmap(self.beatmap_id, existing=self._beatmap) + + +# we use this class to determine which event dataclass to instantiate and +# return, based on the value of the ``type`` parameter. +class _Event(Model): + @classmethod + def override_class(cls, data): + mapping = { + EventType.ACHIEVEMENT: AchievementEvent, + EventType.BEATMAP_PLAYCOUNT: BeatmapPlaycountEvent, + EventType.BEATMAPSET_APPROVE: BeatmapsetApproveEvent, + EventType.BEATMAPSET_DELETE: BeatmapsetDeleteEvent, + EventType.BEATMAPSET_REVIVE: BeatmapsetReviveEvent, + EventType.BEATMAPSET_UPDATE: BeatmapsetUpdateEvent, + EventType.BEATMAPSET_UPLOAD: BeatmapsetUploadEvent, + EventType.RANK: RankEvent, + EventType.RANK_LOST: RankLostEvent, + EventType.USER_SUPPORT_FIRST: UserSupportFirstEvent, + EventType.USER_SUPPORT_AGAIN: UserSupportAgainEvent, + EventType.USER_SUPPORT_GIFT: UserSupportGiftEvent, + EventType.USERNAME_CHANGE: UsernameChangeEvent + } + type_ = EventType(data["type"]) + return mapping[type_] + +class Event(Model): + created_at: Datetime + createdAt: Datetime + id: int + type: EventType + +class AchievementEvent(Event): + achievement: EventAchivement + user: EventUser + +class BeatmapPlaycountEvent(Event): + beatmap: EventBeatmap + count: int + +class BeatmapsetApproveEvent(Event): + approval: BeatmapsetApproval + beatmapset: EventBeatmapset + user: EventUser + +class BeatmapsetDeleteEvent(Event): + beatmapset: EventBeatmapset + +class BeatmapsetReviveEvent(Event): + beatmapset: EventBeatmapset + user: EventUser + +class BeatmapsetUpdateEvent(Event): + beatmapset: EventBeatmapset + user: EventUser + +class BeatmapsetUploadEvent(Event): + beatmapset: EventBeatmapset + user: EventUser + +class RankEvent(Event): + scoreRank: str + rank: int + mode: GameMode + beatmap: EventBeatmap + user: EventUser + +class RankLostEvent(Event): + mode: GameMode + beatmap: EventBeatmap + user: EventUser + +class UserSupportFirstEvent(Event): + user: EventUser + +class UserSupportAgainEvent(Event): + user: EventUser + +class UserSupportGiftEvent(Event): + beatmap: EventBeatmap + +class UsernameChangeEvent(Event): + user: EventUser + +class Build(Model): + created_at: Datetime + display_version: str + id: int + update_stream: Optional[UpdateStream] + users: int + version: Optional[str] + changelog_entries: Optional[List[ChangelogEntry]] + versions: Optional[Versions] + +class Versions(Model): + next: Optional[Build] + previous: Optional[Build] + +class UpdateStream(Model): + display_name: Optional[str] + id: int + is_featured: bool + name: str + latest_build: Optional[Build] + user_count: Optional[int] + +class ChangelogEntry(Model): + category: str + created_at: Optional[Datetime] + github_pull_request_id: Optional[int] + github_url: Optional[str] + id: Optional[int] + major: bool + message: Optional[str] + message_html: Optional[str] + repository: Optional[str] + title: Optional[str] + type: str + url: Optional[str] + github_user: GithubUser + +class ChangelogListing(Model): + builds: List[Build] + search: ChangelogSearch + streams: List[UpdateStream] + +class MultiplayerScores(Model): + cursor: MultiplayerScoresCursor + params: str + scores: List[MultiplayerScore] + total: Optional[int] + user_score: Optional[MultiplayerScore] + +class MultiplayerScore(Model): + id: int + user_id: int + room_id: int + playlist_item_id: int + beatmap_id: int + rank: int + total_score: int + max_combo: int + mods: List[Mod] + statistics: Statistics + passed: bool + position: Optional[int] + scores_around: Optional[MultiplayerScoresAround] + user: User + + def beatmap(self): + return self._fk_beatmap(self.beatmap_id) + +class MultiplayerScoresAround(Model): + higher: List[MultiplayerScore] + lower: List[MultiplayerScore] + +class MultiplayerScoresCursor(Model): + score_id: int + total_score: int + +class NewsListing(Model): + cursor: CursorT + news_posts: List[NewsPost] + news_sidebar: NewsSidebar + search: NewsSearch + +class NewsPost(Model): + author: str + edit_url: str + first_image: Optional[str] + id: int + published_at: Datetime + slug: str + title: str + updated_at: Datetime + content: Optional[str] + navigation: Optional[NewsNavigation] + preview: Optional[str] + +class NewsNavigation(Model): + newer: Optional[NewsPost] + older: Optional[NewsPost] + +class NewsSidebar(Model): + current_year: int + news_posts: List[NewsPost] + years: list[int] + +class SeasonalBackgrounds(Model): + ends_at: Datetime + backgrounds: List[SeasonalBackground] + +class SeasonalBackground(Model): + url: str + user: UserCompact + +class DifficultyAttributes(Model): + attributes: BeatmapDifficultyAttributes + +class BeatmapDifficultyAttributes(Model): + max_combo: int + star_rating: float + + # osu attributes + aim_difficulty: Optional[float] + approach_rate: Optional[float] + flashlight_difficulty: Optional[float] + overall_difficulty: Optional[float] + slider_factor: Optional[float] + speed_difficulty: Optional[float] + + # taiko attributes + stamina_difficulty: Optional[float] + rhythm_difficulty: Optional[float] + colour_difficulty: Optional[float] + approach_raty: Optional[float] + great_hit_windoy: Optional[float] + + # ctb attributes + approach_rate: Optional[float] + + # mania attributes + great_hit_window: Optional[float] + score_multiplier: Optional[float] + + +# =================== +# Undocumented Models +# =================== + +class BeatmapsetSearchResult(Model): + beatmapsets: List[Beatmapset] + cursor: CursorT + recommended_difficulty: Optional[float] + error: Optional[str] + total: int + search: Any + cursor_string: Optional[str] + +class BeatmapsetDiscussions(Model): + beatmaps: List[Beatmap] + cursor: CursorT + discussions: List[BeatmapsetDiscussion] + included_discussions: List[BeatmapsetDiscussion] + reviews_config: ReviewsConfig + users: List[UserCompact] + cursor_string: Optional[str] + +class BeatmapsetDiscussionReview(Model): + # https://github.com/ppy/osu-web/blob/master/app/Libraries/BeatmapsetDiscussionReview.php + max_blocks: int + +class BeatmapsetDiscussionPosts(Model): + beatmapsets: List[BeatmapsetCompact] + discussions: List[BeatmapsetDiscussion] + cursor: CursorT + posts: List[BeatmapsetDiscussionPost] + users: List[UserCompact] + cursor_string: Optional[str] + +class BeatmapsetDiscussionVotes(Model): + cursor: CursorT + discussions: List[BeatmapsetDiscussion] + votes: List[BeatmapsetDiscussionVote] + users: List[UserCompact] + cursor_string: Optional[str] + +class BeatmapsetEventComment(Model): + beatmap_discussion_id: int + beatmap_discussion_post_id: int + +class BeatmapsetEventCommentNoPost(Model): + beatmap_discussion_id: int + beatmap_discussion_post_id: None + +class BeatmapsetEventCommentNone(Model): + beatmap_discussion_id: None + beatmap_discussion_post_id: None + + +class BeatmapsetEventCommentChange(Generic[S], BeatmapsetEventCommentNone): + old: S + new: S + +class BeatmapsetEventCommentLovedRemoval(BeatmapsetEventCommentNone): + reason: str + +class BeatmapsetEventCommentKudosuChange(BeatmapsetEventCommentNoPost): + new_vote: KudosuVote + votes: List[KudosuVote] + +class BeatmapsetEventCommentKudosuRecalculate(BeatmapsetEventCommentNoPost): + new_vote: Optional[KudosuVote] + +class BeatmapsetEventCommentOwnerChange(BeatmapsetEventCommentNone): + beatmap_id: int + beatmap_version: str + new_user_id: int + new_user_username: str + +class BeatmapsetEventCommentNominate(Model): + # for some reason this comment type doesn't have the normal + # beatmap_discussion_id and beatmap_discussion_post_id attributes (they're + # not even null, just missing). + modes: List[GameMode] + +class BeatmapsetEventCommentWithNominators(BeatmapsetEventCommentNoPost): + nominator_ids: Optional[List[int]] + +class BeatmapsetEventCommentWithSourceUser(BeatmapsetEventCommentNoPost): + source_user_id: int + source_user_username: str + +class BeatmapsetEvent(Model): + # https://github.com/ppy/osu-web/blob/master/app/Models/BeatmapsetEvent.php + # https://github.com/ppy/osu-web/blob/master/app/Transformers/BeatmapsetEventTransformer.php + id: int + type: BeatmapsetEventType + comment: str + created_at: Datetime + + user_id: Optional[int] + beatmapset: Optional[BeatmapsetCompact] + discussion: Optional[BeatmapsetDiscussion] + + def override_types(self): + mapping = { + BeatmapsetEventType.BEATMAP_OWNER_CHANGE: BeatmapsetEventCommentOwnerChange, + BeatmapsetEventType.DISCUSSION_DELETE: BeatmapsetEventCommentNoPost, + # ``api.beatmapset_events(types=[BeatmapsetEventType.DISCUSSION_LOCK])`` + # doesn't seem to be recognized, just returns all events. Was this + # type discontinued? + # BeatmapsetEventType.DISCUSSION_LOCK: BeatmapsetEventComment, + BeatmapsetEventType.DISCUSSION_POST_DELETE: BeatmapsetEventComment, + BeatmapsetEventType.DISCUSSION_POST_RESTORE: BeatmapsetEventComment, + BeatmapsetEventType.DISCUSSION_RESTORE: BeatmapsetEventCommentNoPost, + # same here + # BeatmapsetEventType.DISCUSSION_UNLOCK: BeatmapsetEventComment, + BeatmapsetEventType.DISQUALIFY: BeatmapsetEventCommentWithNominators, + # same here + # BeatmapsetEventType.DISQUALIFY_LEGACY: BeatmapsetEventComment + BeatmapsetEventType.GENRE_EDIT: BeatmapsetEventCommentChange[str], + BeatmapsetEventType.ISSUE_REOPEN: BeatmapsetEventComment, + BeatmapsetEventType.ISSUE_RESOLVE: BeatmapsetEventComment, + BeatmapsetEventType.KUDOSU_ALLOW: BeatmapsetEventCommentNoPost, + BeatmapsetEventType.KUDOSU_DENY: BeatmapsetEventCommentNoPost, + BeatmapsetEventType.KUDOSU_GAIN: BeatmapsetEventCommentKudosuChange, + BeatmapsetEventType.KUDOSU_LOST: BeatmapsetEventCommentKudosuChange, + BeatmapsetEventType.KUDOSU_RECALCULATE: BeatmapsetEventCommentKudosuRecalculate, + BeatmapsetEventType.LANGUAGE_EDIT: BeatmapsetEventCommentChange[str], + BeatmapsetEventType.LOVE: type(None), + BeatmapsetEventType.NOMINATE: BeatmapsetEventCommentNominate, + # same here + # BeatmapsetEventType.NOMINATE_MODES: BeatmapsetEventComment, + BeatmapsetEventType.NOMINATION_RESET: BeatmapsetEventCommentWithNominators, + BeatmapsetEventType.NOMINATION_RESET_RECEIVED: BeatmapsetEventCommentWithSourceUser, + BeatmapsetEventType.QUALIFY: type(None), + BeatmapsetEventType.RANK: type(None), + BeatmapsetEventType.REMOVE_FROM_LOVED: BeatmapsetEventCommentLovedRemoval, + BeatmapsetEventType.NSFW_TOGGLE: BeatmapsetEventCommentChange[bool], + } + type_ = BeatmapsetEventType(self.type) + return {"comment": mapping[type_]} + + def user(self) -> Optional[User]: + return self._fk_user(self.user_id) + +class ChatChannel(Model): + channel_id: int + description: Optional[str] + icon: str + # documented as non-optional (try pming tillerino with this non-optional) + moderated: Optional[bool] + name: str + type: ChannelType + + # optional fields + # --------------- + first_message_id: Optional[int] + last_message_id: Optional[int] + last_read_id: Optional[int] + recent_messages: Optional[List[ChatMessage]] + users: Optional[List[int]] + +class ChatMessage(Model): + channel_id: int + content: str + is_action: bool + message_id: int + sender: UserCompact + sender_id: int + timestamp: Datetime + +class CreatePMResponse(Model): + message: ChatMessage + new_channel_id: int + + # undocumented + channel: ChatChannel + + # documented but not present in response + presence: Optional[List[ChatChannel]] + +class ModdingHistoryEventsBundle(Model): + # https://github.com/ppy/osu-web/blob/master/app/Libraries/ModdingHistoryEventsBundle.php#L84 + events: List[BeatmapsetEvent] + reviewsConfig: BeatmapsetDiscussionReview + users: List[UserCompact] + +class UserRelation(Model): + # undocumented (and not a class on osu-web) + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserRelationTransformer.php#L16 + target_id: int + relation_type: UserRelationType + mutual: bool + + # optional fields + # --------------- + target: Optional[UserCompact] + + def target(self) -> Union[User, UserCompact]: + return self._fk_user(self.target_id, existing=self.target) + + +class UserStatistics(Model): + level: UserLevel + pp: float + ranked_score: int + hit_accuracy: float + play_count: int + play_time: int + total_score: int + total_hits: int + maximum_combo: int + replays_watched_by_others: int + is_ranked: bool + grade_counts: UserGradeCounts + + # optional fields + # --------------- + country_rank: Optional[int] + global_rank: Optional[int] + rank: Optional[Any] + user: Optional[UserCompact] + variants: Optional[Any] + +class UserStatisticsRulesets(Model): + # undocumented + # https://github.com/ppy/osu-web/blob/master/app/Transformers/UserStatisticsRulesetsTransformer.php + osu: Optional[UserStatistics] + taiko: Optional[UserStatistics] + fruits: Optional[UserStatistics] + mania: Optional[UserStatistics] diff --git a/ossapi/ossapi.py b/ossapi/ossapi.py new file mode 100644 index 00000000..4bc0bc25 --- /dev/null +++ b/ossapi/ossapi.py @@ -0,0 +1,404 @@ +from json.decoder import JSONDecodeError +import logging +from datetime import datetime, timezone +from typing import List +import time + +import requests +from requests import RequestException + +from ossapi.mod import Mod + +# log level below debug +TRACE = 5 + +class APIException(Exception): + """An error involving the osu! api.""" + +class InvalidKeyException(APIException): + def __init__(self): + super().__init__("Please provide a valid api key") + +class ReplayUnavailableException(APIException): + pass + +class Ossapi: + """ + A simple api wrapper. Every public method takes a dict as its argument, + mapping keys to values. + + No attempt is made to ratelimit the connection or catch request errors. + This is left to the user implementation. + """ + + # how long in seconds to wait for a request to finish before raising a + # ``requests.Timeout`` exception + TIMEOUT = 15 + BASE_URL = "https://osu.ppy.sh/api/" + # how long in seconds it takes the api to refresh our ratelimits after our + # first request + RATELIMIT_REFRESH = 60 + + def __init__(self, key): + self._key = key + self.log = logging.getLogger(__name__) + # when we started our requests cycle + self.start_time = datetime.min + + def _get(self, endpoint, params, type_, list_=False, _beatmap_id=None): + # _beatmap_id parameter exists because api v1 is badly designed and + # returns models which are missing some information if you already + # passed that value in the api call. So we need to supply it here so + # we can make our models homogeneous. + difference = datetime.now() - self.start_time + if difference.seconds > self.RATELIMIT_REFRESH: + self.start_time = datetime.now() + + params["k"] = self._key + url = f"{self.BASE_URL}{endpoint}" + self.log.debug(f"making request to url {url} with params {params}") + + try: + r = requests.get(url, params=params, timeout=self.TIMEOUT) + except RequestException as e: + self.log.warning(f"Request exception: {e}. Likely a network issue; " + "sleeping for 5 seconds then retrying") + time.sleep(5) + return self._get(endpoint, params, type_, list_, _beatmap_id) + + self.log.log(TRACE, f"made request to url {r.request.url}") + + try: + data = r.json() + except JSONDecodeError: + self.log.warning("the api returned invalid json. Likely a " + "temporary issue, waiting and retrying") + time.sleep(3) + return self._get(endpoint, params, type_, list_, _beatmap_id) + + self.log.log(TRACE, f"got data from api: {data}") + + if "error" in data: + error = data["error"] + if error == "Replay not available.": + raise ReplayUnavailableException("Could not find any replay " + "data") + if error == "Requesting too fast! Slow your operation, cap'n!": + self._enforce_ratelimit() + return self._get(endpoint, params, type_, list_, _beatmap_id) + if error == "Replay retrieval failed.": + raise ReplayUnavailableException("Replay retrieval failed") + if error == "Please provide a valid API key.": + raise InvalidKeyException() + raise APIException("Unknown error when requesting a " + f"replay: {error}.") + + if list_: + ret = [] + for entry in data: + if _beatmap_id: + entry["beatmap_id"] = _beatmap_id + ret.append(type_(entry)) + else: + ret = type_(data) + + return ret + + def _enforce_ratelimit(self): + """ + Sleeps the thread until we have refreshed our ratelimits. + """ + difference = datetime.now() - self.start_time + seconds_passed = difference.seconds + + # sleep the remainder of the reset cycle so we guarantee it's been that + # long since the first request + sleep_seconds = self.RATELIMIT_REFRESH - seconds_passed + sleep_seconds = max(sleep_seconds, 0) + self.log.info("Ratelimited, sleeping for %s seconds.", sleep_seconds) + time.sleep(sleep_seconds) + + def get_beatmaps(self, since=None, beatmapset_id=None, beatmap_id=None, + user=None, user_type=None, mode=None, include_converts=None, + beatmap_hash=None, limit=None, mods=None + ) -> List["Beatmap"]: + params = {"since": since, "s": beatmapset_id, "b": beatmap_id, + "u": user, "type": user_type, "m": mode, "a": include_converts, + "h": beatmap_hash, "limit": limit, "mods": mods} + return self._get("get_beatmaps", params, Beatmap, list_=True) + + def get_match(self, match_id) -> "MatchInfo": + params = {"mp": match_id} + return self._get("get_match", params, MatchInfo) + + def get_scores(self, beatmap_id, user=None, mode=None, mods=None, + user_type=None, limit=None + ) -> List["Score"]: + params = {"b": beatmap_id, "u": user, "m": mode, "mods": mods, + "type": user_type, "limit": limit} + return self._get("get_scores", params, Score, list_=True, + _beatmap_id=beatmap_id) + + def get_replay(self, beatmap_id=None, user=None, mode=None, score_id=None, + user_type=None, mods=None + ) -> str: + params = {"b": beatmap_id, "u": user, "m": mode, "s": score_id, + "type": user_type, "mods": mods} + r = self._get("get_replay", params, Replay) + return r.content + + def get_user(self, user, mode=None, user_type=None, event_days=None) \ + -> "User": + params = {"u": user, "m": mode, "type": user_type, + "event_days": event_days} + users = self._get("get_user", params, User, list_=True) + # api returns a list of users even though we never get more than one + # user, just extract it manually + return users[0] if users else users + + def get_user_best(self, user, mode=None, limit=None, user_type=None) \ + -> List["Score"]: + params = {"u": user, "m": mode, "limit": limit, "type": user_type} + return self._get("get_user_best", params, Score, list_=True) + + def get_user_recent(self, user, mode=None, limit=None, user_type=None) \ + -> List["Score"]: + params = {"u": user, "m": mode, "limit": limit, "type": user_type} + return self._get("get_user_recent", params, Score, list_=True) + +# ideally we'd use the ossapiv2 machinery (dataclasses + annotations) for these +# models instead of this manual drudgery. Unfortunately said machinery requires +# python 3.8+ and I'm not willing to drop support for python 3.7 quite yet +# (I'd be okay with dropping 3.6 though). This is a temporary meassure until +# such a time when I can justify dropping 3.7. +# Should be a 'write once and forget about it' kind of thing anyway since v1 is +# extremely stable, but would be nice to migrate over to v2's way of doing +# things at some point. + +class Model: + def __init__(self, data): + self._data = data + + def _get(self, attr, optional=False): + if attr not in self._data and optional: + return None + return self._data[attr] + + def _date(self, attr): + attr = self._data[attr] + if attr is None: + return None + + date = datetime.strptime(attr, "%Y-%m-%d %H:%M:%S") + # all api provided datetimes are in utc + return date.replace(tzinfo=timezone.utc) + + def _int(self, attr): + attr = self._data[attr] + if attr is None: + return None + + return int(attr) + + def _float(self, attr): + attr = self._data[attr] + if attr is None: + return None + + return float(attr) + + def _bool(self, attr): + attr = self._data[attr] + if attr is None: + return None + + if attr == "1": + return True + return False + + + def _mod(self, attr): + attr = self._data[attr] + if attr is None: + return None + + return Mod(int(attr)) + + +class Beatmap(Model): + def __init__(self, data): + super().__init__(data) + + self.approved = self._get("approved") + self.submit_date = self._date("submit_date") + self.approved_date = self._date("approved_date") + self.last_update = self._date("last_update") + self.artist = self._get("artist") + self.beatmap_id = self._int("beatmap_id") + self.beatmapset_id = self._int("beatmapset_id") + self.bpm = self._get("bpm") + self.creator = self._get("creator") + self.creator_id = self._int("creator_id") + self.star_rating = self._float("difficultyrating") + self.stars_aim = self._float("diff_aim") + self.stars_speed = self._float("diff_speed") + self.circle_size = self._float("diff_size") + self.overrall_difficulty = self._float("diff_overall") + self.approach_rate = self._float("diff_approach") + self.health = self._float("diff_drain") + self.hit_length = self._float("hit_length") + self.source = self._get("source") + self.genre_id = self._int("genre_id") + self.language_id = self._int("language_id") + self.title = self._get("title") + self.total_length = self._float("total_length") + self.version = self._get("version") + self.beatmap_hash = self._get("file_md5") + self.mode = self._int("mode") + self.tags = self._get("tags") + self.favourite_count = self._int("favourite_count") + self.rating = self._float("rating") + self.playcount = self._int("playcount") + self.passcount = self._int("passcount") + self.count_hitcircles = self._int("count_normal") + self.count_sliders = self._int("count_slider") + self.count_spinners = self._int("count_spinner") + self.max_combo = self._int("max_combo") + self.storyboard = self._bool("storyboard") + self.video = self._bool("video") + self.download_unavailable = self._bool("download_unavailable") + self.audio_unavailable = self._bool("audio_unavailable") + +class User(Model): + def __init__(self, data): + super().__init__(data) + + self.user_id = self._int("user_id") + self.username = self._get("username") + self.join_date = self._date("join_date") + self.count_300 = self._int("count300") + self.count_100 = self._int("count100") + self.count_50 = self._int("count50") + self.playcount = self._int("playcount") + self.ranked_score = self._int("ranked_score") + self.total_score = self._int("total_score") + self.rank = self._int("pp_rank") + self.level = self._float("level") + self.pp_raw = self._float("pp_raw") + self.accuracy = self._float("accuracy") + self.count_rank_ss = self._int("count_rank_ss") + self.count_rank_ssh = self._int("count_rank_ssh") + self.count_rank_s = self._int("count_rank_s") + self.count_rank_sh = self._int("count_rank_sh") + self.count_rank_a = self._int("count_rank_a") + self.country = self._get("country") + self.seconds_played = self._int("total_seconds_played") + self.country_rank = self._int("pp_country_rank") + + self.events = [] + for event in data["events"]: + event = Event(event) + self.events.append(event) + +class Event(Model): + def __init__(self, data): + super().__init__(data) + + self.display_html = self._get("display_html") + self.beatmap_id = self._int("beatmap_id") + self.beatmapset_id = self._int("beatmapset_id") + self.date = self._date("date") + self.epic_factor = self._int("epicfactor") + +class Score(Model): + def __init__(self, data): + super().__init__(data) + + self.beatmap_id = self._int("beatmap_id") + self.replay_id = self._int("score_id") + self.score = self._int("score") + self.username = self._get("username", optional=True) + self.count_300 = self._int("count300") + self.count_100 = self._int("count100") + self.count_50 = self._int("count50") + self.count_miss = self._int("countmiss") + self.max_combo = self._int("maxcombo") + self.count_katu = self._int("countkatu") + self.count_geki = self._int("countgeki") + self.perfect = self._bool("perfect") + self.mods = self._mod("enabled_mods") + self.user_id = self._int("user_id") + self.date = self._date("date") + self.rank = self._get("rank") + # get_user_recent doesn't provide pp or replay_available at all + self.pp = self._float("pp") if "pp" in data else None + self.replay_available = (self._bool("replay_available") if + "replay_available" in data else None) + +class Replay(Model): + def __init__(self, data): + super().__init__(data) + self.content = self._get("content") + + +class MatchInfo(Model): + def __init__(self, data): + super().__init__(data) + + self.match = Match(data["match"]) + + self.games = [] + for game in data["games"]: + game = MatchGame(game) + self.games.append(game) + + +class Match(Model): + def __init__(self, data): + super().__init__(data) + + self.match_id = self._int("match_id") + self.name = self._get("name") + self.start_time = self._date("start_time") + self.end_time = self._date("end_time") + +class MatchGame(Model): + def __init__(self, data): + super().__init__(data) + + self.game_id = self._int("game_id") + self.start_time = self._date("start_time") + self.end_time = self._date("end_time") + self.beatmap_id = self._int("beatmap_id") + self.play_mode = self._int("play_mode") + self.match_type = self._int("match_type") + self.scoring_type = self._int("scoring_type") + self.team_type = self._int("team_type") + self.mods = self._mod("mods") + + self.scores = [] + for score in data["scores"]: + score = MatchScore(score) + self.scores.append(score) + + +class MatchScore(Model): + def __init__(self, data): + super().__init__(data) + + self.slot = self._int("slot") + self.team = self._int("team") + self.user_id = self._int("user_id") + self.score = self._int("score") + self.max_combo = self._int("maxcombo") + self.rank = self._int("rank") + self.count_300 = self._int("count300") + self.count_100 = self._int("count100") + self.count_50 = self._int("count50") + self.count_miss = self._int("countmiss") + self.max_combo = self._int("maxcombo") + self.count_katu = self._int("countkatu") + self.count_geki = self._int("countgeki") + self.perfect = self._bool("perfect") + self.passed = self._bool("pass") + self.mods = self._mod("enabled_mods") diff --git a/ossapi/ossapiv2.py b/ossapi/ossapiv2.py new file mode 100644 index 00000000..75f034ad --- /dev/null +++ b/ossapi/ossapiv2.py @@ -0,0 +1,1317 @@ +from typing import Union, TypeVar, Optional, List, _GenericAlias +import logging +import webbrowser +import socket +import pickle +from pathlib import Path +from datetime import datetime +from enum import Enum +from urllib.parse import unquote +import inspect +import json +import hashlib +import functools + +from requests_oauthlib import OAuth2Session +from oauthlib.oauth2 import (BackendApplicationClient, TokenExpiredError, + AccessDeniedError) +from oauthlib.oauth2.rfc6749.errors import InsufficientScopeError +import osrparse +from typing_utils import issubtype, get_type_hints, get_origin, get_args + +from ossapi.models import (Beatmap, BeatmapCompact, BeatmapUserScore, + ForumTopicAndPosts, Search, CommentBundle, Cursor, Score, + BeatmapsetSearchResult, ModdingHistoryEventsBundle, User, Rankings, + BeatmapScores, KudosuHistory, Beatmapset, BeatmapPlaycount, Spotlight, + Spotlights, WikiPage, _Event, Event, BeatmapsetDiscussionPosts, Build, + ChangelogListing, MultiplayerScores, MultiplayerScoresCursor, + BeatmapsetDiscussionVotes, CreatePMResponse, BeatmapsetDiscussions, + UserCompact, NewsListing, NewsPost, SeasonalBackgrounds, BeatmapsetCompact, + BeatmapUserScores, DifficultyAttributes) +from ossapi.enums import (GameMode, ScoreType, RankingFilter, RankingType, + UserBeatmapType, BeatmapDiscussionPostSort, UserLookupKey, + BeatmapsetEventType, CommentableType, CommentSort, ForumTopicSort, + SearchMode, MultiplayerScoresSort, BeatmapsetDiscussionVote, + BeatmapsetDiscussionVoteSort, BeatmapsetStatus, MessageType) +from ossapi.utils import (is_compatible_type, is_primitive_type, is_optional, + is_base_model_type, is_model_type, is_high_model_type, Field) +from ossapi.mod import Mod +from ossapi.replay import Replay + +# our ``request`` function below relies on the ordering of these types. The +# base type must come first, with any auxiliary types that the base type accepts +# coming after. +# These types are intended to provide better type hinting for consumers. We +# want to support the ability to pass ``"osu"`` instead of ``GameMode.STD``, +# for instance. We automatically convert any value to its base class if the +# relevant parameter has a type hint of the form below (see ``request`` for +# details). +GameModeT = Union[GameMode, str] +ScoreTypeT = Union[ScoreType, str] +ModT = Union[Mod, str, int, list] +RankingFilterT = Union[RankingFilter, str] +RankingTypeT = Union[RankingType, str] +UserBeatmapTypeT = Union[UserBeatmapType, str] +BeatmapDiscussionPostSortT = Union[BeatmapDiscussionPostSort, str] +UserLookupKeyT = Union[UserLookupKey, str] +BeatmapsetEventTypeT = Union[BeatmapsetEventType, str] +CommentableTypeT = Union[CommentableType, str] +CommentSortT = Union[CommentSort, str] +ForumTopicSortT = Union[ForumTopicSort, str] +SearchModeT = Union[SearchMode, str] +MultiplayerScoresSortT = Union[MultiplayerScoresSort, str] +BeatmapsetDiscussionVoteT = Union[BeatmapsetDiscussionVote, int] +BeatmapsetDiscussionVoteSortT = Union[BeatmapsetDiscussionVoteSort, str] +MessageTypeT = Union[MessageType, str] +BeatmapsetStatusT = Union[BeatmapsetStatus, str] + +BeatmapIdT = Union[int, BeatmapCompact] +UserIdT = Union[int, UserCompact] +BeatmapsetIdT = Union[int, BeatmapCompact, BeatmapsetCompact] + +def request(scope, *, requires_login=False): + """ + Handles various validation and preparation tasks for any endpoint request + method. + + This method does the following things: + * makes sure the client has the requuired scope to access the endpoint in + question + * makes sure the client has the right grant to access the endpoint in + question (the client credentials grant cannot access endpoints which + require the user to be "logged in", such as downloading a replay) + * converts parameters to an instance of a base model if the parameter is + annotated as being a base model. This means, for instance, that a function + with an argument annotated as ``ModT`` (``Union[Mod, str, int, list]``) + will have the value of that parameter automatically converted to a + ``Mod``, even if the user passes a `str`. + * converts arguments of type ``BeatmapIdT`` or ``UserIdT`` into a beatmap or + user id, if the passed argument was a ``BeatmapCompact`` or + ``UserCompact`` respectively. + + Parameters + ---------- + scope: Scope + The scope required for this endpoint. If ``None``, no scope is required + and any authenticated cliient can access it. + requires_login: bool + Whether this endpoint requires a "logged-in" client to retrieve it. + Currently, only authtorization code grants can access these endpoints. + """ + def decorator(function): + instantiate = {} + for name, type_ in function.__annotations__.items(): + origin = get_origin(type_) + args = get_args(type_) + if origin is Union and is_base_model_type(args[0]): + instantiate[name] = type_ + + arg_names = list(inspect.signature(function).parameters) + + @functools.wraps(function) + def wrapper(*args, **kwargs): + self = args[0] + if scope is not None and scope not in self.scopes: + raise InsufficientScopeError(f"A scope of {scope} is required " + "for this endpoint. Your client's current scopes are " + f"{self.scopes}") + + if requires_login and self.grant is Grant.CLIENT_CREDENTIALS: + raise AccessDeniedError("To access this endpoint you must be " + "authorized using the authorization code grant. You are " + "currently authorized with the client credentials grant") + + # we may need to edit this later so convert from tuple + args = list(args) + + def id_from_id_type(arg_name, arg): + annotations = function.__annotations__ + if arg_name not in annotations: + return None + arg_type = annotations[arg_name] + + if issubtype(BeatmapsetIdT, arg_type): + if isinstance(arg, BeatmapCompact): + return arg.beatmapset_id + if isinstance(arg, BeatmapsetCompact): + return arg.id + elif issubtype(BeatmapIdT, arg_type): + if isinstance(arg, BeatmapCompact): + return arg.id + elif issubtype(UserIdT, arg_type): + if isinstance(arg, UserCompact): + return arg.id + + # args and kwargs are handled separately, but in a similar fashion. + # The difference is that for ``args`` we need to know the name of + # the argument so we can look up its type hint and see if it's a + # parameter we need to convert. + + for i, (arg_name, arg) in enumerate(zip(arg_names, args)): + if arg_name in instantiate: + type_ = instantiate[arg_name] + # allow users to pass None for optional args. Without this + # we would try to instantiate types like `GameMode(None)` + # which would error. + if is_optional(type_) and arg is None: + continue + type_ = get_args(type_)[0] + args[i] = type_(arg) + id_ = id_from_id_type(arg_name, arg) + if id_: + args[i] = id_ + + for arg_name, arg in kwargs.items(): + if arg_name in instantiate: + type_ = instantiate[arg_name] + if is_optional(type_) and arg is None: + continue + type_ = get_args(type_)[0] + kwargs[arg_name] = type_(arg) + id_ = id_from_id_type(arg_name, arg) + if id_: + kwargs[arg_name] = id_ + + return function(*args, **kwargs) + return wrapper + return decorator + + +class Grant(Enum): + CLIENT_CREDENTIALS = "client" + AUTHORIZATION_CODE = "authorization" + +class Scope(Enum): + CHAT_WRITE = "chat.write" + DELEGATE = "delegate" + FORUM_WRITE = "forum.write" + FRIENDS_READ = "friends.read" + IDENTIFY = "identify" + PUBLIC = "public" + + +class OssapiV2: + """ + A wrapper around osu api v2. + + Parameters + ---------- + client_id: int + The id of the client to authenticate with. + client_secret: str + The secret of the client to authenticate with. + redirect_uri: str + The redirect uri for the client. Must be passed if using the + authorization code grant. This must exactly match the redirect uri on + the client's settings page. Additionally, in order for ossapi to receive + authentication from this redirect uri, it must be a port on localhost. + So "http://localhost:3914/", "http://localhost:727/", etc are all valid + redirect uris. You can change your client's redirect uri from its + settings page. + scopes: List[str] + What scopes to request when authenticating. + grant: Grant or str + Which oauth grant (aka flow) to use when authenticating with the api. + Currently the api offers the client credentials (pass "client" for this + parameter) and authorization code (pass "authorization" for this + parameter) grants. + |br| + The authorization code grant requires user interaction to authenticate + the first time, but grants full access to the api. In contrast, the + client credentials grant does not require user interaction to + authenticate, but only grants guest user access to the api. This means + you will not be able to do things like download replays on the client + credentials grant. + |br| + If not passed, the grant will be automatically inferred as follows: if + ``redirect_uri`` is passed, use the authorization code grant. If + ``redirect_uri`` is not passed, use the client credentials grant. + strict: bool + Whether to run in "strict" mode. In strict mode, ossapi will raise an + exception if the api returns an attribute in a response which we didn't + expect to be there. This is useful for developers which want to catch + new attributes as they get added. More checks may be added in the future + for things which developers may want to be aware of, but normal users do + not want to have an exception raised for. + |br| + If you are not a developer, you are very unlikely to want to use this + parameter. + token_directory: str + If passed, the given directory will be used to store and retrieve token + files instead of locally wherever ossapi is installed. Useful if you + want more control over token files. + token_key: str + If passed, the given key will be used to name the token file instead of + an automatically generated one. Note that if you pass this, you are + taking responsibility for making sure it is unique / unused, and also + for remembering the key you passed if you wish to eg remove the token in + the future, which requires the key. + """ + TOKEN_URL = "https://osu.ppy.sh/oauth/token" + AUTH_CODE_URL = "https://osu.ppy.sh/oauth/authorize" + BASE_URL = "https://osu.ppy.sh/api/v2" + + def __init__(self, + client_id: int, + client_secret: str, + redirect_uri: Optional[str] = None, + scopes: List[Union[str, Scope]] = [Scope.PUBLIC], + *, + grant: Optional[Union[Grant, str]] = None, + strict: bool = False, + token_directory: Optional[str] = None, + token_key: Optional[str] = None, + ): + if not grant: + grant = (Grant.AUTHORIZATION_CODE if redirect_uri else + Grant.CLIENT_CREDENTIALS) + grant = Grant(grant) + + self.grant = grant + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.scopes = [Scope(scope) for scope in scopes] + self.strict = strict + + self.log = logging.getLogger(__name__) + self.token_key = token_key or self.gen_token_key(self.grant, + self.client_id, self.client_secret, self.scopes) + self.token_directory = ( + Path(token_directory) if token_directory else Path(__file__).parent + ) + self.token_file = self.token_directory / f"{self.token_key}.pickle" + + if self.grant is Grant.CLIENT_CREDENTIALS: + if self.scopes != [Scope.PUBLIC]: + raise ValueError(f"`scopes` must be ['public'] if the " + f"client credentials grant is used. Got {self.scopes}") + + if self.grant is Grant.AUTHORIZATION_CODE and not self.redirect_uri: + raise ValueError("`redirect_uri` must be passed if the " + "authorization code grant is used.") + + self.session = self.authenticate() + + @staticmethod + def gen_token_key(grant, client_id, client_secret, scopes): + """ + The unique key / hash for the given set of parameters. This is intended + to provide a way to allow multiple OssapiV2's to live at the same time, + by eg saving their tokens to different files based on their key. + + This function is also deterministic, to eg allow tokens to be reused if + OssapiV2 is instantiated twice with the same parameters. This avoids the + need to reauthenticate unless absolutely necessary. + """ + grant = Grant(grant) + scopes = [Scope(scope) for scope in scopes] + m = hashlib.sha256() + m.update(grant.value.encode("utf-8")) + m.update(str(client_id).encode("utf-8")) + m.update(client_secret.encode("utf-8")) + for scope in scopes: + m.update(scope.value.encode("utf-8")) + return m.hexdigest() + + @staticmethod + def remove_token(key, token_directory=None): + """ + Removes the token file associated with the given key. If + ``token_directory`` is passed, looks there for the token file instead of + locally in ossapi's install site. + + To determine the key associated with a given grant, client_id, + client_secret, and set of scopes, use ``gen_token_key``. + """ + token_directory = ( + Path(token_directory) if token_directory else Path(__file__).parent + ) + token_file = token_directory / f"{key}.pickle" + token_file.unlink() + + def authenticate(self): + """ + Returns a valid OAuth2Session, either from a saved token file associated + with this OssapiV2's parameters, or from a fresh authentication if no + such file exists. + """ + if self.token_file.exists(): + with open(self.token_file, "rb") as f: + token = pickle.load(f) + + if self.grant is Grant.CLIENT_CREDENTIALS: + return OAuth2Session(self.client_id, token=token) + + if self.grant is Grant.AUTHORIZATION_CODE: + auto_refresh_kwargs = { + "client_id": self.client_id, + "client_secret": self.client_secret + } + return OAuth2Session(self.client_id, token=token, + redirect_uri=self.redirect_uri, + auto_refresh_url=self.TOKEN_URL, + auto_refresh_kwargs=auto_refresh_kwargs, + token_updater=self._save_token, + scope=[scope.value for scope in self.scopes]) + + if self.grant is Grant.CLIENT_CREDENTIALS: + return self._new_client_grant(self.client_id, self.client_secret) + + return self._new_authorization_grant(self.client_id, self.client_secret, + self.redirect_uri, self.scopes) + + def _new_client_grant(self, client_id, client_secret): + """ + Authenticates with the api from scratch on the client grant. + """ + self.log.info("initializing client credentials grant") + client = BackendApplicationClient(client_id=client_id, scope=["public"]) + session = OAuth2Session(client=client) + token = session.fetch_token(token_url=self.TOKEN_URL, + client_id=client_id, client_secret=client_secret) + + self._save_token(token) + return session + + def _new_authorization_grant(self, client_id, client_secret, redirect_uri, + scopes): + """ + Authenticates with the api from scratch on the authorization code grant. + """ + self.log.info("initializing authorization code") + + auto_refresh_kwargs = { + "client_id": client_id, + "client_secret": client_secret + } + session = OAuth2Session(client_id, redirect_uri=redirect_uri, + auto_refresh_url=self.TOKEN_URL, + auto_refresh_kwargs=auto_refresh_kwargs, + token_updater=self._save_token, + scope=[scope.value for scope in scopes]) + + authorization_url, _state = ( + session.authorization_url(self.AUTH_CODE_URL) + ) + webbrowser.open(authorization_url) + + # open up a temporary socket so we can receive the GET request to the + # callback url + port = int(redirect_uri.rsplit(":", 1)[1].split("/")[0]) + serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + serversocket.bind(("localhost", port)) + serversocket.listen(1) + connection, _ = serversocket.accept() + # arbitrary "large enough" byte receive size + data = str(connection.recv(8192)) + connection.send(b"HTTP/1.0 200 OK\n") + connection.send(b"Content-Type: text/html\n") + connection.send(b"\n") + connection.send(b""" +

Ossapi has received your authentication.

You + may now close this tab safely. + + """) + connection.close() + serversocket.close() + + code = data.split("code=")[1].split("&state=")[0] + token = session.fetch_token(self.TOKEN_URL, client_id=client_id, + client_secret=client_secret, code=code) + self._save_token(token) + + return session + + def _save_token(self, token): + """ + Saves the token to this OssapiV2's associated token file. + """ + self.log.info(f"saving token to {self.token_file}") + with open(self.token_file, "wb+") as f: + pickle.dump(token, f) + + def _request(self, type_, method, url, params={}, data={}): + params = self._format_params(params) + # also format data for post requests + data = self._format_params(data) + try: + r = self.session.request(method, f"{self.BASE_URL}{url}", + params=params, data=data) + except TokenExpiredError: + # provide "auto refreshing" for client credentials grant. The client + # grant doesn't actually provide a refresh token, so we can't hook + # onto OAuth2Session's auto_refresh functionality like we do for the + # authorization code grant. But we can do something effectively + # equivalent: whenever we make a request with an expired client + # grant token, just request a new one. + if self.grant is not Grant.CLIENT_CREDENTIALS: + raise + self.session = self._new_client_grant(self.client_id, + self.client_secret) + # redo the request now that we have a valid token + r = self.session.request(method, f"{self.BASE_URL}{url}", + params=params, data=data) + + self.log.info(f"made {method} request to {r.request.url}") + json_ = r.json() + self.log.debug(f"received json: \n{json.dumps(json_, indent=4)}") + self._check_response(json_, url) + + return self._instantiate_type(type_, json_) + + def _check_response(self, json_, url): + # TODO this should just be ``if "error" in json``, but for some reason + # ``self.search_beatmaps`` always returns an error in the response... + # open an issue on osu-web? + if len(json_) == 1 and "error" in json_: + raise ValueError(f"api returned an error of `{json_['error']}` for " + f"a request to {unquote(url)}") + + def _get(self, type_, url, params={}): + return self._request(type_, "GET", url, params=params) + + def _post(self, type_, url, data={}): + return self._request(type_, "POST", url, data=data) + + def _format_params(self, params): + for key, value in params.copy().items(): + if isinstance(value, list): + # we need to pass multiple values for this key, so make its + # value a list https://stackoverflow.com/a/62042144 + params[f"{key}[]"] = [] + for v in value: + params[f"{key}[]"].append(self._format_value(v)) + del params[key] + elif isinstance(value, Cursor): + new_params = self._format_params(value.__dict__) + for k, v in new_params.items(): + params[f"cursor[{k}]"] = v + del params[key] + elif isinstance(value, Mod): + params[f"{key}[]"] = value.decompose() + del params[key] + else: + params[key] = self._format_value(value) + return params + + def _format_value(self, value): + if isinstance(value, datetime): + return 1000 * int(value.timestamp()) + if isinstance(value, Enum): + return value.value + return value + + def _resolve_annotations(self, obj): + """ + This is where the magic happens. Since python lacks a good + deserialization library, I've opted to use type annotations and type + annotations only to convert json to objects. A breakdown follows. + + Every endpoint defines a base object, let's say it's a ``Score``. We + first instantiate this object with the json we received. This is easy to + do because (almost) all of our objects are dataclasses, which means we + can pass the json as ``Score(**json)`` and since the names of our fields + coincide with the names of the api json keys, everything works. + + This populates all of the surface level members, but nested attributes + which are annotated as another dataclass object will still be dicts. So + we traverse down the tree of our base object's attributes (depth-first, + though I'm pretty sure BFS would work just as well), looking for any + attribute with a type annotation that we need to deal with. For + instance, ``Score`` has a ``beatmap`` attribute, which is annotated as + ``Optional[Beatmap]``. We ignore the optional annotation (since we're + looking at this attribute, we must have received data for it, so it's + nonnull) and then instantiate the ``beatmap`` attribute the same way + we instantiated the ``Score`` - with ``Beatmap(**json)``. Of course, the + variables will look different in the method (``type_(**value)``). + + Finally, when traversing the attribute tree, we also look for attributes + which aren't dataclasses, but we still need to convert. For instance, + any attribute with an annotation of ``datetime`` or ``Mod`` we convert + to a ``datetime`` and ``Mod`` object respectively. + + This code is arguably trying to be too smart for its own good, but I + think it's very elegant from the perspective of "just add a dataclass + that mirrors the api's objects and everything works". Will hopefully + make changing our dataclasses to account for breaking api changes in + the future trivial as well. + + And if I'm being honest, it was an excuse to learn the internals of + python's typing system. + """ + # we want to get the annotations of inherited members as well, which is + # why we pass ``type(obj)`` instead of just ``obj``, which would only + # return annotations for attributes defined in ``obj`` and not its + # inherited attributes. + annotations = get_type_hints(type(obj)) + override_annotations = obj.override_types() + annotations = {**annotations, **override_annotations} + self.log.debug(f"resolving annotations for type {type(obj)}") + for attr, value in obj.__dict__.items(): + # we use this attribute later if we encounter an attribute which + # has been instantiated generically, but we don't need to do + # anything with it now. + if attr == "__orig_class__": + continue + type_ = annotations[attr] + # when we instantiate types, we explicitly fill in optional + # attributes with ``None``. We want to skip these, but only if the + # attribute is actually annotated as optional, otherwise we would be + # skipping fields that are null which aren't supposed to be, and + # prevent that error from being caught. + if value is None and is_optional(type_): + continue + self.log.debug(f"resolving attribute {attr}") + + value = self._instantiate_type(type_, value, obj, attr_name=attr) + if not value: + continue + setattr(obj, attr, value) + self.log.debug(f"resolved annotations for type {type(obj)}") + return obj + + def _instantiate_type(self, type_, value, obj=None, attr_name=None): + # ``attr_name`` is purely for debugging, it's the name of the attribute + # being instantiated + origin = get_origin(type_) + args = get_args(type_) + + # if this type is an optional, "unwrap" it to get the true type. + # We don't care about the optional annotation in this context + # because if we got here that means we were passed a value for this + # attribute, so we know it's defined and not optional. + if is_optional(type_): + # leaving these assertions in to help me catch errors in my + # reasoning until I better understand python's typing. + assert len(args) == 2 + type_ = args[0] + origin = get_origin(type_) + args = get_args(type_) + + # validate that the values we're receiving are the types we expect them + # to be + def _check_primitive_type(): + # The osu api occasionally makes attributes optional, so allow null + # values even for non-optional fields if we're not in + # strict mode. + if not self.strict and value is None: + return + if not is_compatible_type(value, type_): + raise TypeError(f"expected type {type_} for value {value}, got " + f"type {type(value)}" + f" (for attribute: {attr_name})" if attr_name else "") + + if is_primitive_type(type_): + _check_primitive_type() + + if is_base_model_type(type_): + self.log.debug(f"instantiating base type {type_}") + return type_(value) + + if origin is list and (is_model_type(args[0]) or + isinstance(args[0], TypeVar)): + assert len(args) == 1 + # check if the list has been instantiated generically; if so, + # use the concrete type backing the generic type. + if isinstance(args[0], TypeVar): + # ``__orig_class__`` is how we can get the concrete type of + # a generic. See https://stackoverflow.com/a/60984681 and + # https://www.python.org/dev/peps/pep-0560/#mro-entries. + type_ = get_args(obj.__orig_class__)[0] + # otherwise, it's been instantiated with a concrete model type, + # so use that type. + else: + type_ = args[0] + new_value = [] + for entry in value: + if is_base_model_type(type_): + entry = type_(entry) + else: + entry = self._instantiate(type_, entry) + # if the list entry is a high (non-base) model type, we need to + # resolve it instead of just sticking it into the list, since + # its children might still be dicts and not model instances. + # We don't do this for base types because that type is the one + # responsible for resolving its own annotations or doing + # whatever else it needs to do, not us. + if is_high_model_type(type_): + entry = self._resolve_annotations(entry) + new_value.append(entry) + return new_value + + # either we ourself are a model type (eg ``Search``), or we are + # a special indexed type (eg ``type_ == SearchResult[UserCompact]``, + # ``origin == UserCompact``). In either case we want to instantiate + # ``type_``. + if not is_model_type(type_) and not is_model_type(origin): + return None + value = self._instantiate(type_, value) + # we need to resolve the annotations of any nested model types before we + # set the attribute. This recursion is well-defined because the base + # case is when ``value`` has no model types, which will always happen + # eventually. + return self._resolve_annotations(value) + + def _instantiate(self, type_, kwargs): + self.log.debug(f"instantiating type {type_}") + # we need a special case to handle when ``type_`` is a + # ``_GenericAlias``. I don't fully understand why this exception is + # necessary, and it's likely the result of some error on my part in our + # type handling code. Nevertheless, until I dig more deeply into it, + # we need to extract the type to use for the init signature and the type + # hints from a ``_GenericAlias`` if we see one, as standard methods + # won't work. + override_type = type_.override_class(kwargs) + type_ = override_type or type_ + signature_type = type_ + try: + type_hints = get_type_hints(type_) + except TypeError: + assert type(type_) is _GenericAlias # pylint: disable=unidiomatic-typecheck + + signature_type = get_origin(type_) + type_hints = get_type_hints(signature_type) + + field_names = {} + for name in type_hints: + # any inherited attributes will be present in the annotations + # (type_hints) but not actually an attribute of the type. Just skip + # them for now. TODO I'm pretty sure this is going to cause issues + # if we ever have a field on a model and then another model + # inheriting from it; the inheriting model won't have the field + # picked up here and the override name won't come into play. + # probably just traverse the mro? + if not hasattr(type_, name): + continue + value = getattr(type_, name) + if not isinstance(value, Field): + continue + if value.name: + field_names[value.name] = name + + # make a copy so we can modify while iterating + for key in list(kwargs): + value = kwargs.pop(key) + if key in field_names: + key = field_names[key] + kwargs[key] = value + + # if we've annotated a class with ``Optional[X]``, and the api response + # didn't return a value for that attribute, pass ``None`` for that + # attribute. + # This is so that we don't have to define a default value of ``None`` + # for each optional attribute of our models, since the default will + # always be ``None``. + for attribute, annotation in type_hints.items(): + if is_optional(annotation): + if attribute not in kwargs: + kwargs[attribute] = None + + # The osu api often adds new fields to various models, and these are not + # considered breaking changes. To make this a non-breaking change on our + # end as well, we ignore any unexpected parameters, unless + # ``self.strict`` is ``True``. This means that consumers using old + # ossapi versions (which aren't up to date with the latest parameters + # list) will have new fields silently ignored instead of erroring. + # This also means that consumers won't be able to benefit from new + # fields unless they upgrade, but this is a conscious decision on our + # part to keep things entirely statically typed. Otherwise we would be + # going the route of PRAW, which returns dynamic results for all api + # queries. I think a statically typed solution is better for the osu! + # api, which promises at least some level of stability in its api. + parameters = list(inspect.signature(signature_type.__init__).parameters) + kwargs_ = {} + + for k, v in kwargs.items(): + if k in parameters: + kwargs_[k] = v + else: + if self.strict: + raise TypeError(f"unexpected parameter `{k}` for type " + f"{type_}") + self.log.info(f"ignoring unexpected parameter `{k}` from " + f"api response for type {type_}") + + # every model gets a special ``_api`` parameter, which is the + # ``OssapiV2`` instance which loaded it (aka us). + kwargs_["_api"] = self + + try: + val = type_(**kwargs_) + except TypeError as e: + raise TypeError(f"type error while instantiating class {type_}: " + f"{str(e)}") from e + + return val + + + # ========= + # Endpoints + # ========= + + + # /beatmaps + # --------- + + @request(Scope.PUBLIC) + def beatmap_user_score(self, + beatmap_id: BeatmapIdT, + user_id: UserIdT, + mode: Optional[GameModeT] = None, + mods: Optional[ModT] = None + ) -> BeatmapUserScore: + """ + https://osu.ppy.sh/docs/index.html#get-a-user-beatmap-score + """ + params = {"mode": mode, "mods": mods} + return self._get(BeatmapUserScore, + f"/beatmaps/{beatmap_id}/scores/users/{user_id}", params) + + @request(Scope.PUBLIC) + def beatmap_user_scores(self, + beatmap_id: BeatmapIdT, + user_id: UserIdT, + mode: Optional[GameModeT] = None + ) -> List[BeatmapUserScore]: + """ + https://osu.ppy.sh/docs/index.html#get-a-user-beatmap-scores + """ + params = {"mode": mode} + scores = self._get(BeatmapUserScores, + f"/beatmaps/{beatmap_id}/scores/users/{user_id}/all", params) + return scores.scores + + @request(Scope.PUBLIC) + def beatmap_scores(self, + beatmap_id: BeatmapIdT, + mode: Optional[GameModeT] = None, + mods: Optional[ModT] = None, + type_: Optional[RankingTypeT] = None + ) -> BeatmapScores: + """ + https://osu.ppy.sh/docs/index.html#get-beatmap-scores + """ + params = {"mode": mode, "mods": mods, "type": type_} + return self._get(BeatmapScores, f"/beatmaps/{beatmap_id}/scores", + params) + + @request(Scope.PUBLIC) + def beatmap(self, + beatmap_id: Optional[BeatmapIdT] = None, + checksum: Optional[str] = None, + filename: Optional[str] = None, + ) -> Beatmap: + """ + combines https://osu.ppy.sh/docs/index.html#get-beatmap and + https://osu.ppy.sh/docs/index.html#lookup-beatmap + """ + if not (beatmap_id or checksum or filename): + raise ValueError("at least one of beatmap_id, checksum, or " + "filename must be passed") + params = {"checksum": checksum, "filename": filename, "id": beatmap_id} + return self._get(Beatmap, "/beatmaps/lookup", params) + + + # /beatmapsets + # ------------ + + @request(Scope.PUBLIC) + def beatmapset_discussion_posts(self, + beatmapset_discussion_id: Optional[int] = None, + limit: Optional[int] = None, + page: Optional[int] = None, + sort: Optional[BeatmapDiscussionPostSortT] = None, + user_id: Optional[UserIdT] = None, + with_deleted: Optional[bool] = None + ) -> BeatmapsetDiscussionPosts: + """ + https://osu.ppy.sh/docs/index.html#get-beatmapset-discussion-posts + """ + params = {"beatmapset_discussion_id": beatmapset_discussion_id, + "limit": limit, "page": page, "sort": sort, "user": user_id, + "with_deleted": with_deleted} + return self._get(BeatmapsetDiscussionPosts, + "/beatmapsets/discussions/posts", params) + + @request(Scope.PUBLIC) + def beatmapset_discussion_votes(self, + beatmapset_discussion_id: Optional[int] = None, + limit: Optional[int] = None, + page: Optional[int] = None, + receiver_id: Optional[int] = None, + vote: Optional[BeatmapsetDiscussionVoteT] = None, + sort: Optional[BeatmapsetDiscussionVoteSortT] = None, + user_id: Optional[UserIdT] = None, + with_deleted: Optional[bool] = None + ) -> BeatmapsetDiscussionVotes: + """ + https://osu.ppy.sh/docs/index.html#get-beatmapset-discussion-votes + """ + params = {"beatmapset_discussion_id": beatmapset_discussion_id, + "limit": limit, "page": page, "receiver": receiver_id, + "score": vote, "sort": sort, "user": user_id, + "with_deleted": with_deleted} + return self._get(BeatmapsetDiscussionVotes, + "/beatmapsets/discussions/votes", params) + + @request(Scope.PUBLIC) + def beatmapset_discussions(self, + beatmapset_id: Optional[int] = None, + beatmap_id: Optional[BeatmapIdT] = None, + beatmapset_status: Optional[BeatmapsetStatusT] = None, + limit: Optional[int] = None, + message_types: Optional[List[MessageTypeT]] = None, + only_unresolved: Optional[bool] = None, + page: Optional[int] = None, + sort: Optional[BeatmapDiscussionPostSortT] = None, + user_id: Optional[UserIdT] = None, + with_deleted: Optional[bool] = None, + ) -> BeatmapsetDiscussions: + """ + https://osu.ppy.sh/docs/index.html#get-beatmapset-discussions + """ + params = {"beatmapset_id": beatmapset_id, "beatmap_id": beatmap_id, + "beatmapset_status": beatmapset_status, "limit": limit, + "message_types": message_types, "only_unresolved": only_unresolved, + "page": page, "sort": sort, "user": user_id, + "with_deleted": with_deleted} + return self._get(BeatmapsetDiscussions, + "/beatmapsets/discussions", params) + + @request(Scope.PUBLIC) + def beatmap_attributes(self, + beatmap_id: int, + mods: Optional[ModT] = None, + ruleset: Optional[GameModeT] = None, + ruleset_id: Optional[int] = None + ) -> DifficultyAttributes: + """ + https://osu.ppy.sh/docs/index.html#get-beatmap-attributes + """ + data = {"mods": mods, "ruleset": ruleset, "ruleset_id": ruleset_id} + return self._post(DifficultyAttributes, + f"/beatmaps/{beatmap_id}/attributes", data=data) + + + # /changelog + # ---------- + + @request(scope=None) + def changelog_build(self, + stream: str, + build: str + ) -> Build: + """ + https://osu.ppy.sh/docs/index.html#get-changelog-build + """ + return self._get(Build, f"/changelog/{stream}/{build}") + + @request(scope=None) + def changelog_listing(self, + from_: Optional[str] = None, + to: Optional[str] = None, + max_id: Optional[int] = None, + stream: Optional[str] = None + ) -> ChangelogListing: + """ + https://osu.ppy.sh/docs/index.html#get-changelog-listing + """ + params = {"from": from_, "to": to, "max_id": max_id, "stream": stream} + return self._get(ChangelogListing, "/changelog", params) + + @request(scope=None) + def changelog_lookup(self, + changelog: str, + key: Optional[str] = None + ) -> Build: + """ + https://osu.ppy.sh/docs/index.html#lookup-changelog-build + """ + params = {"key": key} + return self._get(Build, f"/changelog/{changelog}", params) + + + # /chat + # ----- + + @request(Scope.CHAT_WRITE) + def create_pm(self, + user_id: UserIdT, + message: str, + is_action: Optional[bool] = False + ) -> CreatePMResponse: + """ + https://osu.ppy.sh/docs/index.html#create-new-pm + """ + data = {"target_id": user_id, "message": message, + "is_action": is_action} + return self._post(CreatePMResponse, "/chat/new", data=data) + + + # /comments + # --------- + + @request(Scope.PUBLIC) + def comments(self, + commentable_type: Optional[CommentableTypeT] = None, + commentable_id: Optional[int] = None, + cursor: Optional[Cursor] = None, + parent_id: Optional[int] = None, + sort: Optional[CommentSortT] = None + ) -> CommentBundle: + """ + A list of comments and their replies, up to 2 levels deep. + + https://osu.ppy.sh/docs/index.html#get-comments + + Notes + ----- + ``pinned_comments`` is only included when ``commentable_type`` and + ``commentable_id`` are specified. + """ + params = {"commentable_type": commentable_type, + "commentable_id": commentable_id, "cursor": cursor, + "parent_id": parent_id, "sort": sort} + return self._get(CommentBundle, "/comments", params) + + @request(scope=None) + def comment(self, + comment_id: int + ) -> CommentBundle: + """ + https://osu.ppy.sh/docs/index.html#get-a-comment + """ + return self._get(CommentBundle, f"/comments/{comment_id}") + + + # /forums + # ------- + + @request(Scope.PUBLIC) + def forum_topic(self, + topic_id: int, + cursor: Optional[Cursor] = None, + sort: Optional[ForumTopicSortT] = None, + limit: Optional[int] = None, + start: Optional[int] = None, + end: Optional[int] = None + ) -> ForumTopicAndPosts: + """ + A topic and its posts. + + https://osu.ppy.sh/docs/index.html#get-topic-and-posts + """ + params = {"cursor": cursor, "sort": sort, "limit": limit, + "start": start, "end": end} + return self._get(ForumTopicAndPosts, f"/forums/topics/{topic_id}", + params) + + + # / ("home") + # ---------- + + @request(Scope.PUBLIC) + def search(self, + mode: Optional[SearchModeT] = None, + query: Optional[str] = None, + page: Optional[int] = None + ) -> Search: + """ + https://osu.ppy.sh/docs/index.html#search + """ + params = {"mode": mode, "query": query, "page": page} + return self._get(Search, "/search", params) + + + # /me + # --- + + @request(Scope.IDENTIFY) + def get_me(self, + mode: Optional[GameModeT] = None + ): + """ + https://osu.ppy.sh/docs/index.html#get-own-data + """ + return self._get(User, f"/me/{mode.value if mode else ''}") + + + # /news + # ----- + + @request(scope=None) + def news_listing(self, + limit: Optional[int] = None, + year: Optional[int] = None, + cursor: Optional[Cursor] = None + ) -> NewsListing: + """ + https://osu.ppy.sh/docs/index.html#get-news-listing + """ + params = {"limit": limit, "year": year, "cursor": cursor} + return self._get(NewsListing, "/news", params=params) + + @request(scope=None) + def news_post(self, + news: str, + key: Optional[str] = None + ) -> NewsPost: + """ + https://osu.ppy.sh/docs/index.html#get-news-post + """ + params = {"key": key} + return self._get(NewsPost, f"/news/{news}", params=params) + + + # /rankings + # --------- + + @request(Scope.PUBLIC) + def ranking(self, + mode: GameModeT, + type_: RankingTypeT, + country: Optional[str] = None, + cursor: Optional[Cursor] = None, + filter_: RankingFilterT = RankingFilter.ALL, + spotlight: Optional[int] = None, + variant: Optional[str] = None + ) -> Rankings: + """ + https://osu.ppy.sh/docs/index.html#get-ranking + """ + params = {"country": country, "cursor": cursor, "filter": filter_, + "spotlight": spotlight, "variant": variant} + return self._get(Rankings, f"/rankings/{mode.value}/{type_.value}", + params=params) + + @request(Scope.PUBLIC) + def spotlights(self) -> List[Spotlight]: + """ + https://osu.ppy.sh/docs/index.html#get-spotlights + """ + spotlights = self._get(Spotlights, "/spotlights") + return spotlights.spotlights + + + # /rooms + # ------ + + # TODO add test for this once I figure out values for room_id and + # playlist_id that actually produce a response lol + @request(Scope.PUBLIC) + def multiplayer_scores(self, + room_id: int, + playlist_id: int, + limit: Optional[int] = None, + sort: Optional[MultiplayerScoresSortT] = None, + cursor: Optional[MultiplayerScoresCursor] = None + ) -> MultiplayerScores: + """ + https://osu.ppy.sh/docs/index.html#get-scores + """ + params = {"limit": limit, "sort": sort, "cursor": cursor} + return self._get(MultiplayerScores, + f"/rooms/{room_id}/playlist/{playlist_id}/scores", params=params) + + + # /users + # ------ + + @request(Scope.PUBLIC) + def user_kudosu(self, + user_id: UserIdT, + limit: Optional[int] = None, + offset: Optional[int] = None + ) -> List[KudosuHistory]: + """ + https://osu.ppy.sh/docs/index.html#get-user-kudosu + """ + params = {"limit": limit, "offset": offset} + return self._get(List[KudosuHistory], f"/users/{user_id}/kudosu", + params) + + @request(Scope.PUBLIC) + def user_scores(self, + user_id: UserIdT, + type_: ScoreTypeT, + include_fails: Optional[bool] = None, + mode: Optional[GameModeT] = None, + limit: Optional[int] = None, + offset: Optional[int] = None + ) -> List[Score]: + """ + https://osu.ppy.sh/docs/index.html#get-user-scores + """ + # `include_fails` is actually a string in the api spec. We'll still + # require a bool to be passed, and just do the conversion behind the + # scenes. + if include_fails is False: + include_fails = 0 + if include_fails is True: + include_fails = 1 + + params = {"include_fails": include_fails, "mode": mode, "limit": limit, + "offset": offset} + return self._get(List[Score], f"/users/{user_id}/scores/{type_.value}", + params) + + @request(Scope.PUBLIC) + def user_beatmaps(self, + user_id: UserIdT, + type_: UserBeatmapTypeT, + limit: Optional[int] = None, + offset: Optional[int] = None + ) -> Union[List[Beatmapset], List[BeatmapPlaycount]]: + """ + https://osu.ppy.sh/docs/index.html#get-user-beatmaps + """ + params = {"limit": limit, "offset": offset} + + return_type = List[Beatmapset] + if type_ is UserBeatmapType.MOST_PLAYED: + return_type = List[BeatmapPlaycount] + + return self._get(return_type, + f"/users/{user_id}/beatmapsets/{type_.value}", params) + + @request(Scope.PUBLIC) + def user_recent_activity(self, + user_id: UserIdT, + limit: Optional[int] = None, + offset: Optional[int] = None + ) -> List[Event]: + """ + https://osu.ppy.sh/docs/index.html#get-user-recent-activity + """ + params = {"limit": limit, "offset": offset} + return self._get(List[_Event], f"/users/{user_id}/recent_activity/", + params) + + @request(Scope.PUBLIC) + def user(self, + user: Union[UserIdT, str], + mode: Optional[GameModeT] = None, + key: Optional[UserLookupKeyT] = None + ) -> User: + """ + https://osu.ppy.sh/docs/index.html#get-user + """ + params = {"key": key} + return self._get(User, f"/users/{user}/{mode.value if mode else ''}", + params) + + + # /wiki + # ----- + + @request(scope=None) + def wiki_page(self, + locale: str, + path: str + ) -> WikiPage: + """ + https://osu.ppy.sh/docs/index.html#get-wiki-page + """ + return self._get(WikiPage, f"/wiki/{locale}/{path}") + + + # undocumented + # ------------ + + @request(Scope.PUBLIC) + def score(self, + mode: GameModeT, + score_id: int + ) -> Score: + return self._get(Score, f"/scores/{mode.value}/{score_id}") + + @request(Scope.PUBLIC, requires_login=True) + def download_score(self, + mode: GameModeT, + score_id: int, + *, + raw: bool = False + ) -> Replay: + url = f"{self.BASE_URL}/scores/{mode.value}/{score_id}/download" + r = self.session.get(url) + + # if the response above succeeded, it will return a raw string + # instead of json. If it didn't succeed, it will return json with an + # error. + # So always try parsing as json to check if there's an error. If parsin + # fails, just assume the request succeeded and move on. + try: + json_ = r.json() + self._check_response(json_, url) + except json.JSONDecodeError: + pass + + if raw: + return r.content + + replay = osrparse.Replay.from_string(r.content) + return Replay(replay, self) + + @request(Scope.PUBLIC) + def search_beatmapsets(self, + query: Optional[str] = None, + cursor: Optional[Cursor] = None + ) -> BeatmapsetSearchResult: + # Param key names are the same as https://osu.ppy.sh/beatmapsets, + # so from eg https://osu.ppy.sh/beatmapsets?q=black&s=any we get that + # the query uses ``q`` and the category uses ``s``. + # TODO implement all possible queries, or wait for them to be + # documented. Currently we only implement the most basic "query" option. + params = {"cursor": cursor, "q": query} + return self._get(BeatmapsetSearchResult, "/beatmapsets/search/", params) + + @request(Scope.PUBLIC) + def beatmapset(self, + beatmapset_id: Optional[BeatmapsetIdT] = None, + beatmap_id: Optional[BeatmapIdT] = None + ) -> Beatmapset: + """ + Combines https://osu.ppy.sh/docs/index.html#beatmapsetslookup and + https://osu.ppy.sh/docs/index.html#beatmapsetsbeatmapset. + """ + if not bool(beatmap_id) ^ bool(beatmapset_id): + raise ValueError("exactly one of beatmap_id and beatmapset_id must " + "be passed.") + if beatmap_id: + params = {"beatmap_id": beatmap_id} + return self._get(Beatmapset, "/beatmapsets/lookup", params) + return self._get(Beatmapset, f"/beatmapsets/{beatmapset_id}") + + @request(Scope.PUBLIC) + def beatmapset_events(self, + limit: Optional[int] = None, + page: Optional[int] = None, + user_id: Optional[UserIdT] = None, + types: Optional[List[BeatmapsetEventTypeT]] = None, + min_date: Optional[datetime] = None, + max_date: Optional[datetime] = None + ) -> ModdingHistoryEventsBundle: + """ + https://osu.ppy.sh/beatmapsets/events + """ + # limit is 5-50 + params = {"limit": limit, "page": page, "user": user_id, + "min_date": min_date, "max_date": max_date, "types": types} + return self._get(ModdingHistoryEventsBundle, "/beatmapsets/events", + params) + + @request(Scope.FRIENDS_READ) + def friends(self) -> List[UserCompact]: + return self._get(List[UserCompact], "/friends") + + @request(scope=None) + def seasonal_backgrounds(self) -> SeasonalBackgrounds: + return self._get(SeasonalBackgrounds, "/seasonal-backgrounds") + + # /oauth + # ------ + + def revoke_token(self): + self.session.delete(f"{self.BASE_URL}/oauth/tokens/current") + self.remove_token(self.token_key, self.token_directory) diff --git a/ossapi/replay.py b/ossapi/replay.py new file mode 100644 index 00000000..bbd85981 --- /dev/null +++ b/ossapi/replay.py @@ -0,0 +1,78 @@ +from osrparse import GameMode as OsrparseGameMode + +from ossapi.models import GameMode, User, Beatmap +from ossapi.mod import Mod +from ossapi.enums import UserLookupKey + +game_mode_map = { + OsrparseGameMode.STD: GameMode.STD, + OsrparseGameMode.TAIKO: GameMode.TAIKO, + OsrparseGameMode.CTB: GameMode.CTB, + OsrparseGameMode.MANIA: GameMode.MANIA, +} + +class Replay: + """ + A replay played by a player. + + Notes + ----- + This is a thin shim around an osrparse.Replay instance. It converts some + attributes to more appropriate types and adds #user and #beatmap to retrieve + api-related objects. + """ + def __init__(self, replay, api): + self._api = api + self.mode = game_mode_map[replay.mode] + self.game_version = replay.game_version + self.beatmap_hash = replay.beatmap_hash + self.username = replay.username + self.replay_hash = replay.replay_hash + self.count_300 = replay.count_300 + self.count_100 = replay.count_100 + self.count_50 = replay.count_50 + self.count_geki = replay.count_geki + self.count_katu = replay.count_katu + self.count_miss = replay.count_miss + self.score = replay.score + self.max_combo = replay.max_combo + self.perfect = replay.perfect + self.mods = Mod(replay.mods.value) + self.life_bar_graph = replay.life_bar_graph + self.timestamp = replay.timestamp + self.replay_data = replay.replay_data + self.replay_id = replay.replay_id + self._beatmap = None + self._user = None + + @property + def beatmap(self) -> Beatmap: + """ + The beatmap this replay was played on. + + Warnings + -------- + Accessing this property for the first time will result in a web request + to retrieve the beatmap from the api. We cache the return value, so + further accesses are free. + """ + if self._beatmap: + return self._beatmap + self._beatmap = self._api.beatmap(checksum=self.beatmap_hash) + return self._beatmap + + @property + def user(self) -> User: + """ + The user that played this replay. + + Warnings + -------- + Accessing this property for the first time will result in a web request + to retrieve the user from the api. We cache the return value, so further + accesses are free. + """ + if self._user: + return self._user + self._user = self._api.user(self.username, key=UserLookupKey.USERNAME) + return self._user diff --git a/ossapi/utils.py b/ossapi/utils.py new file mode 100644 index 00000000..536f8cb7 --- /dev/null +++ b/ossapi/utils.py @@ -0,0 +1,215 @@ +from enum import EnumMeta, Enum, IntFlag +from datetime import datetime, timezone +from typing import Any +from dataclasses import dataclass + +from typing_utils import issubtype + +def is_high_model_type(type_): + """ + Whether ``type_`` is both a model type and not a base model type. + + "high" here is meant to indicate that it is not at the bottom of the model + hierarchy, ie not a "base" model. + """ + return is_model_type(type_) and not is_base_model_type(type_) + +def is_model_type(type_): + """ + Whether ``type_`` is a subclass of ``Model``. + """ + if not isinstance(type_, type): + return False + return issubclass(type_, Model) + +def is_base_model_type(type_): + """ + Whether ``type_`` is a subclass of ``BaseModel``. + """ + if not isinstance(type_, type): + return False + return issubclass(type_, BaseModel) + + +class Field: + def __init__(self, *, name=None): + self.name = name + + +class _Model: + """ + Base class for all models in ``ossapi``. If you want a model which handles + its own members and cleanup after instantion, subclass ``BaseModel`` + instead. + """ + def override_types(self): + """ + Sometimes, the types of attributes in models depends on the value of + other fields in that model. By overriding this method, models can return + "override types", which overrides the static annotation of attributes + and tells ossapi to use the returned type to instantiate the attribute + instead. + + This method should return a mapping of ``attribute_name`` to + ``intended_type``. + """ + return {} + + @classmethod + def override_class(cls, _data): + """ + This method addressess a shortcoming in ``override_types`` in order to + achieve full coverage of the intended feature of overriding types. + + The model that we want to override types for may be at the very top of + the hierarchy, meaning we can't go any higher and find a model for which + we can override ``override_types`` to customize this class' type. + + A possible solution for this is to create a wrapper class one step above + it; however, this is both dirty and may not work (I haven't actually + tried it). So this method provides a way for a model to override its + *own* type (ie class) at run-time. + """ + return None + +class ModelMeta(type): + def __new__(cls, name, bases, dct): + model = super().__new__(cls, name, bases, dct) + field_names = [] + for name, value in model.__dict__.items(): + if name.startswith("__") and name.endswith("__"): + continue + if isinstance(value, Field): + field_names.append(name) + + for name in model.__annotations__: + if name in field_names: + continue + setattr(model, name, None) + + return dataclass(model) + +class Model(_Model, metaclass=ModelMeta): + """ + A dataclass-style model. Provides an ``_api`` attribute. + """ + # This is the ``OssapiV2`` instance that loaded this model. + # can't annotate with OssapiV2 or we get a circular import error, this is + # good enough. + _api: Any + + def _foreign_key(self, fk, func, existing): + if existing: + return existing + if fk is None: + return None + return func() + + def _fk_user(self, user_id, existing=None): + func = lambda: self._api.user(user_id) + return self._foreign_key(user_id, func, existing) + + def _fk_beatmap(self, beatmap_id, existing=None): + func = lambda: self._api.beatmap(beatmap_id) + return self._foreign_key(beatmap_id, func, existing) + + def _fk_beatmapset(self, beatmapset_id, existing=None): + func = lambda: self._api.beatmapset(beatmapset_id) + return self._foreign_key(beatmapset_id, func, existing) + +class BaseModel(_Model): + """ + A model which promises to take care of its own members and cleanup, after we + instantiate it. + + Normally, for a high (non-base) model type, we recurse down its members to + look for more model types after we instantiate it. We also resolve + annotations for its members after instantion. None of that happens with a + base model; we hand off the model's data to it and do nothing more. + + A commonly used example of a base model type is an ``Enum``. Enums have + their own magic that takes care of cleaning the data upon instantiation + (taking a string and converting it into one of a finite set of enum members, + for instance). We don't need or want to do anything else with an enum after + instantiating it, hence it's defined as a base type. + """ + pass + +class EnumModel(BaseModel, Enum): + pass + +class IntFlagModel(BaseModel, IntFlag): + pass + + +class Datetime(datetime, BaseModel): + """ + Our replacement for the ``datetime`` object that deals with the various + datetime formats the api returns. + """ + def __new__(cls, value): # pylint: disable=signature-differs + if value is None: + raise ValueError("cannot instantiate a Datetime with a null value") + # the api returns a bunch of different timestamps: two ISO 8601 + # formats (eg "2018-09-11T08:45:49.000000Z" and + # "2014-05-18T17:22:23+00:00"), a unix timestamp (eg + # 1615385278000), and others. We handle each case below. + # Fully compliant ISO 8601 parsing is apparently a pain, and + # the proper way to do this would be to use a third party + # library, but I don't want to add any dependencies. This + # stopgap seems to work for now, but may break in the future if + # the api changes the timestamps they return. + # see https://stackoverflow.com/q/969285. + if value.isdigit(): + # see if it's an int first, if so it's a unix timestamp. The + # api returns the timestamp in milliseconds but + # ``datetime.fromtimestamp`` expects it in seconds, so + # divide by 1000 to convert. + value = int(value) / 1000 + return datetime.fromtimestamp(value, tz=timezone.utc) + if cls._matches_datetime(value, "%Y-%m-%dT%H:%M:%S.%f%z"): + return value + if cls._matches_datetime(value, "%Y-%m-%dT%H:%M:%S%z"): + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z") + if cls._matches_datetime(value, "%Y-%m-%d"): + return datetime.strptime(value, "%Y-%m-%d") + raise ValueError(f"invalid datetime string {value}") + + @staticmethod + def _matches_datetime(value, format_): + try: + _ = datetime.strptime(value, format_) + except ValueError: + return False + return True + + + +# typing utils +# ------------ + +def is_optional(type_): + """ + Whether ``type(None)`` is a valid instance of ``type_``. eg, + ``is_optional(Union[str, int, NoneType]) == True``. + + Exception: when ``type_`` is any, we return false. Strictly speaking, if + ``Any`` is a subtype of ``type_`` then we return false, since + ``Union[Any, str]`` is a valid type not equal to ``Any`` (in python), but + representing the same set of types. + """ + return issubtype(type(None), type_) and not issubtype(Any, type_) + +def is_primitive_type(type_): + if not isinstance(type_, type): + return False + return type_ in [int, float, str, bool] + +def is_compatible_type(value, type_): + # make an exception for an integer being instantiated as a float. In + # the json we receive, eg ``pp`` can have a value of ``15833``, which is + # interpreted as an int by our json parser even though ``pp`` is a + # float. + if type_ is float and isinstance(value, int): + return True + return isinstance(value, type_) diff --git a/ossapi/version.py b/ossapi/version.py new file mode 100644 index 00000000..54499df3 --- /dev/null +++ b/ossapi/version.py @@ -0,0 +1 @@ +__version__ = "2.4.1" From b679e3daee18ff06e1644454c46b589ba576f8c7 Mon Sep 17 00:00:00 2001 From: snipe <72265661+notsniped@users.noreply.github.com> Date: Tue, 2 May 2023 11:10:09 +0530 Subject: [PATCH 3/5] Fix indent level on `api` class instance --- cogs/osu.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cogs/osu.py b/cogs/osu.py index a5e6b490..83afc225 100644 --- a/cogs/osu.py +++ b/cogs/osu.py @@ -10,7 +10,8 @@ class Osu(commands.Cog): def __init__(self, bot): self.bot = bot - api = OssapiV2(13110, 'UDGR1XA2e406y163lRzzJgs4tQCvu94ehbkXU8w2') + + api = OssapiV2(13110, 'UDGR1XA2e406y163lRzzJgs4tQCvu94ehbkXU8w2') @commands.slash_command( name="osu_user", From a44ccb938d9e2953ac646fec1991d337f00abfd8 Mon Sep 17 00:00:00 2001 From: snipe <72265661+notsniped@users.noreply.github.com> Date: Tue, 2 May 2023 11:14:23 +0530 Subject: [PATCH 4/5] Add missing `self` parameters in commands --- cogs/osu.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cogs/osu.py b/cogs/osu.py index 83afc225..2341a667 100644 --- a/cogs/osu.py +++ b/cogs/osu.py @@ -18,7 +18,7 @@ def __init__(self, bot): description="View information on an osu! player." ) @option(name="user", description="The name of the user", type=str) - async def osu_user(ctx, *, user:str): + async def osu_user(self, ctx, *, user:str): try: compact_user = api.search(query=user).users.data[0] e = discord.Embed(title=f'osu! stats for {user}', color=0xff66aa) @@ -41,7 +41,7 @@ async def osu_user(ctx, *, user:str): description="View information on an osu! beatmap." ) @option(name="query", description="The beatmap's id", type=int) - async def osu_beatmap(ctx, *, query:int): + async def osu_beatmap(self, ctx, *, query:int): try: beatmap = api.beatmap(beatmap_id=query) e = discord.Embed(title=f'osu! beatmap info for {beatmap.expand()._beatmapset.title} ({beatmap.expand()._beatmapset.title_unicode})', color=0xff66aa) From 36af59ab2d43ef6b153d7f7b2aeb679637df49cc Mon Sep 17 00:00:00 2001 From: snipe <72265661+notsniped@users.noreply.github.com> Date: Tue, 2 May 2023 11:15:18 +0530 Subject: [PATCH 5/5] Move `api` to `__init__()` using `self` param --- cogs/osu.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cogs/osu.py b/cogs/osu.py index 2341a667..bc1a44b0 100644 --- a/cogs/osu.py +++ b/cogs/osu.py @@ -10,8 +10,7 @@ class Osu(commands.Cog): def __init__(self, bot): self.bot = bot - - api = OssapiV2(13110, 'UDGR1XA2e406y163lRzzJgs4tQCvu94ehbkXU8w2') + self.api = OssapiV2(13110, 'UDGR1XA2e406y163lRzzJgs4tQCvu94ehbkXU8w2') @commands.slash_command( name="osu_user", @@ -20,7 +19,7 @@ def __init__(self, bot): @option(name="user", description="The name of the user", type=str) async def osu_user(self, ctx, *, user:str): try: - compact_user = api.search(query=user).users.data[0] + compact_user = self.api.search(query=user).users.data[0] e = discord.Embed(title=f'osu! stats for {user}', color=0xff66aa) e.set_thumbnail(url='https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Osu%21_Logo_2016.svg/2048px-Osu%21_Logo_2016.svg.png') e.add_field(name='Rank (Global)', value=f'#{compact_user.expand().statistics.global_rank}') @@ -43,7 +42,7 @@ async def osu_user(self, ctx, *, user:str): @option(name="query", description="The beatmap's id", type=int) async def osu_beatmap(self, ctx, *, query:int): try: - beatmap = api.beatmap(beatmap_id=query) + beatmap = self.api.beatmap(beatmap_id=query) e = discord.Embed(title=f'osu! beatmap info for {beatmap.expand()._beatmapset.title} ({beatmap.expand()._beatmapset.title_unicode})', color=0xff66aa) e.set_thumbnail(url='https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Osu%21_Logo_2016.svg/2048px-Osu%21_Logo_2016.svg.png') #.beatmap.data[0]