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]