diff --git a/.github/workflows/daily_collection.yaml b/.github/workflows/daily_collection.yaml index 7745148..418300f 100644 --- a/.github/workflows/daily_collection.yaml +++ b/.github/workflows/daily_collection.yaml @@ -22,6 +22,7 @@ on: jobs: collect: + environment: prod runs-on: ubuntu-latest-large timeout-minutes: 25 steps: @@ -34,28 +35,27 @@ jobs: cache-dependency-glob: | **/pyproject.toml **/__main__.py - - name: Install pip and dependencies + - name: Install dependencies run: | - uv pip install -U pip uv pip install . - name: Collect PyPI Downloads run: | uv run pymetrics collect-pypi \ - --verbose \ --max-days ${{ inputs.max_days_pypi || 30 }} \ --add-metrics \ - --output-folder gdrive://10QHbqyvptmZX4yhu2Y38YJbVHqINRr0n + --output-folder ${{ secrets.PYPI_OUTPUT_FOLDER }} env: PYDRIVE_CREDENTIALS: ${{ secrets.PYDRIVE_CREDENTIALS }} BIGQUERY_CREDENTIALS: ${{ secrets.BIGQUERY_CREDENTIALS }} + PYPI_OUTPUT_FOLDER: ${{ secrets.PYPI_OUTPUT_FOLDER }} - name: Collect Anaconda Downloads run: | uv run pymetrics collect-anaconda \ - --output-folder gdrive://1UnDYovLkL4gletOF5328BG1X59mSHF-Z \ - --max-days ${{ inputs.max_days_anaconda || 90 }} \ - --verbose + --output-folder ${{ secrets.ANACONDA_OUTPUT_FOLDER }} \ + --max-days ${{ inputs.max_days_anaconda || 90 }} env: PYDRIVE_CREDENTIALS: ${{ secrets.PYDRIVE_CREDENTIALS }} + ANACONDA_OUTPUT_FOLDER: ${{ secrets.ANACONDA_OUTPUT_FOLDER }} alert: needs: [collect] runs-on: ubuntu-latest @@ -69,9 +69,12 @@ jobs: activate-environment: true - name: Install pip and dependencies run: | - uv pip install -U pip uv pip install -e .[dev] - name: Slack alert if failure - run: uv run python -m pymetrics.slack_utils -r ${{ github.run_id }} -c ${{ github.event.inputs.slack_channel || 'sdv-alerts' }} + run: | + uv run python -m pymetrics.slack_utils \ + -r ${{ github.run_id }} \ + -c ${{ github.event.inputs.slack_channel || 'sdv-alerts' }} \ + -m 'Daily Collection PyMetrics failed :fire: :dumpster-fire: :fire:' env: SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} diff --git a/.github/workflows/daily_summarize.yaml b/.github/workflows/daily_summarize.yaml index 5c997f2..9bbd938 100644 --- a/.github/workflows/daily_summarize.yaml +++ b/.github/workflows/daily_summarize.yaml @@ -12,6 +12,7 @@ on: jobs: summarize: + environment: prod runs-on: ubuntu-latest-large timeout-minutes: 10 steps: @@ -25,15 +26,14 @@ jobs: **/pyproject.toml **/__main__.py - name: Install pip and dependencies - run: | - uv pip install -U pip - uv pip install . + run: uv pip install . - name: Run Summarize run: | uv run pymetrics summarize \ - --output-folder gdrive://10QHbqyvptmZX4yhu2Y38YJbVHqINRr0n + --output-folder ${{ secrets.PYPI_OUTPUT_FOLDER }} env: PYDRIVE_CREDENTIALS: ${{ secrets.PYDRIVE_CREDENTIALS }} + PYPI_OUTPUT_FOLDER: ${{ secrets.PYPI_OUTPUT_FOLDER }} - uses: actions/checkout@v4 with: repository: sdv-dev/sdv-dev.github.io @@ -63,13 +63,12 @@ jobs: activate-environment: true - name: Install pip and dependencies run: | - uv pip install -U pip uv pip install .[dev] - name: Slack alert if failure run: | uv run python -m pymetrics.slack_utils \ -r ${{ github.run_id }} \ -c ${{ github.event.inputs.slack_channel || 'sdv-alerts' }} \ - -m 'Summarize Analytics build failed :fire: :dumpster-fire: :fire:' + -m 'Daily Summarize PyMetrics failed :fire: :dumpster-fire: :fire:' env: SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} diff --git a/.github/workflows/dryrun.yaml b/.github/workflows/dryrun.yaml index 00f664d..964520f 100644 --- a/.github/workflows/dryrun.yaml +++ b/.github/workflows/dryrun.yaml @@ -10,6 +10,7 @@ concurrency: cancel-in-progress: true jobs: dry_run: + environment: stage runs-on: ubuntu-latest-large timeout-minutes: 25 steps: @@ -24,33 +25,32 @@ jobs: **/__main__.py - name: Install pip and dependencies run: | - uv pip install -U pip uv pip install . - name: Collect PyPI Downloads - Dry Run run: | uv run pymetrics collect-pypi \ - --verbose \ --max-days 30 \ --add-metrics \ - --output-folder gdrive://10QHbqyvptmZX4yhu2Y38YJbVHqINRr0n \ + --output-folder ${{ secrets.PYPI_OUTPUT_FOLDER }} \ --dry-run env: PYDRIVE_CREDENTIALS: ${{ secrets.PYDRIVE_CREDENTIALS }} BIGQUERY_CREDENTIALS: ${{ secrets.BIGQUERY_CREDENTIALS }} + PYPI_OUTPUT_FOLDER: ${{ secrets.PYPI_OUTPUT_FOLDER }} - name: Collect Anaconda Downloads - Dry Run run: | uv run pymetrics collect-anaconda \ - --output-folder gdrive://1UnDYovLkL4gletOF5328BG1X59mSHF-Z \ --max-days 90 \ - --verbose \ + --output-folder ${{ secrets.ANACONDA_OUTPUT_FOLDER }} \ --dry-run env: PYDRIVE_CREDENTIALS: ${{ secrets.PYDRIVE_CREDENTIALS }} + ANACONDA_OUTPUT_FOLDER: ${{ secrets.ANACONDA_OUTPUT_FOLDER }} - name: Summarize - Dry Run run: | uv run pymetrics summarize \ - --verbose \ - --output-folder gdrive://10QHbqyvptmZX4yhu2Y38YJbVHqINRr0n \ + --output-folder ${{ secrets.PYPI_OUTPUT_FOLDER }} \ --dry-run env: PYDRIVE_CREDENTIALS: ${{ secrets.PYDRIVE_CREDENTIALS }} + PYPI_OUTPUT_FOLDER: ${{ secrets.PYPI_OUTPUT_FOLDER }} diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 2b45c44..88648f8 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -20,7 +20,6 @@ jobs: activate-environment: true - name: Install pip and dependencies run: | - uv pip install -U pip uv pip install .[dev] - name: Run lint checks run: uv run invoke lint diff --git a/.github/workflows/manual.yaml b/.github/workflows/manual.yaml index cf6f07c..3db53dc 100644 --- a/.github/workflows/manual.yaml +++ b/.github/workflows/manual.yaml @@ -32,7 +32,6 @@ jobs: activate-environment: true - name: Install pip and dependencies run: | - uv pip install -U pip uv pip install . - name: Collect Downloads Data run: | diff --git a/.github/workflows/unit.yaml b/.github/workflows/unit.yaml index 843a622..81fd85e 100644 --- a/.github/workflows/unit.yaml +++ b/.github/workflows/unit.yaml @@ -26,7 +26,6 @@ jobs: activate-environment: true - name: Install pip and dependencies run: | - uv pip install -U pip uv pip install -e .[test,dev] - name: Run summarize run: | diff --git a/.gitignore b/.gitignore index e6a4cb6..c444c06 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ bigquery_creds.json client_secrets.json credentials.json sdv-dev.github.io/* +uv.lock notebooks *.xlsx diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 1947c48..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,6 +0,0 @@ -include README.md - -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] - -recursive-include docs *.md *.rst conf.py Makefile make.bat *.jpg *.png *.gif diff --git a/README.md b/README.md index 18e8d74..09b8af1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ -# PyMetrics +
+
+

+ This repository is part of The Synthetic Data Vault Project, a project from DataCebo. +

+
+# PyMetrics The PyMetrics project allows you to extract download metrics for Python libraries published on [PyPI](https://pypi.org/) and [Anaconda](https://www.anaconda.com/). The DataCebo team uses these scripts to report download counts for the libraries in the [SDV ecosystem](https://sdv.dev/) and other libraries. @@ -13,8 +19,8 @@ engagement metrics. Currently, the download data is collected from the following distributions: * [PyPI](https://pypi.org/): Information about the project downloads from [PyPI](https://pypi.org/) obtained from the public BigQuery dataset, equivalent to the information shown on - [pepy.tech](https://pepy.tech) and [ClickPy](https://clickpy.clickhouse.com/) - - More information about the BigQuery dataset can be found on the [official PyPI documentation](https://packaging.python.org/en/latest/guides/analyzing-pypi-package-downloads/) + [pepy.tech](https://pepy.tech), [ClickPy](https://clickpy.clickhouse.com/) or [pypistats](https://pypistats.org/). + - More information about the BigQuery dataset can be found on the [official PyPI documentation](https://packaging.python.org/en/latest/guides/analyzing-pypi-package-downloads/). * [Anaconda](https://www.anaconda.com/): Information about conda package downloads for default and select Anaconda channels. - The conda package download data is provided by Anaconda, Inc. It includes package download counts @@ -24,7 +30,6 @@ Currently, the download data is collected from the following distributions: - Replace `{username}` with the Anaconda channel (`conda-forge`) - Replace `{package_name}` with the specific package (`sdv`) in the Anaconda channel - For each file returned by the API endpoint, the current number of downloads is saved. Over time, a historical download recording can be built. - - Both of these sources were used to track Anaconda downloads because the package data for Anaconda does not match the download count on the website. This is due to missing download data. See: https://github.com/anaconda/anaconda-package-data/issues/45 ### Future Data Sources In the future, we may expand the source distributions to include: @@ -33,7 +38,7 @@ In the future, we may expand the source distributions to include: ## Workflows ### Daily Collection -On a daily basis, this workflow collects download data from PyPI and Anaconda. The data is then published to Google Drive in CSV format (`pypi.csv`). In addition, it computes metrics for the PyPI downloads (see below). +On a daily basis, this workflow collects download data from PyPI and Anaconda. The data is then published in CSV format (`pypi.csv`). In addition, it computes metrics for the PyPI downloads (see below). #### Metrics This PyPI download metrics are computed along several dimensions: @@ -41,23 +46,20 @@ This PyPI download metrics are computed along several dimensions: - **By Month**: The number of downloads per month. - **By Version**: The number of downloads per version of the software, as determined by the software maintainers. - **By Python Version**: The number of downloads per minor Python version (eg. 3.8). -- **By Full Python Version**: The number of downloads per full Python version (eg. 3.9.1). - **And more!** ### Daily Summarize -On a daily basis, this workflow summarizes the PyPI download data from `pypi.csv` and calculates downloads for libraries. - -The summarized data is uploaded to a GitHub repo: +On a daily basis, this workflow summarizes the PyPI download data from `pypi.csv` and calculates downloads for libraries. The summarized data is published to a GitHub repo: - [Downloads_Summary.xlsx](https://github.com/sdv-dev/sdv-dev.github.io/blob/gatsby-home/assets/Downloads_Summary.xlsx) #### SDV Calculation Installing the main SDV library also installs all the other libraries as dependencies. To calculate SDV downloads, we use an exclusive download methodology: 1. Get download counts for `sdgym` and `sdv`. -2. Adjust `sdv` downloads by subtracting `sdgym` downloads (since sdgym depends on sdv). +2. Adjust `sdv` downloads by subtracting `sdgym` downloads (since `sdgym` depends on `sdv`). 3. Get download counts for direct SDV dependencies: `rdt`, `copulas`, `ctgan`, `deepecho`, `sdmetrics`. -4. Adjust downloads for each dependency by subtracting the `sdv` download count. +4. Adjust downloads for each dependency by subtracting the `sdv` download count (since `sdv` has a direct dependency). 5. Ensure no download count goes negative using `max(0, adjusted_count)` for each library. This methodology prevents double-counting downloads while providing an accurate representation of SDV usage. @@ -72,6 +74,9 @@ For more information about the configuration, workflows, and metrics, see the re | :floppy_disk: | [COLLECTED DATA](docs/COLLECTED_DATA.md) | Explanation about the data that is being collected. | +## Known Issues +1. The conda package download data for Anaconda does not match the download count shown on the website. This is due to missing download data in the conda package download data. See this: https://github.com/anaconda/anaconda-package-data/issues/45 + ---
diff --git a/config.yaml b/config.yaml index 1986c9f..0143d52 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,3 @@ -output-folder: gdrive://10QHbqyvptmZX4yhu2Y38YJbVHqINRr0n max-days: 7 projects: - sdv diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4284702..012b5ba 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -74,7 +74,7 @@ metric spreadsheets would look like this: ```bash $ pymetrics collect-pypi --verbose --projects sdv ctgan --start-date 2021-01-01 \ - --add-metrics --output-folder gdrive://10QHbqyvptmZX4yhu2Y38YJbVHqINRr0n + --add-metrics --output-folder 'gdrive://{folder_id}' ``` For more details about the data that this would collect and which files would be generated @@ -83,12 +83,12 @@ have a look at the [COLLECTED_DATA.md](COLLECTED_DATA.md) document. ## Python Interface The Python entry point that is equivalent to the CLI explained above is the function -`pymetrics.main.collect_downloads`. +`pymetrics.main.collect_pypi_downloads`. This function has the following interface: ``` -collect_downloads(projects, output_folder, start_date=None, max_days=1, credentials_file=None, +collect_pypi_downloads(projects, output_folder, start_date=None, max_days=1, credentials_file=None, dry_run=False, force=False, add_metrics=True) Pull data about the downloads of a list of projects. @@ -97,8 +97,7 @@ collect_downloads(projects, output_folder, start_date=None, max_days=1, credenti List of projects to analyze. output_folder (str): Folder in which project downloads will be stored. - It can be passed as a local folder or as a Google Drive path in the format - `gdrive://{folder_id}`. + It can be passed as a local folder or as a Google Drive path in the format `gdrive://{folder_id}`. start_date (datetime or None): Date from which to start collecting data. If `None`, start_date will be current date - `max_days`. @@ -138,7 +137,7 @@ following modules: * `bq.py`: Implements the code to run queries on Big Query. * `drive.py`: Implements the functions to upload files to and download files from Google Drive. * `__main__.py`: Implements the Command Line Interface of the project. -* `main.py`: Implements the `collect_downloads` function. +* `main.py`: Implements the `collect_pypi_downloads` function. * `metrics.py`: Implements the functions to compute the aggregation metrics and trigger the creation of the corresponding spreadsheets. * `output.py`: Implements the functions to read and write CSV files and spreadsheets, both diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index c3a1ad9..3f63979 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -16,7 +16,7 @@ The configuration about which libraries are collected is written in the [config. ```yaml # Name or Google Drive ID of the output folder -output-path: gdrive://10QHbqyvptmZX4yhu2Y38YJbVHqINRr0n +output-path: gdrive://{folder_id} # Maximum number of days to include in the query max-days: 7 diff --git a/pymetrics/__main__.py b/pymetrics/__main__.py index 68d3854..6679102 100644 --- a/pymetrics/__main__.py +++ b/pymetrics/__main__.py @@ -10,7 +10,7 @@ import yaml from pymetrics.anaconda import collect_anaconda_downloads -from pymetrics.main import collect_downloads +from pymetrics.main import collect_pypi_downloads from pymetrics.summarize import summarize_downloads LOGGER = logging.getLogger(__name__) @@ -48,10 +48,10 @@ def _load_config(config_path): def _collect_pypi(args): config = _load_config(args.config_file) projects = args.projects or config['projects'] - output_folder = args.output_folder or config.get('output-folder', '.') + output_folder = args.output_folder max_days = args.max_days or config.get('max-days') - collect_downloads( + collect_pypi_downloads( projects=projects, start_date=args.start_date, output_folder=output_folder, @@ -66,7 +66,7 @@ def _collect_pypi(args): def _collect_anaconda(args): config = _load_config(args.config_file) projects = config['projects'] - output_folder = args.output_folder or config.get('output-folder', '.') + output_folder = args.output_folder collect_anaconda_downloads( projects=projects, output_folder=output_folder, @@ -80,12 +80,11 @@ def _summarize(args): config = _load_config(args.config_file) projects = config['projects'] vendors = config['vendors'] - output_folder = args.output_folder or config.get('output-folder', '.') + output_folder = args.output_folder summarize_downloads( projects=projects, vendors=vendors, - input_file=args.input_file, output_folder=output_folder, dry_run=args.dry_run, verbose=args.verbose, @@ -127,7 +126,7 @@ def _get_parser(): action = parser.add_subparsers(title='action') action.required = True - # collect + # collect PyPI collect_pypi = action.add_parser( 'collect-pypi', help='Collect download data from PyPi.', parents=[logging_args] ) @@ -137,7 +136,7 @@ def _get_parser(): '-o', '--output-folder', type=str, - required=False, + required=True, help=( 'Path to the folder where data will be stored. It can be a local path or a' ' Google Drive folder path in the format gdrive://' @@ -191,7 +190,7 @@ def _get_parser(): help='Compute the aggregation metrics and create the corresponding spreadsheets.', ) - # collect + # summarize summarize = action.add_parser( 'summarize', help='Summarize the downloads data.', parents=[logging_args] ) @@ -203,25 +202,18 @@ def _get_parser(): default='summarize_config.yaml', help='Path to the configuration file.', ) - summarize.add_argument( - '-i', - '--input-file', - type=str, - default=None, - help='Path to the pypi.csv. Default None, which means to use output-folder for pypi.csv', - ) summarize.add_argument( '-o', '--output-folder', type=str, - required=False, + required=True, help=( - 'Path to the folder where data will be outputted. It can be a local path or a' + 'Path to the folder where data will be pypi.csv exists. It can be a local path or a' ' Google Drive folder path in the format gdrive://' ), ) - # collect + # collect Anaconda collect_anaconda = action.add_parser( 'collect-anaconda', help='Collect download data from Anaconda.', parents=[logging_args] ) @@ -237,7 +229,7 @@ def _get_parser(): '-o', '--output-folder', type=str, - required=False, + required=True, help=( 'Path to the folder where data will be outputted. It can be a local path or a' ' Google Drive folder path in the format gdrive://' diff --git a/pymetrics/main.py b/pymetrics/main.py index 6123a93..65b6691 100644 --- a/pymetrics/main.py +++ b/pymetrics/main.py @@ -10,7 +10,7 @@ LOGGER = logging.getLogger(__name__) -def collect_downloads( +def collect_pypi_downloads( projects, output_folder, start_date=None, @@ -51,7 +51,7 @@ def collect_downloads( LOGGER.info(f'Collecting new downloads for projects={projects}') csv_path = get_path(output_folder, 'pypi.csv') - previous = get_previous_pypi_downloads(input_file=None, output_folder=output_folder) + previous = get_previous_pypi_downloads(output_folder=output_folder, dry_run=dry_run) pypi_downloads = get_pypi_downloads( projects=projects, @@ -63,7 +63,9 @@ def collect_downloads( force=force, ) - if pypi_downloads.empty: + if dry_run and pypi_downloads.empty: + LOGGER.info(f'dry_run={dry_run} thus no downloads were returned from BigQuery %s', csv_path) + elif pypi_downloads.empty: LOGGER.info('Not creating empty CSV file %s', csv_path) elif pypi_downloads.equals(previous): msg = f'Skipping update of unmodified CSV file {csv_path}' diff --git a/pymetrics/pypi.py b/pymetrics/pypi.py index ab92833..fbe9439 100644 --- a/pymetrics/pypi.py +++ b/pymetrics/pypi.py @@ -129,7 +129,6 @@ def get_pypi_downloads( if previous is not None: if isinstance(projects, str): projects = (projects,) - previous_projects = previous[previous.project.isin(projects)] min_date = previous_projects.timestamp.min().date() max_date = previous_projects.timestamp.max().date() diff --git a/pymetrics/summarize.py b/pymetrics/summarize.py index 1cc7d2b..477ef07 100644 --- a/pymetrics/summarize.py +++ b/pymetrics/summarize.py @@ -110,17 +110,21 @@ def _sum_counts(base_count, dep_to_count, parent_to_count): return base_count + sum(parent_to_count.values()) + sum(dep_to_count.values()) -def get_previous_pypi_downloads(input_file, output_folder): +def get_previous_pypi_downloads(output_folder, dry_run=False): """Read pypi.csv and return a DataFrame of the downloads. Args: - input_file (str): Location of the pypi.csv to use as the previous downloads. - output_folder (str): If input_file is None, this directory location must contain pypi.csv file to use. + dry_run (bool): If True, will reduce the number of rows read. Defaults to False, + which will read all rows. + + Returns: + pd.DataFrame: The DataFrame containing the PyPI download data. + """ - csv_path = input_file or get_path(output_folder, 'pypi.csv') + csv_path = get_path(output_folder, 'pypi.csv') read_csv_kwargs = { 'parse_dates': ['timestamp'], 'dtype': { @@ -138,9 +142,12 @@ def get_previous_pypi_downloads(input_file, output_folder): 'cpu': pd.CategoricalDtype(), }, } + if dry_run: + read_csv_kwargs['nrows'] = 10_000 data = load_csv(csv_path, read_csv_kwargs=read_csv_kwargs) LOGGER.info('Parsing version column to Version class objects') - data['version'] = data['version'].apply(parse) + if 'version' in data.columns: + data['version'] = data['version'].apply(parse) return data @@ -222,7 +229,6 @@ def summarize_downloads( projects, vendors, output_folder, - input_file=None, dry_run=False, verbose=False, ): @@ -257,7 +263,7 @@ def summarize_downloads( `gdrive://{folder_id}`. """ - downloads = get_previous_pypi_downloads(input_file, output_folder) + downloads = get_previous_pypi_downloads(output_folder=output_folder) vendor_df = pd.DataFrame.from_records(vendors) all_df = _create_all_df() diff --git a/pyproject.toml b/pyproject.toml index 73105df..97818c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "pymetrics" -version = "0.0.1.dev0" -description = "Scripts to extract metrics about OSS project downloads." +version = "0.1.0" +description = "Scripts to extract metrics about python project downloads." readme = "README.md" authors = [ { name = "DataCebo", email = "info@datacebo.com" } @@ -66,10 +66,29 @@ exclude = [ [tool.ruff.lint] select = [ - "F", "E", "W", "D", "I001", "T201", "PD", "NPY201" + # Pyflakes + "F", + # Pycodestyle + "E", + "W", + # pydocstyle + "D", + # isort + "I001", + # print statements + "T201", + # pandas-vet + "PD", + # numpy 2.0 + "NPY201" + ] ignore = [ - "D107", "D417", "PD901", "PD101" + # pydocstyle + "D107", # Missing docstring in __init__ + "D417", # Missing argument descriptions in the docstring, this is a bug from pydocstyle: https://github.com/PyCQA/pydocstyle/issues/449 + "PD901", + "PD101", ] [tool.ruff.format] diff --git a/summarize_config.yaml b/summarize_config.yaml index e3c74ca..9f1d278 100644 --- a/summarize_config.yaml +++ b/summarize_config.yaml @@ -1,4 +1,3 @@ -output-folder: gdrive://10QHbqyvptmZX4yhu2Y38YJbVHqINRr0n projects: - ecosystem: "sdv" base_project: "sdv"