Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,17 @@ jobs:
- name: Test changes with full tests 1/3
run: |
bash run_tests.sh full --test-group-count 3 --test-group=1 --reruns 3 --reruns-delay 15
if: github.event.pull_request.base.ref == 'main'
if: ${{ always() && github.event.pull_request.base.ref == 'main'}}
continue-on-error: true
- name: Test changes with full tests 2/3
run: |
bash run_tests.sh full --test-group-count 3 --test-group=2 --reruns 3 --reruns-delay 15
if: github.event.pull_request.base.ref == 'main'
if: ${{ always() && github.event.pull_request.base.ref == 'main'}}
continue-on-error: true
- name: Test changes with full tests 3/3
run: |
bash run_tests.sh full --test-group-count 3 --test-group=3 --reruns 3 --reruns-delay 15
if: github.event.pull_request.base.ref == 'main'
if: ${{ always() && github.event.pull_request.base.ref == 'main'}}
continue-on-error: true
- name: Upload test results
uses: actions/upload-artifact@v4
Expand Down
18 changes: 18 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ The page attempt to keep a clear list of breaking/non-breaking changes and new f
:local:
:backlinks: none

v0.15.0
----------
Bug Fixes
###########
* Missing Rank information in page profiles no longer causes an exception during parsing
* Already following a page no longer causes an exception during parsing
*

New Features
##############
* Page name and url parsing is now more reliable
* More pages now have the `url` attribute
* Added `get_tags` to `Article`
* Added `get_tags` to `Job`
* New enum `PlatformCategory`
* New `Client` methods `Client.upload_addon`, `Client.get_mutable_addon`, `Client.edit_addon`


v0.14.0
-----------
Bug Fixes
Expand Down
26 changes: 26 additions & 0 deletions docs/source/mutables.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.. currentmodule:: moddb.mutables

Mutables
============
Mutables are version of Moddb models which you can change and then send back to
edit an entity on the website.

.. contents:: Table of Contents
:local:
:backlinks: none


MutableAddon
----
.. autoclass:: moddb.mutable.MutableAddon
:members:
:inherited-members:


MutableFile
----
.. autoclass:: moddb.mutable.MutableFile
:members:
:inherited-members:


3 changes: 3 additions & 0 deletions moddb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .base import front_page, login, logout, parse_page, parse_results, rss, search, search_tags
from .client import Client, TwoFactorAuthClient, Thread
from .enums import *
from .mutables import MutableAddon, MutableFile
from .pages import *
from .utils import BASE_URL, LOGGER, Object, get_page, request, soup

Expand Down Expand Up @@ -31,4 +32,6 @@
"get_page",
"request",
"soup",
"MutableAddon",
"MutableFile",
]
24 changes: 17 additions & 7 deletions moddb/boxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import collections
import datetime
import json
import logging
import re
import sys
Expand Down Expand Up @@ -118,8 +119,14 @@ def __init__(self, html: BeautifulSoup):
self.visits, self.today = get_views(visits)

rank = normalize(html.find("h5", string="Rank").parent.a.string).split("of")
self.rank = int(rank[0].replace(",", ""))
self.total = int(rank[1].replace(",", ""))

try:
self.rank = int(rank[0].replace(",", ""))
self.total = int(rank[1].replace(",", ""))
except ValueError:
LOGGER.info("No rank detected")
self.rank = 0
self.total = 0

try:
self.updated = get_date(html.find("time", itemprop="dateModified")["datetime"])
Expand Down Expand Up @@ -208,8 +215,7 @@ def __init__(self, html: BeautifulSoup):
"div", class_="table tablemenu"
)
self.contact = join(html.find("h5", string="Contact").parent.span.a["href"])

self.follow = join(html.find("a", title="Follow")["href"])
self.follow = join(html.find("a", title=("Follow", "Unfollow"))["href"])

try:
share = profile_raw.find("h5", string="Share").parent.span.find_all("a")
Expand Down Expand Up @@ -792,6 +798,8 @@ class MemberProfile:
-----------
name : str
Name of the member
url : str
Link to the member
level : int
Current level
progress : float
Expand All @@ -815,12 +823,14 @@ class MemberProfile:
"""

def __init__(self, html: BeautifulSoup):
breadcrumbs = json.loads(html.find("script", type="application/ld+json").string)
self.name = breadcrumbs["itemListElement"][-1]["Item"]["name"]
self.url = breadcrumbs["itemListElement"][-1]["Item"]["@id"]

profile_raw = html.find("span", string="Profile").parent.parent.parent.find(
"div", class_="table tablemenu"
)
level_raw = profile_raw.find("h5", string="Level").parent.span.div
self.name = html.find("meta", property="og:title")["content"]

self.level = int(level_raw.find("span", class_="level").string)
self.progress = float(
"0." + level_raw.find("span", class_="info").strong.string.replace("%", "")
Expand Down Expand Up @@ -863,7 +873,7 @@ def __init__(self, html: BeautifulSoup):
)

try:
self.follow = join(html.find("a", title="Follow")["href"])
self.follow = join(html.find("a", title=("Follow", "Unfollow"))["href"])
except TypeError:
LOGGER.info(
"Can't watch yourself, narcissist...", exc_info=LOGGER.level >= logging.DEBUG
Expand Down
158 changes: 156 additions & 2 deletions moddb/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
from .boxes import ResultList, Thumbnail, _parse_results
from .enums import Status, ThumbnailType
from .errors import ModdbException
from .pages import Member
from .mutables import MutableAddon
from .pages import Member, Addon
from .utils import (
BASE_URL,
COMMENT_LIMITER,
GLOBAL_LIMITER,
GLOBAL_THROTLE,
LOGGER,
HasUrl,
Object,
concat_docs,
create_login_payload,
generate_hash,
Expand Down Expand Up @@ -329,7 +332,12 @@ def _request(self, method, url, **kwargs):
}

req = requests.Request(
method, url, headers=headers, cookies=cookies, data=kwargs.pop("data", {})
method,
url,
headers=headers,
cookies=cookies,
data=kwargs.pop("data", {}),
files=kwargs.pop("files", {}),
)
prepped = self._session.prepare_request(req)
LOGGER.info("Request: %s", prepped.url)
Expand Down Expand Up @@ -1179,6 +1187,152 @@ def downvote_tag(self, tag: Tag) -> bool:
"""
return self._vote_tag(tag, 1)

def upload_addon(self, addon: MutableAddon) -> Addon:
"""Upload a new addon

Parameters
------------
addon: MutableAddon
The addon to upload

Returns
--------
Addon
The uploaded addon
"""
upload_url = join("/addons/add")
html = soup(self._request("GET", upload_url).text)

formhash = html.find("input", {"name": "formhash"})["value"]
file_name = self._upload_file(formhash, addon)

logo_file = {"logo": (addon.thumbnail.filename, addon.thumbnail.fp)}

data = {
"formhash": formhash,
"legacy": 0,
"platformstemp": 1,
"filedataUp": file_name,
"category": addon.category.value,
"licence": addon.licence.value,
"credit": addon.credits if addon.credits is not None else "",
"tags": ",".join(addon.tags),
"name": addon.name,
"summary": addon.summary,
"description": addon.description if addon.description is not None else "",
"links[]": [platform.value for platform in addon.platforms]
+ [f"{parent.name}|{parent.entity_type}s{parent.id}" for parent in addon.links],
"downloads": "Please wait uploading file",
}

resp = self._request("POST", upload_url, data=data, files=logo_file)
self._validate_post_response(resp.text)
addon.thumbnail.fp.close()

return Addon(soup(resp.text))

def get_mutable_addon(self, addon: Union[Addon, Object[HasUrl]]) -> MutableAddon:
"""Get the mutable version of an addon for editing purpose.

Parameters
-----------
addon: Union[Addon,Object[HasUrl]]
The addon or an object with an url attribute to retrieve

Returns
--------
MutableAddon
The mutable addon retrieved
"""
edit_url = f"{addon.url}/edit"
html = soup(self._request("GET", edit_url).text)

if not html.find("input", {"name": "formhash"}):
raise ModdbException("You do not have permission to edit the requested addon")

return MutableAddon._from_html(html)

def edit_addon(self, addon: MutableAddon):
"""Edit an existing addon. The MutableAddon passed to this
function should be retrieved through `Client.get_mutable_addon`

Parameters
-----------
addon: MutableAddon
The mutable addon to edit
"""
logo_file = {}
data = {
"formhash": addon._form_hash,
"legacy": 0,
"platformstemp": 1,
"category": addon.category.value,
"licence": addon.licence.value,
"credit": addon.credits if addon.credits is not None else "",
"tags": ",".join(addon.tags),
"name": addon.name,
"nameid": addon.name_id,
"summary": addon.summary,
"description": addon.description if addon.description is not None else "",
"links[]": [platform.value for platform in addon.platforms]
+ [f"{parent.name}|{parent.entity_type}s{parent.id}" for parent in addon.links],
"downloads": "Please wait uploading file",
}

if addon.file_file is not None or addon.file_url is not None:
file_name = self._upload_file(addon._form_hash, addon)
data["filedataUp"] = file_name

if addon.thumbnail is not None:
logo_file["logo"] = (addon.thumbnail.filename, addon.thumbnail.fp)

resp = self._request("POST", addon.url, data=data, files=logo_file)
self._validate_post_response(resp.text)

if addon.thumbnail is not None:
addon.thumbnail.fp.close()

def _upload_file(self, hash: str, addon: MutableAddon):
url = f"https://upload.moddb.com/downloads/ajax/upload/{hash}"
resp = None

if addon.file_file is not None:
resp = self._request(
"POST",
url,
data={"filename": addon.file_file.filename},
files={"filedata": addon.file_file.fp},
)
addon.file_file.fp.close()

if addon.file_url is not None:
resp = self._request("POST", url, json={"wget": "t", "filedataWget": addon.file_url})

if resp is not None:
error = resp.json()["error"]
if error:
raise ModdbException(
f"An error occurred while trying to upload the add-on: {error}"
)

return resp.json()["text"]

def _validate_post_response(self, html_str: str):
soup_obj = soup(html_str)
if soup_obj.find("a", id="downloadmirrorstoggle"):
return # Upload successful

# We are still on the upload form
error_tooltip = soup_obj.find("div", class_="tooltip errortooltip clear")
if error_tooltip:
if error_tooltip.ul:
error_list = error_tooltip.ul.find_all("li", recursive=False)
errors = "\n".join([f"- {error.text}" for error in error_list])
else:
# p-tag contains a space at the beginning and a new line at the end
errors = f"- {error_tooltip.p.text.strip()}"
raise ModdbException(f"Please correct the following: \n{errors}")


class TwoFactorAuthClient(Client):
"""A subclass of client to be used when facing 2FA requirements."""
Expand Down
38 changes: 38 additions & 0 deletions moddb/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,44 @@ class Month(enum.Enum):
december = "12"


class PlatformCategory(enum.Enum):
"""The category of the platform"""

windows = "Windows|platforms1"
mac = "Mac|platforms8"
linux = "Linux|platforms7"
vr = "VR|platforms35"
ar = "AR|platforms36"
web = "Web|platforms24"
rtx = "RTX|platforms40"
flash = "Flash|platforms23"
dos = "DOS|platforms19"
steamdeck = "SteamDeck|platforms41"
ios = "iOS|platforms20"
android = "Android|platforms22"
metro = "Metro|platforms25"
xsx = "XSX|platforms39"
xone = "XONE|platforms34"
x360 = "X360|platforms2"
xbox = "XBOX|platforms18"
ps5 = "PS5|platforms38"
ps4 = "PS4|platforms32"
ps3 = "PS3|platforms4"
ps2 = "PS2|platforms17"
ps1 = "PS1|platforms16"
vita = "VITA|platforms28"
psp = "PSP|platforms5"
switch = "Switch|platforms37"
wiiu = "WiiU|platforms31"
wii = "Wii|platforms3"
gcn = "GCN|platforms15"
n64 = "N64|platforms14"
snes = "SNES|platforms13"
nes = "NES|platforms12"
ds = "DS|platforms6"
gba = "GBA|platforms11"


# BELOW THIS LINE ENUMS ARE GENERATED AUTOMATICALLY
# PR changes to scripts/generate_enums.py if you want to
# change something
Expand Down
6 changes: 6 additions & 0 deletions moddb/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ class AuthError(ModdbException):
"""

pass


class ValidationError(ModdbException):
"""A client side validation has failed."""

pass
Loading